mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(clipboard): add native Windows image paste support
Add win32 platform branch to clipboard.py so Ctrl+V image paste works on native Windows (PowerShell / Windows Terminal), not just WSL2. Uses the same .NET System.Windows.Forms.Clipboard approach as the WSL path but calls PowerShell directly instead of powershell.exe (the WSL cross-call path). Tries 'powershell' first (Windows PowerShell 5.1, always available), then 'pwsh' (PowerShell 7+). PowerShell executable is discovered once and cached for the process lifetime. Includes 14 new tests covering: - Platform dispatch (save_clipboard_image + has_clipboard_image) - Image detection via PowerShell .NET check - Base64 PNG extraction and decode - Edge cases: no PowerShell, empty output, invalid base64, timeout
This commit is contained in:
parent
c040b0e4ae
commit
f4528c885b
2 changed files with 209 additions and 4 deletions
|
|
@ -1,4 +1,4 @@
|
|||
"""Clipboard image extraction for macOS, Linux, and WSL2.
|
||||
"""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
|
||||
|
|
@ -6,9 +6,10 @@ 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)
|
||||
WSL2 — powershell.exe via .NET System.Windows.Forms.Clipboard
|
||||
Linux — wl-paste (Wayland), xclip (X11)
|
||||
macOS — osascript (always available), pngpaste (if installed)
|
||||
Windows — PowerShell via .NET System.Windows.Forms.Clipboard
|
||||
WSL2 — powershell.exe via .NET System.Windows.Forms.Clipboard
|
||||
Linux — wl-paste (Wayland), xclip (X11)
|
||||
"""
|
||||
|
||||
import base64
|
||||
|
|
@ -32,6 +33,8 @@ def save_clipboard_image(dest: Path) -> bool:
|
|||
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)
|
||||
|
||||
|
||||
|
|
@ -42,6 +45,8 @@ def has_clipboard_image() -> bool:
|
|||
"""
|
||||
if sys.platform == "darwin":
|
||||
return _macos_has_image()
|
||||
if sys.platform == "win32":
|
||||
return _windows_has_image()
|
||||
if _is_wsl():
|
||||
return _wsl_has_image()
|
||||
if os.environ.get("WAYLAND_DISPLAY"):
|
||||
|
|
@ -112,6 +117,100 @@ def _macos_osascript(dest: Path) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
# ── Native Windows ────────────────────────────────────────────────────────
|
||||
|
||||
# PowerShell scripts for native Windows.
|
||||
# Same .NET approach as the WSL path but called directly (not via powershell.exe
|
||||
# cross-call). ``powershell`` resolves to Windows PowerShell 5.1 (always present);
|
||||
# ``pwsh`` would be PowerShell 7+ (optional). We try ``powershell`` first.
|
||||
_WIN_PS_CHECK = (
|
||||
"Add-Type -AssemblyName System.Windows.Forms;"
|
||||
"[System.Windows.Forms.Clipboard]::ContainsImage()"
|
||||
)
|
||||
|
||||
_WIN_PS_EXTRACT = (
|
||||
"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 _find_powershell() -> str | None:
|
||||
"""Return the first available PowerShell executable, or None."""
|
||||
for name in ("powershell", "pwsh"):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[name, "-NoProfile", "-NonInteractive", "-Command", "echo ok"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if r.returncode == 0 and "ok" in r.stdout:
|
||||
return name
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
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
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[ps, "-NoProfile", "-NonInteractive", "-Command", _WIN_PS_CHECK],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
return r.returncode == 0 and "True" in r.stdout
|
||||
except Exception as e:
|
||||
logger.debug("Windows clipboard image check failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
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
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[ps, "-NoProfile", "-NonInteractive", "-Command", _WIN_PS_EXTRACT],
|
||||
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 Exception as e:
|
||||
logger.debug("Windows clipboard image extraction failed: %s", e)
|
||||
dest.unlink(missing_ok=True)
|
||||
return False
|
||||
|
||||
|
||||
# ── Linux ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _is_wsl() -> bool:
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ from hermes_cli.clipboard import (
|
|||
_wsl_has_image,
|
||||
_wayland_save,
|
||||
_wayland_has_image,
|
||||
_windows_save,
|
||||
_windows_has_image,
|
||||
_convert_to_png,
|
||||
)
|
||||
|
||||
|
|
@ -51,6 +53,14 @@ class TestSaveClipboardImage:
|
|||
save_clipboard_image(dest)
|
||||
m.assert_called_once_with(dest)
|
||||
|
||||
def test_dispatches_to_windows_on_win32(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard.sys") as mock_sys:
|
||||
mock_sys.platform = "win32"
|
||||
with patch("hermes_cli.clipboard._windows_save", return_value=False) as m:
|
||||
save_clipboard_image(dest)
|
||||
m.assert_called_once_with(dest)
|
||||
|
||||
def test_dispatches_to_linux_on_linux(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard.sys") as mock_sys:
|
||||
|
|
@ -497,6 +507,102 @@ class TestLinuxSave:
|
|||
m.assert_called_once_with(dest)
|
||||
|
||||
|
||||
# ── Native Windows (PowerShell) ─────────────────────────────────────────
|
||||
|
||||
class TestWindowsHasImage:
|
||||
def setup_method(self):
|
||||
import hermes_cli.clipboard as cb
|
||||
cb._ps_exe = False # reset cache
|
||||
|
||||
def test_clipboard_has_image(self):
|
||||
with patch("hermes_cli.clipboard._get_ps_exe", return_value="powershell"):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="True\n", returncode=0)
|
||||
assert _windows_has_image() is True
|
||||
|
||||
def test_clipboard_no_image(self):
|
||||
with patch("hermes_cli.clipboard._get_ps_exe", return_value="powershell"):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="False\n", returncode=0)
|
||||
assert _windows_has_image() is False
|
||||
|
||||
def test_no_powershell_available(self):
|
||||
with patch("hermes_cli.clipboard._get_ps_exe", return_value=None):
|
||||
assert _windows_has_image() is False
|
||||
|
||||
def test_powershell_error(self):
|
||||
with patch("hermes_cli.clipboard._get_ps_exe", return_value="powershell"):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="", returncode=1)
|
||||
assert _windows_has_image() is False
|
||||
|
||||
def test_subprocess_exception(self):
|
||||
with patch("hermes_cli.clipboard._get_ps_exe", return_value="powershell"):
|
||||
with patch("hermes_cli.clipboard.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired("powershell", 5)):
|
||||
assert _windows_has_image() is False
|
||||
|
||||
|
||||
class TestWindowsSave:
|
||||
def setup_method(self):
|
||||
import hermes_cli.clipboard as cb
|
||||
cb._ps_exe = False # reset cache
|
||||
|
||||
def test_successful_extraction(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
b64_png = base64.b64encode(FAKE_PNG).decode()
|
||||
with patch("hermes_cli.clipboard._get_ps_exe", return_value="powershell"):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout=b64_png + "\n", returncode=0)
|
||||
assert _windows_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._get_ps_exe", return_value="powershell"):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="", returncode=1)
|
||||
assert _windows_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._get_ps_exe", return_value="powershell"):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="", returncode=0)
|
||||
assert _windows_save(dest) is False
|
||||
|
||||
def test_no_powershell_returns_false(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard._get_ps_exe", return_value=None):
|
||||
assert _windows_save(dest) is False
|
||||
|
||||
def test_invalid_base64(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard._get_ps_exe", return_value="powershell"):
|
||||
with patch("hermes_cli.clipboard.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="not-valid-base64!!!", returncode=0)
|
||||
assert _windows_save(dest) is False
|
||||
|
||||
def test_timeout(self, tmp_path):
|
||||
dest = tmp_path / "out.png"
|
||||
with patch("hermes_cli.clipboard._get_ps_exe", return_value="powershell"):
|
||||
with patch("hermes_cli.clipboard.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired("powershell", 15)):
|
||||
assert _windows_save(dest) is False
|
||||
|
||||
|
||||
class TestHasClipboardImageWin32:
|
||||
"""Verify has_clipboard_image dispatches to _windows_has_image on win32."""
|
||||
|
||||
def test_dispatches_on_win32(self):
|
||||
with patch("hermes_cli.clipboard.sys") as mock_sys:
|
||||
mock_sys.platform = "win32"
|
||||
with patch("hermes_cli.clipboard._windows_has_image", return_value=True) as m:
|
||||
assert has_clipboard_image() is True
|
||||
m.assert_called_once()
|
||||
|
||||
|
||||
# ── BMP conversion ──────────────────────────────────────────────────────
|
||||
|
||||
class TestConvertToPng:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue