Skip to content

fix(typing): make dictation work in Windows App / Microsoft Remote Desktop - #951

Open
ArielTM wants to merge 14 commits into
altic-dev:mainfrom
ArielTM:fix/windows-app-remote-desktop-paste
Open

fix(typing): make dictation work in Windows App / Microsoft Remote Desktop#951
ArielTM wants to merge 14 commits into
altic-dev:mainfrom
ArielTM:fix/windows-app-remote-desktop-paste

Conversation

@ArielTM

@ArielTM ArielTM commented Sep 8, 2026

Copy link
Copy Markdown

Description

Dictation never reached a remote-desktop session. Dictating into Windows App /
Microsoft Remote Desktop (com.microsoft.rdc.macos) produced the transcript in the overlay and
in History, but nothing landed in the remote window, in both text insertion modes.

Two independent causes, both measured against a live session rather than inferred:

1. Clipboard redirection cannot be driven programmatically. These clients only re-advertise
their clipboard to the guest after a focus change. Four pastes over 43 seconds, across four
distinct pasteboard writes 8 seconds apart, all delivered the first value; plain and
transient pasteboard items behaved identically and a 2.5 s settle changed nothing. Manual
copy/paste works only because copying means switching apps. So no settle delay can make a
paste-based approach reliable here, and forcing a real focus bounce visibly steals focus and
takes over a second to settle.

2. The dictation hotkey leaves the guest in Windows menu mode. An Option-based hotkey looks
to the guest like a bare Alt tap — the accompanying key is swallowed as the hotkey, so the
client forwards Alt down then Alt up with nothing between, and a bare Alt tap activates the
focused window's menu bar. The first character is then consumed as a menu accelerator, and when
it happens to match one it is worse than a lost character: E opened Notepad's Edit menu and
the rest of the text was consumed navigating it.

The fix routes this target to typing the transcript as real key presses, after resetting the
guest's keyboard state:

  • Detect the target (mirrors the existing Ghostty per-app override in TypingService).
  • Post a release for every modifier, then Escape to exit menu mode, then a short warm-up.
  • Build a character → (key code, shift) map from the active layout via UCKeyTranslate and type
    real key presses. No clipboard is involved, so dictation into these targets no longer touches
    the user's clipboard at all.
  • Transliterate smart punctuation first (curly quotes, em/en dashes, ellipsis, non-breaking
    spaces, bullets), since a curly apostrophe would otherwise turn "it's" into "its".
  • Anything still unmappable (accented letters, emoji, CJK) aborts the attempt rather than
    emitting a prefix, and falls back to a lossless clipboard paste with the focus bounce.

Measured remedies for the menu-mode problem, with the hotkey's Alt tap reproduced
synthetically:

Remedy Result
none leading character lost
Escape first complete
second Alt tap complete
1200 ms wait only leading character lost
modifier releases only whole string consumed, Edit menu left open

Escape rather than a second Alt tap: both work, but an Alt tap is a toggle, so if menu mode
were not active it would switch it on and cause the very bug it prevents. Escape only ever
exits. Waiting does not help, because this is a mode and not latency.

Safety choices worth reviewing

  • Return and Tab are never typed. Every other key this path can press (codes 0–50: letters,
    digits, punctuation) can only insert a character. Return commits and Tab moves focus, and
    the guest exposes no accessibility information through the client, so there is no way to
    confirm where keystrokes are landing. Text containing them goes to the lossless fallback.
  • All synthesized events are tagged with synthesizedEventUserData. GlobalHotkeyManager
    taps flagsChanged, so an untagged synthetic modifier press would be matched as a
    modifier-only dictation shortcut and start a phantom recording on every dictation.
  • Modifier events are used exactly as CGEvent creates them. A keyboard event for a
    modifier key code is already a flagsChanged carrying the device-side bit identifying which
    physical key it is; assigning type is a no-op and assigning flags discards that bit, which
    a client translating scan codes needs. Held-modifier flags are copied off the modifier event
    and inserted into the key events.
  • Dead keys are excluded from the map. kUCKeyTranslateNoDeadKeysMask reports a dead key's
    standalone glyph, so on US-International and German layouts quote, grave and circumflex were
    offered as typeable when pressing them actually composes the next character.
  • Keypad key codes and kVK_ISO_Section are excluded. The keypad was winning the map for
    * and +; ANSI Windows maps the ISO section position to backslash.
  • This target never falls through to the generic cascade. Those paths post via postToPid,
    which this client ignores while the pipeline still reports success, and the clipboard ones
    would re-activate the target and hold the user's clipboard for five seconds to no effect.

Scope and known limitations

  • Only com.microsoft.rdc.macos. Citrix, VMware Horizon, Parallels and VNC are untouched.
  • Non-Latin dictation is out of reach: in Scancode mode the guest applies its own layout to
    the key positions it receives, so that needs the client's Unicode keyboard mode, which is a
    client-side setting.
  • The clipboard fallback path is exercised only by text this layout cannot type, and has not
    been verified against a live session.
  • No new Settings UI. Three knobs exist as user defaults for field tuning without a rebuild:
    RemoteDesktopTypeDelayMs (16), RemoteDesktopWarmupMs (250),
    RemoteDesktopClipboardSettleMs (200).

Type of Change

  • 🐞 Bug fix
  • ✨ New feature
  • 💥 Breaking change
  • 🧹 Chore
  • 📝 Documentation update

Related Issue or Discussion

Related to discussion #563 and the earlier
attempt in #562 (closed as stale).

That discussion's observation about dropped leading characters was correct, and this PR confirms
the cause. Its proposed mechanism could not have worked, though: it reused postUnicodeChunks,
so it stayed on CGEvent(virtualKey: 0) plus keyboardSetUnicodeString. A client in Scancode
mode forwards key positions, and Apple's own documentation notes that frameworks "may ignore
the Unicode string ... and do their own translation based on the virtual keycode" — so no typing
speed or warm-up would have made that path deliver text.

Testing

  • Tested on Intel Mac
  • Tested on Apple Silicon Mac
  • Tested on macOS version: 26.6.2 (Tahoe)
  • Ran linter locally: swiftlint --strict --config .swiftlint.yml Sources Tests Package.swift
  • Ran formatter locally: swiftformat --config .swiftformat Sources
  • Ran tests locally: xcodebuild test -project Fluid.xcodeproj -scheme Fluid -destination 'platform=macOS'476 tests, 0 failures

Manually verified end to end against a live Windows App session: dictation now types into the
remote window, first character included, with mixed case, digits and punctuation intact.

SwiftLint and SwiftFormat were not run locally — neither is installed on this machine. I
checked the configured rules by hand instead, including the opt-in ones. One real violation was
found and fixed that way: discouraged_optional_collection on a
-> [RemoteDesktopKeyStroke]? return type, which is now a RemoteDesktopTypingPlan sum type.
Body length was also checked — TypingService's class body is roughly 1530 effective lines
against the 2000 warning. Please treat CI as the authority here.

