# Clipboard history without a daemon: NSPasteboard changeCount and concealed types

> How a Mac clipboard history polls NSPasteboard changeCount, picks files over text over images, skips concealed types, and avoids recording its own writes.

Canonical: https://www.notchstar.com/blog/nspasteboard-changecount-clipboard-history-concealed/
Last updated: 2026-11-04

*Poll one integer twice a second, read the contents only when it moves, and skip what a password manager marks as secret.*

Published 2026-11-04.

## TL;DR

- A clipboard history needs to know when the clipboard changed. `NSPasteboard` exposes 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.ConcealedType` is 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 `changeCount` took about 430 to 520 nanoseconds per call in a standalone program, and the counter moves on `clearContents`, not on each `setString`. 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`:

```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:

```text
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:

1. The counter moves on `clearContents()`, which starts a new ownership of the pasteboard. Writing a type after clearing did not move it again.
2. A second `setString` without 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.
3. Reading the contents, including `types` and `string(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:

```swift
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:

```text
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:

```swift
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")
}
```

> **Lesson:** The marker is a courtesy between apps. It protects only against apps that set it. A secret copied from a web page or a terminal has no marker at all.
## 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:

```swift
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:

```swift
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:

```swift
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.
- `ClipboardImage` records 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

1. Make a named pasteboard in a scratch program: `NSPasteboard(name: NSPasteboard.Name("scratch"))`.
2. Print `changeCount` after `clearContents`, after `setString`, after a second `setString`, and after reading `types`.
3. Add `org.nspasteboard.ConcealedType` with `setData(Data(), forType:)` and print `types`.
4. Write a `check()` that compares against a stored count, test it on that pasteboard, and never on `.general`.
5. Time 100,000 reads of `changeCount` on 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](https://www.notchstar.com).

## More posts

- [An empty accessibility tree offscreen: registering control frames instead](https://www.notchstar.com/blog/macos-accessibility-tree-empty-register-frames/)
- [Finding new screenshots, and the macOS 27 custom folder bug](https://www.notchstar.com/blog/finding-new-screenshots-macos-27-custom-folder/)
