mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-01 00:11:39 +08:00
Compare commits
2 Commits
fix/plugin
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1096a8fec2 | ||
|
|
24161d6f4c |
@@ -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
|
Provides a single function `save_clipboard_image(dest)` that checks the
|
||||||
system clipboard for image data, saves it to *dest* as PNG, and returns
|
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).
|
CLI tools that ship with the platform (or are commonly installed).
|
||||||
|
|
||||||
Platform support:
|
Platform support:
|
||||||
macOS — osascript (always available), pngpaste (if installed)
|
macOS — osascript (always available), pngpaste (if installed)
|
||||||
WSL2 — powershell.exe via .NET System.Windows.Forms.Clipboard
|
Windows — PowerShell via .NET System.Windows.Forms.Clipboard
|
||||||
Linux — wl-paste (Wayland), xclip (X11)
|
WSL2 — powershell.exe via .NET System.Windows.Forms.Clipboard
|
||||||
|
Linux — wl-paste (Wayland), xclip (X11)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
@@ -32,6 +33,8 @@ def save_clipboard_image(dest: Path) -> bool:
|
|||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if sys.platform == "darwin":
|
if sys.platform == "darwin":
|
||||||
return _macos_save(dest)
|
return _macos_save(dest)
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return _windows_save(dest)
|
||||||
return _linux_save(dest)
|
return _linux_save(dest)
|
||||||
|
|
||||||
|
|
||||||
@@ -42,6 +45,8 @@ def has_clipboard_image() -> bool:
|
|||||||
"""
|
"""
|
||||||
if sys.platform == "darwin":
|
if sys.platform == "darwin":
|
||||||
return _macos_has_image()
|
return _macos_has_image()
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return _windows_has_image()
|
||||||
if _is_wsl():
|
if _is_wsl():
|
||||||
return _wsl_has_image()
|
return _wsl_has_image()
|
||||||
if os.environ.get("WAYLAND_DISPLAY"):
|
if os.environ.get("WAYLAND_DISPLAY"):
|
||||||
@@ -112,6 +117,104 @@ def _macos_osascript(dest: Path) -> bool:
|
|||||||
return False
|
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())"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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 = 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", _PS_CHECK_IMAGE],
|
||||||
|
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", _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 Exception as e:
|
||||||
|
logger.debug("Windows clipboard image extraction failed: %s", e)
|
||||||
|
dest.unlink(missing_ok=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ── Linux ────────────────────────────────────────────────────────────────
|
# ── Linux ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _is_wsl() -> bool:
|
def _is_wsl() -> bool:
|
||||||
@@ -142,24 +245,7 @@ def _linux_save(dest: Path) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
# ── WSL2 (powershell.exe) ────────────────────────────────────────────────
|
# ── WSL2 (powershell.exe) ────────────────────────────────────────────────
|
||||||
|
# Reuses _PS_CHECK_IMAGE / _PS_EXTRACT_IMAGE defined above.
|
||||||
# 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:
|
def _wsl_has_image() -> bool:
|
||||||
"""Check if Windows clipboard has an image (via powershell.exe)."""
|
"""Check if Windows clipboard has an image (via powershell.exe)."""
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ from hermes_cli.clipboard import (
|
|||||||
_wsl_has_image,
|
_wsl_has_image,
|
||||||
_wayland_save,
|
_wayland_save,
|
||||||
_wayland_has_image,
|
_wayland_has_image,
|
||||||
|
_windows_save,
|
||||||
|
_windows_has_image,
|
||||||
_convert_to_png,
|
_convert_to_png,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,6 +53,14 @@ class TestSaveClipboardImage:
|
|||||||
save_clipboard_image(dest)
|
save_clipboard_image(dest)
|
||||||
m.assert_called_once_with(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):
|
def test_dispatches_to_linux_on_linux(self, tmp_path):
|
||||||
dest = tmp_path / "out.png"
|
dest = tmp_path / "out.png"
|
||||||
with patch("hermes_cli.clipboard.sys") as mock_sys:
|
with patch("hermes_cli.clipboard.sys") as mock_sys:
|
||||||
@@ -497,6 +507,102 @@ class TestLinuxSave:
|
|||||||
m.assert_called_once_with(dest)
|
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 ──────────────────────────────────────────────────────
|
# ── BMP conversion ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
class TestConvertToPng:
|
class TestConvertToPng:
|
||||||
|
|||||||
Reference in New Issue
Block a user