diff --git a/core/internal/clipboard/store.go b/core/internal/clipboard/store.go index 558fabb6..9e9f7f28 100644 --- a/core/internal/clipboard/store.go +++ b/core/internal/clipboard/store.go @@ -15,6 +15,7 @@ import ( _ "golang.org/x/image/bmp" _ "golang.org/x/image/tiff" + "hash/fnv" bolt "go.etcd.io/bbolt" ) @@ -39,6 +40,7 @@ type Entry struct { Size int Timestamp time.Time IsImage bool + Hash uint64 } func Store(data []byte, mimeType string) error { @@ -70,6 +72,7 @@ func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) error { Size: len(data), Timestamp: time.Now(), IsImage: IsImageMimeType(mimeType), + Hash: computeHash(data), } switch { @@ -85,7 +88,7 @@ func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) error { return err } - if err := deduplicateInTx(b, data); err != nil { + if err := deduplicateInTx(b, entry.Hash); err != nil { return err } @@ -126,17 +129,14 @@ func getDBPath() (string, error) { return filepath.Join(dbDir, "db"), nil } -func deduplicateInTx(b *bolt.Bucket, data []byte) error { +func deduplicateInTx(b *bolt.Bucket, hash uint64) error { c := b.Cursor() for k, v := c.Last(); k != nil; k, v = c.Prev() { - entry, err := decodeEntry(v) - if err != nil { + if extractHash(v) != hash { continue } - if bytes.Equal(entry.Data, data) { - if err := b.Delete(k); err != nil { - return err - } + if err := b.Delete(k); err != nil { + return err } } return nil @@ -174,54 +174,30 @@ func encodeEntry(e Entry) ([]byte, error) { } else { buf.WriteByte(0) } + binary.Write(buf, binary.BigEndian, e.Hash) return buf.Bytes(), nil } -func decodeEntry(data []byte) (Entry, error) { - buf := bytes.NewReader(data) - var e Entry - - binary.Read(buf, binary.BigEndian, &e.ID) - - var dataLen uint32 - binary.Read(buf, binary.BigEndian, &dataLen) - e.Data = make([]byte, dataLen) - buf.Read(e.Data) - - var mimeLen uint32 - binary.Read(buf, binary.BigEndian, &mimeLen) - mimeBytes := make([]byte, mimeLen) - buf.Read(mimeBytes) - e.MimeType = string(mimeBytes) - - var prevLen uint32 - binary.Read(buf, binary.BigEndian, &prevLen) - prevBytes := make([]byte, prevLen) - buf.Read(prevBytes) - e.Preview = string(prevBytes) - - var size int32 - binary.Read(buf, binary.BigEndian, &size) - e.Size = int(size) - - var timestamp int64 - binary.Read(buf, binary.BigEndian, ×tamp) - e.Timestamp = time.Unix(timestamp, 0) - - var isImage byte - binary.Read(buf, binary.BigEndian, &isImage) - e.IsImage = isImage == 1 - - return e, nil -} - func itob(v uint64) []byte { b := make([]byte, 8) binary.BigEndian.PutUint64(b, v) return b } +func computeHash(data []byte) uint64 { + h := fnv.New64a() + h.Write(data) + return h.Sum64() +} + +func extractHash(data []byte) uint64 { + if len(data) < 8 { + return 0 + } + return binary.BigEndian.Uint64(data[len(data)-8:]) +} + func textPreview(data []byte) string { text := string(data) text = strings.TrimSpace(text) diff --git a/core/internal/server/clipboard/handlers.go b/core/internal/server/clipboard/handlers.go index 4f37f89e..8901126c 100644 --- a/core/internal/server/clipboard/handlers.go +++ b/core/internal/server/clipboard/handlers.go @@ -208,9 +208,6 @@ func handleSetConfig(conn net.Conn, req models.Request, m *Manager) { if v, ok := req.Params["disableHistory"].(bool); ok { cfg.DisableHistory = v } - if v, ok := req.Params["disablePersist"].(bool); ok { - cfg.DisablePersist = v - } if err := m.SetConfig(cfg); err != nil { models.RespondError(conn, req.ID, err.Error()) diff --git a/core/internal/server/clipboard/manager.go b/core/internal/server/clipboard/manager.go index 1f69383e..c0db6e87 100644 --- a/core/internal/server/clipboard/manager.go +++ b/core/internal/server/clipboard/manager.go @@ -18,6 +18,7 @@ import ( "github.com/fsnotify/fsnotify" _ "golang.org/x/image/bmp" _ "golang.org/x/image/tiff" + "hash/fnv" bolt "go.etcd.io/bbolt" @@ -69,6 +70,10 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error) } m.db = db + if err := m.migrateHashes(); err != nil { + log.Errorf("Failed to migrate hashes: %v", err) + } + if config.ClearAtStartup { if err := m.clearHistoryInternal(); err != nil { log.Errorf("Failed to clear history at startup: %v", err) @@ -224,18 +229,10 @@ func (m *Manager) setupDataDeviceSync() { m.offerMutex.RUnlock() } - m.ownerLock.Lock() - wasOwner := m.isOwner - m.ownerLock.Unlock() - if offer == nil { return } - if wasOwner { - return - } - m.currentOffer = offer m.offerMutex.RLock() @@ -275,154 +272,62 @@ func (m *Manager) storeCurrentClipboard() { return } - allData := make(map[string][]byte) - var orderedMimes []string - - for _, mime := range m.mimeTypes { - data, err := m.receiveData(offer, mime) - if err != nil { - continue - } - if len(data) == 0 || int64(len(data)) > cfg.MaxEntrySize { - continue - } - allData[mime] = data - orderedMimes = append(orderedMimes, mime) + preferredMime := m.selectMimeType(m.mimeTypes) + if preferredMime == "" { + preferredMime = m.mimeTypes[0] } - if len(allData) == 0 { + data, err := m.receiveData(offer, preferredMime) + if err != nil { return } - - preferredMime := m.selectMimeType(orderedMimes) - if preferredMime == "" { - preferredMime = orderedMimes[0] + if len(data) == 0 || int64(len(data)) > cfg.MaxEntrySize { + return } - - data := allData[preferredMime] if len(bytes.TrimSpace(data)) == 0 { return } if !cfg.DisableHistory && m.db != nil { - entry := Entry{ - Data: data, - MimeType: preferredMime, - Size: len(data), - Timestamp: time.Now(), - IsImage: m.isImageMimeType(preferredMime), - } - - switch { - case entry.IsImage: - entry.Preview = m.imagePreview(data, preferredMime) - default: - entry.Preview = m.textPreview(data) - } - - if err := m.storeEntry(entry); err != nil { - log.Errorf("Failed to store clipboard entry: %v", err) - } - } - - if !cfg.DisablePersist { - m.persistClipboard(orderedMimes, allData) + m.storeClipboardEntry(data, preferredMime) } m.updateState() m.notifySubscribers() } -func (m *Manager) persistClipboard(mimeTypes []string, data map[string][]byte) { - m.persistMutex.Lock() - m.persistMimeTypes = mimeTypes - m.persistData = data - m.persistMutex.Unlock() - - m.post(func() { - m.takePersistOwnership() - }) -} - -func (m *Manager) takePersistOwnership() { - if m.dataControlMgr == nil || m.dataDevice == nil { - return +func (m *Manager) storeClipboardEntry(data []byte, mimeType string) { + entry := Entry{ + Data: data, + MimeType: mimeType, + Size: len(data), + Timestamp: time.Now(), + IsImage: m.isImageMimeType(mimeType), } - if m.getConfig().DisablePersist { - return + switch { + case entry.IsImage: + entry.Preview = m.imagePreview(data, mimeType) + default: + entry.Preview = m.textPreview(data) } - m.persistMutex.RLock() - mimeTypes := m.persistMimeTypes - m.persistMutex.RUnlock() - - if len(mimeTypes) == 0 { - return + if err := m.storeEntry(entry); err != nil { + log.Errorf("Failed to store clipboard entry: %v", err) } - - dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1) - - source, err := dataMgr.CreateDataSource() - if err != nil { - log.Errorf("Failed to create persist source: %v", err) - return - } - - for _, mime := range mimeTypes { - if err := source.Offer(mime); err != nil { - log.Errorf("Failed to offer mime type %s: %v", mime, err) - } - } - - source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) { - fd := e.Fd - defer syscall.Close(fd) - - m.persistMutex.RLock() - d := m.persistData[e.MimeType] - m.persistMutex.RUnlock() - - if len(d) == 0 { - return - } - - file := os.NewFile(uintptr(fd), "clipboard-pipe") - defer file.Close() - file.Write(d) - }) - - source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) { - m.ownerLock.Lock() - m.isOwner = false - m.ownerLock.Unlock() - }) - - if m.currentSource != nil { - oldSource := m.currentSource.(*ext_data_control.ExtDataControlSourceV1) - oldSource.Destroy() - } - m.currentSource = source - - device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1) - if err := device.SetSelection(source); err != nil { - log.Errorf("Failed to set persist selection: %v", err) - return - } - - m.ownerLock.Lock() - m.isOwner = true - m.ownerLock.Unlock() } func (m *Manager) storeEntry(entry Entry) error { if m.db == nil { return fmt.Errorf("database not available") } + + entry.Hash = computeHash(entry.Data) + return m.db.Update(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) - if err := m.deduplicateInTx(b, entry.Data); err != nil { + if err := m.deduplicateInTx(b, entry.Hash); err != nil { return err } @@ -446,17 +351,14 @@ func (m *Manager) storeEntry(entry Entry) error { }) } -func (m *Manager) deduplicateInTx(b *bolt.Bucket, data []byte) error { +func (m *Manager) deduplicateInTx(b *bolt.Bucket, hash uint64) error { c := b.Cursor() for k, v := c.Last(); k != nil; k, v = c.Prev() { - entry, err := decodeEntry(v) - if err != nil { + if extractHash(v) != hash { continue } - if bytes.Equal(entry.Data, data) { - if err := b.Delete(k); err != nil { - return err - } + if err := b.Delete(k); err != nil { + return err } } return nil @@ -494,6 +396,7 @@ func encodeEntry(e Entry) ([]byte, error) { } else { buf.WriteByte(0) } + binary.Write(buf, binary.BigEndian, e.Hash) return buf.Bytes(), nil } @@ -533,6 +436,10 @@ func decodeEntry(data []byte) (Entry, error) { binary.Read(buf, binary.BigEndian, &isImage) e.IsImage = isImage == 1 + if buf.Len() >= 8 { + binary.Read(buf, binary.BigEndian, &e.Hash) + } + return e, nil } @@ -542,6 +449,19 @@ func itob(v uint64) []byte { return b } +func computeHash(data []byte) uint64 { + h := fnv.New64a() + h.Write(data) + return h.Sum64() +} + +func extractHash(data []byte) uint64 { + if len(data) < 8 { + return 0 + } + return binary.BigEndian.Uint64(data[len(data)-8:]) +} + func (m *Manager) selectMimeType(mimes []string) string { preferredTypes := []string{ "text/plain;charset=utf-8", @@ -1052,6 +972,79 @@ func (m *Manager) clearOldEntries(days int) error { }) } +func (m *Manager) migrateHashes() error { + if m.db == nil { + return nil + } + + var needsMigration bool + if err := m.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + if b == nil { + return nil + } + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + if extractHash(v) == 0 { + needsMigration = true + return nil + } + } + return nil + }); err != nil { + return err + } + + if !needsMigration { + return nil + } + + log.Info("Migrating clipboard entries to add hashes...") + + return m.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + if b == nil { + return nil + } + + var updates []struct { + key []byte + entry Entry + } + + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + entry, err := decodeEntry(v) + if err != nil { + continue + } + if entry.Hash != 0 { + continue + } + entry.Hash = computeHash(entry.Data) + keyCopy := make([]byte, len(k)) + copy(keyCopy, k) + updates = append(updates, struct { + key []byte + entry Entry + }{keyCopy, entry}) + } + + for _, u := range updates { + encoded, err := encodeEntry(u.entry) + if err != nil { + continue + } + if err := b.Put(u.key, encoded); err != nil { + return err + } + } + + log.Infof("Migrated %d clipboard entries", len(updates)) + return nil + }) +} + func (m *Manager) Search(params SearchParams) SearchResult { if m.db == nil { return SearchResult{} @@ -1212,35 +1205,12 @@ func (m *Manager) applyConfigChange(newCfg Config) { } } - if newCfg.DisablePersist && !oldCfg.DisablePersist { - log.Info("Clipboard persist disabled, releasing ownership") - m.releaseOwnership() - } - - log.Infof("Clipboard config reloaded: disableHistory=%v disablePersist=%v", - newCfg.DisableHistory, newCfg.DisablePersist) + log.Infof("Clipboard config reloaded: disableHistory=%v", newCfg.DisableHistory) m.updateState() m.notifySubscribers() } -func (m *Manager) releaseOwnership() { - m.ownerLock.Lock() - m.isOwner = false - m.ownerLock.Unlock() - - m.persistMutex.Lock() - m.persistData = nil - m.persistMimeTypes = nil - m.persistMutex.Unlock() - - if m.currentSource != nil { - source := m.currentSource.(*ext_data_control.ExtDataControlSourceV1) - source.Destroy() - m.currentSource = nil - } -} - func (m *Manager) StoreData(data []byte, mimeType string) error { cfg := m.getConfig() diff --git a/core/internal/server/clipboard/manager_test.go b/core/internal/server/clipboard/manager_test.go index 9e2eab8b..8c3aeb61 100644 --- a/core/internal/server/clipboard/manager_test.go +++ b/core/internal/server/clipboard/manager_test.go @@ -289,81 +289,6 @@ func TestManager_ConcurrentOfferAccess(t *testing.T) { wg.Wait() } -func TestManager_ConcurrentPersistAccess(t *testing.T) { - m := &Manager{ - persistData: make(map[string][]byte), - persistMimeTypes: []string{}, - } - - var wg sync.WaitGroup - const goroutines = 20 - const iterations = 50 - - for i := 0; i < goroutines/2; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for j := 0; j < iterations; j++ { - m.persistMutex.RLock() - _ = m.persistData - _ = m.persistMimeTypes - m.persistMutex.RUnlock() - } - }() - } - - for i := 0; i < goroutines/2; i++ { - wg.Add(1) - go func(id int) { - defer wg.Done() - for j := 0; j < iterations; j++ { - m.persistMutex.Lock() - m.persistMimeTypes = []string{"text/plain", "text/html"} - m.persistData = map[string][]byte{ - "text/plain": []byte("test"), - } - m.persistMutex.Unlock() - } - }(i) - } - - wg.Wait() -} - -func TestManager_ConcurrentOwnerAccess(t *testing.T) { - m := &Manager{} - - var wg sync.WaitGroup - const goroutines = 30 - const iterations = 100 - - for i := 0; i < goroutines/2; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for j := 0; j < iterations; j++ { - m.ownerLock.Lock() - _ = m.isOwner - m.ownerLock.Unlock() - } - }() - } - - for i := 0; i < goroutines/2; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for j := 0; j < iterations; j++ { - m.ownerLock.Lock() - m.isOwner = j%2 == 0 - m.ownerLock.Unlock() - } - }() - } - - wg.Wait() -} - func TestItob(t *testing.T) { tests := []struct { input uint64 @@ -456,7 +381,6 @@ func TestDefaultConfig(t *testing.T) { assert.False(t, cfg.ClearAtStartup) assert.False(t, cfg.Disabled) assert.False(t, cfg.DisableHistory) - assert.True(t, cfg.DisablePersist) } func TestManager_PostDelegatesToWlContext(t *testing.T) { diff --git a/core/internal/server/clipboard/types.go b/core/internal/server/clipboard/types.go index 74c34f29..ceb5f7a0 100644 --- a/core/internal/server/clipboard/types.go +++ b/core/internal/server/clipboard/types.go @@ -21,7 +21,6 @@ type Config struct { Disabled bool `json:"disabled"` DisableHistory bool `json:"disableHistory"` - DisablePersist bool `json:"disablePersist"` } func DefaultConfig() Config { @@ -30,7 +29,6 @@ func DefaultConfig() Config { MaxEntrySize: 5 * 1024 * 1024, AutoClearDays: 0, ClearAtStartup: false, - DisablePersist: true, } } @@ -103,6 +101,7 @@ type Entry struct { Size int `json:"size"` Timestamp time.Time `json:"timestamp"` IsImage bool `json:"isImage"` + Hash uint64 `json:"hash,omitempty"` } type State struct { @@ -134,12 +133,6 @@ type Manager struct { sourceMimeTypes []string sourceMutex sync.RWMutex - persistData map[string][]byte - persistMimeTypes []string - persistMutex sync.RWMutex - - isOwner bool - ownerLock sync.Mutex initialized bool alive bool diff --git a/core/internal/server/router.go b/core/internal/server/router.go index de443389..44828b55 100644 --- a/core/internal/server/router.go +++ b/core/internal/server/router.go @@ -204,9 +204,6 @@ func handleClipboardSetConfig(conn net.Conn, req models.Request) { if v, ok := req.Params["disableHistory"].(bool); ok { cfg.DisableHistory = v } - if v, ok := req.Params["disablePersist"].(bool); ok { - cfg.DisablePersist = v - } if err := clipboard.SaveConfig(cfg); err != nil { models.RespondError(conn, req.ID, err.Error()) diff --git a/quickshell/Modules/Settings/ClipboardTab.qml b/quickshell/Modules/Settings/ClipboardTab.qml index b3bde370..26d772fc 100644 --- a/quickshell/Modules/Settings/ClipboardTab.qml +++ b/quickshell/Modules/Settings/ClipboardTab.qml @@ -257,16 +257,6 @@ Item { checked: root.config.disableHistory ?? false onToggled: checked => root.saveConfig("disableHistory", checked) } - - SettingsToggleRow { - tab: "clipboard" - tags: ["clipboard", "disable", "persist", "ownership"] - settingKey: "disablePersist" - text: I18n.tr("Disable Clipboard Ownership") - description: I18n.tr("Don't preserve clipboard when apps close") - checked: root.config.disablePersist ?? false - onToggled: checked => root.saveConfig("disablePersist", checked) - } } } }