allow to rename the profile without a browser restart and remove old sockets and registry entry when browser closes
Build & Publish Package / publish (push) Successful in 27s
Package Extension / package-extension (push) Failing after 10m17s

This commit is contained in:
2026-04-10 12:02:14 +02:00
parent c9ecde9338
commit 6979f2ef30
9 changed files with 135 additions and 13 deletions
+2 -2
View File
@@ -122,7 +122,7 @@ browser-cli/
All commands are run with `uv run browser-cli [--browser ALIAS] <command>`.
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 <current-alias> <new-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 <current-alias> <new-alias>`. 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 <current-alias> <new-alias>` 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 <current-alias> <new-alias>`.
- **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.
-1
View File
@@ -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 ────────────────────────────────────────────────────────────────────
+3
View File
@@ -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:
+53 -7
View File
@@ -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) {
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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 = [
+1
View File
@@ -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"])
+73
View File
@@ -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()
Generated
+1 -1
View File
@@ -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" },