1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-24 13:32:50 -05:00
Files
DankMaterialShell/core/internal/server/clipboard/manager.go

1303 lines
26 KiB
Go

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, &timestamp)
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
}