116 lines
3.2 KiB
Go
116 lines
3.2 KiB
Go
// Package dedup provides the anti-ping-pong (deduplication) filter for clip-sync.
|
|
//
|
|
// Two guardrails prevent sync loops:
|
|
// 1. Content hash cache — an LRU ring buffer of the last N SHA-256 hashes
|
|
// of locally written text. If we receive text whose hash matches a recent
|
|
// local write, we ignore it (our own content echoed back through a peer).
|
|
// 2. Origin + timestamp — each payload carries an origin hostname and a
|
|
// nanosecond timestamp. If origin == our hostname (rebound via a third
|
|
// peer) or if the timestamp is not newer than our last write, we ignore.
|
|
package dedup
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const defaultCacheSize = 20
|
|
|
|
// Filter prevents clipboard sync loops.
|
|
type Filter struct {
|
|
mu sync.Mutex
|
|
origin string // our hostname
|
|
cache []string // ring buffer of content hashes
|
|
cacheIdx int // write position in ring buffer
|
|
lastWriteTs int64 // nanosecond timestamp of our last local write
|
|
}
|
|
|
|
// NewFilter creates a new deduplication filter for the given origin hostname.
|
|
func NewFilter(origin string) *Filter {
|
|
return &Filter{
|
|
origin: origin,
|
|
cache: make([]string, 0, defaultCacheSize),
|
|
}
|
|
}
|
|
|
|
// Payload represents an incoming clip payload from a peer. It carries either
|
|
// text (Text) or a base64-encoded image (Mime + Data).
|
|
type Payload struct {
|
|
Text string `json:"text"`
|
|
Mime string `json:"mime,omitempty"`
|
|
Data string `json:"data,omitempty"` // base64-encoded image bytes
|
|
Ts int64 `json:"ts"`
|
|
Origin string `json:"origin"`
|
|
}
|
|
|
|
// ShouldIgnore returns true if the payload should NOT be written to the local clipboard.
|
|
func (f *Filter) ShouldIgnore(p Payload) bool {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
|
|
// Guard 1: ignore our own origin (rebound via a third peer).
|
|
if p.Origin == f.origin {
|
|
return true
|
|
}
|
|
|
|
// Guard 2: ignore stale timestamps (not newer than our last write).
|
|
if p.Ts <= f.lastWriteTs {
|
|
return true
|
|
}
|
|
|
|
// Guard 3: ignore content we recently wrote (hash match).
|
|
hash := hashText(p.Text + "\x00" + p.Data)
|
|
for _, h := range f.cache {
|
|
if h == hash {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// MarkWritten records that we just wrote text to the local clipboard.
|
|
// This populates the hash cache and updates the last-write timestamp.
|
|
func (f *Filter) MarkWritten(text string) {
|
|
f.markWritten(text)
|
|
}
|
|
|
|
// MarkWrittenPayload records that we just wrote a payload (text or image) to
|
|
// the local clipboard, so echoes of it are ignored.
|
|
func (f *Filter) MarkWrittenPayload(p Payload) {
|
|
f.markWritten(p.Text + "\x00" + p.Data)
|
|
}
|
|
|
|
func (f *Filter) markWritten(content string) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
|
|
now := time.Now().UnixNano()
|
|
hash := hashText(content)
|
|
|
|
// Ring buffer: overwrite oldest entry when full.
|
|
if len(f.cache) < defaultCacheSize {
|
|
f.cache = append(f.cache, hash)
|
|
} else {
|
|
f.cache[f.cacheIdx] = hash
|
|
f.cacheIdx = (f.cacheIdx + 1) % defaultCacheSize
|
|
}
|
|
|
|
f.lastWriteTs = now
|
|
}
|
|
|
|
// LastWriteTs returns the timestamp of our last local write.
|
|
func (f *Filter) LastWriteTs() int64 {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.lastWriteTs
|
|
}
|
|
|
|
// hashText returns the hex-encoded SHA-256 of the given text.
|
|
func hashText(text string) string {
|
|
h := sha256.Sum256([]byte(text))
|
|
return hex.EncodeToString(h[:])
|
|
}
|