I built Popy because I kept needing something I'd copied twenty minutes ago and it was gone. Every clipboard manager I tried was either an Electron app heavier than my text editor, a subscription, or both. I just wanted the last 25 things I copied, in my menu bar, click to re-copy.
So I built it in Swift, native AppKit, no dependencies. The installed app is under 500 KB. I'm weirdly proud of that number.
Clipboard observation on macOS
macOS has no push notification for clipboard changes. There's no NSPasteboardDidChangeNotification. The documented approach is to poll NSPasteboard.general.changeCount, a monotonically increasing integer that increments whenever the pasteboard content changes. Popy polls 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 polling interval is a compromise. Faster polling means quicker capture but more CPU wake-ups, which matters for a menu bar app that should be invisible in Activity Monitor. I tested with Instruments and 0.5 seconds registers as zero measurable CPU impact on an M1.
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 app launch, the 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 chose Keychain over UserDefaults or a plist on disk because people copy passwords. Keychain items are encrypted at rest using 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 clipboard manager, that's a liability.
The downside is that Keychain operations are slow relative to file I/O (each write involves IPC with securityd), but at one write per clipboard capture, it's imperceptible.
Global keyboard shortcut
Registering a global keyboard shortcut from a sandboxed app (or even a non-sandboxed one) requires accessibility permissions. Popy uses CGEvent.tapCreate to register a system-wide event tap for a configurable key 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 when the user has granted accessibility permission in System Preferences > Privacy > Accessibility. If the tap is nil (permission denied), Popy detects this at launch and shows a one-time dialog explaining why the permission is needed, with a button that opens the correct System Preferences pane via NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!).
The first-launch UX for this is genuinely tricky. You can't programmatically grant the permission. You can't even check if it's been denied without attempting the tap. And the user has to restart the app after granting permission because the event tap registration happens at launch. I ended up adding a poll that retries CGEvent.tapCreate every 2 seconds until it succeeds, so the user doesn't have to manually restart.
Paste-in-place
Click-to-copy just writes the selected item back to NSPasteboard.general. But paste-in-place (click and immediately paste into the frontmost app) requires simulating a 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 also requires accessibility permission (it's a synthetic input event), which is the same permission as the global hotkey, so there's no additional prompt.
Universal binary CI
The GitHub Actions workflow builds a universal binary (x86_64 + arm64) using xcodebuild with ARCHS="x86_64 arm64". Signing uses a self-signed certificate (the app is distributed outside the App Store, so a Developer ID certificate would be ideal but costs $99/year). Notarization is skipped for now; users get the "unidentified developer" dialog on first launch, which the install script works around with xattr -d com.apple.quarantine.
The install script (curl | bash) downloads the latest release DMG from GitHub, mounts it with hdiutil attach, copies the .app to /Applications, unmounts, and removes the quarantine attribute. It's about 15 lines of bash. I thought about a Homebrew cask but the maintenance overhead for a single-person project isn't worth it yet.