diff options
Diffstat (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard')
4 files changed, 0 insertions, 2963 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 deleted file mode 100644 index 1ccc710..0000000 --- a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/handlers.go +++ /dev/null @@ -1,325 +0,0 @@ -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 deleted file mode 100644 index f5f528b..0000000 --- a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/manager.go +++ /dev/null @@ -1,1911 +0,0 @@ -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 deleted file mode 100644 index db45bca..0000000 --- a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/manager_test.go +++ /dev/null @@ -1,531 +0,0 @@ -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 deleted file mode 100644 index c754561..0000000 --- a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/clipboard/types.go +++ /dev/null @@ -1,196 +0,0 @@ -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: - } -} |