summaryrefslogtreecommitdiff
path: root/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard
diff options
context:
space:
mode:
authorAlexanderCurl <alexc@alexc.hu>2026-04-18 17:46:06 +0100
committerAlexanderCurl <alexc@alexc.hu>2026-04-18 17:46:06 +0100
commit70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69 (patch)
treeab1123d4067c1b086dd6faa7ee4ea643236b565a /raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard
parent5d94c0a7d44a2255b81815a52a7056a94a39842d (diff)
downloadRaveOS-PKGBUILD-70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69.tar.gz
RaveOS-PKGBUILD-70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69.zip
Replaced file structures for themes in the correct format
Diffstat (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard')
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/handlers.go325
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/manager.go1911
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/manager_test.go531
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/types.go196
4 files changed, 2963 insertions, 0 deletions
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/handlers.go
new file mode 100644
index 0000000..1ccc710
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/handlers.go
@@ -0,0 +1,325 @@
+package clipboard
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
+)
+
+func HandleRequest(conn net.Conn, req models.Request, m *Manager) {
+ switch req.Method {
+ case "clipboard.getState":
+ handleGetState(conn, req, m)
+ case "clipboard.getHistory":
+ handleGetHistory(conn, req, m)
+ case "clipboard.getEntry":
+ handleGetEntry(conn, req, m)
+ case "clipboard.deleteEntry":
+ handleDeleteEntry(conn, req, m)
+ case "clipboard.clearHistory":
+ handleClearHistory(conn, req, m)
+ case "clipboard.copy":
+ handleCopy(conn, req, m)
+ case "clipboard.copyEntry":
+ handleCopyEntry(conn, req, m)
+ case "clipboard.paste":
+ handlePaste(conn, req, m)
+ case "clipboard.subscribe":
+ 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":
+ handlePinEntry(conn, req, m)
+ case "clipboard.unpinEntry":
+ handleUnpinEntry(conn, req, m)
+ case "clipboard.getPinnedEntries":
+ handleGetPinnedEntries(conn, req, m)
+ case "clipboard.getPinnedCount":
+ handleGetPinnedCount(conn, req, m)
+ case "clipboard.copyFile":
+ handleCopyFile(conn, req, m)
+ default:
+ models.RespondError(conn, req.ID, "unknown method: "+req.Method)
+ }
+}
+
+func handleGetState(conn net.Conn, req models.Request, m *Manager) {
+ models.Respond(conn, req.ID, m.GetState())
+}
+
+func handleGetHistory(conn net.Conn, req models.Request, m *Manager) {
+ history := m.GetHistory()
+ for i := range history {
+ history[i].Data = nil
+ }
+ models.Respond(conn, req.ID, history)
+}
+
+func handleGetEntry(conn net.Conn, req models.Request, m *Manager) {
+ id, err := params.Int(req.Params, "id")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ entry, err := m.GetEntry(uint64(id))
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, entry)
+}
+
+func handleDeleteEntry(conn net.Conn, req models.Request, m *Manager) {
+ id, err := params.Int(req.Params, "id")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := m.DeleteEntry(uint64(id)); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "entry deleted"})
+}
+
+func handleClearHistory(conn net.Conn, req models.Request, m *Manager) {
+ m.ClearHistory()
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "history cleared"})
+}
+
+func handleCopy(conn net.Conn, req models.Request, m *Manager) {
+ text, err := params.String(req.Params, "text")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := m.CopyText(text); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied to clipboard"})
+}
+
+func handleCopyEntry(conn net.Conn, req models.Request, m *Manager) {
+ id, err := params.Int(req.Params, "id")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ entry, err := m.GetEntry(uint64(id))
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ filePath := m.EntryToFile(entry)
+ if filePath != "" {
+ if err := m.CopyFile(filePath); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ models.Respond(conn, req.ID, map[string]any{
+ "success": true,
+ "filePath": filePath,
+ })
+ return
+ }
+
+ if err := m.SetClipboard(entry.Data, entry.MimeType); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if entry.Pinned {
+ if err := m.CreateHistoryEntryFromPinned(entry); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ } else {
+ if err := m.TouchEntry(uint64(id)); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied to clipboard"})
+}
+
+func handlePaste(conn net.Conn, req models.Request, m *Manager) {
+ text, err := m.PasteText()
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, map[string]string{"text": text})
+}
+
+func handleSubscribe(conn net.Conn, req models.Request, m *Manager) {
+ clientID := fmt.Sprintf("clipboard-%d", req.ID)
+
+ ch := m.Subscribe(clientID)
+ defer m.Unsubscribe(clientID)
+
+ initialState := m.GetState()
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ ID: req.ID,
+ Result: &initialState,
+ }); err != nil {
+ return
+ }
+
+ for state := range ch {
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ ID: req.ID,
+ Result: &state,
+ }); err != nil {
+ return
+ }
+ }
+}
+
+func handleSearch(conn net.Conn, req models.Request, m *Manager) {
+ p := SearchParams{
+ Query: params.StringOpt(req.Params, "query", ""),
+ MimeType: params.StringOpt(req.Params, "mimeType", ""),
+ Limit: params.IntOpt(req.Params, "limit", 50),
+ Offset: params.IntOpt(req.Params, "offset", 0),
+ }
+
+ if img, ok := models.Get[bool](req, "isImage"); ok {
+ p.IsImage = &img
+ }
+ if b, ok := models.Get[float64](req, "before"); ok {
+ v := int64(b)
+ p.Before = &v
+ }
+ if a, ok := models.Get[float64](req, "after"); ok {
+ v := int64(a)
+ p.After = &v
+ }
+
+ models.Respond(conn, req.ID, m.Search(p))
+}
+
+func handleGetConfig(conn net.Conn, req models.Request, m *Manager) {
+ models.Respond(conn, req.ID, m.GetConfig())
+}
+
+func handleSetConfig(conn net.Conn, req models.Request, m *Manager) {
+ cfg := m.GetConfig()
+
+ if v, ok := models.Get[float64](req, "maxHistory"); ok {
+ cfg.MaxHistory = int(v)
+ }
+ if v, ok := models.Get[float64](req, "maxEntrySize"); ok {
+ cfg.MaxEntrySize = int64(v)
+ }
+ if v, ok := models.Get[float64](req, "autoClearDays"); ok {
+ cfg.AutoClearDays = int(v)
+ }
+ if v, ok := models.Get[bool](req, "clearAtStartup"); ok {
+ cfg.ClearAtStartup = v
+ }
+ if v, ok := models.Get[bool](req, "disabled"); ok {
+ cfg.Disabled = v
+ }
+ if v, ok := models.Get[float64](req, "maxPinned"); ok {
+ cfg.MaxPinned = int(v)
+ }
+
+ if err := m.SetConfig(cfg); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "config updated"})
+}
+
+func handleStore(conn net.Conn, req models.Request, m *Manager) {
+ data, err := params.String(req.Params, "data")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ mimeType := params.StringOpt(req.Params, "mimeType", "text/plain;charset=utf-8")
+
+ if err := m.StoreData([]byte(data), mimeType); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "stored"})
+}
+
+func handlePinEntry(conn net.Conn, req models.Request, m *Manager) {
+ id, err := params.Int(req.Params, "id")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := m.PinEntry(uint64(id)); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "entry pinned"})
+}
+
+func handleUnpinEntry(conn net.Conn, req models.Request, m *Manager) {
+ id, err := params.Int(req.Params, "id")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := m.UnpinEntry(uint64(id)); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "entry unpinned"})
+}
+
+func handleGetPinnedEntries(conn net.Conn, req models.Request, m *Manager) {
+ pinned := m.GetPinnedEntries()
+ models.Respond(conn, req.ID, pinned)
+}
+
+func handleGetPinnedCount(conn net.Conn, req models.Request, m *Manager) {
+ count := m.GetPinnedCount()
+ models.Respond(conn, req.ID, map[string]int{"count": count})
+}
+
+func handleCopyFile(conn net.Conn, req models.Request, m *Manager) {
+ filePath, err := params.String(req.Params, "filePath")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := m.CopyFile(filePath); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied"})
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/manager.go
new file mode 100644
index 0000000..f5f528b
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/manager.go
@@ -0,0 +1,1911 @@
+package clipboard
+
+import (
+ "bytes"
+ "encoding/binary"
+ "fmt"
+ "image"
+ _ "image/gif"
+ _ "image/jpeg"
+ _ "image/png"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "slices"
+ "strings"
+ "syscall"
+ "time"
+
+ "hash/fnv"
+
+ "github.com/fsnotify/fsnotify"
+ "github.com/godbus/dbus/v5"
+ _ "golang.org/x/image/bmp"
+ _ "golang.org/x/image/tiff"
+ _ "golang.org/x/image/webp"
+
+ bolt "go.etcd.io/bbolt"
+
+ clipboardstore "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
+ "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"
+)
+
+// These mime types won't be stored in history
+var sensitiveMimeTypes = []string{
+ "x-kde-passwordManagerHint",
+}
+
+func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error) {
+ display := wlCtx.Display()
+ dbPath, err := clipboardstore.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 !config.Disabled {
+ if err := m.setupRegistry(); err != nil {
+ return nil, err
+ }
+ }
+
+ m.notifierWg.Add(1)
+ go m.notifier()
+
+ go m.watchConfig()
+
+ db, err := openDB(dbPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open db: %w", err)
+ }
+ m.db = db
+
+ if err := m.migrateHashes(); err != nil {
+ log.Errorf("Failed to migrate hashes: %v", err)
+ }
+
+ if !config.Disabled {
+ 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 !config.Disabled && m.dataControlMgr != nil && m.seat != nil {
+ m.setupDataDeviceSync()
+ }
+
+ return m, nil
+}
+
+func openDB(path string) (*bolt.DB, error) {
+ db, err := bolt.Open(path, 0o644, &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 {
+ return
+ }
+
+ if wasOwner {
+ return
+ }
+
+ prevOffer := m.currentOffer
+ m.currentOffer = offer
+
+ if prevOffer != nil && prevOffer != offer {
+ m.offerMutex.Lock()
+ delete(m.offerMimeTypes, prevOffer)
+ m.offerMutex.Unlock()
+ }
+
+ m.offerMutex.RLock()
+ mimes := m.offerMimeTypes[offer]
+ m.offerMutex.RUnlock()
+
+ m.mimeTypes = mimes
+
+ if len(mimes) == 0 {
+ return
+ }
+
+ if m.hasSensitiveMimeType(mimes) {
+ return
+ }
+
+ preferredMime := m.selectMimeType(mimes)
+ if preferredMime == "" {
+ return
+ }
+
+ typedOffer := offer.(*ext_data_control.ExtDataControlOfferV1)
+
+ r, w, err := os.Pipe()
+ if err != nil {
+ return
+ }
+
+ if err := typedOffer.Receive(preferredMime, int(w.Fd())); err != nil {
+ r.Close()
+ w.Close()
+ return
+ }
+ w.Close()
+
+ go m.readAndStore(r, preferredMime)
+ })
+
+ 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) readAndStore(r *os.File, mimeType string) {
+ defer r.Close()
+
+ cfg := m.getConfig()
+
+ done := make(chan []byte, 1)
+ go func() {
+ data, _ := io.ReadAll(r)
+ done <- data
+ }()
+
+ var data []byte
+ select {
+ case data = <-done:
+ case <-time.After(500 * time.Millisecond):
+ return
+ }
+
+ if len(data) == 0 || int64(len(data)) > cfg.MaxEntrySize {
+ return
+ }
+ if len(bytes.TrimSpace(data)) == 0 {
+ return
+ }
+
+ if !cfg.Disabled && m.db != nil {
+ m.storeClipboardEntry(data, mimeType)
+ }
+
+ m.updateState()
+ m.notifySubscribers()
+}
+
+func (m *Manager) storeClipboardEntry(data []byte, mimeType string) {
+ if mimeType == "text/uri-list" {
+ if imgData, imgMime, ok := m.tryReadImageFromURI(data); ok {
+ data = imgData
+ mimeType = imgMime
+ }
+ }
+
+ 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)
+ case mimeType == "text/uri-list":
+ entry.Preview, entry.IsImage = m.uriListPreview(data)
+ default:
+ entry.Preview = m.textPreview(data)
+ }
+
+ if err := m.storeEntry(entry); err != nil {
+ log.Errorf("Failed to store clipboard entry: %v", err)
+ }
+}
+
+func (m *Manager) storeEntry(entry Entry) error {
+ if m.db == nil {
+ return fmt.Errorf("database not available")
+ }
+
+ entry.Hash = computeHash(entry.Data)
+
+ return m.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("clipboard"))
+
+ if err := m.deduplicateInTx(b, entry.Hash); 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, hash uint64) error {
+ c := b.Cursor()
+ for k, v := c.Last(); k != nil; k, v = c.Prev() {
+ if extractHash(v) != hash {
+ continue
+ }
+ entry, err := decodeEntry(v)
+ if err == nil && entry.Pinned {
+ continue
+ }
+ if err := b.Delete(k); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (m *Manager) trimLengthInTx(b *bolt.Bucket) error {
+ if m.config.MaxHistory < 0 {
+ return nil
+ }
+ c := b.Cursor()
+ var count int
+ for k, v := c.Last(); k != nil; k, v = c.Prev() {
+ entry, err := decodeEntry(v)
+ if err == nil && entry.Pinned {
+ continue
+ }
+ 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)
+ }
+ binary.Write(buf, binary.BigEndian, e.Hash)
+ if e.Pinned {
+ 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
+
+ if buf.Len() >= 8 {
+ binary.Read(buf, binary.BigEndian, &e.Hash)
+ }
+
+ if buf.Len() >= 1 {
+ var pinnedByte byte
+ binary.Read(buf, binary.BigEndian, &pinnedByte)
+ e.Pinned = pinnedByte == 1
+ }
+
+ return e, nil
+}
+
+func itob(v uint64) []byte {
+ b := make([]byte, 8)
+ binary.BigEndian.PutUint64(b, v)
+ return b
+}
+
+func computeHash(data []byte) uint64 {
+ h := fnv.New64a()
+ h.Write(data)
+ return h.Sum64()
+}
+
+func extractHash(data []byte) uint64 {
+ if len(data) < 9 {
+ return 0
+ }
+ return binary.BigEndian.Uint64(data[len(data)-9 : len(data)-1])
+}
+
+func (m *Manager) hasSensitiveMimeType(mimes []string) bool {
+ return slices.ContainsFunc(mimes, func(mime string) bool {
+ return slices.Contains(sensitiveMimeTypes, mime)
+ })
+}
+
+func (m *Manager) selectMimeType(mimes []string) string {
+ preferredTypes := []string{
+ "text/uri-list",
+ "text/plain;charset=utf-8",
+ "text/plain",
+ "UTF8_STRING",
+ "STRING",
+ "TEXT",
+ "image/png",
+ "image/jpeg",
+ "image/gif",
+ "image/bmp",
+ "image/tiff",
+ }
+
+ for _, pref := range preferredTypes {
+ for _, mime := range mimes {
+ if mime == pref {
+ return mime
+ }
+ }
+ }
+
+ // Skip useless MIME types when falling back
+ for _, mime := range mimes {
+ switch mime {
+ case "application/vnd.portal.filetransfer":
+ continue
+ default:
+ return mime
+ }
+ }
+
+ return ""
+}
+
+func (m *Manager) isImageMimeType(mime string) bool {
+ return strings.HasPrefix(mime, "image/")
+}
+
+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 (m *Manager) uriListPreview(data []byte) (string, bool) {
+ text := strings.TrimSpace(string(data))
+ uris := strings.Split(text, "\r\n")
+ if len(uris) == 0 {
+ uris = strings.Split(text, "\n")
+ }
+
+ if len(uris) > 1 {
+ return fmt.Sprintf("[[ %d files ]]", len(uris)), false
+ }
+
+ if len(uris) == 1 && strings.HasPrefix(uris[0], "file://") {
+ filePath := strings.TrimPrefix(uris[0], "file://")
+ info, err := os.Stat(filePath)
+ if err != nil || info.IsDir() {
+ return m.textPreview(data), false
+ }
+
+ cfg := m.getConfig()
+ if info.Size() <= cfg.MaxEntrySize {
+ if imgData, err := os.ReadFile(filePath); err == nil {
+ if config, imgFmt, err := image.DecodeConfig(bytes.NewReader(imgData)); err == nil {
+ return fmt.Sprintf("[[ file %s %s %dx%d ]]", filepath.Base(filePath), imgFmt, config.Width, config.Height), true
+ }
+ }
+ }
+ return fmt.Sprintf("[[ file %s ]]", filepath.Base(filePath)), false
+ }
+
+ return m.textPreview(data), false
+}
+
+func (m *Manager) tryReadImageFromURI(data []byte) ([]byte, string, bool) {
+ text := strings.TrimSpace(string(data))
+ uris := strings.Split(text, "\r\n")
+ if len(uris) == 0 {
+ uris = strings.Split(text, "\n")
+ }
+
+ if len(uris) != 1 || !strings.HasPrefix(uris[0], "file://") {
+ return nil, "", false
+ }
+
+ filePath := strings.TrimPrefix(uris[0], "file://")
+ info, err := os.Stat(filePath)
+ if err != nil || info.IsDir() {
+ return nil, "", false
+ }
+
+ cfg := m.getConfig()
+ if info.Size() > cfg.MaxEntrySize {
+ return nil, "", false
+ }
+
+ imgData, err := os.ReadFile(filePath)
+ if err != nil {
+ return nil, "", false
+ }
+
+ _, imgFmt, err := image.DecodeConfig(bytes.NewReader(imgData))
+ if err != nil {
+ return nil, "", false
+ }
+
+ return imgData, "image/" + imgFmt, true
+}
+
+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) TouchEntry(id uint64) error {
+ if m.db == nil {
+ return fmt.Errorf("database not available")
+ }
+
+ entry, err := m.GetEntry(id)
+ if err != nil {
+ return err
+ }
+
+ entry.Timestamp = time.Now()
+
+ if err := m.storeEntry(*entry); err != nil {
+ return err
+ }
+
+ m.updateState()
+ m.notifySubscribers()
+
+ return nil
+}
+
+func (m *Manager) CreateHistoryEntryFromPinned(pinnedEntry *Entry) error {
+ if m.db == nil {
+ return fmt.Errorf("database not available")
+ }
+
+ // Create a new unpinned entry with the same data
+ newEntry := Entry{
+ Data: pinnedEntry.Data,
+ MimeType: pinnedEntry.MimeType,
+ Size: pinnedEntry.Size,
+ Timestamp: time.Now(),
+ IsImage: pinnedEntry.IsImage,
+ Preview: pinnedEntry.Preview,
+ Pinned: false,
+ }
+
+ if err := m.storeEntryWithoutDedup(newEntry); err != nil {
+ return err
+ }
+
+ m.updateState()
+ m.notifySubscribers()
+
+ return nil
+}
+
+func (m *Manager) storeEntryWithoutDedup(entry Entry) error {
+ if m.db == nil {
+ return fmt.Errorf("database not available")
+ }
+
+ entry.Hash = computeHash(entry.Data)
+
+ return m.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("clipboard"))
+
+ 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) ClearHistory() {
+ if m.db == nil {
+ return
+ }
+
+ // Delete only non-pinned entries
+ if err := 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 || !entry.Pinned {
+ toDelete = append(toDelete, k)
+ }
+ }
+
+ for _, k := range toDelete {
+ if err := b.Delete(k); err != nil {
+ return err
+ }
+ }
+ return nil
+ }); err != nil {
+ log.Errorf("Failed to clear clipboard history: %v", err)
+ return
+ }
+
+ pinnedCount := 0
+ if err := m.db.View(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("clipboard"))
+ if b != nil {
+ c := b.Cursor()
+ for k, v := c.First(); k != nil; k, v = c.Next() {
+ entry, _ := decodeEntry(v)
+ if entry.Pinned {
+ pinnedCount++
+ }
+ }
+ }
+ return nil
+ }); err != nil {
+ log.Errorf("Failed to count pinned entries: %v", err)
+ }
+
+ if pinnedCount == 0 {
+ 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, 0o644, &bolt.Options{ReadOnly: true, Timeout: time.Second})
+ if err != nil {
+ m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second})
+ return fmt.Errorf("open source: %w", err)
+ }
+
+ dstDB, err := bolt.Open(tmpPath, 0o644, &bolt.Options{Timeout: time.Second})
+ if err != nil {
+ srcDB.Close()
+ m.db, _ = bolt.Open(m.dbPath, 0o644, &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, 0o644, &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, 0o644, &bolt.Options{Timeout: time.Second})
+ return fmt.Errorf("rename: %w", err)
+ }
+
+ m.db, err = bolt.Open(m.dbPath, 0o644, &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)
+ }
+ })
+
+ source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
+ m.ownerLock.Lock()
+ m.isOwner = false
+ m.ownerLock.Unlock()
+ })
+
+ m.currentSource = source
+ m.sourceMutex.Lock()
+ m.sourceMimeTypes = []string{mimeType}
+ m.sourceMutex.Unlock()
+
+ m.ownerLock.Lock()
+ m.isOwner = true
+ m.ownerLock.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
+ }
+ // Skip pinned entries
+ if entry.Pinned {
+ 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) migrateHashes() error {
+ if m.db == nil {
+ return nil
+ }
+
+ var needsMigration bool
+ 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.First(); k != nil; k, v = c.Next() {
+ if extractHash(v) == 0 {
+ needsMigration = true
+ return nil
+ }
+ }
+ return nil
+ }); err != nil {
+ return err
+ }
+
+ if !needsMigration {
+ return nil
+ }
+
+ log.Info("Migrating clipboard entries to add hashes...")
+
+ return m.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("clipboard"))
+ if b == nil {
+ return nil
+ }
+
+ var updates []struct {
+ key []byte
+ entry Entry
+ }
+
+ c := b.Cursor()
+ for k, v := c.First(); k != nil; k, v = c.Next() {
+ entry, err := decodeEntry(v)
+ if err != nil {
+ continue
+ }
+ if entry.Hash != 0 {
+ continue
+ }
+ entry.Hash = computeHash(entry.Data)
+ keyCopy := make([]byte, len(k))
+ copy(keyCopy, k)
+ updates = append(updates, struct {
+ key []byte
+ entry Entry
+ }{keyCopy, entry})
+ }
+
+ for _, u := range updates {
+ encoded, err := encodeEntry(u.entry)
+ if err != nil {
+ continue
+ }
+ if err := b.Put(u.key, encoded); err != nil {
+ return err
+ }
+ }
+
+ log.Infof("Migrated %d clipboard entries", len(updates))
+ 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()
+
+ switch {
+ case newCfg.Disabled && !oldCfg.Disabled:
+ log.Info("Clipboard tracking disabled")
+ case !newCfg.Disabled && oldCfg.Disabled:
+ log.Info("Clipboard tracking enabled")
+ }
+
+ m.updateState()
+ m.notifySubscribers()
+}
+
+func (m *Manager) StoreData(data []byte, mimeType string) error {
+ cfg := m.getConfig()
+
+ if cfg.Disabled {
+ return fmt.Errorf("clipboard tracking 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)
+ case mimeType == "text/uri-list":
+ entry.Preview, entry.IsImage = m.uriListPreview(data)
+ default:
+ entry.Preview = m.textPreview(data)
+ }
+
+ if err := m.storeEntry(entry); err != nil {
+ return err
+ }
+
+ m.updateState()
+ m.notifySubscribers()
+
+ return nil
+}
+
+func (m *Manager) PinEntry(id uint64) error {
+ if m.db == nil {
+ return fmt.Errorf("database not available")
+ }
+
+ entryToPin, err := m.GetEntry(id)
+ if err != nil {
+ return err
+ }
+
+ var hashExists bool
+ 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.First(); k != nil; k, v = c.Next() {
+ entry, err := decodeEntry(v)
+ if err != nil || !entry.Pinned {
+ continue
+ }
+ if entry.Hash == entryToPin.Hash {
+ hashExists = true
+ return nil
+ }
+ }
+ return nil
+ }); err != nil {
+ return err
+ }
+
+ if hashExists {
+ return nil
+ }
+
+ // Check pinned count
+ cfg := m.getConfig()
+ pinnedCount := 0
+ 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.First(); k != nil; k, v = c.Next() {
+ entry, err := decodeEntry(v)
+ if err == nil && entry.Pinned {
+ pinnedCount++
+ }
+ }
+ return nil
+ }); err != nil {
+ log.Errorf("Failed to count pinned entries: %v", err)
+ }
+
+ if pinnedCount >= cfg.MaxPinned {
+ return fmt.Errorf("maximum pinned entries reached (%d)", cfg.MaxPinned)
+ }
+
+ err = m.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte("clipboard"))
+ v := b.Get(itob(id))
+ if v == nil {
+ return fmt.Errorf("entry not found")
+ }
+
+ entry, err := decodeEntry(v)
+ if err != nil {
+ return err
+ }
+
+ entry.Pinned = true
+ encoded, err := encodeEntry(entry)
+ if err != nil {
+ return err
+ }
+
+ return b.Put(itob(id), encoded)
+ })
+
+ if err == nil {
+ m.updateState()
+ m.notifySubscribers()
+ }
+
+ return err
+}
+
+func (m *Manager) UnpinEntry(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"))
+ v := b.Get(itob(id))
+ if v == nil {
+ return fmt.Errorf("entry not found")
+ }
+
+ entry, err := decodeEntry(v)
+ if err != nil {
+ return err
+ }
+
+ entry.Pinned = false
+ encoded, err := encodeEntry(entry)
+ if err != nil {
+ return err
+ }
+
+ return b.Put(itob(id), encoded)
+ })
+
+ if err == nil {
+ m.updateState()
+ m.notifySubscribers()
+ }
+
+ return err
+}
+
+func (m *Manager) GetPinnedEntries() []Entry {
+ if m.db == nil {
+ return nil
+ }
+
+ var pinned []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 entry.Pinned {
+ entry.Data = nil
+ pinned = append(pinned, entry)
+ }
+ }
+ return nil
+ }); err != nil {
+ log.Errorf("Failed to get pinned entries: %v", err)
+ }
+
+ return pinned
+}
+
+func (m *Manager) GetPinnedCount() int {
+ if m.db == nil {
+ return 0
+ }
+
+ count := 0
+ 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.First(); k != nil; k, v = c.Next() {
+ entry, err := decodeEntry(v)
+ if err == nil && entry.Pinned {
+ count++
+ }
+ }
+ return nil
+ }); err != nil {
+ log.Errorf("Failed to count pinned entries: %v", err)
+ }
+
+ return count
+}
+
+func (m *Manager) CopyFile(filePath string) error {
+ fileInfo, err := os.Stat(filePath)
+ if err != nil {
+ return fmt.Errorf("file not found: %w", err)
+ }
+
+ cfg := m.getConfig()
+ if fileInfo.Size() > cfg.MaxEntrySize {
+ return fmt.Errorf("file too large: %d > %d", fileInfo.Size(), cfg.MaxEntrySize)
+ }
+
+ fileData, err := os.ReadFile(filePath)
+ if err != nil {
+ return fmt.Errorf("read file: %w", err)
+ }
+
+ exportedPath, err := m.ExportFileForFlatpak(filePath)
+ if err != nil {
+ exportedPath = filePath
+ }
+ fileURI := "file://" + exportedPath
+
+ if imgData, imgMime, ok := m.tryReadImageFromURI([]byte("file://" + filePath)); ok {
+ entry := Entry{
+ Data: imgData,
+ MimeType: imgMime,
+ Size: len(imgData),
+ Timestamp: time.Now(),
+ IsImage: true,
+ Preview: m.imagePreview(imgData, imgMime),
+ }
+ if err := m.storeEntry(entry); err != nil {
+ log.Errorf("Failed to store file entry: %v", err)
+ }
+ } else {
+ entry := Entry{
+ Data: fileData,
+ MimeType: "text/uri-list",
+ Size: len(fileData),
+ Timestamp: time.Now(),
+ IsImage: false,
+ Preview: fmt.Sprintf("[[ file %s ]]", filepath.Base(filePath)),
+ }
+ if err := m.storeEntry(entry); err != nil {
+ log.Errorf("Failed to store file entry: %v", err)
+ }
+ }
+
+ m.updateState()
+ m.notifySubscribers()
+
+ _, imgMime, imgErr := image.DecodeConfig(bytes.NewReader(fileData))
+
+ 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
+ }
+
+ type offer struct {
+ mime string
+ data []byte
+ }
+ offers := []offer{
+ {"x-special/gnome-copied-files", []byte("copy\n" + fileURI)},
+ {"text/uri-list", []byte(fileURI + "\r\n")},
+ {"text/plain", []byte(filePath)},
+ }
+
+ if imgErr == nil {
+ imgMimeType := "image/" + imgMime
+ offers = append(offers, offer{imgMimeType, fileData})
+ }
+
+ offerData := make(map[string][]byte)
+ for _, o := range offers {
+ if err := source.Offer(o.mime); err != nil {
+ log.Errorf("Failed to offer %s: %v", o.mime, err)
+ return
+ }
+ offerData[o.mime] = o.data
+ }
+
+ 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 data, ok := offerData[e.MimeType]; ok {
+ file.Write(data)
+ }
+ })
+
+ source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
+ m.ownerLock.Lock()
+ m.isOwner = false
+ m.ownerLock.Unlock()
+ })
+
+ m.currentSource = source
+
+ m.ownerLock.Lock()
+ m.isOwner = true
+ m.ownerLock.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) EntryToFile(entry *Entry) string {
+ switch {
+ case entry.MimeType == "text/uri-list":
+ data := strings.TrimSpace(string(entry.Data))
+ lines := strings.Split(data, "\n")
+ if len(lines) == 0 {
+ return ""
+ }
+ uri := strings.TrimSuffix(strings.TrimSpace(lines[0]), "\r")
+ if path, ok := strings.CutPrefix(uri, "file://"); ok {
+ return path
+ }
+ case entry.IsImage:
+ ext := ".png"
+ if suffix, ok := strings.CutPrefix(entry.MimeType, "image/"); ok {
+ ext = "." + suffix
+ }
+ cacheDir, err := os.UserCacheDir()
+ if err != nil {
+ return ""
+ }
+ clipDir := filepath.Join(cacheDir, "dms", "clipboard")
+ if err := os.MkdirAll(clipDir, 0o755); err != nil {
+ return ""
+ }
+ filePath := filepath.Join(clipDir, fmt.Sprintf("%d%s", time.Now().UnixNano(), ext))
+ if os.WriteFile(filePath, entry.Data, 0o644) != nil {
+ return ""
+ }
+ return filePath
+ }
+ return ""
+}
+
+func (m *Manager) ExportFileForFlatpak(filePath string) (string, error) {
+ if _, err := os.Stat(filePath); err != nil {
+ return "", fmt.Errorf("file not found: %w", err)
+ }
+
+ if m.dbusConn == nil {
+ conn, err := dbus.ConnectSessionBus()
+ if err != nil {
+ return "", fmt.Errorf("connect session bus: %w", err)
+ }
+ if !conn.SupportsUnixFDs() {
+ conn.Close()
+ return "", fmt.Errorf("D-Bus connection does not support Unix FD passing")
+ }
+ m.dbusConn = conn
+ }
+
+ file, err := os.Open(filePath)
+ if err != nil {
+ return "", fmt.Errorf("open file: %w", err)
+ }
+ fd := int(file.Fd())
+
+ portal := m.dbusConn.Object("org.freedesktop.portal.Documents", "/org/freedesktop/portal/documents")
+
+ var docIds []string
+ var extra map[string]dbus.Variant
+ flags := uint32(0)
+
+ err = portal.Call(
+ "org.freedesktop.portal.Documents.AddFull",
+ 0,
+ []dbus.UnixFD{dbus.UnixFD(fd)},
+ flags,
+ "",
+ []string{},
+ ).Store(&docIds, &extra)
+
+ file.Close()
+
+ if err != nil {
+ return "", fmt.Errorf("AddFull: %w", err)
+ }
+
+ if len(docIds) == 0 {
+ return "", fmt.Errorf("no doc IDs returned")
+ }
+
+ docId := docIds[0]
+
+ for _, app := range getInstalledFlatpaks() {
+ _ = portal.Call(
+ "org.freedesktop.portal.Documents.GrantPermissions",
+ 0,
+ docId,
+ app,
+ []string{"read"},
+ ).Err
+ }
+
+ uid := os.Getuid()
+ basename := filepath.Base(filePath)
+ exportedPath := fmt.Sprintf("/run/user/%d/doc/%s/%s", uid, docId, basename)
+
+ return exportedPath, nil
+}
+
+func getInstalledFlatpaks() []string {
+ out, err := exec.Command("flatpak", "list", "--app", "--columns=application").Output()
+ if err != nil {
+ return nil
+ }
+
+ var apps []string
+ for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
+ if app := strings.TrimSpace(line); app != "" {
+ apps = append(apps, app)
+ }
+ }
+ return apps
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/manager_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/manager_test.go
new file mode 100644
index 0000000..db45bca
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/manager_test.go
@@ -0,0 +1,531 @@
+package clipboard
+
+import (
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+
+ mocks_wlcontext "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlcontext"
+)
+
+func TestEncodeDecodeEntry_Roundtrip(t *testing.T) {
+ original := Entry{
+ ID: 12345,
+ Data: []byte("hello world"),
+ MimeType: "text/plain;charset=utf-8",
+ Preview: "hello world",
+ Size: 11,
+ Timestamp: time.Now().Truncate(time.Second),
+ IsImage: false,
+ }
+
+ encoded, err := encodeEntry(original)
+ assert.NoError(t, err)
+
+ decoded, err := decodeEntry(encoded)
+ assert.NoError(t, err)
+
+ assert.Equal(t, original.ID, decoded.ID)
+ assert.Equal(t, original.Data, decoded.Data)
+ assert.Equal(t, original.MimeType, decoded.MimeType)
+ assert.Equal(t, original.Preview, decoded.Preview)
+ assert.Equal(t, original.Size, decoded.Size)
+ assert.Equal(t, original.Timestamp.Unix(), decoded.Timestamp.Unix())
+ assert.Equal(t, original.IsImage, decoded.IsImage)
+}
+
+func TestEncodeDecodeEntry_Image(t *testing.T) {
+ original := Entry{
+ ID: 99999,
+ Data: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A},
+ MimeType: "image/png",
+ Preview: "[[ image 8 B png 100x100 ]]",
+ Size: 8,
+ Timestamp: time.Now().Truncate(time.Second),
+ IsImage: true,
+ }
+
+ encoded, err := encodeEntry(original)
+ assert.NoError(t, err)
+
+ decoded, err := decodeEntry(encoded)
+ assert.NoError(t, err)
+
+ assert.Equal(t, original.ID, decoded.ID)
+ assert.Equal(t, original.Data, decoded.Data)
+ assert.True(t, decoded.IsImage)
+ assert.Equal(t, original.Preview, decoded.Preview)
+}
+
+func TestEncodeDecodeEntry_EmptyData(t *testing.T) {
+ original := Entry{
+ ID: 1,
+ Data: []byte{},
+ MimeType: "text/plain",
+ Preview: "",
+ Size: 0,
+ Timestamp: time.Now().Truncate(time.Second),
+ IsImage: false,
+ }
+
+ encoded, err := encodeEntry(original)
+ assert.NoError(t, err)
+
+ decoded, err := decodeEntry(encoded)
+ assert.NoError(t, err)
+
+ assert.Equal(t, original.ID, decoded.ID)
+ assert.Empty(t, decoded.Data)
+}
+
+func TestEncodeDecodeEntry_LargeData(t *testing.T) {
+ largeData := make([]byte, 100000)
+ for i := range largeData {
+ largeData[i] = byte(i % 256)
+ }
+
+ original := Entry{
+ ID: 777,
+ Data: largeData,
+ MimeType: "application/octet-stream",
+ Preview: "binary data...",
+ Size: len(largeData),
+ Timestamp: time.Now().Truncate(time.Second),
+ IsImage: false,
+ }
+
+ encoded, err := encodeEntry(original)
+ assert.NoError(t, err)
+
+ decoded, err := decodeEntry(encoded)
+ assert.NoError(t, err)
+
+ assert.Equal(t, original.Data, decoded.Data)
+ assert.Equal(t, original.Size, decoded.Size)
+}
+
+func TestStateEqual_BothNil(t *testing.T) {
+ assert.False(t, stateEqual(nil, nil))
+}
+
+func TestStateEqual_OneNil(t *testing.T) {
+ s := &State{Enabled: true}
+ assert.False(t, stateEqual(s, nil))
+ assert.False(t, stateEqual(nil, s))
+}
+
+func TestStateEqual_EnabledDiffers(t *testing.T) {
+ a := &State{Enabled: true, History: []Entry{}}
+ b := &State{Enabled: false, History: []Entry{}}
+ assert.False(t, stateEqual(a, b))
+}
+
+func TestStateEqual_HistoryLengthDiffers(t *testing.T) {
+ a := &State{Enabled: true, History: []Entry{{ID: 1}}}
+ b := &State{Enabled: true, History: []Entry{}}
+ assert.False(t, stateEqual(a, b))
+}
+
+func TestStateEqual_BothEqual(t *testing.T) {
+ a := &State{Enabled: true, History: []Entry{{ID: 1}, {ID: 2}}}
+ b := &State{Enabled: true, History: []Entry{{ID: 3}, {ID: 4}}}
+ assert.True(t, stateEqual(a, b))
+}
+
+func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
+ m := &Manager{
+ subscribers: make(map[string]chan State),
+ dirty: make(chan struct{}, 1),
+ }
+
+ var wg sync.WaitGroup
+ const goroutines = 20
+
+ for i := 0; i < goroutines; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ subID := string(rune('a' + id))
+ ch := m.Subscribe(subID)
+ assert.NotNil(t, ch)
+ time.Sleep(time.Millisecond)
+ m.Unsubscribe(subID)
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_ConcurrentGetState(t *testing.T) {
+ m := &Manager{
+ state: &State{
+ Enabled: true,
+ History: []Entry{{ID: 1}, {ID: 2}},
+ },
+ }
+
+ var wg sync.WaitGroup
+ const goroutines = 50
+ const iterations = 100
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ s := m.GetState()
+ _ = s.Enabled
+ _ = len(s.History)
+ }
+ }()
+ }
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.stateMutex.Lock()
+ m.state = &State{
+ Enabled: j%2 == 0,
+ History: []Entry{{ID: uint64(j)}},
+ }
+ m.stateMutex.Unlock()
+ }
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_ConcurrentConfigAccess(t *testing.T) {
+ m := &Manager{
+ config: DefaultConfig(),
+ }
+
+ 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++ {
+ cfg := m.getConfig()
+ _ = cfg.MaxHistory
+ _ = cfg.MaxEntrySize
+ }
+ }()
+ }
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.configMutex.Lock()
+ m.config.MaxHistory = 50 + j
+ m.config.MaxEntrySize = int64(1024 * j)
+ m.configMutex.Unlock()
+ }
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_NotifySubscribersNonBlocking(t *testing.T) {
+ m := &Manager{
+ dirty: make(chan struct{}, 1),
+ }
+
+ for i := 0; i < 10; i++ {
+ m.notifySubscribers()
+ }
+
+ assert.Len(t, m.dirty, 1)
+}
+
+func TestManager_ConcurrentOfferAccess(t *testing.T) {
+ m := &Manager{
+ offerMimeTypes: make(map[any][]string),
+ offerRegistry: make(map[uint32]any),
+ }
+
+ var wg sync.WaitGroup
+ const goroutines = 20
+ const iterations = 50
+
+ for i := 0; i < goroutines; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ key := uint32(id)
+
+ for j := 0; j < iterations; j++ {
+ m.offerMutex.Lock()
+ m.offerRegistry[key] = struct{}{}
+ m.offerMimeTypes[key] = []string{"text/plain"}
+ m.offerMutex.Unlock()
+
+ m.offerMutex.RLock()
+ _ = m.offerRegistry[key]
+ _ = m.offerMimeTypes[key]
+ m.offerMutex.RUnlock()
+
+ m.offerMutex.Lock()
+ delete(m.offerRegistry, key)
+ delete(m.offerMimeTypes, key)
+ m.offerMutex.Unlock()
+ }
+ }(i)
+ }
+
+ 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
+ expected []byte
+ }{
+ {0, []byte{0, 0, 0, 0, 0, 0, 0, 0}},
+ {1, []byte{0, 0, 0, 0, 0, 0, 0, 1}},
+ {256, []byte{0, 0, 0, 0, 0, 0, 1, 0}},
+ {0xFFFFFFFFFFFFFFFF, []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}},
+ }
+
+ for _, tt := range tests {
+ result := itob(tt.input)
+ assert.Equal(t, tt.expected, result)
+ }
+}
+
+func TestSizeStr(t *testing.T) {
+ tests := []struct {
+ input int
+ expected string
+ }{
+ {0, "0 B"},
+ {100, "100 B"},
+ {1024, "1 KiB"},
+ {2048, "2 KiB"},
+ {1048576, "1 MiB"},
+ {5242880, "5 MiB"},
+ }
+
+ for _, tt := range tests {
+ result := sizeStr(tt.input)
+ assert.Equal(t, tt.expected, result)
+ }
+}
+
+func TestSelectMimeType(t *testing.T) {
+ m := &Manager{}
+
+ tests := []struct {
+ mimes []string
+ expected string
+ }{
+ {[]string{"text/plain;charset=utf-8", "text/html"}, "text/plain;charset=utf-8"},
+ {[]string{"text/html", "text/plain"}, "text/plain"},
+ {[]string{"text/html", "image/png"}, "image/png"},
+ {[]string{"image/png", "image/jpeg"}, "image/png"},
+ {[]string{"image/png"}, "image/png"},
+ {[]string{"application/octet-stream"}, "application/octet-stream"},
+ {[]string{}, ""},
+ }
+
+ for _, tt := range tests {
+ result := m.selectMimeType(tt.mimes)
+ assert.Equal(t, tt.expected, result)
+ }
+}
+
+func TestIsImageMimeType(t *testing.T) {
+ m := &Manager{}
+
+ assert.True(t, m.isImageMimeType("image/png"))
+ assert.True(t, m.isImageMimeType("image/jpeg"))
+ assert.True(t, m.isImageMimeType("image/gif"))
+ assert.False(t, m.isImageMimeType("text/plain"))
+ assert.False(t, m.isImageMimeType("application/json"))
+}
+
+func TestTextPreview(t *testing.T) {
+ m := &Manager{}
+
+ short := m.textPreview([]byte("hello world"))
+ assert.Equal(t, "hello world", short)
+
+ withWhitespace := m.textPreview([]byte(" hello world "))
+ assert.Equal(t, "hello world", withWhitespace)
+
+ longText := make([]byte, 200)
+ for i := range longText {
+ longText[i] = 'a'
+ }
+ preview := m.textPreview(longText)
+ assert.True(t, len(preview) > 100)
+ assert.Contains(t, preview, "…")
+}
+
+func TestDefaultConfig(t *testing.T) {
+ cfg := DefaultConfig()
+ assert.Equal(t, 100, cfg.MaxHistory)
+ assert.Equal(t, int64(5*1024*1024), cfg.MaxEntrySize)
+ assert.Equal(t, 0, cfg.AutoClearDays)
+ assert.False(t, cfg.ClearAtStartup)
+ assert.False(t, cfg.Disabled)
+}
+
+func TestManager_PostDelegatesToWlContext(t *testing.T) {
+ mockCtx := mocks_wlcontext.NewMockWaylandContext(t)
+
+ var called atomic.Bool
+ mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Run(func(fn func()) {
+ called.Store(true)
+ fn()
+ }).Once()
+
+ m := &Manager{
+ wlCtx: mockCtx,
+ }
+
+ executed := false
+ m.post(func() {
+ executed = true
+ })
+
+ assert.True(t, called.Load())
+ assert.True(t, executed)
+}
+
+func TestManager_PostExecutesFunctionViaContext(t *testing.T) {
+ mockCtx := mocks_wlcontext.NewMockWaylandContext(t)
+
+ var capturedFn func()
+ mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Run(func(fn func()) {
+ capturedFn = fn
+ }).Times(3)
+
+ m := &Manager{
+ wlCtx: mockCtx,
+ }
+
+ counter := 0
+ m.post(func() { counter++ })
+ m.post(func() { counter += 10 })
+ m.post(func() { counter += 100 })
+
+ assert.NotNil(t, capturedFn)
+ capturedFn()
+ assert.Equal(t, 100, counter)
+}
+
+func TestManager_ConcurrentPostWithMock(t *testing.T) {
+ mockCtx := mocks_wlcontext.NewMockWaylandContext(t)
+
+ var postCount atomic.Int32
+ mockCtx.EXPECT().Post(mock.AnythingOfType("func()")).Run(func(fn func()) {
+ postCount.Add(1)
+ }).Times(100)
+
+ m := &Manager{
+ wlCtx: mockCtx,
+ }
+
+ var wg sync.WaitGroup
+ for i := 0; i < 10; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < 10; j++ {
+ m.post(func() {})
+ }
+ }()
+ }
+
+ wg.Wait()
+ assert.Equal(t, int32(100), postCount.Load())
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/types.go
new file mode 100644
index 0000000..c754561
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/types.go
@@ -0,0 +1,196 @@
+package clipboard
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "github.com/godbus/dbus/v5"
+ bolt "go.etcd.io/bbolt"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
+ wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
+)
+
+type Config struct {
+ MaxHistory int `json:"maxHistory"`
+ MaxEntrySize int64 `json:"maxEntrySize"`
+ AutoClearDays int `json:"autoClearDays"`
+ ClearAtStartup bool `json:"clearAtStartup"`
+ Disabled bool `json:"disabled"`
+ MaxPinned int `json:"maxPinned"`
+}
+
+func DefaultConfig() Config {
+ return Config{
+ MaxHistory: 100,
+ MaxEntrySize: 5 * 1024 * 1024,
+ AutoClearDays: 0,
+ ClearAtStartup: false,
+ MaxPinned: 25,
+ }
+}
+
+func getConfigPath() (string, error) {
+ configDir, err := os.UserConfigDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(configDir, "DankMaterialShell", "clsettings.json"), nil
+}
+
+func LoadConfig() Config {
+ cfg := DefaultConfig()
+
+ path, err := getConfigPath()
+ if err != nil {
+ return cfg
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return cfg
+ }
+
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ return DefaultConfig()
+ }
+ return cfg
+}
+
+func SaveConfig(cfg Config) error {
+ path, err := getConfigPath()
+ if err != nil {
+ return err
+ }
+
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+
+ data, err := json.MarshalIndent(cfg, "", " ")
+ if err != nil {
+ return err
+ }
+
+ return os.WriteFile(path, data, 0o644)
+}
+
+type SearchParams struct {
+ Query string `json:"query"`
+ MimeType string `json:"mimeType"`
+ IsImage *bool `json:"isImage"`
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+ Before *int64 `json:"before"`
+ After *int64 `json:"after"`
+}
+
+type SearchResult struct {
+ Entries []Entry `json:"entries"`
+ Total int `json:"total"`
+ HasMore bool `json:"hasMore"`
+}
+
+type Entry struct {
+ ID uint64 `json:"id"`
+ Data []byte `json:"data,omitempty"`
+ MimeType string `json:"mimeType"`
+ Preview string `json:"preview"`
+ Size int `json:"size"`
+ Timestamp time.Time `json:"timestamp"`
+ IsImage bool `json:"isImage"`
+ Hash uint64 `json:"hash,omitempty"`
+ Pinned bool `json:"pinned"`
+}
+
+type State struct {
+ Enabled bool `json:"enabled"`
+ History []Entry `json:"history"`
+ Current *Entry `json:"current,omitempty"`
+}
+
+type Manager struct {
+ config Config
+ configMutex sync.RWMutex
+ configPath string
+
+ display wlclient.WaylandDisplay
+ wlCtx wlcontext.WaylandContext
+
+ registry *wlclient.Registry
+ dataControlMgr any
+ seat *wlclient.Seat
+ dataDevice any
+ currentOffer any
+ currentSource any
+ seatName uint32
+ mimeTypes []string
+ offerMimeTypes map[any][]string
+ offerMutex sync.RWMutex
+ offerRegistry map[uint32]any
+
+ sourceMimeTypes []string
+ sourceMutex sync.RWMutex
+
+ persistData map[string][]byte
+ persistMimeTypes []string
+ persistMutex sync.RWMutex
+
+ isOwner bool
+ ownerLock sync.Mutex
+
+ initialized bool
+
+ alive bool
+ stopChan chan struct{}
+
+ db *bolt.DB
+ dbPath string
+
+ state *State
+ stateMutex sync.RWMutex
+
+ subscribers map[string]chan State
+ subMutex sync.RWMutex
+ dirty chan struct{}
+ notifierWg sync.WaitGroup
+ lastState *State
+
+ dbusConn *dbus.Conn
+}
+
+func (m *Manager) GetState() State {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ if m.state == nil {
+ return State{}
+ }
+ return *m.state
+}
+
+func (m *Manager) Subscribe(id string) chan State {
+ ch := make(chan State, 64)
+ m.subMutex.Lock()
+ m.subscribers[id] = ch
+ m.subMutex.Unlock()
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ m.subMutex.Lock()
+ if ch, ok := m.subscribers[id]; ok {
+ close(ch)
+ delete(m.subscribers, id)
+ }
+ m.subMutex.Unlock()
+}
+
+func (m *Manager) notifySubscribers() {
+ select {
+ case m.dirty <- struct{}{}:
+ default:
+ }
+}