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:
kshitijk4poor 2026-04-07 23:41:11 +05:30 committed by Teknium
parent c040b0e4ae
commit f4528c885b
2 changed files with 209 additions and 4 deletions

View file

@ -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:

View file

@ -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: