{
  "slug": "popy-500kb-clipboard-manager",
  "title": "Popy: a 500 KB clipboard manager because every other one is an Electron app",
  "date": "2026-03-12",
  "description": "Writing a native Swift macOS menu bar app. NSPasteboard polling, Keychain storage, global hotkeys via CGEvent, and universal binary CI.",
  "url": "https://anuragxd.com/blogs/popy-500kb-clipboard-manager",
  "markdownUrl": "https://anuragxd.com/blogs/popy-500kb-clipboard-manager.md",
  "contentFormat": "markdown",
  "content": "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.\n\nSo 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.\n\nSo I built [Popy](https://github.com/anuragxxd/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:\n\n- Lives in the menu bar. No dock icon, no clutter.\n- Remembers your last 25 copies, and survives restarts.\n- Stores history in the macOS Keychain, encrypted at rest.\n- Makes zero network calls. No analytics, no accounts.\n\nThe installed app is under 500 KB. I am weirdly proud of that number.\n\n## Clipboard observation on macOS\n\nmacOS 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.\n\n```swift\nTimer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in\n    let current = NSPasteboard.general.changeCount\n    if current != self?.lastChangeCount {\n        self?.lastChangeCount = current\n        self?.captureClipboard()\n    }\n}\n```\n\n`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.\n\nThe 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.\n\n## Keychain storage\n\nThe 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.\n\n```swift\nfunc save(_ items: [ClipboardItem]) {\n    let data = try? JSONEncoder().encode(items)\n    let query: [String: Any] = [\n        kSecClass as String: kSecClassGenericPassword,\n        kSecAttrService as String: \"com.popy.clipboard\",\n        kSecAttrAccount as String: \"history\",\n    ]\n    SecItemDelete(query as CFDictionary) // delete-then-add pattern\n    var addQuery = query\n    addQuery[kSecValueData as String] = data\n    SecItemAdd(addQuery as CFDictionary, nil)\n}\n```\n\nI 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.\n\nThe 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.\n\n## Global keyboard shortcut\n\nA 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`).\n\n```swift\nlet tap = CGEvent.tapCreate(\n    tap: .cgSessionEventTap,\n    place: .headInsertEventTap,\n    options: .defaultTap,\n    eventsOfInterest: CGEventMask(1 << CGEventType.keyDown.rawValue),\n    callback: hotkeyCallback,\n    userInfo: Unmanaged.passUnretained(self).toOpaque()\n)\n```\n\nThe 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\")!)`.\n\nThis 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.\n\n## Paste-in-place\n\nClick-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:\n\n```swift\nfunc simulatePaste() {\n    let source = CGEventSource(stateID: .hidSystemState)\n    let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true) // 0x09 = 'v'\n    keyDown?.flags = .maskCommand\n    let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: false)\n    keyDown?.post(tap: .cghidEventTap)\n    keyUp?.post(tap: .cghidEventTap)\n}\n```\n\nThis 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.\n\n## Universal binary CI\n\nThe 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`.\n\nThe 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.\n\nI 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](https://github.com/anuragxxd/popy). Poke at it, or tell me where I got it wrong. Would you rather this be an Electron app?"
}