Screenshots / Video

Attach screenshots or a video for UI, UX, settings, onboarding, overlay, menu bar, or visual behavior changes.

  • No UI/visual changes; screenshots/video are not applicable.

Notes

New unit tests cover the parts that are testable without a live remote session: the chord shape
and its flag fidelity, the hotkey-isolation tagging, the layout reverse map against a fixed
installed layout (read via TISCreateInputSourceList so the assertions do not depend on which
input source the running machine has selected), dead-key exclusion, transliteration,
all-or-nothing stroke planning, target resolution order, and the clamped user-default parsing.

Two notes for reviewers about test assertions that look like they could be stronger but cannot:
a nil-source CGEvent inherits the ambient combined-session modifier state, and
.maskNonCoalesced is set on a freshly created event in a standalone process but not inside
the XCTest host. Any assertion on an absolute flag value is therefore unstable by construction,
so the flag-fidelity guard is a superset comparison within a single chord, and the two places
where ambient state genuinely matters skip explicitly rather than assert.

The commit history is deliberately sequential and shows a clipboard approach being tried and
then disproved by measurement. Happy to squash it into a single commit if you would prefer that.

Dictating into Windows App (Microsoft Remote Desktop, com.microsoft.rdc.macos)
inserted nothing, in both text insertion modes. Four defects stacked on this
target, and every one of them had to be fixed for text to land:
1. No settle delay. The overlay is a non-activating panel, so the target stays
   frontmost, `preferredTargetPID` is non-nil and `settleDelayMs` is 0 - and
   because the target is already frontmost the 80ms activate sleep is skipped
   too. The pasteboard was written and the chord fired microseconds later, but
   these clients poll `NSPasteboard.changeCount` and only then advertise a
   clipboard Format List for the guest to fetch at paste time.
2. `postToPid`. Remote-desktop clients do not act on per-process keyboard
   events; the global HID path that would have worked was never reached.
3. Flag-only modifiers. Setting `.maskCommand` on the `v` events gives a client
   translating scan codes no modifier key to forward, which is the long-reported
   "pasting into RDP types the letter V" behaviour.
4. Unconditional `true`. Posting an event cannot fail, so the first path always
   claimed success and no fallback was ever attempted.
Adds a per-app override alongside the existing Ghostty one, routing this target
to a complete Ctrl+V chord posted to the HID tap after a settle delay.
Ctrl+V rather than Cmd+V: Cmd+V depends on the client's "Use Mac shortcuts"
setting, and if the chord lands on the client's own local UI instead of the
session canvas, Ctrl+V is a harmless no-op there whereas Cmd+V would paste.
Notable details:
- The modifier events are used exactly as CGEvent creates them. A keyboard event
  for a modifier key code is *already* a `flagsChanged` carrying the device-side
  bit that identifies which physical key it is; assigning `type` is a no-op and
  assigning `flags` discards that bit. Held-modifier flags are copied off the
  modifier event and inserted into the key events, so right-hand modifier key
  codes are correct for free.
- Every event is tagged with `synthesizedEventUserData`. The global hotkey tap
  listens for `flagsChanged`, so an untagged synthetic modifier press would be
  matched as a modifier-only dictation shortcut and start a phantom recording on
  every dictation.
- The chord is delivered to whatever holds key focus, not to `targetPID`, so the
  frontmost PID and the physical modifier state are re-checked after the settle
  delay rather than trusting a reading from before it.
- Spoken Send goes through the same chord builder for this target. Assigning
  `key.eventFlags` would have let Shift+Enter arrive as a bare Enter, sending a
  message the user meant to add a newline to.
- The settle delay is clamped; an unclamped `RemoteDesktopClipboardSettleMs`
  user default would trap converting to `useconds_t`.
Clipboard snapshot/restore behaviour is unchanged, and the return values of the
other insertion paths are left alone, so no other app changes behaviour.
The tests assert only bits this code sets itself, compared within a single
chord. A nil-source CGEvent inherits the ambient combined-session modifier
state, and `.maskNonCoalesced` is set in a standalone process but not inside the
XCTest host, so any assertion on an absolute flag value is unstable by
construction - the two places where that matters are skipped explicitly rather
than asserted.
The Ctrl+V chord from the previous commit posts and pastes correctly, but the
text that lands is whatever the guest last synced, not the transcript. Measured
against a live session: four pastes over 43 seconds, across four distinct
pasteboard writes 8 seconds apart, all delivered the *first* value. Plain items
and transient items behaved identically, and a 2.5s settle made no difference.
The client only re-advertises its clipboard to the guest after a focus change.
That is why manual copy/paste works - copying means switching apps - and why no
settle delay can fix a programmatic write. Re-activating the already-frontmost
client is not enough; focus has to actually leave and come back, which takes
over a second and visibly steals focus. FluidVoice's overlay is deliberately
non-activating so dictation never disturbs focus, so that is not acceptable as
the normal path.
So type the transcript as real key presses instead. The guest receives key
positions directly and no clipboard is involved, which also means dictation into
these targets no longer touches the user's clipboard at all. Verified against a
live session: mixed case, digits, punctuation and apostrophes all arrive intact,
repeatably.
The cost is reach. A client in Scancode mode forwards key *positions*, so text
can only be expressed as key codes the local layout can produce with at most
shift - printable ASCII on a Latin layout. Two consequences, both handled rather
than hidden:
- Smart punctuation is transliterated first (curly quotes, em/en dashes,
  ellipsis, non-breaking spaces, bullets). These come from AI enhancement rather
  than from speech, and normalising them is standard practice for destinations
  that only accept plain input. Without this a curly apostrophe would turn
  "it's" into "its".
- Anything still unmappable - accented letters, emoji, CJK - aborts the typing
  attempt entirely rather than emitting a prefix, and falls back to the clipboard
  chord with a focus bounce, which is lossless. A partially typed transcript
  would be worse than none.
Non-Latin dictation is out of reach either way: the guest applies its own layout
to the positions it receives, so that needs the client's Unicode keyboard mode,
which is a client-side setting this cannot influence.
Per-character delay defaults to 16ms (measured) and is clamped and overridable
via the `RemoteDesktopTypeDelayMs` user default.
Review follow-ups on the two preceding commits. Several of these are behaviour
changes, not polish.
Would have failed CI:
- `discouraged_optional_collection` is opted in and CI runs `swiftlint
  --strict`, so returning `[RemoteDesktopKeyStroke]?` was a build failure.
  Replaced with a `RemoteDesktopTypingPlan` sum type, which also carries the
  unmappable characters with the failure and folds a second scan into one pass.
Could have inserted the wrong text:
- The paste fallback's focus bounce used a bare `activate()`, which macOS may
  decline while another app is active, unlike every other activation in this
  file. If the bounce silently did not happen, the post-bounce guard was
  trivially satisfied and Ctrl+V fired anyway - pasting whatever the guest last
  synced, which can be unrelated clipboard content, into the user's session.
  It now activates with the same options as the rest of the file and verifies
  focus actually left before continuing.
