diff options
Diffstat (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/clipboard')
3 files changed, 1181 insertions, 0 deletions
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/clipboard/clipboard.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/clipboard/clipboard.go new file mode 100644 index 0000000..d27d6ff --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/clipboard/clipboard.go @@ -0,0 +1,630 @@ +package clipboard + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "syscall" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control" + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +const envServe = "_DMS_CLIPBOARD_SERVE" +const envMime = "_DMS_CLIPBOARD_MIME" +const envPasteOnce = "_DMS_CLIPBOARD_PASTE_ONCE" +const envCacheFile = "_DMS_CLIPBOARD_CACHE" + +// MaybeServeAndExit intercepts before cobra when re-exec'd as a clipboard +// child. Reads source data into memory, deletes any cache file, then serves. +func MaybeServeAndExit() { + if os.Getenv(envServe) == "" { + return + } + + mimeType := os.Getenv(envMime) + pasteOnce := os.Getenv(envPasteOnce) == "1" + cachePath := os.Getenv(envCacheFile) + + var data []byte + var err error + + switch { + case cachePath != "": + data, err = os.ReadFile(cachePath) + os.Remove(cachePath) + default: + data, err = io.ReadAll(os.Stdin) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "clipboard: read source: %v\n", err) + os.Exit(1) + } + + if err := serveClipboard(data, mimeType, pasteOnce); err != nil { + fmt.Fprintf(os.Stderr, "clipboard: serve: %v\n", err) + os.Exit(1) + } + os.Exit(0) +} + +func Copy(data []byte, mimeType string) error { + return copyForkCached(data, mimeType, false) +} + +func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error { + if foreground { + return serveClipboard(data, mimeType, pasteOnce) + } + return copyForkCached(data, mimeType, pasteOnce) +} + +func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error { + if foreground { + buf, err := io.ReadAll(data) + if err != nil { + return fmt.Errorf("read source: %w", err) + } + return serveClipboard(buf, mimeType, pasteOnce) + } + return copyFork(data, mimeType, pasteOnce) +} + +func newForkCmd(mimeType string, pasteOnce bool, extra ...string) *exec.Cmd { + cmd := exec.Command(os.Args[0]) + cmd.Stderr = nil + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + cmd.Env = append(os.Environ(), + envServe+"=1", + envMime+"="+mimeType, + ) + if pasteOnce { + cmd.Env = append(cmd.Env, envPasteOnce+"=1") + } + cmd.Env = append(cmd.Env, extra...) + return cmd +} + +func waitReady(cmd *exec.Cmd) error { + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("stdout pipe: %w", err) + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("start: %w", err) + } + var buf [1]byte + if _, err := stdout.Read(buf[:]); err != nil { + return fmt.Errorf("waiting for clipboard ready: %w", err) + } + return nil +} + +func copyForkCached(data []byte, mimeType string, pasteOnce bool) error { + cacheFile, err := createClipboardCacheFile() + if err != nil { + return fmt.Errorf("create cache file: %w", err) + } + cachePath := cacheFile.Name() + + if _, err := cacheFile.Write(data); err != nil { + cacheFile.Close() + os.Remove(cachePath) + return fmt.Errorf("write cache file: %w", err) + } + if err := cacheFile.Close(); err != nil { + os.Remove(cachePath) + return fmt.Errorf("close cache file: %w", err) + } + + cmd := newForkCmd(mimeType, pasteOnce, envCacheFile+"="+cachePath) + cmd.Stdin = nil + if err := waitReady(cmd); err != nil { + os.Remove(cachePath) + return err + } + return nil +} + +func copyFork(data io.Reader, mimeType string, pasteOnce bool) error { + cmd := newForkCmd(mimeType, pasteOnce) + + switch src := data.(type) { + case *os.File: + cmd.Stdin = src + return waitReady(cmd) + + default: + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("stdin pipe: %w", err) + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("stdout pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start: %w", err) + } + if _, err := io.Copy(stdin, data); err != nil { + stdin.Close() + return fmt.Errorf("write stdin: %w", err) + } + if err := stdin.Close(); err != nil { + return fmt.Errorf("close stdin: %w", err) + } + + var buf [1]byte + if _, err := stdout.Read(buf[:]); err != nil { + return fmt.Errorf("waiting for clipboard ready: %w", err) + } + return nil + } +} + +func signalReady() { + if os.Getenv(envServe) == "" { + return + } + os.Stdout.Write([]byte{1}) +} + +func createClipboardCacheFile() (*os.File, error) { + preferredDirs := []string{} + + if cacheDir, err := os.UserCacheDir(); err == nil { + preferredDirs = append(preferredDirs, filepath.Join(cacheDir, "dms", "clipboard")) + } + preferredDirs = append(preferredDirs, "/var/tmp/dms/clipboard") + + for _, dir := range preferredDirs { + if err := os.MkdirAll(dir, 0o700); err != nil { + continue + } + cachedData, err := os.CreateTemp(dir, "dms-clipboard-*") + if err == nil { + return cachedData, nil + } + } + return os.CreateTemp("", "dms-clipboard-*") +} + +func serveClipboard(data []byte, mimeType string, pasteOnce bool) error { + display, err := wlclient.Connect("") + if err != nil { + return fmt.Errorf("wayland connect: %w", err) + } + defer display.Destroy() + + ctx := display.Context() + registry, err := display.GetRegistry() + if err != nil { + return fmt.Errorf("get registry: %w", err) + } + defer registry.Destroy() + + var dataControlMgr *ext_data_control.ExtDataControlManagerV1 + var seat *wlclient.Seat + var bindErr error + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + switch e.Interface { + case "ext_data_control_manager_v1": + dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx) + if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil { + bindErr = err + } + case "wl_seat": + if seat != nil { + return + } + seat = wlclient.NewSeat(ctx) + if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil { + bindErr = err + } + } + }) + + display.Roundtrip() + display.Roundtrip() + + if bindErr != nil { + return fmt.Errorf("registry bind: %w", bindErr) + } + if dataControlMgr == nil { + return fmt.Errorf("compositor does not support ext_data_control_manager_v1") + } + defer dataControlMgr.Destroy() + if seat == nil { + return fmt.Errorf("no seat available") + } + + device, err := dataControlMgr.GetDataDevice(seat) + if err != nil { + return fmt.Errorf("get data device: %w", err) + } + defer device.Destroy() + + source, err := dataControlMgr.CreateDataSource() + if err != nil { + return fmt.Errorf("create data source: %w", err) + } + + if err := source.Offer(mimeType); err != nil { + return fmt.Errorf("offer mime type: %w", err) + } + if mimeType == "text/plain;charset=utf-8" || mimeType == "text/plain" { + if err := source.Offer("text/plain"); err != nil { + return fmt.Errorf("offer text/plain: %w", err) + } + if err := source.Offer("text/plain;charset=utf-8"); err != nil { + return fmt.Errorf("offer text/plain;charset=utf-8: %w", err) + } + if err := source.Offer("UTF8_STRING"); err != nil { + return fmt.Errorf("offer UTF8_STRING: %w", err) + } + if err := source.Offer("STRING"); err != nil { + return fmt.Errorf("offer STRING: %w", err) + } + if err := source.Offer("TEXT"); err != nil { + return fmt.Errorf("offer TEXT: %w", err) + } + } + + cancelled := make(chan struct{}) + pasted := make(chan struct{}, 1) + + source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) { + _ = syscall.SetNonblock(e.Fd, false) + file := os.NewFile(uintptr(e.Fd), "pipe") + defer file.Close() + _, _ = file.Write(data) + select { + case pasted <- struct{}{}: + default: + } + }) + + source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) { + close(cancelled) + }) + + if err := device.SetSelection(source); err != nil { + return fmt.Errorf("set selection: %w", err) + } + + display.Roundtrip() + signalReady() + + for { + select { + case <-cancelled: + return nil + case <-pasted: + if pasteOnce { + return nil + } + default: + if err := ctx.Dispatch(); err != nil { + return nil + } + } + } +} + +func CopyText(text string) error { + return Copy([]byte(text), "text/plain;charset=utf-8") +} + +func Paste() ([]byte, string, error) { + display, err := wlclient.Connect("") + if err != nil { + return nil, "", fmt.Errorf("wayland connect: %w", err) + } + defer display.Destroy() + + ctx := display.Context() + registry, err := display.GetRegistry() + if err != nil { + return nil, "", fmt.Errorf("get registry: %w", err) + } + defer registry.Destroy() + + var dataControlMgr *ext_data_control.ExtDataControlManagerV1 + var seat *wlclient.Seat + var bindErr error + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + switch e.Interface { + case "ext_data_control_manager_v1": + dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx) + if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil { + bindErr = err + } + case "wl_seat": + if seat != nil { + return + } + seat = wlclient.NewSeat(ctx) + if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil { + bindErr = err + } + } + }) + + display.Roundtrip() + display.Roundtrip() + + if bindErr != nil { + return nil, "", fmt.Errorf("registry bind: %w", bindErr) + } + + if dataControlMgr == nil { + return nil, "", fmt.Errorf("compositor does not support ext_data_control_manager_v1") + } + defer dataControlMgr.Destroy() + + if seat == nil { + return nil, "", fmt.Errorf("no seat available") + } + + device, err := dataControlMgr.GetDataDevice(seat) + if err != nil { + return nil, "", fmt.Errorf("get data device: %w", err) + } + defer device.Destroy() + + offerMimeTypes := make(map[*ext_data_control.ExtDataControlOfferV1][]string) + + device.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) { + if e.Id == nil { + return + } + offerMimeTypes[e.Id] = nil + e.Id.SetOfferHandler(func(me ext_data_control.ExtDataControlOfferV1OfferEvent) { + offerMimeTypes[e.Id] = append(offerMimeTypes[e.Id], me.MimeType) + }) + }) + + var selectionOffer *ext_data_control.ExtDataControlOfferV1 + gotSelection := false + + device.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) { + selectionOffer = e.Id + gotSelection = true + }) + + display.Roundtrip() + display.Roundtrip() + + if !gotSelection || selectionOffer == nil { + return nil, "", fmt.Errorf("no clipboard data") + } + + mimeTypes := offerMimeTypes[selectionOffer] + selectedMime := selectPreferredMimeType(mimeTypes) + if selectedMime == "" { + return nil, "", fmt.Errorf("no supported mime type") + } + + r, w, err := os.Pipe() + if err != nil { + return nil, "", fmt.Errorf("create pipe: %w", err) + } + defer r.Close() + + if err := selectionOffer.Receive(selectedMime, int(w.Fd())); err != nil { + w.Close() + return nil, "", fmt.Errorf("receive: %w", err) + } + w.Close() + + display.Roundtrip() + + data, err := io.ReadAll(r) + if err != nil { + return nil, "", fmt.Errorf("read: %w", err) + } + + return data, selectedMime, nil +} + +func PasteText() (string, error) { + data, _, err := Paste() + if err != nil { + return "", err + } + return string(data), nil +} + +func selectPreferredMimeType(mimes []string) string { + preferred := []string{ + "text/plain;charset=utf-8", + "text/plain", + "UTF8_STRING", + "STRING", + "TEXT", + "image/png", + "image/jpeg", + } + + for _, pref := range preferred { + for _, mime := range mimes { + if mime == pref { + return mime + } + } + } + + if len(mimes) > 0 { + return mimes[0] + } + return "" +} + +func IsImageMimeType(mime string) bool { + return len(mime) > 6 && mime[:6] == "image/" +} + +type Offer struct { + MimeType string + Data []byte +} + +func CopyMulti(offers []Offer, foreground, pasteOnce bool) error { + if !foreground { + return copyMultiFork(offers, pasteOnce) + } + return copyMultiServe(offers, pasteOnce) +} + +func copyMultiFork(offers []Offer, pasteOnce bool) error { + args := []string{os.Args[0], "cl", "copy", "--foreground", "--type", "__multi__"} + if pasteOnce { + args = append(args, "--paste-once") + } + + cmd := exec.Command(args[0], args[1:]...) + cmd.Stdin = nil + cmd.Stdout = nil + cmd.Stderr = nil + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("stdin pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start: %w", err) + } + + for _, offer := range offers { + fmt.Fprintf(stdin, "%s\x00%d\x00", offer.MimeType, len(offer.Data)) + if _, err := stdin.Write(offer.Data); err != nil { + stdin.Close() + return fmt.Errorf("write offer data: %w", err) + } + } + stdin.Close() + + return nil +} + +func copyMultiServe(offers []Offer, pasteOnce bool) error { + display, err := wlclient.Connect("") + if err != nil { + return fmt.Errorf("wayland connect: %w", err) + } + defer display.Destroy() + + ctx := display.Context() + registry, err := display.GetRegistry() + if err != nil { + return fmt.Errorf("get registry: %w", err) + } + defer registry.Destroy() + + var dataControlMgr *ext_data_control.ExtDataControlManagerV1 + var seat *wlclient.Seat + var bindErr error + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + switch e.Interface { + case "ext_data_control_manager_v1": + dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx) + if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil { + bindErr = err + } + case "wl_seat": + if seat != nil { + return + } + seat = wlclient.NewSeat(ctx) + if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil { + bindErr = err + } + } + }) + + display.Roundtrip() + display.Roundtrip() + + if bindErr != nil { + return fmt.Errorf("registry bind: %w", bindErr) + } + if dataControlMgr == nil { + return fmt.Errorf("compositor does not support ext_data_control_manager_v1") + } + defer dataControlMgr.Destroy() + if seat == nil { + return fmt.Errorf("no seat available") + } + + device, err := dataControlMgr.GetDataDevice(seat) + if err != nil { + return fmt.Errorf("get data device: %w", err) + } + defer device.Destroy() + + source, err := dataControlMgr.CreateDataSource() + if err != nil { + return fmt.Errorf("create data source: %w", err) + } + + offerMap := make(map[string][]byte) + for _, offer := range offers { + if err := source.Offer(offer.MimeType); err != nil { + return fmt.Errorf("offer %s: %w", offer.MimeType, err) + } + offerMap[offer.MimeType] = offer.Data + } + + cancelled := make(chan struct{}) + pasted := make(chan struct{}, 1) + + source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) { + _ = syscall.SetNonblock(e.Fd, false) + file := os.NewFile(uintptr(e.Fd), "pipe") + defer file.Close() + + if data, ok := offerMap[e.MimeType]; ok { + _, _ = file.Write(data) + } + + select { + case pasted <- struct{}{}: + default: + } + }) + + source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) { + close(cancelled) + }) + + if err := device.SetSelection(source); err != nil { + return fmt.Errorf("set selection: %w", err) + } + + display.Roundtrip() + + for { + select { + case <-cancelled: + return nil + case <-pasted: + if pasteOnce { + return nil + } + default: + if err := ctx.Dispatch(); err != nil { + return nil + } + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/clipboard/store.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/clipboard/store.go new file mode 100644 index 0000000..17ad7a9 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/clipboard/store.go @@ -0,0 +1,248 @@ +package clipboard + +import ( + "bytes" + "encoding/binary" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "os" + "path/filepath" + "strings" + "time" + + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/tiff" + "hash/fnv" + + bolt "go.etcd.io/bbolt" +) + +type StoreConfig struct { + MaxHistory int + MaxEntrySize int64 +} + +func DefaultStoreConfig() StoreConfig { + return StoreConfig{ + MaxHistory: 100, + MaxEntrySize: 5 * 1024 * 1024, + } +} + +type Entry struct { + ID uint64 + Data []byte + MimeType string + Preview string + Size int + Timestamp time.Time + IsImage bool + Hash uint64 +} + +func Store(data []byte, mimeType string) error { + return StoreWithConfig(data, mimeType, DefaultStoreConfig()) +} + +func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) error { + if len(data) == 0 { + return nil + } + if int64(len(data)) > cfg.MaxEntrySize { + return fmt.Errorf("data too large: %d > %d", len(data), cfg.MaxEntrySize) + } + + dbPath, err := GetDBPath() + if err != nil { + return fmt.Errorf("get db path: %w", err) + } + + db, err := bolt.Open(dbPath, 0o644, &bolt.Options{Timeout: 1 * time.Second}) + if err != nil { + return fmt.Errorf("open db: %w", err) + } + defer db.Close() + + entry := Entry{ + Data: data, + MimeType: mimeType, + Size: len(data), + Timestamp: time.Now(), + IsImage: IsImageMimeType(mimeType), + Hash: computeHash(data), + } + + switch { + case entry.IsImage: + entry.Preview = imagePreview(data, mimeType) + default: + entry.Preview = textPreview(data) + } + + return db.Update(func(tx *bolt.Tx) error { + b, err := tx.CreateBucketIfNotExists([]byte("clipboard")) + if err != nil { + return err + } + + if err := 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 trimLengthInTx(b, cfg.MaxHistory) + }) +} + +func GetDBPath() (string, error) { + cacheDir, err := os.UserCacheDir() + if err != nil { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + cacheDir = filepath.Join(homeDir, ".cache") + } + + newDir := filepath.Join(cacheDir, "DankMaterialShell", "clipboard") + newPath := filepath.Join(newDir, "db") + + if _, err := os.Stat(newPath); err == nil { + return newPath, nil + } + + oldDir := filepath.Join(cacheDir, "dms-clipboard") + oldPath := filepath.Join(oldDir, "db") + + if _, err := os.Stat(oldPath); err == nil { + if err := os.MkdirAll(newDir, 0o700); err != nil { + return "", err + } + if err := os.Rename(oldPath, newPath); err != nil { + return "", err + } + os.Remove(oldDir) + return newPath, nil + } + + if err := os.MkdirAll(newDir, 0o700); err != nil { + return "", err + } + return newPath, nil +} + +func 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 + } + if err := b.Delete(k); err != nil { + return err + } + } + return nil +} + +func trimLengthInTx(b *bolt.Bucket, maxHistory int) error { + c := b.Cursor() + var count int + for k, _ := c.Last(); k != nil; k, _ = c.Prev() { + if count < 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) + + return buf.Bytes(), 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) < 8 { + return 0 + } + return binary.BigEndian.Uint64(data[len(data)-8:]) +} + +func 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 imagePreview(data []byte, format string) string { + config, imgFmt, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return fmt.Sprintf("[[ image %s %s ]]", sizeStr(len(data)), format) + } + return fmt.Sprintf("[[ image %s %s %dx%d ]]", sizeStr(len(data)), imgFmt, config.Width, config.Height) +} + +func sizeStr(size int) string { + units := []string{"B", "KiB", "MiB"} + var i int + fsize := float64(size) + for fsize >= 1024 && i < len(units)-1 { + fsize /= 1024 + i++ + } + return fmt.Sprintf("%.0f %s", fsize, units[i]) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/clipboard/watch.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/clipboard/watch.go new file mode 100644 index 0000000..327c5c5 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/clipboard/watch.go @@ -0,0 +1,303 @@ +package clipboard + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control" + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +type ClipboardChange struct { + Data []byte + MimeType string + MimeTypes []string +} + +func Watch(ctx context.Context, callback func(data []byte, mimeType string)) error { + display, err := wlclient.Connect("") + if err != nil { + return fmt.Errorf("wayland connect: %w", err) + } + defer display.Destroy() + + wlCtx := display.Context() + registry, err := display.GetRegistry() + if err != nil { + return fmt.Errorf("get registry: %w", err) + } + defer registry.Destroy() + + var dataControlMgr *ext_data_control.ExtDataControlManagerV1 + var seat *wlclient.Seat + var bindErr error + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + switch e.Interface { + case "ext_data_control_manager_v1": + dataControlMgr = ext_data_control.NewExtDataControlManagerV1(wlCtx) + if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil { + bindErr = err + } + case "wl_seat": + if seat != nil { + return + } + seat = wlclient.NewSeat(wlCtx) + if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil { + bindErr = err + } + } + }) + + display.Roundtrip() + display.Roundtrip() + + if bindErr != nil { + return fmt.Errorf("registry bind: %w", bindErr) + } + + if dataControlMgr == nil { + return fmt.Errorf("compositor does not support ext_data_control_manager_v1") + } + defer dataControlMgr.Destroy() + + if seat == nil { + return fmt.Errorf("no seat available") + } + + device, err := dataControlMgr.GetDataDevice(seat) + if err != nil { + return fmt.Errorf("get data device: %w", err) + } + defer device.Destroy() + + offerMimeTypes := make(map[*ext_data_control.ExtDataControlOfferV1][]string) + + device.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) { + if e.Id == nil { + return + } + offerMimeTypes[e.Id] = nil + e.Id.SetOfferHandler(func(me ext_data_control.ExtDataControlOfferV1OfferEvent) { + offerMimeTypes[e.Id] = append(offerMimeTypes[e.Id], me.MimeType) + }) + }) + + device.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) { + if e.Id == nil { + return + } + + mimes := offerMimeTypes[e.Id] + selectedMime := selectPreferredMimeType(mimes) + if selectedMime == "" { + return + } + + r, w, err := os.Pipe() + if err != nil { + return + } + + if err := e.Id.Receive(selectedMime, int(w.Fd())); err != nil { + w.Close() + r.Close() + return + } + w.Close() + + go func() { + defer r.Close() + data, err := io.ReadAll(r) + if err != nil || len(data) == 0 { + return + } + callback(data, selectedMime) + }() + }) + + display.Roundtrip() + display.Roundtrip() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + if err := wlCtx.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil { + return fmt.Errorf("set read deadline: %w", err) + } + if err := wlCtx.Dispatch(); err != nil { + if isTimeoutError(err) { + continue + } + return fmt.Errorf("dispatch: %w", err) + } + } + } +} + +func WatchAll(ctx context.Context, callback func(data []byte, mimeType string, allMimeTypes []string)) error { + display, err := wlclient.Connect("") + if err != nil { + return fmt.Errorf("wayland connect: %w", err) + } + defer display.Destroy() + + wlCtx := display.Context() + registry, err := display.GetRegistry() + if err != nil { + return fmt.Errorf("get registry: %w", err) + } + defer registry.Destroy() + + var dataControlMgr *ext_data_control.ExtDataControlManagerV1 + var seat *wlclient.Seat + var bindErr error + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + switch e.Interface { + case "ext_data_control_manager_v1": + dataControlMgr = ext_data_control.NewExtDataControlManagerV1(wlCtx) + if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil { + bindErr = err + } + case "wl_seat": + if seat != nil { + return + } + seat = wlclient.NewSeat(wlCtx) + if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil { + bindErr = err + } + } + }) + + display.Roundtrip() + display.Roundtrip() + + if bindErr != nil { + return fmt.Errorf("registry bind: %w", bindErr) + } + if dataControlMgr == nil { + return fmt.Errorf("compositor does not support ext_data_control_manager_v1") + } + defer dataControlMgr.Destroy() + if seat == nil { + return fmt.Errorf("no seat available") + } + + device, err := dataControlMgr.GetDataDevice(seat) + if err != nil { + return fmt.Errorf("get data device: %w", err) + } + defer device.Destroy() + + offerMimeTypes := make(map[*ext_data_control.ExtDataControlOfferV1][]string) + + device.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) { + if e.Id == nil { + return + } + offerMimeTypes[e.Id] = nil + e.Id.SetOfferHandler(func(me ext_data_control.ExtDataControlOfferV1OfferEvent) { + offerMimeTypes[e.Id] = append(offerMimeTypes[e.Id], me.MimeType) + }) + }) + + device.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) { + if e.Id == nil { + return + } + + mimes := offerMimeTypes[e.Id] + selectedMime := selectPreferredMimeType(mimes) + if selectedMime == "" { + return + } + + mimesCopy := make([]string, len(mimes)) + copy(mimesCopy, mimes) + + r, w, err := os.Pipe() + if err != nil { + return + } + + if err := e.Id.Receive(selectedMime, int(w.Fd())); err != nil { + w.Close() + r.Close() + return + } + w.Close() + + go func() { + defer r.Close() + data, err := io.ReadAll(r) + if err != nil || len(data) == 0 { + return + } + callback(data, selectedMime, mimesCopy) + }() + }) + + display.Roundtrip() + display.Roundtrip() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + if err := wlCtx.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil { + return fmt.Errorf("set read deadline: %w", err) + } + if err := wlCtx.Dispatch(); err != nil { + if isTimeoutError(err) { + continue + } + return fmt.Errorf("dispatch: %w", err) + } + } + } +} + +func isTimeoutError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, os.ErrDeadlineExceeded) { + return true + } + if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() { + return true + } + return false +} + +func WatchChan(ctx context.Context) (<-chan ClipboardChange, <-chan error) { + ch := make(chan ClipboardChange, 16) + errCh := make(chan error, 1) + + go func() { + defer close(ch) + err := Watch(ctx, func(data []byte, mimeType string) { + select { + case ch <- ClipboardChange{Data: data, MimeType: mimeType}: + default: + } + }) + if err != nil && err != context.Canceled { + errCh <- err + } + close(errCh) + }() + + time.Sleep(50 * time.Millisecond) + return ch, errCh +} |