Clipboard history without a daemon: NSPasteboard changeCount and concealed types
Poll one integer twice a second, read the contents only when it moves, and skip what a password manager marks as secret.
TL;DR
- A clipboard history needs to know when the clipboard changed.
NSPasteboardexposes an integer,changeCount, that moves on every change. NotchStar reads it every 0.5 seconds and only looks at the contents when the number is new. - Four decisions sit around that one integer: files beat text, text beats images, anything marked
org.nspasteboard.ConcealedTypeis skipped, and the app’s own writes are not recorded again. Each has a test that uses a private pasteboard, never the user’s. - Measured on 4 November 2026: reading
changeCounttook about 430 to 520 nanoseconds per call in a standalone program, and the counter moves onclearContents, not on eachsetString. What polling misses is listed at the end.
The problem
A clipboard history has to notice that the user copied something. The code NotchStar uses is a polling loop. There is no custom background process and no login helper: the loop lives in the clipboard tool object inside the app, and the history it fills is in memory only, capped at 50 items. It skips items password managers mark as concealed.
The loop is short. Here is the core of ClipboardTool.swift:
init(pasteboard: NSPasteboard = .general) {
self.pasteboard = pasteboard
lastChange = pasteboard.changeCount
poll = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(500))
guard let self else { return }
self.check()
}
}
}
Two things are worth noting. The pasteboard is a parameter that defaults to the system one, which is what lets the tests and the UI test mode pass in a private pasteboard. And lastChange starts at the current count, so the first look does not swallow whatever was already on the clipboard when the app launched.
A note on where this runs: the tool is a main actor class, so the loop’s Task inherits the main actor. The project’s fact sheet describes the poll as running on a utility queue; the code does not do that. check() runs on the main actor, and the comparison is cheap enough for it, as the next section shows.
What changeCount counts
changeCount is documented as a number that changes when the pasteboard’s contents change. What counts as a change is the part to pin down. A small program was compiled with swiftc on 4 November 2026 on macOS 27.0.1 and run against a private named pasteboard, not the user’s clipboard:
fresh: 0
after clearContents: 1
after setString: 1
after second setString (no clear): 1
after clear + setString: 2
after reading: unchanged
Three facts fall out of it:
- The counter moves on
clearContents(), which starts a new ownership of the pasteboard. Writing a type after clearing did not move it again. - A second
setStringwithout a clear did not move it. A program that adds types to the same pasteboard contents is invisible to a watcher that only compares counts. - Reading the contents, including
typesandstring(forType:), does not move it. The watcher can look without disturbing anything.
The cost of looking at the number was measured too: 100,000 calls to NSPasteboard.general.changeCount in a loop, twice. The mean was 517 ns per call in the first run and 427 ns in the second. That is a standalone measurement of the call alone. It says nothing about the cost of the half second Task wake up around it, which was not measured, and it is not a claim about the app’s idle energy use.
The practical point stands without a benchmark: the loop does two integer comparisons a second and reads the clipboard contents only when the integer is new.
The check
check() is the whole policy. It is the function the tests call directly:
func check() {
let pb = pasteboard
guard pb.changeCount != lastChange else { return }
lastChange = pb.changeCount
let concealed = NSPasteboard.PasteboardType("org.nspasteboard.ConcealedType")
guard pb.types?.contains(concealed) != true else { return }
if let urls = pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL],
!urls.isEmpty {
urls.reversed().forEach { store.addFile($0) }
} else if let text = pb.string(forType: .string) {
record(text)
} else if let image = NSImage(pasteboard: pb), let clip = ClipboardImage.make(from: image) {
store.addImage(clip)
}
}
The order of the branches is the product decision. The comment above it says files win over text because Finder puts both a file and its name on the pasteboard, and text wins over images because spreadsheets put both. Without that order, copying a file in Finder would record the file name as text, and copying cells would record a picture of the cells.
Concealed types
The convention for secrets is a marker type. The site nspasteboard.org describes org.nspasteboard.ConcealedType as a marker whose presence indicates content that should be treated as confidential, and suggests it not be written to disk unless encrypted. Password managers that follow the convention add it next to the plain text.
The marker is visible in the type list. The same program wrote a string and the marker, and listed the types:
types with concealed marker: ["public.utf8-plain-text", "NSStringPboardType", "org.nspasteboard.ConcealedType"]
NotchStar updates lastChange first and then returns early, so a concealed item is neither recorded nor looked at again on the next tick. The test pins the behaviour:
func testSkipsConcealedPasteboardContent() {
pb.clearContents()
pb.setString("hunter2", forType: .string)
pb.setData(Data(), forType: NSPasteboard.PasteboardType("org.nspasteboard.ConcealedType"))
tool.check()
XCTAssertTrue(tool.store.items.isEmpty, "password-manager secrets must never be recorded")
}
What happens after the check: the store
The loop only decides what to hand over. ClipboardStore is a plain value type with the rules for what to keep, and it has no AppKit in it, so its tests are quick and need no pasteboard:
mutating func add(_ text: String, now: Date = .now) -> Bool {
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false }
items.removeAll { if case .text(let t) = $0.payload { return t == text } else { return false } }
insert(ClipboardItem(payload: .text(text), date: now))
return true
}
Blank text is ignored. A repeat of an earlier text removes the old row and inserts the new one at the front, so copying the same thing twice moves it up instead of listing it twice. Images are deduplicated by their hash and capped at 10, and the cap removes only older images, never text, which a test (testImageCapDoesNotEvictText) holds in place. The whole list is capped at 50, dropping the oldest. Search is case and diacritic insensitive, and typing “image” or a dimension such as “1200” finds images, because an image row has no text of its own.
The UI draws at most 30 rows of the results, in a lazy stack, so a full history of 50 does not build 50 rows.
Why polling at all
The code reads the integer on a timer because the NSPasteboard interface it uses exposes a changeCount to read and no notification to subscribe to. Polling is the usual answer to that shape of API. The consequence is the usual trade of polling: a delay of up to half a second before an item appears, and some work every half second for as long as the app runs, with the second part not measured here.
A tab switch does not change any of this. The loop runs whether or not the Clipboard tab is open, because the history has to be complete when the user opens it. That is different from the System tab, whose sampling runs only while its tab is open, and it is the reason the loop has to stay as small as it is. Pausing it while the notch is closed would save wake ups and would lose every copy made in that time.
The interval is a number somebody chose. Shorter would shrink the window in which two copies merge; longer would shave wake ups. Nothing in the repository records a measurement behind 0.5 seconds, and none is claimed.
Not recording its own writes
When a user clicks a history row, the tool writes the item back to the pasteboard. That is a change, and the next poll would see it and record it again. The code advances the baseline after its own write:
pasteboard.clearContents()
// ... write the item ...
lastChange = pasteboard.changeCount
The test for it is testCopyingAnItemBackWritesItToThePasteboardWithoutDuplicating. It copies “first” back, calls check(), and asserts the history is still ["first", "second"]. Separately, copy(_:) returns at once when the sample data mode is on, with the comment that demo clicks must never overwrite the real clipboard.
Testing without waiting
The polling loop is the one piece that is not tested. The logic is in check(), which is synchronous, and the tests call it on a pasteboard named app.notchstar.tests.<UUID> that is released at the end of each test:
override func setUp() async throws {
pb = NSPasteboard(name: NSPasteboard.Name("app.notchstar.tests.\(UUID().uuidString)"))
pb.clearContents()
tool = ClipboardTool(pasteboard: pb)
}
override func tearDown() async throws { pb.releaseGlobally() }
The six capture tests cover text, an image that is downscaled, file URLs winning over their names, the concealed marker, an unchanged pasteboard not being read twice, and the copy back case. The image test uses an explicit 2000 by 1000 pixel bitmap and asserts that the stored PNG is at most 640 pixels on its long edge, so it does not depend on the display scale.
The end to end UI tests use the same seam: with --ui-testing, the app builds its clipboard tool on a pasteboard named app.notchstar.uitesting, so the real clipboard is never read or changed. The drag detection uses the same integer trick on the drag pasteboard: mouseDown() stores NSPasteboard(name: .drag).changeCount as a baseline, and dragMoved() only considers a file drag if the count has moved since.
Numbers and method
| Measure | Value | Method |
|---|---|---|
| Poll interval | 0.5 s | Task.sleep(for: .milliseconds(500)) in ClipboardTool.swift |
| History limit | 50 items, 10 of them images | ClipboardStore(limit: 50) and imageLimit: Int = 10 |
| Stored image long edge | at most 640 px | maxEdge: CGFloat = 640, asserted by a test |
Cost of one changeCount read | 517 ns and 427 ns mean | 100,000 calls, standalone swiftc program, macOS 27.0.1, 4 November 2026 |
| Counter on a fresh named pasteboard | 0, then 1 after clearContents | same program |
Counter after a second setString without clearing | unchanged | same program |
| Capture tests | 6 | grep -c "func test" Tests/NotchStarTests/ClipboardCaptureTests.swift |
What is still bad
- Polling can miss changes. Two copies inside one half second move the counter by two, and the check runs once, so only the last copy is recorded.
- A program that adds a type to the pasteboard without clearing it does not move the counter in the test above, so such a change is never seen.
- Only the concealed marker is honoured. The same site describes a transient marker and an auto generated marker;
check()does not look for them. - Image identity is
png.hashValue. Swift randomises hashing per process, which is fine for an in memory history and would not be for anything stored. ClipboardImagerecords the original width and height but keeps the downscaled PNG, so the label shows the source size and not the stored size.- The history is in memory only, which is a privacy choice and also means a restart empties it.
- The polling loop itself has no test.
Try it yourself
- Make a named pasteboard in a scratch program:
NSPasteboard(name: NSPasteboard.Name("scratch")). - Print
changeCountafterclearContents, aftersetString, after a secondsetString, and after readingtypes. - Add
org.nspasteboard.ConcealedTypewithsetData(Data(), forType:)and printtypes. - Write a
check()that compares against a stored count, test it on that pasteboard, and never on.general. - Time 100,000 reads of
changeCounton your Mac and compare with the numbers above.
Cheat sheet
| Question | Answer |
|---|---|
| How to notice a change | Compare changeCount to the last seen value |
| What moves it | clearContents() in the test above, not each write |
| Does reading move it | No |
| How to skip secrets | Return early when types contains org.nspasteboard.ConcealedType |
| How to avoid recording your own write | Store changeCount right after you write |
| Which wins when several types are present | Files, then text, then images |
| How to test | Call a synchronous check() on a private named pasteboard |
Close
NotchStar is a Mac app that puts ten tools in the notch, and Clipboard is the tab built on this loop. If you want to try it, the details are at notchstar.com.
NotchStar is a native Mac notch app, $4 once, with a 7 day free trial. Try it, or read the ten tools.