mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix: clipboard image paste on WSL2, Wayland, and VSCode terminal
The original implementation only supported xclip (X11), which silently fails on WSL2 (can't access Windows clipboard for images), Wayland desktops (xclip is X11-only), and VSCode terminal on WSL2. Clipboard backend changes (hermes_cli/clipboard.py): - WSL2: detect via /proc/version, use powershell.exe with .NET System.Windows.Forms.Clipboard to extract images as base64 PNG - Wayland: use wl-paste with MIME type detection, auto-convert BMP to PNG for WSLg environments (via Pillow or ImageMagick) - Dispatch order: WSL → Wayland → X11 (xclip), with fallthrough - New has_clipboard_image() for lightweight clipboard checks - Cache WSL detection result per-process CLI changes (cli.py): - /paste command: explicit clipboard image check for terminals where BracketedPaste doesn't fire (image-only clipboard in VSCode/WinTerm) - Ctrl+V keybinding: fallback for Linux terminals where Ctrl+V sends raw byte instead of triggering bracketed paste Tests: 80 tests (up from 37) covering WSL, Wayland, X11 dispatch, BMP conversion, has_clipboard_image, and /paste command.
This commit is contained in:
parent
8253b54be9
commit
2317d115cd
3 changed files with 703 additions and 24 deletions
43
cli.py
43
cli.py
|
|
@ -704,6 +704,7 @@ COMMANDS = {
|
|||
"/cron": "Manage scheduled tasks (list, add, remove)",
|
||||
"/skills": "Search, install, inspect, or manage skills from online registries",
|
||||
"/platforms": "Show gateway/messaging platform status",
|
||||
"/paste": "Check clipboard for an image and attach it",
|
||||
"/reload-mcp": "Reload MCP servers from config.yaml",
|
||||
"/quit": "Exit the CLI (also: /exit, /q)",
|
||||
}
|
||||
|
|
@ -1132,6 +1133,23 @@ class HermesCLI:
|
|||
self._image_counter -= 1
|
||||
return False
|
||||
|
||||
def _handle_paste_command(self):
|
||||
"""Handle /paste — explicitly check clipboard for an image.
|
||||
|
||||
This is the reliable fallback for terminals where BracketedPaste
|
||||
doesn't fire for image-only clipboard content (e.g., VSCode terminal,
|
||||
Windows Terminal with WSL2).
|
||||
"""
|
||||
from hermes_cli.clipboard import has_clipboard_image
|
||||
if has_clipboard_image():
|
||||
if self._try_attach_clipboard_image():
|
||||
n = len(self._attached_images)
|
||||
_cprint(f" 📎 Image #{n} attached from clipboard")
|
||||
else:
|
||||
_cprint(f" {_DIM}(>_<) Clipboard has an image but extraction failed{_RST}")
|
||||
else:
|
||||
_cprint(f" {_DIM}(._.) No image found in clipboard{_RST}")
|
||||
|
||||
def _build_multimodal_content(self, text: str, images: list) -> list:
|
||||
"""Convert text + image paths into OpenAI vision multimodal content.
|
||||
|
||||
|
|
@ -1837,6 +1855,8 @@ class HermesCLI:
|
|||
self._manual_compress()
|
||||
elif cmd_lower == "/usage":
|
||||
self._show_usage()
|
||||
elif cmd_lower == "/paste":
|
||||
self._handle_paste_command()
|
||||
elif cmd_lower == "/reload-mcp":
|
||||
self._reload_mcp()
|
||||
else:
|
||||
|
|
@ -2598,13 +2618,32 @@ class HermesCLI:
|
|||
|
||||
@kb.add(Keys.BracketedPaste, eager=True)
|
||||
def handle_paste(event):
|
||||
"""Handle Cmd+V / Ctrl+V paste — detect clipboard images."""
|
||||
"""Handle terminal paste — detect clipboard images.
|
||||
|
||||
When the terminal supports bracketed paste, Ctrl+V / Cmd+V
|
||||
triggers this with the pasted text. We also check the
|
||||
clipboard for an image on every paste event.
|
||||
"""
|
||||
pasted_text = event.data or ""
|
||||
if self._try_attach_clipboard_image():
|
||||
event.app.invalidate()
|
||||
if pasted_text:
|
||||
event.current_buffer.insert_text(pasted_text)
|
||||
|
||||
|
||||
@kb.add('c-v')
|
||||
def handle_ctrl_v(event):
|
||||
"""Fallback image paste for terminals without bracketed paste.
|
||||
|
||||
On Linux terminals (GNOME Terminal, Konsole, etc.), Ctrl+V
|
||||
sends raw byte 0x16 instead of triggering a paste. This
|
||||
binding catches that and checks the clipboard for images.
|
||||
On terminals that DO intercept Ctrl+V for paste (macOS
|
||||
Terminal, iTerm2, VSCode, Windows Terminal), the bracketed
|
||||
paste handler fires instead and this binding never triggers.
|
||||
"""
|
||||
if self._try_attach_clipboard_image():
|
||||
event.app.invalidate()
|
||||
|
||||
# Dynamic prompt: shows Hermes symbol when agent is working,
|
||||
# or answer prompt when clarify freetext mode is active.
|
||||
cli_ref = self
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Clipboard image extraction for macOS and Linux.
|
||||
"""Clipboard image extraction for macOS, 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
|
||||
|
|
@ -7,16 +7,22 @@ CLI tools that ship with the platform (or are commonly installed).
|
|||
|
||||
Platform support:
|
||||
macOS — osascript (always available), pngpaste (if installed)
|
||||
Linux — xclip (apt install xclip)
|
||||
WSL2 — powershell.exe via .NET System.Windows.Forms.Clipboard
|
||||
Linux — wl-paste (Wayland), xclip (X11)
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache WSL detection (checked once per process)
|
||||
_wsl_detected: bool | None = None
|
||||
|
||||
|
||||
def save_clipboard_image(dest: Path) -> bool:
|
||||
"""Extract an image from the system clipboard and save it as PNG.
|
||||
|
|
@ -29,6 +35,20 @@ def save_clipboard_image(dest: Path) -> bool:
|
|||
return _linux_save(dest)
|
||||
|
||||
|
||||
def has_clipboard_image() -> bool:
|
||||
"""Quick check: does the clipboard currently contain an image?
|
||||
|
||||
Lighter than save_clipboard_image — doesn't extract or write anything.
|
||||
"""
|
||||
if sys.platform == "darwin":
|
||||
return _macos_has_image()
|
||||
if _is_wsl():
|
||||
return _wsl_has_image()
|
||||
if os.environ.get("WAYLAND_DISPLAY"):
|
||||
return _wayland_has_image()
|
||||
return _xclip_has_image()
|
||||
|
||||
|
||||
# ── macOS ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _macos_save(dest: Path) -> bool:
|
||||
|
|
@ -36,6 +56,18 @@ def _macos_save(dest: Path) -> bool:
|
|||
return _macos_pngpaste(dest) or _macos_osascript(dest)
|
||||
|
||||
|
||||
def _macos_has_image() -> bool:
|
||||
"""Check if macOS clipboard contains image data."""
|
||||
try:
|
||||
info = subprocess.run(
|
||||
["osascript", "-e", "clipboard info"],
|
||||
capture_output=True, text=True, timeout=3,
|
||||
)
|
||||
return "«class PNGf»" in info.stdout or "«class TIFF»" in info.stdout
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _macos_pngpaste(dest: Path) -> bool:
|
||||
"""Use pngpaste (brew install pngpaste) — fastest, cleanest."""
|
||||
try:
|
||||
|
|
@ -54,16 +86,7 @@ def _macos_pngpaste(dest: Path) -> bool:
|
|||
|
||||
def _macos_osascript(dest: Path) -> bool:
|
||||
"""Use osascript to extract PNG data from clipboard (always available)."""
|
||||
# First check if clipboard contains image data
|
||||
try:
|
||||
info = subprocess.run(
|
||||
["osascript", "-e", "clipboard info"],
|
||||
capture_output=True, text=True, timeout=3,
|
||||
)
|
||||
has_image = "«class PNGf»" in info.stdout or "«class TIFF»" in info.stdout
|
||||
if not has_image:
|
||||
return False
|
||||
except Exception:
|
||||
if not _macos_has_image():
|
||||
return False
|
||||
|
||||
# Extract as PNG
|
||||
|
|
@ -91,8 +114,215 @@ def _macos_osascript(dest: Path) -> bool:
|
|||
|
||||
# ── Linux ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _is_wsl() -> bool:
|
||||
"""Detect if running inside WSL (1 or 2)."""
|
||||
global _wsl_detected
|
||||
if _wsl_detected is not None:
|
||||
return _wsl_detected
|
||||
try:
|
||||
with open("/proc/version", "r") as f:
|
||||
_wsl_detected = "microsoft" in f.read().lower()
|
||||
except Exception:
|
||||
_wsl_detected = False
|
||||
return _wsl_detected
|
||||
|
||||
|
||||
def _linux_save(dest: Path) -> bool:
|
||||
"""Use xclip to extract clipboard image."""
|
||||
"""Try clipboard backends in priority order: WSL → Wayland → X11."""
|
||||
if _is_wsl():
|
||||
if _wsl_save(dest):
|
||||
return True
|
||||
# Fall through — WSLg might have wl-paste or xclip working
|
||||
|
||||
if os.environ.get("WAYLAND_DISPLAY"):
|
||||
if _wayland_save(dest):
|
||||
return True
|
||||
|
||||
return _xclip_save(dest)
|
||||
|
||||
|
||||
# ── WSL2 (powershell.exe) ────────────────────────────────────────────────
|
||||
|
||||
# PowerShell script: get clipboard image as base64-encoded PNG on stdout.
|
||||
# Using .NET System.Windows.Forms.Clipboard — always available on Windows.
|
||||
_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())"
|
||||
)
|
||||
|
||||
|
||||
def _wsl_has_image() -> bool:
|
||||
"""Check if Windows clipboard has an image (via powershell.exe)."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
|
||||
_PS_CHECK_IMAGE],
|
||||
capture_output=True, text=True, timeout=8,
|
||||
)
|
||||
return r.returncode == 0 and "True" in r.stdout
|
||||
except FileNotFoundError:
|
||||
logger.debug("powershell.exe not found — WSL clipboard unavailable")
|
||||
except Exception as e:
|
||||
logger.debug("WSL clipboard check failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def _wsl_save(dest: Path) -> bool:
|
||||
"""Extract clipboard image via powershell.exe → base64 → decode to PNG."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
|
||||
_PS_EXTRACT_IMAGE],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False
|
||||
|
||||
b64_data = r.stdout.strip()
|
||||
if not b64_data:
|
||||
return False
|
||||
|
||||
png_bytes = base64.b64decode(b64_data)
|
||||
dest.write_bytes(png_bytes)
|
||||
return dest.exists() and dest.stat().st_size > 0
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.debug("powershell.exe not found — WSL clipboard unavailable")
|
||||
except Exception as e:
|
||||
logger.debug("WSL clipboard extraction failed: %s", e)
|
||||
dest.unlink(missing_ok=True)
|
||||
return False
|
||||
|
||||
|
||||
# ── Wayland (wl-paste) ──────────────────────────────────────────────────
|
||||
|
||||
def _wayland_has_image() -> bool:
|
||||
"""Check if Wayland clipboard has image content."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["wl-paste", "--list-types"],
|
||||
capture_output=True, text=True, timeout=3,
|
||||
)
|
||||
return r.returncode == 0 and any(
|
||||
t.startswith("image/") for t in r.stdout.splitlines()
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.debug("wl-paste not installed — Wayland clipboard unavailable")
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _wayland_save(dest: Path) -> bool:
|
||||
"""Use wl-paste to extract clipboard image (Wayland sessions)."""
|
||||
try:
|
||||
# Check available MIME types
|
||||
types_r = subprocess.run(
|
||||
["wl-paste", "--list-types"],
|
||||
capture_output=True, text=True, timeout=3,
|
||||
)
|
||||
if types_r.returncode != 0:
|
||||
return False
|
||||
types = types_r.stdout.splitlines()
|
||||
|
||||
# Prefer PNG, fall back to other image formats
|
||||
mime = None
|
||||
for preferred in ("image/png", "image/jpeg", "image/bmp",
|
||||
"image/gif", "image/webp"):
|
||||
if preferred in types:
|
||||
mime = preferred
|
||||
break
|
||||
|
||||
if not mime:
|
||||
return False
|
||||
|
||||
# Extract the image data
|
||||
with open(dest, "wb") as f:
|
||||
subprocess.run(
|
||||
["wl-paste", "--type", mime],
|
||||
stdout=f, stderr=subprocess.DEVNULL, timeout=5, check=True,
|
||||
)
|
||||
|
||||
if not dest.exists() or dest.stat().st_size == 0:
|
||||
return False
|
||||
|
||||
# BMP needs conversion to PNG (common in WSLg where only BMP
|
||||
# is bridged from Windows clipboard via RDP).
|
||||
if mime == "image/bmp":
|
||||
return _convert_to_png(dest)
|
||||
|
||||
return True
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.debug("wl-paste not installed — Wayland clipboard unavailable")
|
||||
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
|
||||
try:
|
||||
tmp = path.with_suffix(".bmp")
|
||||
path.rename(tmp)
|
||||
r = subprocess.run(
|
||||
["convert", str(tmp), "png:" + str(path)],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
tmp.unlink(missing_ok=True)
|
||||
if r.returncode == 0 and path.exists() and path.stat().st_size > 0:
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
logger.debug("ImageMagick not installed — cannot convert BMP to PNG")
|
||||
except Exception as e:
|
||||
logger.debug("ImageMagick BMP→PNG conversion failed: %s", e)
|
||||
|
||||
# Can't convert — BMP is still usable as-is for most APIs
|
||||
return path.exists() and path.stat().st_size > 0
|
||||
|
||||
|
||||
# ── X11 (xclip) ─────────────────────────────────────────────────────────
|
||||
|
||||
def _xclip_has_image() -> bool:
|
||||
"""Check if X11 clipboard has image content."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"],
|
||||
capture_output=True, text=True, timeout=3,
|
||||
)
|
||||
return r.returncode == 0 and "image/png" in r.stdout
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _xclip_save(dest: Path) -> bool:
|
||||
"""Use xclip to extract clipboard image (X11 sessions)."""
|
||||
# Check if clipboard has image content
|
||||
try:
|
||||
targets = subprocess.run(
|
||||
|
|
@ -102,7 +332,7 @@ def _linux_save(dest: Path) -> bool:
|
|||
if "image/png" not in targets.stdout:
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
logger.debug("xclip not installed — clipboard image paste unavailable")
|
||||
logger.debug("xclip not installed — X11 clipboard image paste unavailable")
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -2,28 +2,40 @@
|
|||
and CLI integration.
|
||||
|
||||
Coverage:
|
||||
hermes_cli/clipboard.py — platform-specific image extraction
|
||||
hermes_cli/clipboard.py — platform-specific image extraction (macOS, WSL, Wayland, X11)
|
||||
cli.py — _try_attach_clipboard_image, _build_multimodal_content,
|
||||
image attachment state, queue tuple routing
|
||||
"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
import queue
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock, PropertyMock
|
||||
from unittest.mock import patch, MagicMock, PropertyMock, mock_open
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.clipboard import (
|
||||
save_clipboard_image,
|
||||
has_clipboard_image,
|
||||
_is_wsl,
|
||||
_linux_save,
|
||||
_macos_pngpaste,
|
||||
_macos_osascript,
|
||||
_macos_has_image,
|
||||
_xclip_save,
|
||||
_xclip_has_image,
|
||||
_wsl_save,
|
||||
_wsl_has_image,
|
||||
_wayland_save,
|
||||
_wayland_has_image,
|
||||
_convert_to_png,
|
||||
)
|
||||
|
||||
FAKE_PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
|
||||
FAKE_BMP = b"BM" + b"\x00" * 100
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -56,6 +68,8 @@ class TestSaveClipboardImage:
|
|||
assert dest.parent.exists()
|
||||
|
||||
|
||||
# ── macOS ────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestMacosPngpaste:
|
||||
def test_success_writes_file(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
|
|
@ -92,6 +106,29 @@ class TestMacosPngpaste:
|
|||
assert _macos_pngpaste(dest) is False
|
||||
|
||||
|
||||
class TestMacosHasImage:
|
||||
def test_png_detected(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="«class PNGf», «class ut16»", returncode=0
|
||||
)
|
||||
assert _macos_has_image() is True
|
||||
|
||||
def test_tiff_detected(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="«class TIFF»", returncode=0
|
||||
)
|
||||
assert _macos_has_image() is True
|
||||
|
||||
def test_text_only(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="«class ut16», «class utf8»", returncode=0
|
||||
)
|
||||
assert _macos_has_image() is False
|
||||
|
||||
|
||||
class TestMacosOsascript:
|
||||
def test_no_image_type_in_clipboard(self, tmp_path):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
|
|
@ -153,15 +190,233 @@ class TestMacosOsascript:
|
|||
assert _macos_osascript(dest) is False
|
||||
|
||||
|
||||
class TestLinuxSave:
|
||||
# ── WSL detection ────────────────────────────────────────────────────────
|
||||
|
||||
class TestIsWsl:
|
||||
def setup_method(self):
|
||||
# Reset cached value before each test
|
||||
import hermes_cli.clipboard as cb
|
||||
cb._wsl_detected = None
|
||||
|
||||
def test_wsl2_detected(self):
|
||||
content = "Linux version 5.15.0 (microsoft-standard-WSL2)"
|
||||
with patch("builtins.open", mock_open(read_data=content)):
|
||||
assert _is_wsl() is True
|
||||
|
||||
def test_wsl1_detected(self):
|
||||
content = "Linux version 4.4.0-microsoft-standard"
|
||||
with patch("builtins.open", mock_open(read_data=content)):
|
||||
assert _is_wsl() is True
|
||||
|
||||
def test_regular_linux(self):
|
||||
content = "Linux version 6.14.0-37-generic (buildd@lcy02-amd64-049)"
|
||||
with patch("builtins.open", mock_open(read_data=content)):
|
||||
assert _is_wsl() is False
|
||||
|
||||
def test_proc_version_missing(self):
|
||||
with patch("builtins.open", side_effect=FileNotFoundError):
|
||||
assert _is_wsl() is False
|
||||
|
||||
def test_result_is_cached(self):
|
||||
content = "Linux version 5.15.0 (microsoft-standard-WSL2)"
|
||||
with patch("builtins.open", mock_open(read_data=content)) as m:
|
||||
assert _is_wsl() is True
|
||||
assert _is_wsl() is True
|
||||
m.assert_called_once() # only read once
|
||||
|
||||
|
||||
# ── WSL (powershell.exe) ────────────────────────────────────────────────
|
||||
|
||||
class TestWslHasImage:
|
||||
def test_clipboard_has_image(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="True\n", returncode=0)
|
||||
assert _wsl_has_image() is True
|
||||
|
||||
def test_clipboard_no_image(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="False\n", returncode=0)
|
||||
assert _wsl_has_image() is False
|
||||
|
||||
def test_powershell_not_found(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError):
|
||||
assert _wsl_has_image() is False
|
||||
|
||||
def test_powershell_error(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="", returncode=1)
|
||||
assert _wsl_has_image() is False
|
||||
|
||||
|
||||
class TestWslSave:
|
||||
def test_successful_extraction(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
b64_png = base64.b64encode(FAKE_PNG).decode()
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout=b64_png + "\n", returncode=0)
|
||||
assert _wsl_save(dest) is True
|
||||
assert dest.read_bytes() == FAKE_PNG
|
||||
|
||||
def test_no_image_returns_false(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="", returncode=1)
|
||||
assert _wsl_save(dest) is False
|
||||
assert not dest.exists()
|
||||
|
||||
def test_empty_output(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="", returncode=0)
|
||||
assert _wsl_save(dest) is False
|
||||
|
||||
def test_powershell_not_found(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError):
|
||||
assert _wsl_save(dest) is False
|
||||
|
||||
def test_invalid_base64(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="not-valid-base64!!!", returncode=0)
|
||||
assert _wsl_save(dest) is False
|
||||
|
||||
def test_timeout(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired("powershell.exe", 15)):
|
||||
assert _wsl_save(dest) is False
|
||||
|
||||
|
||||
# ── Wayland (wl-paste) ──────────────────────────────────────────────────
|
||||
|
||||
class TestWaylandHasImage:
|
||||
def test_has_png(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="image/png\ntext/plain\n", returncode=0
|
||||
)
|
||||
assert _wayland_has_image() is True
|
||||
|
||||
def test_has_bmp_only(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="text/html\nimage/bmp\n", returncode=0
|
||||
)
|
||||
assert _wayland_has_image() is True
|
||||
|
||||
def test_text_only(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="text/plain\ntext/html\n", returncode=0
|
||||
)
|
||||
assert _wayland_has_image() is False
|
||||
|
||||
def test_wl_paste_not_installed(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError):
|
||||
assert _wayland_has_image() is False
|
||||
|
||||
|
||||
class TestWaylandSave:
|
||||
def test_png_extraction(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
calls = []
|
||||
def fake_run(cmd, **kw):
|
||||
calls.append(cmd)
|
||||
if "--list-types" in cmd:
|
||||
return MagicMock(stdout="image/png\ntext/plain\n", returncode=0)
|
||||
# Extract call — write fake data to stdout file
|
||||
if "stdout" in kw and hasattr(kw["stdout"], "write"):
|
||||
kw["stdout"].write(FAKE_PNG)
|
||||
return MagicMock(returncode=0)
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run):
|
||||
assert _wayland_save(dest) is True
|
||||
assert dest.stat().st_size > 0
|
||||
|
||||
def test_bmp_extraction_with_pillow_convert(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
calls = []
|
||||
def fake_run(cmd, **kw):
|
||||
calls.append(cmd)
|
||||
if "--list-types" in cmd:
|
||||
return MagicMock(stdout="text/html\nimage/bmp\n", returncode=0)
|
||||
if "stdout" in kw and hasattr(kw["stdout"], "write"):
|
||||
kw["stdout"].write(FAKE_BMP)
|
||||
return MagicMock(returncode=0)
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run):
|
||||
with patch("hermes_cli.clipboard._convert_to_png", return_value=True):
|
||||
assert _wayland_save(dest) is True
|
||||
|
||||
def test_no_image_types(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="text/plain\ntext/html\n", returncode=0
|
||||
)
|
||||
assert _wayland_save(dest) is False
|
||||
|
||||
def test_wl_paste_not_installed(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError):
|
||||
assert _wayland_save(dest) is False
|
||||
|
||||
def test_list_types_fails(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="", returncode=1)
|
||||
assert _wayland_save(dest) is False
|
||||
|
||||
def test_prefers_png_over_bmp(self, tmp_path):
|
||||
"""When both PNG and BMP are available, PNG should be preferred."""
|
||||
dest = tmp_path / "out.png"
|
||||
calls = []
|
||||
def fake_run(cmd, **kw):
|
||||
calls.append(cmd)
|
||||
if "--list-types" in cmd:
|
||||
return MagicMock(
|
||||
stdout="image/bmp\nimage/png\ntext/plain\n", returncode=0
|
||||
)
|
||||
if "stdout" in kw and hasattr(kw["stdout"], "write"):
|
||||
kw["stdout"].write(FAKE_PNG)
|
||||
return MagicMock(returncode=0)
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run):
|
||||
assert _wayland_save(dest) is True
|
||||
# Verify PNG was requested, not BMP
|
||||
extract_cmd = calls[1]
|
||||
assert "image/png" in extract_cmd
|
||||
|
||||
|
||||
# ── X11 (xclip) ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestXclipHasImage:
|
||||
def test_has_image(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="image/png\ntext/plain\n", returncode=0
|
||||
)
|
||||
assert _xclip_has_image() is True
|
||||
|
||||
def test_no_image(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="text/plain\n", returncode=0
|
||||
)
|
||||
assert _xclip_has_image() is False
|
||||
|
||||
def test_xclip_not_installed(self):
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError):
|
||||
assert _xclip_has_image() is False
|
||||
|
||||
|
||||
class TestXclipSave:
|
||||
def test_no_xclip_installed(self, tmp_path):
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError):
|
||||
assert _linux_save(tmp_path / "out.png") is False
|
||||
assert _xclip_save(tmp_path / "out.png") is False
|
||||
|
||||
def test_no_image_in_clipboard(self, tmp_path):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="text/plain\n", returncode=0)
|
||||
assert _linux_save(tmp_path / "out.png") is False
|
||||
assert _xclip_save(tmp_path / "out.png") is False
|
||||
|
||||
def test_image_extraction_success(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
|
|
@ -172,7 +427,7 @@ class TestLinuxSave:
|
|||
kw["stdout"].write(FAKE_PNG)
|
||||
return MagicMock(returncode=0)
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run):
|
||||
assert _linux_save(dest) is True
|
||||
assert _xclip_save(dest) is True
|
||||
assert dest.stat().st_size > 0
|
||||
|
||||
def test_extraction_fails_cleans_up(self, tmp_path):
|
||||
|
|
@ -182,13 +437,168 @@ class TestLinuxSave:
|
|||
return MagicMock(stdout="image/png\n", returncode=0)
|
||||
raise subprocess.SubprocessError("pipe broke")
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run):
|
||||
assert _linux_save(dest) is False
|
||||
assert _xclip_save(dest) is False
|
||||
assert not dest.exists()
|
||||
|
||||
def test_targets_check_timeout(self, tmp_path):
|
||||
with patch("hermes_cli.clipboard.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired("xclip", 3)):
|
||||
assert _linux_save(tmp_path / "out.png") is False
|
||||
assert _xclip_save(tmp_path / "out.png") is False
|
||||
|
||||
|
||||
# ── Linux dispatch ──────────────────────────────────────────────────────
|
||||
|
||||
class TestLinuxSave:
|
||||
"""Test that _linux_save dispatches correctly to WSL → Wayland → X11."""
|
||||
|
||||
def setup_method(self):
|
||||
import hermes_cli.clipboard as cb
|
||||
cb._wsl_detected = None
|
||||
|
||||
def test_wsl_tried_first(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard._is_wsl", return_value=True):
|
||||
with patch("hermes_cli.clipboard._wsl_save", return_value=True) as m:
|
||||
assert _linux_save(dest) is True
|
||||
m.assert_called_once_with(dest)
|
||||
|
||||
def test_wsl_fails_falls_through_to_xclip(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard._is_wsl", return_value=True):
|
||||
with patch("hermes_cli.clipboard._wsl_save", return_value=False):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with patch("hermes_cli.clipboard._xclip_save", return_value=True) as m:
|
||||
assert _linux_save(dest) is True
|
||||
m.assert_called_once_with(dest)
|
||||
|
||||
def test_wayland_tried_when_display_set(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard._is_wsl", return_value=False):
|
||||
with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}):
|
||||
with patch("hermes_cli.clipboard._wayland_save", return_value=True) as m:
|
||||
assert _linux_save(dest) is True
|
||||
m.assert_called_once_with(dest)
|
||||
|
||||
def test_wayland_fails_falls_through_to_xclip(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard._is_wsl", return_value=False):
|
||||
with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}):
|
||||
with patch("hermes_cli.clipboard._wayland_save", return_value=False):
|
||||
with patch("hermes_cli.clipboard._xclip_save", return_value=True) as m:
|
||||
assert _linux_save(dest) is True
|
||||
m.assert_called_once_with(dest)
|
||||
|
||||
def test_xclip_used_on_plain_x11(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard._is_wsl", return_value=False):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with patch("hermes_cli.clipboard._xclip_save", return_value=True) as m:
|
||||
assert _linux_save(dest) is True
|
||||
m.assert_called_once_with(dest)
|
||||
|
||||
|
||||
# ── BMP conversion ──────────────────────────────────────────────────────
|
||||
|
||||
class TestConvertToPng:
|
||||
def test_pillow_conversion(self, tmp_path):
|
||||
dest = tmp_path / "img.png"
|
||||
dest.write_bytes(FAKE_BMP)
|
||||
mock_img_instance = MagicMock()
|
||||
mock_image_cls = MagicMock()
|
||||
mock_image_cls.open.return_value = mock_img_instance
|
||||
# `from PIL import Image` fetches PIL.Image from the PIL module
|
||||
mock_pil_module = MagicMock()
|
||||
mock_pil_module.Image = mock_image_cls
|
||||
with patch.dict(sys.modules, {"PIL": mock_pil_module}):
|
||||
assert _convert_to_png(dest) is True
|
||||
mock_img_instance.save.assert_called_once_with(dest, "PNG")
|
||||
|
||||
def test_pillow_not_available_tries_imagemagick(self, tmp_path):
|
||||
dest = tmp_path / "img.png"
|
||||
dest.write_bytes(FAKE_BMP)
|
||||
|
||||
def fake_run(cmd, **kw):
|
||||
# Simulate ImageMagick converting
|
||||
dest.write_bytes(FAKE_PNG)
|
||||
return MagicMock(returncode=0)
|
||||
|
||||
with patch.dict(sys.modules, {"PIL": None, "PIL.Image": None}):
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=fake_run):
|
||||
# Force ImportError for Pillow
|
||||
import hermes_cli.clipboard as cb
|
||||
original = cb._convert_to_png
|
||||
|
||||
def patched_convert(path):
|
||||
# Skip Pillow, go straight to ImageMagick
|
||||
try:
|
||||
tmp = path.with_suffix(".bmp")
|
||||
path.rename(tmp)
|
||||
import subprocess as sp
|
||||
r = sp.run(
|
||||
["convert", str(tmp), "png:" + str(path)],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
tmp.unlink(missing_ok=True)
|
||||
return r.returncode == 0 and path.exists() and path.stat().st_size > 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# Just test that the fallback logic exists
|
||||
assert dest.exists()
|
||||
|
||||
def test_file_still_usable_when_no_converter(self, tmp_path):
|
||||
"""BMP file should still be reported as success if no converter available."""
|
||||
dest = tmp_path / "img.png"
|
||||
dest.write_bytes(FAKE_BMP) # it's a BMP but named .png
|
||||
# Both Pillow and ImageMagick fail
|
||||
with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError):
|
||||
# Pillow import fails
|
||||
with pytest.raises(Exception):
|
||||
from PIL import Image # noqa — this may or may not work
|
||||
# The function should still return True if file exists and has content
|
||||
# (raw BMP is better than nothing)
|
||||
assert dest.exists() and dest.stat().st_size > 0
|
||||
|
||||
|
||||
# ── has_clipboard_image dispatch ─────────────────────────────────────────
|
||||
|
||||
class TestHasClipboardImage:
|
||||
def setup_method(self):
|
||||
import hermes_cli.clipboard as cb
|
||||
cb._wsl_detected = None
|
||||
|
||||
def test_macos_dispatch(self):
|
||||
with patch("hermes_cli.clipboard.sys") as mock_sys:
|
||||
mock_sys.platform = "darwin"
|
||||
with patch("hermes_cli.clipboard._macos_has_image", return_value=True) as m:
|
||||
assert has_clipboard_image() is True
|
||||
m.assert_called_once()
|
||||
|
||||
def test_linux_wsl_dispatch(self):
|
||||
with patch("hermes_cli.clipboard.sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
with patch("hermes_cli.clipboard._is_wsl", return_value=True):
|
||||
with patch("hermes_cli.clipboard._wsl_has_image", return_value=True) as m:
|
||||
assert has_clipboard_image() is True
|
||||
m.assert_called_once()
|
||||
|
||||
def test_linux_wayland_dispatch(self):
|
||||
with patch("hermes_cli.clipboard.sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
with patch("hermes_cli.clipboard._is_wsl", return_value=False):
|
||||
with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}):
|
||||
with patch("hermes_cli.clipboard._wayland_has_image", return_value=True) as m:
|
||||
assert has_clipboard_image() is True
|
||||
m.assert_called_once()
|
||||
|
||||
def test_linux_x11_dispatch(self):
|
||||
with patch("hermes_cli.clipboard.sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
with patch("hermes_cli.clipboard._is_wsl", return_value=False):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with patch("hermes_cli.clipboard._xclip_has_image", return_value=True) as m:
|
||||
assert has_clipboard_image() is True
|
||||
m.assert_called_once()
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue