// 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() }