feat: implémentation complete v0.1.0 — daemon de sync clipboard bidirectionnel
CI / Lint (push) Failing after 14m46s
CI / Test (push) Failing after 4m23s
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
CI / Lint (push) Failing after 14m46s
CI / Test (push) Failing after 4m23s
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
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.dracodev.net/Projets/clip-sync/internal/config"
|
||||
)
|
||||
|
||||
// mockClipboard is a thread-safe in-memory clipboard for integration tests.
|
||||
type mockClipboard struct {
|
||||
text string
|
||||
}
|
||||
|
||||
func (m *mockClipboard) Read() (string, error) { return m.text, nil }
|
||||
func (m *mockClipboard) Write(s string) error { m.text = s; return nil }
|
||||
|
||||
// TestIntegrationTwoInstances simulates two clip-sync instances syncing via localhost
|
||||
// with distinct origins to avoid the same-hostname anti-loop filter.
|
||||
func TestIntegrationTwoInstances(t *testing.T) {
|
||||
// Instance A: port 19137
|
||||
cfgA := &config.Config{
|
||||
Daemon: config.DaemonConfig{Port: 19137, PollIntervalMs: 50},
|
||||
Peers: []config.PeerConfig{
|
||||
{Name: "instance-b", Addr: "127.0.0.1:19138"},
|
||||
},
|
||||
}
|
||||
|
||||
// Instance B: port 19138
|
||||
cfgB := &config.Config{
|
||||
Daemon: config.DaemonConfig{Port: 19138, PollIntervalMs: 50},
|
||||
Peers: []config.PeerConfig{
|
||||
{Name: "instance-a", Addr: "127.0.0.1:19137"},
|
||||
},
|
||||
}
|
||||
|
||||
cbA := &mockClipboard{}
|
||||
cbB := &mockClipboard{}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Build daemons with distinct origins (simulating different machines).
|
||||
da, err := newWithClipboardOrigin(cfgA, cbA, "instance-a")
|
||||
if err != nil {
|
||||
t.Fatalf("daemon A: %v", err)
|
||||
}
|
||||
|
||||
db, err := newWithClipboardOrigin(cfgB, cbB, "instance-b")
|
||||
if err != nil {
|
||||
t.Fatalf("daemon B: %v", err)
|
||||
}
|
||||
|
||||
// Start both daemons.
|
||||
go da.Run(ctx)
|
||||
go db.Run(ctx)
|
||||
|
||||
// Wait for servers to start.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Write text to mock clipboard A (simulating a local copy).
|
||||
cbA.text = "sync test from A"
|
||||
da.filter.MarkWritten(cbA.text)
|
||||
da.broadcaster.Broadcast(cbA.text)
|
||||
|
||||
// Wait for propagation A → B via HTTP.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
if cbB.text != "sync test from A" {
|
||||
t.Errorf("B clipboard = %q, want \"sync test from A\"", cbB.text)
|
||||
}
|
||||
|
||||
// Now write to B and check A.
|
||||
cbB.text = "sync test from B"
|
||||
db.filter.MarkWritten(cbB.text)
|
||||
db.broadcaster.Broadcast(cbB.text)
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
if cbA.text != "sync test from B" {
|
||||
t.Errorf("A clipboard = %q, want \"sync test from B\"", cbA.text)
|
||||
}
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
// TestNoPingPong verifies that the anti-loop filter prevents echo-back.
|
||||
func TestNoPingPong(t *testing.T) {
|
||||
cfgA := &config.Config{
|
||||
Daemon: config.DaemonConfig{Port: 19139, PollIntervalMs: 50},
|
||||
Peers: []config.PeerConfig{
|
||||
{Name: "instance-b", Addr: "127.0.0.1:19140"},
|
||||
},
|
||||
}
|
||||
|
||||
cfgB := &config.Config{
|
||||
Daemon: config.DaemonConfig{Port: 19140, PollIntervalMs: 50},
|
||||
Peers: []config.PeerConfig{
|
||||
{Name: "instance-a", Addr: "127.0.0.1:19139"},
|
||||
},
|
||||
}
|
||||
|
||||
cbA := &mockClipboard{}
|
||||
cbB := &mockClipboard{}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
da, err := newWithClipboardOrigin(cfgA, cbA, "instance-a")
|
||||
if err != nil {
|
||||
t.Fatalf("daemon A: %v", err)
|
||||
}
|
||||
|
||||
db, err := newWithClipboardOrigin(cfgB, cbB, "instance-b")
|
||||
if err != nil {
|
||||
t.Fatalf("daemon B: %v", err)
|
||||
}
|
||||
|
||||
go da.Run(ctx)
|
||||
go db.Run(ctx)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Phase 1: Write to A, verify B receives it.
|
||||
cbA.text = "ping-pong test"
|
||||
da.filter.MarkWritten(cbA.text)
|
||||
da.broadcaster.Broadcast(cbA.text)
|
||||
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
|
||||
if cbB.text != "ping-pong test" {
|
||||
t.Errorf("B clipboard = %q, want \"ping-pong test\"", cbB.text)
|
||||
}
|
||||
|
||||
// Phase 2: B's poller detects the new text and broadcasts it back.
|
||||
// A's server should reject it via the dedup filter (hash match).
|
||||
// Wait for B's poll cycle to fire.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Phase 3: Write new text on B, verify A receives it correctly
|
||||
// (no interference from the blocked echo).
|
||||
cbB.text = "final test"
|
||||
db.filter.MarkWritten(cbB.text)
|
||||
db.broadcaster.Broadcast(cbB.text)
|
||||
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
|
||||
if cbA.text != "final test" {
|
||||
t.Errorf("A clipboard = %q, want \"final test\"", cbA.text)
|
||||
}
|
||||
|
||||
cancel()
|
||||
}
|
||||
Reference in New Issue
Block a user