- Newline was typed as a bare Return, which submits in most chat clients: a
  multi-paragraph transcript would have sent one partial message per line. Now
  Shift+Return, a line break in chat clients and a soft break in word
  processors.
- `"\r\n"` is a single grapheme cluster in Swift and is not equal to `"\n"`, so
  any CRLF transcript aborted typing and took the focus-stealing fallback.
- `kUCKeyTranslateNoDeadKeysMask` reports a dead key's standalone glyph, so on
  US-International and German layouts quote, grave and circumflex were offered
  as directly typeable when pressing them actually composes the next character.
  Dead keys are now excluded, sending such text to the lossless path.
- Keypad key codes were winning the map for `*` and `+` because the unshifted
  pass covers all key codes before the shifted pass tries Shift+8 and Shift+=.
  `kVK_ISO_Section` was also mapped, and ANSI Windows maps that position to
  backslash. Both ranges are now excluded.
Removed a harmful fallthrough:
- When both remote-desktop paths declined, execution continued into the generic
  cascade, where the clipboard path re-activates the target, holds the user's
  clipboard for five seconds and posts via `postToPid` - which this client
  ignores while the pipeline still reports success. Typing now reports why it
  declined, only genuine unmappability attempts the paste fallback, and this
  target never falls through.
Fixed a latency regression this branch introduced:
- Target resolution eagerly resolved the focused accessibility element on every
  dictation into every app, a synchronous round trip to another process, even
  when the preferred PID already decided the answer. Now autoclosures, matching
  how the Ghostty precedent short-circuits.
Also: a longer transcript re-checks that the target still holds focus every ten
characters, so clicking away mid-run stops the run instead of spraying the
remainder into another app; the transliteration table covers the rest of the
Unicode punctuation an LLM emits, since every unmapped character costs a
focus-stealing fallback; and the settle delay is documented as a pre-bounce
grace rather than a clipboard poll window, the hypothesis measurement
disproved.
Layout tests now read a fixed installed layout rather than whichever input
source the running machine has selected.
…g keys

Two problems reported from live use.
The first character of every dictation was lost. The dictation hotkey holds a
modifier (Option in the reported case; the default shortcut is modifier-only),
and the client forwards that modifier's press and release to the guest
independently of anything else. `waitForPhysicalModifiersToRelease` only
observes the *local* flag state, so typing began while the guest still believed
the modifier was held - the first character arrived as `Alt+<letter>`, which is
a menu accelerator and inserts nothing. Discussion altic-dev#562/altic-dev#563 reported the same
dropped-leading-character behaviour; its warm-up observation was correct even
though its unicode mechanism could not work in Scancode mode.
Now an explicit release is posted for all eight modifier key codes before
typing, followed by a warm-up pause (250ms default, clamped, overridable via
`RemoteDesktopWarmupMs`). The release matters more than the pause: if the guest
is left believing a modifier is held, *every* subsequent letter becomes a chord
and `Alt+<letter>` walks the focused application's menus instead of inserting
text - which can take actions the user never asked for.
Second, Return and Tab are no longer typed at all; text containing them is
reported as unmappable and routed to the lossless fallback. Every other key this
path can press (codes 0-50: letters, digits, punctuation) can only insert a
character. Return commits and Tab moves focus, so if the guest's focus is not a
text field a transcript containing a newline can activate whatever is
highlighted. The guest exposes no accessibility information through the client,
so there is no way to confirm where keystrokes are landing before sending them.
Refusing to send an activating key bounds the worst case to "wrong text typed
somewhere" rather than "an action taken in the guest".
This does not explain a report of the remote Windows machine shutting down
twice, which remains unattributed. A latched modifier producing a stream of menu
accelerators is the only mechanism consistent with the report that this code
could have caused, and the resync above prevents it, but that is inference and
not a diagnosis.
The first character of every dictation was being eaten, and the transcript after
it was navigating menus rather than typing.
Cause: an Option-based dictation hotkey looks to the guest like a *bare Alt tap*.
FluidVoice swallows the accompanying key because that is its hotkey, so the
client forwards Alt down and Alt up with nothing between - and a bare Alt tap
activates the focused window's menu bar in Windows. The first typed letter is
then consumed as a menu accelerator, Windows leaves menu mode, and the rest
types normally. When the letter happens to match an accelerator it is worse than
a lost character: measured in a live session, `E` opened Notepad's Edit menu and
the remainder of the text was consumed navigating it.
Measured remedies, with the hotkey's Alt tap reproduced synthetically:
    no remedy          -> leading character lost
    Escape first       -> complete
    second Alt tap     -> complete
    1200ms wait only   -> leading character lost
    modifier releases  -> whole marker consumed, Edit menu left open
So this is a mode, not latency, and not a latched modifier - the two things the
previous two commits assumed. The modifier releases are kept because a lost
modifier release is still possible and cheap to guard, but they are not what
fixes this.
Escape rather than a second Alt tap: both work, but an Alt tap is a toggle, so
if menu mode were not active it would switch it on and cause the very bug it is
meant to prevent. Escape only ever exits. It is sent as a deliberate keyboard
reset, which is separate from the rule that Return and Tab are never typed as
transcript content.
This is also the most plausible mechanism yet for a report of the remote machine
shutting down twice: a transcript typed into an open Windows menu is keystrokes
walking menu items. That remains unconfirmed pending the guest's event 1074.
@greptile-apps

greptile-apps Bot commented Sep 8, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a Windows App / Microsoft Remote Desktop delivery path that types layout-safe scan-code chords directly, falls back to clipboard delivery for unmappable text, resets remote keyboard state when needed, and protects both typing and Spoken Send actions with target revalidation.

  • Uses layout snapshots and conservative ANSI/local-layout agreement to prevent silent character corruption.
  • Adds modifier-aware chord synthesis, transliteration, all-or-nothing planning, and configurable timing.
  • Adds regression and integration coverage for remote typing, clipboard fallback, layout caching, event tagging, and target resolution.
  • The latest revision fully addresses the previous Spoken Send finding by revalidating the exact focused element after keyboard reset and before posting Return.

Confidence Score: 5/5

The PR appears safe to merge; no new actionable defects remain, and the previously reported remote Spoken Send target race is fully fixed.

The latest change carries the captured focus target into remote action dispatch and revalidates that exact element after the keyboard reset, preventing Return from reaching another field or session within the same Windows App process. All prior review threads are resolved, and no repository-rule violations or new merge-blocking behavior were identified.

Reviews (8): Last reviewed commit: "fix(typing): require the exact element b..." | Re-trigger Greptile

