CI / Lint (push) Successful in 18m53s
CI / Test (push) Failing after 5m34s
CI / Build (darwin/amd64) (push) Skipped
CI / Build (linux/amd64) (push) Skipped
CI / Build (windows/amd64) (push) Skipped
CI / Build (darwin/arm64) (push) Skipped
CI / Build (linux/arm64) (push) Skipped
CI / Build (windows/arm64) (push) Skipped
CI / Release (push) Skipped
218 lines
5.6 KiB
Go
218 lines
5.6 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)
|
|
}
|
|
|
|
// 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[:])
|
|
}
|