From 859c14d783dc31fe68550b114fc8adeea09dab2a Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sat, 13 Jun 2026 21:48:32 +0800
Subject: [PATCH] Sync macOS paste clipboard safety to X-ASR branch
---
privatevoice.src/internal/input/paste_darwin_test.go | 42 ++++++++++
privatevoice.src/internal/input/paste_darwin.go | 153 ++++++++++++++++++++++++++++++++++----
2 files changed, 178 insertions(+), 17 deletions(-)
diff --git a/privatevoice.src/internal/input/paste_darwin.go b/privatevoice.src/internal/input/paste_darwin.go
index cc54e46..bcd8aa2 100755
--- a/privatevoice.src/internal/input/paste_darwin.go
+++ b/privatevoice.src/internal/input/paste_darwin.go
@@ -24,6 +24,11 @@
[pb setString:str forType:NSPasteboardTypeString];
}
+static void clearClipboardText(void) {
+ NSPasteboard* pb = [NSPasteboard generalPasteboard];
+ [pb clearContents];
+}
+
static char* getClipboardText(void) {
NSPasteboard* pb = [NSPasteboard generalPasteboard];
NSString* str = [pb stringForType:NSPasteboardTypeString];
@@ -91,11 +96,33 @@
import (
"fmt"
+ "sync"
"time"
"unsafe"
)
+const (
+ darwinClipboardVerifyTimeout = 500 * time.Millisecond
+ darwinClipboardPollInterval = 15 * time.Millisecond
+ darwinClipboardRestoreDelay = 900 * time.Millisecond
+)
+
type darwinPaster struct{}
+
+type darwinClipboardSnapshot struct {
+ text string
+ hasText bool
+}
+
+type darwinPasteState struct {
+ mu sync.Mutex
+ generation uint64
+ pendingRestore bool
+ pendingText string
+ restoreTo darwinClipboardSnapshot
+}
+
+var globalDarwinPasteState darwinPasteState
func newPlatformPaster() Paster {
return &darwinPaster{}
@@ -106,31 +133,44 @@
return fmt.Errorf("accessibility permission is required to paste")
}
- var oldClip *string
+ state := &globalDarwinPasteState
+ state.mu.Lock()
+ state.generation++
+ generation := state.generation
+
+ originalClipboard := currentDarwinClipboardSnapshot()
if !keepClipboard {
- cOld := C.getClipboardText()
- if cOld != nil {
- old := C.GoString(cOld)
- oldClip = &old
- C.free(unsafe.Pointer(cOld))
- }
+ originalClipboard = resolveDarwinRestoreTarget(
+ originalClipboard,
+ state.pendingRestore,
+ state.pendingText,
+ state.restoreTo,
+ )
}
cstr := C.CString(text)
defer C.free(unsafe.Pointer(cstr))
C.setClipboardText(cstr)
- time.Sleep(60 * time.Millisecond)
- C.simulateCmdV()
-
- if !keepClipboard && oldClip != nil {
- go func(old string) {
- time.Sleep(600 * time.Millisecond)
- cOld := C.CString(old)
- defer C.free(unsafe.Pointer(cOld))
- C.setClipboardText(cOld)
- }(*oldClip)
+ if !waitForDarwinClipboardText(text, darwinClipboardVerifyTimeout) {
+ state.pendingRestore = false
+ state.mu.Unlock()
+ return fmt.Errorf("clipboard verification failed before paste")
}
+ C.simulateCmdV()
+
+ if keepClipboard {
+ state.pendingRestore = false
+ state.mu.Unlock()
+ return nil
+ }
+
+ state.pendingRestore = true
+ state.pendingText = text
+ state.restoreTo = originalClipboard
+ state.mu.Unlock()
+
+ go restoreDarwinClipboardAfterPaste(generation, text, originalClipboard)
return nil
}
@@ -143,3 +183,82 @@
}
return nil
}
+
+func currentDarwinClipboardSnapshot() darwinClipboardSnapshot {
+ cText := C.getClipboardText()
+ if cText == nil {
+ return darwinClipboardSnapshot{}
+ }
+ defer C.free(unsafe.Pointer(cText))
+ return darwinClipboardSnapshot{
+ text: C.GoString(cText),
+ hasText: true,
+ }
+}
+
+func resolveDarwinRestoreTarget(current darwinClipboardSnapshot, pending bool, pendingText string, restoreTo darwinClipboardSnapshot) darwinClipboardSnapshot {
+ if pending && current.hasText && current.text == pendingText {
+ return restoreTo
+ }
+ return current
+}
+
+func waitForDarwinClipboardText(expected string, timeout time.Duration) bool {
+ deadline := time.Now().Add(timeout)
+ for {
+ current := currentDarwinClipboardSnapshot()
+ if current.hasText && current.text == expected {
+ return true
+ }
+ if time.Now().After(deadline) {
+ return false
+ }
+ time.Sleep(darwinClipboardPollInterval)
+ }
+}
+
+func restoreDarwinClipboardAfterPaste(generation uint64, pastedText string, restoreTo darwinClipboardSnapshot) {
+ time.Sleep(darwinClipboardRestoreDelay)
+
+ state := &globalDarwinPasteState
+ state.mu.Lock()
+ defer state.mu.Unlock()
+
+ if state.generation != generation || !state.pendingRestore || state.pendingText != pastedText {
+ return
+ }
+
+ current := currentDarwinClipboardSnapshot()
+ if !shouldRestoreDarwinClipboard(
+ state.generation,
+ generation,
+ state.pendingRestore,
+ state.pendingText,
+ pastedText,
+ current,
+ ) {
+ state.pendingRestore = false
+ return
+ }
+
+ restoreDarwinClipboardSnapshot(restoreTo)
+ state.pendingRestore = false
+}
+
+func shouldRestoreDarwinClipboard(currentGeneration, restoreGeneration uint64, pending bool, pendingText, pastedText string, current darwinClipboardSnapshot) bool {
+ if currentGeneration != restoreGeneration || !pending || pendingText != pastedText {
+ return false
+ }
+ return current.hasText && current.text == pastedText
+}
+
+func restoreDarwinClipboardSnapshot(snapshot darwinClipboardSnapshot) {
+ if !snapshot.hasText {
+ C.clearClipboardText()
+ return
+ }
+
+ cText := C.CString(snapshot.text)
+ defer C.free(unsafe.Pointer(cText))
+ C.setClipboardText(cText)
+}
diff --git a/privatevoice.src/internal/input/paste_darwin_test.go b/privatevoice.src/internal/input/paste_darwin_test.go
new file mode 100644
index 0000000..2bdcbc9
--- /dev/null
+++ b/privatevoice.src/internal/input/paste_darwin_test.go
@@ -0,0 +1,42 @@
+//go:build darwin
+
+package input
+
+import "testing"
+
+func TestResolveDarwinRestoreTargetCarriesOriginalAcrossRapidPastes(t *testing.T) {
+ original := darwinClipboardSnapshot{text: "user clipboard", hasText: true}
+ current := darwinClipboardSnapshot{text: "first dictated text", hasText: true}
+
+ got := resolveDarwinRestoreTarget(current, true, "first dictated text", original)
+ if got != original {
+ t.Fatalf("restore target = %+v, want original %+v", got, original)
+ }
+}
+
+func TestResolveDarwinRestoreTargetUsesCurrentWhenClipboardChanged(t *testing.T) {
+ original := darwinClipboardSnapshot{text: "user clipboard", hasText: true}
+ current := darwinClipboardSnapshot{text: "external clipboard update", hasText: true}
+
+ got := resolveDarwinRestoreTarget(current, true, "first dictated text", original)
+ if got != current {
+ t.Fatalf("restore target = %+v, want current %+v", got, current)
+ }
+}
+
+func TestShouldRestoreDarwinClipboardRequiresCurrentGenerationAndText(t *testing.T) {
+ current := darwinClipboardSnapshot{text: "dictated text", hasText: true}
+
+ if !shouldRestoreDarwinClipboard(3, 3, true, "dictated text", "dictated text", current) {
+ t.Fatal("expected current generation with matching clipboard to restore")
+ }
+
+ if shouldRestoreDarwinClipboard(4, 3, true, "dictated text", "dictated text", current) {
+ t.Fatal("stale generation must not restore")
+ }
+
+ changed := darwinClipboardSnapshot{text: "user changed clipboard", hasText: true}
+ if shouldRestoreDarwinClipboard(3, 3, true, "dictated text", "dictated text", changed) {
+ t.Fatal("changed clipboard must not be overwritten")
+ }
+}
--
Gitblit v1.9.3