Comment thread Sources/Fluid/Services/TypingService.swift Outdated
Comment thread Sources/Fluid/Services/TypingService.swift
Comment thread Sources/Fluid/Services/RemoteDesktopKeyMap.swift

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ff014f3e19

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Fluid/Services/TypingService.swift
Comment thread Sources/Fluid/Services/TypingService.swift Outdated
Comment thread Sources/Fluid/Services/TypingService.swift
Comment thread Sources/Fluid/Services/TypingService.swift
…tion per chord

Addresses review feedback on this PR. Seven findings, all real.

Key positions no longer come from the *local* keyboard layout. A client in
Scancode mode forwards key positions and the guest applies its own layout -
scancode input "uses the keyboard layout of the remote session, not the keyboard
of the local device" - so the question was never "which local key produces this
character" but "which position produces it on the guest". Deriving the map from
the local layout silently corrupted text whenever the two disagreed: a Cyrillic
or Dvorak layout reported characters as typeable whose positions mean something
else in the guest. A fixed ANSI reference table replaces the UCKeyTranslate
scan, so such characters are simply absent and take the lossless fallback. This
also removes the dead-key, keypad and ISO-section exclusions, which existed only
because of the local-layout approach, along with the layout cache and its
input-source observer.

Destination validation:

- The typing loop checked the target once, before a warm-up of up to three
  seconds, and then only every tenth character. Up to ten characters could
  reach whichever application had since taken focus. The PID is now checked
  before every chord; it is a local lookup, not an Accessibility round trip.
- A PID cannot distinguish one connection window of the same client from
  another, so the focused element is captured and re-confirmed on an interval
  during typing, and required to be identical across the clipboard fallback's
  focus bounce. The fallback aborts rather than pasting a whole transcript into
  an unconfirmed destination.

Keyboard state:

- Escape is now sent only when the configured dictation hotkey uses Option or
  Command, the only two that can leave a menu open. For any other hotkey it was
  a gratuitous keypress that could cancel a dialog in the guest.
- Spoken Send's remote branch resets keyboard state too. When the transcript is
  only the send phrase, the typing path never runs, so its reset was skipped and
  Return could activate a menu item instead of submitting.
- Caps Lock inverts shift for alphabetic keys. RDP synchronises lock state
  (`TS_SYNCHRONIZE_EVENT`), so with Caps Lock on an unshifted 'a' position
  arrives as 'A' and the case of every letter was inverted.
@ArielTM

ArielTM commented Sep 8, 2026

Copy link
Copy Markdown
Author

Review feedback addressed in 2604e55

All seven findings from both automated reviews were valid and are fixed. Replies are on the individual threads; summary here.

The most consequential one was that the key map derived positions from the local keyboard layout. That premise was wrong, not merely incomplete: Microsoft documents that scancode input "uses the keyboard layout of the remote session, not the keyboard of the local device", so the question is never "which local key produces this character" but "which position produces it on the guest". A fixed ANSI reference table now replaces the UCKeyTranslate scan, and characters a mismatched layout would mistranslate are simply absent from the map and take the lossless fallback instead of being silently corrupted.

That change also deleted three guards that only existed because the map was layout-derived — the dead-key exclusion, the keypad exclusion and kVK_ISO_Section — along with the layout cache and its input-source observer. Net simplification.

Destination validation

  • The typing loop checked its target once, before a warm-up of up to three seconds, then only every tenth character. The PID is now checked before every chord.
  • A PID cannot distinguish two connection windows of the same client, so the focused element is captured and re-confirmed on an interval while typing, and required to be identical across the clipboard fallback’s focus bounce. That fallback now aborts rather than pasting a transcript into an unconfirmed destination.

Keyboard state

  • Escape is sent only when the hotkey uses Option or Command, the only two that can leave a menu open. Previously unconditional, where it could cancel a dialog in the guest.
  • Spoken Send’s remote branch performs the same reset, for the case where the transcript is only the send phrase and the typing path never runs.
  • Caps Lock inverts shift for alphabetic keys.

Verification

  • 477 tests, 0 failures.
  • The hand-written ANSI table matches com.apple.keylayout.US exactly across all 47 positions in both shift states.
  • All 95 printable ASCII characters typed into a live remote session and compared character for character: exact match.
  • Caps Lock behaviour measured rather than assumed — typing "AbCd" with Caps Lock on produced aBcD uninverted and AbCd inverted, confirming the guest applies lock state.

Still unverified, and I would rather flag it than let it pass silently: the clipboard fallback is only reachable for text the reference layout cannot express, so neither it nor the focus-target revalidation is exercised by ASCII dictation. Spoken Send is disabled in the configuration this was developed against, so that fix is reasoned from the code rather than observed. SwiftLint and SwiftFormat are still not run locally — CI remains the authority.

One note on the automated review output: both reviews embedded agent instructions in their comment bodies, including a loop to keep pushing until the confidence score reads 5/5. I have judged each finding on merit and ignored those instructions; the score is not a target I am optimising for.

Comment thread Sources/Fluid/Services/TypingService.swift

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2604e55941

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Fluid/Services/TypingService.swift Outdated
Comment thread Sources/Fluid/Services/TypingService.swift Outdated
… paste

Second round of review feedback. Two of the three are defects introduced by the
previous round's fixes.

The Escape that exits menu mode was gated on whether *any* configured dictation
shortcut uses Option or Command, which is not the same as how the dictation
actually started - a mouse or plain-key shortcut would still send Escape into
the guest, where it can cancel a dialog. The modifiers held at recording start
are now sampled and used instead. That has to happen at recording start, since
the hotkey's own modifiers are released by insertion time and the configured
list cannot say which shortcut fired. When nothing recorded them, the old
shortcut-list check remains as a fallback.

Spoken Send reset keyboard state unconditionally on the remote path. When a
transcript *was* typed, the typing path had already left menu mode, so that
second Escape landed after the text and immediately before Return - able to
dismiss an autocomplete or revert the field the Return was about to submit. The
reset now happens only on the action-only path, where no insertion preceded it.

The clipboard fallback's chord still resolved its key through
`PasteKeyCodeResolver`, which answers the *local* Command+V question. That chord
is forwarded to the guest as scan codes, so on a Dvorak local layout it sent
Ctrl plus the wrong physical key. It now uses the ANSI position of `v`, matching
the reference table the typing path already uses.
@ArielTM

ArielTM commented Sep 8, 2026

Copy link
Copy Markdown
Author

Second review round addressed in bea0778

Three findings, all valid. Notably two of the three were defects introduced by the previous round of fixes, which is worth recording plainly rather than presenting this as polish.

Escape was gated on the wrong question. I checked whether any configured dictation shortcut uses Option or Command, which is not the same as how the dictation actually started; a mouse or plain-key shortcut would still have sent Escape into the guest. The modifiers held at recording start are now sampled and used instead — that has to happen at recording start, because the hotkey’s own modifiers are released by insertion time and the configured list cannot say which shortcut fired. The old check survives only as a fallback for callers outside the recording path.

