Files
hermes-agent/hermes_cli/clipboard.py

506 lines
18 KiB
Python

"""Clipboard image extraction for macOS, Windows, Linux, and WSL2.
Provides a single function `save_clipboard_image(dest)` that checks the system clipboard for image
data, saves it to *dest* as PNG, and returns True on success. No external Python dependencies — uses
only OS-level CLI tools that ship with the platform (or are commonly installed).
Platform support: macOS — osascript (always available), pngpaste (if installed) Windows — PowerShell
via WinForms, Get-Clipboard, file-drop fallback WSL2 — powershell.exe via WinForms, Get-Clipboard,
file-drop fallback Linux — wl-paste (Wayland), xclip (X11)
"""
import base64
import logging
import os
import subprocess
import sys
from pathlib import Path
from hermes_constants import is_wsl as _is_wsl
logger = logging.getLogger(__name__)
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
_TEXT = dict(capture_output=True, text=True, encoding='utf-8', errors='replace')
def _nonempty(path: Path) -> bool:
return path.exists() and path.stat().st_size > 0
def _probe(argv: list, timeout: int, ok, *, missing: str | None = None) -> bool:
"""Run a text-mode probe; True when it ran and ``ok(result)`` holds.
A missing executable logs *missing* (when given); every other failure is silent."""
try:
return bool(ok(subprocess.run(argv, timeout=timeout, **_TEXT)))
except FileNotFoundError:
if missing:
logger.debug(missing)
except Exception:
pass
return False
def _linux_backends():
"""(enabled, has_image, save) in Linux fallthrough order: WSL → Wayland → X11."""
return (
(_is_wsl(), _wsl_has_image, _wsl_save),
(bool(os.environ.get("WAYLAND_DISPLAY")), _wayland_has_image, _wayland_save),
(True, _xclip_has_image, _xclip_save),
)
def save_clipboard_image(dest: Path) -> bool:
"""Extract an image from the system clipboard and save it as PNG.
Returns True if an image was found and saved, False otherwise.
"""
dest.parent.mkdir(parents=True, exist_ok=True)
if sys.platform == "darwin":
return _macos_save(dest)
if sys.platform == "win32":
return _windows_save(dest)
return _linux_save(dest)
def has_clipboard_image() -> bool:
"""Quick check: does the clipboard currently contain an image?"""
if sys.platform == "darwin":
return _macos_has_image()
if sys.platform == "win32":
return _windows_has_image()
return any(enabled and has() for enabled, has, _ in _linux_backends())
# ── Text write (native tools, mirrors ui-tui/src/lib/clipboard.ts) ──────
def _powershell_write_script(b64: str) -> str:
# PowerShell decodes piped stdin with the system ANSI code page (e.g.
# CP936), not UTF-8, so stdin-based writes mangle CJK/emoji. Base64 the
# UTF-8 bytes and decode inside PowerShell instead — same approach as
# the TUI's writeClipboardText.
return (
"Set-Clipboard -Value ([System.Text.Encoding]::UTF8.GetString("
f"[System.Convert]::FromBase64String('{b64}')))"
)
def _write_clipboard_commands() -> list:
"""Return (cmd_argv, use_stdin) candidates in platform fallback order."""
if sys.platform == "darwin":
return [(["pbcopy"], True)]
if sys.platform == "win32":
return [(["powershell", "-NoProfile", "-NonInteractive"], False)]
attempts = []
if _is_wsl():
attempts.append((["powershell.exe", "-NoProfile", "-NonInteractive"], False))
if os.environ.get("WAYLAND_DISPLAY"):
attempts.append((["wl-copy", "--type", "text/plain"], True))
attempts.append((["xclip", "-selection", "clipboard", "-in"], True))
attempts.append((["xsel", "--clipboard", "--input"], True))
return attempts
def is_remote_shell_session(env=None) -> bool:
"""True when running inside an SSH session.
Mirrors ui-tui/src/lib/terminalSetup.ts isRemoteShellSession(). Over SSH, native clipboard tools
write the REMOTE machine's clipboard (or an X-forwarded one), which is almost never what the
user wants — OSC 52 reaches the LOCAL terminal emulator instead.
"""
e = os.environ if env is None else env
return bool(
e.get("SSH_CONNECTION") or e.get("SSH_TTY") or e.get("SSH_CLIENT")
)
def write_clipboard_text(text: str) -> bool:
"""Write *text* to the system clipboard via native platform tools.
Fallback order matches the TUI (ui-tui/src/lib/clipboard.ts): macOS pbcopy → Windows/WSL
PowerShell Set-Clipboard → wl-copy → xclip → xsel. Returns True if any backend succeeded;
callers should fall back to OSC 52 on False.
"""
data = text.encode("utf-8")
quiet = dict(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for argv, use_stdin in _write_clipboard_commands():
try:
if use_stdin:
proc = subprocess.run(argv, input=data, timeout=10, **quiet)
else:
b64 = base64.b64encode(data).decode("ascii")
proc = subprocess.run(
argv + ["-Command", _powershell_write_script(b64)],
stdin=subprocess.DEVNULL, timeout=10, **quiet,
)
if proc.returncode == 0:
return True
except (OSError, subprocess.SubprocessError):
continue
return False
# ── macOS ────────────────────────────────────────────────────────────────
def _macos_save(dest: Path) -> bool:
"""Try pngpaste first (fast, handles more formats), fall back to osascript."""
return _macos_pngpaste(dest) or _macos_osascript(dest)
def _macos_has_image() -> bool:
"""Check if macOS clipboard contains image data."""
return _probe(["osascript", "-e", "clipboard info"], 3,
lambda r: "«class PNGf»" in r.stdout or "«class TIFF»" in r.stdout)
def _macos_pngpaste(dest: Path) -> bool:
"""Use pngpaste (brew install pngpaste) — fastest, cleanest."""
try:
r = subprocess.run(["pngpaste", str(dest)], capture_output=True, timeout=3)
if r.returncode == 0 and _nonempty(dest):
return True
except FileNotFoundError:
pass # pngpaste not installed
except Exception as e:
logger.debug("pngpaste failed: %s", e)
return False
def _macos_osascript(dest: Path) -> bool:
"""Use osascript to extract PNG data from clipboard (always available)."""
if not _macos_has_image():
return False
# Extract as PNG
script = (
'try\n'
' set imgData to the clipboard as «class PNGf»\n'
f' set f to open for access POSIX file "{dest}" with write permission\n'
' write imgData to f\n'
' close access f\n'
'on error\n'
' return "fail"\n'
'end try\n'
)
try:
r = subprocess.run(["osascript", "-e", script], timeout=5, **_TEXT)
if r.returncode == 0 and "fail" not in r.stdout and _nonempty(dest):
return True
except Exception as e:
logger.debug("osascript clipboard extract failed: %s", e)
return False
# ── Shared PowerShell scripts (native Windows + WSL2) ─────────────────────
# .NET System.Windows.Forms.Clipboard — used by both native Windows (powershell)
# and WSL2 (powershell.exe) paths.
_PS_CHECK_IMAGE = (
"Add-Type -AssemblyName System.Windows.Forms;"
"[System.Windows.Forms.Clipboard]::ContainsImage()"
)
_PS_EXTRACT_IMAGE = (
"Add-Type -AssemblyName System.Windows.Forms;"
"Add-Type -AssemblyName System.Drawing;"
"$img = [System.Windows.Forms.Clipboard]::GetImage();"
"if ($null -eq $img) { exit 1 }"
"$ms = New-Object System.IO.MemoryStream;"
"$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png);"
"[System.Convert]::ToBase64String($ms.ToArray())"
)
_PS_CHECK_IMAGE_GET_CLIPBOARD = (
"try { "
"$img = Get-Clipboard -Format Image -ErrorAction Stop;"
"if ($null -ne $img) { 'True' } else { 'False' }"
"} catch { 'False' }"
)
_PS_EXTRACT_IMAGE_GET_CLIPBOARD = (
"try { "
"Add-Type -AssemblyName System.Drawing;"
"Add-Type -AssemblyName PresentationCore;"
"Add-Type -AssemblyName WindowsBase;"
"$img = Get-Clipboard -Format Image -ErrorAction Stop;"
"if ($null -eq $img) { exit 1 }"
"$ms = New-Object System.IO.MemoryStream;"
"if ($img -is [System.Drawing.Image]) {"
"$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)"
"} elseif ($img -is [System.Windows.Media.Imaging.BitmapSource]) {"
"$enc = New-Object System.Windows.Media.Imaging.PngBitmapEncoder;"
"$enc.Frames.Add([System.Windows.Media.Imaging.BitmapFrame]::Create($img));"
"$enc.Save($ms)"
"} else { exit 2 }"
"[System.Convert]::ToBase64String($ms.ToArray())"
"} catch { exit 1 }"
)
_FILEDROP_IMAGE_EXTS = "'.png','.jpg','.jpeg','.gif','.webp','.bmp','.tiff','.tif'"
_PS_FILEDROP_HIT = (
"try { "
"$files = Get-Clipboard -Format FileDropList -ErrorAction Stop;"
f"$exts = @({_FILEDROP_IMAGE_EXTS});"
"$hit = $files | Where-Object { $exts -contains ([System.IO.Path]::GetExtension($_).ToLowerInvariant()) } | Select-Object -First 1;"
)
_PS_CHECK_FILEDROP_IMAGE = (
_PS_FILEDROP_HIT
+ "if ($null -ne $hit) { 'True' } else { 'False' }"
"} catch { 'False' }"
)
_PS_EXTRACT_FILEDROP_IMAGE = (
_PS_FILEDROP_HIT
+ "if ($null -eq $hit) { exit 1 }"
"[System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($hit))"
"} catch { exit 1 }"
)
_POWERSHELL_HAS_IMAGE_SCRIPTS = (
_PS_CHECK_IMAGE,
_PS_CHECK_IMAGE_GET_CLIPBOARD,
_PS_CHECK_FILEDROP_IMAGE,
)
_POWERSHELL_EXTRACT_IMAGE_SCRIPTS = (
_PS_EXTRACT_IMAGE,
_PS_EXTRACT_IMAGE_GET_CLIPBOARD,
_PS_EXTRACT_FILEDROP_IMAGE,
)
def _run_powershell(exe: str, script: str, timeout: int) -> subprocess.CompletedProcess:
return subprocess.run(
[exe, "-NoProfile", "-NonInteractive", "-Command", script], timeout=timeout, **_TEXT,
)
def _write_base64_image(dest: Path, b64_data: str) -> bool:
dest.write_bytes(base64.b64decode(b64_data, validate=True))
return _nonempty(dest)
def _powershell_has_image(exe: str, *, timeout: int, label: str) -> bool:
for script in _POWERSHELL_HAS_IMAGE_SCRIPTS:
try:
r = _run_powershell(exe, script, timeout=timeout)
if r.returncode == 0 and "True" in r.stdout:
return True
except FileNotFoundError:
logger.debug("%s not found — clipboard unavailable", exe)
return False
except Exception as e:
logger.debug("%s clipboard image check failed: %s", label, e)
return False
def _powershell_save_image(exe: str, dest: Path, *, timeout: int, label: str) -> bool:
for script in _POWERSHELL_EXTRACT_IMAGE_SCRIPTS:
try:
r = _run_powershell(exe, script, timeout=timeout)
if r.returncode != 0:
continue
b64_data = r.stdout.strip()
if b64_data and _write_base64_image(dest, b64_data):
return True
except FileNotFoundError:
logger.debug("%s not found — clipboard unavailable", exe)
return False
except Exception as e:
logger.debug("%s clipboard image extraction failed: %s", label, e)
dest.unlink(missing_ok=True)
return False
# ── Native Windows ────────────────────────────────────────────────────────
# Native Windows uses ``powershell`` (Windows PowerShell 5.1, always present)
# or ``pwsh`` (PowerShell 7+, optional). Discovery is cached per-process.
def _find_powershell() -> str | None:
"""Return the first available PowerShell executable, or None."""
for name in ("powershell", "pwsh"):
try:
r = _run_powershell(name, "echo ok", timeout=5)
if r.returncode == 0 and "ok" in r.stdout:
return name
except Exception:
continue
return None
# Cache the resolved PowerShell executable (checked once per process)
_ps_exe: str | None | bool = False # False = not yet checked
def _get_ps_exe() -> str | None:
global _ps_exe
if _ps_exe is False:
_ps_exe = _find_powershell()
return _ps_exe
def _windows_has_image() -> bool:
"""Check if the Windows clipboard contains an image."""
ps = _get_ps_exe()
if ps is None:
return False
return _powershell_has_image(ps, timeout=5, label="Windows")
def _windows_save(dest: Path) -> bool:
"""Extract clipboard image on native Windows via PowerShell → base64 PNG."""
ps = _get_ps_exe()
if ps is None:
logger.debug("No PowerShell found — Windows clipboard image paste unavailable")
return False
return _powershell_save_image(ps, dest, timeout=15, label="Windows")
# ── Linux ────────────────────────────────────────────────────────────────
def _linux_save(dest: Path) -> bool:
"""Try clipboard backends in priority order: WSL → Wayland → X11
(a failed WSL probe falls through — WSLg might have wl-paste or xclip working)."""
return any(enabled and save(dest) for enabled, _, save in _linux_backends())
# ── WSL2 (powershell.exe) ────────────────────────────────────────────────
# Reuses _PS_CHECK_IMAGE / _PS_EXTRACT_IMAGE defined above.
def _wsl_has_image() -> bool:
"""Check if Windows clipboard has an image (via powershell.exe)."""
return _powershell_has_image("powershell.exe", timeout=8, label="WSL")
def _wsl_save(dest: Path) -> bool:
"""Extract clipboard image via powershell.exe → base64 → decode to PNG."""
return _powershell_save_image("powershell.exe", dest, timeout=15, label="WSL")
# ── Wayland (wl-paste) ──────────────────────────────────────────────────
_WAYLAND_MIME_PREFERENCE = ("image/png", "image/jpeg", "image/bmp", "image/gif", "image/webp")
_WL_LIST_TYPES = ["wl-paste", "--list-types"]
_WL_MISSING = "wl-paste not installed — Wayland clipboard unavailable"
def _wayland_has_image() -> bool:
"""Check if Wayland clipboard has image content."""
return _probe(_WL_LIST_TYPES, 3, lambda r: r.returncode == 0 and any(
t.startswith("image/") for t in r.stdout.splitlines()), missing=_WL_MISSING)
def _pipe_to_file(argv: list, dest: Path) -> bool:
"""Run *argv* with stdout redirected into *dest*; True when a non-empty file resulted."""
with open(dest, "wb") as f:
subprocess.run(argv, stdout=f, stderr=subprocess.DEVNULL, timeout=5, check=True)
return _nonempty(dest)
def _wayland_save(dest: Path) -> bool:
"""Use wl-paste to extract clipboard image (Wayland sessions)."""
try:
types_r = subprocess.run(_WL_LIST_TYPES, timeout=3, **_TEXT)
if types_r.returncode != 0:
return False
types = types_r.stdout.splitlines()
# Prefer PNG, fall back to other image formats
mime = next((m for m in _WAYLAND_MIME_PREFERENCE if m in types), None)
if not mime:
return False
if not _pipe_to_file(["wl-paste", "--type", mime], dest):
dest.unlink(missing_ok=True)
return False
# save_clipboard_image() promises a PNG output path. Wayland can offer
# JPEG/GIF/WebP/BMP payloads, so normalize every non-PNG result before
# returning success.
if mime != "image/png" and (not _convert_to_png(dest) or not _is_png_file(dest)):
dest.unlink(missing_ok=True)
return False
return True
except FileNotFoundError:
logger.debug(_WL_MISSING)
except Exception as e:
logger.debug("wl-paste clipboard extraction failed: %s", e)
dest.unlink(missing_ok=True)
return False
def _convert_to_png(path: Path) -> bool:
"""Convert an image file to PNG in-place (requires Pillow or ImageMagick)."""
# Try Pillow first (likely installed in the venv)
try:
from PIL import Image
img = Image.open(path)
img.save(path, "PNG")
return True
except ImportError:
pass
except Exception as e:
logger.debug("Pillow BMP→PNG conversion failed: %s", e)
# Fall back to ImageMagick convert
tmp = path.with_suffix(".bmp")
try:
path.rename(tmp)
r = subprocess.run(
["convert", str(tmp), "png:" + str(path)],
capture_output=True, timeout=5,
)
if r.returncode == 0 and _nonempty(path):
tmp.unlink(missing_ok=True)
return True
# Convert failed — restore the original file
tmp.rename(path)
except Exception as e:
if isinstance(e, FileNotFoundError):
logger.debug("ImageMagick not installed — cannot convert BMP to PNG")
else:
logger.debug("ImageMagick BMP→PNG conversion failed: %s", e)
if tmp.exists() and not path.exists():
tmp.rename(path)
# Can't convert — BMP is still usable as-is for most APIs
return _nonempty(path)
def _is_png_file(path: Path) -> bool:
"""Return True when *path* starts with the PNG file signature."""
try:
with path.open("rb") as f:
return f.read(len(_PNG_SIGNATURE)) == _PNG_SIGNATURE
except OSError:
return False
# ── X11 (xclip) ─────────────────────────────────────────────────────────
_XCLIP_TARGETS = ["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"]
def _xclip_has_image() -> bool:
"""Check if X11 clipboard has image content."""
return _probe(_XCLIP_TARGETS, 3, lambda r: r.returncode == 0 and "image/png" in r.stdout)
def _xclip_save(dest: Path) -> bool:
"""Use xclip to extract clipboard image (X11 sessions)."""
# Check if clipboard has image content
if not _probe(_XCLIP_TARGETS, 3, lambda r: "image/png" in r.stdout,
missing="xclip not installed — X11 clipboard image paste unavailable"):
return False
# Extract PNG data
try:
if _pipe_to_file(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], dest):
return True
except Exception as e:
logger.debug("xclip image extraction failed: %s", e)
dest.unlink(missing_ok=True)
return False