From 7e0f110fb8122d72fc355a08c8775f6809e3ed3e Mon Sep 17 00:00:00 2001 From: purian23 Date: Sat, 24 Oct 2026 03:39:08 -0400 Subject: [PATCH] fix(clipboard): bind & release the data device when tracking is toggled Port 2.0 --- core/internal/server/clipboard/handlers.go | 38 ----- core/internal/server/clipboard/manager.go | 141 ++++++++++++------ .../internal/server/clipboard/manager_test.go | 32 +++- core/internal/server/clipboard/types.go | 18 ++- core/internal/server/router.go | 6 +- core/internal/server/server.go | 2 +- 6 files changed, 145 insertions(+), 92 deletions(-) diff --git a/core/internal/server/clipboard/handlers.go b/core/internal/server/clipboard/handlers.go index bc2f53c05..75bbfb769 100644 --- a/core/internal/server/clipboard/handlers.go +++ b/core/internal/server/clipboard/handlers.go @@ -40,10 +40,6 @@ func HandleRequest(conn *ipc.ConnWriter, req ipc.Request, m *Manager) { handleSubscribe(conn, req, m) case "clipboard.search": handleSearch(conn, req, m) - case "clipboard.getConfig": - handleGetConfig(conn, req, m) - case "clipboard.setConfig": - handleSetConfig(conn, req, m) case "clipboard.store": handleStore(conn, req, m) case "clipboard.pinEntry": @@ -320,40 +316,6 @@ func handleSearch(conn *ipc.ConnWriter, req ipc.Request, m *Manager) { conn.Respond(req.ID, m.Search(p)) } -func handleGetConfig(conn *ipc.ConnWriter, req ipc.Request, m *Manager) { - conn.Respond(req.ID, m.GetConfig()) -} - -func handleSetConfig(conn *ipc.ConnWriter, req ipc.Request, m *Manager) { - cfg := m.GetConfig() - - if v, ok := req.Get[float64]("maxHistory"); ok { - cfg.MaxHistory = int(v) - } - if v, ok := req.Get[float64]("maxEntrySize"); ok { - cfg.MaxEntrySize = int64(v) - } - if v, ok := req.Get[float64]("autoClearDays"); ok { - cfg.AutoClearDays = int(v) - } - if v, ok := req.Get[bool]("clearAtStartup"); ok { - cfg.ClearAtStartup = v - } - if v, ok := req.Get[bool]("disabled"); ok { - cfg.Disabled = v - } - if v, ok := req.Get[float64]("maxPinned"); ok { - cfg.MaxPinned = int(v) - } - - if err := m.SetConfig(cfg); err != nil { - conn.RespondError(req.ID, err.Error()) - return - } - - conn.Respond(req.ID, models.SuccessResult{Success: true, Message: "config updated"}) -} - func handleStore(conn *ipc.ConnWriter, req ipc.Request, m *Manager) { data, err := params.String(req.Params, "data") if err != nil { diff --git a/core/internal/server/clipboard/manager.go b/core/internal/server/clipboard/manager.go index 17b61f4a3..f6e0dd5fc 100644 --- a/core/internal/server/clipboard/manager.go +++ b/core/internal/server/clipboard/manager.go @@ -277,17 +277,65 @@ func (m *Manager) setupRegistry() error { } func (m *Manager) setupDataDeviceSync() { - if m.dataControlMgr == nil || m.seat == nil { + if !m.bindDataDevice() { return } - ctx := m.display.Context() + if err := m.display.Context().Dispatch(); err != nil { + log.Errorf("Failed to dispatch initial events: %v", err) + return + } + + log.Info("Data device setup complete") +} + +// Runs on the dispatcher goroutine; a disabled manager must hold no device so it receives no offers. +func (m *Manager) syncTracking() { + if m.getConfig().Disabled { + m.releaseDataDevice() + return + } + if m.dataDevice != nil { + return + } + if m.registry == nil { + if err := m.setupRegistry(); err != nil { + log.Errorf("Failed to enable clipboard tracking: %v", err) + return + } + } + if m.bindDataDevice() { + log.Info("Data device setup complete") + } +} + +func (m *Manager) releaseDataDevice() { + m.deviceBound.Store(false) + + if m.currentOffer != nil { + m.releaseOffer(m.currentOffer) + m.currentOffer = nil + } + + if m.dataDevice == nil { + return + } + m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1).Destroy() + m.dataDevice = nil + m.mimeTypes = nil +} + +func (m *Manager) bindDataDevice() bool { + if m.dataControlMgr == nil || m.seat == nil { + return false + } + dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1) dataDevice, err := dataMgr.GetDataDevice(m.seat) if err != nil { log.Errorf("Failed to send get_data_device request: %v", err) - return + return false } dataDevice.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) { @@ -307,8 +355,12 @@ func (m *Manager) setupDataDeviceSync() { }) dataDevice.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) { + // The first event replays the selection that existed before binding. if !m.initialized { m.initialized = true + if e.Id != nil { + m.releaseOffer(e.Id) + } return } @@ -336,6 +388,11 @@ func (m *Manager) setupDataDeviceSync() { return } + // An event can land between a runtime disable and the posted release. + if m.getConfig().Disabled { + return + } + m.offerMutex.RLock() mimes := m.offerMimeTypes[offer] m.offerMutex.RUnlock() @@ -394,14 +451,10 @@ func (m *Manager) setupDataDeviceSync() { go m.readAndStore(r, preferredMime, altR, altMime) }) + m.initialized = false m.dataDevice = dataDevice - - if err := ctx.Dispatch(); err != nil { - log.Errorf("Failed to dispatch initial events: %v", err) - return - } - - log.Info("Data device setup complete") + m.deviceBound.Store(true) + return true } func (m *Manager) releaseOffer(offer any) { @@ -520,6 +573,9 @@ func (m *Manager) storeClipboardEntry(data []byte, mimeType string, altData []by } func (m *Manager) storeEntry(entry Entry) error { + if m.getConfig().Disabled { + return errTrackingDisabled + } if m.db == nil { return fmt.Errorf("database not available") } @@ -574,7 +630,8 @@ func (m *Manager) deduplicateInTx(b *bolt.Bucket, hash uint64) error { } func (m *Manager) trimLengthInTx(b *bolt.Bucket) error { - if m.config.MaxHistory < 0 { + maxHistory := m.getConfig().MaxHistory + if maxHistory < 0 { return nil } c := b.Cursor() @@ -584,7 +641,7 @@ func (m *Manager) trimLengthInTx(b *bolt.Bucket) error { if err == nil && entry.Pinned { continue } - if count < m.config.MaxHistory { + if count < maxHistory { count++ continue } @@ -1341,19 +1398,22 @@ func (m *Manager) reopenDB() { m.db = db } -var errSelectionUnavailable = errors.New("clipboard selection unavailable: tracking disabled or no data control protocol") +var ( + errSelectionUnavailable = errors.New("clipboard selection unavailable: tracking disabled or no data control protocol") + errTrackingDisabled = errors.New("clipboard tracking disabled") +) -// Both are bound once in NewManager; a disabled manager never binds them but -// still serves copy requests, so callers must fail before touching history. +// A disabled manager still serves copy requests; failing here lets callers +// such as dms screenshot fall back to their own copy. func (m *Manager) canTakeSelection() bool { - return m.dataControlMgr != nil && m.dataDevice != nil + return m.deviceBound.Load() && !m.getConfig().Disabled } func (m *Manager) SetClipboard(data []byte, mimeType string) error { if !m.canTakeSelection() { return errSelectionUnavailable } - if int64(len(data)) > m.config.MaxEntrySize { + if int64(len(data)) > m.getConfig().MaxEntrySize { return fmt.Errorf("data too large") } @@ -1371,7 +1431,7 @@ func (m *Manager) SetClipboardEntry(entry *Entry) error { if !m.canTakeSelection() { return errSelectionUnavailable } - if int64(len(entry.Data)) > m.config.MaxEntrySize { + if int64(len(entry.Data)) > m.getConfig().MaxEntrySize { return fmt.Errorf("data too large") } @@ -1520,16 +1580,7 @@ func (m *Manager) Close() { m.subMutex.Unlock() m.releaseCurrentSource() - - if m.currentOffer != nil { - m.releaseOffer(m.currentOffer) - m.currentOffer = nil - } - - if m.dataDevice != nil { - device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1) - device.Destroy() - } + m.releaseDataDevice() if m.dataControlMgr != nil { mgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1) @@ -1729,19 +1780,12 @@ func (m *Manager) Search(params SearchParams) SearchResult { } } -func (m *Manager) GetConfig() Config { - return m.config -} - func (m *Manager) SetConfig(cfg Config) error { - m.configMutex.Lock() - m.config = cfg - m.configMutex.Unlock() - - m.updateState() - m.notifySubscribers() - - return SaveConfig(cfg) + if err := SaveConfig(cfg); err != nil { + return err + } + m.applyConfigChange(cfg) + return nil } func (m *Manager) getConfig() Config { @@ -1782,7 +1826,12 @@ func (m *Manager) watchConfig() { if event.Op&(fsnotify.Write|fsnotify.Create) == 0 { continue } - newCfg := LoadConfig() + // os.WriteFile truncates first; parsing that empty read would + // briefly reset every setting to its default. + newCfg, err := readConfig() + if err != nil { + continue + } m.applyConfigChange(newCfg) case err, ok := <-watcher.Errors: if !ok { @@ -1799,11 +1848,9 @@ func (m *Manager) applyConfigChange(newCfg Config) { m.config = newCfg m.configMutex.Unlock() - switch { - case newCfg.Disabled && !oldCfg.Disabled: - log.Info("Clipboard tracking disabled") - case !newCfg.Disabled && oldCfg.Disabled: - log.Info("Clipboard tracking enabled") + if newCfg.Disabled != oldCfg.Disabled { + log.Infof("Clipboard tracking disabled=%v", newCfg.Disabled) + m.post(m.syncTracking) } m.updateState() @@ -1814,7 +1861,7 @@ func (m *Manager) StoreData(data []byte, mimeType string) error { cfg := m.getConfig() if cfg.Disabled { - return fmt.Errorf("clipboard tracking disabled") + return errTrackingDisabled } if m.db == nil { diff --git a/core/internal/server/clipboard/manager_test.go b/core/internal/server/clipboard/manager_test.go index 9821c961b..0ff861907 100644 --- a/core/internal/server/clipboard/manager_test.go +++ b/core/internal/server/clipboard/manager_test.go @@ -18,6 +18,7 @@ import ( bolt "go.etcd.io/bbolt" "github.com/duxlabs-dev/dux-go/ipc" + wlclient "github.com/duxlabs-dev/dux-go/wayland/client" "github.com/duxlabs-dev/dux-go/wayland/ext_data_control" mocks_wlcontext "github.com/duxlabs-dev/dux-shell/core/internal/mocks/wlcontext" "github.com/duxlabs-dev/dux-shell/core/internal/server/models" @@ -51,13 +52,15 @@ func newTestManagerWithDB(t *testing.T) *Manager { mockCtx := mocks_wlcontext.NewMockWaylandContext(t) mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Return().Maybe() - return &Manager{ + m := &Manager{ config: DefaultConfig(), db: db, wlCtx: mockCtx, dataControlMgr: &ext_data_control.ExtDataControlManagerV1{}, dataDevice: &ext_data_control.ExtDataControlDeviceV1{}, } + m.deviceBound.Store(true) + return m } func TestEncodeDecodeEntry_Roundtrip(t *testing.T) { @@ -1058,3 +1061,30 @@ func TestCopyFile_DisabledTrackingRefusesCopy(t *testing.T) { require.ErrorIs(t, m.CopyFile(path), errSelectionUnavailable) assert.Empty(t, m.GetHistory()) } + +func TestApplyConfigChange_DisableReleasesDataDevice(t *testing.T) { + sock := filepath.Join(t.TempDir(), "wayland-test") + ln, err := net.Listen("unix", sock) + require.NoError(t, err) + t.Cleanup(func() { ln.Close() }) + + display, err := wlclient.Connect(sock) + require.NoError(t, err) + t.Cleanup(func() { display.Context().Close() }) + + m := newTestManagerWithDB(t) + mockCtx := mocks_wlcontext.NewMockWaylandContext(t) + mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Run(func(fn func()) { fn() }) + m.wlCtx = mockCtx + device := ext_data_control.NewExtDataControlDeviceV1(display.Context()) + m.dataDevice = device + + cfg := DefaultConfig() + cfg.Disabled = true + m.applyConfigChange(cfg) + + assert.True(t, device.IsZombie()) + assert.Nil(t, m.dataDevice) + require.ErrorIs(t, m.CopyText("secret"), errSelectionUnavailable) + assert.Empty(t, m.GetHistory()) +} diff --git a/core/internal/server/clipboard/types.go b/core/internal/server/clipboard/types.go index a3931b53a..f0224e8bb 100644 --- a/core/internal/server/clipboard/types.go +++ b/core/internal/server/clipboard/types.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "sync" + "sync/atomic" "time" "github.com/godbus/dbus/v5" @@ -44,22 +45,30 @@ func getConfigPath() (string, error) { } func LoadConfig() Config { + cfg, err := readConfig() + if err != nil { + return DefaultConfig() + } + return cfg +} + +func readConfig() (Config, error) { cfg := DefaultConfig() path, err := getConfigPath() if err != nil { - return cfg + return cfg, err } data, err := os.ReadFile(path) if err != nil { - return cfg + return cfg, err } if err := json.Unmarshal(data, &cfg); err != nil { - return DefaultConfig() + return DefaultConfig(), err } - return cfg + return cfg, nil } func SaveConfig(cfg Config) error { @@ -140,6 +149,7 @@ type Manager struct { pasteSupported bool initialized bool + deviceBound atomic.Bool alive bool stopChan chan struct{} diff --git a/core/internal/server/router.go b/core/internal/server/router.go index 8ae953c18..0d24adce4 100644 --- a/core/internal/server/router.go +++ b/core/internal/server/router.go @@ -279,7 +279,11 @@ func handleClipboardSetConfig(conn *ipc.ConnWriter, req ipc.Request) { cfg.MaxPinned = int(v) } - if err := clipboard.SaveConfig(cfg); err != nil { + save := clipboard.SaveConfig + if clipboardManager != nil { + save = clipboardManager.SetConfig + } + if err := save(cfg); err != nil { conn.RespondError(req.ID, err.Error()) return } diff --git a/core/internal/server/server.go b/core/internal/server/server.go index 5eaa1c0b2..e4d980c3e 100644 --- a/core/internal/server/server.go +++ b/core/internal/server/server.go @@ -1126,7 +1126,7 @@ func (s *Server) Serve(printDocs bool) error { log.Info(" clipboard.paste - Get current clipboard text") log.Info(" clipboard.search - Search history (params: query?, mimeType?, isImage?, limit?, offset?, before?, after?)") log.Info(" clipboard.getConfig - Get clipboard configuration") - log.Info(" clipboard.setConfig - Set configuration (params: maxHistory?, maxEntrySize?, autoClearDays?, clearAtStartup?)") + log.Info(" clipboard.setConfig - Set configuration (params: maxHistory?, maxEntrySize?, autoClearDays?, clearAtStartup?, disabled?, maxPinned?)") log.Info(" clipboard.subscribe - Subscribe to clipboard state changes (streaming)") log.Info("Notify:") log.Info(" notify.watchAction - Open a file when a notification action fires (params: id, path)")