mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-06 21:48:30 -04:00
clipboard: store text alongside image when both are offered
related #2849
port 1.5
(cherry picked from commit bb0be2b215)
This commit is contained in:
@@ -138,20 +138,22 @@ func handleCopyEntry(conn net.Conn, req models.Request, m *Manager) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
filePath := m.EntryToFile(entry)
|
if entry.AltMimeType == "" {
|
||||||
if filePath != "" {
|
filePath := m.EntryToFile(entry)
|
||||||
if err := m.CopyFile(filePath); err != nil {
|
if filePath != "" {
|
||||||
models.RespondError(conn, req.ID, err.Error())
|
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
|
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())
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -366,11 +408,13 @@ func (m *Manager) storeClipboardEntry(data []byte, mimeType string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
entry := Entry{
|
entry := Entry{
|
||||||
Data: data,
|
Data: data,
|
||||||
MimeType: mimeType,
|
MimeType: mimeType,
|
||||||
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/")
|
||||||
}
|
}
|
||||||
@@ -929,13 +1028,15 @@ func (m *Manager) CreateHistoryEntryFromPinned(pinnedEntry *Entry) error {
|
|||||||
|
|
||||||
// Create a new unpinned entry with the same data
|
// Create a new unpinned entry with the same data
|
||||||
newEntry := Entry{
|
newEntry := Entry{
|
||||||
Data: pinnedEntry.Data,
|
Data: pinnedEntry.Data,
|
||||||
MimeType: pinnedEntry.MimeType,
|
MimeType: pinnedEntry.MimeType,
|
||||||
Size: pinnedEntry.Size,
|
Size: pinnedEntry.Size,
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,15 +95,17 @@ type SearchResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
Data []byte `json:"data,omitempty"`
|
Data []byte `json:"data,omitempty"`
|
||||||
MimeType string `json:"mimeType"`
|
MimeType string `json:"mimeType"`
|
||||||
Preview string `json:"preview"`
|
Preview string `json:"preview"`
|
||||||
Size int `json:"size"`
|
Size int `json:"size"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user