I kept losing things I'd copied five minutes ago. Copy a chunk of config, paste something else twice, then reach for that first chunk again. Gone. Maybe three times a day.
So I went looking for a clipboard manager, and every one I tried was an Electron app heavier than my text editor, wanted a subscription, or did who-knows-what with my clipboard data. That last one is what stuck with me. A clipboard manager has access to everything you copy: passwords, API keys, personal messages. It sees the most sensitive stream on your machine, and you are trusting it not to phone home with it. I wanted one I could actually verify.
So I built Popy and open-sourced it under MIT. Native Swift, AppKit, no dependencies, and you can read every line before you install it. What it does, and nothing more:
- Lives in the menu bar. No dock icon, no clutter.
- Remembers your last 25 copies, and survives restarts.
- Stores history in the macOS Keychain, encrypted at rest.
- Makes zero network calls. No analytics, no accounts.
The installed app is under 500 KB. I am weirdly proud of that number.
Clipboard observation on macOS
macOS gives you no push notification for clipboard changes. There is no NSPasteboardDidChangeNotification. I went looking for one, assuming I had missed it, and I had not. The documented approach is to poll NSPasteboard.general.changeCount, a monotonically increasing integer that ticks up whenever the pasteboard content changes. Popy polls it on a 0.5-second Timer in the main run loop.
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
let current = NSPasteboard.general.changeCount
if current != self?.lastChangeCount {
self?.lastChangeCount = current
self?.captureClipboard()
}
}
captureClipboard() reads NSPasteboard.general.string(forType: .string). If the string is non-nil, non-empty, and different from the most recent entry (deduplication by content hash), it gets prepended to the history array. The array is capped at 25 entries, FIFO eviction.
The 0.5-second interval is a compromise, and I sat with it for a while. Faster polling captures quicker but wakes the CPU more often, which matters for a menu bar app that should be invisible in Activity Monitor. I profiled it with Instruments. At 0.5 seconds the CPU impact on an M1 is zero measurable. I stopped tuning there.
Keychain storage
The history array is serialized to JSON and stored in the macOS Keychain via the Security framework. The item uses kSecClassGenericPassword with a fixed service name and account name. On launch, history is deserialized from the Keychain. On every capture, the updated array is written back.
func save(_ items: [ClipboardItem]) {
let data = try? JSONEncoder().encode(items)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.popy.clipboard",
kSecAttrAccount as String: "history",
]
SecItemDelete(query as CFDictionary) // delete-then-add pattern
var addQuery = query
addQuery[kSecValueData as String] = data
SecItemAdd(addQuery as CFDictionary, nil)
}
I picked Keychain over UserDefaults or a plist on disk for one reason: people copy passwords. I copy passwords. Keychain items are encrypted at rest with the user's login keychain key, protected by the Secure Enclave on Apple Silicon. UserDefaults writes a plaintext plist to ~/Library/Preferences/, which any process running as the same user can read. For a thing that quietly records everything you copy, storing it in the clear is a liability I was not going to ship.
The tradeoff is that Keychain operations are slow next to file I/O, since each write is an IPC round trip with securityd. At one write per clipboard capture, I have never felt it.
Global keyboard shortcut
A global keyboard shortcut needs accessibility permissions, sandboxed or not. Popy uses CGEvent.tapCreate to register a system-wide event tap for a configurable combo (default Cmd+Shift+V).
let tap = CGEvent.tapCreate(
tap: .cgSessionEventTap,
place: .headInsertEventTap,
options: .defaultTap,
eventsOfInterest: CGEventMask(1 << CGEventType.keyDown.rawValue),
callback: hotkeyCallback,
userInfo: Unmanaged.passUnretained(self).toOpaque()
)
The tap only fires once the user has granted accessibility permission in System Preferences > Privacy > Accessibility. If the tap comes back nil (permission denied), Popy catches that at launch and shows a one-time dialog explaining why, with a button that opens the correct pane via NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!).
This first-launch flow is where I lost the most time, and it is genuinely nasty. You cannot grant the permission programmatically. You cannot even check whether it has been denied without attempting the tap first. And the user has to restart the app after granting it, because the tap registers at launch. My first version told people to quit and reopen, which felt broken. So I added a poll that retries CGEvent.tapCreate every 2 seconds until it succeeds. No manual restart. That is the version I kept.
Paste-in-place
Click-to-copy just writes the selected item back to NSPasteboard.general. Paste-in-place (click, and it lands in the frontmost app immediately) needs a simulated Cmd+V keystroke:
func simulatePaste() {
let source = CGEventSource(stateID: .hidSystemState)
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true) // 0x09 = 'v'
keyDown?.flags = .maskCommand
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: false)
keyDown?.post(tap: .cghidEventTap)
keyUp?.post(tap: .cghidEventTap)
}
This is a synthetic input event, so it wants accessibility permission too. It is the same permission as the global hotkey, so there is no second prompt. That was a small relief.
Universal binary CI
The GitHub Actions workflow builds a universal binary (x86_64 + arm64) with xcodebuild and ARCHS="x86_64 arm64". Signing uses a self-signed certificate. A Developer ID certificate would be the right answer, but it costs $99/year and this is a free menu bar app. Notarization is skipped for now, so users get the "unidentified developer" dialog on first launch, which the install script clears with xattr -d com.apple.quarantine.
The install script (curl | bash) pulls the latest release DMG from GitHub, mounts it with hdiutil attach, copies the .app to /Applications, unmounts, and strips the quarantine attribute. About 15 lines of bash. I considered a Homebrew cask and decided the maintenance is not worth it for a one-person project yet.
I still reach for that first chunk of text I copied twenty minutes ago. The difference now is it is sitting in my menu bar, and clicking it costs nothing. The 500 KB is the part I would defend hardest, but the part I actually care about is that the thing recording my clipboard is 500 KB of Swift I can read top to bottom, not a black box making network calls I cannot see. It is all on GitHub. Poke at it, or tell me where I got it wrong. Would you rather this be an Electron app?