A clipboard-history popup for macOS, built to replace Raycast's Clipboard History for one job: press ⌘⇧V, pick something you copied in the last 24 hours, and it gets pasted into the app you were using.
The repo started as a bake-off: the same app on several UI stacks, measured
against Raycast with memory as the headline number. Round one (Rust + Tauri
versus Rust + GPUI, results/baseline-2026-10-10/) went to GPUI by a wide
margin. Round two (results/appkit-2026-10-11/, summarised under
Results) put plain AppKit against GPUI and AppKit idles at half
the footprint and settles at a third, so the AppKit frontend is now the
one being developed and released. GPUI stays in the tree and in the
default build as the benchmark comparison; Tauri stays for reference only
and is not built by default.
Reference UI: docs/raycast-clipboard-history.png (Raycast's panel).
Layout
crates/
paste-buff-core/ history store (24h TTL, JSON on disk), pasteboard watcher,
paste-into-previous-app, AppKit helpers, SIGUSR1 toggle hook
paste-buff-appkit/ the released frontend: AppKit via objc2, an NSPanel subclass
with NSTableView rows and an NSTextView/NSImageView preview,
ordered out while hidden
paste-buff-gpui/ GPUI 0.2 frontend: non-activating NSPanel, uniform_list rows
paste-buff-tauri/ reference only: Tauri 2 frontend (text clips only)
paste-buff-bench/ benchmark harness (footprint / RSS / CPU per process tree)
scripts/
seed-history.py writes a deterministic history file so frontends start equal
results/ benchmark outputs (gitignored CSV/JSON; keep the .md summaries)
docs/
Shared behaviour (all frontends):
- Polls
NSPasteboard.generalPasteboard().changeCountevery 250 ms and records text, file and image clips. Files copied in Finder are stored as their paths and pasted back as real file URLs; images (screenshots, "Copy Image") are normalised to PNG under~/Library/Application Support/paste-buff/images/and deleted with their entry. When a clip carries both text and an image (spreadsheet cells, rich text) the text wins. Clips flaggedorg.nspasteboard.ConcealedTypeorTransientType(password managers) are skipped, as are blank clips, text over 1 MiB and images over 32 MiB. - Re-copying existing text moves it to the top and bumps "times copied".
- History lives at
~/Library/Application Support/paste-buff/history.json, written atomically. Entries older than 24 h are pruned on load and every minute. Override the path withPASTE_BUFF_HISTORY=/path/to/file.json. - On ⌘⇧V the app remembers the frontmost app, shows its panel, and on Enter writes the entry to the pasteboard, hides itself, re-activates that app and posts ⌘V.
SIGUSR1toggles the panel. The benchmark harness uses this so it can exercise the UI without the Accessibility permission that synthetic keystrokes need.
Keys inside the panel: type to filter, ↑/↓ move, ⏎ paste, ⌘⌫ or Delete removes an entry, Esc or clicking elsewhere hides, ⌘Q quits.
Install
Download paste-buff.dmg
from the latest GitHub release,
open it, and drag paste-buff into Applications. It is one universal app for
Apple Silicon and Intel Macs and needs macOS 13 or later. SHA256SUMS next to
the DMG has its checksum.
paste-buff has no window, Dock icon or menu bar item: open it once and press ⌘⇧V. On that first launch it adds itself to System Settings > General > Login Items so it starts at every login; switch it off there and it stays off. ⌘Q inside the panel quits until the next login.
On first launch macOS asks for Accessibility access so paste-buff can press ⌘V in the target app. Raycast's Clipboard History also uses ⌘⇧V; turn that hotkey off in Raycast or the two will fight over the panel.
The app and DMG are signed with a Developer ID, notarised and stapled, so
Gatekeeper opens them without warnings and the Accessibility grant carries
over to new versions. To uninstall, quit it, drag it to the Trash, and remove
its Login Items entry; history stays in
~/Library/Application Support/paste-buff until you delete it.
Moving from the curl installer (v0.2.x)
Versions up to 0.2.1 installed a bare binary and a launchd agent. Remove
those before installing the app, then re-grant Accessibility to the app
(remove the old paste-buff entry from the list first):
launchctl bootout gui/$(id -u)/io.roush.paste-buff
rm ~/Library/LaunchAgents/io.roush.paste-buff.plist ~/.local/bin/paste-buff
Releasing
Push a tag that matches the workspace version in Cargo.toml.
.github/workflows/release.yml builds both architectures into a universal
binary, wraps it in paste-buff.app (packaging/), signs it, builds the
DMG, notarises and staples it, and publishes paste-buff.dmg plus
SHA256SUMS:
git tag v0.3.0 && git push origin v0.3.0
To build the app locally (ad-hoc signed):
cargo build --release -p paste-buff-appkit
packaging/bundle.sh target/release/paste-buff-appkit dev target
codesign --force --sign - target/paste-buff.app
The icon is drawn by packaging/make-icon.swift; regenerate AppIcon.icns
with the two commands at the top of that file.
Build and run
cargo build --release
./target/release/paste-buff-appkit
# the GPUI frontend, kept for comparison:
./target/release/paste-buff-gpui
# reference frontend, text only:
cargo build --release -p paste-buff-tauri && ./target/release/paste-buff-tauri
The app runs as an accessory (no Dock icon) and stays hidden until ⌘⇧V.
Things macOS will ask for:
- Accessibility for whatever launches the app (your terminal, or the binary itself once it is bundled). Without it the app still copies the entry to the pasteboard but cannot send ⌘V, and logs a one-line warning.
- Hotkey conflicts: Raycast and paste-buff can both register ⌘⇧V and both will fire. Whichever panel gets key status last wins the keyboard, and paste-buff hides itself when it loses key status, so with both bound you will mostly see Raycast. Change or disable Raycast's Clipboard History hotkey, or quit Raycast.
- Ad-hoc signatures and Accessibility: macOS ties the Accessibility grant to the binary's code signature. Every new build has a new ad-hoc signature, so after an upgrade the existing switch in System Settings may stop working; toggle it off and on (or remove and re-add the entry). A Developer ID signature would make the grant survive upgrades.
- GPUI shaders (GPUI frontend only): the GPUI crate is built with the
runtime_shadersfeature because Xcode 26 ships its Metal compiler as a separate download. If you install it (xcodebuild -downloadComponent MetalToolchain) you can drop the feature incrates/paste-buff-gpui/Cargo.tomlto precompile the shaders and shave a little off startup.
Run the core's tests with cargo test -p paste-buff-core.
Benchmark harness
# Seed a history: 200 text entries plus a screenshot-sized PNG and a Finder
# file clip as the newest entries (add --no-media for text only)
python3 -I scripts/seed-history.py results/seed-history.json --entries 200
# Raycast (already running): attach by name, drive it through its deeplink
./target/release/paste-buff-bench run --name Raycast --toggle raycast --out results/raycast
# paste-buff: launch, toggle with SIGUSR1, kill when done
PASTE_BUFF_HISTORY=results/seed-history.json \
./target/release/paste-buff-bench run --launch target/release/paste-buff-appkit --out results/appkit
# Side-by-side table
./target/release/paste-buff-bench report results/*.json
Each run is: --warmup (default 3 s, launch mode only) → --idle 10 s →
--cycles 10 × (open, hold 1 s, close, wait 1 s) → --cooldown 10 s. Samples
are taken every --interval 0.5 s and written to <out>.csv; a summary
goes to <out>.json. paste-buff-bench ps --name Raycast shows what would
be measured.
What the columns mean:
- footprint is
ri_phys_footprintfromproc_pid_rusage, the number Activity Monitor shows as "Memory" and what the kernel uses for memory pressure. RSS is reported too but overstates shared framework pages. - procs is the whole process tree. Helpers are attributed with the same
responsible-pid relationship Activity Monitor uses for its hierarchy view
(
responsibility_get_pid_responsible_for_pid), plus parent pids. Raycast comes with three WebKit XPC helpers; so does a Tauri app. An app launched from a terminal is not its own responsible process, so in--launchmode any WebKit XPC process that appears after launch is attributed to it. - idle is the average while hidden, peak (cycling) the maximum while the panel was being opened and closed, after cycles the average once it was hidden again (a gap between idle and after is memory the UI keeps).
- CPU is user+system time over wall time, summed over the tree.
Toggle modes: signal (SIGUSR1, paste-buff frontends), raycast (opens
raycast://extensions/raycast/clipboard-history/clipboard-history, then
re-activates the previous app so Raycast dismisses), keys (posts ⌘⇧V and
Esc; needs Accessibility), none (idle only).
Results
Round three, 2026-10-24: 0.4.0 (Liquid Glass) against 0.3.0 and Maccy;
round two, 2026-10-11: AppKit against GPUI. Apple M2 Max, 32 GB,
macOS 27.0.1, rustc 1.98.0, release builds. Protocol: 20 s idle →
20 × (open, hold 1 s, close, wait 1 s) → 20 s cooldown, sampled every 0.5 s,
launched by the harness and toggled with SIGUSR1, each run from a fresh copy
of the seed (202 entries with a 1600×1000 PNG newest, or 200 text entries
with --no-media). Maccy got the same 200 text entries through the
pasteboard and was toggled with its shortcut. Raycast and Tauri rows are
round one's, text-only seed, same protocol.
| target | procs | idle footprint | peak (panel up) | after 20 cycles | cycling CPU | binary |
|---|---|---|---|---|---|---|
| paste-buff-appkit 0.4.0, text-only seed | 1 | 16.4 MB | 20.3 MB | 20.1 MB | 2.8% | 0.7 MB |
| paste-buff-appkit 0.4.0, image newest | 1 | 17.4 MB | 31.6 MB | 27.4 MB | 3.4% | 0.7 MB |
| paste-buff-appkit 0.3.0, text-only seed (round two) | 1 | 14.6 MB | 17.6 MB | 17.3 MB | 4.4% | 0.7 MB |
| paste-buff-appkit 0.3.0, image newest (round two) | 1 | 14.7 MB | 17.9 MB | 17.8 MB | 3.9% | 0.7 MB |
| paste-buff-gpui, text-only seed | 1 | 30.1 MB | 51.0 MB | 45.0 MB | 2.2% | 6.0 MB |
| paste-buff-gpui, image newest | 1 | 35.7 MB | 50.4 MB | 45.7 MB | 2.9% | 6.0 MB |
| Maccy 2.7.1 (round three) | 3 | 53.6 MB | 90.8 MB | 63.9 MB | 3.4% | 3.6 MB |
| paste-buff-tauri (round one) | 4 | 66.3 MB | 111.6 MB | 73.5 MB | 5.2% | 5.5 MB |
| Raycast (round one) | 6 | 116.4 MB | 131.1 MB | 126.8 MB | 7.0% | – |
What the numbers say:
- Liquid Glass costs 2–3 MB. 0.4.0 idles at 16.4 MB against 14.2 MB for 0.3.0 re-run the same afternoon. After an image preview it settles 9 MB higher (27 MB) and stays there: the glass view's layer-backed hierarchy keeps the decoded image cached after the panel is ordered out.
- Maccy idles at about three times paste-buff (53.6 MB across Maccy and two system helpers macOS attributes to it) and peaks at 91 MB.
- AppKit idles at half of GPUI and settles at a third. The AppKit panel is ordered out when hidden, so WindowServer drops its backing store. GPUI keeps the window, three Retina drawables (16 MB of IOSurface) and the Metal renderer alive between shows, which is the 15 MB it never gives back after the first open.
- AppKit with the panel up uses less than GPUI hidden. Part of that is accounting: an AppKit window's pixels live in WindowServer, roughly 5.5 MB for this panel at 2×, and are charged there rather than to paste-buff, whereas GPUI's IOSurfaces are charged to paste-buff. Adding it back still leaves AppKit's panel-up total under GPUI's hidden baseline.
- Images cost AppKit nothing at idle. NSImage decodes on the first show, not at startup, and the image view is cleared on hide. GPUI paints the hidden window once at startup and caches the decoded image.
- Cycling CPU is higher for AppKit (4% against 2–3%) because each show rebuilds the table and lays out the text view; GPUI's element tree is warm. Idle CPU is at the noise floor for every frontend.
- The binary is 0.7 MB against 6.0 MB. No shader compiler, image decoders or font shaper of our own; everything comes from the OS.
- Against Raycast, AppKit idles at about a seventh of its footprint in one process instead of six. Raycast is a whole launcher, so this is the cost of its clipboard history being bundled, not a like-for-like comparison.
Raw samples, summaries and stderr logs for round three are in
results/appkit-glass-2026-10-24/ and for round two in
results/appkit-2026-10-11/, each with its own RESULTS.md. Round one's GPUI
runs are in results/RESULTS.md and the original three-way comparison in
results/baseline-2026-10-10/. Regenerate any table with
paste-buff-bench report <dir>/*.json.
Design notes
- One shared core keeps the comparison honest: the frontends differ only in windowing, rendering and hotkey plumbing.
- The AppKit frontend is one
NSPanelsubclass that owns all state. It is borderless and non-activating, overridescanBecomeKeyWindow, takes every key press insendEvent:so it does not matter which subview is first responder, and hides itself inresignKeyWindowso a click elsewhere dismisses it. Dismiss isorderOut:; the app never activated, so the target still has focus and the window's backing store is released. - The GPUI window is a
WindowKind::PopUp, which GPUI maps to a non-activatingNSPanel; the Tauri window is a regular undecoratedNSWindow. Both hide the whole app on dismiss ([NSApp hide:]) because that reliably returns focus to the previous app, and both keep the window alive while hidden. - Tauri's UI is plain HTML/CSS/JS with no bundler so the frontend cost is WebKit itself, not a framework.
- Neither Rust frontend has a text-input widget; the filter box is a string fed by key-down events, which is all a filter needs.
Candidates for round three
- A small Swift/SwiftUI app as the "what Apple would do" baseline. It can ship as a bare executable like the Rust builds; expect it to land between AppKit and GPUI.
egui/eframeoriced: other Rust-native renderers, likely to share GPUI's kept-warm GPU surface problem.