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/PasteboardSession.swift b/Sources/Fluid/Services/PasteboardSession.swift new file mode 100644 index 00000000..13c5a3f9 --- /dev/null +++ b/Sources/Fluid/Services/PasteboardSession.swift @@ -0,0 +1,65 @@ +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 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 + } + + /// 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/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..449c97b0 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,231 @@ 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? { + // 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 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) + guard acquiredSession else { + self.diag("Clipboard fallback: skipped because pasteboard session is busy") + return nil + } + defer { + PasteboardSession.endExclusive() + } + + 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 + + // 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?)") + 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. + /// + /// 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 = 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 { + 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 + + /// 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` 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 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: 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, + 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 + var waited: useconds_t = 0 + + while waited < Self.lateCopySettleMicros { + usleep(pollMicros) + waited += pollMicros + + guard pasteboard.changeCount != lastObservedChangeCount else { continue } + + // 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) + 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 != lastObservedChangeCount, 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 259c9a05..64130080 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? @@ -51,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 { @@ -61,69 +51,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) @@ -534,67 +466,40 @@ 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, action: () -> Bool ) -> Bool { - Self.pasteboardSessionSemaphore.wait() + PasteboardSession.beginExclusive() var releasesPasteboardSessionOnReturn = true defer { if releasesPasteboardSessionOnReturn { - Self.pasteboardSessionSemaphore.signal() + PasteboardSession.endExclusive() } } 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 } releasesPasteboardSessionOnReturn = false - Self.pasteboardRestoreQueue.async { - defer { Self.pasteboardSessionSemaphore.signal() } + PasteboardSession.restoreQueue.async { + defer { PasteboardSession.endExclusive() } _ = self.waitForFocusedTextVerification( from: focusedTextSnapshot, expectedText: text, @@ -604,7 +509,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") diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift index ec528172..7aa39444 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,85 @@ 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) + } + + // 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() + } }