From fffa7a41770ee7b3dda72a0e136d951789a859af Mon Sep 17 00:00:00 2001 From: theluckystrike <51033404+theluckystrike@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:45:33 +0100 Subject: [PATCH] Mark clipboard writes so history apps skip them The paste path writes the transcript to the clipboard and restores the old contents afterwards. Neither write was marked, so clipboard history apps recorded every dictation and then added the old clipboard again as a new entry. The transcript written for a paste now carries the nspasteboard.org TransientType and AutoGeneratedType markers, the restore carries TransientType, and every write sets org.nspasteboard.source to the app's bundle identifier. The manual paste fallback is left unmarked. SystemClipboard takes an optional pasteboard name (default .general) so the new tests run on a private pasteboard. Fixes #310 --- .../Injection/SystemClipboard.swift | 43 +++++++++-- .../SystemClipboardMarkerTests.swift | 72 +++++++++++++++++++ 2 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 Tests/BlurtEngineTests/SystemClipboardMarkerTests.swift diff --git a/Sources/BlurtEngine/Injection/SystemClipboard.swift b/Sources/BlurtEngine/Injection/SystemClipboard.swift index fb390b97..ff9b367c 100644 --- a/Sources/BlurtEngine/Injection/SystemClipboard.swift +++ b/Sources/BlurtEngine/Injection/SystemClipboard.swift @@ -55,11 +55,27 @@ /// comparison that gates the deferred restore lives here, behind the seam, so a /// fake never re-implements it. struct SystemClipboard: ClipboardAccess { + /// The pasteboard to use. Always `.general` in the app; tests pass a private, + /// uniquely named pasteboard so they never touch the user's clipboard. + var pasteboardName: NSPasteboard.Name = .general + + private var pasteboard: NSPasteboard { NSPasteboard(name: pasteboardName) } + + /// Markers from the nspasteboard.org conventions that clipboard history apps + /// (Raycast, Maccy, Alfred, Paste ...) read. A write that carries `transient` + /// is not recorded; `source` names the app that wrote the data, instead of the + /// app that happens to be in front. + static let transientType = NSPasteboard.PasteboardType("org.nspasteboard.TransientType") + static let autoGeneratedType = NSPasteboard.PasteboardType("org.nspasteboard.AutoGeneratedType") + static let sourceType = NSPasteboard.PasteboardType("org.nspasteboard.source") + func write(_ text: String) { setString(text) } func writeAndPrepareRestore(_ text: String) -> @Sendable () -> Void { let saved = snapshot() - setString(text) + // The transcript sits on the clipboard only for the paste, so history apps + // should not keep it (it may also be text dictated into a password field). + setString(text, transient: true) // Snapshot the change count our own write produced. If anything else writes // to the pasteboard before the restore fires (e.g. the user copies // something), the count moves and the restore leaves their newer contents @@ -85,7 +101,7 @@ // MARK: - NSPasteboard building blocks (also exercised directly by SystemClipboardTests) - var changeCount: Int { NSPasteboard.general.changeCount } + var changeCount: Int { pasteboard.changeCount } /// Counts the writes Blurt itself makes to the pasteboard, bumped *before* /// each one, so a reader that sees the pasteboard's change count move can @@ -101,7 +117,6 @@ /// (`pasteboardItems` is documented to return nil on error). Callers must treat /// nil as "don't restore" — never as an empty clipboard. func snapshot() -> PasteboardSnapshot? { - let pasteboard = NSPasteboard.general guard let items = pasteboard.pasteboardItems else { return nil } let captured = items.map { item in var dataMap: [NSPasteboard.PasteboardType: Data] = [:] @@ -117,16 +132,28 @@ return PasteboardSnapshot(items: captured, plainText: pasteboard.string(forType: .string)) } - func setString(_ text: String) { + /// Writes `text` as the only item. `transient` marks it so clipboard history + /// apps skip it; the source marker is set on every write Blurt makes. + func setString(_ text: String, transient: Bool = false) { noteOwnWrite() - let pasteboard = NSPasteboard.general + let pasteboard = pasteboard pasteboard.clearContents() pasteboard.setString(text, forType: .string) + if transient { + pasteboard.setData(Data(), forType: Self.transientType) + pasteboard.setData(Data(), forType: Self.autoGeneratedType) + } + if let bundleID = Bundle.main.bundleIdentifier { + pasteboard.setString(bundleID, forType: Self.sourceType) + } } + /// Puts `saved` back. The restore is marked transient: the user's contents are + /// already in any clipboard history, so recording them again would only add a + /// duplicate entry on top after every dictation. func restore(_ saved: PasteboardSnapshot) { noteOwnWrite() - let pasteboard = NSPasteboard.general + let pasteboard = pasteboard // The pasteboard genuinely held nothing, so restoring it means emptying it. // Safe to clear because the snapshot succeeded — a *failed* read is nil and @@ -147,6 +174,7 @@ } return pasteboardItem } + rebuilt.first?.setData(Data(), forType: Self.transientType) if !rebuilt.isEmpty { pasteboard.clearContents() @@ -170,12 +198,13 @@ if let plainText = saved.plainText { pasteboard.clearContents() pasteboard.setString(plainText, forType: .string) + pasteboard.setData(Data(), forType: Self.transientType) } } } /// The read-aloud copy fallback's view of the pasteboard (see `SelectionCopy`). extension SystemClipboard: CopyPasteboard { - func currentString() -> String? { NSPasteboard.general.string(forType: .string) } + func currentString() -> String? { pasteboard.string(forType: .string) } } #endif diff --git a/Tests/BlurtEngineTests/SystemClipboardMarkerTests.swift b/Tests/BlurtEngineTests/SystemClipboardMarkerTests.swift new file mode 100644 index 00000000..5d7eb81d --- /dev/null +++ b/Tests/BlurtEngineTests/SystemClipboardMarkerTests.swift @@ -0,0 +1,72 @@ +#if os(macOS) + import AppKit + import Foundation + import Testing + + @testable import BlurtEngine + + /// Issue 310: clipboard history apps saved every dictation, and the restore added the + /// user's old clipboard again as a new entry. Blurt's own writes now carry the + /// nspasteboard.org transient marker. These tests use a private, uniquely named + /// pasteboard, so they never read or change the user's clipboard. + @Suite("SystemClipboard history markers", .serialized) + struct SystemClipboardMarkerTests { + + private func withPrivatePasteboard(_ body: (SystemClipboard, NSPasteboard) throws -> Void) rethrows { + let name = NSPasteboard.Name("app.blurt.tests.\(UUID().uuidString)") + let pb = NSPasteboard(name: name) + defer { pb.releaseGlobally() } + try body(SystemClipboard(pasteboardName: name), pb) + } + + @Test("the transcript written for a paste is marked transient") + func transcriptIsTransient() { + withPrivatePasteboard { clip, pb in + pb.clearContents() + pb.setString("original", forType: .string) + + _ = clip.writeAndPrepareRestore("dictated text") + + #expect(pb.string(forType: .string) == "dictated text") + let types = pb.types ?? [] + #expect(types.contains(SystemClipboard.transientType)) + #expect(types.contains(SystemClipboard.autoGeneratedType)) + } + } + + @Test("the restore brings the old contents back, marked transient") + func restoreIsTransient() { + withPrivatePasteboard { clip, pb in + pb.clearContents() + pb.setString("original", forType: .string) + + let restore = clip.writeAndPrepareRestore("dictated text") + restore() + + #expect(pb.string(forType: .string) == "original") + #expect((pb.types ?? []).contains(SystemClipboard.transientType)) + } + } + + @Test("a transcript left for a manual paste stays in history") + func manualPasteFallbackIsNotTransient() { + withPrivatePasteboard { clip, pb in + clip.write("paste me") + #expect(pb.string(forType: .string) == "paste me") + #expect(!(pb.types ?? []).contains(SystemClipboard.transientType)) + } + } + + @Test("Blurt names itself as the source when it has a bundle identifier") + func sourceMarker() { + withPrivatePasteboard { clip, pb in + clip.write("paste me") + if let bundleID = Bundle.main.bundleIdentifier { + #expect(pb.string(forType: SystemClipboard.sourceType) == bundleID) + } else { + #expect(pb.string(forType: SystemClipboard.sourceType) == nil) + } + } + } + } +#endif