Skip to content

Commit ad96e44

Browse files
committed
fix(insertion): harden text insertion with multi-tier clipboard verification
1 parent 16a6b9c commit ad96e44

File tree

2 files changed

+115
-28
lines changed

2 files changed

+115
-28
lines changed

OpenDictation/Core/Services/DictationStateMachine.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,13 @@ final class DictationStateMachine: ObservableObject {
186186
} else {
187187
// Try to insert text, check if it was actually inserted or just clipboard
188188
let wasInserted = onInsertText?(trimmed) ?? false
189-
state = wasInserted ? .success : .copiedToClipboard
189+
if wasInserted {
190+
state = .success
191+
} else {
192+
// Insertion failed (clipboard verification timeout or missing permissions)
193+
// Trigger error state for loud feedback (shake + sound)
194+
state = .error(message: "Failed to insert text. Please try again.")
195+
}
190196
}
191197
}
192198

OpenDictation/Core/Services/TextInsertionService.swift

Lines changed: 108 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ import os.log
77
///
88
/// Implements the "Always Paste" strategy with Apple-quality hardening:
99
/// 1. Save current clipboard state (all types, not just string)
10-
/// 2. Set text to clipboard with verification
10+
/// 2. Set text to clipboard with multi-tier changeCount verification
1111
/// 3. Simulate Cmd+V via CGEvent with explicit key events
1212
/// 4. Restore previous clipboard state after synchronous delay
1313
///
1414
/// Hardening techniques from industry analysis:
1515
/// - `.combinedSessionState` for proper event coordination
16-
/// - 50ms clipboard stabilization delay before paste
17-
/// - 150ms synchronous delay for paste completion
16+
/// - 3-attempt retry loop for clipboard writes with escalating delays
17+
/// - changeCount polling (200ms timeout) for asynchronous commitment verification
1818
/// - Full pasteboard preservation (all types)
1919
/// - Concurrency lock to prevent overlapping operations
2020
/// - Event source suppression to prevent input interference
@@ -79,34 +79,62 @@ final class TextInsertionService {
7979
// 2. Save previous clipboard contents (all types)
8080
let savedContents = savePasteboardContents(pasteboard)
8181

82-
// 3. Set new text to clipboard
83-
pasteboard.clearContents()
84-
pasteboard.setString(text, forType: .string)
82+
// 3. Robust Write-Verify Cycle
83+
// We attempt to write and verify the clipboard up to 3 times with escalating delays.
84+
// This handles rare macOS pasteboard race conditions or system-level coalescing.
85+
var verified = false
86+
let maxAttempts = 3
8587

86-
// 4. Verify clipboard content was set correctly
87-
guard pasteboard.string(forType: .string) == text else {
88-
logger.error("Clipboard content not set correctly - aborting paste")
89-
restorePasteboardContents(savedContents, to: pasteboard)
90-
return false
88+
for attempt in 1...maxAttempts {
89+
let changeCountBefore = pasteboard.changeCount
90+
91+
if attempt > 1 {
92+
let delay = Double(attempt - 1) * 0.05 // 50ms, 100ms
93+
logger.info("Clipboard write retry \(attempt) after \(delay)s delay")
94+
Thread.sleep(forTimeInterval: delay)
95+
}
96+
97+
// Write new content
98+
pasteboard.clearContents()
99+
pasteboard.setString(text, forType: .string)
100+
101+
// Wait for commit (increased timeout to 200ms for robustness)
102+
let committed = waitForClipboardCommit(
103+
pasteboard: pasteboard,
104+
expectedChangeCount: changeCountBefore + 1,
105+
timeout: 0.2
106+
)
107+
108+
if committed && verifyClipboardContent(pasteboard: pasteboard, expected: text) {
109+
verified = true
110+
break
111+
}
112+
113+
logger.warning("Clipboard verification failed on attempt \(attempt)/\(maxAttempts)")
91114
}
92-
93-
// 5. Wait for clipboard to stabilize (50ms)
94-
Thread.sleep(forTimeInterval: 0.05)
95-
96-
// 6. Simulate Cmd+V
97-
simulatePaste()
98-
99-
// 7. Wait synchronously for paste to complete (150ms)
100-
Thread.sleep(forTimeInterval: 0.15)
101-
102-
// 8. Restore clipboard if it still contains our text
103-
// (avoids overwriting if user copied something else)
104-
if let current = pasteboard.string(forType: .string), current == text {
115+
if verified {
116+
// 4. Simulate Cmd+V
117+
simulatePaste()
118+
119+
// 5. Wait synchronously for paste to complete (150ms)
120+
// This gives the target application time to read the clipboard.
121+
Thread.sleep(forTimeInterval: 0.15)
122+
123+
// 6. Restore clipboard if it still contains our text
124+
// (avoids overwriting if user copied something else in the meantime)
125+
if let current = pasteboard.string(forType: .string), current == text {
126+
restorePasteboardContents(savedContents, to: pasteboard)
127+
logger.debug("Clipboard restored")
128+
}
129+
return true
130+
} else {
131+
// 7. LOUD FAILURE: If we couldn't verify the write after all retries,
132+
// we restore the user's original clipboard content to maintain system consistency.
133+
// Returning false will trigger an error state (shake + sound) in the UI.
134+
logger.error("CRITICAL: Failed to verify clipboard content after \(maxAttempts) attempts. Aborting paste and restoring original clipboard.")
105135
restorePasteboardContents(savedContents, to: pasteboard)
106-
logger.debug("Clipboard restored")
136+
return false
107137
}
108-
109-
return true
110138
}
111139

112140
// MARK: - Private Helpers
@@ -200,4 +228,57 @@ final class TextInsertionService {
200228
vUp.post(tap: .cghidEventTap)
201229
cmdUp.post(tap: .cghidEventTap)
202230
}
231+
232+
// MARK: - Bulletproof Verification Helpers
233+
234+
/// Polls until pasteboard.changeCount reaches expected value.
235+
///
236+
/// - Parameters:
237+
/// - pasteboard: The pasteboard to monitor.
238+
/// - expectedChangeCount: The count we're waiting for.
239+
/// - timeout: Maximum time to wait in seconds.
240+
private func waitForClipboardCommit(
241+
pasteboard: NSPasteboard,
242+
expectedChangeCount: Int,
243+
timeout: TimeInterval
244+
) -> Bool {
245+
let deadline = Date().addingTimeInterval(timeout)
246+
let pollInterval: TimeInterval = 0.005 // 5ms polling
247+
248+
while Date() < deadline {
249+
if pasteboard.changeCount >= expectedChangeCount {
250+
return true
251+
}
252+
Thread.sleep(forTimeInterval: pollInterval)
253+
}
254+
255+
return false
256+
}
257+
258+
/// Verifies clipboard content matches expected text with multiple retries.
259+
///
260+
/// - Parameters:
261+
/// - pasteboard: The pasteboard to check.
262+
/// - expected: The text we expect to find.
263+
/// - maxRetries: Number of attempts before giving up.
264+
private func verifyClipboardContent(
265+
pasteboard: NSPasteboard,
266+
expected: String,
267+
maxRetries: Int = 3
268+
) -> Bool {
269+
for attempt in 1...maxRetries {
270+
if let current = pasteboard.string(forType: .string), current == expected {
271+
return true
272+
}
273+
274+
if attempt < maxRetries {
275+
// Short wait before retry to let system state settle
276+
Thread.sleep(forTimeInterval: 0.01) // 10ms
277+
}
278+
}
279+
280+
let actual = pasteboard.string(forType: .string) ?? "<nil>"
281+
logger.error("Clipboard verification failed after \(maxRetries) retries. Expected length: \(expected.count), Actual length: \(actual.count)")
282+
return false
283+
}
203284
}

0 commit comments

Comments
 (0)