1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-15 15:45:20 -04:00

fix(Clipboard): stale image entry handling

- Resolved random DMS API errors & QML Warnings
This commit is contained in:
purian23
2026-06-03 03:17:02 -04:00
parent 510269dda9
commit a34fda984d
6 changed files with 290 additions and 18 deletions
@@ -2,6 +2,7 @@ package clipboard
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net" "net"
@@ -73,6 +74,10 @@ func handleGetEntry(conn net.Conn, req models.Request, m *Manager) {
entry, err := m.GetEntry(uint64(id)) entry, err := m.GetEntry(uint64(id))
if err != nil { if err != nil {
if errors.Is(err, errEntryNotFound) {
models.Respond[any](conn, req.ID, nil)
return
}
models.RespondError(conn, req.ID, err.Error()) models.RespondError(conn, req.ID, err.Error())
return return
} }
+20 -1
View File
@@ -3,6 +3,7 @@ package clipboard
import ( import (
"bytes" "bytes"
"encoding/binary" "encoding/binary"
"errors"
"fmt" "fmt"
"image" "image"
_ "image/gif" _ "image/gif"
@@ -34,6 +35,8 @@ import (
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
) )
var errEntryNotFound = errors.New("entry not found")
// These mime types won't be stored in history // These mime types won't be stored in history
var sensitiveMimeTypes = []string{ var sensitiveMimeTypes = []string{
"x-kde-passwordManagerHint", "x-kde-passwordManagerHint",
@@ -764,9 +767,25 @@ func stateEqual(a, b *State) bool {
if len(a.History) != len(b.History) { if len(a.History) != len(b.History) {
return false return false
} }
for i := range a.History {
if !entryStateEqual(a.History[i], b.History[i]) {
return false
}
}
return true return true
} }
func entryStateEqual(a, b Entry) bool {
return a.ID == b.ID &&
a.Hash == b.Hash &&
a.Pinned == b.Pinned &&
a.IsImage == b.IsImage &&
a.MimeType == b.MimeType &&
a.Preview == b.Preview &&
a.Size == b.Size &&
a.Timestamp.Equal(b.Timestamp)
}
func (m *Manager) GetHistory() []Entry { func (m *Manager) GetHistory() []Entry {
if m.db == nil { if m.db == nil {
return nil return nil
@@ -854,7 +873,7 @@ func (m *Manager) GetEntry(id uint64) (*Entry, error) {
return nil, err return nil, err
} }
if !found { if !found {
return nil, fmt.Errorf("entry not found") return nil, errEntryNotFound
} }
return &entry, nil return &entry, nil
+139 -2
View File
@@ -1,17 +1,52 @@
package clipboard package clipboard
import ( import (
"bytes"
"encoding/json"
"net"
"path/filepath"
"sync" "sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
mocks_wlcontext "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlcontext" mocks_wlcontext "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlcontext"
) )
type clipboardTestConn struct {
net.Conn
writeBuf *bytes.Buffer
}
func newClipboardTestConn() *clipboardTestConn {
return &clipboardTestConn{writeBuf: &bytes.Buffer{}}
}
func (c *clipboardTestConn) Write(b []byte) (int, error) {
return c.writeBuf.Write(b)
}
func newTestManagerWithDB(t *testing.T) *Manager {
t.Helper()
db, err := openDB(filepath.Join(t.TempDir(), "clipboard.db"))
require.NoError(t, err)
t.Cleanup(func() {
db.Close()
})
return &Manager{
config: DefaultConfig(),
db: db,
}
}
func TestEncodeDecodeEntry_Roundtrip(t *testing.T) { func TestEncodeDecodeEntry_Roundtrip(t *testing.T) {
original := Entry{ original := Entry{
ID: 12345, ID: 12345,
@@ -131,11 +166,113 @@ func TestStateEqual_HistoryLengthDiffers(t *testing.T) {
} }
func TestStateEqual_BothEqual(t *testing.T) { func TestStateEqual_BothEqual(t *testing.T) {
a := &State{Enabled: true, History: []Entry{{ID: 1}, {ID: 2}}} ts := time.Now().Truncate(time.Second)
b := &State{Enabled: true, History: []Entry{{ID: 3}, {ID: 4}}} entry := Entry{
ID: 1,
Hash: 100,
MimeType: "image/png",
Preview: "[[ image 1 KiB png 32x32 ]]",
Size: 1024,
Timestamp: ts,
IsImage: true,
Pinned: true,
}
a := &State{Enabled: true, History: []Entry{entry}}
b := &State{Enabled: true, History: []Entry{entry}}
assert.True(t, stateEqual(a, b)) assert.True(t, stateEqual(a, b))
} }
func TestStateEqual_SameLengthDifferentIDs(t *testing.T) {
ts := time.Now().Truncate(time.Second)
a := &State{Enabled: true, History: []Entry{{ID: 1, Hash: 100, Timestamp: ts}}}
b := &State{Enabled: true, History: []Entry{{ID: 2, Hash: 100, Timestamp: ts}}}
assert.False(t, stateEqual(a, b))
}
func TestStateEqual_MetadataDiffers(t *testing.T) {
ts := time.Now().Truncate(time.Second)
base := Entry{
ID: 1,
Hash: 100,
MimeType: "image/png",
Preview: "[[ image 1 KiB png 32x32 ]]",
Size: 1024,
Timestamp: ts,
IsImage: true,
Pinned: false,
}
tests := []struct {
name string
mutate func(*Entry)
}{
{name: "hash", mutate: func(e *Entry) { e.Hash = 101 }},
{name: "pinned", mutate: func(e *Entry) { e.Pinned = true }},
{name: "is image", mutate: func(e *Entry) { e.IsImage = false }},
{name: "mime type", mutate: func(e *Entry) { e.MimeType = "image/jpeg" }},
{name: "preview", mutate: func(e *Entry) { e.Preview = "[[ image 2 KiB jpeg 64x64 ]]" }},
{name: "size", mutate: func(e *Entry) { e.Size = 2048 }},
{name: "timestamp", mutate: func(e *Entry) { e.Timestamp = ts.Add(time.Second) }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
changed := base
tt.mutate(&changed)
a := &State{Enabled: true, History: []Entry{base}}
b := &State{Enabled: true, History: []Entry{changed}}
assert.False(t, stateEqual(a, b))
})
}
}
func TestHandleGetEntry_ReturnsExistingEntry(t *testing.T) {
m := newTestManagerWithDB(t)
err := m.storeEntry(Entry{
Data: []byte("hello world"),
MimeType: "text/plain;charset=utf-8",
Preview: "hello world",
Size: len("hello world"),
Timestamp: time.Now().Truncate(time.Second),
IsImage: false,
})
require.NoError(t, err)
history := m.GetHistory()
require.Len(t, history, 1)
conn := newClipboardTestConn()
handleGetEntry(conn, models.Request{
ID: 1,
Params: map[string]any{"id": float64(history[0].ID)},
}, m)
var resp models.Response[Entry]
require.NoError(t, json.NewDecoder(conn.writeBuf).Decode(&resp))
assert.Empty(t, resp.Error)
require.NotNil(t, resp.Result)
assert.Equal(t, history[0].ID, resp.Result.ID)
assert.Equal(t, []byte("hello world"), resp.Result.Data)
}
func TestHandleGetEntry_MissingIDReturnsNullResult(t *testing.T) {
m := newTestManagerWithDB(t)
conn := newClipboardTestConn()
handleGetEntry(conn, models.Request{
ID: 1,
Params: map[string]any{"id": float64(999)},
}, m)
var resp models.Response[any]
require.NoError(t, json.NewDecoder(conn.writeBuf).Decode(&resp))
assert.Empty(t, resp.Error)
assert.Nil(t, resp.Result)
}
func TestManager_ConcurrentSubscriberAccess(t *testing.T) { func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
m := &Manager{ m := &Manager{
subscribers: make(map[string]chan State), subscribers: make(map[string]chan State),
@@ -89,6 +89,10 @@ Item {
if (!root.entry || root.entry.id !== requestedId) { if (!root.entry || root.entry.id !== requestedId) {
return; return;
} }
if (!response.result) {
ClipboardService.refresh();
return;
}
const result = response.result; const result = response.result;
let fullText = ""; let fullText = "";
if (result?.data) { if (result?.data) {
@@ -13,6 +13,7 @@ Item {
required property var modal required property var modal
required property var listView required property var listView
required property int itemIndex required property int itemIndex
property bool disposed: false
Image { Image {
id: thumbnailImage id: thumbnailImage
@@ -20,6 +21,13 @@ Item {
property bool isVisible: false property bool isVisible: false
property string cachedImageData: "" property string cachedImageData: ""
property bool loadQueued: false property bool loadQueued: false
property bool activeLoad: false
property bool completed: false
property int loadGeneration: 0
property var activeEntryId: null
property var activeRequest: null
property var currentEntryId: entry && entry.id !== undefined ? entry.id : null
property string currentEntryType: entryType
anchors.fill: parent anchors.fill: parent
source: cachedImageData ? `data:image/png;base64,${cachedImageData}` : "" source: cachedImageData ? `data:image/png;base64,${cachedImageData}` : ""
@@ -31,29 +39,119 @@ Item {
sourceSize.width: 128 sourceSize.width: 128
sourceSize.height: 128 sourceSize.height: 128
onCurrentEntryIdChanged: {
if (thumbnailImage.completed) {
thumbnailImage.resetForEntry();
}
}
onCurrentEntryTypeChanged: {
if (thumbnailImage.completed) {
thumbnailImage.resetForEntry();
}
}
function hasValidEntryId() {
return entry && entry.id !== undefined && entry.id !== null;
}
function releaseActiveLoad() {
if (!thumbnailImage.activeLoad) {
return;
}
thumbnailImage.activeLoad = false;
if (modal && modal.activeImageLoads > 0) {
modal.activeImageLoads--;
}
}
function finishLoad(request) {
thumbnailImage.loadQueued = false;
thumbnailImage.activeEntryId = null;
if (!request || thumbnailImage.activeRequest === request) {
thumbnailImage.activeRequest = null;
}
thumbnailImage.releaseActiveLoad();
}
function cancelLoad() {
if (thumbnailImage.activeRequest) {
thumbnailImage.activeRequest.cancelled = true;
thumbnailImage.activeRequest = null;
}
retryTimer.stop();
visibilityTimer.stop();
thumbnailImage.loadQueued = false;
thumbnailImage.activeEntryId = null;
thumbnailImage.releaseActiveLoad();
}
function resetForEntry() {
thumbnailImage.loadGeneration++;
thumbnailImage.cachedImageData = "";
thumbnailImage.isVisible = false;
thumbnailImage.cancelLoad();
Qt.callLater(function () {
if (thumbnail.disposed) {
return;
}
thumbnailImage.checkVisibility();
});
}
function startLoad() {
if (!modal) {
thumbnailImage.loadQueued = false;
return;
}
modal.activeImageLoads++;
thumbnailImage.activeLoad = true;
thumbnailImage.loadImage();
}
function tryLoadImage() { function tryLoadImage() {
if (thumbnailImage.loadQueued || entryType !== "image" || thumbnailImage.cachedImageData) { if (thumbnail.disposed || thumbnailImage.loadQueued || entryType !== "image" || thumbnailImage.cachedImageData || !thumbnailImage.hasValidEntryId()) {
return; return;
} }
thumbnailImage.loadQueued = true; thumbnailImage.loadQueued = true;
if (modal.activeImageLoads < modal.maxConcurrentLoads) { if (modal && modal.activeImageLoads < modal.maxConcurrentLoads) {
modal.activeImageLoads++; thumbnailImage.startLoad();
thumbnailImage.loadImage();
} else { } else {
retryTimer.restart(); retryTimer.restart();
} }
} }
function loadImage() { function loadImage() {
if (!thumbnailImage.hasValidEntryId()) {
thumbnailImage.finishLoad();
return;
}
const requestedId = entry.id;
const generation = thumbnailImage.loadGeneration;
const request = {
"cancelled": false
};
thumbnailImage.activeEntryId = requestedId;
thumbnailImage.activeRequest = request;
DMSService.sendRequest("clipboard.getEntry", { DMSService.sendRequest("clipboard.getEntry", {
"id": entry.id "id": requestedId
}, function (response) { }, function (response) {
thumbnailImage.loadQueued = false; if (request.cancelled) {
if (modal.activeImageLoads > 0) { return;
modal.activeImageLoads--; }
if (thumbnail.disposed || generation !== thumbnailImage.loadGeneration || thumbnailImage.activeRequest !== request || thumbnailImage.activeEntryId !== requestedId) {
return;
}
thumbnailImage.finishLoad(request);
if (!entry || entry.id !== requestedId || entryType !== "image") {
return;
} }
if (response.error) { if (response.error) {
log.warn("Failed to load image:", entry.id); log.warn("Failed to load image:", requestedId);
return;
}
if (!response.result) {
ClipboardService.refresh();
return; return;
} }
const data = response.result?.data; const data = response.result?.data;
@@ -70,9 +168,8 @@ Item {
if (!thumbnailImage.loadQueued) { if (!thumbnailImage.loadQueued) {
return; return;
} }
if (modal.activeImageLoads < modal.maxConcurrentLoads) { if (modal && modal.activeImageLoads < modal.maxConcurrentLoads) {
modal.activeImageLoads++; thumbnailImage.startLoad();
thumbnailImage.loadImage();
} else { } else {
retryTimer.restart(); retryTimer.restart();
} }
@@ -80,7 +177,8 @@ Item {
} }
Component.onCompleted: { Component.onCompleted: {
if (entryType !== "image" || listView.height <= 0) { thumbnailImage.completed = true;
if (entryType !== "image" || listView.height <= 0 || !thumbnailImage.hasValidEntryId()) {
return; return;
} }
@@ -94,6 +192,11 @@ Item {
} }
} }
Component.onDestruction: {
thumbnail.disposed = true;
thumbnailImage.cancelLoad();
}
Timer { Timer {
id: visibilityTimer id: visibilityTimer
interval: 100 interval: 100
@@ -101,7 +204,7 @@ Item {
} }
function checkVisibility() { function checkVisibility() {
if (entryType !== "image" || listView.height <= 0 || isVisible) { if (thumbnail.disposed || entryType !== "image" || listView.height <= 0 || isVisible || !thumbnailImage.hasValidEntryId()) {
return; return;
} }
const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing); const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing);
@@ -57,7 +57,11 @@ Rectangle {
return; return;
if (response.error) if (response.error)
return; return;
const result = response.result ?? {}; if (!response.result) {
ClipboardService.refresh();
return;
}
const result = response.result;
const mimeType = (result.mimeType ?? entry?.mimeType ?? "").toString(); const mimeType = (result.mimeType ?? entry?.mimeType ?? "").toString();
const data = (result.data ?? "").toString(); const data = (result.data ?? "").toString();
if (data.length === 0 || !resolvedSourceUrl(data, mimeType)) if (data.length === 0 || !resolvedSourceUrl(data, mimeType))