From 5874f35d3eb0b9b75daa115fad0c229424cf45e1 Mon Sep 17 00:00:00 2001 From: Nabzx Date: Sat, 26 Sep 2026 18:50:25 +0100 Subject: [PATCH] Paste restore: save every clipboard type, not just the string coercion (#432) RealClipboardPaster only ever saved pasteboard.string(forType: .string) before borrowing the clipboard for the dictated text. A copied image or a Finder file reference has no plain-string representation, so that came back nil - after the paste, the restore branch never fired and the user's original clipboard content was left cleared, not restored. Now saves the full item set (every type on every NSPasteboardItem) and writes it all back on restore. The existing changeCount race guard - skip the restore if the user copied something else while it was borrowed - is unchanged. Compile-verified only (swift build is clean); real NSPasteboard behaviour needs a Mac, checklist in docs/testing/clipboard-restore-432.md. Claude-Session: https://claude.ai/code/session_0144sxo5CJiyk99vaR7sgjuj --- docs/testing/clipboard-restore-432.md | 40 +++++++++++++++++++ .../AccessibilityInjection/RealAdapters.swift | 25 +++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 docs/testing/clipboard-restore-432.md diff --git a/docs/testing/clipboard-restore-432.md b/docs/testing/clipboard-restore-432.md new file mode 100644 index 0000000..7b55913 --- /dev/null +++ b/docs/testing/clipboard-restore-432.md @@ -0,0 +1,40 @@ +# Manual check: paste restore preserves non-text clipboard content (#432) + +`RealClipboardPaster.paste` runs inside `accessibility-helper`, a real Mac +process reading `NSPasteboard.general`. This sandbox has no GUI clipboard +to test against, so the fix is compile-verified only (`swift build` is +clean) - everything below needs a real Mac. + +The bug: the paste path only ever saved `pasteboard.string(forType: .string)` +before borrowing the clipboard for the dictated text. Anything without a +plain-string representation - a copied image, a Finder file reference - +came back `nil`, so after the paste the pasteboard was left cleared +instead of restored. The fix now saves every type on every pasteboard +item and writes them all back. + +## Steps + +1. **Build the helper.** `npm run build:accessibility-helper` (or + `swift build --package-path native/accessibility-helper`). +2. **Trigger a paste-path delivery.** Paste-based delivery is rung 2 - + it only runs when a direct AX write isn't possible or fails + verification. A browser address bar, a terminal, or most Electron + apps (Slack, VS Code) are reliable ways to land on it. Check the + accessibility-helper's log output to confirm which rung actually ran. +3. **Copy an image**, e.g. `Cmd+Shift+Ctrl+4` for a screenshot-to-clipboard, + or copy an image from Preview/Finder. Then dictate into one of the + apps from step 2. After the dictation lands, paste (`Cmd+V`) into + Preview or Notes - the original image should still be there, not an + empty clipboard. +4. **Copy a Finder file reference** (select a file in Finder, `Cmd+C`), + dictate the same way, then paste into Finder - the file reference + should still paste normally. +5. **Regression: plain text still restores.** Copy some ordinary text, + dictate, then paste - the original text should come back exactly as + it did before this change. +6. **Regression: the race guard still holds.** Copy some text, start a + dictation, and while it's mid-flight copy something *else* before the + restore fires. The dictation's paste should still have landed, and + your second copy should survive untouched (console should show + "skipped the clipboard restore - the user copied something else while + it was borrowed"). diff --git a/native/accessibility-helper/Sources/AccessibilityInjection/RealAdapters.swift b/native/accessibility-helper/Sources/AccessibilityInjection/RealAdapters.swift index db3f217..f9eb6b6 100644 --- a/native/accessibility-helper/Sources/AccessibilityInjection/RealAdapters.swift +++ b/native/accessibility-helper/Sources/AccessibilityInjection/RealAdapters.swift @@ -360,9 +360,21 @@ public final class RealClipboardPaster: ClipboardPasting { // the user copies something else while our text is still sitting in // the clipboard, changeCount will have moved past what we set, and we // leave their copy alone rather than clobbering it. + // + // #432: saved as the raw data for every type on every item, not just + // the plain-string coercion - a copied image or a Finder file reference + // has no `.string` representation, so reading only that used to come + // back nil and the restore silently dropped the user's real clipboard + // content instead of putting it back. public func paste(text: String, verifyAgainst: AccessibilityTarget?) -> PasteResult { let pasteboard = NSPasteboard.general - let saved = pasteboard.string(forType: .string) + let savedItems: [[NSPasteboard.PasteboardType: Data]] = (pasteboard.pasteboardItems ?? []).map { item in + var data: [NSPasteboard.PasteboardType: Data] = [:] + for type in item.types { + data[type] = item.data(forType: type) + } + return data + } pasteboard.clearContents() pasteboard.setString(text, forType: .string) @@ -373,7 +385,16 @@ public final class RealClipboardPaster: ClipboardPasting { if pasteboard.changeCount == ourChangeCount { pasteboard.clearContents() - if let saved = saved { pasteboard.setString(saved, forType: .string) } + if !savedItems.isEmpty { + let restoredItems = savedItems.map { data -> NSPasteboardItem in + let item = NSPasteboardItem() + for (type, value) in data { + item.setData(value, forType: type) + } + return item + } + pasteboard.writeObjects(restoredItems) + } } else { log("skipped the clipboard restore - the user copied something else while it was borrowed") }