Watching the macOS clipboard: NSPasteboard and four traps
There is no notification when the macOS clipboard changes.
No NSNotification, no KVO, no delegate callback. NSPasteboard exposes a
monotonically increasing changeCount that ticks up on every write, and that is
the entire API surface you get for observing it. Every clipboard manager on the
Mac — including Jackdaw — is a polling loop around that integer.
That sounds trivial. It is, for about a day. Then the interesting parts show up.
The loop
The shape is what you would expect: read changeCount, compare it to the last
one you saw, and if it moved, read the pasteboard’s contents.
let pasteboard = NSPasteboard::generalPasteboard();
let mut last_count = pasteboard.changeCount();
while running.load(Ordering::SeqCst) {
thread::sleep(POLL_INTERVAL);
let current_count = pasteboard.changeCount();
if current_count == last_count {
continue;
}
last_count = current_count;
// ...capture
}
The first real decision is the interval, and the reasoning is not the one people expect.
Trap 1: the poll interval is not about responsiveness
The instinct is to tune the interval so the UI feels fast. That is the wrong axis. Nobody opens a clipboard manager within 400ms of copying — the history is read seconds or minutes later. A 500ms interval is imperceptible for the feature itself.
What the interval actually bounds is shutdown latency. The worker thread only notices it should stop when it wakes up, so a 500ms tick means teardown can block for half a second. That is long enough to be visible when the app quits, and long enough to matter when the monitor is restarted after the machine wakes from sleep.
Jackdaw polls every 100ms for that reason, not for responsiveness. On Apple silicon, reading an integer ten times a second is energy-negligible; the cost is entirely in what you do after the count moves.
Trap 2: autorelease pools on long-lived threads
This is the one that produces a bug report six weeks after you ship, phrased as “the app slowly eats memory”.
Every pasteboard read hands you autoreleased Objective-C objects — NSString,
NSData, NSURL, the type array. On the main thread, AppKit drains the
autorelease pool on every pass of the run loop, so you never think about it. A
long-lived worker thread has no run loop and therefore no pool draining. Those
objects accumulate for the lifetime of the thread, which for a clipboard monitor
means for as long as the app is running.
The fix is to wrap the per-tick body:
let should_notify = autoreleasepool(|_| {
let current_count = pasteboard.changeCount();
// ...read and store
});
Two details matter. The pool must not be held across the sleep — that
would defeat the point by keeping it open ~99% of the time. And
generalPasteboard() is a singleton, so it stays outside the pool rather than
being re-fetched and released each tick.
Trap 3: some clipboard writes are none of your business
Password managers put your credentials on the general pasteboard. So do secure text fields. A naive clipboard manager cheerfully records all of it, in plaintext, forever.
macOS itself does not solve this for you. What exists instead is a community convention documented at nspasteboard.org: a writer declares an extra type on the pasteboard to signal intent. The one that matters for privacy is:
org.nspasteboard.ConcealedType
1Password and other credential tools declare it. Honouring it is a one-line check before any capture, and it must come before you read anything, so a concealed write is dropped whole rather than partially recorded:
if pasteboard_is_concealed(&pasteboard) {
return false; // never touch the contents
}
The same convention defines TransientType and AutoGeneratedType. Jackdaw
deliberately does not filter those two: they are declared by tools whose output
users often do want in their history, and dropping them silently loses wanted
clips. Concealed is a privacy boundary; transient is a preference. They deserve
different answers.
If you are writing to the pasteboard from your own app and the content is
sensitive, declare ConcealedType yourself. Every well-behaved clipboard
manager will skip it.
Trap 4: your own writes come back to you
The moment your app pastes something, it writes to the pasteboard. That bumps
changeCount. Your own monitor sees the change and records it — so pasting an
item from your history creates a duplicate of that item at the top of your
history.
The fix is a suspension flag that the paste path raises and the monitor consumes exactly once:
pub fn suspend_capture_once() { raise_suspension(&MONITOR_SUSPENDED); }
Consuming it once rather than using a boolean “ignore mode” matters. A flag
you set and clear around the write is a race: if the write’s changeCount bump
lands after you cleared it, the duplicate appears anyway. Consume-once ties the
suppression to the next observed change, whenever it arrives.
The ordering question
Once a change is real and allowed, one pasteboard write can carry several representations at once. Copying a file in Finder gives you a file URL and a string. Copying from a design tool can give you an image and text.
There is no correct universal order, only a product decision. Jackdaw checks file URL first, then image, then text — most specific to least — because a file the user can drag out again is more useful than the string form of its path.
let captured = if let Some(item) = capture_file_url(&pasteboard, app.clone()) {
Captured::Item(item)
} else if let Some(bytes) = read_image_bytes(&pasteboard) {
Captured::Image(bytes, app)
} else if let Some(item) = capture_text(&pasteboard, app) {
Captured::Item(item)
} else {
Captured::None
};
Whatever order you pick, pick it deliberately and write it down. It is the kind of decision that looks arbitrary six months later and gets “fixed” into a regression.
What this costs
An integer read every 100ms, an autorelease pool per tick, and one string comparison before any capture. The expensive part of a clipboard manager is never the watching — it is deciding what deserves to be kept, and being disciplined about what does not.