This commit is contained in:
@@ -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.
|
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
|
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)
|
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.
|
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).
|
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.
|
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.
|
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.
|
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/
|
├── browser_cli/
|
||||||
│ ├── __init__.py # Python API — BrowserCLI class and Python API entry point
|
│ ├── __init__.py # Python API — BrowserCLI class and Python API entry point
|
||||||
│ ├── cli.py # Click CLI 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
|
│ ├── models.py # Tab and Group helper models
|
||||||
│ ├── native_host.py # Native messaging host launched by the browser
|
│ ├── native_host.py # Native messaging host launched by the browser
|
||||||
│ └── commands/
|
│ └── commands/
|
||||||
|
|||||||
+71
-24
@@ -28,6 +28,7 @@ from browser_cli.client import (
|
|||||||
active_browser_targets,
|
active_browser_targets,
|
||||||
display_browser_name,
|
display_browser_name,
|
||||||
)
|
)
|
||||||
|
from browser_cli.platform import install_base_dir, is_windows
|
||||||
|
|
||||||
console = Console()
|
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:
|
def _rename_target_profile(target_browser: str | None) -> str | None:
|
||||||
if target_browser:
|
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:
|
def _native_host_wrapper_path() -> Path:
|
||||||
if sys.platform == "darwin":
|
base_dir = install_base_dir()
|
||||||
base_dir = Path.home() / "Library" / "Application Support" / "browser-cli"
|
if is_windows():
|
||||||
else:
|
return base_dir / "libexec" / "native-host.cmd"
|
||||||
base_dir = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "browser-cli"
|
|
||||||
return base_dir / "libexec" / "native-host"
|
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")
|
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:
|
def _project_version() -> str:
|
||||||
pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
||||||
try:
|
try:
|
||||||
@@ -235,12 +267,16 @@ def cmd_install(browser):
|
|||||||
native_host_script_path = _native_host_script_path()
|
native_host_script_path = _native_host_script_path()
|
||||||
wrapper_path.parent.mkdir(parents=True, exist_ok=True)
|
wrapper_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.copy2(Path(__file__).with_name("native_host.py"), native_host_script_path)
|
shutil.copy2(Path(__file__).with_name("native_host.py"), native_host_script_path)
|
||||||
native_host_script_path.chmod(
|
if not is_windows():
|
||||||
native_host_script_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
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_content = f'#!/bin/sh\nexec "{sys.executable}" "{native_host_script_path}" "$@"\n'
|
||||||
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
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
|
# Ask for extension ID
|
||||||
ext_urls = {
|
ext_urls = {
|
||||||
@@ -269,30 +305,41 @@ def cmd_install(browser):
|
|||||||
"allowed_origins": [f"chrome-extension://{extension_id}/"],
|
"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 = []
|
installed = []
|
||||||
for d in dirs:
|
if is_windows():
|
||||||
try:
|
manifest_dir = wrapper_path.parent
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
manifest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
manifest_path = d / f"{NATIVE_HOST_NAME}.json"
|
manifest_path = manifest_dir / f"{NATIVE_HOST_NAME}.json"
|
||||||
manifest_path.write_text(json.dumps(manifest, indent=2))
|
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
||||||
installed.append(manifest_path)
|
installed = _register_windows_native_host(browser, manifest_path)
|
||||||
except Exception as e:
|
else:
|
||||||
console.print(f"[yellow]Could not write to {d}: {e}[/yellow]")
|
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:
|
if not installed:
|
||||||
console.print("[red]Failed to install native host manifest[/red]")
|
console.print("[red]Failed to install native host manifest[/red]")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
for p in installed:
|
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 script: {native_host_script_path}")
|
||||||
console.print(f"[green]✓[/green] Installed native host wrapper: {wrapper_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("\n[green bold]✓ Installation complete![/green bold]")
|
||||||
console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]")
|
console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]")
|
||||||
|
|
||||||
|
|||||||
+22
-14
@@ -1,11 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Unix socket client — sends commands to the native host relay socket.
|
Local IPC client — sends commands to native host relay endpoint.
|
||||||
Used by both the CLI and the public Python API.
|
Used by both CLI and public Python API.
|
||||||
|
|
||||||
Profile selection order:
|
Profile selection order:
|
||||||
1. Explicit `profile` argument to send_command()
|
1. Explicit `profile` argument to send_command()
|
||||||
2. BROWSER_CLI_PROFILE environment variable
|
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
|
4. Otherwise, no browser can be resolved automatically
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
@@ -14,11 +14,13 @@ import socket
|
|||||||
import struct
|
import struct
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from multiprocessing.connection import Client as PipeClient
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
SOCKET_DIR = Path("/tmp/.browser_cli")
|
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path
|
||||||
REGISTRY_PATH = SOCKET_DIR / "registry.json"
|
|
||||||
|
REGISTRY_PATH = registry_path()
|
||||||
|
|
||||||
|
|
||||||
class BrowserNotConnected(Exception):
|
class BrowserNotConnected(Exception):
|
||||||
@@ -32,8 +34,10 @@ class BrowserTarget:
|
|||||||
socket_path: str
|
socket_path: str
|
||||||
|
|
||||||
|
|
||||||
def _active_sockets(reg: dict) -> dict:
|
def _active_endpoints(reg: dict) -> dict:
|
||||||
"""Return only entries whose socket file exists on disk."""
|
"""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()}
|
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 []
|
||||||
return [
|
return [
|
||||||
BrowserTarget(profile=profile, display_name=display_browser_name(profile, sock_path), socket_path=sock_path)
|
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]
|
return reg[target]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
safe = target.replace(" ", "_").replace("/", "_")
|
return endpoint_for_alias(target)
|
||||||
return str(SOCKET_DIR / f"{safe}.sock")
|
|
||||||
|
|
||||||
# Auto-detect: error when multiple browser instances are active
|
# Auto-detect: error when multiple browser instances are active
|
||||||
try:
|
try:
|
||||||
@@ -107,10 +110,15 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
|
|||||||
framed = struct.pack("<I", len(payload)) + payload
|
framed = struct.pack("<I", len(payload)) + payload
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
if is_windows():
|
||||||
sock.connect(sock_path)
|
with PipeClient(sock_path, family="AF_PIPE") as conn:
|
||||||
sock.sendall(framed)
|
conn.send_bytes(payload)
|
||||||
response = _recv_all(sock)
|
response = conn.recv_bytes()
|
||||||
|
else:
|
||||||
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||||
|
sock.connect(sock_path)
|
||||||
|
sock.sendall(framed)
|
||||||
|
response = _recv_all(sock)
|
||||||
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
||||||
profile_hint = f" (profile: {profile})" if profile else ""
|
profile_hint = f" (profile: {profile})" if profile else ""
|
||||||
raise BrowserNotConnected(
|
raise BrowserNotConnected(
|
||||||
|
|||||||
+35
-19
@@ -2,13 +2,9 @@
|
|||||||
"""
|
"""
|
||||||
Native Messaging Host for browser-cli.
|
Native Messaging Host for browser-cli.
|
||||||
|
|
||||||
Chrome launches this process when the extension calls connectNative().
|
Chrome launches this process when extension calls connectNative().
|
||||||
It relays messages between the Chrome extension (via stdin/stdout using the
|
It relays messages between extension (stdin/stdout Native Messaging protocol)
|
||||||
Native Messaging protocol) and the CLI (via a Unix domain socket).
|
and CLI (local IPC endpoint: Unix socket on Unix, named pipe on Windows).
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -18,15 +14,15 @@ import struct
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
|
from multiprocessing.connection import Listener
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
SOCKET_DIR = Path("/tmp/.browser_cli")
|
from browser_cli.platform import DEFAULT_ALIAS, endpoint_for_alias, is_windows, registry_path, runtime_dir
|
||||||
REGISTRY_PATH = SOCKET_DIR / "registry.json"
|
|
||||||
DEFAULT_ALIAS = "default"
|
|
||||||
|
|
||||||
SOCKET_PATH: str = "" # set after hello handshake
|
SOCKET_PATH: str = "" # set after hello handshake
|
||||||
PENDING: dict[str, queue.Queue] = {}
|
PENDING: dict[str, queue.Queue] = {}
|
||||||
PENDING_LOCK = threading.Lock()
|
PENDING_LOCK = threading.Lock()
|
||||||
|
REGISTRY_PATH = registry_path()
|
||||||
|
|
||||||
# --- Native Messaging protocol (4-byte LE length prefix + UTF-8 JSON) ---
|
# --- 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:
|
def _socket_path_for(alias: str) -> str:
|
||||||
safe = alias.replace(" ", "_").replace("/", "_")
|
return endpoint_for_alias(alias)
|
||||||
return str(SOCKET_DIR / f"{safe}.sock")
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_profile_alias(first_msg: dict | None) -> str:
|
def _resolve_profile_alias(first_msg: dict | None) -> str:
|
||||||
@@ -113,6 +108,16 @@ def stdin_reader(alias: str):
|
|||||||
# --- Thread B: accept CLI socket connections ---
|
# --- Thread B: accept CLI socket connections ---
|
||||||
|
|
||||||
def socket_server(sock_path: str):
|
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)
|
path = Path(sock_path)
|
||||||
if path.exists():
|
if path.exists():
|
||||||
path.unlink()
|
path.unlink()
|
||||||
@@ -126,12 +131,12 @@ def socket_server(sock_path: str):
|
|||||||
conn, _ = sock.accept()
|
conn, _ = sock.accept()
|
||||||
except OSError:
|
except OSError:
|
||||||
break
|
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:
|
try:
|
||||||
data = _recv_all(conn)
|
data = conn.recv_bytes() if is_windows() else _recv_all(conn)
|
||||||
if not data:
|
if not data:
|
||||||
return
|
return
|
||||||
cmd = json.loads(data)
|
cmd = json.loads(data)
|
||||||
@@ -154,14 +159,24 @@ def handle_cli_connection(conn: socket.socket) -> None:
|
|||||||
with PENDING_LOCK:
|
with PENDING_LOCK:
|
||||||
PENDING.pop(msg_id, None)
|
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:
|
except Exception as exc:
|
||||||
try:
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
if listener is not None:
|
||||||
|
listener.close()
|
||||||
|
|
||||||
|
|
||||||
# --- Socket helpers (length-prefixed framing) ---
|
# --- Socket helpers (length-prefixed framing) ---
|
||||||
@@ -191,7 +206,8 @@ def _recv_exact(conn: socket.socket, n: int) -> bytes | None:
|
|||||||
|
|
||||||
def _cleanup(alias: str):
|
def _cleanup(alias: str):
|
||||||
try:
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
_registry_remove(alias)
|
_registry_remove(alias)
|
||||||
@@ -215,7 +231,7 @@ def main():
|
|||||||
PENDING[msg_id] = q
|
PENDING[msg_id] = q
|
||||||
write_native_message(sys.stdout.buffer, first_msg)
|
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)
|
sock_path = _socket_path_for(alias)
|
||||||
_registry_add(alias, sock_path)
|
_registry_add(alias, sock_path)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,4 +1,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import sys
|
||||||
|
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
from unittest.mock import patch
|
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"
|
assert _project_version() == "9.9.9"
|
||||||
|
|
||||||
def test_clients_rename_uses_command_level_browser_target():
|
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"])
|
result = CliRunner().invoke(main, ["clients", "rename", "--browser", "old-id", "work"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
send_command.assert_called_once_with("clients.rename_profile", {"alias": "work"}, profile="old-id")
|
send_command.assert_called_once_with("clients.rename_profile", {"alias": "work"}, profile="old-id")
|
||||||
|
|
||||||
def test_clients_rename_uses_global_browser_target_when_set():
|
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"])
|
result = CliRunner().invoke(main, ["--browser", "old-id", "clients", "rename", "work"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -72,6 +78,61 @@ def test_install_help_lists_supported_browsers():
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "[chrome|chromium|brave|edge|vivaldi]" in result.output
|
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():
|
def test_clients_exits_cleanly_when_registry_is_missing():
|
||||||
with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")):
|
with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")):
|
||||||
result = CliRunner().invoke(main, ["clients"])
|
result = CliRunner().invoke(main, ["clients"])
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from browser_cli.client import BrowserNotConnected, _resolve_socket, active_browser_targets, display_browser_name
|
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):
|
def test_resolve_socket_raises_when_registry_missing(monkeypatch):
|
||||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
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):
|
def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path):
|
||||||
active_socket = tmp_path / "work.sock"
|
active_socket = tmp_path / "work.sock"
|
||||||
active_socket.write_text("")
|
active_socket.write_text("")
|
||||||
@@ -60,3 +68,16 @@ def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path):
|
|||||||
assert len(targets) == 1
|
assert len(targets) == 1
|
||||||
assert targets[0].profile == "work"
|
assert targets[0].profile == "work"
|
||||||
assert targets[0].display_name == "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"
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ def test_cleanup_removes_socket_and_registry_entry(monkeypatch, tmp_path):
|
|||||||
registry_path = tmp_path / "registry.json"
|
registry_path = tmp_path / "registry.json"
|
||||||
registry_path.write_text(json.dumps({alias: str(socket_path), "other": str(tmp_path / "other.sock")}))
|
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, "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)
|
native_host._cleanup(alias)
|
||||||
|
|
||||||
@@ -41,6 +42,18 @@ def test_stdin_reader_cleans_up_on_eof(monkeypatch):
|
|||||||
assert cleaned == ["work"]
|
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):
|
def test_stdin_reader_cleans_up_on_bye(monkeypatch):
|
||||||
cleaned = []
|
cleaned = []
|
||||||
messages = iter([{"type": "bye"}])
|
messages = iter([{"type": "bye"}])
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user