diff --git a/core/internal/server/clipboard/handlers.go b/core/internal/server/clipboard/handlers.go index 8901126c..4f37f89e 100644 --- a/core/internal/server/clipboard/handlers.go +++ b/core/internal/server/clipboard/handlers.go @@ -208,6 +208,9 @@ 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 3d40851d..ccd2c525 100644 --- a/core/internal/server/clipboard/manager.go +++ b/core/internal/server/clipboard/manager.go @@ -229,10 +229,18 @@ 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() @@ -311,6 +319,10 @@ func (m *Manager) readAndStore(r *os.File, mimeType string) { m.storeClipboardEntry(data, mimeType) } + if !cfg.DisablePersist { + m.persistClipboard([]string{mimeType}, map[string][]byte{mimeType: data}) + } + m.updateState() m.notifySubscribers() } @@ -336,6 +348,105 @@ func (m *Manager) storeClipboardEntry(data []byte, mimeType string) { } } +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 + } + + if m.getConfig().DisablePersist { + return + } + + m.persistMutex.RLock() + mimeTypes := m.persistMimeTypes + m.persistMutex.RUnlock() + + if len(mimeTypes) == 0 { + return + } + + 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) 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) storeEntry(entry Entry) error { if m.db == nil { return fmt.Errorf("database not available") @@ -1198,7 +1309,13 @@ func (m *Manager) applyConfigChange(newCfg Config) { } } - log.Infof("Clipboard config reloaded: disableHistory=%v", newCfg.DisableHistory) + 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) m.updateState() m.notifySubscribers() diff --git a/core/internal/server/clipboard/manager_test.go b/core/internal/server/clipboard/manager_test.go index b6645bb9..56e9e05a 100644 --- a/core/internal/server/clipboard/manager_test.go +++ b/core/internal/server/clipboard/manager_test.go @@ -289,6 +289,81 @@ 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 @@ -383,6 +458,7 @@ 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 ceb5f7a0..232677a5 100644 --- a/core/internal/server/clipboard/types.go +++ b/core/internal/server/clipboard/types.go @@ -21,6 +21,7 @@ type Config struct { Disabled bool `json:"disabled"` DisableHistory bool `json:"disableHistory"` + DisablePersist bool `json:"disablePersist"` } func DefaultConfig() Config { @@ -29,6 +30,7 @@ func DefaultConfig() Config { MaxEntrySize: 5 * 1024 * 1024, AutoClearDays: 0, ClearAtStartup: false, + DisablePersist: true, } } @@ -133,6 +135,13 @@ 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 44828b55..de443389 100644 --- a/core/internal/server/router.go +++ b/core/internal/server/router.go @@ -204,6 +204,9 @@ 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 26d772fc..b73a36c9 100644 --- a/quickshell/Modules/Settings/ClipboardTab.qml +++ b/quickshell/Modules/Settings/ClipboardTab.qml @@ -13,31 +13,88 @@ Item { property bool saving: false readonly property var maxHistoryOptions: [ - { text: "25", value: 25 }, - { text: "50", value: 50 }, - { text: "100", value: 100 }, - { text: "200", value: 200 }, - { text: "500", value: 500 }, - { text: "1000", value: 1000 } + { + text: "25", + value: 25 + }, + { + text: "50", + value: 50 + }, + { + text: "100", + value: 100 + }, + { + text: "200", + value: 200 + }, + { + text: "500", + value: 500 + }, + { + text: "1000", + value: 1000 + } ] readonly property var maxEntrySizeOptions: [ - { text: "1 MB", value: 1048576 }, - { text: "2 MB", value: 2097152 }, - { text: "5 MB", value: 5242880 }, - { text: "10 MB", value: 10485760 }, - { text: "20 MB", value: 20971520 }, - { text: "50 MB", value: 52428800 } + { + text: "1 MB", + value: 1048576 + }, + { + text: "2 MB", + value: 2097152 + }, + { + text: "5 MB", + value: 5242880 + }, + { + text: "10 MB", + value: 10485760 + }, + { + text: "20 MB", + value: 20971520 + }, + { + text: "50 MB", + value: 52428800 + } ] readonly property var autoClearOptions: [ - { text: I18n.tr("Never"), value: 0 }, - { text: I18n.tr("1 day"), value: 1 }, - { text: I18n.tr("3 days"), value: 3 }, - { text: I18n.tr("7 days"), value: 7 }, - { text: I18n.tr("14 days"), value: 14 }, - { text: I18n.tr("30 days"), value: 30 }, - { text: I18n.tr("90 days"), value: 90 } + { + text: I18n.tr("Never"), + value: 0 + }, + { + text: I18n.tr("1 day"), + value: 1 + }, + { + text: I18n.tr("3 days"), + value: 3 + }, + { + text: I18n.tr("7 days"), + value: 7 + }, + { + text: I18n.tr("14 days"), + value: 14 + }, + { + text: I18n.tr("30 days"), + value: 30 + }, + { + text: I18n.tr("90 days"), + value: 90 + } ] function getMaxHistoryText(value) { @@ -139,9 +196,7 @@ Item { StyledText { font.pixelSize: Theme.fontSizeSmall - text: !DMSService.isConnected - ? I18n.tr("DMS service is not connected. Clipboard settings are unavailable.") - : I18n.tr("Failed to load clipboard configuration.") + text: !DMSService.isConnected ? I18n.tr("DMS service is not connected. Clipboard settings are unavailable.") : I18n.tr("Failed to load clipboard configuration.") wrapMode: Text.WordWrap width: parent.width - Theme.iconSizeSmall - Theme.spacingM anchors.verticalCenter: parent.verticalCenter @@ -257,6 +312,16 @@ 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 ?? true + onToggled: checked => root.saveConfig("disablePersist", checked) + } } } }