Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 36 additions & 7 deletions Sources/BlurtEngine/Injection/SystemClipboard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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] = [:]
Expand All @@ -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
Expand All @@ -147,6 +174,7 @@
}
return pasteboardItem
}
rebuilt.first?.setData(Data(), forType: Self.transientType)

if !rebuilt.isEmpty {
pasteboard.clearContents()
Expand All @@ -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
72 changes: 72 additions & 0 deletions Tests/BlurtEngineTests/SystemClipboardMarkerTests.swift
Original file line number Diff line number Diff line change
@@ -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