Files

359 lines
9.2 KiB
Go

// 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 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 {
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, and retry after a file changes.
if signature != nextSignature {
p.report(err)
}
} else {
if !reflect.DeepEqual(update, last) {
p.publish(update)
last = update
}
}
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...)
}
}
}
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
}