138 lines
4.2 KiB
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()
|
|
}
|