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