diff --git a/README.md b/README.md index 8ee7a71..48acbe5 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ browser-cli/ All commands are run with `uv run browser-cli [--browser ALIAS] `. -If exactly one browser instance is connected, commands auto-target it. Use `--browser ALIAS` when multiple browser instances are connected. You can inspect the active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli rename-profile --browser `. +If exactly one browser instance is connected, commands auto-target it. Use `--browser ALIAS` when multiple browser instances are connected. You can inspect the active instances with `browser-cli clients` and assign a persistent profile alias from inside the target browser with `browser-cli rename-profile --browser `. Closed browsers are removed from the client registry automatically. Important: profile aliases are browser-instance aliases, not window aliases. Window aliases created with `windows rename` are only for targeting windows in commands like `nav open --window work`. If a browser instance has no explicit profile alias set, the native host gives it a generated UUID alias so multiple unaliased browsers stay distinct. @@ -385,6 +385,6 @@ bash examples/demo.sh - **Chrome internal pages** (`chrome://`, `brave://`, `about:`) cannot be scripted. DOM and extract commands only work on regular `http://` and `https://` pages. - **Profile switching** via `windows open --profile` depends on browser support and does not replace launching a separate browser profile externally with `--profile-directory`. -- **Multiple browser instances can be auto-distinguished, but generated aliases are temporary**. Unaliased browsers get UUID aliases from the native host, which avoids collisions but is less ergonomic than setting a stable alias with `browser-cli rename-profile --browser ` and restarting that browser. +- **Multiple browser instances can be auto-distinguished, but generated aliases are temporary**. Unaliased browsers get UUID aliases from the native host, which avoids collisions but is less ergonomic than setting a stable alias with `browser-cli rename-profile --browser `. - **Supported install targets are explicit, not “all Chromium browsers”**. The installer currently supports Chrome, Chromium, Brave, Edge, and Vivaldi. Other Chromium-based browsers may use different or shared native messaging manifest locations, so they need browser-specific verification before being added safely. - **Linux and macOS only** — Windows native messaging paths are not yet handled. diff --git a/browser_cli/cli.py b/browser_cli/cli.py index 3ffdcb4..cc40545 100755 --- a/browser_cli/cli.py +++ b/browser_cli/cli.py @@ -191,7 +191,6 @@ def cmd_rename_profile(target_browser, alias): console.print(f"[red]Error:[/red] {e}") sys.exit(1) console.print(f"[green]Profile renamed to '{alias}'[/green]") - console.print(" Restart the browser for the change to take effect.") # ── install ──────────────────────────────────────────────────────────────────── diff --git a/browser_cli/native_host.py b/browser_cli/native_host.py index 4470f34..1e77bcf 100644 --- a/browser_cli/native_host.py +++ b/browser_cli/native_host.py @@ -98,6 +98,9 @@ def stdin_reader(alias: str): # Profile alias handshake if msg.get("type") == "hello": continue # already handled during startup + if msg.get("type") == "bye": + _cleanup(alias) + os._exit(0) msg_id = msg.get("id") if msg_id: diff --git a/extension/background.js b/extension/background.js index da75f44..6bfa276 100644 --- a/extension/background.js +++ b/extension/background.js @@ -8,27 +8,53 @@ const NATIVE_HOST = "com.browsercli.host"; let port = null; +let keepaliveEnabled = true; // ── Connection management ───────────────────────────────────────────────────── +function sendControlMessage(targetPort, message) { + if (!targetPort) return; + try { + targetPort.postMessage(message); + } catch (e) { + console.warn("[browser-cli] Failed to send control message:", e); + } +} + +function disconnectPort({ sendBye = false } = {}) { + const currentPort = port; + if (!currentPort) return; + + if (sendBye) sendControlMessage(currentPort, { type: "bye" }); + + if (port === currentPort) port = null; + + try { + currentPort.disconnect(); + } catch (e) { + console.warn("[browser-cli] Failed to disconnect native port:", e); + } +} + async function getProfileAlias() { const { profileAlias } = await chrome.storage.local.get("profileAlias"); return profileAlias || "default"; } async function connect() { - if (port) return; + if (port || !keepaliveEnabled) return; try { - port = chrome.runtime.connectNative(NATIVE_HOST); - port.onMessage.addListener(onMessage); - port.onDisconnect.addListener(() => { - port = null; + const nativePort = chrome.runtime.connectNative(NATIVE_HOST); + port = nativePort; + nativePort.onMessage.addListener(onMessage); + nativePort.onDisconnect.addListener(() => { + if (port === nativePort) port = null; const err = chrome.runtime.lastError; if (err) console.warn("[browser-cli] Native host disconnected:", err.message); }); // Send hello so native host knows which profile/alias this is const alias = await getProfileAlias(); - port.postMessage({ type: "hello", alias }); + nativePort.postMessage({ type: "hello", alias }); console.log("[browser-cli] Connected to native host as profile:", alias); } catch (e) { port = null; @@ -38,12 +64,26 @@ async function connect() { chrome.runtime.onInstalled.addListener(connect); chrome.runtime.onStartup.addListener(connect); +chrome.runtime.onSuspend.addListener(() => { + disconnectPort({ sendBye: true }); +}); +chrome.windows.onCreated.addListener(() => { + keepaliveEnabled = true; + if (!port) connect(); +}); +chrome.windows.onRemoved.addListener(async () => { + const windows = await chrome.windows.getAll({}); + if (windows.length > 0) return; + + keepaliveEnabled = false; + disconnectPort({ sendBye: true }); +}); // Keepalive alarm — prevents service worker suspension and reconnects if needed chrome.alarms.create("keepalive", { periodInMinutes: 0.4 }); chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === "keepalive") { - if (!port) connect(); + if (!port && keepaliveEnabled) connect(); } }); @@ -69,6 +109,12 @@ async function onMessage(msg) { console.log("[browser-cli] →", command, data); port.postMessage({ id, success: true, data }); } + + if (command === "clients.rename_profile" && error === undefined) { + disconnectPort({ sendBye: true }); + keepaliveEnabled = true; + await connect(); + } } async function dispatch(command, args) { diff --git a/extension/manifest.json b/extension/manifest.json index 56ecdc5..f18b61b 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "browser-cli", - "version": "0.5.1", + "version": "0.5.2", "description": "Control your browser from the terminal via browser-cli", "permissions": [ "tabs", diff --git a/pyproject.toml b/pyproject.toml index 113d7fa..f007263 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browser-cli" -version = "0.5.1" +version = "0.5.2" description = "Control your real running browser from the terminal via a Chrome extension" requires-python = ">=3.10" dependencies = [ diff --git a/tests/test_cli.py b/tests/test_cli.py index ed4401e..b6ef267 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -41,6 +41,7 @@ def test_rename_profile_uses_global_browser_target_when_set(): assert result.exit_code == 0 send_command.assert_called_once_with("clients.rename_profile", {"alias": "work"}, profile=None) + assert "Restart the browser" not in result.output def test_install_help_lists_supported_browsers(): result = CliRunner().invoke(main, ["install", "--help"]) diff --git a/tests/test_native_host.py b/tests/test_native_host.py new file mode 100644 index 0000000..0b0dbd4 --- /dev/null +++ b/tests/test_native_host.py @@ -0,0 +1,73 @@ +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import browser_cli.native_host as native_host + + +def _raise_system_exit(code: int): + raise SystemExit(code) + + +def test_cleanup_removes_socket_and_registry_entry(monkeypatch, tmp_path): + alias = "work" + socket_path = tmp_path / "work.sock" + socket_path.write_text("") + 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) + + native_host._cleanup(alias) + + assert not socket_path.exists() + assert json.loads(registry_path.read_text()) == {"other": str(tmp_path / "other.sock")} + + +def test_stdin_reader_cleans_up_on_eof(monkeypatch): + cleaned = [] + + monkeypatch.setattr(native_host, "read_native_message", lambda stream: None) + monkeypatch.setattr(native_host, "_cleanup", cleaned.append) + monkeypatch.setattr(native_host.os, "_exit", _raise_system_exit) + monkeypatch.setattr(native_host.sys, "stdin", SimpleNamespace(buffer=object())) + + with pytest.raises(SystemExit, match="0"): + native_host.stdin_reader("work") + + assert cleaned == ["work"] + + +def test_stdin_reader_cleans_up_on_bye(monkeypatch): + cleaned = [] + messages = iter([{"type": "bye"}]) + + monkeypatch.setattr(native_host, "read_native_message", lambda stream: next(messages)) + monkeypatch.setattr(native_host, "_cleanup", cleaned.append) + monkeypatch.setattr(native_host.os, "_exit", _raise_system_exit) + monkeypatch.setattr(native_host.sys, "stdin", SimpleNamespace(buffer=object())) + + with pytest.raises(SystemExit, match="0"): + native_host.stdin_reader("work") + + assert cleaned == ["work"] + + +def test_stdin_reader_routes_response_messages(monkeypatch): + response_queue = native_host.queue.Queue() + native_host.PENDING["msg-1"] = response_queue + messages = iter([{"type": "hello"}, {"id": "msg-1", "success": True}, None]) + + monkeypatch.setattr(native_host, "read_native_message", lambda stream: next(messages)) + monkeypatch.setattr(native_host, "_cleanup", lambda alias: None) + monkeypatch.setattr(native_host.os, "_exit", _raise_system_exit) + monkeypatch.setattr(native_host.sys, "stdin", SimpleNamespace(buffer=object())) + + with pytest.raises(SystemExit, match="0"): + native_host.stdin_reader("work") + + assert response_queue.get_nowait() == {"id": "msg-1", "success": True} + native_host.PENDING.clear() diff --git a/uv.lock b/uv.lock index 5ae21ee..2448086 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.10" [[package]] name = "browser-cli" -version = "0.5.1" +version = "0.5.2" source = { editable = "." } dependencies = [ { name = "click" },