Ariver
2026-06-13 859c14d783dc31fe68550b114fc8adeea09dab2a
Sync macOS paste clipboard safety to X-ASR branch
1 files modified
1 files added
195 ■■■■■ changed files
privatevoice.src/internal/input/paste_darwin.go 153 ●●●● patch | view | raw | blame | history
privatevoice.src/internal/input/paste_darwin_test.go 42 ●●●●● patch | view | raw | blame | history
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)
}
privatevoice.src/internal/input/paste_darwin_test.go
New file
@@ -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")
    }
}