fix(clipboard): bind & release the data device when tracking is toggled

Port 2.0
This commit is contained in:
purian23 committed 2026-10-24 03:39:08 -04:00
1 parent 52d6a46d89
commit 7e0f110fb8
6 files changed
+145 -92

No files matched your search

@@ -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 {
+94 -47
View File
@@ -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 {
+31 -1
View File
@@ -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())
}
+14 -4
View File
@@ -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{}
+5 -1
View File
@@ -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
}
+1 -1
View File
@@ -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)")