| name | macos-clipboard-pasteboard |
| description | Use when reading, writing, or watching the macOS clipboard — NSPasteboard types and multiple representations, the changeCount polling pattern, the transient/concealed pasteboard conventions clipboard tools must respect, and the Sequoia paste-privacy alerts. |
macOS Clipboard & Pasteboard
Work with NSPasteboard correctly: write rich content with plain-text fallbacks, watch for changes the only way macOS allows (polling changeCount), and — if you're building anything clipboard-manager-shaped — respect the concealed/transient conventions that keep passwords out of your history.
When to use
Use this skill when the user says:
- clipboard / pasteboard /
NSPasteboard
- copy / paste programmatically
- clipboard history / clipboard manager
- watch the clipboard / detect copy
- paste as plain text
- custom pasteboard type / drag-and-drop data type
- clipboard privacy / "app pasted from" alert
Do not use this skill for iOS (UIPasteboard differs in important ways), or for drag-and-drop UI mechanics (onDrag/Transferable view wiring) beyond the data-type layer.
Core rule
Write every flavor the receiver might want, richest first.
Read by asking for the best type you can handle.
Never clobber, log, or upload the user's clipboard —
and never store what a password manager marked concealed.
Writing
Always clearContents() first — it claims ownership and bumps changeCount. Then declare the richest set of representations you have:
import AppKit
let pb = NSPasteboard.general
pb.clearContents()
pb.setString("hello", forType: .string)
let item = NSPasteboardItem()
item.setString(htmlString, forType: .html)
item.setString(plainString, forType: .string)
pb.clearContents()
pb.writeObjects([item])
pb.clearContents()
pb.writeObjects([fileURL as NSURL])
pb.writeObjects(urls as [NSURL])
The classic bug is writing only HTML or only a custom type: paste then silently does nothing in half the apps on the system. Plain .string rides along with almost everything.
Custom types
Namespace them like a UTI and add them alongside standard flavors:
extension NSPasteboard.PasteboardType {
static let taskItem = NSPasteboard.PasteboardType("com.example.myapp.task")
}
let item = NSPasteboardItem()
item.setData(try JSONEncoder().encode(task), forType: .taskItem)
item.setString(task.title, forType: .string)
Your own app pastes with full fidelity; everyone else gets sensible text.
Reading
let pb = NSPasteboard.general
if let types = pb.types {
if types.contains(.fileURL),
let urls = pb.readObjects(forClasses: [NSURL.self]) as? [URL] {
handle(files: urls)
} else if let s = pb.string(forType: .string) {
handle(text: s)
}
}
let canPaste = pb.canReadObject(forClasses: [NSURL.self, NSString.self],
options: nil)
Read the pasteboard only on user intent — a paste command, a button, a drop. Reading on timers or at launch is what triggers the privacy alerts below, and it's the behavior they were designed to shame.
Watching for changes — changeCount polling
There is no notification for pasteboard changes on macOS. The sanctioned pattern is polling changeCount — an integer that increments on every ownership change:
@MainActor
final class PasteboardWatcher {
private var timer: Timer?
private var lastCount = NSPasteboard.general.changeCount
private let onChange: (NSPasteboard) -> Void
init(onChange: @escaping (NSPasteboard) -> Void) {
self.onChange = onChange
}
func start(interval: TimeInterval = 0.5) {
stop()
timer = Timer.scheduledTimer(withTimeInterval: interval,
repeats: true) { [weak self] _ in
guard let self else { return }
let pb = NSPasteboard.general
guard pb.changeCount != self.lastCount else { return }
self.lastCount = pb.changeCount
self.onChange(pb)
}
timer?.tolerance = interval / 2
}
func stop() {
timer?.invalidate()
timer = nil
}
}
- 0.5s is plenty; nobody notices half a second of clipboard-history lag, and
tolerance keeps it off the battery's back.
- Compare
changeCount only — don't read contents to detect change.
- Stop the watcher when the feature is off. An always-on reader is exactly what the OS now warns users about.
The clipboard-manager conventions
Any tool that stores clipboard contents must honor the de-facto org.nspasteboard marker types (password managers write them; well-behaved history tools read them):
extension NSPasteboard.PasteboardType {
static let concealed = NSPasteboard.PasteboardType("org.nspasteboard.ConcealedType")
static let transient = NSPasteboard.PasteboardType("org.nspasteboard.TransientType")
static let autoGenerated = NSPasteboard.PasteboardType("org.nspasteboard.AutoGeneratedType")
}
func shouldStore(_ pb: NSPasteboard) -> Bool {
guard let types = pb.types else { return false }
return !types.contains(.concealed) && !types.contains(.transient)
}
And the mirror image: if your app copies something secret (a generated token, a recovery code), mark it concealed so other tools skip it:
let item = NSPasteboardItem()
item.setString(secret, forType: .string)
item.setString("", forType: .concealed)
pb.clearContents()
pb.writeObjects([item])
Sequoia paste privacy
Since macOS 15.x, reading NSPasteboard.general without user intent can surface a system alert — "AppName pasted from OtherApp" — and a per-app clipboard setting exists under Privacy & Security. Consequences:
- Read only inside user-intent code paths (paste command, drop handler, explicit button). Never probe contents at launch "to see what's there".
- Use
canReadObject(forClasses:) / inspect types for UI enablement — checking types is not reading contents.
- A clipboard-history feature is by definition intent-less reading: make it opt-in, explain it at enable time ("reads everything you copy, stores it only on this Mac"), and expect the system to tell the user you're doing it.
Don't clobber the user's clipboard
"Copy to clipboard" features are fine. But automation that pastes through the clipboard (type-for-the-user tools) must save and restore:
let saved = pb.pasteboardItems?.map { old -> NSPasteboardItem in
let copy = NSPasteboardItem()
for type in old.types {
if let data = old.data(forType: type) {
copy.setData(data, forType: type)
}
}
return copy
} ?? []
pb.clearContents()
pb.writeObjects(saved)
Restore after a short delay (the receiving app must read the payload first). Losing what the user had copied is one of the fastest ways to make them delete your app.
Checklist
[ ] Writes include a plain .string fallback next to rich/custom flavors
[ ] clearContents() before every write
[ ] Reads happen on user intent only (paste/drop/button), never on timers
[ ] Watching uses changeCount polling with tolerance, and stops when off
[ ] History/storage honors ConcealedType and TransientType
[ ] Own secrets are written with the concealed marker
[ ] Programmatic paste-through saves and restores the user's clipboard
[ ] Nothing from the clipboard is logged, synced, or uploaded
Manual test
- Copy from your app → paste into TextEdit, a terminal, and Mail — all three produce something sensible.
- Copy a password from a password manager → your history feature does not record it.
- Enable watching → copy elsewhere → change picked up within ~1s; disable → polling stops (check with a breakpoint).
- On macOS 15+: confirm normal use never triggers the "pasted from" alert outside genuine paste actions.
- Paste-through automation: copy something first, run the feature, press ⌘V afterwards → your original copy is back.