From 3fad60d370c52a409480fd3ff34281731d51af10 Mon Sep 17 00:00:00 2001 From: postoso Date: Fri, 19 Jun 2026 00:32:08 -0400 Subject: [PATCH 1/4] fix: add clipboard fallback for Write Mode selection reading (#259) Write/Rewrite mode could not read selected text from external apps (Zed, VS Code, Chrome/Docs, Obsidian) because TextSelectionService was AX-tree-only: when kAXFocusedUIElementAttribute returns nothing for Electron / GPU-rendered editors, both AX strategies fail and selection capture returned nil, silently degrading to a fresh prompt instead of rewriting the selection. Add a third fallback in getSelectedText(): synthesize Cmd+C, poll the pasteboard changeCount until the target app writes (bounded timeout, since Cmd+C is async), read the copied string, then always restore the user's previous pasteboard contents. An empty selection produces no pasteboard write, so it times out and returns nil, preserving the existing write-mode behavior. Extract the existing pasteboard snapshot/restore logic from TypingService into a shared PasteboardSnapshot value type so both the paste-insertion path and the new selection-read path use one full-fidelity implementation instead of duplicating it. --- .../Fluid/Services/PasteboardSnapshot.swift | 44 +++++++++ .../Fluid/Services/TextSelectionService.swift | 99 +++++++++++++++++++ Sources/Fluid/Services/TypingService.swift | 43 +------- 3 files changed, 147 insertions(+), 39 deletions(-) create mode 100644 Sources/Fluid/Services/PasteboardSnapshot.swift diff --git a/Sources/Fluid/Services/PasteboardSnapshot.swift b/Sources/Fluid/Services/PasteboardSnapshot.swift new file mode 100644 index 00000000..3b29b9f6 --- /dev/null +++ b/Sources/Fluid/Services/PasteboardSnapshot.swift @@ -0,0 +1,44 @@ +import AppKit +import Foundation + +/// A full-fidelity snapshot of an `NSPasteboard`'s contents (every item, every type). +/// +/// Used to save and restore the user's clipboard around synthetic copy/paste operations +/// so those operations don't clobber whatever the user had on the pasteboard. Shared by +/// `TypingService` (paste insertion) and `TextSelectionService` (Cmd+C selection fallback). +struct PasteboardSnapshot { + private struct ItemSnapshot { + let dataByType: [NSPasteboard.PasteboardType: Data] + } + + private let items: [ItemSnapshot] + + /// Captures the current contents of `pasteboard`. + static func capture(from pasteboard: NSPasteboard) -> PasteboardSnapshot { + let items: [ItemSnapshot] = pasteboard.pasteboardItems?.map { item in + var dataByType: [NSPasteboard.PasteboardType: Data] = [:] + for type in item.types { + if let data = item.data(forType: type) { + dataByType[type] = data + } + } + return ItemSnapshot(dataByType: dataByType) + } ?? [] + return PasteboardSnapshot(items: items) + } + + /// Restores the captured contents onto `pasteboard`, replacing whatever is there. + func restore(to pasteboard: NSPasteboard) { + pasteboard.clearContents() + guard !self.items.isEmpty else { return } + + let restoredItems = self.items.map { snap -> NSPasteboardItem in + let item = NSPasteboardItem() + for (type, data) in snap.dataByType { + item.setData(data, forType: type) + } + return item + } + _ = pasteboard.writeObjects(restoredItems) + } +} diff --git a/Sources/Fluid/Services/TextSelectionService.swift b/Sources/Fluid/Services/TextSelectionService.swift index 601a06f9..96145b79 100644 --- a/Sources/Fluid/Services/TextSelectionService.swift +++ b/Sources/Fluid/Services/TextSelectionService.swift @@ -1,5 +1,6 @@ import AppKit import ApplicationServices +import Carbon.HIToolbox import Foundation final class TextSelectionService { @@ -42,12 +43,110 @@ final class TextSelectionService { } } + // 3. Final fallback: synthetic Cmd+C, read the pasteboard, restore it. + // Electron / GPU-rendered editors (Zed, VS Code, Obsidian, web editors in Chrome) + // frequently don't expose the focused element or selection through the AX tree, so + // both AX strategies above return nothing. Copying the live selection is the only + // reliable way to read it from those apps (issue #259). + if let text = getSelectedTextViaClipboard() { + return text + } + self.diag("Selection capture failed: no selected text found") return nil } // MARK: - Private Helpers + /// Reads the current selection by synthesizing Cmd+C, polling the pasteboard for the + /// resulting write, then restoring the user's previous pasteboard contents. This is a + /// last resort used only when the Accessibility tree yields no selection. + /// + /// Runs synchronously on the caller's thread (the main thread for Write/Rewrite mode); + /// the poll is bounded by `Self.clipboardCopyTimeoutMicros` so a missing selection (no + /// pasteboard write) degrades to `nil` quickly rather than hanging. + private func getSelectedTextViaClipboard() -> String? { + let pasteboard = NSPasteboard.general + let snapshot = PasteboardSnapshot.capture(from: pasteboard) + let changeCountBeforeCopy = pasteboard.changeCount + + self.diag("Trying clipboard fallback (synthetic Cmd+C)") + guard self.postSyntheticCopy() else { + self.diag("Clipboard fallback: failed to post Cmd+C events") + return nil + } + + // Cmd+C is delivered asynchronously; wait for the target app to write the selection + // to the pasteboard. The change-count increments on any write, even if the copied + // text equals the prior contents, so this reliably detects a successful copy. + let didChange = self.waitForPasteboardChange( + pasteboard, + since: changeCountBeforeCopy, + timeoutMicros: Self.clipboardCopyTimeoutMicros + ) + + // Read the copied selection only if the pasteboard actually changed. + let copiedText = didChange ? pasteboard.string(forType: .string) : nil + + // Always restore the user's previous pasteboard, regardless of outcome. + snapshot.restore(to: pasteboard) + + guard didChange else { + self.diag("Clipboard fallback: pasteboard unchanged within timeout (no selection?)") + return nil + } + guard let copiedText, !copiedText.isEmpty else { + self.diag("Clipboard fallback: pasteboard changed but yielded no string") + return nil + } + + self.diag("Clipboard fallback succeeded (chars=\(copiedText.count))") + return copiedText + } + + /// Posts a synthetic Cmd+C to the currently focused app via the HID event tap. + /// Returns false only if the CGEvents could not be created. + private func postSyntheticCopy() -> Bool { + let cKeyCode = CGKeyCode(kVK_ANSI_C) + guard let keyDown = CGEvent(keyboardEventSource: nil, virtualKey: cKeyCode, keyDown: true), + let keyUp = CGEvent(keyboardEventSource: nil, virtualKey: cKeyCode, keyDown: false) + else { + return false + } + + keyDown.flags = .maskCommand + keyUp.flags = .maskCommand + + keyDown.post(tap: .cghidEventTap) + usleep(10_000) + keyUp.post(tap: .cghidEventTap) + return true + } + + /// Polls `pasteboard.changeCount` until it differs from `previousChangeCount` or the + /// timeout elapses. Returns whether a change was observed. + private func waitForPasteboardChange( + _ pasteboard: NSPasteboard, + since previousChangeCount: Int, + timeoutMicros: useconds_t + ) -> Bool { + let pollMicros: useconds_t = 10_000 + var waited: useconds_t = 0 + while waited < timeoutMicros { + if pasteboard.changeCount != previousChangeCount { + return true + } + usleep(pollMicros) + waited += pollMicros + } + return pasteboard.changeCount != previousChangeCount + } + + /// Upper bound on how long to wait for a synthetic Cmd+C to land on the pasteboard. + /// Kept tight because this runs on the main thread; the copy normally completes in tens + /// of milliseconds, and exceeding this just means "treat as no selection." + private static let clipboardCopyTimeoutMicros: useconds_t = 300_000 + private func getFocusedElement() -> AXUIElement? { let systemWideElement = AXUIElementCreateSystemWide() var focusedElement: CFTypeRef? diff --git a/Sources/Fluid/Services/TypingService.swift b/Sources/Fluid/Services/TypingService.swift index 259c9a05..3b6862e2 100644 --- a/Sources/Fluid/Services/TypingService.swift +++ b/Sources/Fluid/Services/TypingService.swift @@ -24,14 +24,6 @@ final class TypingService { let element: AXUIElement? } - private struct PasteboardItemSnapshot { - let dataByType: [NSPasteboard.PasteboardType: Data] - } - - private struct PasteboardSnapshot { - let items: [PasteboardItemSnapshot] - } - private struct FocusedTextSnapshot { let pid: pid_t let bundleIdentifier: String? @@ -534,33 +526,6 @@ final class TypingService { return ["AXTextField", "AXTextArea", "AXSearchField", "AXComboBox", "AXWebArea", "AXGroup"].contains(currentRole) } - private func capturePasteboardSnapshot(_ pasteboard: NSPasteboard) -> PasteboardSnapshot { - let items: [PasteboardItemSnapshot] = pasteboard.pasteboardItems?.map { item in - var dataByType: [NSPasteboard.PasteboardType: Data] = [:] - for type in item.types { - if let data = item.data(forType: type) { - dataByType[type] = data - } - } - return PasteboardItemSnapshot(dataByType: dataByType) - } ?? [] - return PasteboardSnapshot(items: items) - } - - private func restorePasteboardSnapshot(_ snapshot: PasteboardSnapshot, to pasteboard: NSPasteboard) { - pasteboard.clearContents() - guard !snapshot.items.isEmpty else { return } - - let restoredItems = snapshot.items.map { snap -> NSPasteboardItem in - let item = NSPasteboardItem() - for (type, data) in snap.dataByType { - item.setData(data, forType: type) - } - return item - } - _ = pasteboard.writeObjects(restoredItems) - } - private func withTemporaryPasteboardString( _ text: String, restoreDelayMicros: useconds_t, @@ -575,19 +540,19 @@ final class TypingService { } let pasteboard = NSPasteboard.general - let snapshot = self.capturePasteboardSnapshot(pasteboard) + let snapshot = PasteboardSnapshot.capture(from: pasteboard) pasteboard.clearContents() guard pasteboard.setString(text, forType: .string) else { self.log("[TypingService] ERROR: Failed to set temporary clipboard string") - self.restorePasteboardSnapshot(snapshot, to: pasteboard) + snapshot.restore(to: pasteboard) return false } let temporaryChangeCount = pasteboard.changeCount let focusedTextSnapshot = self.captureFocusedTextSnapshot() let actionResult = action() guard actionResult else { - self.restorePasteboardSnapshot(snapshot, to: pasteboard) + snapshot.restore(to: pasteboard) self.log("[TypingService] Restored previous clipboard snapshot after paste dispatch failure") return false } @@ -604,7 +569,7 @@ final class TypingService { // Avoid clobbering user clipboard changes that happened after our insertion. if pasteboard.changeCount == temporaryChangeCount || pasteboard.string(forType: .string) == text { - self.restorePasteboardSnapshot(snapshot, to: pasteboard) + snapshot.restore(to: pasteboard) self.log("[TypingService] Restored previous clipboard snapshot") } else { self.log("[TypingService] Skipped clipboard restore because clipboard changed externally") From b2be71e941f7b0e18b51f9336bffd0e9963463a3 Mon Sep 17 00:00:00 2001 From: postoso Date: Fri, 19 Jun 2026 01:14:51 -0400 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20harden=20Write=20Mode=20clipboard=20?= =?UTF-8?q?fallback=20=E2=80=94=20late-copy=20restore=20+=20layout-aware?= =?UTF-8?q?=20Cmd+C=20(#259)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address two issues found in code review of the synthetic-Cmd+C selection fallback added in d25796b. 1. Late-write clipboard clobber. getSelectedTextViaClipboard() restored the user's pasteboard unconditionally right after the 300ms copy-wait timeout. A slow/busy target app could process the synthetic Cmd+C *after* that restore, and its delayed pasteboard write would clobber the restored clipboard — leaving the copied selection where the user's content belongs, breaking the "always restore the user's clipboard" guarantee. Replace the unconditional restore with restoreClipboardDefensively(_:to:): restore, record the change count our own restore produced, then poll a short bounded settle window; any further write (the app's late copy) is re-restored. Doubly bounded (200ms total / max 3 re-restores) so it cannot hang, and every exit path ends having just re-restored the user's snapshot so it cannot leave the selection on the clipboard. Synchronous on the main thread, consistent with the existing copy-wait; only paid on this last-resort fallback path. 2. Cmd+C key code was not layout-aware. postSyntheticCopy() hard-coded kVK_ANSI_C, so on Dvorak/AZERTY/QWERTZ it posted Cmd+ and never copied. Extract TypingService's existing TIS/UCKeyTranslate layout lookup (used for Cmd+V) into a shared LayoutAwareKeyCode enum and route both the Cmd+V paste path and the new Cmd+C path through it, re-evaluated per call so runtime layout switches are picked up, with the ANSI key code as fallback. Add LayoutAwareKeyCode unit tests (resolves Latin chars, falls back for an unmappable character, deterministic across calls). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Fluid/Services/LayoutAwareKeyCode.swift | 78 +++++++++++++++++++ .../Fluid/Services/TextSelectionService.swift | 74 +++++++++++++++++- Sources/Fluid/Services/TypingService.swift | 62 +-------------- .../DictationE2ETests.swift | 36 +++++++++ 4 files changed, 187 insertions(+), 63 deletions(-) create mode 100644 Sources/Fluid/Services/LayoutAwareKeyCode.swift diff --git a/Sources/Fluid/Services/LayoutAwareKeyCode.swift b/Sources/Fluid/Services/LayoutAwareKeyCode.swift new file mode 100644 index 00000000..fc9860a2 --- /dev/null +++ b/Sources/Fluid/Services/LayoutAwareKeyCode.swift @@ -0,0 +1,78 @@ +import Carbon.HIToolbox +import CoreGraphics +import Foundation + +/// Resolves the virtual key code that produces a given character under the *current* +/// keyboard layout. +/// +/// Synthetic keyboard shortcuts (Cmd+C, Cmd+V) are posted by virtual key code, not by +/// character. A hard-coded ANSI key code (e.g. `kVK_ANSI_V` = 9) only lands on the right +/// physical key for QWERTY layouts; on Dvorak/AZERTY/QWERTZ the same key code produces a +/// different character, so the shortcut silently misfires. This looks up the key code for +/// the desired character in the active layout instead. +/// +/// Shared by `TypingService` (Cmd+V paste insertion) and `TextSelectionService` (Cmd+C +/// selection-read fallback) so both paths use one implementation rather than duplicating +/// the TIS / `UCKeyTranslate` scan. +enum LayoutAwareKeyCode { + /// Returns the virtual key code that produces `character` under the current keyboard + /// layout, falling back to `qwertyFallback` when the layout data is unavailable. + /// + /// Re-evaluated on every call so a runtime keyboard-layout switch is picked up + /// immediately. The underlying TIS API must run on the main thread, so the lookup is + /// dispatched there when called from a background thread. + static func virtualKeyCode(for character: Character, qwertyFallback: CGKeyCode) -> CGKeyCode { + if Thread.isMainThread { + return self.tisLookup(for: character, qwertyFallback: qwertyFallback) + } + var result = qwertyFallback + DispatchQueue.main.sync { + result = self.tisLookup(for: character, qwertyFallback: qwertyFallback) + } + return result + } + + /// Performs the actual TIS + `UCKeyTranslate` scan. Must be called on the main thread. + private static func tisLookup(for character: Character, qwertyFallback: CGKeyCode) -> CGKeyCode { + guard let targetScalar = character.unicodeScalars.first else { return qwertyFallback } + + guard let sourceRef = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue(), + let rawPtr = TISGetInputSourceProperty(sourceRef, kTISPropertyUnicodeKeyLayoutData) + else { + return qwertyFallback + } + let layoutData = Unmanaged.fromOpaque(rawPtr).takeUnretainedValue() as Data + + return layoutData.withUnsafeBytes { buffer -> CGKeyCode in + guard let layoutPtr = buffer.baseAddress?.assumingMemoryBound(to: UCKeyboardLayout.self) else { + return qwertyFallback + } + var deadKeyState: UInt32 = 0 + var chars = [UniChar](repeating: 0, count: 4) + var length = 0 + let kbType = UInt32(LMGetKbdType()) + + for keyCode: UInt16 in 0..<128 { + deadKeyState = 0 + length = 0 + let status = UCKeyTranslate( + layoutPtr, + keyCode, + UInt16(kUCKeyActionDisplay), + 0, + kbType, + UInt32(kUCKeyTranslateNoDeadKeysMask), + &deadKeyState, + chars.count, + &length, + &chars + ) + guard status == noErr, length > 0 else { continue } + if Unicode.Scalar(chars[0]) == targetScalar { + return CGKeyCode(keyCode) + } + } + return qwertyFallback + } + } +} diff --git a/Sources/Fluid/Services/TextSelectionService.swift b/Sources/Fluid/Services/TextSelectionService.swift index 96145b79..09278a8b 100644 --- a/Sources/Fluid/Services/TextSelectionService.swift +++ b/Sources/Fluid/Services/TextSelectionService.swift @@ -88,8 +88,10 @@ final class TextSelectionService { // Read the copied selection only if the pasteboard actually changed. let copiedText = didChange ? pasteboard.string(forType: .string) : nil - // Always restore the user's previous pasteboard, regardless of outcome. - snapshot.restore(to: pasteboard) + // Always restore the user's previous pasteboard, then defend that restore against a + // late synthetic-copy write (a slow/busy target app processing our Cmd+C after the + // read timeout would otherwise clobber the restored clipboard — see the method doc). + self.restoreClipboardDefensively(snapshot, to: pasteboard) guard didChange else { self.diag("Clipboard fallback: pasteboard unchanged within timeout (no selection?)") @@ -106,8 +108,13 @@ final class TextSelectionService { /// Posts a synthetic Cmd+C to the currently focused app via the HID event tap. /// Returns false only if the CGEvents could not be created. + /// + /// The key code for "c" is resolved against the active keyboard layout (via the same + /// mechanism `TypingService` uses for Cmd+V), so the copy lands on the correct physical + /// key on non-QWERTY layouts (Dvorak/AZERTY/...). Falls back to the ANSI "c" key code + /// only when the layout data is unavailable. private func postSyntheticCopy() -> Bool { - let cKeyCode = CGKeyCode(kVK_ANSI_C) + let cKeyCode = LayoutAwareKeyCode.virtualKeyCode(for: "c", qwertyFallback: CGKeyCode(kVK_ANSI_C)) guard let keyDown = CGEvent(keyboardEventSource: nil, virtualKey: cKeyCode, keyDown: true), let keyUp = CGEvent(keyboardEventSource: nil, virtualKey: cKeyCode, keyDown: false) else { @@ -147,6 +154,67 @@ final class TextSelectionService { /// of milliseconds, and exceeding this just means "treat as no selection." private static let clipboardCopyTimeoutMicros: useconds_t = 300_000 + /// Restores `snapshot` onto `pasteboard`, then briefly watches for a *late* pasteboard + /// write and re-restores if one lands. + /// + /// The hazard: our synthetic Cmd+C is delivered asynchronously. If the target app is + /// slow/busy it may process the copy *after* `clipboardCopyTimeoutMicros` has elapsed and + /// we've already restored — its delayed write would then clobber the user's clipboard, + /// leaving the copied selection (or stale data) where the user's content should be. That + /// breaks the "always restore the user's clipboard" guarantee. + /// + /// Defense: after restoring, record the change count our own restore produced and poll + /// for a short, bounded settle window. Any further change is an external write — almost + /// certainly the app's delayed copy — so we restore again (capped at `maxLateCopyRestores` + /// re-restores within `lateCopySettleMicros` total). The loop is doubly bounded (time and + /// retry count) so it cannot hang; because each pass ends by re-restoring the user's + /// snapshot, it cannot leave the selection text on the clipboard. + /// + /// Runs synchronously on the caller's thread (the main thread for Write/Rewrite mode), + /// consistent with the copy-wait above; the added latency is bounded by + /// `lateCopySettleMicros` and only paid on this last-resort fallback path. + private func restoreClipboardDefensively(_ snapshot: PasteboardSnapshot, to pasteboard: NSPasteboard) { + snapshot.restore(to: pasteboard) + var lastRestoreChangeCount = pasteboard.changeCount + + var restoresRemaining = Self.maxLateCopyRestores + let pollMicros: useconds_t = 10_000 + var waited: useconds_t = 0 + + while waited < Self.lateCopySettleMicros { + usleep(pollMicros) + waited += pollMicros + + guard pasteboard.changeCount != lastRestoreChangeCount else { continue } + + // A write landed after our restore — the target app's delayed synthetic copy + // clobbering the user's clipboard. Restore the user's snapshot again. + guard restoresRemaining > 0 else { + self.diag("Clipboard fallback: late write persisted past \(Self.maxLateCopyRestores) restores; leaving user clipboard restored as of last attempt") + return + } + restoresRemaining -= 1 + snapshot.restore(to: pasteboard) + lastRestoreChangeCount = pasteboard.changeCount + self.diag("Clipboard fallback: re-restored user clipboard after a late synthetic-copy write") + } + + // Reconcile a write that landed in the final poll interval (just past the loop edge). + if pasteboard.changeCount != lastRestoreChangeCount, restoresRemaining > 0 { + snapshot.restore(to: pasteboard) + self.diag("Clipboard fallback: final re-restore after a late write at the settle-window edge") + } + } + + /// Total time the defensive restore watches for a late synthetic-copy write before giving + /// up. Bounds the extra main-thread latency added on top of `clipboardCopyTimeoutMicros`. + private static let lateCopySettleMicros: useconds_t = 200_000 + + /// Maximum number of re-restores during the settle window. A real app emits one delayed + /// write per Cmd+C, so 1 suffices in practice; the small headroom covers pathological + /// repeated writers without ever looping unbounded. + private static let maxLateCopyRestores = 3 + private func getFocusedElement() -> AXUIElement? { let systemWideElement = AXUIElementCreateSystemWide() var focusedElement: CFTypeRef? diff --git a/Sources/Fluid/Services/TypingService.swift b/Sources/Fluid/Services/TypingService.swift index 3b6862e2..d6b13311 100644 --- a/Sources/Fluid/Services/TypingService.swift +++ b/Sources/Fluid/Services/TypingService.swift @@ -53,69 +53,11 @@ final class TypingService { // MARK: - Layout-aware key code lookup - /// Returns the virtual key code that produces `character` under the current keyboard layout. - /// Uses the TIS (Text Input Services) API which must run on the main thread, so the lookup - /// is dispatched there when called from a background thread. Falls back to `qwertyFallback` - /// if the layout data is unavailable. - private static func virtualKeyCode(for character: Character, qwertyFallback: CGKeyCode) -> CGKeyCode { - if Thread.isMainThread { - return self.tisLookup(for: character, qwertyFallback: qwertyFallback) - } - var result = qwertyFallback - DispatchQueue.main.sync { - result = self.tisLookup(for: character, qwertyFallback: qwertyFallback) - } - return result - } - - /// Performs the actual TIS + UCKeyTranslate scan. Must be called on the main thread. - private static func tisLookup(for character: Character, qwertyFallback: CGKeyCode) -> CGKeyCode { - guard let targetScalar = character.unicodeScalars.first else { return qwertyFallback } - - guard let sourceRef = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue(), - let rawPtr = TISGetInputSourceProperty(sourceRef, kTISPropertyUnicodeKeyLayoutData) - else { - return qwertyFallback - } - let layoutData = Unmanaged.fromOpaque(rawPtr).takeUnretainedValue() as Data - - return layoutData.withUnsafeBytes { buffer -> CGKeyCode in - guard let layoutPtr = buffer.baseAddress?.assumingMemoryBound(to: UCKeyboardLayout.self) else { - return qwertyFallback - } - var deadKeyState: UInt32 = 0 - var chars = [UniChar](repeating: 0, count: 4) - var length = 0 - let kbType = UInt32(LMGetKbdType()) - - for keyCode: UInt16 in 0..<128 { - deadKeyState = 0 - length = 0 - let status = UCKeyTranslate( - layoutPtr, - keyCode, - UInt16(kUCKeyActionDisplay), - 0, - kbType, - UInt32(kUCKeyTranslateNoDeadKeysMask), - &deadKeyState, - chars.count, - &length, - &chars - ) - guard status == noErr, length > 0 else { continue } - if Unicode.Scalar(chars[0]) == targetScalar { - return CGKeyCode(keyCode) - } - } - return qwertyFallback - } - } - /// The virtual key code for "v" in the current keyboard layout (used for Cmd+V paste). /// Re-evaluated on every call so runtime keyboard layout switches are picked up immediately. + /// Falls back to the ANSI "v" key code when the layout data is unavailable. private static var pasteVirtualKeyCode: CGKeyCode { - virtualKeyCode(for: "v", qwertyFallback: 9) + LayoutAwareKeyCode.virtualKeyCode(for: "v", qwertyFallback: CGKeyCode(kVK_ANSI_V)) } // MARK: - Focus helpers (shared) diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift index ec528172..7d23dd59 100644 --- a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift +++ b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift @@ -1,3 +1,5 @@ +import Carbon.HIToolbox +import CoreGraphics @testable import FluidVoice_Debug import Foundation import XCTest @@ -619,4 +621,38 @@ final class DictationE2ETests: XCTestCase { run: run ) } + + // MARK: - LayoutAwareKeyCode (shared Cmd+C / Cmd+V key-code lookup, issue #259) + + func testLayoutAwareKeyCode_resolvesLatinCharactersOnCurrentLayout() { + // "c" and "v" exist on every Latin keyboard layout, so the lookup must resolve a real + // key code rather than returning the fallback. We pass an out-of-band sentinel as the + // fallback so a successful lookup is provably distinct from the fallback path. + let sentinel = CGKeyCode(0xFFFF) + let cKey = LayoutAwareKeyCode.virtualKeyCode(for: "c", qwertyFallback: sentinel) + let vKey = LayoutAwareKeyCode.virtualKeyCode(for: "v", qwertyFallback: sentinel) + + XCTAssertNotEqual(cKey, sentinel, "Expected to resolve a real key code for \"c\"") + XCTAssertNotEqual(vKey, sentinel, "Expected to resolve a real key code for \"v\"") + XCTAssertLessThan(cKey, 128, "Virtual key codes are in 0..<128") + XCTAssertLessThan(vKey, 128, "Virtual key codes are in 0..<128") + } + + func testLayoutAwareKeyCode_fallsBackForUnmappableCharacter() { + // No physical key produces this emoji, so the scan finds nothing and must return the + // supplied fallback — exercising the layout-unavailable / not-found path deterministically. + let sentinel = CGKeyCode(0xABCD) + let result = LayoutAwareKeyCode.virtualKeyCode(for: "🍎", qwertyFallback: sentinel) + + XCTAssertEqual(result, sentinel) + } + + func testLayoutAwareKeyCode_isDeterministicAcrossCalls() { + // Re-evaluated on every call (so a runtime layout switch is picked up) but stable for a + // fixed layout: two back-to-back lookups must agree. + let first = LayoutAwareKeyCode.virtualKeyCode(for: "v", qwertyFallback: CGKeyCode(kVK_ANSI_V)) + let second = LayoutAwareKeyCode.virtualKeyCode(for: "v", qwertyFallback: CGKeyCode(kVK_ANSI_V)) + + XCTAssertEqual(first, second) + } } From f006a407119e4821765e89fcc2f512de92eae2f1 Mon Sep 17 00:00:00 2001 From: postoso Date: Fri, 19 Jun 2026 03:21:57 -0400 Subject: [PATCH 3/4] fix: coordinate clipboard-read fallback with TypingService pasteboard session (#259) The Write/Rewrite selection-read fallback (TextSelectionService. getSelectedTextViaClipboard) snapshotted and restored NSPasteboard.general around a synthetic Cmd+C without coordinating with TypingService's paste path. TypingService.withTemporaryPasteboardString keeps a temporary string on the same pasteboard and defers the user-clipboard restore onto a background queue, holding pasteboardSessionSemaphore until that async restore completes (up to ~5s later). If a new rewrite's selection read sampled changeCount while a prior paste's restore was still pending, the paste's writes were mistaken for the user's copy (wrong selection captured) and the two snapshot/restores fought over the real pasteboard. Extract the semaphore + restore queue out of TypingService into a shared PasteboardSession type (mirroring the PasteboardSnapshot / LayoutAwareKeyCode extractions) so both subsystems share one mutual-exclusion primitive instead of racing parallel locks. The selection read now acquires the same session (bounded wait) around its snapshot -> Cmd+C -> read -> restore sequence, so it cannot sample changeCount or touch the pasteboard while a paste session is in flight. The acquire is a timed wait (tryBeginExclusive). This is load-bearing for deadlock-freedom: the paste path resolves the layout-aware Cmd+V key code via LayoutAwareKeyCode, which does DispatchQueue.main.sync when off-main, so a background paste can hold the session while waiting on the main thread; a blocking acquire on the main-thread selection read would let the two wait on each other. The timeout breaks that inversion (on expiry the read proceeds unguarded, no worse than the prior uncoordinated behavior) and bounds any main-thread stall. TypingService keeps identical behavior (pure rename to the shared session). Adds PasteboardSession unit tests; LayoutAwareKeyCode tests still pass. --- .../Fluid/Services/PasteboardSession.swift | 64 +++++++++++++++++++ .../Fluid/Services/TextSelectionService.swift | 35 ++++++++++ Sources/Fluid/Services/TypingService.swift | 10 ++- .../DictationE2ETests.swift | 47 ++++++++++++++ 4 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 Sources/Fluid/Services/PasteboardSession.swift diff --git a/Sources/Fluid/Services/PasteboardSession.swift b/Sources/Fluid/Services/PasteboardSession.swift new file mode 100644 index 00000000..442025df --- /dev/null +++ b/Sources/Fluid/Services/PasteboardSession.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Serializes access to `NSPasteboard.general` across Fluid's synthetic copy/paste operations. +/// +/// Two subsystems mutate the shared pasteboard with snapshot/restore semantics: +/// - `TypingService` paste insertion temporarily writes the to-be-pasted text, posts Cmd+V, +/// then restores the user's clipboard on a background queue *after* a verification window +/// (up to several seconds later) — so its pasteboard "session" outlives the synchronous call. +/// - `TextSelectionService` selection-read fallback snapshots the clipboard, posts Cmd+C, +/// reads the copied selection, then restores the snapshot. +/// +/// Without coordination these overlap: a still-pending paste restore bumps `changeCount` +/// (and rewrites the pasteboard) while a selection read is polling for *its* Cmd+C, so the +/// previous insertion text gets mistaken for the freshly-copied selection and the wrong +/// snapshot is restored. This type is the single primitive both paths share to make their +/// pasteboard sessions mutually exclusive (issue #259). +/// +/// The session is a counting semaphore with value 1. A paste path holds it from the +/// synchronous setup through the async restore (handing the `signal()` off to `restoreQueue`); +/// the selection-read path holds it only for its own short critical section and always +/// releases before returning. +enum PasteboardSession { + /// One-at-a-time gate around `NSPasteboard.general` mutations. Mirrors the lock that + /// previously lived privately in `TypingService`; shared here so the selection-read path + /// composes with it instead of racing a parallel lock. + private static let semaphore = DispatchSemaphore(value: 1) + + /// Serial queue on which `TypingService` performs its deferred pasteboard restore and + /// releases the session. Kept here so the session's lifetime (acquire → async restore → + /// release) is owned by one type. + static let restoreQueue = DispatchQueue(label: "PasteboardSession.Restore", qos: .utility) + + /// Acquires the session, blocking until it is free. Used by the paste path, which runs on + /// a background queue and intentionally serializes behind any in-flight session. + static func beginExclusive() { + self.semaphore.wait() + } + + /// Attempts to acquire the session within `timeoutMicros`. Returns `true` if acquired (the + /// caller then owns the session and must call `endExclusive()`), `false` on timeout (the + /// caller did **not** acquire it and must **not** call `endExclusive()`). + /// + /// Used by the main-thread selection-read fallback. The bounded timeout is load-bearing for + /// deadlock-freedom, not just UX: the paste path runs its paste closure *while holding the + /// session*, and that closure resolves the layout-aware key code, which does + /// `DispatchQueue.main.sync` when invoked off the main thread (see `LayoutAwareKeyCode`). So + /// during its brief key-code-lookup window a background paste holds the session *and* is + /// waiting on the main thread — if the main-thread selection read blocked on the session + /// forever, the two would wait on each other. The timeout breaks that cycle: on expiry the + /// selection read returns, the main run loop drains, the paste's `main.sync` completes, and + /// the session is released. On timeout the selection read proceeds without the lock — no + /// worse than the uncoordinated behavior that preceded this guard. **Do not convert this to + /// an unbounded blocking wait: the timeout is what prevents the inversion from hanging.** + static func tryBeginExclusive(timeoutMicros: useconds_t) -> Bool { + let deadline = DispatchTime.now() + .microseconds(Int(timeoutMicros)) + return self.semaphore.wait(timeout: deadline) == .success + } + + /// Releases the session previously acquired via `beginExclusive()` or a `true` result from + /// `tryBeginExclusive(timeoutMicros:)`. + static func endExclusive() { + self.semaphore.signal() + } +} diff --git a/Sources/Fluid/Services/TextSelectionService.swift b/Sources/Fluid/Services/TextSelectionService.swift index 09278a8b..4b81d4f3 100644 --- a/Sources/Fluid/Services/TextSelectionService.swift +++ b/Sources/Fluid/Services/TextSelectionService.swift @@ -66,6 +66,33 @@ final class TextSelectionService { /// the poll is bounded by `Self.clipboardCopyTimeoutMicros` so a missing selection (no /// pasteboard write) degrades to `nil` quickly rather than hanging. private func getSelectedTextViaClipboard() -> String? { + // Coordinate with TypingService's pasteboard session. A paste insertion keeps the + // user's clipboard "in flight" — it writes the to-be-pasted text, posts Cmd+V, and + // restores the snapshot on a background queue up to several seconds later — all under + // the shared `PasteboardSession`. If we sampled `changeCount` and posted our Cmd+C + // while that restore was still pending, the paste's writes would masquerade as our + // copy (wrong selection captured) and our snapshot/restore would fight the paste's. + // Acquiring the same session makes the two mutually exclusive (issue #259). + // + // This runs on the main thread for Write/Rewrite mode, so the acquire is BOUNDED. The + // timeout is load-bearing for deadlock-freedom, not just UX: the paste path holds the + // session while running its paste closure, which resolves the layout-aware Cmd+V key + // code via `LayoutAwareKeyCode`, which does `DispatchQueue.main.sync` when off-main. So a + // background paste can hold the session *and* be waiting on the main thread; a blocking + // acquire here would let the two wait on each other. The timed wait breaks that — on + // timeout we proceed unguarded (no worse than the uncoordinated behavior this guard + // replaces), the main run loop drains, the paste's `main.sync` completes, and the + // session frees. We always release before returning (the deferred `endExclusive`). + let acquiredSession = PasteboardSession.tryBeginExclusive(timeoutMicros: Self.pasteboardSessionWaitMicros) + if !acquiredSession { + self.diag("Clipboard fallback: proceeding without pasteboard session (wait timed out)") + } + defer { + if acquiredSession { + PasteboardSession.endExclusive() + } + } + let pasteboard = NSPasteboard.general let snapshot = PasteboardSnapshot.capture(from: pasteboard) let changeCountBeforeCopy = pasteboard.changeCount @@ -154,6 +181,14 @@ final class TextSelectionService { /// of milliseconds, and exceeding this just means "treat as no selection." private static let clipboardCopyTimeoutMicros: useconds_t = 300_000 + /// Upper bound on how long to wait for an in-flight `TypingService` pasteboard session to + /// finish before reading the selection. Covers TypingService's full async-restore window + /// (`restoreDelayMicros` = 5 s) plus margin, so a genuinely pending paste restore completes + /// before we sample `changeCount`. Capped so a stuck session can't freeze the main thread: + /// on timeout we proceed without the session guard (no worse than the prior behavior). In + /// practice the session is released as soon as the paste is verified, far sooner than this. + private static let pasteboardSessionWaitMicros: useconds_t = 5_500_000 + /// Restores `snapshot` onto `pasteboard`, then briefly watches for a *late* pasteboard /// write and re-restores if one lands. /// diff --git a/Sources/Fluid/Services/TypingService.swift b/Sources/Fluid/Services/TypingService.swift index d6b13311..64130080 100644 --- a/Sources/Fluid/Services/TypingService.swift +++ b/Sources/Fluid/Services/TypingService.swift @@ -43,8 +43,6 @@ final class TypingService { } private static let focusSnapshotQueue = DispatchQueue(label: "TypingService.FocusSnapshot") - private static let pasteboardSessionSemaphore = DispatchSemaphore(value: 1) - private static let pasteboardRestoreQueue = DispatchQueue(label: "TypingService.PasteboardRestore", qos: .utility) private static var focusSnapshot: FocusSnapshot? private var textInsertionMode: SettingsStore.TextInsertionMode { @@ -473,11 +471,11 @@ final class TypingService { restoreDelayMicros: useconds_t, action: () -> Bool ) -> Bool { - Self.pasteboardSessionSemaphore.wait() + PasteboardSession.beginExclusive() var releasesPasteboardSessionOnReturn = true defer { if releasesPasteboardSessionOnReturn { - Self.pasteboardSessionSemaphore.signal() + PasteboardSession.endExclusive() } } @@ -500,8 +498,8 @@ final class TypingService { } releasesPasteboardSessionOnReturn = false - Self.pasteboardRestoreQueue.async { - defer { Self.pasteboardSessionSemaphore.signal() } + PasteboardSession.restoreQueue.async { + defer { PasteboardSession.endExclusive() } _ = self.waitForFocusedTextVerification( from: focusedTextSnapshot, expectedText: text, diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift index 7d23dd59..7aa39444 100644 --- a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift +++ b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift @@ -655,4 +655,51 @@ final class DictationE2ETests: XCTestCase { XCTAssertEqual(first, second) } + + // MARK: - PasteboardSession (shared pasteboard mutual-exclusion guard, issue #259) + + func testPasteboardSession_isFreeInitiallyAndReleases() { + // Acquiring a free session must succeed immediately; after releasing, the next + // acquire must succeed again (the signal is balanced, value returns to 1). + XCTAssertTrue(PasteboardSession.tryBeginExclusive(timeoutMicros: 50_000)) + PasteboardSession.endExclusive() + XCTAssertTrue(PasteboardSession.tryBeginExclusive(timeoutMicros: 50_000)) + PasteboardSession.endExclusive() + } + + func testPasteboardSession_blocksWhileHeldThenSucceedsAfterRelease() { + // Model the real race: a paste path holds the session on a background queue while the + // (main-thread) selection-read path tries to acquire it. The bounded attempt must FAIL + // while held, then SUCCEED once the holder releases — proving mutual exclusion and the + // bounded-wait fallback both work. + let acquiredByHolder = DispatchSemaphore(value: 0) + let holderMayRelease = DispatchSemaphore(value: 0) + + DispatchQueue.global(qos: .userInitiated).async { + PasteboardSession.beginExclusive() + acquiredByHolder.signal() + holderMayRelease.wait() + PasteboardSession.endExclusive() + } + + // Wait until the background holder owns the session. + XCTAssertEqual(acquiredByHolder.wait(timeout: .now() + 2.0), .success) + + // While held, a short bounded attempt must time out (not acquire, not hang). + XCTAssertFalse( + PasteboardSession.tryBeginExclusive(timeoutMicros: 100_000), + "Session is held by the background holder; bounded attempt must time out" + ) + + // Release the holder; the session becomes free. + holderMayRelease.signal() + + // Now an acquire must succeed (poll briefly to absorb the cross-thread handoff). + var acquired = false + for _ in 0..<20 where !acquired { + acquired = PasteboardSession.tryBeginExclusive(timeoutMicros: 100_000) + } + XCTAssertTrue(acquired, "Session must be acquirable after the holder releases") + PasteboardSession.endExclusive() + } } From c7fbc7fdb5a6e3fadfc9c5bf216e79e67026e214 Mon Sep 17 00:00:00 2001 From: postoso Date: Fri, 19 Jun 2026 04:58:01 -0400 Subject: [PATCH 4/4] Fix #259 clipboard fallback review findings Use a short non-blocking pasteboard session wait and skip the synthetic-copy selection fallback on contention to avoid main-thread stalls. Only restore the pasteboard after an observed copy write, preserving complex clipboard contents when Cmd+C times out without changing the pasteboard. --- .../Fluid/Services/PasteboardSession.swift | 9 +- .../Fluid/Services/TextSelectionService.swift | 106 ++++++++++-------- 2 files changed, 67 insertions(+), 48 deletions(-) diff --git a/Sources/Fluid/Services/PasteboardSession.swift b/Sources/Fluid/Services/PasteboardSession.swift index 442025df..13c5a3f9 100644 --- a/Sources/Fluid/Services/PasteboardSession.swift +++ b/Sources/Fluid/Services/PasteboardSession.swift @@ -47,10 +47,11 @@ enum PasteboardSession { /// during its brief key-code-lookup window a background paste holds the session *and* is /// waiting on the main thread — if the main-thread selection read blocked on the session /// forever, the two would wait on each other. The timeout breaks that cycle: on expiry the - /// selection read returns, the main run loop drains, the paste's `main.sync` completes, and - /// the session is released. On timeout the selection read proceeds without the lock — no - /// worse than the uncoordinated behavior that preceded this guard. **Do not convert this to - /// an unbounded blocking wait: the timeout is what prevents the inversion from hanging.** + /// selection read skips its clipboard fallback, the main run loop drains, the paste's + /// `main.sync` completes, and the session is released. Callers must not proceed to mutate + /// or sample the pasteboard unguarded after a timeout, because that re-opens the + /// cross-session race this guard exists to prevent. **Do not convert this to an unbounded + /// blocking wait: the timeout is what prevents the inversion from hanging.** static func tryBeginExclusive(timeoutMicros: useconds_t) -> Bool { let deadline = DispatchTime.now() + .microseconds(Int(timeoutMicros)) return self.semaphore.wait(timeout: deadline) == .success diff --git a/Sources/Fluid/Services/TextSelectionService.swift b/Sources/Fluid/Services/TextSelectionService.swift index 4b81d4f3..449c97b0 100644 --- a/Sources/Fluid/Services/TextSelectionService.swift +++ b/Sources/Fluid/Services/TextSelectionService.swift @@ -74,23 +74,24 @@ final class TextSelectionService { // copy (wrong selection captured) and our snapshot/restore would fight the paste's. // Acquiring the same session makes the two mutually exclusive (issue #259). // - // This runs on the main thread for Write/Rewrite mode, so the acquire is BOUNDED. The - // timeout is load-bearing for deadlock-freedom, not just UX: the paste path holds the - // session while running its paste closure, which resolves the layout-aware Cmd+V key - // code via `LayoutAwareKeyCode`, which does `DispatchQueue.main.sync` when off-main. So a - // background paste can hold the session *and* be waiting on the main thread; a blocking - // acquire here would let the two wait on each other. The timed wait breaks that — on - // timeout we proceed unguarded (no worse than the uncoordinated behavior this guard - // replaces), the main run loop drains, the paste's `main.sync` completes, and the - // session frees. We always release before returning (the deferred `endExclusive`). + // This runs on the main thread for Write/Rewrite mode, so the acquire is short and + // BOUNDED. The timeout is load-bearing for deadlock-freedom, not just UX: the paste + // path holds the session while running its paste closure, which resolves the + // layout-aware Cmd+V key code via `LayoutAwareKeyCode`, which does + // `DispatchQueue.main.sync` when off-main. So a background paste can hold the session + // *and* be waiting on the main thread; a blocking acquire here would let the two wait + // on each other. The short timed wait breaks that inversion. On timeout we skip the + // clipboard fallback entirely (degrading to "no selection") rather than sampling + // `changeCount` or posting Cmd+C without the guard, which would re-open the + // cross-session race this coordination prevents. We always release before returning + // (the deferred `endExclusive`). let acquiredSession = PasteboardSession.tryBeginExclusive(timeoutMicros: Self.pasteboardSessionWaitMicros) - if !acquiredSession { - self.diag("Clipboard fallback: proceeding without pasteboard session (wait timed out)") + guard acquiredSession else { + self.diag("Clipboard fallback: skipped because pasteboard session is busy") + return nil } defer { - if acquiredSession { - PasteboardSession.endExclusive() - } + PasteboardSession.endExclusive() } let pasteboard = NSPasteboard.general @@ -115,10 +116,16 @@ final class TextSelectionService { // Read the copied selection only if the pasteboard actually changed. let copiedText = didChange ? pasteboard.string(forType: .string) : nil - // Always restore the user's previous pasteboard, then defend that restore against a - // late synthetic-copy write (a slow/busy target app processing our Cmd+C after the - // read timeout would otherwise clobber the restored clipboard — see the method doc). - self.restoreClipboardDefensively(snapshot, to: pasteboard) + // Restore only in response to a real pasteboard write. If Cmd+C never changed the + // pasteboard (usually no selection), clearing and rewriting the snapshot would degrade + // complex clipboard contents that `data(forType:)` could not faithfully capture. The + // helper still watches briefly for a late copy write and restores if one appears. + self.restoreClipboardDefensively( + snapshot, + to: pasteboard, + changeCountBeforeCopy: changeCountBeforeCopy, + didObserveCopyWrite: didChange + ) guard didChange else { self.diag("Clipboard fallback: pasteboard unchanged within timeout (no selection?)") @@ -181,36 +188,46 @@ final class TextSelectionService { /// of milliseconds, and exceeding this just means "treat as no selection." private static let clipboardCopyTimeoutMicros: useconds_t = 300_000 - /// Upper bound on how long to wait for an in-flight `TypingService` pasteboard session to - /// finish before reading the selection. Covers TypingService's full async-restore window - /// (`restoreDelayMicros` = 5 s) plus margin, so a genuinely pending paste restore completes - /// before we sample `changeCount`. Capped so a stuck session can't freeze the main thread: - /// on timeout we proceed without the session guard (no worse than the prior behavior). In - /// practice the session is released as soon as the paste is verified, far sooner than this. - private static let pasteboardSessionWaitMicros: useconds_t = 5_500_000 + /// Upper bound on how long to wait for an in-flight `TypingService` pasteboard session + /// before reading the selection. Kept comparable to `clipboardCopyTimeoutMicros`: long + /// enough to absorb normal handoff jitter, short enough that a paste verification timeout + /// in AX-opaque apps cannot freeze the main thread for seconds. If this expires, the + /// clipboard fallback is skipped rather than run without the session guard. + private static let pasteboardSessionWaitMicros: useconds_t = 250_000 - /// Restores `snapshot` onto `pasteboard`, then briefly watches for a *late* pasteboard - /// write and re-restores if one lands. + /// Restores `snapshot` onto `pasteboard` only after observing a pasteboard write, then + /// briefly watches for a *late* pasteboard write and re-restores if one lands. /// /// The hazard: our synthetic Cmd+C is delivered asynchronously. If the target app is /// slow/busy it may process the copy *after* `clipboardCopyTimeoutMicros` has elapsed and - /// we've already restored — its delayed write would then clobber the user's clipboard, - /// leaving the copied selection (or stale data) where the user's content should be. That - /// breaks the "always restore the user's clipboard" guarantee. + /// we've already given up — its delayed write would then clobber the user's clipboard, + /// leaving the copied selection (or stale data) where the user's content should be. /// - /// Defense: after restoring, record the change count our own restore produced and poll - /// for a short, bounded settle window. Any further change is an external write — almost - /// certainly the app's delayed copy — so we restore again (capped at `maxLateCopyRestores` - /// re-restores within `lateCopySettleMicros` total). The loop is doubly bounded (time and - /// retry count) so it cannot hang; because each pass ends by re-restoring the user's - /// snapshot, it cannot leave the selection text on the clipboard. + /// Defense: if the initial copy write landed, restore immediately; otherwise leave the + /// pasteboard untouched. Then record the relevant change count and poll for a short, + /// bounded settle window. Any further change is an external write — almost certainly the + /// app's delayed copy — so we restore then (capped at `maxLateCopyRestores` restores within + /// `lateCopySettleMicros` total). The loop is doubly bounded (time and retry count) so it + /// cannot hang. Crucially, a timed-out Cmd+C with no late write never clears and rewrites + /// the pasteboard, preserving complex clipboard contents that a snapshot may only partially + /// capture. /// /// Runs synchronously on the caller's thread (the main thread for Write/Rewrite mode), /// consistent with the copy-wait above; the added latency is bounded by /// `lateCopySettleMicros` and only paid on this last-resort fallback path. - private func restoreClipboardDefensively(_ snapshot: PasteboardSnapshot, to pasteboard: NSPasteboard) { - snapshot.restore(to: pasteboard) - var lastRestoreChangeCount = pasteboard.changeCount + private func restoreClipboardDefensively( + _ snapshot: PasteboardSnapshot, + to pasteboard: NSPasteboard, + changeCountBeforeCopy: Int, + didObserveCopyWrite: Bool + ) { + var lastObservedChangeCount: Int + if didObserveCopyWrite { + snapshot.restore(to: pasteboard) + lastObservedChangeCount = pasteboard.changeCount + } else { + lastObservedChangeCount = changeCountBeforeCopy + } var restoresRemaining = Self.maxLateCopyRestores let pollMicros: useconds_t = 10_000 @@ -220,22 +237,23 @@ final class TextSelectionService { usleep(pollMicros) waited += pollMicros - guard pasteboard.changeCount != lastRestoreChangeCount else { continue } + guard pasteboard.changeCount != lastObservedChangeCount else { continue } - // A write landed after our restore — the target app's delayed synthetic copy - // clobbering the user's clipboard. Restore the user's snapshot again. + // A write landed after our restore or after the initial copy timeout — the target + // app's delayed synthetic copy clobbering the user's clipboard. Restore the user's + // snapshot in response to that observed write. guard restoresRemaining > 0 else { self.diag("Clipboard fallback: late write persisted past \(Self.maxLateCopyRestores) restores; leaving user clipboard restored as of last attempt") return } restoresRemaining -= 1 snapshot.restore(to: pasteboard) - lastRestoreChangeCount = pasteboard.changeCount + lastObservedChangeCount = pasteboard.changeCount self.diag("Clipboard fallback: re-restored user clipboard after a late synthetic-copy write") } // Reconcile a write that landed in the final poll interval (just past the loop edge). - if pasteboard.changeCount != lastRestoreChangeCount, restoresRemaining > 0 { + if pasteboard.changeCount != lastObservedChangeCount, restoresRemaining > 0 { snapshot.restore(to: pasteboard) self.diag("Clipboard fallback: final re-restore after a late write at the settle-window edge") }