feat: implement security, discovery, history, file transfer, web UI (P0-P3)
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
// Package history provides a persistent, size-bounded clipboard history store.
|
||||
//
|
||||
// Entries are kept in memory as a ring buffer and flushed to a JSON file so the
|
||||
// `clip-sync history` command can read them even when the daemon is not running.
|
||||
package history
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Entry is a single clipboard history record.
|
||||
type Entry struct {
|
||||
Text string `json:"text"`
|
||||
Origin string `json:"origin"`
|
||||
Ts int64 `json:"ts"` // nanosecond timestamp
|
||||
}
|
||||
|
||||
// Store is a thread-safe, persistent history store.
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
max int
|
||||
entries []Entry
|
||||
}
|
||||
|
||||
// New creates a history store that persists to the given file path, keeping at
|
||||
// most max entries. If max <= 0, a sensible default is used.
|
||||
func New(path string, max int) *Store {
|
||||
if max <= 0 {
|
||||
max = 50
|
||||
}
|
||||
s := &Store{path: path, max: max}
|
||||
s.load()
|
||||
return s
|
||||
}
|
||||
|
||||
// Append adds an entry, trimming the store to max entries and persisting it.
|
||||
func (s *Store) Append(e Entry) {
|
||||
if e.Text == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Skip exact duplicates of the most recent entry.
|
||||
if len(s.entries) > 0 && s.entries[len(s.entries)-1].Text == e.Text {
|
||||
return
|
||||
}
|
||||
|
||||
s.entries = append(s.entries, e)
|
||||
if len(s.entries) > s.max {
|
||||
s.entries = s.entries[len(s.entries)-s.max:]
|
||||
}
|
||||
s.save()
|
||||
}
|
||||
|
||||
// List returns a copy of the history, oldest first.
|
||||
func (s *Store) List() []Entry {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]Entry, len(s.entries))
|
||||
copy(out, s.entries)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) load() {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = json.Unmarshal(data, &s.entries)
|
||||
if len(s.entries) > s.max {
|
||||
s.entries = s.entries[len(s.entries)-s.max:]
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) save() {
|
||||
data, err := json.MarshalIndent(s.entries, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
|
||||
return
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.Rename(tmp, s.path)
|
||||
}
|
||||
|
||||
// DefaultPath returns the default history file location.
|
||||
func DefaultPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("history: cannot find home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".config", "clip-sync", "history.json"), nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package history
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppendAndList(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "history.json")
|
||||
s := New(path, 10)
|
||||
|
||||
s.Append(Entry{Text: "first", Origin: "a", Ts: 1})
|
||||
s.Append(Entry{Text: "second", Origin: "b", Ts: 2})
|
||||
|
||||
got := s.List()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].Text != "first" || got[1].Text != "second" {
|
||||
t.Errorf("order wrong: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendSkipsDuplicate(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "history.json")
|
||||
s := New(path, 10)
|
||||
|
||||
s.Append(Entry{Text: "dup", Origin: "a", Ts: 1})
|
||||
s.Append(Entry{Text: "dup", Origin: "a", Ts: 2})
|
||||
|
||||
if len(s.List()) != 1 {
|
||||
t.Errorf("len = %d, want 1 (duplicate skipped)", len(s.List()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimToMax(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "history.json")
|
||||
s := New(path, 3)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
s.Append(Entry{Text: string(rune('a' + i)), Origin: "a", Ts: int64(i)})
|
||||
}
|
||||
|
||||
got := s.List()
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len = %d, want 3", len(got))
|
||||
}
|
||||
if got[0].Text != "h" {
|
||||
t.Errorf("oldest kept = %q, want \"h\"", got[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistence(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "history.json")
|
||||
s := New(path, 10)
|
||||
s.Append(Entry{Text: "persisted", Origin: "a", Ts: 42})
|
||||
|
||||
// A new store reading the same path should reload the entry.
|
||||
s2 := New(path, 10)
|
||||
got := s2.List()
|
||||
if len(got) != 1 || got[0].Text != "persisted" {
|
||||
t.Errorf("reloaded = %+v, want persisted entry", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPath(t *testing.T) {
|
||||
p, err := DefaultPath()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultPath: %v", err)
|
||||
}
|
||||
if p == "" {
|
||||
t.Error("DefaultPath returned empty string")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user