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 @@ -27,6 +27,7 @@
C0DE63600000000000000002 /* AudioEngineRetirementDrainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */; };
DA7100020000000000000002 /* DirectAudioReliabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7100010000000000000001 /* DirectAudioReliabilityTests.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 @@ -85,6 +86,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 @@ -169,6 +171,7 @@
C0DE63600000000000000001 /* AudioEngineRetirementDrainTests.swift */,
DA7100010000000000000001 /* DirectAudioReliabilityTests.swift */,
7CFA1D0B2F500000C0DEF002 /* TypingServiceTransientPasteboardTests.swift */,
7CFA1D0C2F510000C0DEF002 /* TypingServicePasteOnlyRoutingTests.swift */,
803000000000000000000001 /* MediaPlaybackServiceTests.swift */,
B51800000000000000000001 /* SpokenSendTests.swift */,
B51900000000000000000001 /* PrivateAIProviderPromptFormatTests.swift */,
Expand Down Expand Up @@ -346,6 +349,7 @@
C0DE63600000000000000002 /* AudioEngineRetirementDrainTests.swift in Sources */,
DA7100020000000000000002 /* DirectAudioReliabilityTests.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
106 changes: 77 additions & 29 deletions Sources/Fluid/Services/TypingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,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 @@ -303,36 +309,75 @@ 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)
)
}

/// Safari turns one synthesized unicode key event into a single `keypress` carrying only the
/// first character. Editors that build their text from `keypress`, which is what Google Docs and
/// Slides appear to do, therefore drop everything after it and need the clipboard path. The
/// title is looked up lazily so targets that match on bundle ID alone, and the far more common
/// targets that match nothing, never pay for an Accessibility round trip.
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 +356 to +358

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 @@ -553,14 +598,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 Expand Up @@ -874,11 +919,15 @@ final class TypingService {
releasesPasteboardSessionOnReturn = false
Self.pasteboardRestoreQueue.async {
defer { Self.pasteboardSessionSemaphore.signal() }
_ = self.waitForFocusedTextVerification(
let verificationStartedAt = ProcessInfo.processInfo.systemUptime
let verification = self.waitForFocusedTextVerification(
from: focusedTextSnapshot,
expectedText: text,
timeoutMicros: restoreDelayMicros
)
self.bench(
"paste_verification result=\(verification.rawValue) elapsedMs=\(Self.elapsedMs(since: verificationStartedAt)) chars=\(text.count)"
)
let pasteboard = NSPasteboard.general

// Avoid clobbering user clipboard changes that happened after our insertion.
Expand Down Expand Up @@ -910,7 +959,7 @@ final class TypingService {
}
self.bench("paste_target_prepared elapsedMs=\(Self.elapsedMs(since: targetStartedAt))")

return self.withTemporaryPasteboardString(text, restoreDelayMicros: 5_000_000) {
return self.withTemporaryPasteboardString(text, restoreDelayMicros: 1_500_000) {

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 Badge Keep the temporary clipboard alive for delayed paste events

When the target app's event loop takes more than 1.5 seconds to handle the posted Cmd+V and the focused-text snapshot cannot verify completion—as is common for web editors—postToPid has already returned successfully but cleanup restores the previous clipboard before the app reads it. This can paste the user's old clipboard contents instead of the transcript; the previous five-second grace period should not be shortened for unverifiable asynchronous paste delivery.

Useful? React with 👍 / 👎.

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 Clipboard Window Shortened Globally

This Safari-specific fix also reduces the clipboard restoration window from five seconds to 1.5 seconds for every reliable, fallback, and AppleScript paste target, including the same changes at lines 1087 and 1127. If a slow destination has not consumed the transient paste when verification times out, the previous clipboard contents are restored and no dictated text may be inserted even though dispatch was reported as successful. This violates the repository requirement that Swift changes stay within the stated scope and avoid risking existing features, so the requirement must be satisfied before merging. Retain the established timeout or validate and test the shorter window independently.

Rule Used: What: Ensure macOS Swift PR changes match the stated scope, don’t introduce unrelated UI/UX/theming work, and don’t risk breaking existing features. Why: Keeps reviews focused, prevents scope creep (especially UI/UX), and avoids regressions or inc... (source)

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: 962

Comment:
**Clipboard Window Shortened Globally**

This Safari-specific fix also reduces the clipboard restoration window from five seconds to 1.5 seconds for every reliable, fallback, and AppleScript paste target, including the same changes at lines 1087 and 1127. If a slow destination has not consumed the transient paste when verification times out, the previous clipboard contents are restored and no dictated text may be inserted even though dispatch was reported as successful. This violates the repository requirement that Swift changes stay within the stated scope and avoid risking existing features, so the requirement must be satisfied before merging. Retain the established timeout or validate and test the shorter window independently.

**Rule Used:** What: Ensure macOS Swift PR changes match the stated scope, don’t introduce unrelated UI/UX/theming work, and don’t risk breaking existing features.    Why: Keeps reviews focused, prevents scope creep (especially UI/UX), and avoids regressions or inc... ([source](https://app.greptile.com/altic/-/custom-context?memory=c54a31bd-761f-45ed-8fcb-a3cb1158d02e))

**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

let dispatchStartedAt = ProcessInfo.processInfo.systemUptime
let vKey = Self.pasteVirtualKeyCode
let keyResolvedAt = ProcessInfo.processInfo.systemUptime
Expand Down Expand Up @@ -1035,7 +1084,7 @@ final class TypingService {
/// More reliable but slightly slower - copies text to clipboard then pastes
private func insertTextViaClipboard(_ text: String) -> Bool {
self.log("[TypingService] Starting clipboard-based insertion")
return self.withTemporaryPasteboardString(text, restoreDelayMicros: 5_000_000) {
return self.withTemporaryPasteboardString(text, restoreDelayMicros: 1_500_000) {
let dispatchStartedAt = ProcessInfo.processInfo.systemUptime
let vKey = Self.pasteVirtualKeyCode
let keyResolvedAt = ProcessInfo.processInfo.systemUptime
Expand Down Expand Up @@ -1075,7 +1124,7 @@ final class TypingService {
return false
}

return self.withTemporaryPasteboardString(text, restoreDelayMicros: 5_000_000) {
return self.withTemporaryPasteboardString(text, restoreDelayMicros: 1_500_000) {
let escapedAppName = appName.replacingOccurrences(of: "\"", with: "\\\"")
let script = """
tell application "System Events"
Expand Down Expand Up @@ -1348,11 +1397,10 @@ final class TypingService {
let pollMicros: useconds_t = 50_000
let expectedLength = max(1, (expectedText as NSString).length)
let tolerance = max(2, expectedLength / 5)
var waited: useconds_t = 0
let deadline = ProcessInfo.processInfo.systemUptime + Double(timeoutMicros) / 1_000_000

while waited < timeoutMicros {
while ProcessInfo.processInfo.systemUptime < deadline {
usleep(pollMicros)
waited += pollMicros

guard let current = self.captureFocusedTextSnapshot(),
current.pid == snapshot.pid
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