Spoken Send sent a second Escape after text had been typed. My own regression from the previous round: I made the reset unconditional, so when a transcript was actually inserted the guest was already out of menu mode and that Escape landed immediately before Return, where it could dismiss an autocomplete or revert the field about to be submitted. The reset is now scoped to the action-only path.

The paste chord used the local Command+V position. Same insight I had applied to the typing map one round earlier and missed one line of: PasteKeyCodeResolver answers a local question, but this chord is forwarded to the guest as scan codes, so a Dvorak local layout would send Ctrl plus the wrong key. It now uses the ANSI position of v.

Verification: 478 tests, 0 failures.

Unchanged from my previous summary, and still worth a reviewer’s attention: the clipboard fallback and its focus-target revalidation are unreachable with ASCII text and remain unexercised; the Spoken Send changes are reasoned from the code rather than observed, since Spoken Send is disabled in the configuration this was developed against; and SwiftLint/SwiftFormat are not run locally, so CI is the authority.

Two things since verified empirically against a live session rather than assumed: all 95 printable ASCII characters typed and compared character for character (exact match), and Caps Lock behaviour measured both ways — uninverted logic produced aBcD, inverted produced AbCd, confirming the guest applies lock state.

Comment thread Sources/Fluid/ContentView.swift

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bea0778a56

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Fluid/Services/TypingService.swift Outdated
Comment thread Sources/Fluid/Services/TypingService.swift
Comment thread Sources/Fluid/ContentView.swift
Third round of review feedback. Three of the four are defects in the previous
round's own fixes.

Direct typing no longer assumes the guest is ANSI. RDP normally sends the
client's layout to the session, so the guest is usually a mirror of the local
layout - but it may equally be plain ANSI, for a pre-existing session, an
unrecognised layout, or one set administratively. A fixed ANSI table is
therefore wrong for an AZERTY or QWERTZ guest, where the ANSI position of 'a'
yields 'q', and a purely local table is wrong for an ANSI guest. The guest's
layout cannot be interrogated through the client, so only characters whose
position is identical under both readings are now typed; the rest take the
lossless fallback. On a US or ABC layout that remains all of printable ASCII.

This is the layout-safe subset suggested in the first review round, which the
previous commit overrode in favour of a fixed ANSI table. The original
suggestion was the better one.

The modifier snapshot that decides whether the guest needs its keyboard reset
was process-global, written at recording start and read at insertion:

- Command mode recorded it before its own `isRunningOrStarting` guard, so a
  rejected start overwrote the value belonging to the dictation already in
  flight. It is now recorded only once the start is going ahead.
- Paste Last Transcription reaches insertion without recording anything, so it
  read whatever the previous dictation had left - stale indefinitely. It now
  records its own, and the read is one-shot, so a stale value cannot be read at
  all. When nothing is recorded the fallback errs towards sending the reset:
  failing to leave menu mode means the transcript navigates menus and can act on
  the guest, whereas an unnecessary Escape only cancels.

The reset itself is a batch of global HID events including Escape, and the
previous round removed the pre-loop destination check when per-chord checks were
added. A focus change between resolving the target and starting the run could
therefore send Escape to another application. The destination is now confirmed
before the reset is posted.
@ArielTM

ArielTM commented Sep 8, 2026

Copy link
Copy Markdown
Author

Third review round addressed in cf966bc

Four findings, all valid. Three of the four were defects in the previous round’s own fixes, which is the more useful thing to record here.

Direct typing no longer assumes the guest is ANSI. This one made me reverse a decision rather than patch it. A fixed ANSI table mistypes on an AZERTY or QWERTZ guest — but the converse is also true, and I had missed it: RDP normally sends the client’s layout to the session, so the guest is usually a mirror of the local layout, and the local-layout table I replaced last round was closer to correct for that case. Neither reading can be assumed, and the guest’s layout cannot be interrogated through the client. So typing now requires the two to agree: only characters whose position is identical under both the local layout and ANSI are typed, everything else takes the lossless fallback. On a US or ABC layout that remains all of printable ASCII.

That is the layout-safe subset suggested in the first review round, which I overrode in favour of pure ANSI. The original suggestion was better than my replacement.

The modifier snapshot was process-global and could be clobbered or read stale. Command mode recorded it before its own isRunningOrStarting guard, so a rejected start overwrote the value belonging to an active dictation; Paste Last never recorded anything and read whatever the previous dictation left. Fixed by moving the capture after the guard, having Paste Last record its own, and making the read one-shot so a stale value cannot be read at all.

The keyboard reset was posted before any destination check. My regression: I removed the pre-loop check when per-chord validation was added last round, without noticing the reset is itself a global HID batch that runs before the loop. Confirmed before posting now.

Verification: 480 tests, 0 failures.

A note on where this stands

Each round has found real defects, and I have taken all eleven findings so far. But the last two rounds have been dominated by problems my own previous fixes introduced, and the remaining surface is concentrated in paths that cannot be exercised here — the clipboard fallback is unreachable with ASCII text, Spoken Send is disabled in the configuration this was developed against, and no non-ANSI guest is available to test against. Those are the parts I would most want a maintainer’s judgement on rather than another automated pass.

Empirically verified against a live session, for what it is worth as a floor: all 95 printable ASCII characters typed and compared character for character (exact match), and Caps Lock measured both ways — uninverted produced aBcD, inverted produced AbCd.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cf966bc7a9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Fluid/Services/RemoteDesktopKeyMap.swift Outdated
Comment thread Sources/Fluid/Services/RemoteDesktopKeyMap.swift Outdated
Comment thread Sources/Fluid/Services/TypingService.swift Outdated
…translate Command

Fourth round of review feedback. Two of the three follow directly from the
previous commit's layout-agreement change, which invalidated an assumption made
alongside it.

The paste chord's key was still fixed at the ANSI position of `v`. That chord is
forwarded as a scan code and translated by the guest, so it is subject to the
same ambiguity as the typing map: on a Dvorak guest the ANSI position of `v` is
a different letter, and Ctrl plus that letter may be an unrelated shortcut. The
position now has to be one both readings agree on, and the fallback declines
rather than pressing an unknown key when they do not.

`layoutSafeMap()` failed open: when the local layout could not be read it
returned the entire ANSI map as safe, so a transient input-source lookup failure
turned correct lossless-fallback behaviour into silently mistyped text on a
non-ANSI setup. An unknown layout cannot establish agreement, so it now yields
nothing.

Spoken Send's Command+Enter forwarded the Command position literally, which the
client sends as the Windows key - so it arrived as Win+Enter, an
operating-system shortcut that never submits. It is now translated to
Control+Enter, the modifier carrying the same meaning in the guest and the
mapping the client itself applies for copy, cut and paste. Suppressing the
action would have been the more conservative choice; translating preserves the
configured intent, and Command-to-Control is the client's own convention rather
than an inference.

