package platform import ( "context" "crypto/sha256" "encoding/hex" "sync" ) // ClipboardEvent is one observed change of the local clipboard. type ClipboardEvent struct { Text string // Sensitive is set by the platform Source when the pasteboard carries a // privacy marker — on macOS org.nspasteboard.ConcealedType / TransientType / // AutoGeneratedType or a known password-manager UTI; on Windows the // ExcludeClipboardContentFromMonitorProcessing / CanUploadToCloudClipboard // formats. Sensitive events are never uploaded. Sensitive bool } // Sink receives clipboard text that the policy decided should be uploaded. type Sink func(text string) // Source is the platform-specific clipboard backend: it watches for local // changes and can write text back. Implementations live in clipboard_darwin.go // / clipboard_windows.go. The upstream golang.design/x/clipboard library only // surfaces the changed text (no change-count, no pasteboard types), so the // Sensitive flag and the upload policy below are this package's responsibility, // not the library's. type Source interface { // Watch emits an event on every observed local clipboard change until ctx // is cancelled, then closes the channel. The implementation sets Sensitive // from the pasteboard's privacy markers. Watch(ctx context.Context) <-chan ClipboardEvent // Write replaces the local clipboard text. Write(text string) error } // Monitor applies cdrop's upload policy on top of a raw clipboard event stream: // it drops sensitive content, de-duplicates repeats, and suppresses the echo of // our own writes (the loop guard, PLAN.md R4). Everything here is pure and // host-independent, so it is unit-tested without a real clipboard; the // platform Source produces the raw events and performs the OS writes. // // Debounce of rapid successive copies is intentionally left to the upload // callback (wrap it with github.com/bep/debounce in the app wiring) so the // policy stays free of timers and trivially testable. type Monitor struct { upload Sink mu sync.Mutex lastHash string // hash of the last text we accepted (dedup) selfHash string // hash of the text we most recently wrote ourselves (loop guard) } // NewMonitor builds a Monitor that forwards accepted text to upload. func NewMonitor(upload Sink) *Monitor { return &Monitor{upload: upload} } // Observe runs the policy for one raw event. It returns true when the event was // accepted and forwarded to the sink, false when it was filtered out. Safe to // call concurrently with ApplyRemote — the watch goroutine and the download // path share one Monitor. func (m *Monitor) Observe(ev ClipboardEvent) bool { if ev.Sensitive || ev.Text == "" { return false } h := hashText(ev.Text) m.mu.Lock() if h == m.selfHash { // Echo of our own write: consume it once (don't re-upload) and disarm, // so a genuine later re-copy of the same text still passes. m.selfHash = "" m.lastHash = h m.mu.Unlock() return false } if h == m.lastHash { m.mu.Unlock() return false // unchanged content re-observed } m.lastHash = h m.mu.Unlock() m.upload(ev.Text) // outside the lock: the sink may block (network / IPC) return true } // WillWrite must be called immediately before the Source writes text to the // local clipboard, so the resulting change notification (carrying the same // text) is recognised as our own echo and not re-uploaded. func (m *Monitor) WillWrite(text string) { m.mu.Lock() m.selfHash = hashText(text) m.mu.Unlock() } // ApplyRemote writes a remote clipboard change to the local clipboard (the // download direction), skipping our own uploads echoed back by the server and // content we already hold. It arms the loop guard before writing, so the // Source's resulting change event is not re-uploaded. Returns true if it wrote. func (m *Monitor) ApplyRemote(src Source, cc ClipboardContent, selfDevice string) bool { if cc.Content == "" { return false } if selfDevice != "" && cc.SourceDevice == selfDevice { return false // our own upload, echoed back by the server } h := hashText(cc.Content) m.mu.Lock() if h == m.lastHash { m.mu.Unlock() return false // already the current local content } m.selfHash = h // arm loop guard inline (avoid re-locking via WillWrite) m.mu.Unlock() if err := src.Write(cc.Content); err != nil { return false } m.mu.Lock() m.lastHash = h m.mu.Unlock() return true } // Run drives src's events through m until ctx is cancelled or the source closes. // It is the glue the app uses; all policy decisions live in Monitor. func Run(ctx context.Context, src Source, m *Monitor) { ch := src.Watch(ctx) for { select { case <-ctx.Done(): return case ev, ok := <-ch: if !ok { return } m.Observe(ev) } } } func hashText(s string) string { sum := sha256.Sum256([]byte(s)) return hex.EncodeToString(sum[:]) }