Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions docs/testing/clipboard-restore-432.md
Original file line number Diff line number Diff line change
@@ -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").
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
}
Expand Down
Loading