Files
cthulhu/internal/app/tentacle_clipboard.go

138 lines
4.2 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"errors"
"io"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
)
func readClipboardTarget(ctx context.Context, target string) (string, error) {
command := exec.CommandContext(ctx, "xclip", "-selection", "clipboard", "-out", "-target", target)
command.WaitDelay = time.Second
pipe, err := command.StdoutPipe()
if err != nil {
return "", err
}
if err = command.Start(); err != nil {
return "", err
}
data, readErr := io.ReadAll(io.LimitReader(pipe, 8193))
if readErr != nil || len(data) > 8192 {
command.Process.Kill()
command.Wait()
return "", errors.New("clipboard too large")
}
err = command.Wait()
return string(data), err
}
func readTentacleClipboard(ctx context.Context) (tentaclewire.Item, error) {
targets, err := readClipboardTarget(ctx, "TARGETS")
if err != nil {
return tentaclewire.Item{}, err
}
for _, target := range strings.Fields(targets) {
if target == "text/uri-list" || target == "x-special/gnome-copied-files" {
data, err := readClipboardTarget(ctx, target)
if err != nil {
return tentaclewire.Item{}, err
}
return singleClipboardURI(data)
}
}
for _, target := range []string{"UTF8_STRING", "text/plain;charset=utf-8", "text/plain", "STRING"} {
for _, available := range strings.Fields(targets) {
if available == target {
text, err := readClipboardTarget(ctx, target)
return tentaclewire.Item{Text: text}, err
}
}
}
for _, target := range strings.Fields(targets) {
if target == "image/png" {
return clipboardPNG(ctx)
}
}
return tentaclewire.Item{}, errors.New("clipboard has no transferable text or file")
}
func singleClipboardURI(data string) (tentaclewire.Item, error) {
// GTK file managers can include a C-string terminator in the X selection.
// Remove only that trailing byte; embedded NULs remain invalid URI data.
data = strings.TrimSuffix(data, "\x00")
var uris []string
for _, line := range strings.Split(data, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || line == "copy" || line == "cut" {
continue
}
uris = append(uris, line)
}
if len(uris) != 1 {
return tentaclewire.Item{}, errors.New("copy exactly one file or folder")
}
parsed, err := url.Parse(uris[0])
if err != nil || parsed.Scheme != "file" || parsed.Host != "" && parsed.Host != "localhost" || parsed.RawQuery != "" || parsed.Fragment != "" || !filepath.IsAbs(parsed.Path) || strings.ContainsRune(parsed.Path, '\x00') {
return tentaclewire.Item{}, errors.New("local file path unavailable")
}
return tentaclewire.Item{Path: parsed.Path}, nil
}
func clipboardPNG(ctx context.Context) (tentaclewire.Item, error) {
directory, err := os.MkdirTemp("", "cthulhu-clipboard-*")
if err != nil {
return tentaclewire.Item{}, err
}
keep := false
defer func() {
if !keep {
os.RemoveAll(directory)
}
}()
path := filepath.Join(directory, "clipboard.png")
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return tentaclewire.Item{}, err
}
defer file.Close()
command := exec.CommandContext(ctx, "xclip", "-selection", "clipboard", "-out", "-target", "image/png")
command.WaitDelay = time.Second
pipe, err := command.StdoutPipe()
if err != nil {
return tentaclewire.Item{}, err
}
if err = command.Start(); err != nil {
return tentaclewire.Item{}, err
}
count, copyErr := io.Copy(file, io.LimitReader(pipe, 32*1024*1024+1))
if copyErr != nil || count > 32*1024*1024 {
command.Process.Kill()
command.Wait()
return tentaclewire.Item{}, errors.New("clipboard image too large")
}
if err = command.Wait(); err != nil {
return tentaclewire.Item{}, err
}
if count == 0 {
return tentaclewire.Item{}, errors.New("empty clipboard image")
}
if err = file.Close(); err != nil {
return tentaclewire.Item{}, err
}
keep = true
return tentaclewire.Item{Path: path, Temporary: true}, nil
}
func copyTentacleText(ctx context.Context, value string) error {
command := exec.CommandContext(ctx, "xclip", "-selection", "clipboard", "-in")
command.Stdin = strings.NewReader(value)
return command.Run()
}