359 lines
9.2 KiB
Go
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
|
|
}
|