From 2a02d5594c2e66b5e9cb80d4355c1addd518f6a6 Mon Sep 17 00:00:00 2001 From: bbedward Date: Mon, 26 Jan 2026 16:34:47 -0500 Subject: [PATCH] clipboard: add cl copy --download option for images/videos - offers application/vnd.portal.filetransfer and text/uri-list --- core/cmd/dms/commands_clipboard.go | 181 +++++++++++++++++- core/internal/clipboard/clipboard.go | 160 ++++++++++++++++ core/internal/server/clipboard/manager.go | 69 +++++++ .../Modals/DankLauncherV2/Controller.qml | 14 +- quickshell/PLUGINS/LauncherExample/README.md | 4 + quickshell/Services/AppSearchService.qml | 20 ++ 6 files changed, 442 insertions(+), 6 deletions(-) diff --git a/core/cmd/dms/commands_clipboard.go b/core/cmd/dms/commands_clipboard.go index 6f71e774..3dab31f1 100644 --- a/core/cmd/dms/commands_clipboard.go +++ b/core/cmd/dms/commands_clipboard.go @@ -12,17 +12,22 @@ import ( _ "image/jpeg" _ "image/png" "io" + "net/http" + "net/url" "os" "os/exec" "os/signal" "path/filepath" "strconv" + "strings" "syscall" "time" + "github.com/godbus/dbus/v5" bolt "go.etcd.io/bbolt" _ "golang.org/x/image/bmp" _ "golang.org/x/image/tiff" + _ "golang.org/x/image/webp" "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/log" @@ -48,6 +53,7 @@ var ( clipCopyForeground bool clipCopyPasteOnce bool clipCopyType string + clipCopyDownload bool clipJSONOutput bool ) @@ -184,6 +190,7 @@ func init() { clipCopyCmd.Flags().BoolVarP(&clipCopyForeground, "foreground", "f", false, "Stay in foreground instead of forking") clipCopyCmd.Flags().BoolVarP(&clipCopyPasteOnce, "paste-once", "o", false, "Exit after first paste") clipCopyCmd.Flags().StringVarP(&clipCopyType, "type", "t", "text/plain;charset=utf-8", "MIME type") + clipCopyCmd.Flags().BoolVarP(&clipCopyDownload, "download", "d", false, "Download URL as image and copy as file") clipWatchCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON") clipHistoryCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON") @@ -215,9 +222,10 @@ func init() { func runClipCopy(cmd *cobra.Command, args []string) { var data []byte - if len(args) > 0 { + switch { + case len(args) > 0: data = []byte(args[0]) - } else { + default: var err error data, err = io.ReadAll(os.Stdin) if err != nil { @@ -225,11 +233,67 @@ func runClipCopy(cmd *cobra.Command, args []string) { } } + if clipCopyDownload { + filePath, err := downloadToTempFile(strings.TrimSpace(string(data))) + if err != nil { + log.Fatalf("download: %v", err) + } + if err := copyFileToClipboard(filePath); err != nil { + log.Fatalf("copy file: %v", err) + } + return + } + + if clipCopyType == "__multi__" { + offers, err := parseMultiOffers(data) + if err != nil { + log.Fatalf("parse multi offers: %v", err) + } + if err := clipboard.CopyMulti(offers, true, clipCopyPasteOnce); err != nil { + log.Fatalf("copy multi: %v", err) + } + return + } + if err := clipboard.CopyOpts(data, clipCopyType, clipCopyForeground, clipCopyPasteOnce); err != nil { log.Fatalf("copy: %v", err) } } +func parseMultiOffers(data []byte) ([]clipboard.Offer, error) { + var offers []clipboard.Offer + pos := 0 + + for pos < len(data) { + mimeEnd := bytes.IndexByte(data[pos:], 0) + if mimeEnd == -1 { + break + } + mimeType := string(data[pos : pos+mimeEnd]) + pos += mimeEnd + 1 + + lenEnd := bytes.IndexByte(data[pos:], 0) + if lenEnd == -1 { + break + } + dataLen, err := strconv.Atoi(string(data[pos : pos+lenEnd])) + if err != nil { + return nil, fmt.Errorf("parse length: %w", err) + } + pos += lenEnd + 1 + + if pos+dataLen > len(data) { + return nil, fmt.Errorf("data truncated") + } + offerData := data[pos : pos+dataLen] + pos += dataLen + + offers = append(offers, clipboard.Offer{MimeType: mimeType, Data: offerData}) + } + + return offers, nil +} + func runClipPaste(cmd *cobra.Command, args []string) { data, _, err := clipboard.Paste() if err != nil { @@ -795,3 +859,116 @@ func detectMimeType(data []byte) string { func btoi(v []byte) uint64 { return binary.BigEndian.Uint64(v) } + +func downloadToTempFile(rawURL string) (string, error) { + if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") { + return "", fmt.Errorf("invalid URL: %s", rawURL) + } + + parsedURL, err := url.Parse(rawURL) + if err != nil { + return "", fmt.Errorf("parse URL: %w", err) + } + + ext := filepath.Ext(parsedURL.Path) + if ext == "" { + ext = ".png" + } + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(rawURL) + if err != nil { + return "", fmt.Errorf("download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download failed: status %d", resp.StatusCode) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read response: %w", err) + } + + contentType := resp.Header.Get("Content-Type") + if idx := strings.Index(contentType, ";"); idx != -1 { + contentType = strings.TrimSpace(contentType[:idx]) + } + + if !strings.HasPrefix(contentType, "image/") && !strings.HasPrefix(contentType, "video/") { + if _, _, err := image.DecodeConfig(bytes.NewReader(data)); err != nil { + return "", fmt.Errorf("not a valid media file (content-type: %s)", contentType) + } + } + + cacheDir, err := os.UserCacheDir() + if err != nil { + cacheDir = "/tmp" + } + clipDir := filepath.Join(cacheDir, "dms", "clipboard") + if err := os.MkdirAll(clipDir, 0755); err != nil { + return "", fmt.Errorf("create cache dir: %w", err) + } + + filePath := filepath.Join(clipDir, fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)) + if err := os.WriteFile(filePath, data, 0644); err != nil { + return "", fmt.Errorf("write file: %w", err) + } + + return filePath, nil +} + +func copyFileToClipboard(filePath string) error { + fileURI := "file://" + filePath + + transferKey, err := startPortalFileTransfer(filePath) + if err != nil { + log.Warnf("portal file transfer unavailable: %v", err) + } + + offers := []clipboard.Offer{ + {MimeType: "text/uri-list", Data: []byte(fileURI + "\r\n")}, + } + if transferKey != "" { + offers = append(offers, clipboard.Offer{ + MimeType: "application/vnd.portal.filetransfer", + Data: []byte(transferKey), + }) + } + + return clipboard.CopyMulti(offers, clipCopyForeground, clipCopyPasteOnce) +} + +func startPortalFileTransfer(filePath string) (string, error) { + conn, err := dbus.ConnectSessionBus() + if err != nil { + return "", fmt.Errorf("connect session bus: %w", err) + } + defer conn.Close() + + portal := conn.Object("org.freedesktop.portal.Documents", "/org/freedesktop/portal/documents") + + var key string + options := map[string]dbus.Variant{ + "writable": dbus.MakeVariant(false), + "autostop": dbus.MakeVariant(true), + } + if err := portal.Call("org.freedesktop.portal.FileTransfer.StartTransfer", 0, options).Store(&key); err != nil { + return "", fmt.Errorf("start transfer: %w", err) + } + + fd, err := syscall.Open(filePath, syscall.O_RDONLY, 0) + if err != nil { + return "", fmt.Errorf("open file: %w", err) + } + + addOptions := map[string]dbus.Variant{} + if err := portal.Call("org.freedesktop.portal.FileTransfer.AddFiles", 0, key, []dbus.UnixFD{dbus.UnixFD(fd)}, addOptions).Err; err != nil { + syscall.Close(fd) + return "", fmt.Errorf("add files: %w", err) + } + syscall.Close(fd) + + return key, nil +} diff --git a/core/internal/clipboard/clipboard.go b/core/internal/clipboard/clipboard.go index cce95e17..7d117942 100644 --- a/core/internal/clipboard/clipboard.go +++ b/core/internal/clipboard/clipboard.go @@ -330,3 +330,163 @@ func selectPreferredMimeType(mimes []string) string { 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) { + defer syscall.Close(e.Fd) + 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/core/internal/server/clipboard/manager.go b/core/internal/server/clipboard/manager.go index f9d849f6..a0b91304 100644 --- a/core/internal/server/clipboard/manager.go +++ b/core/internal/server/clipboard/manager.go @@ -21,6 +21,7 @@ import ( "github.com/fsnotify/fsnotify" _ "golang.org/x/image/bmp" _ "golang.org/x/image/tiff" + _ "golang.org/x/image/webp" bolt "go.etcd.io/bbolt" @@ -316,6 +317,13 @@ func (m *Manager) readAndStore(r *os.File, mimeType string) { } 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, @@ -327,6 +335,8 @@ func (m *Manager) storeClipboardEntry(data []byte, mimeType string) { 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) } @@ -507,6 +517,7 @@ func (m *Manager) hasSensitiveMimeType(mimes []string) bool { func (m *Manager) selectMimeType(mimes []string) string { preferredTypes := []string{ + "text/uri-list", "text/plain;charset=utf-8", "text/plain", "UTF8_STRING", @@ -557,6 +568,62 @@ func (m *Manager) imagePreview(data []byte, format string) string { 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 && strings.HasPrefix(uris[0], "file://") { + filePath := strings.TrimPrefix(uris[0], "file://") + if info, err := os.Stat(filePath); err == nil && !info.IsDir() { + 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 + } + } + + if len(uris) > 1 { + return fmt.Sprintf("[[ %d files ]]", len(uris)), 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 + } + + 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 @@ -1291,6 +1358,8 @@ func (m *Manager) StoreData(data []byte, mimeType string) error { 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) } diff --git a/quickshell/Modals/DankLauncherV2/Controller.qml b/quickshell/Modals/DankLauncherV2/Controller.qml index ce6d8961..045a3922 100644 --- a/quickshell/Modals/DankLauncherV2/Controller.qml +++ b/quickshell/Modals/DankLauncherV2/Controller.qml @@ -65,6 +65,12 @@ Item { running: false } + Process { + id: copyProcess + running: false + onExited: pasteTimer.start() + } + Timer { id: pasteTimer interval: 200 @@ -83,12 +89,12 @@ Item { const pluginId = selectedItem.pluginId; if (!pluginId) return; - const pasteText = AppSearchService.getPluginPasteText(pluginId, selectedItem.data); - if (!pasteText) + const pasteArgs = AppSearchService.getPluginPasteArgs(pluginId, selectedItem.data); + if (!pasteArgs) return; - Quickshell.execDetached(["dms", "cl", "copy", pasteText]); + copyProcess.command = pasteArgs; + copyProcess.running = true; itemExecuted(); - pasteTimer.start(); } readonly property var sectionDefinitions: [ diff --git a/quickshell/PLUGINS/LauncherExample/README.md b/quickshell/PLUGINS/LauncherExample/README.md index d3b327f6..db34b832 100644 --- a/quickshell/PLUGINS/LauncherExample/README.md +++ b/quickshell/PLUGINS/LauncherExample/README.md @@ -82,6 +82,10 @@ signal itemsChanged() // Required functions function getItems(query): array function executeItem(item): void + +// Optional functions (for Shift+Enter paste support) +function getPasteText(item): string|null +function getPasteArgs(item): array|null ``` **Item Structure**: diff --git a/quickshell/Services/AppSearchService.qml b/quickshell/Services/AppSearchService.qml index 824cf499..a34e143e 100644 --- a/quickshell/Services/AppSearchService.qml +++ b/quickshell/Services/AppSearchService.qml @@ -870,6 +870,26 @@ Singleton { return null; } + function getPluginPasteArgs(pluginId, item) { + if (typeof PluginService === "undefined") + return null; + + const instance = PluginService.pluginInstances[pluginId]; + if (!instance) + return null; + + if (typeof instance.getPasteArgs === "function") + return instance.getPasteArgs(item); + + if (typeof instance.getPasteText === "function") { + const text = instance.getPasteText(item); + if (text) + return ["dms", "cl", "copy", text]; + } + + return null; + } + function searchPluginItems(query) { if (typeof PluginService === "undefined") return [];