Files
zero/internal/imageinput/clipboard.go
Vasanth T ec3b822505 fix(tui): paste clipboard images with Ctrl+V (#534) (#807)
* fix(tui): paste clipboard images with Ctrl+V

Ctrl+V did nothing when the clipboard held a screenshot, while right-click
paste attached it. The image plumbing was already there: routePaste probes the
OS clipboard for an image whenever a paste arrives with empty text.

The gap is that a clipboard holding only an image produces no bracketed paste
at all, since the terminal has no text to send, so routePaste never runs and its
probe never fires. Right-click reached the probe only because that path always
delivers a clipboardReadMsg, empty or not. Bubble's own Ctrl+V binding stays
disabled, so nothing else was listening for the key.

Handle Ctrl+V by issuing the image probe directly. It reads image content only,
never text, so bracketed paste remains the single text path and a Ctrl+V with
text on the clipboard is unaffected. The probe yields no message when there is
no image, keeping it a silent no-op, and it is gated on noBlockingModal so a
permission prompt or picker still swallows the key.

readClipboardImage becomes a var so the tests stub it instead of depending on
the machine's real clipboard.

Fixes #534

* fix(tui): also probe the clipboard on Cmd+V, and test the real key route

Two things raised in review, both fair.

The handler matched keyCtrl only, which is ModCtrl. macOS reports Command as
ModSuper, as the rest of the TUI already models it, so Cmd+V never reached the
image probe even though the change claimed to cover it. It now matches both, via
a keySuper helper alongside the existing modifier helpers.

Worth being precise about the limit: this only helps where the terminal delivers
the key to the application. A terminal that handles Cmd+V itself pastes the
clipboard TEXT and sends no key event at all, so an image-only clipboard still
produces nothing for this to react to. That is a separate problem from the one
being fixed here.

The tests also did not exercise the route they were named for. One asserted that
Update returned some non-nil command and then ran a freshly built probe; the
other never called Update at all. Both now stub the reader BEFORE Update and run
the command Update actually returned.

That matters more than it sounds. Making the branch return a different non-nil
command leaves the old assertion passing and fails the new one, which is exactly
the hole: the old test proved a command was issued, not that it was the image
probe. Removing the route entirely, or reverting to ModCtrl only, also fails now.

* fix(imageinput): report an unreadable clipboard image instead of silence

On macOS the clipboard reader shells to pngpaste or a PyObjC one-liner, and
neither ships with the OS: pngpaste is Homebrew-only and Apple dropped the
bundled PyObjC. A stock Mac therefore fails every time, and the failure was
returned as (nil, nil), which the caller reads as "no image on the clipboard"
and treats as a silent no-op. Pressing Ctrl+V produced nothing at all: no
image, no error, no hint that anything was missing.

The darwin path already knows the difference. It probes with `clipboard info`
first and only reaches the extraction step once the clipboard is confirmed to
hold an image, so a failure there is not absence, it is "there is an image and
nothing here can read it". That now returns ErrClipboardImageUnreadable, which
the existing error path turns into a transcript notice naming what to install.

The defect predates this branch, but this branch is what makes the path
reachable from Ctrl+V, so shipping without it would close #534 with a feature
that is inert on one of the three platforms. Raised by gnanam, who measured it
on a real Mac rather than inferring it.

Deliberately narrow. The Linux arm cannot tell "no tool" from "no image"
without probing separately, and Windows uses PowerShell, which is always
present, so neither is touched.

The test drives the whole path: stubbed reader returns the error, Ctrl+V
issues the probe, and the assertion is that the failure reaches the transcript
as an error row rather than stopping earlier. Swallowing the error again makes
it fail.

* test(imageinput): cover the darwin unreadable-clipboard decision itself

The test added with the fix asserted around the change rather than on it. It
substituted ReadClipboardImage with a stub returning the error, which proves
the TUI surfaces an error handed to it, but never reaches
readClipboardImageDarwin, where the decision lives. Reverting both production
return sites to nil,nil left every package green, which is the definition of a
test that is not testing the change. Caught by gnanam.

readClipboardImageDarwin shells out twice and had no seam, so the two steps are
now package-level vars, the same shape as the readClipboardImage var this
change already relied on. That makes the case that matters reachable: the info
probe succeeds and reports an image, and the extractor then fails. It cannot be
arranged from outside the function, because from there both steps are one
opaque call.

Five cases, and the negative ones carry as much weight as the positive. An
image with no working extractor, and an extractor that exits 0 with no bytes,
must both be actionable. A clipboard offering no image, and a failing info
probe, must both stay silent, because turning those into a notice would make
every stray Ctrl+V nag the user. The extractor must not run at all in either
silent case.

Repeating gnanam's experiment now fails: reverting both return sites to nil,nil
fails the two actionable cases and leaves the silent ones passing, which is
exactly the discrimination the fix is supposed to add.
2026-07-28 17:51:28 +08:00

227 lines
8.2 KiB
Go

package imageinput
import (
"bytes"
"errors"
"fmt"
"net/http"
"os"
"os/exec"
"runtime"
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// ReadClipboardImage returns the raw image bytes and media type from the OS
// clipboard, or (nil, "", nil) when the clipboard has no image. Called when
// text clipboard is empty (the user pasted a screenshot). The media type is
// sniffed from the bytes, not trusted from the clipboard.
// ErrClipboardImageUnreadable reports that the clipboard holds an image the host
// has no way to extract. It is deliberately distinct from "no image on the
// clipboard", which is an ordinary no-op: this one is actionable, and silence is
// the wrong response to it.
var ErrClipboardImageUnreadable = errors.New(
"the clipboard holds an image but no helper on this host can read it; on macOS install pngpaste (brew install pngpaste) or a Python with pyobjc")
func ReadClipboardImage() ([]byte, string, error) {
data, err := readClipboardImageBytes()
if err != nil {
return nil, "", err
}
if data == nil {
return nil, "", nil
}
// Sniff the media type from the bytes — don't trust the clipboard's claim.
sniffLen := len(data)
if sniffLen > 512 {
sniffLen = 512
}
mediaType := zeroruntime.NormalizeImageMediaType(http.DetectContentType(data[:sniffLen]))
if mediaType == "" {
return nil, "", fmt.Errorf("clipboard image is not a supported type (allowed: png, jpeg, gif, webp)")
}
return data, mediaType, nil
}
// readClipboardImageBytes calls the platform-specific clipboard tool to extract
// image bytes. Returns (nil, nil) when no image is present.
func readClipboardImageBytes() ([]byte, error) {
switch runtime.GOOS {
case "windows":
return readClipboardImageWindows()
case "darwin":
return readClipboardImageDarwin()
case "linux":
return readClipboardImageLinux()
default:
return nil, nil
}
}
// readClipboardImageWindows uses PowerShell to check for and read a clipboard
// image. The image is saved as PNG to a temp file, read back, and the temp file
// deleted. Returns (nil, nil) when no image is on the clipboard.
func readClipboardImageWindows() ([]byte, error) {
// Check if the clipboard contains an image.
check := `Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; [System.Windows.Forms.Clipboard]::ContainsImage()`
out, err := exec.Command("powershell", "-NoProfile", "-Command", check).Output()
if err != nil {
return nil, nil // clipboard not available, treat as no image
}
if strings.TrimSpace(string(out)) != "True" {
return nil, nil
}
// Save the clipboard image as PNG to a temp file, then read the bytes.
// PowerShell stdout can't reliably emit raw binary — $ms.ToArray() prints
// a .NET byte array as space-separated text, not raw bytes. A temp file
// is the correct binary-safe path.
tmpFile, err := os.CreateTemp("", "zero-clipboard-*.png")
if err != nil {
return nil, nil
}
tmpPath := tmpFile.Name()
tmpFile.Close()
defer os.Remove(tmpPath)
// Stay on -Command: -File is subject to script execution policy (default
// Restricted on Windows clients) and Windows PowerShell decodes BOM-less
// UTF-8 .ps1 files as ANSI. Doubling single quotes is all the escaping the
// single-quoted PowerShell literal needs.
escapedTmpPath := strings.ReplaceAll(tmpPath, "'", "''")
script := `Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img -ne $null) { $img.Save('` + escapedTmpPath + `', [System.Drawing.Imaging.ImageFormat]::Png) }`
cmd := exec.Command("powershell", "-NoProfile", "-Command", script)
if err := cmd.Run(); err != nil {
return nil, nil
}
data, err := os.ReadFile(tmpPath)
if err != nil || len(data) == 0 {
return nil, nil
}
return data, nil
}
// readClipboardImageDarwin uses osascript to check for and read a clipboard
// image. Returns (nil, nil) when no image is present.
// darwinClipboardInfo reports what the pasteboard is currently offering, and
// darwinClipboardExtract pulls the PNG bytes out of it.
//
// Both are vars so the two-step logic below can be driven in a test. The step
// that matters, "the clipboard holds an image and nothing here can extract it",
// only happens when the first succeeds and the second fails, which cannot be
// arranged by stubbing the caller: a test that substitutes ReadClipboardImage
// asserts the plumbing around this function rather than the decision inside it,
// and stays green with that decision reverted.
var (
darwinClipboardInfo = func() (string, error) {
out, err := exec.Command("sh", "-c", `osascript -e 'clipboard info'`).Output()
return string(out), err
}
darwinClipboardExtract = func() ([]byte, error) {
// pngpaste if available, falling back to a Python one-liner. Neither
// ships with macOS: pngpaste is Homebrew-only and Apple dropped the
// bundled PyObjC, so a stock Mac fails here every time.
cmd := exec.Command("sh", "-c", `pngpaste - 2>/dev/null || python3 -c "
import AppKit, sys
pb = AppKit.NSPasteboard.generalPasteboard()
data = pb.dataForType_(AppKit.NSPasteboardTypePNG)
if data:
sys.stdout.buffer.write(data.bytes())
"`)
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return nil, err
}
return stdout.Bytes(), nil
}
)
// darwinClipboardOffersImage reports whether the pasteboard info line names an
// image class.
func darwinClipboardOffersImage(info string) bool {
for _, class := range []string{"PNG", "JPEG", "TIFF", "GIF"} {
if strings.Contains(info, class) {
return true
}
}
return false
}
func readClipboardImageDarwin() ([]byte, error) {
info, err := darwinClipboardInfo()
if err != nil {
return nil, nil
}
if !darwinClipboardOffersImage(info) {
return nil, nil
}
// Past this point the clipboard is KNOWN to hold an image, because the probe
// above said so. A failure here is therefore not "no image", it is "there is
// an image and nothing on this host can extract it". Reporting that as
// absence is what left users staring at a paste that silently did nothing.
data, err := darwinClipboardExtract()
if err != nil {
return nil, ErrClipboardImageUnreadable
}
if len(data) == 0 {
return nil, ErrClipboardImageUnreadable
}
return data, nil
}
// readClipboardImageLinux tries wl-paste (Wayland) then xclip (X11) to read
// clipboard image bytes. Returns (nil, nil) when no image or no tool available.
//
// The MIME type comes from the clipboard (wl-paste --list-types / xclip
// TARGETS), so it is NEVER interpolated into a shell — every command runs via
// exec.Command(prog, args...) with the type passed as a discrete argument.
// (A hostile clipboard offerer could otherwise register a target like
// "image/png; rm -rf ~" that passes the "image/" prefix check.)
func readClipboardImageLinux() ([]byte, error) {
// Try Wayland first.
if types, err := runClipboardStdout("wl-paste", "--list-types"); err == nil {
for _, t := range imageMIMETypes(types) {
if data, err := runClipboardStdout("wl-paste", "--type", t); err == nil && len(data) > 0 {
return data, nil
}
}
}
// Fall back to X11 xclip.
if types, err := runClipboardStdout("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"); err == nil {
for _, t := range imageMIMETypes(types) {
if data, err := runClipboardStdout("xclip", "-selection", "clipboard", "-t", t, "-o"); err == nil && len(data) > 0 {
return data, nil
}
}
}
return nil, nil
}
// runClipboardStdout runs a clipboard helper and returns only its stdout.
// Stderr is discarded (the helpers are noisy when the clipboard is empty or the
// tool is missing); a missing tool surfaces as the command error, treated as
// "no image" by the callers.
func runClipboardStdout(name string, args ...string) ([]byte, error) {
cmd := exec.Command(name, args...)
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return nil, err
}
return stdout.Bytes(), nil
}
// imageMIMETypes extracts the "image/*" lines from a newline-separated type
// list, each safe to pass as a discrete argument (no shell).
func imageMIMETypes(list []byte) []string {
var out []string
for _, line := range strings.Split(string(list), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "image/") {
out = append(out, line)
}
}
return out
}