Files
clip-sync/internal/daemon/daemon.go
T

223 lines
5.8 KiB
Go

// Package daemon orchestrates the main clip-sync loop: poll the local clipboard,
// broadcast changes to peers, and receive incoming clips via the HTTP(S) server.
package daemon
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"log"
"os"
"sync"
"time"
"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
broadcaster *peer.Broadcaster
history *history.Store
origin string
discovery discovery.Discovery
}
// New creates a Daemon from the given configuration.
func New(cfg *config.Config) (*Daemon, error) {
cb, err := clipboard.New()
if err != nil {
return nil, err
}
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).
func newWithClipboard(cfg *config.Config, cb clipboard.Clipboard) (*Daemon, error) {
return newWithClipboardOrigin(cfg, cb, "")
}
// 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()
if err != nil {
return nil, err
}
}
filter := dedup.NewFilter(origin)
broadcaster := peer.NewBroadcaster(cfg.Peers, origin, peer.Options{
SharedKey: cfg.Security.SharedKey,
TLS: cfg.Security.TLS,
CertFile: cfg.Security.CertFile,
InsecureSkipVerify: cfg.Security.InsecureSkipVerify,
})
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,
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(S) server in a background goroutine.
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
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()
var lastText string
var lastImageHash string
// Optional image sync (Linux only today); requires platform support.
imageCB, _ := d.clipboard.(clipboard.ImageClipboard)
log.Printf("clip-sync: polling clipboard every %v, %d peer(s) configured",
d.cfg.PollInterval(), len(d.cfg.Peers))
for {
select {
case <-ctx.Done():
d.server.Close()
wg.Wait()
return ctx.Err()
case <-ticker.C:
text, err := d.clipboard.Read()
if err != nil {
log.Printf("clip-sync: clipboard read: %v", err)
} else if text != "" && text != lastText {
lastText = text
// 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()})
}
if len(d.cfg.Peers) > 0 || d.discovery != nil {
d.broadcaster.Broadcast(text)
}
}
// Image sync (opt-in).
if d.cfg.Daemon.SyncImages && imageCB != nil {
mime, data, err := imageCB.ReadImage()
if err != nil || len(data) == 0 {
continue
}
h := hashBytes(data)
if h == lastImageHash {
continue
}
lastImageHash = h
d.filter.MarkWrittenPayload(dedup.Payload{
Mime: mime,
Data: base64.StdEncoding.EncodeToString(data),
})
if len(d.cfg.Peers) > 0 || d.discovery != nil {
d.broadcaster.BroadcastImage(mime, data)
}
}
}
}
}
// hashBytes returns a short hex digest of b, used to detect clipboard changes.
func hashBytes(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}