From 9dbe57c66ce9d6f67dd7b719a80b2736efcb7953 Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Mon, 13 Apr 2026 11:02:54 +0200 Subject: [PATCH] implement windows support of the extension --- README.md | 10 ++-- browser_cli/cli.py | 95 ++++++++++++++++++++++++++++---------- browser_cli/client.py | 36 +++++++++------ browser_cli/native_host.py | 54 ++++++++++++++-------- browser_cli/platform.py | 36 +++++++++++++++ tests/test_cli.py | 65 +++++++++++++++++++++++++- tests/test_client.py | 21 +++++++++ tests/test_native_host.py | 15 +++++- tests/test_platform.py | 30 ++++++++++++ 9 files changed, 297 insertions(+), 65 deletions(-) create mode 100644 browser_cli/platform.py create mode 100644 tests/test_platform.py diff --git a/README.md b/README.md index 6ce0bc5..30a1ba7 100644 --- a/README.md +++ b/README.md @@ -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/ diff --git a/browser_cli/cli.py b/browser_cli/cli.py index 63afe2a..76ccc70 100755 --- a/browser_cli/cli.py +++ b/browser_cli/cli.py @@ -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) - 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) + 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,30 +305,41 @@ def cmd_install(browser): "allowed_origins": [f"chrome-extension://{extension_id}/"], } - # Write to OS native messaging dirs - 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) - manifest_path = d / f"{NATIVE_HOST_NAME}.json" - manifest_path.write_text(json.dumps(manifest, indent=2)) - installed.append(manifest_path) - except Exception as e: - console.print(f"[yellow]Could not write to {d}: {e}[/yellow]") + 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] + + for d in dirs: + try: + d.mkdir(parents=True, exist_ok=True) + manifest_path = d / f"{NATIVE_HOST_NAME}.json" + manifest_path.write_text(json.dumps(manifest, indent=2)) + installed.append(manifest_path) + except Exception as e: + console.print(f"[yellow]Could not write to {d}: {e}[/yellow]") if not installed: console.print("[red]Failed to install native host manifest[/red]") sys.exit(1) for p in installed: - console.print(f"[green]✓[/green] Wrote native host manifest: {p}") + 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]") diff --git a/browser_cli/client.py b/browser_cli/client.py index 0267ceb..2b8004f 100644 --- a/browser_cli/client.py +++ b/browser_cli/client.py @@ -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,10 +110,15 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N framed = struct.pack(" 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,7 +206,8 @@ def _recv_exact(conn: socket.socket, n: int) -> bytes | None: def _cleanup(alias: str): try: - Path(_socket_path_for(alias)).unlink(missing_ok=True) + if not is_windows(): + Path(_socket_path_for(alias)).unlink(missing_ok=True) except Exception: pass _registry_remove(alias) @@ -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) diff --git a/browser_cli/platform.py b/browser_cli/platform.py new file mode 100644 index 0000000..040098c --- /dev/null +++ b/browser_cli/platform.py @@ -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") diff --git a/tests/test_cli.py b/tests/test_cli.py index b8b7ce7..a11a074 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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"]) diff --git a/tests/test_client.py b/tests/test_client.py index 7c0315a..c07ca70 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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" diff --git a/tests/test_native_host.py b/tests/test_native_host.py index 0b0dbd4..4469d38 100644 --- a/tests/test_native_host.py +++ b/tests/test_native_host.py @@ -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"}]) diff --git a/tests/test_platform.py b/tests/test_platform.py new file mode 100644 index 0000000..b530392 --- /dev/null +++ b/tests/test_platform.py @@ -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"