diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index 0fffbeeaf..bb5b5824e 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ DA7100020000000000000002 /* DirectAudioReliabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7100010000000000000001 /* DirectAudioReliabilityTests.swift */; }; DA7200020000000000000002 /* AudioHardwareRecoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7200010000000000000001 /* AudioHardwareRecoveryTests.swift */; }; 7CFA1D0B2F500000C0DEF001 /* TypingServiceTransientPasteboardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CFA1D0B2F500000C0DEF002 /* TypingServiceTransientPasteboardTests.swift */; }; + 7CFA1D0C2F510000C0DEF001 /* TypingServicePasteOnlyRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CFA1D0C2F510000C0DEF002 /* TypingServicePasteOnlyRoutingTests.swift */; }; 803000000000000000000002 /* MediaPlaybackServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803000000000000000000001 /* MediaPlaybackServiceTests.swift */; }; 0B05F11E000000000000A002 /* SupportedFileExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B05F11E000000000000A001 /* SupportedFileExtensionsTests.swift */; }; 803000000000000000000003 /* MediaRemoteAdapter in Frameworks */ = {isa = PBXBuildFile; productRef = 803000000000000000000004 /* MediaRemoteAdapter */; }; @@ -87,6 +88,7 @@ 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = ""; }; CD1C7A0000000000000000B1 /* CustomDictionaryManualEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDictionaryManualEntryTests.swift; sourceTree = ""; }; 7CFA1D0B2F500000C0DEF002 /* TypingServiceTransientPasteboardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingServiceTransientPasteboardTests.swift; sourceTree = ""; }; + 7CFA1D0C2F510000C0DEF002 /* TypingServicePasteOnlyRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingServicePasteOnlyRoutingTests.swift; sourceTree = ""; }; 803000000000000000000001 /* MediaPlaybackServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlaybackServiceTests.swift; sourceTree = ""; }; 0B05F11E000000000000A001 /* SupportedFileExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedFileExtensionsTests.swift; sourceTree = ""; }; 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFixtureLoader.swift; sourceTree = ""; }; @@ -172,6 +174,7 @@ DA7100010000000000000001 /* DirectAudioReliabilityTests.swift */, DA7200010000000000000001 /* AudioHardwareRecoveryTests.swift */, 7CFA1D0B2F500000C0DEF002 /* TypingServiceTransientPasteboardTests.swift */, + 7CFA1D0C2F510000C0DEF002 /* TypingServicePasteOnlyRoutingTests.swift */, 803000000000000000000001 /* MediaPlaybackServiceTests.swift */, B51800000000000000000001 /* SpokenSendTests.swift */, B51900000000000000000001 /* PrivateAIProviderPromptFormatTests.swift */, @@ -350,6 +353,7 @@ DA7100020000000000000002 /* DirectAudioReliabilityTests.swift in Sources */, DA7200020000000000000002 /* AudioHardwareRecoveryTests.swift in Sources */, 7CFA1D0B2F500000C0DEF001 /* TypingServiceTransientPasteboardTests.swift in Sources */, + 7CFA1D0C2F510000C0DEF001 /* TypingServicePasteOnlyRoutingTests.swift in Sources */, 803000000000000000000002 /* MediaPlaybackServiceTests.swift in Sources */, B51800000000000000000002 /* SpokenSendTests.swift in Sources */, B51900000000000000000002 /* PrivateAIProviderPromptFormatTests.swift in Sources */, diff --git a/Sources/Fluid/Services/TypingService.swift b/Sources/Fluid/Services/TypingService.swift index 28478c7e3..57160b4d6 100644 --- a/Sources/Fluid/Services/TypingService.swift +++ b/Sources/Fluid/Services/TypingService.swift @@ -106,7 +106,13 @@ final class TypingService { private static let pasteboardSessionSemaphore = DispatchSemaphore(value: 1) private static let pasteboardRestoreQueue = DispatchQueue(label: "TypingService.PasteboardRestore", qos: .utility) private static var focusSnapshot: FocusSnapshot? - private static let ghosttyBundleIdentifier = "com.mitchellh.ghostty" + + private static let pasteOnlyBundleIdentifiers: Set = ["com.mitchellh.ghostty"] + private static let webKitBrowserBundleIdentifiers: Set = [ + "com.apple.Safari", "com.apple.SafariTechnologyPreview", + ] + private static let keypressDrivenEditorTitles = ["Google Docs", "Google Slides"] + private static let axMessagingTimeoutSeconds: Float = 0.25 private var textInsertionMode: SettingsStore.TextInsertionMode { SettingsStore.shared.textInsertionMode @@ -286,36 +292,70 @@ final class TypingService { return Self.isCurrentlyFocusedElement(element, expectedPID: pid) } - private func isGhosttyApplication(pid: pid_t) -> Bool { - guard pid > 0, - let app = NSRunningApplication(processIdentifier: pid) - else { - return false - } - - return app.bundleIdentifier == Self.ghosttyBundleIdentifier - } - - private func ghosttyTargetPID(preferredTargetPID: pid_t?) -> pid_t? { + private func pasteOnlyTarget(preferredTargetPID: pid_t?) -> (pid: pid_t, reason: String)? { if let preferredTargetPID, preferredTargetPID > 0 { - return self.isGhosttyApplication(pid: preferredTargetPID) ? preferredTargetPID : nil + guard let reason = self.pasteOnlyReason(forPID: preferredTargetPID) else { return nil } + return (preferredTargetPID, reason) } if let focusedPID = self.getSystemFocusedElementAndPID()?.pid, - self.isGhosttyApplication(pid: focusedPID) + let reason = self.pasteOnlyReason(forPID: focusedPID) { - return focusedPID + return (focusedPID, reason) } if let frontmostPID = NSWorkspace.shared.frontmostApplication?.processIdentifier, - self.isGhosttyApplication(pid: frontmostPID) + let reason = self.pasteOnlyReason(forPID: frontmostPID) { - return frontmostPID + return (frontmostPID, reason) } return nil } + private func pasteOnlyReason(forPID pid: pid_t) -> String? { + guard pid > 0, let app = NSRunningApplication(processIdentifier: pid) else { return nil } + return Self.pasteOnlyReason( + bundleIdentifier: app.bundleIdentifier, + focusedWindowTitle: Self.focusedWindowTitle(forPID: pid) + ) + } + + static func pasteOnlyReason( + bundleIdentifier: String?, + focusedWindowTitle: @autoclosure () -> String? + ) -> String? { + guard let bundleIdentifier else { return nil } + + if Self.pasteOnlyBundleIdentifiers.contains(bundleIdentifier) { + return "bundleID=\(bundleIdentifier)" + } + + guard Self.webKitBrowserBundleIdentifiers.contains(bundleIdentifier), + let title = focusedWindowTitle(), + let editor = Self.keypressDrivenEditorTitles.first(where: { title.hasSuffix($0) }) + else { + return nil + } + return "document=\(editor)" + } + + private static func focusedWindowTitle(forPID pid: pid_t) -> String? { + guard AXIsProcessTrusted(), pid > 0 else { return nil } + + let appElement = AXUIElementCreateApplication(pid) + AXUIElementSetMessagingTimeout(appElement, Self.axMessagingTimeoutSeconds) + + guard let window = Self.copyAXElementAttribute(from: appElement, attribute: kAXFocusedWindowAttribute as CFString) + ?? Self.copyAXElementAttribute(from: appElement, attribute: kAXMainWindowAttribute as CFString) + else { + return nil + } + + AXUIElementSetMessagingTimeout(window, Self.axMessagingTimeoutSeconds) + return Self.stringAXAttribute(from: window, attribute: kAXTitleAttribute as CFString) + } + /// Activation options used to restore focus to the external target app after dictation. /// `.activateAllWindows` is intentionally omitted: raising every window of a multi-window /// app (e.g. WebStorm) destroys the user's window layout on each dictation (issue #748). @@ -536,14 +576,14 @@ final class TypingService { self.log("[TypingService] Attempting to type text: \"\(text.prefix(50))\(text.count > 50 ? "..." : "")\"") if self.textInsertionMode == .standard, - let ghosttyTargetPID = self.ghosttyTargetPID(preferredTargetPID: preferredTargetPID) + let pasteOnlyTarget = self.pasteOnlyTarget(preferredTargetPID: preferredTargetPID) { - self.log("[TypingService] Ghostty target detected in standard mode (PID \(ghosttyTargetPID)); forcing Reliable Paste path") - if self.tryReliablePasteInsertion(text, preferredTargetPID: ghosttyTargetPID) { - self.log("[TypingService] SUCCESS: Ghostty Reliable Paste path completed") + self.log("[TypingService] Paste-only target detected in standard mode (PID \(pasteOnlyTarget.pid), \(pasteOnlyTarget.reason)); forcing Reliable Paste path") + if self.tryReliablePasteInsertion(text, preferredTargetPID: pasteOnlyTarget.pid) { + self.log("[TypingService] SUCCESS: Paste-only Reliable Paste path completed") return true } - self.log("[TypingService] Ghostty Reliable Paste path fell through to direct-typing fallbacks") + self.log("[TypingService] Paste-only Reliable Paste path fell through to direct-typing fallbacks") } if self.textInsertionMode == .reliablePaste { diff --git a/Tests/FluidDictationIntegrationTests/TypingServicePasteOnlyRoutingTests.swift b/Tests/FluidDictationIntegrationTests/TypingServicePasteOnlyRoutingTests.swift new file mode 100644 index 000000000..bd44e98e2 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/TypingServicePasteOnlyRoutingTests.swift @@ -0,0 +1,93 @@ +import AppKit +@testable import FluidVoice_Debug +import XCTest + +// Safari turns one synthesized unicode key event into a single `keypress` carrying only the first +// character, so editors that build their text from `keypress` insert "H" for "Hello world". Only +// Google Docs and Slides in a WebKit browser lose text this way, and only those targets may be +// pushed off the direct-typing path onto the clipboard. + +final class TypingServicePasteOnlyRoutingTests: XCTestCase { + private func reason(_ bundleIdentifier: String?, _ title: String?) -> String? { + TypingService.pasteOnlyReason(bundleIdentifier: bundleIdentifier, focusedWindowTitle: title) + } + + func testKnownBundleIdentifierMatchesWithoutAWindowTitle() { + XCTAssertEqual( + self.reason("com.mitchellh.ghostty", nil), + "bundleID=com.mitchellh.ghostty", + "apps that never accept synthesized typing must match on bundle ID alone, before any AX lookup" + ) + } + + func testTheWindowTitleIsOnlyReadWhenTheBundleIdentifierCouldMatch() { + var lookups = 0 + func reasonCountingLookups(_ bundleIdentifier: String?) -> String? { + TypingService.pasteOnlyReason( + bundleIdentifier: bundleIdentifier, + focusedWindowTitle: { + lookups += 1 + return "Quarterly notes - Google Docs" + }() + ) + } + + _ = reasonCountingLookups("com.apple.Notes") + XCTAssertEqual(lookups, 0, "a native app must not trigger an Accessibility round trip") + + _ = reasonCountingLookups("com.mitchellh.ghostty") + XCTAssertEqual(lookups, 0, "a bundle ID match resolves before the title is ever needed") + + _ = reasonCountingLookups("com.apple.Safari") + XCTAssertEqual(lookups, 1, "only a WebKit browser reads the focused window title") + } + + func testSafariMatchesGoogleDocsAndSlides() { + XCTAssertEqual( + self.reason("com.apple.Safari", "Quarterly notes - Google Docs"), + "document=Google Docs", + "Docs in Safari drops everything after the first character and needs the clipboard path" + ) + XCTAssertEqual( + self.reason("com.apple.Safari", "Launch deck - Google Slides"), + "document=Google Slides", + "Slides truncates the same way Docs does" + ) + } + + func testGoogleSheetsKeepsTheDirectTypingPath() { + XCTAssertNil( + self.reason("com.apple.Safari", "Budget - Google Sheets"), + "Sheets inserts the full string in Safari, so forcing it onto the clipboard would be a regression" + ) + } + + func testChromiumAndGeckoKeepTheDirectTypingPath() { + XCTAssertNil( + self.reason("com.google.Chrome", "Quarterly notes - Google Docs"), + "Chromium fires no keypress and inserts the full string" + ) + XCTAssertNil( + self.reason("org.mozilla.firefox", "Quarterly notes - Google Docs"), + "Gecko fires one keypress per character and inserts the full string" + ) + } + + func testOrdinaryBrowsingDoesNotMatch() { + XCTAssertNil( + self.reason("com.apple.Safari", "Apple"), + "a WebKit browser alone is not enough; only the affected documents may be rerouted" + ) + } + + func testNativeAppsAreNeverForcedOntoTheClipboard() { + XCTAssertNil( + self.reason("com.apple.Notes", "Quarterly notes - Google Docs"), + "a matching window title in a native app must not trigger the browser rule" + ) + XCTAssertNil( + self.reason(nil, nil), + "an unidentifiable target keeps the default path" + ) + } +}