feat: implement security, discovery, history, file transfer, web UI (P0-P3)

This commit is contained in:
2026-08-14 09:45:54 -04:00
parent 8281478f0c
commit 884415c1c1
32 changed files with 1923 additions and 200 deletions
+90 -17
View File
@@ -1,5 +1,5 @@
// Package daemon orchestrates the main clip-sync loop: poll the local clipboard,
// broadcast changes to peers, and receive incoming clips via the HTTP server.
// broadcast changes to peers, and receive incoming clips via the HTTP(S) server.
package daemon
import (
@@ -12,17 +12,23 @@ import (
"git.dracodev.net/Projets/clip-sync/internal/clipboard"
"git.dracodev.net/Projets/clip-sync/internal/config"
"git.dracodev.net/Projets/clip-sync/internal/dedup"
"git.dracodev.net/Projets/clip-sync/internal/discovery"
"git.dracodev.net/Projets/clip-sync/internal/history"
"git.dracodev.net/Projets/clip-sync/internal/notify"
"git.dracodev.net/Projets/clip-sync/internal/peer"
"git.dracodev.net/Projets/clip-sync/internal/server"
)
// Daemon is the main clip-sync process.
type Daemon struct {
cfg *config.Config
clipboard clipboard.Clipboard
filter *dedup.Filter
server *server.Server
cfg *config.Config
clipboard clipboard.Clipboard
filter *dedup.Filter
server *server.Server
broadcaster *peer.Broadcaster
history *history.Store
origin string
discovery discovery.Discovery
}
// New creates a Daemon from the given configuration.
@@ -32,7 +38,18 @@ func New(cfg *config.Config) (*Daemon, error) {
return nil, err
}
return newWithClipboard(cfg, cb)
origin, err := os.Hostname()
if err != nil {
return nil, err
}
// History is best-effort: if we cannot resolve a path, run without it.
hist := (*history.Store)(nil)
if p, err := history.DefaultPath(); err == nil {
hist = history.New(p, cfg.Daemon.HistorySize)
}
return newDaemon(cfg, cb, origin, hist)
}
// newWithClipboard creates a Daemon with a pre-existing clipboard (used by tests).
@@ -42,6 +59,10 @@ func newWithClipboard(cfg *config.Config, cb clipboard.Clipboard) (*Daemon, erro
// newWithClipboardOrigin creates a Daemon with a pre-existing clipboard and explicit origin.
func newWithClipboardOrigin(cfg *config.Config, cb clipboard.Clipboard, origin string) (*Daemon, error) {
return newDaemon(cfg, cb, origin, nil)
}
func newDaemon(cfg *config.Config, cb clipboard.Clipboard, origin string, hist *history.Store) (*Daemon, error) {
if origin == "" {
var err error
origin, err = os.Hostname()
@@ -51,34 +72,82 @@ func newWithClipboardOrigin(cfg *config.Config, cb clipboard.Clipboard, origin s
}
filter := dedup.NewFilter(origin)
broadcaster := peer.NewBroadcaster(cfg.Peers, origin)
srv := server.New(cfg.ListenAddr(), cb, filter)
broadcaster := peer.NewBroadcaster(cfg.Peers, origin, peer.Options{
SharedKey: cfg.Security.SharedKey,
TLS: cfg.Security.TLS,
CertFile: cfg.Security.CertFile,
InsecureSkipVerify: cfg.Security.InsecureSkipVerify,
})
return &Daemon{
var notifier notify.Notifier
if cfg.Daemon.Notify {
notifier = notify.New()
}
srv := server.New(server.Options{
Addr: cfg.ListenAddr(),
SharedKey: cfg.Security.SharedKey,
AllowedOrigins: cfg.Security.AllowedOrigins,
MaxBodyBytes: cfg.MaxBodyBytes(),
History: hist,
Notifier: notifier,
ReceiveFiles: cfg.Daemon.ReceiveFiles,
ReceiveDir: cfg.ReceiveDir(),
}, cb, filter)
d := &Daemon{
cfg: cfg,
clipboard: cb,
filter: filter,
server: srv,
broadcaster: broadcaster,
}, nil
history: hist,
origin: origin,
}
if cfg.Daemon.Discovery {
d.discovery = discovery.New(origin, cfg.Daemon.Port, broadcaster)
}
return d, nil
}
// Run starts the daemon loop. It blocks until ctx is cancelled.
func (d *Daemon) Run(ctx context.Context) error {
// Start the HTTP server in a background goroutine.
// Start the HTTP(S) server in a background goroutine.
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
log.Printf("clip-sync: listening on %s", d.cfg.ListenAddr())
if err := d.server.ListenAndServe(); err != nil {
// ListenAndServe returns http.ErrServerClosed on graceful shutdown.
if ctx.Err() == nil {
log.Printf("clip-sync: server error: %v", err)
var err error
if d.cfg.Security.TLS {
certFile, keyFile := d.cfg.Security.CertFile, d.cfg.Security.KeyFile
if certFile == "" {
certFile, _ = config.DefaultCertFile()
}
if keyFile == "" {
keyFile, _ = config.DefaultKeyFile()
}
log.Printf("clip-sync: listening on %s (TLS)", d.cfg.ListenAddr())
err = d.server.ListenAndServeTLS(certFile, keyFile)
} else {
log.Printf("clip-sync: listening on %s", d.cfg.ListenAddr())
err = d.server.ListenAndServe()
}
if err != nil && ctx.Err() == nil {
log.Printf("clip-sync: server error: %v", err)
}
}()
// Start mDNS discovery if enabled.
if d.discovery != nil {
if err := d.discovery.Start(); err != nil {
log.Printf("clip-sync: discovery: %v", err)
} else {
defer d.discovery.Stop()
}
}
// Main clipboard polling loop.
ticker := time.NewTicker(d.cfg.PollInterval())
defer ticker.Stop()
@@ -111,8 +180,12 @@ func (d *Daemon) Run(ctx context.Context) error {
// Record our write so the filter can block echoes.
d.filter.MarkWritten(text)
if d.history != nil {
d.history.Append(history.Entry{Text: text, Origin: d.origin, Ts: time.Now().UnixNano()})
}
// Broadcast to all peers.
if len(d.cfg.Peers) > 0 {
if len(d.cfg.Peers) > 0 || d.discovery != nil {
d.broadcaster.Broadcast(text)
}
}