Add bundled browser launch specs

This commit is contained in:
jwinterm
2026-07-07 19:39:06 -04:00
parent b1db4e9390
commit 637b2e6e7c
9 changed files with 269 additions and 10 deletions
+21 -3
View File
@@ -2,12 +2,13 @@ from __future__ import annotations
import argparse
from pathlib import Path
import shlex
import sys
from .config import load_config
from .docsgen import generate_docs
from .fixtures import check_fixture_manifest
from .launch import prepare_open
from .launch import execute_open, prepare_open
from .plan import plan_url
from .platforms import PLATFORM_CHOICES
from .routing import route_url
@@ -35,12 +36,18 @@ def main(argv: list[str] | None = None) -> int:
action="store_true",
help="Approve first-use setup in the dry-run plan.",
)
open_parser.add_argument(
open_mode = open_parser.add_mutually_exclusive_group()
open_mode.add_argument(
"--dry-run",
action="store_true",
default=True,
help="Print the open plan without side effects. This is the current default.",
)
open_mode.add_argument(
"--launch",
action="store_true",
help="Create the isolated profile and launch the bundled browser when the route is ready.",
)
subcommands.add_parser("inspect", help="Inspect local transport readiness.")
@@ -64,7 +71,8 @@ def main(argv: list[str] | None = None) -> int:
return _cmd_open(
args.url,
consent=args.yes,
dry_run=args.dry_run,
dry_run=not args.launch,
launch=args.launch,
config_path=args.config,
platform=args.platform,
)
@@ -123,13 +131,20 @@ def _cmd_open(
*,
consent: bool,
dry_run: bool,
launch: bool,
config_path: Path | None,
platform: str | None,
) -> int:
config = load_config(Path.cwd(), config_path)
open_plan = prepare_open(url, consent=consent, dry_run=dry_run, config=config, platform=platform)
if launch:
open_plan = execute_open(open_plan, root=Path.cwd())
browse_plan = open_plan.browse_plan
setup_steps = "|".join(open_plan.setup_steps) if open_plan.setup_steps else "-"
launch_spec = open_plan.launch_spec
launch_command = shlex.join(launch_spec.command) if launch_spec else "-"
runtime_path = launch_spec.runtime_path if launch_spec else "-"
user_js_path = launch_spec.user_js_path if launch_spec else "-"
message = _safe(open_plan.message)
print(
"AMPBROWSER_OPEN "
@@ -146,6 +161,9 @@ def _cmd_open(
f"consent_granted={str(open_plan.consent_granted).lower()} "
f"profile_path={open_plan.profile_path} "
f"proxy={open_plan.proxy} "
f"runtime_path={runtime_path} "
f"user_js_path={user_js_path} "
f"launch_command=\"{_safe(launch_command)}\" "
f"setup_steps=\"{_safe(setup_steps)}\" "
f"message=\"{message}\""
)
+3
View File
@@ -24,6 +24,7 @@ class ConfigError(ValueError):
class AppConfig:
state_dir: str = ".ampb"
default_engine: str = "ampb-gecko"
runtime_path: str = ""
isolate_by_transport: bool = True
transport_modes: dict[str, str] | None = None
@@ -69,12 +70,14 @@ def _parse_config(data: dict[str, Any], path: Path) -> AppConfig:
state_dir = _string(browser, "state_dir", ".ampb", path)
default_engine = _string(browser, "default_engine", "ampb-gecko", path)
runtime_path = _string(browser, "runtime_path", "", path)
isolate_by_transport = _bool(profiles, "isolate_by_transport", True, path)
transport_modes = _transport_modes(transports, path)
return AppConfig(
state_dir=state_dir,
default_engine=default_engine,
runtime_path=runtime_path,
isolate_by_transport=isolate_by_transport,
transport_modes=transport_modes,
)
+166 -1
View File
@@ -1,12 +1,27 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import replace
import json
import os
from pathlib import Path
import subprocess
from urllib.parse import urlparse
from .adapters import adapter_for
from .config import AppConfig, default_config
from .plan import BrowsePlan, plan_url
@dataclass(frozen=True)
class BrowserLaunchSpec:
runtime_path: str
profile_path: str
user_js_path: str
command: tuple[str, ...]
prefs: tuple[str, ...]
@dataclass(frozen=True)
class OpenPlan:
browse_plan: BrowsePlan
@@ -17,6 +32,7 @@ class OpenPlan:
proxy: str
setup_steps: tuple[str, ...]
message: str
launch_spec: BrowserLaunchSpec | None = None
def prepare_open(
@@ -31,6 +47,7 @@ def prepare_open(
browse_plan = plan_url(raw_url, config=config, platform=platform)
profile_path = config.profile_path(browse_plan.route.profile)
proxy = _proxy_for(browse_plan)
launch_spec = _launch_spec_for(browse_plan, config=config, profile_path=profile_path)
if browse_plan.action.startswith("blocked"):
return OpenPlan(
@@ -42,6 +59,7 @@ def prepare_open(
proxy=proxy,
setup_steps=(),
message=browse_plan.action,
launch_spec=launch_spec,
)
if browse_plan.requires_consent:
@@ -56,6 +74,7 @@ def prepare_open(
proxy=proxy,
setup_steps=setup_steps,
message=browse_plan.prompt,
launch_spec=launch_spec,
)
return OpenPlan(
browse_plan=browse_plan,
@@ -65,7 +84,8 @@ def prepare_open(
profile_path=profile_path,
proxy=proxy,
setup_steps=setup_steps,
message="first-use setup approved; execution is not implemented yet",
message="first-use setup approved; browser launch waits for transport readiness",
launch_spec=launch_spec,
)
return OpenPlan(
@@ -77,9 +97,41 @@ def prepare_open(
proxy=proxy,
setup_steps=(),
message="ready to open with isolated profile",
launch_spec=launch_spec,
)
def execute_open(open_plan: OpenPlan, *, root: Path | None = None) -> OpenPlan:
root = root or Path.cwd()
spec = open_plan.launch_spec
if spec is None:
return replace(
open_plan,
dry_run=False,
status="launch-unsupported",
message=f"no browser launch path for {open_plan.browse_plan.route.transport}",
)
if open_plan.status != "ready":
return replace(
open_plan,
dry_run=False,
message=f"not launched: {open_plan.message}",
)
runtime_path = _path_from(root, spec.runtime_path)
if not runtime_path.exists():
return replace(
open_plan,
dry_run=False,
status="runtime-missing",
message=f"browser runtime not found: {spec.runtime_path}",
)
_write_profile(root, spec)
subprocess.Popen(spec.command, cwd=str(root)) # noqa: S603
return replace(open_plan, dry_run=False, status="launched", message="launched bundled browser")
def _proxy_for(browse_plan: BrowsePlan) -> str:
if browse_plan.route.transport in {"tor", "i2p"} and browse_plan.status:
return browse_plan.status.endpoint
@@ -108,3 +160,116 @@ def _setup_steps(browse_plan: BrowsePlan) -> tuple[str, ...]:
return tuple(steps)
steps.extend(adapter.setup_steps(installed=browse_plan.status.installed, platform=platform))
return tuple(steps)
def _launch_spec_for(
browse_plan: BrowsePlan,
*,
config: AppConfig,
profile_path: str,
) -> BrowserLaunchSpec | None:
if browse_plan.route.transport not in {"clearnet", "tor", "i2p"}:
return None
runtime_path = _runtime_path(config)
user_js_path = str(Path(profile_path) / "user.js")
command = (runtime_path, "-no-remote", "-profile", profile_path, browse_plan.route.normalized)
prefs = _prefs_for(browse_plan)
return BrowserLaunchSpec(
runtime_path=runtime_path,
profile_path=profile_path,
user_js_path=user_js_path,
command=command,
prefs=prefs,
)
def _runtime_path(config: AppConfig) -> str:
if config.runtime_path:
return config.runtime_path
env_path = os.environ.get("AMPB_BROWSER_BIN")
if env_path:
return env_path
build_root = Path(os.environ.get("AMPB_BROWSER_BUILD_ROOT", "/tmp/ampb-browser-build"))
for candidate in _runtime_candidates(build_root):
if candidate.exists():
return str(candidate)
return str(_runtime_candidates(build_root)[0])
def _runtime_candidates(build_root: Path) -> tuple[Path, ...]:
return (
build_root / "obj/gecko-desktop-source/dist/Nightly.app/Contents/MacOS/firefox",
build_root / "obj/gecko-desktop-source/dist/bin/firefox",
build_root / "obj/gecko-desktop-artifact/dist/Nightly.app/Contents/MacOS/firefox",
build_root / "obj/gecko-desktop-artifact/dist/bin/firefox",
)
def _prefs_for(browse_plan: BrowsePlan) -> tuple[str, ...]:
prefs: list[tuple[str, str | int | bool]] = [
("browser.shell.checkDefaultBrowser", False),
("datareporting.policy.dataSubmissionEnabled", False),
("toolkit.telemetry.enabled", False),
("network.dns.disablePrefetch", True),
("network.predictor.enabled", False),
]
transport = browse_plan.route.transport
if transport == "tor" and browse_plan.status:
host, port = _endpoint_host_port(browse_plan.status.endpoint)
prefs.extend(
[
("network.proxy.type", 1),
("network.proxy.socks", host),
("network.proxy.socks_port", port),
("network.proxy.socks_version", 5),
("network.proxy.socks_remote_dns", True),
("network.proxy.no_proxies_on", ""),
]
)
elif transport == "i2p" and browse_plan.status:
host, port = _endpoint_host_port(browse_plan.status.endpoint)
prefs.extend(
[
("network.proxy.type", 1),
("network.proxy.http", host),
("network.proxy.http_port", port),
("network.proxy.ssl", host),
("network.proxy.ssl_port", port),
("network.proxy.no_proxies_on", ""),
]
)
else:
prefs.append(("network.proxy.type", 0))
return tuple(_format_pref(name, value) for name, value in prefs)
def _endpoint_host_port(endpoint: str) -> tuple[str, int]:
parsed = urlparse(endpoint)
return parsed.hostname or "127.0.0.1", parsed.port or 0
def _format_pref(name: str, value: str | int | bool) -> str:
if isinstance(value, bool):
literal = "true" if value else "false"
elif isinstance(value, int):
literal = str(value)
else:
literal = json.dumps(value)
return f"user_pref({json.dumps(name)}, {literal});"
def _write_profile(root: Path, spec: BrowserLaunchSpec) -> None:
profile_path = _path_from(root, spec.profile_path)
profile_path.mkdir(parents=True, exist_ok=True)
user_js_path = _path_from(root, spec.user_js_path)
text = "// Managed by AMPB. Local browser profile policy.\n" + "\n".join(spec.prefs) + "\n"
user_js_path.write_text(text, encoding="utf-8")
def _path_from(root: Path, value: str) -> Path:
path = Path(value)
if path.is_absolute():
return path
return root / path