diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index 0725e76d..d5404a49 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */; }; 7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */; }; 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */; }; + 7CFA1D0B2F500000C0DEF001 /* TypingServiceTransientPasteboardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CFA1D0B2F500000C0DEF002 /* TypingServiceTransientPasteboardTests.swift */; }; 7CDB0A2F2F3C4D5600FB7CAD /* dictation_fixture.wav in Resources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */; }; 7CDB0A302F3C4D5600FB7CAD /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */; }; 7CE006BD2E80EBE600DDCCD6 /* AppUpdater in Frameworks */ = {isa = PBXBuildFile; productRef = 7CE006BC2E80EBE600DDCCD6 /* AppUpdater */; }; @@ -36,6 +37,7 @@ 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyShortcutTests.swift; sourceTree = ""; }; 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = ""; }; 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMClientRequestBodyTests.swift; sourceTree = ""; }; + 7CFA1D0B2F500000C0DEF002 /* TypingServiceTransientPasteboardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingServiceTransientPasteboardTests.swift; sourceTree = ""; }; 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFixtureLoader.swift; sourceTree = ""; }; 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = dictation_fixture.wav; sourceTree = ""; }; 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; @@ -107,6 +109,7 @@ 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */, 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */, 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */, + 7CFA1D0B2F500000C0DEF002 /* TypingServiceTransientPasteboardTests.swift */, ); path = FluidDictationIntegrationTests; sourceTree = ""; @@ -262,6 +265,7 @@ 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */, 7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */, 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */, + 7CFA1D0B2F500000C0DEF001 /* TypingServiceTransientPasteboardTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/Fluid/Services/TypingService.swift b/Sources/Fluid/Services/TypingService.swift index c52c4de6..9202d849 100644 --- a/Sources/Fluid/Services/TypingService.swift +++ b/Sources/Fluid/Services/TypingService.swift @@ -570,6 +570,18 @@ final class TypingService { _ = pasteboard.writeObjects(restoredItems) } + /// Builds the pasteboard item for a temporary paste write, tagged with the nspasteboard.org + /// Transient and AutoGenerated marker types so clipboard managers exclude it from history. + /// `ConcealedType` is deliberately not used: it signals sensitive/password content, which + /// would be misleading for a dictation transcript. + static func makeTransientPasteboardItem(_ text: String) -> NSPasteboardItem { + let item = NSPasteboardItem() + item.setString(text, forType: .string) + item.setData(Data(), forType: NSPasteboard.PasteboardType("org.nspasteboard.TransientType")) + item.setData(Data(), forType: NSPasteboard.PasteboardType("org.nspasteboard.AutoGeneratedType")) + return item + } + private func withTemporaryPasteboardString( _ text: String, restoreDelayMicros: useconds_t, @@ -587,7 +599,10 @@ final class TypingService { let snapshot = self.capturePasteboardSnapshot(pasteboard) pasteboard.clearContents() - guard pasteboard.setString(text, forType: .string) else { + // Mark this temporary write so clipboard managers (Maccy, Raycast, Paste, etc.) skip it. + // The string is written only to drive a synthetic Cmd+V and the previous clipboard is + // restored immediately afterward, so it is ephemeral, not something the user copied. + guard pasteboard.writeObjects([Self.makeTransientPasteboardItem(text)]) else { self.log("[TypingService] ERROR: Failed to set temporary clipboard string") self.restorePasteboardSnapshot(snapshot, to: pasteboard) return false diff --git a/Tests/FluidDictationIntegrationTests/TypingServiceTransientPasteboardTests.swift b/Tests/FluidDictationIntegrationTests/TypingServiceTransientPasteboardTests.swift new file mode 100644 index 00000000..1fb1374a --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/TypingServiceTransientPasteboardTests.swift @@ -0,0 +1,51 @@ +import AppKit +@testable import FluidVoice_Debug +import XCTest + +// The temporary clipboard write used to drive synthetic Cmd+V must be tagged so clipboard +// managers exclude it from history. It is restored immediately and is not user-copied content. +final class TypingServiceTransientPasteboardTests: XCTestCase { + + func testTransientPasteboardItem_carriesText() { + let item = TypingService.makeTransientPasteboardItem("hello world") + XCTAssertEqual( + item.string(forType: .string), + "hello world", + "the plain string must survive so paste and clipboard restore behave unchanged" + ) + } + + func testTransientPasteboardItem_isMarkedForClipboardManagers() { + let item = TypingService.makeTransientPasteboardItem("hello world") + let transientType = NSPasteboard.PasteboardType("org.nspasteboard.TransientType") + let autoGeneratedType = NSPasteboard.PasteboardType("org.nspasteboard.AutoGeneratedType") + let types = item.types.map(\.rawValue) + XCTAssertTrue( + types.contains(transientType.rawValue), + "temporary write must be marked Transient so clipboard managers skip it" + ) + XCTAssertTrue( + types.contains(autoGeneratedType.rawValue), + "temporary write must be marked AutoGenerated so clipboard managers skip it" + ) + XCTAssertEqual( + item.data(forType: transientType)?.count, + 0, + "TransientType should be a marker-only zero-length payload" + ) + XCTAssertEqual( + item.data(forType: autoGeneratedType)?.count, + 0, + "AutoGeneratedType should be a marker-only zero-length payload" + ) + } + + func testTransientPasteboardItem_isNotMarkedConcealed() { + let item = TypingService.makeTransientPasteboardItem("hello world") + let types = item.types.map(\.rawValue) + XCTAssertFalse( + types.contains("org.nspasteboard.ConcealedType"), + "ConcealedType signals sensitive content and must not be applied to a transcript" + ) + } +}