feat: native clipboard image sync (Linux, opt-in sync_images)
This commit is contained in:
@@ -4,7 +4,9 @@ package server
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
@@ -119,8 +121,9 @@ func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if p.Text == "" {
|
||||
http.Error(w, "bad request: empty text", http.StatusBadRequest)
|
||||
isImage := p.Data != ""
|
||||
if p.Text == "" && !isImage {
|
||||
http.Error(w, "bad request: empty content", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -137,13 +140,21 @@ func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.clipboard.Write(p.Text); err != nil {
|
||||
log.Printf("server: clipboard write: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
if isImage {
|
||||
if err := s.writeImage(p); err != nil {
|
||||
log.Printf("server: image write: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := s.clipboard.Write(p.Text); err != nil {
|
||||
log.Printf("server: clipboard write: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.filter.MarkWritten(p.Text)
|
||||
s.filter.MarkWrittenPayload(p)
|
||||
s.recordReceive(p)
|
||||
|
||||
if s.opts.History != nil {
|
||||
@@ -151,7 +162,11 @@ func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if s.opts.Notifier != nil {
|
||||
if err := s.opts.Notifier.Notify("clip-sync", "Clip reçu de "+p.Origin); err != nil {
|
||||
msg := "Clip reçu de " + p.Origin
|
||||
if isImage {
|
||||
msg = "Image reçue de " + p.Origin
|
||||
}
|
||||
if err := s.opts.Notifier.Notify("clip-sync", msg); err != nil {
|
||||
log.Printf("server: notify: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -159,6 +174,24 @@ func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// writeImage decodes and writes an image payload to the clipboard. It requires
|
||||
// the clipboard implementation to support ImageClipboard.
|
||||
func (s *Server) writeImage(p dedup.Payload) error {
|
||||
ic, ok := s.clipboard.(clipboard.ImageClipboard)
|
||||
if !ok {
|
||||
return fmt.Errorf("image clipboard not supported on this platform")
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(p.Data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode image: %w", err)
|
||||
}
|
||||
mime := p.Mime
|
||||
if mime == "" {
|
||||
mime = "image/png"
|
||||
}
|
||||
return ic.WriteImage(mime, data)
|
||||
}
|
||||
|
||||
func (s *Server) recordReceive(p dedup.Payload) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -9,17 +9,32 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.dracodev.net/Projets/clip-sync/internal/clipboard"
|
||||
"git.dracodev.net/Projets/clip-sync/internal/dedup"
|
||||
)
|
||||
|
||||
// mockClipboard is an in-memory clipboard for testing.
|
||||
type mockClipboard struct {
|
||||
text string
|
||||
text string
|
||||
mime string
|
||||
imgBytes []byte
|
||||
}
|
||||
|
||||
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) ReadImage() (string, []byte, error) {
|
||||
if len(m.imgBytes) == 0 {
|
||||
return "", nil, clipboard.ErrNoImage
|
||||
}
|
||||
return m.mime, m.imgBytes, nil
|
||||
}
|
||||
func (m *mockClipboard) WriteImage(mime string, data []byte) error {
|
||||
m.mime = mime
|
||||
m.imgBytes = append([]byte(nil), data...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTestServer(cb *mockClipboard, opts Options) *Server {
|
||||
filter := dedup.NewFilter("my-machine")
|
||||
return New(opts, cb, filter)
|
||||
@@ -206,6 +221,28 @@ func TestHandleFileDisabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleClipWritesImage(t *testing.T) {
|
||||
cb := &mockClipboard{}
|
||||
srv := newTestServer(cb, Options{MaxBodyBytes: 1 << 20})
|
||||
|
||||
// base64 "iVBORw0KGgo=" is a truncated PNG header; content is arbitrary for the test.
|
||||
body := `{"mime":"image/png","data":"iVBORw0KGgo=","ts":1,"origin":"other-machine"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/clip", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
srv.handleClip(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusNoContent)
|
||||
}
|
||||
if cb.mime != "image/png" {
|
||||
t.Errorf("mime = %q, want \"image/png\"", cb.mime)
|
||||
}
|
||||
if len(cb.imgBytes) == 0 {
|
||||
t.Error("image bytes not written to clipboard")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user