diff options
| author | AlexanderCurl <alexc@alexc.hu> | 2026-04-18 17:46:06 +0100 |
|---|---|---|
| committer | AlexanderCurl <alexc@alexc.hu> | 2026-04-18 17:46:06 +0100 |
| commit | 70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69 (patch) | |
| tree | ab1123d4067c1b086dd6faa7ee4ea643236b565a /raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard | |
| parent | 5d94c0a7d44a2255b81815a52a7056a94a39842d (diff) | |
| download | RaveOS-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')
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, ×tamp) + 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: + } +} |