1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-07 14:08:29 -04:00

clipboard: store text alongside image when both are offered

related #2849
port 1.5
This commit is contained in:
bbedward
2026-07-13 18:23:00 -04:00
parent 25847c3f03
commit bb0be2b215
4 changed files with 240 additions and 48 deletions
+3 -1
View File
@@ -138,6 +138,7 @@ func handleCopyEntry(conn net.Conn, req models.Request, m *Manager) {
return return
} }
if entry.AltMimeType == "" {
filePath := m.EntryToFile(entry) filePath := m.EntryToFile(entry)
if filePath != "" { if filePath != "" {
if err := m.CopyFile(filePath); err != nil { if err := m.CopyFile(filePath); err != nil {
@@ -150,8 +151,9 @@ func handleCopyEntry(conn net.Conn, req models.Request, m *Manager) {
}) })
return 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()) models.RespondError(conn, req.ID, err.Error())
return return
} }
+138 -16
View File
@@ -279,7 +279,29 @@ func (m *Manager) setupDataDeviceSync() {
} }
w.Close() 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 { if err := dataMgr.GetDataDeviceWithProxy(dataDevice, m.seat); err != nil {
@@ -324,22 +346,42 @@ func (m *Manager) releaseCurrentSource() {
source.Destroy() source.Destroy()
} }
func (m *Manager) readAndStore(r *os.File, mimeType string) { func readPipeTimeout(r *os.File) []byte {
defer r.Close()
cfg := m.getConfig()
done := make(chan []byte, 1) done := make(chan []byte, 1)
go func() { go func() {
data, _ := io.ReadAll(r) data, _ := io.ReadAll(r)
done <- data done <- data
}() }()
var data []byte
select { select {
case data = <-done: case data := <-done:
return data
case <-time.After(500 * time.Millisecond): 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 { 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 { if !cfg.Disabled && m.db != nil {
m.storeClipboardEntry(data, mimeType) m.storeClipboardEntry(data, mimeType, altData, altMime)
} }
m.updateState() m.updateState()
m.notifySubscribers() 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 mimeType == "text/uri-list" {
if imgData, imgMime, ok := m.tryReadImageFromURI(data); ok { if imgData, imgMime, ok := m.tryReadImageFromURI(data); ok {
data = imgData data = imgData
@@ -371,6 +413,8 @@ func (m *Manager) storeClipboardEntry(data []byte, mimeType string) {
Size: len(data), Size: len(data),
Timestamp: time.Now(), Timestamp: time.Now(),
IsImage: m.isImageMimeType(mimeType), IsImage: m.isImageMimeType(mimeType),
AltData: altData,
AltMimeType: altMime,
} }
switch { switch {
@@ -483,6 +527,12 @@ func encodeEntry(e Entry) ([]byte, error) {
} else { } else {
buf.WriteByte(0) 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 return buf.Bytes(), nil
} }
@@ -547,6 +597,21 @@ func decodeEntryFields(data []byte, withData bool) (Entry, error) {
e.Pinned = pinnedByte == 1 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 return e, nil
} }
@@ -563,10 +628,27 @@ func computeHash(data []byte) uint64 {
} }
func extractHash(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 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 { func (m *Manager) hasSensitiveMimeType(mimes []string) bool {
@@ -611,6 +693,23 @@ func (m *Manager) selectMimeType(mimes []string) string {
return "" 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 { func (m *Manager) isImageMimeType(mime string) bool {
return strings.HasPrefix(mime, "image/") return strings.HasPrefix(mime, "image/")
} }
@@ -936,6 +1035,8 @@ func (m *Manager) CreateHistoryEntryFromPinned(pinnedEntry *Entry) error {
IsImage: pinnedEntry.IsImage, IsImage: pinnedEntry.IsImage,
Preview: pinnedEntry.Preview, Preview: pinnedEntry.Preview,
Pinned: false, Pinned: false,
AltData: pinnedEntry.AltData,
AltMimeType: pinnedEntry.AltMimeType,
} }
if err := m.storeEntry(newEntry); err != nil { if err := m.storeEntry(newEntry); err != nil {
@@ -1061,6 +1162,23 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
return nil 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 // takeSelection makes the daemon the selection owner, serving the given
// offers until another client claims the clipboard. // offers until another client claims the clipboard.
func (m *Manager) takeSelection(offers []clipboardstore.Offer) { func (m *Manager) takeSelection(offers []clipboardstore.Offer) {
@@ -1154,16 +1272,20 @@ func (m *Manager) PasteText() (string, error) {
} }
entry := history[0] entry := history[0]
if entry.IsImage {
return "", fmt.Errorf("clipboard contains image, not text")
}
fullEntry, err := m.GetEntry(entry.ID) fullEntry, err := m.GetEntry(entry.ID)
if err != nil { if err != nil {
return "", err return "", err
} }
switch {
case !fullEntry.IsImage:
return string(fullEntry.Data), nil 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() { func (m *Manager) Close() {
@@ -144,6 +144,72 @@ func TestEncodeDecodeEntry_LargeData(t *testing.T) {
assert.Equal(t, original.Size, decoded.Size) 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) { func TestStateEqual_BothNil(t *testing.T) {
assert.False(t, stateEqual(nil, nil)) assert.False(t, stateEqual(nil, nil))
} }
+2
View File
@@ -104,6 +104,8 @@ type Entry struct {
IsImage bool `json:"isImage"` IsImage bool `json:"isImage"`
Hash uint64 `json:"hash,omitempty"` Hash uint64 `json:"hash,omitempty"`
Pinned bool `json:"pinned"` Pinned bool `json:"pinned"`
AltData []byte `json:"altData,omitempty"`
AltMimeType string `json:"altMimeType,omitempty"`
} }
type State struct { type State struct {