A minimal, keyboard-first clipboard manager for macOS — with a prompt library for the instructions you reuse every day.
340 pt frosted panel at your cursor. Menu-bar only: no Dock icon, no ⌘Tab entry. History encrypted at rest; password-manager clipboards are never recorded.
swift/ the app. Swift 6, AppKit + SwiftUI, zero dependencies.
tauri/ v0.1.3, frozen. Kept as the record of a technology choice.
tauri/ was the original implementation (Tauri 2 + React + Rust). It is frozen,
not deleted — the reasoning is in
ADR-0001, and .githooks/pre-push
still builds and tests it so the archive cannot rot.
Everything below describes the Swift app.
| Tool | Version |
|---|---|
| Xcode | 26+ (Swift 6.0+) |
| macOS | 14.0+ to run, 26+ to build |
cd swift
swift test # 220 tests, headless, ~0.15s
bash scripts/bundle.sh # debug .app → swift/build/SwilClip.app
bash scripts/bundle.sh --release # universal, Developer ID signed
bash scripts/release.sh # …plus notarized, stapled, packaged as a .dmgbundle.sh --release produces an app that runs here. release.sh produces one
that runs on someone else's Mac: Gatekeeper refuses an unseen Developer ID
binary unless Apple has notarized it. Both the app and the disk image are
notarized and stapled — the ticket travels with whatever it is attached to, so
the app stays verifiable after it is dragged out of the image on a machine that
happens to be offline.
There is no .xcodeproj, on purpose: the build is fully scriptable and
reproducible from a clean checkout, with no IDE state in the repository.
Press ⌘⇧V anywhere. The panel opens at your cursor.
| Key | Clipboard tab | Prompts tab |
|---|---|---|
↑ ↓ Home End |
navigate | navigate |
⌘↑ ⌘↓ |
first / last row | first / last row |
s or any letter |
search | search (title + body) |
⏎ |
copy — or paste, with Auto Paste on | same |
d / p |
delete / pin | delete / pin |
e |
expand | preview |
u |
undo delete | undo delete |
n |
— | new prompt |
⇧S |
save as prompt | — |
⇥ |
switch tab (rebindable) | switch tab |
esc |
collapse → leave search → close | same |
Every action is also reachable with the arrow keys alone.
← → step through the selected row's buttons — pin, save as prompt, expand,
delete — and ⏎ runs the one you land on. The focused button carries a ring,
and the trash carries a red one; while a button is armed the footer stops
showing hints and shows what ⏎ will actually do (⏎ delete). Arrow keys are
only safe on a destructive button if the panel says so before you press Return.
↑ from the first row reaches the bar above the list, which is a band with
stops, not just two tabs:
[ Clipboard | Prompts ] … + ⚙
← → walk along it and ⏎ activates the stop. The arrows spend themselves on
switching tabs first, so flipping between the two lists stays one key — and you
cannot land on + while looking at the clipboard, where a plus would mean
nothing. + and ⚙ are how "new prompt" and "settings" became keyboard-
reachable at all: the first was n-only with no button anywhere, the second was
mouse-only.
Reaching an action by arrowing onto its button is not a second implementation of
that action — ⏎ on the trash routes back through the same reducer transition
d does, including the part that picks the next selection before the row goes
away. PanelFocusTests asserts the two paths produce identical state.
Settings has a Shortcuts pane listing all 25 ways to drive the panel,
grouped by when you would want them. It is generated from ShortcutReference
rather than written out by hand, and KeyCommand.documentedAction is an
exhaustive switch — a new key cannot ship undocumented, because the project will
not compile until it has somewhere in that list to live. The two rebindable keys
are substituted from your live settings, so a printed keycap cannot disagree
with the key that works.
Clipboard history is short-term memory: passive, capped, evicted oldest-first. Prompts are the curated tier — manually saved, never evicted, searched by title and body.
⇧S moves an entry from the first to the second. Write a good instruction in a
chat window and it is already in your history; ⌘⇧V, ⇧S, and it is in the
library permanently with a title taken from its first line. That action is the
reason both live in one panel.
A clipboard manager that is not running is not failing loudly — it is silently
not recording, and you find out at the next ⌘⇧V. The toggle is in Settings.
There is no stored preference behind it: SMAppService.mainApp.status is the
state, read live. A cached Bool would drift the moment the login item is
switched off in System Settings › General › Login Items, leaving the app
showing "on" and never launching.
Pinned entries sit in a group at the top, above a RECENT divider, and never
count toward the history limit.
Unpinning moves the entry to the top of the unpinned group rather than back to its chronological place. Two reasons, and the second is the important one:
- you unpinned it because you were looking at it, so it should still be under your eye afterwards — not several hundred rows down;
- dropping it back to its real age lands it among the oldest unpinned rows, where the next few captures evict it. Before this change, unpinning an old entry was effectively a delayed delete.
If an entry is ever lost, swift run swilclip-recover --list-missing-pinned
finds it: the v1 history file is read-only and permanent, so it stays a complete
record of everything captured before the migration.
中文 / English, switchable in Settings, applied instantly everywhere. Defaults to following macOS.
Deliberately not .lproj catalogues: that route follows the system
language, and an in-app switch would mean hand-loading a bundle — a known hack
that silently leaks the key onto the screen when a translation is missing. Every
string instead lives on one line as pick(english, chinese) in
SwilClipCore/Localization/Strings.swift, so a missing translation is a compile
error rather than a blank label.
Light / dark / system, applied through NSApp.appearance so the panel, both
sheets and the vibrancy material all switch at once with nothing to observe. The
light palette is not an inversion of the dark one — flipping lightness around 50
gives muddy greys and blue-tinted glass, so each value was rewritten for its role
on a light ground.
The accent is a hue. Eight presets and a spectrum slider, but only the hue moves: saturation and brightness stay locked at the ratified values, so any choice still reads as "this row is selected" rather than as decoration. The label colour on top of it is derived from WCAG relative luminance rather than hard-coded white — at one fixed brightness the hue alone swings the accent from navy to highlighter, and white on lime is unreadable.
Panel opacity — sheer / standard / solid — decides how much of the app behind
shows through. macOS exposes no "blur harder" control; NSVisualEffectView picks
a material, not a radius. What actually keeps text readable over a bright
window is the panel's own tint, and that tint has to sit on top of the glass:
underneath it, a .behindWindow effect paints its own blurred backdrop and hides
it completely.
Three presets in Settings — Small (340×480, default), Medium (425×600), Large
(510×720). Each scales the whole design system, text and spacing together,
not just the window: 340 points is comfortable on a laptop and cramped on a 5K
display. Fonts are scaled by point size rather than scaleEffect, so glyphs stay
sharp instead of being resampled.
The list holds still while the cursor moves inside it, and scrolls only when the selection comes within two rows of an edge — then by the minimum that restores that margin. Keyboard scrolling is never animated.
The obvious alternative, re-centring the selected row on every change, means the
whole list slides under a cursor that never moves; every keypress becomes a jolt
and holding a key becomes a smear. See ScrollAnchoring in SwilClipCore.
By default ⏎ only copies. SwilClip writes the pasteboard, hides, and
restores focus to whatever app you came from — then stops. You press ⌘V
yourself, wherever you actually want it. This needs no Accessibility permission
and can never paste into the wrong field.
Auto Paste (Settings) makes ⏎ also synthesise ⌘V. It requires
Accessibility, and refuses to switch on until the permission is granted rather
than failing silently.
Text and images, polled every 500 ms. Images are re-encoded to PNG and stored as encrypted sidecar files rather than inline, so the list never holds payloads it is not showing.
Each image also gets a thumbnail — a few kilobytes, made once at capture and kept in the row, so a list row can show the picture without loading the picture. It is a column rather than a second sidecar file, which makes cleanup a property of the schema: delete or evict the row and the thumbnail goes with it.
Never captured: pasteboards marked with the
nspasteboard.org conventions — ConcealedType,
TransientType, AutoGeneratedType. 1Password, Bitwarden, Apple Passwords and
Keychain Access all set the first, so copied passwords never enter the history.
Encryption at rest is not a reason to record them.
History and prompts are encrypted per row with AES-256-GCM (CryptoKit), each
value under its own nonce. The key is 256 random bits in your login Keychain,
ThisDeviceOnly, never synced.
Per-row rather than one blob is the substantive change from v1: a write is O(1) instead of O(entire history), which is why v2 has none of v1's 50-entry, 10 MB and 32 MB caps — those existed only to make an O(n) write survivable.
The Keychain read distinguishes "no key yet" from every other failure. A locked
keychain or a denied prompt raises an error; only errSecItemNotFound creates a
key. Getting that wrong would orphan the entire history behind a fresh key.
First launch imports ~/Library/Application Support/com.supwilsoft.swilclip/clipboard_history.json
automatically — entries, timestamps, pins, and your hotkey preference.
The v1 file is read, never written or deleted, and stays as your fallback.
v2 uses its own data directory (SwilClipSwift/), so the frozen v1 app and this
one cannot touch each other's bytes. If the import fails it aborts without
marking itself done, and retries on the next launch.
macOS will ask once for permission to read the v1 encryption key, because this binary's signature differs from v1's. Choose Always Allow.
swift/Sources/
├── SwilClipCore/ pure Swift, no AppKit — the whole test suite runs headless
│ ├── Models/ ClipItem · PromptItem · ContentKind
│ ├── Crypto/ FieldCipher · KeychainKeyStore
│ ├── Database/ SQLite wrapper · Schema · LocalStore
│ ├── Search/ Matcher
│ ├── Selection/ SelectionReducer · PanelFocus · KeyMapping · ShortcutReference
│ ├── Localization/ Strings · AppLanguage · Appearance · AccentPalette
│ └── Migration/ LegacyImporter
└── SwilClip/ AppKit windowing + SwiftUI views
Anything decidable without a window lives in SwilClipCore. That includes the
entire keyboard model: NSEvent is unwrapped in the app target, but which
command a keypress is — and what that command does to the selection — are pure
functions with tests.
Selection has exactly one home. v1's worst shipped bug deleted the wrong row,
because cmdk kept a private copy of the selection and rewrote it outside
React's knowledge. SelectionReducer is the answer: views read state and emit
commands, and never write selection themselves.
- v2 design spec
- ADR-0001 — freeze Tauri, adopt Swift
- ADR-0002 — reopen the prompt library
- UI design system — "Brushed Quartz", canonical
- Code review register — scoped to the frozen
tauri/tree - Swift audit — an external review of
swift/, and what came of it - Changelog
- Releasing — signing, notarization, packaging
MIT — © 2026 SUPWILSOFT LLC.