package clipboard import ( "bytes" "encoding/binary" "fmt" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "io" "os" "path/filepath" "strings" "syscall" "time" "github.com/fsnotify/fsnotify" _ "golang.org/x/image/bmp" _ "golang.org/x/image/tiff" bolt "go.etcd.io/bbolt" "github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" ) func NewManager(wlCtx *wlcontext.SharedContext, config Config) (*Manager, error) { if config.Disabled { return nil, fmt.Errorf("clipboard disabled in config") } display := wlCtx.Display() dbPath, err := getDBPath() if err != nil { return nil, fmt.Errorf("failed to get db path: %w", err) } configPath, _ := getConfigPath() m := &Manager{ config: config, configPath: configPath, display: display, wlCtx: wlCtx, stopChan: make(chan struct{}), subscribers: make(map[string]chan State), dirty: make(chan struct{}, 1), offerMimeTypes: make(map[any][]string), offerRegistry: make(map[uint32]any), dbPath: dbPath, } if err := m.setupRegistry(); err != nil { return nil, err } m.notifierWg.Add(1) go m.notifier() go m.watchConfig() if !config.DisableHistory { db, err := openDB(dbPath) if err != nil { return nil, fmt.Errorf("failed to open db: %w", err) } m.db = db if config.ClearAtStartup { if err := m.clearHistoryInternal(); err != nil { log.Errorf("Failed to clear history at startup: %v", err) } } if config.AutoClearDays > 0 { if err := m.clearOldEntries(config.AutoClearDays); err != nil { log.Errorf("Failed to clear old entries: %v", err) } } } m.alive = true m.updateState() if m.dataControlMgr != nil && m.seat != nil { m.setupDataDeviceSync() } return m, nil } func getDBPath() (string, error) { cacheDir := os.Getenv("XDG_CACHE_HOME") if cacheDir == "" { homeDir, err := os.UserHomeDir() if err != nil { return "", err } cacheDir = filepath.Join(homeDir, ".cache") } dbDir := filepath.Join(cacheDir, "dms-clipboard") if err := os.MkdirAll(dbDir, 0700); err != nil { return "", err } return filepath.Join(dbDir, "db"), nil } func openDB(path string) (*bolt.DB, error) { db, err := bolt.Open(path, 0644, &bolt.Options{ Timeout: 1 * time.Second, }) if err != nil { return nil, err } err = db.Update(func(tx *bolt.Tx) error { _, err := tx.CreateBucketIfNotExists([]byte("clipboard")) return err }) if err != nil { db.Close() return nil, err } return db, nil } func (m *Manager) post(fn func()) { m.wlCtx.Post(fn) } func (m *Manager) setupRegistry() error { ctx := m.display.Context() registry, err := m.display.GetRegistry() if err != nil { return fmt.Errorf("failed to get registry: %w", err) } m.registry = registry registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { switch e.Interface { case "ext_data_control_manager_v1": if e.Version < 1 { return } dataControlMgr := ext_data_control.NewExtDataControlManagerV1(ctx) if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil { log.Errorf("Failed to bind ext_data_control_manager_v1: %v", err) return } m.dataControlMgr = dataControlMgr log.Info("Bound ext_data_control_manager_v1") case "wl_seat": 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) return } m.seat = seat m.seatName = e.Name log.Info("Bound wl_seat") } }) m.display.Roundtrip() m.display.Roundtrip() if m.dataControlMgr == nil { return fmt.Errorf("compositor does not support ext_data_control_manager_v1") } if m.seat == nil { return fmt.Errorf("no seat available") } return nil } func (m *Manager) setupDataDeviceSync() { if m.dataControlMgr == nil || m.seat == nil { return } ctx := m.display.Context() dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1) dataDevice := ext_data_control.NewExtDataControlDeviceV1(ctx) dataDevice.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) { if e.Id == nil { return } m.offerMutex.Lock() m.offerRegistry[e.Id.ID()] = e.Id m.offerMimeTypes[e.Id] = make([]string, 0) m.offerMutex.Unlock() e.Id.SetOfferHandler(func(me ext_data_control.ExtDataControlOfferV1OfferEvent) { m.offerMutex.Lock() m.offerMimeTypes[e.Id] = append(m.offerMimeTypes[e.Id], me.MimeType) m.offerMutex.Unlock() }) }) dataDevice.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) { if !m.initialized { m.initialized = true return } var offer any if e.Id != nil { offer = e.Id } else if e.OfferId != 0 { m.offerMutex.RLock() offer = m.offerRegistry[e.OfferId] m.offerMutex.RUnlock() } m.ownerLock.Lock() wasOwner := m.isOwner m.ownerLock.Unlock() if offer == nil { if wasOwner { return } m.persistMutex.RLock() hasData := len(m.persistData) > 0 m.persistMutex.RUnlock() if hasData { log.Debug("Selection cleared, re-taking ownership") m.post(func() { m.takePersistOwnership() }) } return } if wasOwner { return } m.currentOffer = offer m.offerMutex.RLock() mimes := m.offerMimeTypes[offer] m.offerMutex.RUnlock() m.mimeTypes = mimes go m.storeCurrentClipboard() }) if err := dataMgr.GetDataDeviceWithProxy(dataDevice, m.seat); err != nil { log.Errorf("Failed to send get_data_device request: %v", err) return } 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") } func (m *Manager) storeCurrentClipboard() { if m.currentOffer == nil { return } cfg := m.getConfig() offer := m.currentOffer.(*ext_data_control.ExtDataControlOfferV1) if len(m.mimeTypes) == 0 { 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) } if len(allData) == 0 { return } preferredMime := m.selectMimeType(orderedMimes) if preferredMime == "" { preferredMime = orderedMimes[0] } 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.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 } 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) storeEntry(entry Entry) error { if m.db == nil { return fmt.Errorf("database not available") } return m.db.Update(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) if err := m.deduplicateInTx(b, entry.Data); err != nil { return err } id, err := b.NextSequence() if err != nil { return err } entry.ID = id encoded, err := encodeEntry(entry) if err != nil { return err } if err := b.Put(itob(id), encoded); err != nil { return err } return m.trimLengthInTx(b) }) } func (m *Manager) deduplicateInTx(b *bolt.Bucket, data []byte) error { c := b.Cursor() for k, v := c.Last(); k != nil; k, v = c.Prev() { entry, err := decodeEntry(v) if err != nil { continue } if bytes.Equal(entry.Data, data) { if err := b.Delete(k); err != nil { return err } } } return nil } func (m *Manager) trimLengthInTx(b *bolt.Bucket) error { c := b.Cursor() var count int for k, _ := c.Last(); k != nil; k, _ = c.Prev() { if count < m.config.MaxHistory { count++ continue } if err := b.Delete(k); err != nil { return err } } return nil } func encodeEntry(e Entry) ([]byte, error) { buf := new(bytes.Buffer) binary.Write(buf, binary.BigEndian, e.ID) binary.Write(buf, binary.BigEndian, uint32(len(e.Data))) buf.Write(e.Data) binary.Write(buf, binary.BigEndian, uint32(len(e.MimeType))) buf.WriteString(e.MimeType) 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()) if e.IsImage { buf.WriteByte(1) } else { buf.WriteByte(0) } 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 (m *Manager) selectMimeType(mimes []string) string { preferredTypes := []string{ "text/plain;charset=utf-8", "text/plain", "UTF8_STRING", "STRING", "TEXT", "image/png", "image/jpeg", "image/bmp", "image/gif", } for _, pref := range preferredTypes { for _, mime := range mimes { if mime == pref { return mime } } } return "" } func (m *Manager) isImageMimeType(mime string) bool { return strings.HasPrefix(mime, "image/") } func (m *Manager) receiveData(offer *ext_data_control.ExtDataControlOfferV1, mimeType string) ([]byte, error) { r, w, err := os.Pipe() if err != nil { return nil, err } defer r.Close() if err := offer.Receive(mimeType, int(w.Fd())); err != nil { w.Close() return nil, err } w.Close() type result struct { data []byte err error } done := make(chan result, 1) go func() { data, err := io.ReadAll(r) done <- result{data, err} }() select { case res := <-done: return res.data, res.err case <-time.After(100 * time.Millisecond): return nil, fmt.Errorf("timeout reading clipboard data") } } func (m *Manager) textPreview(data []byte) string { text := string(data) text = strings.TrimSpace(text) text = strings.Join(strings.Fields(text), " ") if len(text) > 100 { return text[:100] + "…" } return text } func (m *Manager) imagePreview(data []byte, format string) string { config, imgFmt, err := image.DecodeConfig(bytes.NewReader(data)) if err != nil { return fmt.Sprintf("[[ image %s %s ]]", sizeStr(len(data)), format) } return fmt.Sprintf("[[ image %s %s %dx%d ]]", sizeStr(len(data)), imgFmt, config.Width, config.Height) } func sizeStr(size int) string { units := []string{"B", "KiB", "MiB"} var i int fsize := float64(size) for fsize >= 1024 && i < len(units)-1 { fsize /= 1024 i++ } return fmt.Sprintf("%.0f %s", fsize, units[i]) } func (m *Manager) updateState() { history := m.GetHistory() for i := range history { history[i].Data = nil } var current *Entry if len(history) > 0 { c := history[0] c.Data = nil current = &c } newState := &State{ Enabled: m.alive, History: history, Current: current, } m.stateMutex.Lock() m.state = newState m.stateMutex.Unlock() } func (m *Manager) notifier() { defer m.notifierWg.Done() for range m.dirty { state := m.GetState() if m.lastState != nil && stateEqual(m.lastState, &state) { continue } m.lastState = &state m.subMutex.RLock() subs := make([]chan State, 0, len(m.subscribers)) for _, ch := range m.subscribers { subs = append(subs, ch) } m.subMutex.RUnlock() for _, ch := range subs { select { case ch <- state: default: } } } } func stateEqual(a, b *State) bool { if a == nil || b == nil { return false } if a.Enabled != b.Enabled { return false } if len(a.History) != len(b.History) { return false } return true } func (m *Manager) GetHistory() []Entry { if m.db == nil { return nil } cfg := m.getConfig() var cutoff time.Time if cfg.AutoClearDays > 0 { cutoff = time.Now().AddDate(0, 0, -cfg.AutoClearDays) } var history []Entry var stale []uint64 if err := m.db.View(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) c := b.Cursor() for k, v := c.Last(); k != nil; k, v = c.Prev() { entry, err := decodeEntry(v) if err != nil { continue } if !cutoff.IsZero() && entry.Timestamp.Before(cutoff) { stale = append(stale, entry.ID) continue } history = append(history, entry) } return nil }); err != nil { log.Errorf("Failed to read clipboard history: %v", err) } if len(stale) > 0 { go m.deleteStaleEntries(stale) } return history } func (m *Manager) deleteStaleEntries(ids []uint64) { if m.db == nil { return } if err := m.db.Update(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) for _, id := range ids { if err := b.Delete(itob(id)); err != nil { log.Errorf("Failed to delete stale entry %d: %v", id, err) } } return nil }); err != nil { log.Errorf("Failed to delete stale entries: %v", err) } } func (m *Manager) GetEntry(id uint64) (*Entry, error) { if m.db == nil { return nil, fmt.Errorf("database not available") } var entry Entry var found bool err := m.db.View(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) v := b.Get(itob(id)) if v == nil { return nil } var err error entry, err = decodeEntry(v) if err != nil { return err } found = true return nil }) if err != nil { return nil, err } if !found { return nil, fmt.Errorf("entry not found") } return &entry, nil } func (m *Manager) DeleteEntry(id uint64) error { if m.db == nil { return fmt.Errorf("database not available") } err := m.db.Update(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) return b.Delete(itob(id)) }) if err == nil { m.updateState() m.notifySubscribers() } return err } func (m *Manager) ClearHistory() { if m.db == nil { return } if err := m.db.Update(func(tx *bolt.Tx) error { if err := tx.DeleteBucket([]byte("clipboard")); err != nil { return err } _, err := tx.CreateBucket([]byte("clipboard")) return err }); err != nil { log.Errorf("Failed to clear clipboard history: %v", err) return } if err := m.compactDB(); err != nil { log.Errorf("Failed to compact database: %v", err) } m.updateState() m.notifySubscribers() } func (m *Manager) compactDB() error { m.db.Close() tmpPath := m.dbPath + ".compact" defer os.Remove(tmpPath) srcDB, err := bolt.Open(m.dbPath, 0644, &bolt.Options{ReadOnly: true, Timeout: time.Second}) if err != nil { m.db, _ = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second}) return fmt.Errorf("open source: %w", err) } dstDB, err := bolt.Open(tmpPath, 0644, &bolt.Options{Timeout: time.Second}) if err != nil { srcDB.Close() m.db, _ = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second}) return fmt.Errorf("open destination: %w", err) } if err := bolt.Compact(dstDB, srcDB, 0); err != nil { srcDB.Close() dstDB.Close() m.db, _ = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second}) return fmt.Errorf("compact: %w", err) } srcDB.Close() dstDB.Close() if err := os.Rename(tmpPath, m.dbPath); err != nil { m.db, _ = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second}) return fmt.Errorf("rename: %w", err) } m.db, err = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second}) if err != nil { return fmt.Errorf("reopen: %w", err) } return nil } func (m *Manager) SetClipboard(data []byte, mimeType string) error { if int64(len(data)) > m.config.MaxEntrySize { return fmt.Errorf("data too large") } dataCopy := make([]byte, len(data)) copy(dataCopy, data) m.post(func() { if m.dataControlMgr == nil || m.dataDevice == nil { log.Error("Data control manager or device not initialized") return } dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1) source, err := dataMgr.CreateDataSource() if err != nil { log.Errorf("Failed to create data source: %v", err) return } if err := source.Offer(mimeType); err != nil { log.Errorf("Failed to offer mime type: %v", err) return } source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) { fd := e.Fd defer syscall.Close(fd) file := os.NewFile(uintptr(fd), "clipboard-pipe") defer file.Close() if _, err := file.Write(dataCopy); err != nil { log.Errorf("Failed to write clipboard data: %v", err) } }) m.currentSource = source m.sourceMutex.Lock() m.sourceMimeTypes = []string{mimeType} m.sourceMutex.Unlock() device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1) if err := device.SetSelection(source); err != nil { log.Errorf("Failed to set selection: %v", err) } }) return nil } func (m *Manager) CopyText(text string) error { if err := m.SetClipboard([]byte(text), "text/plain;charset=utf-8"); err != nil { return err } entry := Entry{ Data: []byte(text), MimeType: "text/plain;charset=utf-8", Size: len(text), Timestamp: time.Now(), IsImage: false, Preview: m.textPreview([]byte(text)), } if err := m.storeEntry(entry); err != nil { log.Errorf("Failed to store clipboard entry: %v", err) } m.updateState() m.notifySubscribers() return nil } func (m *Manager) PasteText() (string, error) { history := m.GetHistory() if len(history) == 0 { return "", fmt.Errorf("no clipboard data available") } entry := history[0] if entry.IsImage { return "", fmt.Errorf("clipboard contains image, not text") } fullEntry, err := m.GetEntry(entry.ID) if err != nil { return "", err } return string(fullEntry.Data), nil } func (m *Manager) Close() { if !m.alive { return } m.alive = false close(m.stopChan) close(m.dirty) m.notifierWg.Wait() m.subMutex.Lock() for _, ch := range m.subscribers { close(ch) } m.subscribers = make(map[string]chan State) m.subMutex.Unlock() if m.currentSource != nil { source := m.currentSource.(*ext_data_control.ExtDataControlSourceV1) source.Destroy() } if m.dataDevice != nil { device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1) device.Destroy() } if m.dataControlMgr != nil { mgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1) mgr.Destroy() } if m.registry != nil { m.registry.Destroy() } if m.db != nil { m.db.Close() } } func (m *Manager) clearHistoryInternal() error { return m.db.Update(func(tx *bolt.Tx) error { if err := tx.DeleteBucket([]byte("clipboard")); err != nil { return err } _, err := tx.CreateBucket([]byte("clipboard")) return err }) } func (m *Manager) clearOldEntries(days int) error { cutoff := time.Now().AddDate(0, 0, -days) return m.db.Update(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("clipboard")) if b == nil { return nil } var toDelete [][]byte c := b.Cursor() for k, v := c.First(); k != nil; k, v = c.Next() { entry, err := decodeEntry(v) if err != nil { continue } if entry.Timestamp.Before(cutoff) { toDelete = append(toDelete, k) } } for _, k := range toDelete { if err := b.Delete(k); err != nil { return err } } return nil }) } func (m *Manager) Search(params SearchParams) SearchResult { if m.db == nil { return SearchResult{} } if params.Limit <= 0 { params.Limit = 50 } if params.Limit > 500 { params.Limit = 500 } query := strings.ToLower(params.Query) mimeFilter := strings.ToLower(params.MimeType) var all []Entry 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.Last(); k != nil; k, v = c.Prev() { entry, err := decodeEntry(v) if err != nil { continue } if params.IsImage != nil && entry.IsImage != *params.IsImage { continue } if mimeFilter != "" && !strings.Contains(strings.ToLower(entry.MimeType), mimeFilter) { continue } if params.Before != nil && entry.Timestamp.Unix() >= *params.Before { continue } if params.After != nil && entry.Timestamp.Unix() <= *params.After { continue } if query != "" && !strings.Contains(strings.ToLower(entry.Preview), query) { continue } entry.Data = nil all = append(all, entry) } return nil }); err != nil { log.Errorf("Search failed: %v", err) } total := len(all) start := params.Offset if start > total { start = total } end := start + params.Limit if end > total { end = total } return SearchResult{ Entries: all[start:end], Total: total, HasMore: end < total, } } 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) } func (m *Manager) getConfig() Config { m.configMutex.RLock() defer m.configMutex.RUnlock() return m.config } func (m *Manager) watchConfig() { if m.configPath == "" { return } watcher, err := fsnotify.NewWatcher() if err != nil { log.Warnf("Failed to create config watcher: %v", err) return } defer watcher.Close() configDir := filepath.Dir(m.configPath) if err := watcher.Add(configDir); err != nil { log.Warnf("Failed to watch config directory: %v", err) return } for { select { case <-m.stopChan: return case event, ok := <-watcher.Events: if !ok { return } if event.Name != m.configPath { continue } if event.Op&(fsnotify.Write|fsnotify.Create) == 0 { continue } newCfg := LoadConfig() m.applyConfigChange(newCfg) case err, ok := <-watcher.Errors: if !ok { return } log.Warnf("Config watcher error: %v", err) } } } func (m *Manager) applyConfigChange(newCfg Config) { m.configMutex.Lock() oldCfg := m.config m.config = newCfg m.configMutex.Unlock() if newCfg.DisableHistory && !oldCfg.DisableHistory && m.db != nil { log.Info("Clipboard history disabled, closing database") m.db.Close() m.db = nil } if !newCfg.DisableHistory && oldCfg.DisableHistory && m.db == nil { log.Info("Clipboard history enabled, opening database") if db, err := openDB(m.dbPath); err == nil { m.db = db } else { log.Errorf("Failed to reopen database: %v", err) } } 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() } 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() if cfg.DisableHistory { return fmt.Errorf("clipboard history disabled") } if m.db == nil { return fmt.Errorf("database not available") } if len(data) == 0 { return nil } if int64(len(data)) > cfg.MaxEntrySize { return fmt.Errorf("data too large") } if len(bytes.TrimSpace(data)) == 0 { return nil } entry := Entry{ Data: data, MimeType: mimeType, Size: len(data), Timestamp: time.Now(), IsImage: m.isImageMimeType(mimeType), } switch { case entry.IsImage: entry.Preview = m.imagePreview(data, mimeType) default: entry.Preview = m.textPreview(data) } if err := m.storeEntry(entry); err != nil { return err } m.updateState() m.notifySubscribers() return nil }