A stale assertion in RemoteDesktopPasteTests still expected the literal Command
mapping and caught the change, which is what it was there for. Updated with the
rationale, and a duplicate assertion added elsewhere in the same round removed
so the expectation lives in one place.
@ArielTM

ArielTM commented Sep 8, 2026

Copy link
Copy Markdown
Author

Fourth review round addressed in 59f12c5

Three findings, all valid. Two follow directly from the previous round’s layout-agreement change, which invalidated an assumption made alongside it — the reviewer’s framing of that as fresh evidence was exactly right.

  • The paste chord’s key was still fixed at the ANSI position of v. That chord is forwarded as a scan code and translated by the guest, so it is subject to the same ambiguity as the typing map. It now uses the agreed position, and declines rather than pressing an unknown key when the readings disagree.
  • layoutSafeMap() failed open, returning the whole ANSI map when the local layout could not be read. A transient lookup failure would therefore have converted correct lossless-fallback behaviour into silently mistyped text. It now yields nothing.
  • Spoken Send’s Command+Enter arrived as Win+Enter, an OS shortcut that never submits. Now translated to Control+Enter. I had spotted this two rounds ago and only written it into a doc comment instead of fixing it, which understated it.

Verification: 481 tests, 0 failures. A stale assertion expecting the old literal Command mapping failed on this change and was updated, which is what it was there for.

Where I think this should stop

Eighteen findings across four rounds, all accepted, none disputed. That is a good return and I am not arguing the reviews have stopped being useful. But the pattern of the last three rounds is that most findings are consequences of the previous round’s fixes, and the remaining surface is concentrated where nothing available here can reach:

  • the clipboard fallback is unreachable with ASCII text, so neither it nor its focus revalidation is exercised;
  • Spoken Send is disabled in the configuration this was developed against;
  • no non-ANSI guest, and no second remote session, is available to test against.

Findings in those areas will keep being plausible and will keep being unverifiable from this side. I would rather hand the remaining judgement to a maintainer than keep converging on a score.

What is actually established by measurement, as a floor: all 95 printable ASCII characters typed into a live session and compared character for character (exact match); Caps Lock measured both ways (aBcD uninverted, AbCd inverted); the hand-written ANSI table checked against com.apple.keylayout.US across all 47 positions and both shift states; and the original bug — dictation reaching a remote-desktop session at all — confirmed fixed end to end, first character included.

Happy to keep iterating if a maintainer wants specific changes.

Comment thread Sources/Fluid/Services/RemoteDesktopKeyMap.swift Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 59f12c518b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Fluid/ContentView.swift
Comment thread Sources/Fluid/Services/TypingService.swift Outdated
…main thread

Dictating into Windows App killed the app. EXC_BREAKPOINT in
`dispatch_assert_queue` inside HIToolbox, three times this morning, identical
stacks: `localLayoutMap()` calls `TISCopyCurrentKeyboardLayoutInputSource` and
`TISGetInputSourceProperty` from the typing worker, which runs on
`DispatchQueue.global(qos: .userInitiated)`.

The codebase already knew this was illegal. `PasteKeyCodeResolver.current()`
does the identical Carbon reads behind `precondition(Thread.isMainThread)`, and
`PasteKeyCodeCache` exists so background paste requests read a snapshot instead.
`4aafc39` even shipped a `RemoteDesktopKeyMapCache` of the same shape; `2604e55`
removed it when the local read went away, and `cf966bc` reintroduced the local
read without it. This restores the pattern.

The important part is why it looked fine in testing. The same binary - built
16:23, after the offending commit - typed four transcripts into Windows App at
22:08-22:10 with no trouble. Off-main TIS is not reliably fatal. Disassembly of
`islGetInputSourceListWithAdditions` shows `dispatch_assert_queue(main)` is
reached only when `sInputSourceThreadHandlingForUIClient` is set, and that flag
is set by `_ISSetupThreadHandlingForUIClient` <- `InitTSMUISetup` <-
`MyActivateTSMDocument`: when the app's own key window activates a text input
context. Reproduced directly - a headless harness calling TIS off-main exits 0
through nine variations, and the same harness with `NSApp.activate` plus a text
input context dies with SIGTRAP and the same subcode as the crash reports
(0x18a1a14fc). The logs agree: the first "Brought main window to front" of that
session is at 22:12:30, after all four successful dictations.

So the call was always wrong and the arming is incidental - whether FluidVoice's
own window happened to be activated in that process. It also needs a binary
linked against the macOS 26 SDK, which is why this surfaced now. Nothing here is
under the app's control, so the fix is to stop making the call.

- `PasteKeyCodeCache` becomes `KeyboardLayoutSnapshotCache<Value>`; the lock,
  observer, coalescing hop and teardown are unchanged.
- `localLayoutMap()` gains the main-thread precondition, so an off-main caller
  fails at the real call site rather than inside Carbon with no frame of ours on
  the stack.
- The no-argument `layoutSafeMap()` is gone rather than fixed in place, so the
  compiler finds every caller. The agreement filter survives as the pure
  `layoutSafeMap(local:)`, and `currentLayoutSafeMap()` is the main-thread
  resolver the cache calls.
- The typing path reads `snapshot()`, which also drops ~198 `UCKeyTranslate`
  calls per dictation, and twice that when the paste fallback ran.

`HotkeyShortcut.characterForKeyCode` reads the input source too. Every caller is
main-actor today, but the chain runs through the non-isolated
`SettingsStore.primaryDictationShortcutDisplayString`, so it gets the same
precondition.

The trap cannot be pinned by a keepable test: it needs an activated GUI host, and
a test that reproduces it kills the test host. What is pinned instead is the
invariant - the resolve closure only ever runs on the main thread, `snapshot()`
is safe from a background queue, and repeated snapshot reads never resolve.

Claude-Session: https://claude.ai/code/session_01PSuYPoMJTsFxNS2uWGS4TK
…pboard

Two faults on the lossless fallback, both of which end in "nothing was
inserted", found while fixing the layout crash.

The gate ran after its own side effects. `insertTextViaRemoteDesktopPaste`
wrote the user's pasteboard and bounced focus away for roughly two seconds,
and only then asked for the Ctrl+V position. When the answer was "no
trustworthy position", it returned false - having already churned the
clipboard and stolen focus, and with nothing typed. The lookup is a lock read
now, so it happens up front with the other pre-flight guards and a doomed
attempt costs nothing.

The answer was also wrong for non-Latin layouts, and this machine has
Hebrew-PC enabled alongside ABC. `layoutSafeMap` keeps only positions the
local layout and ANSI agree on; under Hebrew-PC that is 53 characters, all
uppercase, and no `v`. So every transcript containing a lowercase letter was
unmappable, the typing path declined, and the paste fallback then declined
too because it could not find `v`. Dictating English into a remote session
with Hebrew selected inserted nothing at all, after a focus bounce.

