package attach
import (
"context"
"encoding/hex"
"errors"
"os"
"os/exec"
"runtime"
"strings"
"time"
)
// Reading an image off the clipboard is done by asking whichever helper the
// desktop ships, because there is no portable syscall for it and the helpers
// are what every other program uses too. Several are tried in turn: a machine
// may have both Wayland and X11 tools installed with only one of them working,
// and a helper that is missing looks exactly like a helper that found nothing.
//
// Each runs under a short deadline. These programs block when the clipboard
// owner is slow or gone — a closed window whose selection nobody took over
// hangs xclip indefinitely — and a hung paste would take the whole UI with it.
const grabTimeout = 3 * time.Second
// grabber is one way to ask for the clipboard's image.
type grabber struct {
cmd string
args []string
// decode converts the helper's stdout into image bytes. Nil means the
// bytes are already the image.
decode func([]byte) ([]byte, error)
}
// grabbers is the list for this machine, in the order they are tried. It is a
// variable so a test can stand in for the desktop.
var grabbers = defaultGrabbers()
func defaultGrabbers() []grabber {
switch runtime.GOOS {
case "darwin":
return []grabber{
// pngpaste is not standard, but it is the one people install, and
// it hands back the bytes with nothing in the way.
{cmd: "pngpaste", args: []string{"-"}},
// AppleScript is always there. It answers with «data PNGf89504…»,
// hex inside guillemets, which decodeAppleScript unwraps.
{cmd: "osascript", args: []string{"-e", "the clipboard as «class PNGf»"}, decode: decodeAppleScript},
}
case "windows":
return []grabber{{cmd: "powershell", args: []string{"-NoProfile", "-STA", "-Command", psScript}, decode: decodeHexLine}}
default:
// xsel is not here on purpose: it cannot ask for a MIME target, so it
// would answer an image request with the clipboard's text.
return []grabber{
{cmd: "wl-paste", args: []string{"--no-newline", "--type", "image/png"}},
{cmd: "xclip", args: []string{"-selection", "clipboard", "-t", "image/png", "-o"}},
}
}
}
// psScript pulls the clipboard image through PowerShell and writes it as hex
// on one line, because a pipe out of PowerShell mangles raw bytes.
const psScript = `Add-Type -AssemblyName System.Windows.Forms;` +
`$i=[Windows.Forms.Clipboard]::GetImage();` +
`if($i -eq $null){exit 1};` +
`$m=New-Object System.IO.MemoryStream;` +
`$i.Save($m,[System.Drawing.Imaging.ImageFormat]::Png);` +
`[BitConverter]::ToString($m.ToArray()).Replace('-','')`
func decodeHexLine(out []byte) ([]byte, error) {
return hex.DecodeString(strings.TrimSpace(string(out)))
}
// decodeAppleScript unwraps «data PNGf89504E47…». The guillemets are UTF-8 in
// osascript's output; what matters is the hex between the tag and the closing
// mark.
func decodeAppleScript(out []byte) ([]byte, error) {
s := strings.TrimSpace(string(out))
i := strings.Index(s, "PNGf")
if i < 0 {
return nil, ErrNoImage
}
s = s[i+4:]
if j := strings.IndexAny(s, "»\""); j >= 0 {
s = s[:j]
}
return hex.DecodeString(strings.TrimSpace(s))
}
// Clipboard returns the picture on the system clipboard. ErrNoImage means
// there is none — the usual case, and not worth reporting as a failure.
func Clipboard() (Image, error) {
var missing int
for _, g := range grabbers {
if _, err := exec.LookPath(g.cmd); err != nil {
missing++
continue
}
out, err := run(g)
if err != nil || len(out) == 0 {
continue // no image in that flavour, or the helper said no
}
if g.decode != nil {
if out, err = g.decode(out); err != nil || len(out) == 0 {
continue
}
}
im, err := New(out, "clipboard")
if err == nil {
return im, nil
}
if errors.Is(err, ErrTooBig) {
// A picture was found and it is not going to fit. Say so, rather
// than reporting an empty clipboard to someone who can see their
// screenshot sitting on it.
return Image{}, err
}
}
if missing == len(grabbers) && len(grabbers) > 0 {
return Image{}, errors.New("no clipboard helper installed — " + advice())
}
return Image{}, ErrNoImage
}
func advice() string {
switch runtime.GOOS {
case "darwin":
return "brew install pngpaste"
case "windows":
return "powershell is required"
default:
return "install wl-clipboard (Wayland) or xclip (X11)"
}
}
func run(g grabber) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), grabTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, g.cmd, g.args...)
cmd.Stderr = nil
cmd.Stdin = nil
// The helpers inherit a terminal they must not write to: this process owns
// the screen, and a warning printed into the alternate buffer stays there.
cmd.Env = append(os.Environ(), "NO_COLOR=1")
return cmd.Output()
}