mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-01-27 06:52:50 -05:00
clipboard: add cl copy --download option for images/videos
- offers application/vnd.portal.filetransfer and text/uri-list
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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**:
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
Reference in New Issue
Block a user