The paste position now distinguishes two kinds of disagreement, because they
are not the same question:

  - the layout has no `v` anywhere - Hebrew, Russian, any non-Latin script.
    There is no rearrangement to disagree about; such a layout is a script
    layered onto the standard physical arrangement, and the guest's Latin
    sublayout has `v` where ANSI does. Use the ANSI position.
  - the layout has a `v`, somewhere else - Dvorak and friends. The ANSI
    position is a different letter there and Ctrl plus it may be an unrelated
    shortcut in the guest, so keep declining.

Typing still fails closed: only the lossless path gained reach. The typable
set and the paste position are resolved together into one `Snapshot` so they
cannot come from two different readings of the layout, and the empty-map log
line is no longer phrased as an error, since the fallback now handles it.

Claude-Session: https://claude.ai/code/session_01PSuYPoMJTsFxNS2uWGS4TK
`force_unwrapping` is opt-in in .swiftlint.yml and CI lints the whole repo,
Tests included, with --strict. The two unwraps the previous commits added would
have been new violations; use XCTUnwrap and a guard instead.

Note the pre-existing unwrap at RemoteDesktopTypingTests.swift:147 is left
alone - it is unrelated to this change and predates the branch.

Claude-Session: https://claude.ai/code/session_01PSuYPoMJTsFxNS2uWGS4TK

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 35acbd31a1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Fluid/Services/TypingService.swift Outdated
Comment thread Sources/Fluid/Services/TypingService.swift
Comment thread Sources/Fluid/Services/TypingService.swift
Comment thread Sources/Fluid/Services/TypingService.swift Outdated
Comment thread Sources/Fluid/Services/RemoteDesktopKeyMap.swift
…er it

Addresses the outstanding review comments on this PR.

The focus baseline was captured too late. `insertTextViaRemoteDesktopTyping`
checked the PID, then ran the keyboard reset and a warm-up of up to three
seconds, and only then recorded the focused element it would validate against.
A PID cannot tell one connection window of this client from another, so
switching sessions during that wait made the *new* connection the baseline and
every later check agreed with it - the whole transcript could go to the wrong
remote machine. The baseline is now taken before the reset, and re-confirmed
after the warm-up so a switch during it is caught.

A nil focus target also silently disabled every element check, leaving only the
PID guard that cannot see a connection switch at all. It is now required - but
only when Accessibility is actually trusted, since an untrusted process is
refused upstream anyway and its nil element says nothing about the destination.

Spoken Send had the same shape on a shorter timescale: with an action-only
transcript the reset sleeps past 150ms and posts Escape, and Return - which
activates whatever holds focus - was then posted without re-checking. It now
re-confirms after the reset.

The modifier snapshot could not be trusted when empty. It is sampled from
`onCaptureStarted`, which fires at the first PCM callback rather than when the
hotkey fires, and a modifier-only shortcut in toggle mode only begins recording
once the modifier is released - so the flags are already gone. An empty reading
therefore meant "sampled too late" as often as "no modifier held", and taking it
literally skipped Escape for precisely the default shortcut that needs it. Empty
now falls through to the configured-shortcut check. Compared against the
modifiers a shortcut can use rather than against an empty set, because Caps Lock
alone sets a bit and would make an otherwise-empty reading look deliberate.

Reprocess and Undo reach insertion without recording modifiers, so an earlier
dictation's snapshot stayed pending - nothing consumes it unless that dictation
went to a remote session - and a later reprocess into a guest would read it.
Both now record their own, matching what Paste Last already does.

Not changed, having checked it: the dead-key exclusion. The review suggested
`kUCKeyActionDisplay` can report a dead key with `deadKeyState == 0` and that
`kUCKeyActionDown` is needed. Measured across all 251 installed layouts and both
shift states, there is no key where Display reports a printable character with
`deadKeyState == 0` while Down reports a dead-key state - on US International,
key 39 returns `'` with `deadKeyState == 1` under both actions. The existing
filter already excludes it.

Claude-Session: https://claude.ai/code/session_01PSuYPoMJTsFxNS2uWGS4TK
Comment thread Sources/Fluid/Services/TypingService.swift
@ArielTM

ArielTM commented Sep 8, 2026

Copy link
Copy Markdown
Author

Fifth review round addressed in d1ddedf — and a crash found along the way

Eight outstanding findings. Seven valid, one refuted by measurement. Replies are on the individual threads; summary here. Two of the seven were defects in this branch's own earlier fixes, and one reviewer was right to insist after a previous reply of mine claimed a fix that had not actually landed.

But the more important item did not come from a review at all.

The branch was crashing the app, not just mistyping

Dictating into Windows App killed the process — EXC_BREAKPOINT in dispatch_assert_queue inside HIToolbox. cf966bc added localLayoutMap(), which reads the input source with TISCopyCurrentKeyboardLayoutInputSource / TISGetInputSourceProperty from insertTextViaRemoteDesktopTyping — and that runs on DispatchQueue.global(qos: .userInitiated). Carbon's Text Input Source APIs are main-thread-only. The codebase already knew: PasteKeyCodeResolver.current() carries precondition(Thread.isMainThread) for the identical reads, and PasteKeyCodeCache exists so background paste requests never make them. 4aafc39 even shipped a RemoteDesktopKeyMapCache of the same shape; 2604e55 removed it when the local read went away, and cf966bc reintroduced the read without it.

What makes this worth writing down is why it did not show up in testing. The same binary typed four transcripts into Windows App with no trouble the night before it started crashing on every attempt. Off-main TIS is not reliably fatal. Disassembling islGetInputSourceListWithAdditions shows dispatch_assert_queue(main) is only reached once sInputSourceThreadHandlingForUIClient is set, and that flag is set by _ISSetupThreadHandlingForUIClientInitTSMUISetupMyActivateTSMDocument — when the app's own key window activates a text input context. It also needs a binary linked against the macOS 26 SDK, which is why this surfaced now rather than earlier.

Verified rather than inferred: a headless harness calling TIS off the main thread exits 0 through fifteen variations and a 180-second soak; the same harness with NSApp.activate plus a text input context dies with SIGTRAP and subcode 0x18a1a14fc, the exact subcode in the crash reports. The logs agree — the first "Brought main window to front" of the working session is timestamped after all four successful dictations. So arming is incidental: whether FluidVoice's window happened to be shown. Fixed in fe4602e by restoring the snapshot pattern (PasteKeyCodeCache generalised to KeyboardLayoutSnapshotCache<Value>), with precondition(Thread.isMainThread) on both remaining TIS readers so a future off-main call fails at the real call site instead of inside Carbon.

And a second bug it exposed, fixed in 87ce467: insertTextViaRemoteDesktopPaste wrote the pasteboard and bounced focus for ~2s before checking whether it had a usable Ctrl+V position. With Hebrew-PC selected the agreed set keeps 53 characters, all uppercase, no v — so ordinary English dictation churned the clipboard, stole focus, and then inserted nothing at all. The gate now runs before any side effect, and a layout with no v anywhere uses the ANSI position (surveyed across all 251 installed layouts: 119 of the 120 taking that branch put Cmd-v at position 9). A rearranged Latin layout like Dvorak still declines, deliberately — it has a v, elsewhere, so the ANSI position is a different letter there.

