diff --git a/.claude/PROJECT-CONTEXT.md b/.claude/PROJECT-CONTEXT.md new file mode 100644 index 0000000..03bdc5a --- /dev/null +++ b/.claude/PROJECT-CONTEXT.md @@ -0,0 +1,335 @@ +# 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, 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 +- Consistent dark styling across all menus (tray + tile right-click) +- Persistence: memes 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 + Library\ + index.json list of LocalMemeRecord (Id, FileName, OriginalFileName, DateAdded) + .png/.jpg/.gif imported files, stored under a GUID name +``` + +--- + +## Build / run / publish + +```bash +# run from source +dotnet run --project src/EbbesMemeClipboard + +# build +dotnet build + +# publish self-contained single-file binary -> publish/EbbesMemeClipboard.exe (~78 MB) +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 +``` + +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) + Models/ + LocalMemeRecord.cs Id, FileName, OriginalFileName, DateAdded + AppSettings.cs InsertMode, HotkeyModifiers, HotkeyKey + InsertMode.cs CopyOnly | PasteIntoActiveWindow | PasteAndSend + 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 + ImageDecoding.cs first-frame decode helper + 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 | +| `CF_HTML` | The other route web-based compose boxes check; carries a base64 `data:` URI | + +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+ 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 + ``; 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 and remove +`%AppData%\EbbesMemeClipboard` so seeded test data doesn't leak into real use. + +--- + +## Pending requests (not yet implemented) + +From the user's most recent batch. **None of these are started.** + +### 1. Windows auto-start, with a toggle in Settings +Straightforward. Write `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`. +**Important**: use `Environment.ProcessPath`, **not** `Assembly.Location` — the latter +returns an empty string in single-file published mode. Add an `IAutostartService` + +a checkbox in `SettingsWindow`. + +### 2. Tab/dropdown for meme sources (Local, Giphy) +UI shell for multiple sources, plus the Giphy integration behind it. + +**Critical context — Tenor is dead.** The user originally asked for Tenor; research during +planning confirmed Google **shut down the public Tenor API on 2026-06-30** (corroborated by +9to5Google, Shacknews, Slashdot). It is not buildable. The user chose **Giphy** as the +replacement. Notes: +- `GET https://api.giphy.com/v1/gifs/search?api_key={key}&q={query}&limit={n}&rating=pg-13` +- Response: `data[].images.{fixed_width,downsized,original}.url` +- Free "beta" key is 100 req/hr — plenty for personal use. Production tier is ~$9k/yr and + irrelevant here. **The user needs to supply their own key** (add a field in Settings). +- Giphy's ToS **requires a visible "Powered by GIPHY" badge** wherever results appear. + This is a real UI requirement, not optional attribution. +- Build behind an `IGifProvider` interface so another provider can be swapped in later. +- Remote GIFs must be **downloaded to a local cache file before** they can go on the + clipboard as CF_HDROP. +- 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. + +### 3. "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 selected "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.** + +### 4. Fix GIFs — "clicking a GIF doesn't copy/paste it correctly" +User confirmed this specific symptom (not thumbnails, not crashes, not lag). + +**Strong root-cause hypothesis (not yet verified — verify before/while fixing):** +`ClipboardService` sets the `"PNG"` clipboard format from `firstFrame` — i.e. **frame 0 of +the GIF re-encoded as a static PNG**. Chromium-based apps *prefer* the `"PNG"` format over +everything else (that preference is exactly why it was added, to fix static-image pasting +into Teams). So for a GIF, Teams/Discord grab the static first frame instead of the animated +GIF → the GIF pastes as a still image. + +**Suggested fix**: skip the `"PNG"` format when the file is a GIF, so Chromium falls back to +`CF_HTML` (which already carries the full animated `data:image/gif;base64,...` URI) or +`CF_HDROP`. `ImageDecoding.IsGif()` already exists for the check. Keep `CF_DIB` so Paint +still gets something. Test in both Teams and Paint, since these two want opposite things. + +**Also worth fixing while in there** (separate, minor, pre-existing): +`ImageDecoding.DecodeFirstFrame` **ignores `decodePixelWidth` for GIFs** — it returns the +`GifBitmapDecoder` frame before reaching the `DecodePixelWidth` logic. So GIF thumbnails +decode at full resolution while static images are capped at 150px. A library with many +large GIFs will use more memory than it should. + +### 5. Paste-send reliability ("Teams is sometimes too slow") +Current delays in `AutoPasteService`: `FocusSettleDelay` 200ms (before Ctrl+V), +`PasteSettleDelay` 400ms (before Enter). Both are fixed sleeps — a slow app will still miss. + +Bumping the constants is the cheap fix, but it makes every paste slower for everyone. A +better approach is to **poll for readiness instead of guessing**: e.g. verify the target +window is actually foreground before sending Ctrl+V, and for the send step consider +retrying/confirming rather than firing Enter blind. Note the failure mode already seen: +if Enter fires when the paste didn't land, **Teams sends an empty message** — so the Enter +step should be conservative, since a wrong guess posts to a real chat. + +--- + +## 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. + +## 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 + ``**, 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).