From d87e2fcb01ad238767d87c25205b91caa86c7c33 Mon Sep 17 00:00:00 2001 From: bbedward Date: Mon, 13 Jul 2026 18:23:00 -0400 Subject: [PATCH] clipboard: store text alongside image when both are offered related #2849 port 1.5 (cherry picked from commit bb0be2b2159dd791a9d9d53ebf3b70be1d4b6b1e) --- core/internal/server/clipboard/handlers.go | 22 ++- core/internal/server/clipboard/manager.go | 180 +++++++++++++++--- .../internal/server/clipboard/manager_test.go | 66 +++++++ core/internal/server/clipboard/types.go | 20 +- 4 files changed, 240 insertions(+), 48 deletions(-) diff --git a/core/internal/server/clipboard/handlers.go b/core/internal/server/clipboard/handlers.go index d9045e661..ac993094e 100644 --- a/core/internal/server/clipboard/handlers.go +++ b/core/internal/server/clipboard/handlers.go @@ -138,20 +138,22 @@ func handleCopyEntry(conn net.Conn, req models.Request, m *Manager) { return } - filePath := m.EntryToFile(entry) - if filePath != "" { - if err := m.CopyFile(filePath); err != nil { - models.RespondError(conn, req.ID, err.Error()) + if entry.AltMimeType == "" { + filePath := m.EntryToFile(entry) + if filePath != "" { + if err := m.CopyFile(filePath); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + models.Respond(conn, req.ID, map[string]any{ + "success": true, + "filePath": filePath, + }) return } - models.Respond(conn, req.ID, map[string]any{ - "success": true, - "filePath": filePath, - }) - return } - if err := m.SetClipboard(entry.Data, entry.MimeType); err != nil { + if err := m.SetClipboardEntry(entry); err != nil { models.RespondError(conn, req.ID, err.Error()) return } diff --git a/core/internal/server/clipboard/manager.go b/core/internal/server/clipboard/manager.go index 664a29775..d6baa889f 100644 --- a/core/internal/server/clipboard/manager.go +++ b/core/internal/server/clipboard/manager.go @@ -279,7 +279,29 @@ func (m *Manager) setupDataDeviceSync() { } w.Close() - go m.readAndStore(r, preferredMime) + altMime := "" + if m.isImageMimeType(preferredMime) && !slices.Contains(mimes, "x-special/gnome-copied-files") { + altMime = selectAltTextMimeType(mimes) + } + if altMime == "" { + go m.readAndStore(r, preferredMime, nil, "") + return + } + + altR, altW, err := os.Pipe() + if err != nil { + go m.readAndStore(r, preferredMime, nil, "") + return + } + if err := typedOffer.Receive(altMime, int(altW.Fd())); err != nil { + altR.Close() + altW.Close() + go m.readAndStore(r, preferredMime, nil, "") + return + } + altW.Close() + + go m.readAndStore(r, preferredMime, altR, altMime) }) if err := dataMgr.GetDataDeviceWithProxy(dataDevice, m.seat); err != nil { @@ -324,22 +346,42 @@ func (m *Manager) releaseCurrentSource() { source.Destroy() } -func (m *Manager) readAndStore(r *os.File, mimeType string) { - defer r.Close() - - cfg := m.getConfig() - +func readPipeTimeout(r *os.File) []byte { done := make(chan []byte, 1) go func() { data, _ := io.ReadAll(r) done <- data }() - var data []byte select { - case data = <-done: + case data := <-done: + return data case <-time.After(500 * time.Millisecond): - return + return nil + } +} + +func (m *Manager) readAndStore(r *os.File, mimeType string, altR *os.File, altMime string) { + defer r.Close() + + cfg := m.getConfig() + + altCh := make(chan []byte, 1) + switch altR { + case nil: + altCh <- nil + default: + go func() { + defer altR.Close() + altCh <- readPipeTimeout(altR) + }() + } + + data := readPipeTimeout(r) + altData := <-altCh + + if len(bytes.TrimSpace(altData)) == 0 || int64(len(altData)) > cfg.MaxEntrySize { + altData, altMime = nil, "" } if len(data) == 0 || int64(len(data)) > cfg.MaxEntrySize { @@ -350,14 +392,14 @@ func (m *Manager) readAndStore(r *os.File, mimeType string) { } if !cfg.Disabled && m.db != nil { - m.storeClipboardEntry(data, mimeType) + m.storeClipboardEntry(data, mimeType, altData, altMime) } m.updateState() m.notifySubscribers() } -func (m *Manager) storeClipboardEntry(data []byte, mimeType string) { +func (m *Manager) storeClipboardEntry(data []byte, mimeType string, altData []byte, altMime string) { if mimeType == "text/uri-list" { if imgData, imgMime, ok := m.tryReadImageFromURI(data); ok { data = imgData @@ -366,11 +408,13 @@ func (m *Manager) storeClipboardEntry(data []byte, mimeType string) { } entry := Entry{ - Data: data, - MimeType: mimeType, - Size: len(data), - Timestamp: time.Now(), - IsImage: m.isImageMimeType(mimeType), + Data: data, + MimeType: mimeType, + Size: len(data), + Timestamp: time.Now(), + IsImage: m.isImageMimeType(mimeType), + AltData: altData, + AltMimeType: altMime, } switch { @@ -483,6 +527,12 @@ func encodeEntry(e Entry) ([]byte, error) { } else { buf.WriteByte(0) } + if e.AltMimeType != "" { + binary.Write(buf, binary.BigEndian, uint32(len(e.AltMimeType))) + buf.WriteString(e.AltMimeType) + binary.Write(buf, binary.BigEndian, uint32(len(e.AltData))) + buf.Write(e.AltData) + } return buf.Bytes(), nil } @@ -547,6 +597,21 @@ func decodeEntryFields(data []byte, withData bool) (Entry, error) { e.Pinned = pinnedByte == 1 } + if buf.Len() >= 4 { + var altMimeLen uint32 + binary.Read(buf, binary.BigEndian, &altMimeLen) + altMimeBytes := make([]byte, altMimeLen) + buf.Read(altMimeBytes) + e.AltMimeType = string(altMimeBytes) + + var altDataLen uint32 + binary.Read(buf, binary.BigEndian, &altDataLen) + if withData { + e.AltData = make([]byte, altDataLen) + buf.Read(e.AltData) + } + } + return e, nil } @@ -563,10 +628,27 @@ func computeHash(data []byte) uint64 { } func extractHash(data []byte) uint64 { - if len(data) < 9 { + buf := bytes.NewReader(data) + if _, err := buf.Seek(8, io.SeekStart); err != nil { return 0 } - return binary.BigEndian.Uint64(data[len(data)-9 : len(data)-1]) + for range 3 { // data, mime type, preview + var length uint32 + if binary.Read(buf, binary.BigEndian, &length) != nil { + return 0 + } + if _, err := buf.Seek(int64(length), io.SeekCurrent); err != nil { + return 0 + } + } + if _, err := buf.Seek(4+8+1, io.SeekCurrent); err != nil { // size, timestamp, isImage + return 0 + } + var hash uint64 + if binary.Read(buf, binary.BigEndian, &hash) != nil { + return 0 + } + return hash } func (m *Manager) hasSensitiveMimeType(mimes []string) bool { @@ -611,6 +693,23 @@ func (m *Manager) selectMimeType(mimes []string) string { return "" } +var altTextMimeTypes = []string{ + "text/plain;charset=utf-8", + "text/plain", + "UTF8_STRING", + "STRING", + "TEXT", +} + +func selectAltTextMimeType(mimes []string) string { + for _, pref := range altTextMimeTypes { + if slices.Contains(mimes, pref) { + return pref + } + } + return "" +} + func (m *Manager) isImageMimeType(mime string) bool { return strings.HasPrefix(mime, "image/") } @@ -929,13 +1028,15 @@ func (m *Manager) CreateHistoryEntryFromPinned(pinnedEntry *Entry) error { // Create a new unpinned entry with the same data newEntry := Entry{ - Data: pinnedEntry.Data, - MimeType: pinnedEntry.MimeType, - Size: pinnedEntry.Size, - Timestamp: time.Now(), - IsImage: pinnedEntry.IsImage, - Preview: pinnedEntry.Preview, - Pinned: false, + Data: pinnedEntry.Data, + MimeType: pinnedEntry.MimeType, + Size: pinnedEntry.Size, + Timestamp: time.Now(), + IsImage: pinnedEntry.IsImage, + Preview: pinnedEntry.Preview, + Pinned: false, + AltData: pinnedEntry.AltData, + AltMimeType: pinnedEntry.AltMimeType, } if err := m.storeEntry(newEntry); err != nil { @@ -1061,6 +1162,23 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error { return nil } +// SetClipboardEntry takes the selection serving the entry's primary +// representation plus its stored alternate, so history restores keep +// both the text and image sides pasteable. +func (m *Manager) SetClipboardEntry(entry *Entry) error { + if int64(len(entry.Data)) > m.config.MaxEntrySize { + return fmt.Errorf("data too large") + } + + offers := clipboardstore.ExpandOffers(slices.Clone(entry.Data), entry.MimeType) + if entry.AltMimeType != "" { + offers = append(offers, clipboardstore.ExpandOffers(slices.Clone(entry.AltData), entry.AltMimeType)...) + } + + m.takeSelection(offers) + return nil +} + // takeSelection makes the daemon the selection owner, serving the given // offers until another client claims the clipboard. func (m *Manager) takeSelection(offers []clipboardstore.Offer) { @@ -1154,16 +1272,20 @@ func (m *Manager) PasteText() (string, error) { } entry := history[0] - if entry.IsImage { - return "", fmt.Errorf("clipboard contains image, not text") - } fullEntry, err := m.GetEntry(entry.ID) if err != nil { return "", err } - return string(fullEntry.Data), nil + switch { + case !fullEntry.IsImage: + return string(fullEntry.Data), nil + case fullEntry.AltMimeType != "": + return string(fullEntry.AltData), nil + default: + return "", fmt.Errorf("clipboard contains image, not text") + } } func (m *Manager) Close() { diff --git a/core/internal/server/clipboard/manager_test.go b/core/internal/server/clipboard/manager_test.go index 398f303dc..a0a3c703f 100644 --- a/core/internal/server/clipboard/manager_test.go +++ b/core/internal/server/clipboard/manager_test.go @@ -144,6 +144,72 @@ func TestEncodeDecodeEntry_LargeData(t *testing.T) { assert.Equal(t, original.Size, decoded.Size) } +func TestEncodeDecodeEntry_AltRepresentation(t *testing.T) { + original := Entry{ + ID: 555, + Data: []byte{0x42, 0x4D, 0x01, 0x02}, + MimeType: "image/bmp", + Preview: "[[ image 4 B bmp 85x19 ]]", + Size: 4, + Timestamp: time.Now().Truncate(time.Second), + IsImage: true, + Hash: computeHash([]byte{0x42, 0x4D, 0x01, 0x02}), + Pinned: true, + AltData: []byte("real text from OneNote"), + AltMimeType: "text/plain;charset=utf-8", + } + + encoded, err := encodeEntry(original) + assert.NoError(t, err) + + decoded, err := decodeEntry(encoded) + assert.NoError(t, err) + assert.Equal(t, original.Data, decoded.Data) + assert.Equal(t, original.MimeType, decoded.MimeType) + assert.True(t, decoded.Pinned) + assert.Equal(t, original.AltData, decoded.AltData) + assert.Equal(t, original.AltMimeType, decoded.AltMimeType) + + meta, err := decodeEntryMeta(encoded) + assert.NoError(t, err) + assert.Empty(t, meta.Data) + assert.Equal(t, original.AltMimeType, meta.AltMimeType) + + assert.Equal(t, original.Hash, extractHash(encoded)) +} + +func TestExtractHash_NoAlt(t *testing.T) { + entry := Entry{ + ID: 1, + Data: []byte("plain entry"), + MimeType: "text/plain", + Preview: "plain entry", + Size: 11, + Timestamp: time.Now().Truncate(time.Second), + Hash: computeHash([]byte("plain entry")), + } + + encoded, err := encodeEntry(entry) + assert.NoError(t, err) + assert.Equal(t, entry.Hash, extractHash(encoded)) +} + +func TestSelectAltTextMimeType(t *testing.T) { + tests := []struct { + mimes []string + expected string + }{ + {[]string{"image/bmp", "TEXT", "text/html", "text/plain", "text/plain;charset=utf-8", "UTF8_STRING"}, "text/plain;charset=utf-8"}, + {[]string{"image/png", "UTF8_STRING"}, "UTF8_STRING"}, + {[]string{"image/png", "text/html"}, ""}, + {[]string{"image/png"}, ""}, + } + + for _, tt := range tests { + assert.Equal(t, tt.expected, selectAltTextMimeType(tt.mimes)) + } +} + func TestStateEqual_BothNil(t *testing.T) { assert.False(t, stateEqual(nil, nil)) } diff --git a/core/internal/server/clipboard/types.go b/core/internal/server/clipboard/types.go index f883d2113..c1a468f17 100644 --- a/core/internal/server/clipboard/types.go +++ b/core/internal/server/clipboard/types.go @@ -95,15 +95,17 @@ type SearchResult struct { } type Entry struct { - ID uint64 `json:"id"` - Data []byte `json:"data,omitempty"` - MimeType string `json:"mimeType"` - Preview string `json:"preview"` - Size int `json:"size"` - Timestamp time.Time `json:"timestamp"` - IsImage bool `json:"isImage"` - Hash uint64 `json:"hash,omitempty"` - Pinned bool `json:"pinned"` + ID uint64 `json:"id"` + Data []byte `json:"data,omitempty"` + MimeType string `json:"mimeType"` + Preview string `json:"preview"` + Size int `json:"size"` + Timestamp time.Time `json:"timestamp"` + IsImage bool `json:"isImage"` + Hash uint64 `json:"hash,omitempty"` + Pinned bool `json:"pinned"` + AltData []byte `json:"altData,omitempty"` + AltMimeType string `json:"altMimeType,omitempty"` } type State struct {