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
4 changes: 4 additions & 0 deletions Fluid.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -87,6 +88,7 @@
7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = "<group>"; };
CD1C7A0000000000000000B1 /* CustomDictionaryManualEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDictionaryManualEntryTests.swift; sourceTree = "<group>"; };
7CFA1D0B2F500000C0DEF002 /* TypingServiceTransientPasteboardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingServiceTransientPasteboardTests.swift; sourceTree = "<group>"; };
7CFA1D0C2F510000C0DEF002 /* TypingServicePasteOnlyRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingServicePasteOnlyRoutingTests.swift; sourceTree = "<group>"; };
803000000000000000000001 /* MediaPlaybackServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlaybackServiceTests.swift; sourceTree = "<group>"; };
0B05F11E000000000000A001 /* SupportedFileExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedFileExtensionsTests.swift; sourceTree = "<group>"; };
7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFixtureLoader.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
84 changes: 62 additions & 22 deletions Sources/Fluid/Services/TypingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = ["com.mitchellh.ghostty"]
private static let webKitBrowserBundleIdentifiers: Set<String> = [
"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
Expand Down Expand Up @@ -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) })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle localized Google editor window titles

When the Google account/UI language localizes the product name (for example, Safari exposes a title ending in the localized equivalent of Google Docs), this exact English suffix check returns nil, so the affected editor stays on the direct-typing path and continues dropping all but the first character. The routing signal needs to be independent of the localized window title, or explicitly support localized variants.

Useful? React with 👍 / 👎.

Comment on lines +334 to +336

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 security Page Title Controls Clipboard

The routing predicate treats Safari’s page-controlled window title as proof that the destination is Google Docs or Slides. Any ordinary page whose title ends with one of those strings can therefore move otherwise directly typed dictation through NSPasteboard.general, where observers that do not honor the advisory transient markers can read or retain it. Verify the Google origin or another non-spoofable document identity before selecting the clipboard path.

How this was verified: Safari’s page-derived accessibility title is matched by suffix alone, after which the dictated string is written to the general pasteboard with only advisory markers.

Knowledge Base Used: Dictation processing and typing

Prompt To Fix With AI
This is a comment left during a code review.
Path: Sources/Fluid/Services/TypingService.swift
Line: 356-358

Comment:
**Page Title Controls Clipboard**

The routing predicate treats Safari’s page-controlled window title as proof that the destination is Google Docs or Slides. Any ordinary page whose title ends with one of those strings can therefore move otherwise directly typed dictation through `NSPasteboard.general`, where observers that do not honor the advisory transient markers can read or retain it. Verify the Google origin or another non-spoofable document identity before selecting the clipboard path.

**How this was verified:** Safari’s page-derived accessibility title is matched by suffix alone, after which the dictated string is written to the general pasteboard with only advisory markers.

**Knowledge Base Used:** [Dictation processing and typing](https://app.greptile.com/altic/-/custom-context/knowledge-base/altic-dev/fluidvoice/-/docs/dictation-processing-and-typing.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex

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).
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
)
}
}
Loading