// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package clipboard import ( "errors" "fmt" "io" "os" "reflect" "sync" "syscall" "time" ) // HistoryUpdate contains shared entries and the local submission it acknowledges. // Selection stays local; Selected is only the next launch's saved selection. type HistoryUpdate struct { State Snapshot Enabled bool Sequence uint64 } type historyJob struct { action string state Snapshot text string limit int sequence uint64 imported bool } // Persistence applies actions to the latest shared snapshot under a short lock. // Disk I/O and lock contention stay off the input loop. Close drains actions. type Persistence struct { Initial Snapshot LoadErr error InitialEnabled bool wake chan struct{} errors chan error updates chan HistoryUpdate done chan struct{} mutex sync.Mutex jobs []historyJob sequence uint64 closed bool lastErr error } func New_persistence(path string, load bool) *Persistence { p := &Persistence{Initial: Snapshot{Version: 1}, InitialEnabled: load, wake: make(chan struct{}, 1), errors: make(chan error, 1), updates: make(chan HistoryUpdate, 1), done: make(chan struct{})} if load { file, err := acquire_history(path, false) if err == nil { p.InitialEnabled, err = persistence_enabled(file) if err == nil && p.InitialEnabled { p.Initial, err = load_history(path) } if file != nil { file.Close() } } p.LoadErr = err } go p.run(path, load) return p } func (p *Persistence) Errors() <-chan error { return p.errors } func (p *Persistence) Updates() <-chan HistoryUpdate { return p.updates } func (p *Persistence) Sequence() uint64 { p.mutex.Lock() defer p.mutex.Unlock() return p.sequence } func (p *Persistence) submit(job historyJob) { p.mutex.Lock() p.sequence++ job.sequence = p.sequence // Intermediate navigation is disposable; data and enable/disable actions // retain their order, including an enable immediately followed by Clear. if job.action == "select" && len(p.jobs) > 0 && p.jobs[len(p.jobs)-1].action == "select" { p.jobs = p.jobs[:len(p.jobs)-1] } p.jobs = append(p.jobs, job) p.mutex.Unlock() select { case p.wake <- struct{}{}: default: } } func (p *Persistence) Enable(state Snapshot, limit int) { p.submit(historyJob{action: "enable", state: state, limit: limit}) } func (p *Persistence) Add(text string, limit int) { p.submit(historyJob{action: "add", text: text, limit: limit}) } func (p *Persistence) Import(text string, limit int) { p.submit(historyJob{action: "add", text: text, limit: limit, imported: true}) } func (p *Persistence) Select(state Snapshot) { p.submit(historyJob{action: "select", state: state}) } func (p *Persistence) Resize(limit int) { p.submit(historyJob{action: "resize", limit: limit}) } func (p *Persistence) Clear() { p.submit(historyJob{action: "clear"}) } func (p *Persistence) Disable() { p.submit(historyJob{action: "disable"}) } func (p *Persistence) Close() error { p.mutex.Lock() p.closed = true p.mutex.Unlock() select { case p.wake <- struct{}{}: default: } <-p.done return p.lastErr } func (p *Persistence) report(err error) { p.lastErr = err if err != nil { select { case <-p.errors: default: } p.errors <- err } } func (p *Persistence) run(path string, watching bool) { defer close(p.done) tick := time.NewTicker(200 * time.Millisecond) defer tick.Stop() last := HistoryUpdate{State: p.Initial, Enabled: p.InitialEnabled} var signature string var retryLock bool var acknowledged uint64 for { select { case <-p.wake: case <-tick.C: } p.mutex.Lock() jobs, closed := p.jobs, p.closed p.jobs = nil p.mutex.Unlock() for _, job := range jobs { update, err := apply_history(path, job) acknowledged = job.sequence p.report(err) if job.action == "enable" { watching = true } if err == nil && watching { update.Sequence = acknowledged p.publish(update) last = update } signature = "" } if closed { return } // Starting disabled never imports a previous snapshot. Once participating, // readers also observe another participant's disable/re-enable operations. if !watching { continue } nextSignature := history_signature(path) if nextSignature == signature && !retryLock { continue } file, err := acquire_history(path, false) update := HistoryUpdate{State: Snapshot{Version: 1}, Sequence: acknowledged} if err == nil { update.Enabled, err = persistence_enabled(file) if err == nil && update.Enabled { update.State, err = load_history(path) } if file != nil { file.Close() } } if err != nil { // Report a stable failure once. Lock contention can clear without // changing either file, so it must also be retried on the next tick. if signature != nextSignature { p.report(err) } } else { if retryLock { p.report(nil) } if !reflect.DeepEqual(update, last) { p.publish(update) last = update } } retryLock = errors.Is(err, syscall.EWOULDBLOCK) signature = nextSignature } } func (p *Persistence) publish(update HistoryUpdate) { select { case <-p.updates: default: } p.updates <- update } func history_signature(path string) string { result := "" for _, name := range []string{path, path + ".lock"} { info, err := os.Lstat(name) if err != nil { result += fmt.Sprint(err) continue } stat := info.Sys().(*syscall.Stat_t) result += fmt.Sprintf("%d:%d:%d:%d;", stat.Dev, stat.Ino, info.Size(), info.ModTime().UnixNano()) } return result } // The empty original lock file means enabled. One metadata byte coordinates // disabling without retaining clipboard text or resurrecting a deleted file. func persistence_enabled(file *os.File) (bool, error) { if file == nil { return true, nil } var value [1]byte n, err := file.ReadAt(value[:], 0) if n == 0 && errors.Is(err, io.EOF) { return true, nil } if err != nil { return false, err } if value[0] != '0' && value[0] != '1' { return false, fmt.Errorf("invalid clipboard persistence state") } return value[0] == '1', nil } func set_persistence_enabled(file *os.File, enabled bool) error { value := []byte("0") if enabled { value[0] = '1' } _, err := file.WriteAt(value, 0) return errors.Join(err, file.Sync()) } func apply_history(path string, job historyJob) (HistoryUpdate, error) { result := HistoryUpdate{State: Snapshot{Version: 1}} create := job.action == "enable" || job.action == "add" || job.action == "disable" file, err := acquire_history(path, create) if err != nil { return result, err } if file != nil { defer file.Close() } result.Enabled, err = persistence_enabled(file) if err != nil { return result, err } switch job.action { case "clear", "disable": if job.action == "disable" { if err := set_persistence_enabled(file, false); err != nil { return result, err } result.Enabled = false } return result, remove_history(path) case "enable": if err := validate_snapshot(job.state); err != nil { return result, err } // Enabling deliberately saves retained memory, but preserves newer shared // entries when another reader already has an active saved history. state, err := load_history(path) if err != nil { return result, err } if len(state.Entries) == 0 { state = job.state } else { for i := len(job.state.Entries) - 1; i >= 0; i-- { text := job.state.Entries[i] found := false for _, existing := range state.Entries { if existing == text { found = true break } } if !found { state.Entries = append([]string{text}, state.Entries...) state.Selected++ } } } result.State = trim_history(state, job.limit) if len(result.State.Entries) > 0 { err = save_history(path, result.State) } else { err = remove_history(path) } if err != nil { return result, err } if err := set_persistence_enabled(file, true); err != nil { return result, err } result.Enabled = true return result, nil } if !result.Enabled { return result, nil } result.State, err = load_history(path) if err != nil { return result, err } switch job.action { case "add": if err := Validate_text(job.text); err != nil { return result, err } if job.imported && len(result.State.Entries) > 0 && result.State.Entries[0] == job.text { return result, nil // Two X observers imported the same clipboard change. } result.State.Entries = append([]string{job.text}, result.State.Entries...) result.State.Selected = 0 result.State = trim_history(result.State, job.limit) case "select": // Navigation cannot recreate entries removed by another reader. if job.state.Selected < len(job.state.Entries) && job.state.Selected >= 0 { history := New_history(10000) if err := history.Restore(job.state); err != nil { return result, err } if err := history.Refresh(result.State); err != nil { return result, err } result.State.Selected = history.Snapshot().Selected } case "resize": result.State = trim_history(result.State, job.limit) } if len(result.State.Entries) == 0 { return result, nil } return result, save_history(path, result.State) } func trim_history(state Snapshot, limit int) Snapshot { state.Entries = state.Entries[:min(len(state.Entries), max(1, limit))] state.Selected = min(state.Selected, max(0, len(state.Entries)-1)) return state }