// 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[:]) }