Files
ebbes-meme-clipboard/.claude/PROJECT-CONTEXT.md
T

368 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
# 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)
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 02 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).