From 3c365533c773ae0119b6edfb32d6862febcc1028 Mon Sep 17 00:00:00 2001 From: xibielemn <160737596+sijin-xb@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:55:25 +0800 Subject: [PATCH] clipboard: bind the first wl_seat only (#3989) port 2.0 (cherry picked from commit df8b1d85ca6709b42e35d89bb7e8353bb38381ba) --- core/internal/server/clipboard/manager.go | 76 ++++++++++++++++--- .../internal/server/clipboard/manager_test.go | 61 ++++++++++++++- quickshell/Services/ClipboardService.qml | 7 +- 3 files changed, 128 insertions(+), 16 deletions(-) diff --git a/core/internal/server/clipboard/manager.go b/core/internal/server/clipboard/manager.go index f6e0dd5fc..d8fec547e 100644 --- a/core/internal/server/clipboard/manager.go +++ b/core/internal/server/clipboard/manager.go @@ -2,6 +2,7 @@ package clipboard import ( "bytes" + "cmp" "encoding/binary" "errors" "fmt" @@ -249,6 +250,9 @@ func (m *Manager) setupRegistry() error { m.dataControlMgr = dataControlMgr log.Info("Bound ext_data_control_manager_v1") case "wl_seat": + if m.seat != nil { + return + } seat := wlclient.NewSeat(ctx) if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil { log.Errorf("Failed to bind wl_seat: %v", err) @@ -634,18 +638,27 @@ func (m *Manager) trimLengthInTx(b *bolt.Bucket) error { if maxHistory < 0 { return nil } + + type row struct { + key []byte + entry Entry + } + var rows []row c := b.Cursor() - var count int - for k, v := c.Last(); k != nil; k, v = c.Prev() { + for k, v := c.First(); k != nil; k, v = c.Next() { entry, err := decodeEntryMeta(v) - if err == nil && entry.Pinned { + if err != nil || entry.Pinned { continue } - if count < maxHistory { - count++ - continue - } - if err := b.Delete(k); err != nil { + rows = append(rows, row{slices.Clone(k), entry}) + } + + slices.SortFunc(rows, func(a, b row) int { + return compareEntriesNewestFirst(a.entry, b.entry) + }) + + for _, r := range rows[min(maxHistory, len(rows)):] { + if err := b.Delete(r.key); err != nil { return err } } @@ -663,7 +676,7 @@ func encodeEntry(e Entry) ([]byte, error) { binary.Write(buf, binary.BigEndian, uint32(len(e.Preview))) buf.WriteString(e.Preview) binary.Write(buf, binary.BigEndian, int32(e.Size)) - binary.Write(buf, binary.BigEndian, e.Timestamp.Unix()) + binary.Write(buf, binary.BigEndian, e.Timestamp.UnixNano()) if e.IsImage { buf.WriteByte(1) } else { @@ -729,7 +742,13 @@ func decodeEntryFields(data []byte, withData bool) (Entry, error) { var timestamp int64 binary.Read(buf, binary.BigEndian, ×tamp) - e.Timestamp = time.Unix(timestamp, 0) + // Rows written before the switch to nanosecond precision hold seconds; + // no second-resolution stamp reaches 1e12 until the year 33658. + if timestamp < 1e12 { + e.Timestamp = time.Unix(timestamp, 0) + } else { + e.Timestamp = time.Unix(0, timestamp) + } var isImage byte binary.Read(buf, binary.BigEndian, &isImage) @@ -1105,9 +1124,25 @@ func (m *Manager) GetHistory() []Entry { go m.deleteStaleEntries(stale) } + sortEntriesNewestFirst(history) + return history } +// sortEntriesNewestFirst orders by timestamp, falling back to id: TouchEntry +// rewrites a row in place to keep its id stable, so bucket key order stops +// tracking recency. +func sortEntriesNewestFirst(entries []Entry) { + slices.SortFunc(entries, compareEntriesNewestFirst) +} + +func compareEntriesNewestFirst(a, b Entry) int { + if c := b.Timestamp.Compare(a.Timestamp); c != 0 { + return c + } + return cmp.Compare(b.ID, a.ID) +} + func (m *Manager) deleteStaleEntries(ids []uint64) { if m.db == nil { return @@ -1243,9 +1278,24 @@ func (m *Manager) TouchEntry(id uint64) error { return err } - entry.Timestamp = time.Now() + // Round(0) drops the monotonic reading so the in-memory stamp compares the + // same way as the one decoded back from the bucket. + entry.Timestamp = time.Now().Round(0) - if err := m.storeEntry(*entry); err != nil { + // storeEntry would dedupe the row against itself and hand out a fresh + // sequence number, invalidating the id the UI is still holding. + err = m.dbUpdate(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + if b == nil { + return fmt.Errorf("clipboard bucket missing") + } + encoded, err := encodeEntry(*entry) + if err != nil { + return err + } + return b.Put(itob(id), encoded) + }) + if err != nil { return err } @@ -2200,6 +2250,8 @@ func (m *Manager) GetPinnedEntries() []Entry { log.Errorf("Failed to get pinned entries: %v", err) } + sortEntriesNewestFirst(pinned) + return pinned } diff --git a/core/internal/server/clipboard/manager_test.go b/core/internal/server/clipboard/manager_test.go index 0ff861907..0d7d941db 100644 --- a/core/internal/server/clipboard/manager_test.go +++ b/core/internal/server/clipboard/manager_test.go @@ -3,6 +3,7 @@ package clipboard import ( "bytes" "encoding/json" + "fmt" "image" "image/png" "net" @@ -446,7 +447,9 @@ func TestUnpinEntry_KeepsTopUnpinnedDuplicate(t *testing.T) { history = m.GetHistory() require.Len(t, history, 3) - require.Equal(t, topHistoryDuplicate.ID, history[0].ID) + // GetHistory sorts by timestamp, so the newer row leads even though its + // id is lower. + require.Equal(t, olderHistoryDuplicate.ID, history[0].ID) require.NoError(t, m.UnpinEntry(pinnedID)) history = m.GetHistory() @@ -1088,3 +1091,59 @@ func TestApplyConfigChange_DisableReleasesDataDevice(t *testing.T) { require.ErrorIs(t, m.CopyText("secret"), errSelectionUnavailable) assert.Empty(t, m.GetHistory()) } + +func TestTouchEntry_SurvivesTrimWhenHistoryFull(t *testing.T) { + m := newTestManagerWithDB(t) + m.config.MaxHistory = 3 + + for i := range 3 { + require.NoError(t, m.storeEntry(Entry{ + Data: []byte(fmt.Sprintf("entry-%d", i)), + MimeType: "text/plain", + Timestamp: time.Now(), + })) + } + + oldest := m.GetHistory()[2] + require.NoError(t, m.TouchEntry(oldest.ID)) + + // A new copy trims the bucket back to MaxHistory. + require.NoError(t, m.storeEntry(Entry{ + Data: []byte("newcomer"), + MimeType: "text/plain", + Timestamp: time.Now(), + })) + + history := m.GetHistory() + require.Len(t, history, 3) + ids := make([]uint64, len(history)) + for i, e := range history { + ids[i] = e.ID + } + assert.Contains(t, ids, oldest.ID, "the touched row must survive trimming") + assert.Equal(t, oldest.ID, history[1].ID, "the touched row ranks second, behind the newer copy") +} + +func TestGetHistory_SameSecondOrdersByID(t *testing.T) { + m := newTestManagerWithDB(t) + m.config.MaxHistory = 10 + + stamp := time.Now() + for i := range 3 { + require.NoError(t, m.storeEntry(Entry{ + Data: []byte(fmt.Sprintf("same-second-%d", i)), + MimeType: "text/plain", + Timestamp: stamp, + })) + } + + // Touching the oldest row rewrites it in place, so the bucket now yields + // it first even though every row shares a timestamp. + oldest := m.GetHistory()[2] + require.NoError(t, m.TouchEntry(oldest.ID)) + + history := m.GetHistory() + require.Len(t, history, 3) + assert.Equal(t, oldest.ID, history[0].ID, "the touched row must lead: its timestamp is newer") + assert.Greater(t, history[1].ID, history[2].ID, "rows still tied on timestamp break on newest id") +} diff --git a/quickshell/Services/ClipboardService.qml b/quickshell/Services/ClipboardService.qml index 630a68312..4866f7e89 100644 --- a/quickshell/Services/ClipboardService.qml +++ b/quickshell/Services/ClipboardService.qml @@ -119,9 +119,10 @@ Singleton { (entry.pinned ? pinned : unpinned).push(entry); } - const byIdDesc = (a, b) => b.id - a.id; - pinned.sort(byIdDesc); - unpinned.sort(byIdDesc); + // JS Date keeps milliseconds only, so rows sharing one get id as the tie-break. + const byTimestampDesc = (a, b) => (new Date(b.timestamp) - new Date(a.timestamp)) || (b.id - a.id); + pinned.sort(byTimestampDesc); + unpinned.sort(byTimestampDesc); pinnedEntries = pinned; unpinnedEntries = unpinned;