// Package daemon orchestrates the main clip-sync loop: poll the local clipboard, // broadcast changes to peers, and receive incoming clips via the HTTP server. package daemon import ( "context" "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/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 } // 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 } return newWithClipboard(cfg, cb) } // 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) { 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) srv := server.New(cfg.ListenAddr(), cb, filter) return &Daemon{ cfg: cfg, clipboard: cb, filter: filter, server: srv, broadcaster: broadcaster, }, 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. 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) } } }() // Main clipboard polling loop. ticker := time.NewTicker(d.cfg.PollInterval()) defer ticker.Stop() var lastText string 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) continue } if text == "" || text == lastText { continue } lastText = text // Record our write so the filter can block echoes. d.filter.MarkWritten(text) // Broadcast to all peers. if len(d.cfg.Peers) > 0 { d.broadcaster.Broadcast(text) } } } }