move registry into own files
This commit is contained in:
+2
-4
@@ -34,6 +34,7 @@ from browser_cli.client import (
|
|||||||
save_remote_token,
|
save_remote_token,
|
||||||
)
|
)
|
||||||
from browser_cli.platform import install_base_dir, is_windows
|
from browser_cli.platform import install_base_dir, is_windows
|
||||||
|
from browser_cli.registry import load_registry
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
@@ -231,10 +232,7 @@ def clients_group(ctx):
|
|||||||
else:
|
else:
|
||||||
profiles: dict[str, str] = {}
|
profiles: dict[str, str] = {}
|
||||||
if REGISTRY_PATH.exists():
|
if REGISTRY_PATH.exists():
|
||||||
try:
|
profiles = load_registry(REGISTRY_PATH)
|
||||||
profiles = json.loads(REGISTRY_PATH.read_text())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
for profile_name, sock_path in profiles.items():
|
for profile_name, sock_path in profiles.items():
|
||||||
display_profile = display_browser_name(profile_name, sock_path)
|
display_profile = display_browser_name(profile_name, sock_path)
|
||||||
|
|||||||
+5
-10
@@ -19,6 +19,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path
|
from browser_cli.platform import endpoint_for_alias, is_windows, registry_path
|
||||||
|
from browser_cli.registry import load_registry
|
||||||
|
|
||||||
REGISTRY_PATH = registry_path()
|
REGISTRY_PATH = registry_path()
|
||||||
REMOTE_REGISTRY_PATH = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "browser-cli" / "remotes.json"
|
REMOTE_REGISTRY_PATH = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "browser-cli" / "remotes.json"
|
||||||
@@ -118,10 +119,7 @@ def _remote_browser_targets() -> list[BrowserTarget]:
|
|||||||
def active_browser_targets(*, include_remotes: bool = True) -> list[BrowserTarget]:
|
def active_browser_targets(*, include_remotes: bool = True) -> list[BrowserTarget]:
|
||||||
targets: list[BrowserTarget] = []
|
targets: list[BrowserTarget] = []
|
||||||
if REGISTRY_PATH.exists():
|
if REGISTRY_PATH.exists():
|
||||||
try:
|
reg = load_registry(REGISTRY_PATH)
|
||||||
reg = json.loads(REGISTRY_PATH.read_text())
|
|
||||||
except Exception:
|
|
||||||
reg = {}
|
|
||||||
targets.extend(
|
targets.extend(
|
||||||
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_endpoints(reg).items()
|
for profile, sock_path in _active_endpoints(reg).items()
|
||||||
@@ -137,12 +135,9 @@ def _resolve_socket(profile: str | None = None) -> str:
|
|||||||
|
|
||||||
if target:
|
if target:
|
||||||
if REGISTRY_PATH.exists():
|
if REGISTRY_PATH.exists():
|
||||||
try:
|
reg = load_registry(REGISTRY_PATH)
|
||||||
reg = json.loads(REGISTRY_PATH.read_text())
|
if target in reg:
|
||||||
if target in reg:
|
return reg[target]
|
||||||
return reg[target]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return endpoint_for_alias(target)
|
return endpoint_for_alias(target)
|
||||||
|
|
||||||
# Auto-detect: error when multiple browser instances are active
|
# Auto-detect: error when multiple browser instances are active
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from multiprocessing.connection import Listener
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from browser_cli.platform import DEFAULT_ALIAS, endpoint_for_alias, is_windows, registry_path, runtime_dir
|
from browser_cli.platform import DEFAULT_ALIAS, endpoint_for_alias, is_windows, registry_path, runtime_dir
|
||||||
|
from browser_cli.registry import update_registry
|
||||||
|
|
||||||
SOCKET_PATH: str = "" # set after hello handshake
|
SOCKET_PATH: str = "" # set after hello handshake
|
||||||
PENDING: dict[str, queue.Queue] = {}
|
PENDING: dict[str, queue.Queue] = {}
|
||||||
@@ -67,20 +68,14 @@ def write_native_message(stream, msg: dict) -> None:
|
|||||||
|
|
||||||
def _registry_add(alias: str, sock_path: str) -> None:
|
def _registry_add(alias: str, sock_path: str) -> None:
|
||||||
try:
|
try:
|
||||||
reg = json.loads(REGISTRY_PATH.read_text()) if REGISTRY_PATH.exists() else {}
|
update_registry(alias, sock_path, REGISTRY_PATH)
|
||||||
reg[alias] = sock_path
|
|
||||||
REGISTRY_PATH.write_text(json.dumps(reg))
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _registry_remove(alias: str) -> None:
|
def _registry_remove(alias: str) -> None:
|
||||||
try:
|
try:
|
||||||
if not REGISTRY_PATH.exists():
|
update_registry(alias, None, REGISTRY_PATH)
|
||||||
return
|
|
||||||
reg = json.loads(REGISTRY_PATH.read_text())
|
|
||||||
reg.pop(alias, None)
|
|
||||||
REGISTRY_PATH.write_text(json.dumps(reg))
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Runtime registry helpers for active browser-cli native host endpoints."""
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
from browser_cli.platform import registry_path
|
||||||
|
|
||||||
|
REGISTRY_PATH = registry_path()
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _file_lock(path: Path) -> Iterator[None]:
|
||||||
|
"""Best-effort cross-process lock for registry read/modify/write updates."""
|
||||||
|
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
lock_path = path.with_suffix(path.suffix + ".lock")
|
||||||
|
with lock_path.open("a+") as lock_file:
|
||||||
|
if os.name == "nt":
|
||||||
|
try:
|
||||||
|
import msvcrt
|
||||||
|
|
||||||
|
msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _coerce_registry(data) -> dict[str, str]:
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {}
|
||||||
|
return {str(alias): str(endpoint) for alias, endpoint in data.items() if alias and endpoint}
|
||||||
|
|
||||||
|
def load_registry(path: Path | None = None) -> dict[str, str]:
|
||||||
|
"""Load the active browser registry.
|
||||||
|
|
||||||
|
Older native hosts wrote this file non-atomically, so tolerate trailing
|
||||||
|
garbage from interrupted/concurrent writes and keep the first valid JSON
|
||||||
|
object when possible.
|
||||||
|
"""
|
||||||
|
registry = path or REGISTRY_PATH
|
||||||
|
if not registry.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
text = registry.read_text(encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
return {}
|
||||||
|
if not text.strip():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return _coerce_registry(json.loads(text))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
try:
|
||||||
|
data, _ = json.JSONDecoder().raw_decode(text)
|
||||||
|
return _coerce_registry(data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def save_registry(data: dict[str, str], path: Path | None = None) -> None:
|
||||||
|
"""Atomically write the active browser registry."""
|
||||||
|
registry = path or REGISTRY_PATH
|
||||||
|
registry.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
payload = json.dumps(_coerce_registry(data), sort_keys=True)
|
||||||
|
fd, tmp_name = tempfile.mkstemp(prefix=registry.name + ".", suffix=".tmp", dir=registry.parent)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as tmp:
|
||||||
|
tmp.write(payload)
|
||||||
|
tmp.flush()
|
||||||
|
os.fsync(tmp.fileno())
|
||||||
|
os.replace(tmp_name, registry)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_name)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update_registry(alias: str, endpoint: str | None, path: Path | None = None) -> None:
|
||||||
|
"""Add/update an alias, or remove it when endpoint is None."""
|
||||||
|
registry = path or REGISTRY_PATH
|
||||||
|
with _file_lock(registry):
|
||||||
|
data = load_registry(registry)
|
||||||
|
if endpoint is None:
|
||||||
|
data.pop(alias, None)
|
||||||
|
else:
|
||||||
|
data[alias] = endpoint
|
||||||
|
save_registry(data, registry)
|
||||||
@@ -141,6 +141,25 @@ def test_clients_exits_cleanly_when_registry_is_missing():
|
|||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "No browser clients found" in result.output
|
assert "No browser clients found" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_clients_reads_registry_with_trailing_garbage(tmp_path):
|
||||||
|
registry_path = tmp_path / "registry.json"
|
||||||
|
registry_path.write_text('{"main": "/tmp/.browser_cli/main.sock"}"}', encoding="utf-8")
|
||||||
|
|
||||||
|
def fake_send_command(command, args=None, profile=None):
|
||||||
|
assert command == "clients.list"
|
||||||
|
assert profile == "main"
|
||||||
|
return [{"profile": "main", "name": "Chrome", "version": "1", "extensionVersion": "0.8.2"}]
|
||||||
|
|
||||||
|
with patch("browser_cli.cli.REGISTRY_PATH", registry_path), patch(
|
||||||
|
"browser_cli.cli.send_command", side_effect=fake_send_command
|
||||||
|
):
|
||||||
|
result = CliRunner().invoke(main, ["clients"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "main" in result.output
|
||||||
|
assert "0.8.2" in result.output
|
||||||
|
|
||||||
def test_clients_remote_uses_remote_endpoint_without_local_registry():
|
def test_clients_remote_uses_remote_endpoint_without_local_registry():
|
||||||
def fake_send_command(command, args=None, profile=None):
|
def fake_send_command(command, args=None, profile=None):
|
||||||
assert command == "clients.list"
|
assert command == "clients.list"
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from browser_cli.registry import load_registry, save_registry, update_registry
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_registry_tolerates_trailing_garbage_from_old_non_atomic_writes(tmp_path):
|
||||||
|
registry = tmp_path / "registry.json"
|
||||||
|
registry.write_text('{"main": "/tmp/.browser_cli/main.sock"}"}', encoding="utf-8")
|
||||||
|
|
||||||
|
assert load_registry(registry) == {"main": "/tmp/.browser_cli/main.sock"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_registry_repairs_corrupted_registry_and_preserves_entries(tmp_path):
|
||||||
|
registry = tmp_path / "registry.json"
|
||||||
|
registry.write_text('{"main": "/tmp/.browser_cli/main.sock"}"}', encoding="utf-8")
|
||||||
|
|
||||||
|
update_registry("work", "/tmp/.browser_cli/work.sock", registry)
|
||||||
|
|
||||||
|
assert json.loads(registry.read_text(encoding="utf-8")) == {
|
||||||
|
"main": "/tmp/.browser_cli/main.sock",
|
||||||
|
"work": "/tmp/.browser_cli/work.sock",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_registry_writes_valid_json_atomically(tmp_path):
|
||||||
|
registry = tmp_path / "registry.json"
|
||||||
|
|
||||||
|
save_registry({"main": "/tmp/main.sock"}, registry)
|
||||||
|
|
||||||
|
assert json.loads(registry.read_text(encoding="utf-8")) == {"main": "/tmp/main.sock"}
|
||||||
Reference in New Issue
Block a user