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

19 KiB
Raw Blame History

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

# 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+ 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}.urlfixed_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).