diff --git a/hermes_cli/clipboard.py b/hermes_cli/clipboard.py index 4a56fd0fdc..d968de3ba6 100644 --- a/hermes_cli/clipboard.py +++ b/hermes_cli/clipboard.py @@ -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: diff --git a/tests/tools/test_clipboard.py b/tests/tools/test_clipboard.py index 6f1ecf8db4..82a4aa6faf 100644 --- a/tests/tools/test_clipboard.py +++ b/tests/tools/test_clipboard.py @@ -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: