17 KiB
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)
<guid>.png/.jpg/.gif imported files, stored under a GUID name
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)
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:
- It never receives a
Loadedevent (not in any visual tree), so its icon is never created → must call_trayIcon.ForceCreate(enablesEfficiencyMode: false)explicitly. Shell_NotifyIconsilently no-ops if no window in the process has a real Win32 handle yet → must callnew 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. TheVersion/StartHTML/EndHTML/ StartFragment/EndFragmentheader with exact byte offsets must be built by hand (BuildCfHtml). Receiving apps are unforgiving about this being wrong. Clipboard.SetDataObjectcan throwCOMExceptionif 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+UseWindowsFormsboth add implicit global usings that collide onApplication,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 inapp.manifest(PerMonitorV2), which is right for a WPF app.[LibraryImport]requiresAllowUnsafeBlocks. 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
INPUTstruct 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, everySendInputcall silently fails returning 0 withERROR_INVALID_PARAMETER(87). This wasted significant time — an early test script had this wrong and made a perfectly working app look broken. SendKeysdoes not triggerRegisterHotKeyhotkeys. It posts to the focused window's queue rather than injecting real input. UseSendInput.- PowerShell 5.1's
Add-Typeuses 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 anImage), so name-based queries will match the+button instead. - Verifying the tray icon: enumerate
Shell_TrayWnddescendants 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
IGifProviderinterface 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
SendInputinto 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 bydotnet 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).