implement windows support of the extension
Testing / test (push) Successful in 47s

This commit is contained in:
2026-04-13 11:02:54 +02:00
parent 080ca6da6d
commit 9dbe57c66c
9 changed files with 297 additions and 65 deletions
+5 -5
View File
@@ -8,7 +8,7 @@ Control your real, running browser from the terminal or a Python script — no h
You have 40 tabs open. You want to close all the duplicates, group the GitHub ones, save your session before a meeting, and open a few URLs into a specific group — all from a script. That is what browser-cli is for.
It works by pairing a small Chrome/Brave extension with a Python CLI tool. The extension has full access to your browser's tabs, windows, groups, and page DOM. The CLI talks to it in real time over a local socket.
It works by pairing a small Chrome/Brave extension with a Python CLI tool. The extension has full access to your browser's tabs, windows, groups, and page DOM. The CLI talks to it in real time over a local IPC channel.
---
@@ -17,7 +17,7 @@ It works by pairing a small Chrome/Brave extension with a Python CLI tool. The e
```
terminal / python script
Unix socket (/tmp/browser-cli.sock)
Local IPC (Unix socket on Linux/macOS, named pipe on Windows)
Native Messaging Host (Python process, launched by the browser)
@@ -32,7 +32,7 @@ terminal / python script
1. The extension calls `chrome.runtime.connectNative('com.browsercli.host')` on startup.
2. The browser launches the native host Python process (registered in the OS).
3. The native host opens a Unix socket at `/tmp/browser-cli.sock`.
3. The native host opens a local IPC endpoint for the CLI.
4. CLI commands connect to that socket, send a JSON command, and wait for the result.
5. The native host relays the command to the extension via stdout, receives the result via stdin, and sends it back to the CLI.
@@ -70,7 +70,7 @@ The `install` command will:
After install, **fully restart your browser** (Quit and reopen — not just close the window). The extension will connect to the native host automatically on startup.
Only the `browser-cli` command needs to be on your `PATH`. The browser launches the native host wrapper directly from its absolute path in the native messaging manifest, and that wrapper points to the internally installed `native_host.py` copy.
Only the `browser-cli` command needs to be on your `PATH`. The browser launches the native host wrapper directly from its absolute path in the native messaging manifest, and that wrapper points to the internally installed `native_host.py` copy. On Windows the install command also registers the host in the current user's Registry for the selected browser.
---
@@ -81,7 +81,7 @@ browser-cli/
├── browser_cli/
│ ├── __init__.py # Python API — BrowserCLI class and Python API entry point
│ ├── cli.py # Click CLI entry point
│ ├── client.py # Unix socket client used by CLI and API
│ ├── client.py # Local IPC client used by CLI and API
│ ├── models.py # Tab and Group helper models
│ ├── native_host.py # Native messaging host launched by the browser
│ └── commands/
+54 -7
View File
@@ -28,6 +28,7 @@ from browser_cli.client import (
active_browser_targets,
display_browser_name,
)
from browser_cli.platform import install_base_dir, is_windows
console = Console()
@@ -56,6 +57,14 @@ NATIVE_HOST_DIRS = {
},
}
WINDOWS_NATIVE_HOST_REGISTRY_KEYS = {
"chrome": [r"Software\Google\Chrome\NativeMessagingHosts"],
"chromium": [r"Software\Chromium\NativeMessagingHosts"],
"brave": [r"Software\BraveSoftware\Brave-Browser\NativeMessagingHosts"],
"edge": [r"Software\Microsoft\Edge\NativeMessagingHosts"],
"vivaldi": [r"Software\Vivaldi\NativeMessagingHosts"],
}
def _rename_target_profile(target_browser: str | None) -> str | None:
if target_browser:
@@ -82,10 +91,9 @@ def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None
def _native_host_wrapper_path() -> Path:
if sys.platform == "darwin":
base_dir = Path.home() / "Library" / "Application Support" / "browser-cli"
else:
base_dir = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "browser-cli"
base_dir = install_base_dir()
if is_windows():
return base_dir / "libexec" / "native-host.cmd"
return base_dir / "libexec" / "native-host"
@@ -93,6 +101,30 @@ def _native_host_script_path() -> Path:
return _native_host_wrapper_path().with_name("native_host.py")
def _windows_registry_views():
import winreg
return [0, getattr(winreg, "KEY_WOW64_32KEY", 0), getattr(winreg, "KEY_WOW64_64KEY", 0)]
def _register_windows_native_host(browser: str, manifest_path: Path) -> list[str]:
import winreg
installed = []
for key_path in WINDOWS_NATIVE_HOST_REGISTRY_KEYS[browser]:
full_key = f"{key_path}\\{NATIVE_HOST_NAME}"
for view in _windows_registry_views():
try:
access = winreg.KEY_WRITE | view
key = winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, full_key, 0, access)
with key:
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, str(manifest_path))
installed.append(f"HKCU\\{full_key}")
except OSError as e:
console.print(f"[yellow]Could not write registry key {full_key}: {e}[/yellow]")
return installed
def _project_version() -> str:
pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml"
try:
@@ -235,12 +267,16 @@ def cmd_install(browser):
native_host_script_path = _native_host_script_path()
wrapper_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(Path(__file__).with_name("native_host.py"), native_host_script_path)
if not is_windows():
native_host_script_path.chmod(
native_host_script_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
)
wrapper_content = f'#!/bin/sh\nexec "{sys.executable}" "{native_host_script_path}" "$@"\n'
wrapper_path.write_text(wrapper_content)
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
else:
wrapper_content = f'@echo off\r\n"{sys.executable}" "{native_host_script_path}" %*\r\n'
wrapper_path.write_text(wrapper_content, encoding="utf-8")
# Ask for extension ID
ext_urls = {
@@ -269,11 +305,17 @@ def cmd_install(browser):
"allowed_origins": [f"chrome-extension://{extension_id}/"],
}
# Write to OS native messaging dirs
installed = []
if is_windows():
manifest_dir = wrapper_path.parent
manifest_dir.mkdir(parents=True, exist_ok=True)
manifest_path = manifest_dir / f"{NATIVE_HOST_NAME}.json"
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
installed = _register_windows_native_host(browser, manifest_path)
else:
platform = "darwin" if sys.platform == "darwin" else "linux"
dirs = NATIVE_HOST_DIRS[browser][platform]
installed = []
for d in dirs:
try:
d.mkdir(parents=True, exist_ok=True)
@@ -288,11 +330,16 @@ def cmd_install(browser):
sys.exit(1)
for p in installed:
if is_windows():
console.print(f"[green]✓[/green] Registered native host: {p}")
else:
console.print(f"[green]✓[/green] Wrote native host manifest: {p}")
console.print(f"[green]✓[/green] Installed native host script: {native_host_script_path}")
console.print(f"[green]✓[/green] Installed native host wrapper: {wrapper_path}")
if is_windows():
console.print("\n[green]✓[/green] Wrote native host manifest:", manifest_path)
console.print(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (Cmd/Ctrl+Q, then reopen)")
console.print(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (quit app, then reopen)")
console.print("\n[green bold]✓ Installation complete![/green bold]")
console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]")
+18 -10
View File
@@ -1,11 +1,11 @@
"""
Unix socket client — sends commands to the native host relay socket.
Used by both the CLI and the public Python API.
Local IPC client — sends commands to native host relay endpoint.
Used by both CLI and public Python API.
Profile selection order:
1. Explicit `profile` argument to send_command()
2. BROWSER_CLI_PROFILE environment variable
3. First entry in /tmp/.browser_cli/registry.json
3. First entry in runtime registry
4. Otherwise, no browser can be resolved automatically
"""
import json
@@ -14,11 +14,13 @@ import socket
import struct
import uuid
from dataclasses import dataclass
from multiprocessing.connection import Client as PipeClient
from pathlib import Path
from typing import Any
SOCKET_DIR = Path("/tmp/.browser_cli")
REGISTRY_PATH = SOCKET_DIR / "registry.json"
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path
REGISTRY_PATH = registry_path()
class BrowserNotConnected(Exception):
@@ -32,8 +34,10 @@ class BrowserTarget:
socket_path: str
def _active_sockets(reg: dict) -> dict:
"""Return only entries whose socket file exists on disk."""
def _active_endpoints(reg: dict) -> dict:
"""Return only entries whose endpoint appears reachable."""
if is_windows():
return dict(reg)
return {k: v for k, v in reg.items() if Path(v).exists()}
@@ -52,7 +56,7 @@ def active_browser_targets() -> list[BrowserTarget]:
return []
return [
BrowserTarget(profile=profile, display_name=display_browser_name(profile, sock_path), socket_path=sock_path)
for profile, sock_path in _active_sockets(reg).items()
for profile, sock_path in _active_endpoints(reg).items()
]
@@ -68,8 +72,7 @@ def _resolve_socket(profile: str | None = None) -> str:
return reg[target]
except Exception:
pass
safe = target.replace(" ", "_").replace("/", "_")
return str(SOCKET_DIR / f"{safe}.sock")
return endpoint_for_alias(target)
# Auto-detect: error when multiple browser instances are active
try:
@@ -107,6 +110,11 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
framed = struct.pack("<I", len(payload)) + payload
try:
if is_windows():
with PipeClient(sock_path, family="AF_PIPE") as conn:
conn.send_bytes(payload)
response = conn.recv_bytes()
else:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.connect(sock_path)
sock.sendall(framed)
+34 -18
View File
@@ -2,13 +2,9 @@
"""
Native Messaging Host for browser-cli.
Chrome launches this process when the extension calls connectNative().
It relays messages between the Chrome extension (via stdin/stdout using the
Native Messaging protocol) and the CLI (via a Unix domain socket).
Multi-browser support: the extension sends a "hello" message on startup
with a profile alias. The host uses that alias to create a unique socket
path and registers it in a shared registry file.
Chrome launches this process when extension calls connectNative().
It relays messages between extension (stdin/stdout Native Messaging protocol)
and CLI (local IPC endpoint: Unix socket on Unix, named pipe on Windows).
"""
import json
import os
@@ -18,15 +14,15 @@ import struct
import sys
import threading
import uuid
from multiprocessing.connection import Listener
from pathlib import Path
SOCKET_DIR = Path("/tmp/.browser_cli")
REGISTRY_PATH = SOCKET_DIR / "registry.json"
DEFAULT_ALIAS = "default"
from browser_cli.platform import DEFAULT_ALIAS, endpoint_for_alias, is_windows, registry_path, runtime_dir
SOCKET_PATH: str = "" # set after hello handshake
PENDING: dict[str, queue.Queue] = {}
PENDING_LOCK = threading.Lock()
REGISTRY_PATH = registry_path()
# --- Native Messaging protocol (4-byte LE length prefix + UTF-8 JSON) ---
@@ -71,8 +67,7 @@ def _registry_remove(alias: str) -> None:
def _socket_path_for(alias: str) -> str:
safe = alias.replace(" ", "_").replace("/", "_")
return str(SOCKET_DIR / f"{safe}.sock")
return endpoint_for_alias(alias)
def _resolve_profile_alias(first_msg: dict | None) -> str:
@@ -113,6 +108,16 @@ def stdin_reader(alias: str):
# --- Thread B: accept CLI socket connections ---
def socket_server(sock_path: str):
if is_windows():
while True:
try:
listener = Listener(sock_path, family="AF_PIPE")
conn = listener.accept()
except OSError:
break
threading.Thread(target=handle_cli_connection, args=(conn, listener), daemon=True).start()
return
path = Path(sock_path)
if path.exists():
path.unlink()
@@ -126,12 +131,12 @@ def socket_server(sock_path: str):
conn, _ = sock.accept()
except OSError:
break
threading.Thread(target=handle_cli_connection, args=(conn,), daemon=True).start()
threading.Thread(target=handle_cli_connection, args=(conn, None), daemon=True).start()
def handle_cli_connection(conn: socket.socket) -> None:
def handle_cli_connection(conn, listener=None) -> None:
try:
data = _recv_all(conn)
data = conn.recv_bytes() if is_windows() else _recv_all(conn)
if not data:
return
cmd = json.loads(data)
@@ -154,14 +159,24 @@ def handle_cli_connection(conn: socket.socket) -> None:
with PENDING_LOCK:
PENDING.pop(msg_id, None)
_send_all(conn, json.dumps(result).encode("utf-8"))
response = json.dumps(result).encode("utf-8")
if is_windows():
conn.send_bytes(response)
else:
_send_all(conn, response)
except Exception as exc:
try:
_send_all(conn, json.dumps({"success": False, "error": str(exc)}).encode("utf-8"))
response = json.dumps({"success": False, "error": str(exc)}).encode("utf-8")
if is_windows():
conn.send_bytes(response)
else:
_send_all(conn, response)
except Exception:
pass
finally:
conn.close()
if listener is not None:
listener.close()
# --- Socket helpers (length-prefixed framing) ---
@@ -191,6 +206,7 @@ def _recv_exact(conn: socket.socket, n: int) -> bytes | None:
def _cleanup(alias: str):
try:
if not is_windows():
Path(_socket_path_for(alias)).unlink(missing_ok=True)
except Exception:
pass
@@ -215,7 +231,7 @@ def main():
PENDING[msg_id] = q
write_native_message(sys.stdout.buffer, first_msg)
SOCKET_DIR.mkdir(mode=0o700, exist_ok=True)
runtime_dir().mkdir(mode=0o700, exist_ok=True)
sock_path = _socket_path_for(alias)
_registry_add(alias, sock_path)
+36
View File
@@ -0,0 +1,36 @@
import os
import sys
from pathlib import Path
APP_NAME = "browser-cli"
RUNTIME_DIRNAME = ".browser_cli"
DEFAULT_ALIAS = "default"
def is_windows() -> bool:
return sys.platform.startswith("win")
def runtime_dir() -> Path:
if is_windows():
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
return base / APP_NAME
return Path("/tmp") / RUNTIME_DIRNAME
def registry_path() -> Path:
return runtime_dir() / "registry.json"
def install_base_dir() -> Path:
if is_windows():
return runtime_dir()
if sys.platform == "darwin":
return Path.home() / "Library" / "Application Support" / APP_NAME
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / APP_NAME
def sanitize_alias(alias:str) -> str:
cleaned = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in alias.strip())
return cleaned or DEFAULT_ALIAS
def endpoint_for_alias(alias:str) -> str:
safe = sanitize_alias(alias)
if is_windows():
return rf"\\.\pipe\browser-cli-{safe}"
return str(runtime_dir() / f"{safe}.sock")
+63 -2
View File
@@ -1,4 +1,6 @@
from pathlib import Path
from types import SimpleNamespace
import sys
from click.testing import CliRunner
from unittest.mock import patch
@@ -31,14 +33,18 @@ def test_project_version_falls_back_to_installed_package_metadata():
assert _project_version() == "9.9.9"
def test_clients_rename_uses_command_level_browser_target():
with patch("browser_cli.cli.send_command") as send_command:
with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")), patch(
"browser_cli.cli.send_command"
) as send_command:
result = CliRunner().invoke(main, ["clients", "rename", "--browser", "old-id", "work"])
assert result.exit_code == 0
send_command.assert_called_once_with("clients.rename_profile", {"alias": "work"}, profile="old-id")
def test_clients_rename_uses_global_browser_target_when_set():
with patch("browser_cli.cli.send_command") as send_command:
with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")), patch(
"browser_cli.cli.send_command"
) as send_command:
result = CliRunner().invoke(main, ["--browser", "old-id", "clients", "rename", "work"])
assert result.exit_code == 0
@@ -72,6 +78,61 @@ def test_install_help_lists_supported_browsers():
assert result.exit_code == 0
assert "[chrome|chromium|brave|edge|vivaldi]" in result.output
def test_install_windows_registers_native_host(tmp_path, monkeypatch):
local_app_data = tmp_path / "LocalAppData"
extension_dir = tmp_path / "extension"
extension_dir.mkdir()
native_host_src = tmp_path / "native_host.py"
native_host_src.write_text("print('ok')", encoding="utf-8")
writes = []
class FakeKey:
def __init__(self, path):
self.path = path
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
fake_winreg = SimpleNamespace(
HKEY_CURRENT_USER="HKCU",
KEY_WRITE=0x20006,
KEY_WOW64_32KEY=0x0200,
KEY_WOW64_64KEY=0x0100,
REG_SZ=1,
)
def fake_create_key(root, path, reserved, access):
return FakeKey(path)
def fake_set_value(key, name, reserved, reg_type, value):
writes.append((key.path, name, value))
fake_winreg.CreateKeyEx = fake_create_key
fake_winreg.SetValueEx = fake_set_value
monkeypatch.setenv("LOCALAPPDATA", str(local_app_data))
with patch("browser_cli.cli.is_windows", return_value=True), patch(
"browser_cli.cli.Path.home", return_value=tmp_path
), patch("browser_cli.cli.click.prompt", return_value="abc123"), patch(
"browser_cli.cli.shutil.copy2"
) as copy2, patch("browser_cli.cli.Path.write_text") as write_text, patch.dict(
sys.modules, {"winreg": fake_winreg}
):
copy2.side_effect = lambda src, dst: Path(dst).write_text(native_host_src.read_text(encoding="utf-8"), encoding="utf-8")
result = CliRunner().invoke(main, ["install", "edge"])
assert result.exit_code == 0
assert any("Software\\Microsoft\\Edge\\NativeMessagingHosts\\com.browsercli.host" in path for path, _, _ in writes)
assert "Registered native host" in result.output
assert "Wrote native host manifest" in result.output
wrapper_writes = [call.args[0] for call in write_text.call_args_list if call.args]
assert any("@echo off" in text for text in wrapper_writes)
def test_clients_exits_cleanly_when_registry_is_missing():
with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")):
result = CliRunner().invoke(main, ["clients"])
+21
View File
@@ -4,6 +4,7 @@ from pathlib import Path
import pytest
from browser_cli.client import BrowserNotConnected, _resolve_socket, active_browser_targets, display_browser_name
from browser_cli.platform import endpoint_for_alias
def test_resolve_socket_raises_when_registry_missing(monkeypatch):
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
@@ -46,6 +47,13 @@ def test_display_browser_name_uses_uuid_stem_for_default():
)
def test_resolve_socket_uses_platform_endpoint_for_explicit_alias(monkeypatch):
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json"))
assert _resolve_socket("work") == endpoint_for_alias("work")
def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path):
active_socket = tmp_path / "work.sock"
active_socket.write_text("")
@@ -60,3 +68,16 @@ def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path):
assert len(targets) == 1
assert targets[0].profile == "work"
assert targets[0].display_name == "work"
def test_active_browser_targets_keeps_windows_registry_entries(monkeypatch, tmp_path):
registry_path = tmp_path / "registry.json"
registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"}))
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", registry_path)
monkeypatch.setattr("browser_cli.client.is_windows", lambda: True)
targets = active_browser_targets()
assert len(targets) == 1
assert targets[0].socket_path == r"\\.\pipe\browser-cli-work"
+14 -1
View File
@@ -18,8 +18,9 @@ def test_cleanup_removes_socket_and_registry_entry(monkeypatch, tmp_path):
registry_path = tmp_path / "registry.json"
registry_path.write_text(json.dumps({alias: str(socket_path), "other": str(tmp_path / "other.sock")}))
monkeypatch.setattr(native_host, "SOCKET_DIR", tmp_path)
monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path)
monkeypatch.setattr(native_host, "_socket_path_for", lambda alias: str(socket_path))
monkeypatch.setattr(native_host, "is_windows", lambda: False)
native_host._cleanup(alias)
@@ -41,6 +42,18 @@ def test_stdin_reader_cleans_up_on_eof(monkeypatch):
assert cleaned == ["work"]
def test_cleanup_windows_skips_socket_unlink(monkeypatch, tmp_path):
registry_path = tmp_path / "registry.json"
registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"}))
monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path)
monkeypatch.setattr(native_host, "is_windows", lambda: True)
native_host._cleanup("work")
assert json.loads(registry_path.read_text()) == {}
def test_stdin_reader_cleans_up_on_bye(monkeypatch):
cleaned = []
messages = iter([{"type": "bye"}])
+30
View File
@@ -0,0 +1,30 @@
from pathlib import Path
from browser_cli.platform import endpoint_for_alias, install_base_dir, registry_path, runtime_dir
def test_runtime_dir_defaults_to_tmp_on_unix(monkeypatch):
monkeypatch.setattr("browser_cli.platform.is_windows", lambda: False)
assert runtime_dir() == Path("/tmp/.browser_cli")
def test_runtime_dir_uses_localappdata_on_windows(monkeypatch, tmp_path):
monkeypatch.setattr("browser_cli.platform.is_windows", lambda: True)
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "LocalAppData"))
assert runtime_dir() == tmp_path / "LocalAppData" / "browser-cli"
assert registry_path() == tmp_path / "LocalAppData" / "browser-cli" / "registry.json"
def test_endpoint_for_alias_uses_pipe_on_windows(monkeypatch):
monkeypatch.setattr("browser_cli.platform.is_windows", lambda: True)
assert endpoint_for_alias("work/browser") == r"\\.\pipe\browser-cli-work_browser"
def test_install_base_dir_uses_runtime_dir_on_windows(monkeypatch, tmp_path):
monkeypatch.setattr("browser_cli.platform.is_windows", lambda: True)
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "LocalAppData"))
assert install_base_dir() == tmp_path / "LocalAppData" / "browser-cli"