Files
2026-08-19 10:30:41 +02:00

21 KiB
Raw Permalink 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

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