Files
Commilitia-Drop/desktop/platform/clipboard.go
T
admin f21fa5b5e8 cdrop — 跨 OS 剪贴板与文件传输服务
Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。

- 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。
- 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。
- 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。
- 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。

架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。

本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
2026-06-15 21:38:28 +08:00

152 lines
4.8 KiB
Go

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[:])
}