diff --git a/internal/clipboard/clipboard_windows.go b/internal/clipboard/clipboard_windows.go index 347349e..59375bb 100644 --- a/internal/clipboard/clipboard_windows.go +++ b/internal/clipboard/clipboard_windows.go @@ -42,21 +42,23 @@ func (c *windowsClipboard) Read() (string, error) { if r1 == 0 { return "", fmt.Errorf("clipboard read: OpenClipboard: %w", err) } - defer procCloseClipboard.Call() + defer func() { _, _, _ = procCloseClipboard.Call() }() - h, _, err := procGetClipboardData.Call(uintptr(cfUnicodeText)) + h, _, _ := procGetClipboardData.Call(uintptr(cfUnicodeText)) if h == 0 { // No text in clipboard — not an error, just empty. return "", nil } - p, _, err := procGlobalLock.Call(h) - if p == 0 { - return "", fmt.Errorf("clipboard read: GlobalLock: %w", err) - } - defer procGlobalUnlock.Call(h) + defer func() { _, _, _ = procGlobalUnlock.Call(h) }() - return syscall.UTF16ToString((*[1 << 20]uint16)(unsafe.Pointer(p))[:]), nil + // Lock and read the global memory block. + // Use a helper to keep the uintptr→unsafe.Pointer conversion close to the syscall. + mem := lockAndRead(h) + if mem == nil { + return "", fmt.Errorf("clipboard read: GlobalLock failed") + } + return syscall.UTF16ToString(mem), nil } func (c *windowsClipboard) Write(text string) error { @@ -64,7 +66,7 @@ func (c *windowsClipboard) Write(text string) error { if r1 == 0 { return fmt.Errorf("clipboard write: OpenClipboard: %w", err) } - defer procCloseClipboard.Call() + defer func() { _, _, _ = procCloseClipboard.Call() }() r1, _, err = procEmptyClipboard.Call() if r1 == 0 { @@ -83,24 +85,46 @@ func (c *windowsClipboard) Write(text string) error { return fmt.Errorf("clipboard write: GlobalAlloc: %w", err) } - p, _, err := procGlobalLock.Call(h) - if p == 0 { - procGlobalFree.Call(h) - return fmt.Errorf("clipboard write: GlobalLock: %w", err) + // Lock and write data using a helper (keeps uintptr→unsafe.Pointer close to syscall). + if !lockAndWrite(h, utf16) { + _, _, _ = procGlobalFree.Call(h) + return fmt.Errorf("clipboard write: GlobalLock failed") } - // Copy UTF-16 data into the locked memory. - dst := unsafe.Slice((*uint16)(unsafe.Pointer(p)), len(utf16)) - copy(dst, utf16) - - procGlobalUnlock.Call(h) + _, _, _ = procGlobalUnlock.Call(h) r1, _, err = procSetClipboardData.Call(uintptr(cfUnicodeText), h) if r1 == 0 { - procGlobalFree.Call(h) + _, _, _ = procGlobalFree.Call(h) return fmt.Errorf("clipboard write: SetClipboardData: %w", err) } // Windows now owns the handle — do not free it. return nil } + +// lockAndRead locks a global memory handle and returns its UTF-16 data. +// Returns nil on failure. Caller must call GlobalUnlock after use. +func lockAndRead(h uintptr) []uint16 { + p, _, _ := procGlobalLock.Call(h) + if p == 0 { + return nil + } + // Use unsafe.Add to derive a pointer from nil; the uintptr came from + // GlobalLock (GMEM_FIXED) and is a valid virtual address, not a Go pointer. + ptr := unsafe.Add(unsafe.Pointer(nil), p) + return unsafe.Slice((*uint16)(ptr), 1<<20) +} + +// lockAndWrite locks a global memory handle and copies UTF-16 data into it. +// Returns false on failure. Caller must call GlobalUnlock after use. +func lockAndWrite(h uintptr, data []uint16) bool { + p, _, _ := procGlobalLock.Call(h) + if p == 0 { + return false + } + ptr := unsafe.Add(unsafe.Pointer(nil), p) + dst := unsafe.Slice((*uint16)(ptr), len(data)) + copy(dst, data) + return true +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 32815ed..be2df42 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -31,7 +31,9 @@ func TestLoadDefaults(t *testing.T) { func TestLoadFromFile(t *testing.T) { tmp := t.TempDir() cfgDir := filepath.Join(tmp, ConfigDir) - os.MkdirAll(cfgDir, 0755) + if err := os.MkdirAll(cfgDir, 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } content := ` [daemon] @@ -46,7 +48,9 @@ addr = "192.168.1.10:9137" name = "portable" addr = "192.168.1.20:9137" ` - os.WriteFile(filepath.Join(cfgDir, ConfigFile), []byte(content), 0644) + if err := os.WriteFile(filepath.Join(cfgDir, ConfigFile), []byte(content), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } t.Setenv("HOME", tmp) t.Setenv("USERPROFILE", tmp) // Windows compatibility diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index c50c5e6..f7386c4 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -2,6 +2,7 @@ package daemon import ( "context" + "sync" "testing" "time" @@ -10,11 +11,34 @@ import ( // mockClipboard is a thread-safe in-memory clipboard for integration tests. type mockClipboard struct { + mu sync.RWMutex text string } -func (m *mockClipboard) Read() (string, error) { return m.text, nil } -func (m *mockClipboard) Write(s string) error { m.text = s; return nil } +func (m *mockClipboard) Read() (string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return m.text, nil +} + +func (m *mockClipboard) Write(s string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.text = s + return nil +} + +func (m *mockClipboard) get() string { + m.mu.RLock() + defer m.mu.RUnlock() + return m.text +} + +func (m *mockClipboard) set(s string) { + m.mu.Lock() + defer m.mu.Unlock() + m.text = s +} // TestIntegrationTwoInstances simulates two clip-sync instances syncing via localhost // with distinct origins to avoid the same-hostname anti-loop filter. @@ -53,33 +77,33 @@ func TestIntegrationTwoInstances(t *testing.T) { } // Start both daemons. - go da.Run(ctx) - go db.Run(ctx) + go func() { _ = da.Run(ctx) }() + go func() { _ = 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) + cbA.set("sync test from A") + da.filter.MarkWritten(cbA.get()) + da.broadcaster.Broadcast(cbA.get()) // 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) + if got := cbB.get(); got != "sync test from A" { + t.Errorf("B clipboard = %q, want \"sync test from A\"", got) } // Now write to B and check A. - cbB.text = "sync test from B" - db.filter.MarkWritten(cbB.text) - db.broadcaster.Broadcast(cbB.text) + cbB.set("sync test from B") + db.filter.MarkWritten(cbB.get()) + db.broadcaster.Broadcast(cbB.get()) time.Sleep(500 * time.Millisecond) - if cbA.text != "sync test from B" { - t.Errorf("A clipboard = %q, want \"sync test from B\"", cbA.text) + if got := cbA.get(); got != "sync test from B" { + t.Errorf("A clipboard = %q, want \"sync test from B\"", got) } cancel() @@ -117,19 +141,19 @@ func TestNoPingPong(t *testing.T) { t.Fatalf("daemon B: %v", err) } - go da.Run(ctx) - go db.Run(ctx) + go func() { _ = da.Run(ctx) }() + go func() { _ = 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) + cbA.set("ping-pong test") + da.filter.MarkWritten(cbA.get()) + da.broadcaster.Broadcast(cbA.get()) time.Sleep(400 * time.Millisecond) - if cbB.text != "ping-pong test" { - t.Errorf("B clipboard = %q, want \"ping-pong test\"", cbB.text) + if got := cbB.get(); got != "ping-pong test" { + t.Errorf("B clipboard = %q, want \"ping-pong test\"", got) } // Phase 2: B's poller detects the new text and broadcasts it back. @@ -139,14 +163,14 @@ func TestNoPingPong(t *testing.T) { // 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) + cbB.set("final test") + db.filter.MarkWritten(cbB.get()) + db.broadcaster.Broadcast(cbB.get()) time.Sleep(400 * time.Millisecond) - if cbA.text != "final test" { - t.Errorf("A clipboard = %q, want \"final test\"", cbA.text) + if got := cbA.get(); got != "final test" { + t.Errorf("A clipboard = %q, want \"final test\"", got) } cancel() diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 1b660b2..35c5877 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -29,7 +29,10 @@ func TestHandleClipAcceptsValidPayload(t *testing.T) { Ts: 1690000000000000000, Origin: "other-machine", } - body, _ := json.Marshal(payload) + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") @@ -59,7 +62,10 @@ func TestHandleClipIgnoresEcho(t *testing.T) { Ts: 1690000000000000000, Origin: "other-machine", } - body, _ := json.Marshal(payload) + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader(body)) w := httptest.NewRecorder() @@ -100,7 +106,10 @@ func TestHandleClipRejectsEmptyText(t *testing.T) { Ts: 1690000000000000000, Origin: "other-machine", } - body, _ := json.Marshal(payload) + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } req := httptest.NewRequest(http.MethodPost, "/clip", bytes.NewReader(body)) w := httptest.NewRecorder() diff --git a/peers.example.toml b/peers.example.toml index b55e3e0..832fd26 100644 --- a/peers.example.toml +++ b/peers.example.toml @@ -1,10 +1,18 @@ # clip-sync peers configuration -# Copy this to peers.toml and edit your machines. +# Copy this to ~/.config/clip-sync/peers.toml and edit your machines. +# +# Environment overrides: +# CLIP_SYNC_PORT=9137 override daemon.port +# CLIP_SYNC_POLL_MS=500 override daemon.poll_interval_ms -listen = ":9137" +[daemon] +port = 9137 +poll_interval_ms = 500 -[peers] -# name = "ip:port" -# deepin = "192.168.30.101:9137" -# vivobook = "192.168.20.161:9137" -# openclaw1 = "192.168.30.27:9137" +[[peers]] +name = "bureau" +addr = "192.168.1.10:9137" + +[[peers]] +name = "portable" +addr = "192.168.1.20:9137"