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