399 lines
21 KiB
Markdown
399 lines
21 KiB
Markdown
# Ebbe's Meme Clipboard — Project Context & Handoff
|
||
|
||
Context document for continuing this project in a new Claude Code session.
|
||
Last updated: 2026-08-13.
|
||
|
||
---
|
||
|
||
## What this is
|
||
|
||
A Windows meme/GIF picker inspired by the Windows Emoji Picker (Win+.). Press a global
|
||
hotkey anywhere, a popup grid appears, search/browse your own memes, click one, and it's
|
||
copied — and optionally auto-pasted (and optionally auto-sent) into whatever app you were
|
||
just using. Tray icon for access and settings.
|
||
|
||
**Repo**: `c:\Users\e.bass\Coding\ebbes-meme-clipboard`
|
||
**Stack**: .NET 10 (current LTS) + WPF + C#, single project.
|
||
|
||
---
|
||
|
||
## Current state
|
||
|
||
Everything below is **implemented and verified working by actually running the app**
|
||
(not just compiling):
|
||
|
||
- Global hotkey (default **Ctrl+Alt+M**), user-remappable, persisted
|
||
- Tray icon with menu: **Open / Settings / Exit**
|
||
- Popup picker: search box, thumbnail grid, drag-to-move via top handle strip,
|
||
Esc / click-away to dismiss, positions near cursor clamped to the current monitor
|
||
- Add memes: `+` button (file dialog), drag-and-drop onto picker, **Ctrl+V paste**, jpg/png/gif
|
||
- Search filters by filename (case-insensitive substring)
|
||
- Right-click a tile → **Remove** (deletes to Recycle Bin, recoverable — verified)
|
||
- Multi-format clipboard write (see "Clipboard formats" below)
|
||
- Three insert modes, chosen in Settings: **Copy only / Paste into active window /
|
||
Paste and send instantly**
|
||
- Settings window: hotkey capture (incl. Windows key), insert mode radio buttons,
|
||
**autostart toggle**, **Giphy API key field**
|
||
- **Source tabs in the picker: Local / Giphy**, with debounced Giphy search, remote
|
||
thumbnails, download-on-select caching, and the required "Powered by GIPHY" badge
|
||
- **Windows autostart** via the HKCU Run key, toggled in Settings (verified: checkbox and
|
||
registry track each other in both directions)
|
||
- **Per-source favourites**: right-click any tile → "Add to favourites", shown in a
|
||
★ Favourites section at the top of that tab, with a star overlay on favourited tiles.
|
||
Scoped per source (Local favourites don't appear on the Giphy tab) and persisted.
|
||
- App title shown top-left of the picker; the title row doubles as the drag surface
|
||
- Consistent dark styling across all menus (tray + tile right-click) **and scrollbars**
|
||
- Persistence: memes, favourites and settings survive restarts
|
||
|
||
**Build is clean** (0 warnings, 0 errors). Working tree was clean at last check;
|
||
`publish/` is gitignored.
|
||
|
||
### Where user data lives
|
||
|
||
```
|
||
%AppData%\EbbesMemeClipboard\
|
||
settings.json InsertMode, HotkeyModifiers, HotkeyKey, GiphyApiKey
|
||
favorites.json list of FavoriteRecord (Source, Key, Title, Preview/FullUrl, DateAdded)
|
||
Library\
|
||
index.json list of LocalMemeRecord (Id, FileName, OriginalFileName, DateAdded)
|
||
<guid>.png/.jpg/.gif imported files, stored under a GUID name
|
||
|
||
%LocalAppData%\EbbesMemeClipboard\
|
||
GifCache\ downloaded Giphy GIFs (re-downloadable cache, so Local not Roaming)
|
||
|
||
HKCU\Software\Microsoft\Windows\CurrentVersion\Run\EbbesMemeClipboard
|
||
autostart entry; the registry is the single source of truth for
|
||
this (deliberately NOT mirrored into settings.json, to avoid drift)
|
||
```
|
||
|
||
---
|
||
|
||
## Build / run / publish
|
||
|
||
```bash
|
||
# run from source
|
||
dotnet run --project src/EbbesMemeClipboard
|
||
|
||
# build
|
||
dotnet build
|
||
|
||
# portable single exe -> publish/EbbesMemeClipboard.exe (~75 MB, compressed)
|
||
dotnet publish src/EbbesMemeClipboard/EbbesMemeClipboard.csproj -c Release -r win-x64 \
|
||
--self-contained true -p:PublishSingleFile=true \
|
||
-p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true \
|
||
-o publish
|
||
|
||
# installer build: NOTE compression deliberately OFF (see below) -> publish-installer/ (~172 MB)
|
||
dotnet publish src/EbbesMemeClipboard/EbbesMemeClipboard.csproj -c Release -r win-x64 \
|
||
--self-contained true -p:PublishSingleFile=true \
|
||
-p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=false \
|
||
-o publish-installer
|
||
|
||
# then compile the wizard -> installer/output/EbbesMemeClipboard-Setup-<version>.exe (~51 MB)
|
||
"$LOCALAPPDATA/Programs/Inno Setup 6/ISCC.exe" installer/EbbesMemeClipboard.iss
|
||
```
|
||
|
||
### Installer (Inno Setup)
|
||
|
||
Script: `installer/EbbesMemeClipboard.iss` (tracked; `installer/output/` is gitignored).
|
||
Inno Setup 6 installs **per-user** to `%LocalAppData%\Programs\Inno Setup 6\`, not Program
|
||
Files — that tripped up the first attempt to locate `ISCC.exe`.
|
||
|
||
- Per-user install by default (no UAC), with a wizard page letting the user pick
|
||
"just me" vs "all users" (`PrivilegesRequired=lowest` +
|
||
`PrivilegesRequiredOverridesAllowed=dialog`).
|
||
- Optional unchecked tasks: desktop icon, start-with-Windows.
|
||
- **Don't double-compress.** Publishing with `EnableCompressionInSingleFile=false` and
|
||
letting Inno's LZMA2 do the work gives a ~51 MB setup vs ~70 MB, and the installed app
|
||
starts faster because there's no decompression at launch.
|
||
- **Uninstall only removes the autostart entry if it points at the installed copy.** The
|
||
app's own Settings toggle writes the same `Run` value name, so an unconditional delete
|
||
would wipe out a portable build's autostart. This was a real bug caught in testing.
|
||
- User data under `%AppData%\EbbesMemeClipboard` is deliberately preserved on uninstall;
|
||
only the re-downloadable `GifCache` is removed.
|
||
- Verified end-to-end: silent install → files/shortcut/uninstall-entry present → app runs →
|
||
silent uninstall → everything removed, user data and unrelated autostart intact.
|
||
- An installer does **not** fix SmartScreen warnings; only code signing does.
|
||
|
||
Note: the published single-file exe takes **~10 seconds on first launch** before the tray
|
||
icon appears (native library self-extraction to TEMP). Not a bug. Subsequent launches are
|
||
faster.
|
||
|
||
---
|
||
|
||
## File map
|
||
|
||
```
|
||
src/EbbesMemeClipboard/
|
||
App.xaml / App.xaml.cs composition root: DI container, tray icon, hotkey registration
|
||
Styles/DarkMenuStyles.xaml implicit ContextMenu/MenuItem/Separator styles (app-wide)
|
||
Styles/DarkScrollBarStyles.xaml implicit slim dark ScrollBar style (app-wide)
|
||
Models/
|
||
LocalMemeRecord.cs Id, FileName, OriginalFileName, DateAdded
|
||
AppSettings.cs InsertMode, HotkeyModifiers, HotkeyKey, GiphyApiKey
|
||
InsertMode.cs CopyOnly | PasteIntoActiveWindow | PasteAndSend
|
||
MemeSource.cs Local | Giphy
|
||
GifSearchResult.cs provider-agnostic remote hit
|
||
FavoriteRecord.cs per-source favourite (remote ones carry their own URLs)
|
||
Services/
|
||
ILocalMemeLibraryService / LocalMemeLibraryService import/search/remove, index.json
|
||
IClipboardService / ClipboardService multi-format clipboard write
|
||
IGlobalHotkeyService / GlobalHotkeyService RegisterHotKey + message-only HwndSource
|
||
ISettingsService / SettingsService System.Text.Json persistence
|
||
IAutoPasteService / AutoPasteService foreground restore + SendInput paste
|
||
IAutostartService / AutostartService HKCU Run key
|
||
IGifProvider / GiphyGifProvider remote search behind a provider interface
|
||
IGifCacheService / GifCacheService download + cache remote GIFs
|
||
IFavoritesService / FavoritesService per-source favourites, favorites.json
|
||
ImageDecoding.cs first-frame decode helper (file + bytes)
|
||
HotkeyFormatter.cs "Ctrl + Alt + M" display strings
|
||
Native/
|
||
NativeMethods.cs P/Invoke declarations
|
||
InputSimulation.cs INPUT/KEYBDINPUT structs + key send helpers
|
||
MonitorInterop.cs per-monitor DPI resolution
|
||
HotkeyInterop.cs MOD_*/WM_HOTKEY constants
|
||
ViewModels/
|
||
PickerViewModel.cs search, items, select/remove/import commands
|
||
MemeTileViewModel.cs one tile: record + lazily-loaded thumbnail
|
||
SettingsViewModel.cs insert mode + hotkey rebinding
|
||
Views/
|
||
PickerWindow.xaml(.cs) the popup
|
||
SettingsWindow.xaml(.cs) settings window
|
||
```
|
||
|
||
---
|
||
|
||
## Hard-won technical knowledge
|
||
|
||
**These were all discovered the slow way. Don't re-derive them.**
|
||
|
||
### Tray icon requires two non-obvious steps
|
||
`TaskbarIcon` is declared in `Application.Resources` (so it lives for the whole app with no
|
||
host window). Two consequences:
|
||
1. It never receives a `Loaded` event (not in any visual tree), so its icon is never
|
||
created → must call `_trayIcon.ForceCreate(enablesEfficiencyMode: false)` explicitly.
|
||
2. `Shell_NotifyIcon` **silently no-ops** if no window in the process has a real Win32
|
||
handle yet → must call `new WindowInteropHelper(_pickerWindow).EnsureHandle()` first.
|
||
|
||
Neither throws an exception when missing; the icon just never appears. Verified by
|
||
enumerating the tray via UI Automation with and without these lines.
|
||
|
||
Also: `x:Name` on elements nested in `Application.Resources` does **not** reliably generate
|
||
code-behind fields. Look elements up by `Tag`/traversal instead.
|
||
|
||
### Clipboard formats — four, and each exists for a reason
|
||
`ClipboardService.CopyLocalFileAsync` writes all of these into one `DataObject`:
|
||
|
||
| Format | Why |
|
||
|---|---|
|
||
| `CF_HDROP` (file drop list) | Discord/Slack/Teams/Explorer paste the real file — preserves GIF animation |
|
||
| `CF_DIB` (`SetImage`) | Bitmap-only apps like Paint |
|
||
| `"PNG"` (registered format) | **Chromium-based apps (Teams, Slack, Discord, browsers) prefer this over CF_DIB** and may paste nothing at all without it. **Deliberately omitted for GIFs** — see below |
|
||
| `CF_HTML` | The other route web-based compose boxes check; carries a base64 `data:` URI |
|
||
|
||
**Reading the clipboard (Ctrl+V to import) uses the same knowledge in reverse.**
|
||
`ClipboardService.TryReadImage()` checks formats in descending fidelity order: FileDrop →
|
||
CF_HTML `data:` URI → `"PNG"` → CF_DIB bitmap. The order is not cosmetic: a GIF is only
|
||
still animated in the first two, so a naive implementation that reached for the bitmap
|
||
first would silently flatten every pasted GIF. Verified byte-identical (SHA-256) import for
|
||
a pasted GIF file. Ctrl+V is handled in `PickerWindow.OnPreviewKeyDown` and only swallows
|
||
the keystroke when an image is actually present — plain text still pastes into the search
|
||
box as normal.
|
||
|
||
**The `"PNG"` format is skipped for GIFs on purpose.** A PNG can only hold one static frame,
|
||
and since Chromium *prefers* that format over all others, offering it for a GIF is exactly
|
||
what made animated GIFs paste as a still first frame. Omitting it lets those targets fall
|
||
through to `CF_HTML`, which carries the full animated data URI. Verified: a GIF's clipboard
|
||
formats are FileDrop/Bitmap/HTML with no PNG, while a static image still gets PNG.
|
||
|
||
Two traps here:
|
||
- WPF does **NOT** auto-wrap `DataFormats.Html`. The `Version/StartHTML/EndHTML/
|
||
StartFragment/EndFragment` header with exact **byte** offsets must be built by hand
|
||
(`BuildCfHtml`). Receiving apps are unforgiving about this being wrong.
|
||
- `Clipboard.SetDataObject` can throw `COMException` if another process transiently holds
|
||
the clipboard → wrapped in a retry loop.
|
||
|
||
### Auto-paste: do NOT send a synthetic Alt tap
|
||
A widely-recommended trick for beating focus-stealing prevention is to `SendInput` a lone
|
||
Alt keypress before `SetForegroundWindow`. **This broke pasting into Teams**, and was the
|
||
actual root cause of a long debugging session: Electron/Chromium apps treat a lone Alt as
|
||
"toggle menu bar focus", which moved Teams' internal focus off the compose box. The window
|
||
correctly returned to the foreground (so the follow-up Enter registered and sent an *empty*
|
||
message), but Ctrl+V had nothing focused to land in.
|
||
|
||
Removing the Alt tap fixed it — and it was unnecessary anyway, since our picker window is
|
||
already the foreground process at that point, which Windows exempts from the restriction.
|
||
`AttachThreadInput` + `SetForegroundWindow` alone is sufficient.
|
||
|
||
### Deactivated handler must be suppressed in two cases
|
||
`PickerWindow` hides itself on `Deactivated` (click-away to dismiss). Two things falsely
|
||
trigger it, and both were real bugs:
|
||
- Opening the "add memes" file dialog → guarded by `PickerViewModel.IsDialogOpen`
|
||
- Opening a tile's right-click context menu → guarded by `_isContextMenuOpen`
|
||
(`ContextMenuOpening`/`ContextMenuClosing`)
|
||
|
||
### Windows key is not in `Keyboard.Modifiers`
|
||
WPF only tracks Ctrl/Alt/Shift there. For Win+<key> hotkeys, check
|
||
`Keyboard.IsKeyDown(Key.LWin)` / `Key.RWin` separately and OR in `ModifierKeys.Windows`.
|
||
|
||
### csproj quirks
|
||
- `UseWPF` + `UseWindowsForms` both add implicit global usings that **collide** on
|
||
`Application`, `DataObject`, `KeyEventArgs`, `DragEventArgs`. Resolved with
|
||
`<Using Remove="System.Windows.Forms" />`; the two WinForms helpers actually used
|
||
(`Cursor`, `Screen`) are fully qualified.
|
||
- `NoWarn: WFO0003` — WinForms analyzer wrongly suggests its own DPI API; DPI is correctly
|
||
declared in `app.manifest` (PerMonitorV2), which is right for a WPF app.
|
||
- `[LibraryImport]` requires `AllowUnsafeBlocks`. Not worth it for two P/Invokes — plain
|
||
`[DllImport]` is used instead.
|
||
|
||
### No single-instance protection (known gap)
|
||
Two running instances fight over the same global hotkey; whichever registered first wins and
|
||
the other silently fails. This caused a very confusing phantom "regression" during
|
||
development (a `dotnet run` instance from VS Code was swallowing hotkeys intended for a
|
||
freshly-built one). **Worth adding** (named Mutex + named pipe) — see original plan.
|
||
|
||
---
|
||
|
||
## Testing methodology notes
|
||
|
||
Automated UI testing of this app is unusually fiddly. What works:
|
||
|
||
- **Do show + act in ONE script.** Launching any new process steals focus, which triggers
|
||
the picker's auto-hide. Splitting "show the picker" and "click a tile" across two tool
|
||
calls will always fail with the window already hidden.
|
||
- **The `INPUT` struct must be 40 bytes on x64.** The union has to be padded to 32 bytes
|
||
(`[StructLayout(LayoutKind.Explicit, Size = 32)]`) even if only the keyboard member is
|
||
used. If it's wrong, **every `SendInput` call silently fails** returning 0 with
|
||
`ERROR_INVALID_PARAMETER` (87). This wasted significant time — an early test script had
|
||
this wrong and made a perfectly working app look broken.
|
||
- **`SendKeys` does not trigger `RegisterHotKey` hotkeys.** It posts to the focused window's
|
||
queue rather than injecting real input. Use `SendInput`.
|
||
- **PowerShell 5.1's `Add-Type` uses an old C# compiler** — no expression-bodied members
|
||
(`=>`), no ternary in some positions. Use classic syntax in test scripts.
|
||
- **UI Automation beats pixel coordinates** for driving menus (`ExpandCollapsePattern`,
|
||
`InvokePattern`). But note meme tiles have **no accessible name** (content is just an
|
||
`Image`), so name-based queries will match the `+` button instead.
|
||
- Verifying the tray icon: enumerate `Shell_TrayWnd` descendants via UI Automation and look
|
||
for a name containing "Meme Clipboard". This finds it even when tucked in the overflow
|
||
area, unlike screenshots.
|
||
|
||
Clean up after testing: kill `EbbesMemeClipboard` processes, remove
|
||
`%AppData%\EbbesMemeClipboard` and `%LocalAppData%\EbbesMemeClipboard`, and **check the
|
||
autostart Run key isn't left enabled** if autostart was exercised — that one writes to the
|
||
user's real machine state, not just app-local files.
|
||
|
||
---
|
||
|
||
## Giphy integration notes
|
||
|
||
**Tenor is dead.** The user originally asked for Tenor; research confirmed Google **shut
|
||
down the public Tenor API on 2026-06-30** (corroborated by 9to5Google, Shacknews,
|
||
Slashdot). Not buildable. **Giphy** was chosen instead and is what's implemented.
|
||
|
||
- `GET https://api.giphy.com/v1/gifs/search?api_key={key}&q={q}&limit={n}&rating=pg-13`
|
||
- Empty query falls back to `/trending` with the same parameters.
|
||
- Response: `data[].images.{fixed_width,original}.url` — `fixed_width` for grid
|
||
thumbnails, `original` for the full-quality animated file that gets pasted.
|
||
- **The user supplies their own key** in Settings. Free tier is 100 req/hr; the ~$9k/yr
|
||
production tier is irrelevant for personal use. Without a key the tab shows guidance
|
||
instead of failing at request time (`IGifProvider.IsConfigured`).
|
||
- Searches are **debounced 350ms** and cancel the previous in-flight request — without
|
||
that, every keystroke burns a request against the free rate limit.
|
||
- Giphy's ToS **requires the visible "Powered by GIPHY" badge**, rendered in the picker
|
||
footer whenever the Giphy tab is active. Don't remove it.
|
||
- Klipy (`api.klipy.com`) is a viable alternative if Giphy becomes a problem — founded by
|
||
ex-Tenor people, deliberately mirrors Tenor's API shape, free. Add it as a second
|
||
`IGifProvider`; the tab strip and view models are already provider-agnostic.
|
||
|
||
**Untested by the assistant**: actual Giphy search results, remote thumbnail loading, and
|
||
download-on-select were never exercised against the live API, because no API key was
|
||
available. The code paths are written but unverified end-to-end — if something misbehaves
|
||
once a key is entered, start with `GiphyGifProvider.SearchAsync` (JSON field names) and
|
||
`GifCacheService.GetOrDownloadAsync`.
|
||
|
||
---
|
||
|
||
## Pending requests
|
||
|
||
### ⚠️ 1. "Central store specific to the application for all local memes" — STILL AMBIGUOUS
|
||
**Ask the user before building.** The app *already* stores memes centrally in
|
||
`%AppData%\EbbesMemeClipboard\Library`. When asked to clarify, the user chose "Something
|
||
else" but the conversation moved on before they explained. Options offered *and rejected*
|
||
were: (a) save Giphy results into the local library, (b) configurable library folder
|
||
location. So it's something other than those two — get a concrete description first.
|
||
|
||
### 2. Paste-send reliability ("Teams is sometimes too slow")
|
||
Not yet addressed. Current delays in `AutoPasteService`: `FocusSettleDelay` 200ms (before
|
||
Ctrl+V), `PasteSettleDelay` 400ms (before Enter). Both are fixed sleeps, so a slow app will
|
||
still occasionally miss.
|
||
|
||
Bumping the constants is the cheap fix but slows every paste for everyone. Better: **poll
|
||
for readiness instead of guessing** — verify the target window is genuinely foreground
|
||
before sending Ctrl+V, and confirm rather than firing Enter blind. Note the failure mode
|
||
already observed: if Enter fires when the paste didn't land, **Teams sends an empty
|
||
message**, so the Enter step should be conservative — a wrong guess posts to a real chat.
|
||
|
||
### 3. Possible follow-ups (not requested, just noted)
|
||
- **Single-instance protection** (named Mutex + named pipe). Currently two instances fight
|
||
over the global hotkey; this caused a confusing phantom "regression" during development.
|
||
- **`WPF-UI` is referenced but unused** — intended for Mica/Fluent polish. Use it or drop it.
|
||
- **Animated GIF previews on hover** (currently static first frame only) — `GifFramePlayer`
|
||
control was designed for this in the original plan but never built.
|
||
|
||
---
|
||
|
||
## Decisions already made (don't re-litigate)
|
||
|
||
- **WPF over WinUI 3** — simpler unpackaged single-exe deployment, more mature tray support.
|
||
- **WPF over Electron/Tauri** — user chose native .NET.
|
||
- **Giphy over Tenor** — Tenor's API no longer exists (see above).
|
||
- **Clipboard keeps the meme after auto-paste** — user explicitly chose this (matches
|
||
Windows' own clipboard-history behavior). Do not add clipboard-clearing or
|
||
restore-previous-contents logic.
|
||
- **"Paste and send" is opt-in, never the default** — it presses Enter, which submits in
|
||
chat apps. Default insert mode is `PasteIntoActiveWindow`.
|
||
- No Settings-window dark title bar (content is dark, title bar is standard OS chrome).
|
||
User was offered a fix and it wasn't prioritized.
|
||
- **Autostart state lives only in the registry**, not in `settings.json`. Mirroring it would
|
||
let the two drift apart if the entry is removed via Task Manager or another tool; the
|
||
Settings checkbox reads live registry state each time the window opens.
|
||
|
||
## Known limitations (inherent, not bugs)
|
||
|
||
- **No caret-following.** The real Emoji Panel is a privileged shell component with text-
|
||
services integration; a third-party app can't replicate it. Popup appears at the cursor.
|
||
- **Can't auto-paste into elevated (admin) windows** — UIPI security boundary. A non-
|
||
elevated process cannot `SendInput` into a higher-integrity window, full stop.
|
||
- **Unsigned exe** will likely trigger a SmartScreen prompt on first run elsewhere.
|
||
|
||
## Environment setup notes
|
||
|
||
- .NET 10 SDK (10.0.400) was installed via `winget install Microsoft.DotNet.SDK.10`.
|
||
- The machine's global NuGet config (`%APPDATA%\NuGet\NuGet.Config`) had an **empty
|
||
`<packageSources>`**, which broke all restores. Fixed by
|
||
`dotnet nuget add source https://api.nuget.org/v3/index.json --name nuget.org`.
|
||
If restore mysteriously fails on another machine, check this first.
|
||
|
||
## Packages
|
||
|
||
| Package | Version |
|
||
|---|---|
|
||
| H.NotifyIcon.Wpf | 2.4.1 |
|
||
| WPF-UI | 4.3.0 |
|
||
| CommunityToolkit.Mvvm | 8.4.2 |
|
||
| Microsoft.Extensions.DependencyInjection | 10.0.0 |
|
||
|
||
`WPF-UI` is referenced but **not actually used yet** — it was intended for Mica/Fluent
|
||
visual polish (a later phase in the original plan). Current styling is hand-rolled dark
|
||
theme. Either use it or drop the reference.
|
||
|
||
---
|
||
|
||
## Original phased plan
|
||
|
||
A fuller plan document from the start of the project lives outside this repo at
|
||
`C:\Users\e.bass\.claude\plans\nested-finding-tower.md`. Phases 0–2 are essentially done
|
||
(plus auto-paste, which was Phase 4). Remaining from that plan: Giphy integration (Phase 3),
|
||
Mica/Fluent visual polish + animated GIF hover previews (Phase 5), and packaging polish
|
||
(Phase 6 — single-file publish already works).
|