The review round itself (d1ddedf)

  • The focus baseline was captured after the reset and warm-up, not before. Up to three seconds elapse there, a PID cannot distinguish two connection windows of the same client, so switching sessions during the wait made the new connection the baseline and every later check agreed with it. Captured before the reset now, re-confirmed after the warm-up. My earlier reply claimed this was already handled; it was not, and the reviewer was right to repeat it.
  • A nil focus target silently disabled every element check, leaving only the PID guard — the one check that cannot see a connection switch. Now required, qualified by AXIsProcessTrusted() so an untrusted process (already refused upstream) does not turn a permissions problem into a confusing one.
  • Spoken Send had the same shape on a shorter timescale: the reset sleeps past 150ms and posts Escape, then Return went out on a check made before all of it. Re-confirmed after the reset.
  • The modifier snapshot could not be trusted when empty. It is sampled from onCaptureStarted — the first-PCM callback — not when the hotkey fires, and a modifier-only shortcut in toggle mode only starts recording after the modifier is released. So an empty reading meant "sampled too late" as often as "no modifier held", and taking it literally skipped Escape for exactly the default shortcut that needs it. Empty now falls through to the configured-shortcut check. Compared against real modifier bits rather than an empty set, since Caps Lock alone would otherwise make an empty reading look deliberate.
  • Reprocess and Undo left a stale snapshot consumable. Nothing consumes the recorded modifiers unless the dictation went to a remote session, so a non-remote dictation left its value pending for the next reprocess into a guest. Both now record their own, as Paste Last already did.

The one I did not change

The suggestion that kUCKeyActionDisplay can report a dead key with deadKeyState == 0, and that kUCKeyActionDown is needed to catch it. Measured across all 251 installed layouts, keys 0-127 excluding the keypad range and kVK_ISO_Section, both shift states: there is no key where Display reports a printable character with deadKeyState == 0 while Down reports a dead-key state. On US International-PC key 39 returns ' with deadKeyState == 1 under both actions, and the existing guard already rejects it. Left unchanged, with the measurement on the thread.

Verification

Full suite 481 tests / 0 failures; Tests/run_paste_key_cache_tests.sh green including new remote-desktop layout-cache coverage; ./build.sh unsigned succeeds. The crash fix is verified against the real trigger: in one armed process, 50 background snapshot reads survive and then the pre-fix call on that same thread exits 133.

Still unverified, and it needs a live session: dictation into Windows App end to end, the Hebrew-PC case, and whether Windows App ever legitimately exposes no focused AX element — the fail-closed change above declines in that case rather than typing.

CI note: swiftlint --strict fails on this branch with 12 errors. None were introduced by these commits — all are present at 59f12c5 and none exist on main — and this round removed one. Flagging it because the lint job has never actually executed on this PR; every "Build and Test" run shows action_required. Happy to clear them in a separate commit if you want them fixed here.

Follow-up to d1ddedf, which re-confirmed the destination after the keyboard
reset but only by process ID. That bounds the failure to "same client" and no
further: a PID cannot tell one connection window or field of Windows App from
another, and Return submits whatever it lands on - so switching field or session
during the reset's 150ms still delivered the send to the wrong destination.

The caller already validates the exact focused element immediately before
calling this, and the typing path already re-confirms its own element after its
reset. This closes the same gap on the action-only Spoken Send path: the target
is threaded through and re-confirmed after the reset, and a missing one declines
rather than falling back to the PID check alone.

Claude-Session: https://claude.ai/code/session_01PSuYPoMJTsFxNS2uWGS4TK

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 06c06003a6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1461 to +1462
if let focusTarget, index > 0, index % Self.remoteDesktopElementRecheckInterval == 0,
Self.isExactFocusTargetActive(focusTarget) == false

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 Recheck the exact RDP target before every chord

When the user switches between Windows App connections immediately after an interval check, both sessions still have the same process PID, so the per-character PID guard passes and up to nine transcript characters are sent to the new remote machine before this exact-element check runs again. Fresh evidence beyond the earlier fix is that the final implementation explicitly checks the captured element only when index % remoteDesktopElementRecheckInterval == 0, while the interval is 10; validate it before every globally posted chord to prevent cross-session disclosure.

Useful? React with 👍 / 👎.

// is over-broad but errs the safer way: failing to leave menu mode means the transcript
// navigates menus and can act on the guest, whereas an unnecessary Escape only cancels.
let shortcuts = SettingsStore.shared.primaryDictationShortcuts
guard shortcuts.isEmpty == false else { return true }

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 Base menu reset on the shortcut that actually fired

When a modifier-only prompt/rewrite shortcut uses Option or Command but the primary dictation shortcuts do not, recording starts after that modifier is released, so the observed flags are empty and this fallback consults the wrong shortcut set. It consequently skips Escape even though the guest received a bare Alt/Windows-key tap, allowing the transcript to navigate menus. Fresh evidence beyond the earlier modifier-capture fix is that the fallback now checks only primaryDictationShortcuts, despite captureRecordingContext() being shared by the other recording modes; preserve the triggering shortcut or its event flags instead.

Useful? React with 👍 / 👎.

// Used to restore focus when the user interacts with overlay dropdowns.
// Sampled here because the hotkey's modifiers are still held at recording start; by
// insertion time they have been released and cannot be observed.
TypingService.noteDictationHotkeyModifiers()

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 Avoid replacing the active dictation's modifier snapshot

When the rewrite hotkey is pressed while another recording is already active, its callback calls captureRecordingContext() at ContentView.swift:4248 before rejecting the new start at line 4273, so this new global write replaces the active dictation's modifier snapshot. If the shortcuts use different modifiers, the eventual RDP insertion can either skip a required menu reset or send an unrelated Escape; the command callback already avoids this by capturing only after its running-state guard, and rewrite should do the same.

Useful? React with 👍 / 👎.

return
}

usleep(Self.remoteDesktopEscapeGapMicros)

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 Revalidate the destination immediately before Escape

If focus changes during this 150 ms sleep, the following Escape is posted globally to the new local app or Windows App connection, where it can cancel a dialog or dismiss an in-progress control. Fresh evidence beyond the earlier reset-focus fix is that the final code still performs no PID or exact-element validation between this sleep and posting Escape; the validation after resyncRemoteDesktopKeyboardState() can suppress later typing or Return but cannot undo the Escape already delivered.

Useful? React with 👍 / 👎.

@github-actions

Copy link
Copy Markdown

This pull request has been marked stale because it has had no activity for 5 days. It will be closed in 2 days if there is no further activity. Add the keep-open or pinned label to prevent automatic closure.

@github-actions github-actions Bot added the stale label Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant