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
+2
View File
@@ -29,6 +29,8 @@ Example configs live under `examples/`.
[browser]
default_engine = "ampb-gecko"
state_dir = ".ampb"
# Optional. Leave unset to use AMPB_BROWSER_BIN or the default /tmp build output.
runtime_path = ""
[transports.tor]
enabled = true
+4 -2
View File
@@ -17,8 +17,9 @@ application starts. Setup is lazy:
4. If the transport is missing, AMPB shows a prompt naming what will be installed or started.
5. AMPB proceeds only after approval.
Current `open` output is a dry-run launch spec. It is intentionally side-effect free while
the transport adapters and browser launcher are being stabilized.
By default, `open` output is a dry-run launch spec. Use `--launch` to create the isolated
profile and launch the configured browser runtime when the selected route is already
ready. Transport install/start remains gated by first-use approval and readiness checks.
## Examples
@@ -28,4 +29,5 @@ python3 -m ampbrowser open http://example.b32.i2p/
python3 -m ampbrowser open http://example.b32.i2p/ --yes
python3 -m ampbrowser open http://example.b32.i2p/ --config examples/config.toml
python3 -m ampbrowser open http://example.b32.i2p/ --platform android
python3 -m ampbrowser open https://wownero.org/ --launch
```
+4 -2
View File
@@ -5,8 +5,10 @@
Transport management follows one rule: use what is already healthy, and manage missing
transports only after the user opens a URL that needs them and approves setup.
`ampbrowser open` currently emits a dry-run launch spec. The spec includes the selected
profile, proxy endpoint, required setup steps, and whether user consent is still needed.
`ampbrowser open` emits a dry-run launch spec by default. The spec includes the selected
profile, proxy endpoint, browser runtime path, generated profile prefs path, required
setup steps, and whether user consent is still needed. With `--launch`, AMPB creates the
profile and starts the bundled browser only when the selected route is already ready.
Transport policy comes from `.ampb/config.toml` or an explicit `--config` path.
## Lifecycle
+2
View File
@@ -1,6 +1,8 @@
[browser]
default_engine = "ampb-gecko"
state_dir = ".ampb"
# Optional. Leave unset to use AMPB_BROWSER_BIN or the default /tmp build output.
runtime_path = ""
[transports.tor]
enabled = true
+3
View File
@@ -14,6 +14,7 @@ class ConfigTest(unittest.TestCase):
self.assertEqual(".ampb", config.state_dir)
self.assertEqual("ampb-gecko", config.default_engine)
self.assertEqual("", config.runtime_path)
self.assertEqual("adopt-or-prompt-manage", config.transport_mode("tor"))
self.assertEqual(".ampb/profiles/tor", config.profile_path("tor"))
@@ -24,6 +25,7 @@ class ConfigTest(unittest.TestCase):
"""
[browser]
state_dir = ".local/ampb"
runtime_path = "/opt/ampb/firefox"
[transports.tor]
mode = "adopt"
@@ -37,6 +39,7 @@ enabled = false
config = load_config(Path(tmp), path)
self.assertEqual(".local/ampb", config.state_dir)
self.assertEqual("/opt/ampb/firefox", config.runtime_path)
self.assertEqual("adopt", config.transport_mode("tor"))
self.assertEqual("disabled", config.transport_mode("i2p"))
self.assertEqual(".local/ampb/profiles/i2p", config.profile_path("i2p"))
+64 -2
View File
@@ -2,20 +2,31 @@ from __future__ import annotations
import unittest
from unittest.mock import patch
import tempfile
from pathlib import Path
from ampbrowser.config import AppConfig
from ampbrowser.launch import prepare_open
from ampbrowser.launch import execute_open, prepare_open
from ampbrowser.transports import TransportStatus
class PrepareOpenTest(unittest.TestCase):
def test_clearnet_is_ready_without_consent(self) -> None:
plan = prepare_open("wownero.org")
config = AppConfig(runtime_path="/opt/ampb/firefox", transport_modes={})
plan = prepare_open("wownero.org", config=config)
self.assertEqual("ready", plan.status)
self.assertFalse(plan.browse_plan.requires_consent)
self.assertEqual(".ampb/profiles/clearnet", plan.profile_path)
self.assertEqual("-", plan.proxy)
self.assertIsNotNone(plan.launch_spec)
self.assertEqual("/opt/ampb/firefox", plan.launch_spec.runtime_path)
self.assertEqual(".ampb/profiles/clearnet/user.js", plan.launch_spec.user_js_path)
self.assertEqual(
("/opt/ampb/firefox", "-no-remote", "-profile", ".ampb/profiles/clearnet", "https://wownero.org"),
plan.launch_spec.command,
)
def test_missing_i2p_requires_consent_before_setup(self) -> None:
status = TransportStatus(
@@ -58,6 +69,57 @@ class PrepareOpenTest(unittest.TestCase):
self.assertTrue(plan.consent_granted)
self.assertEqual(("start managed Arti SOCKS proxy", "wait for socks5://127.0.0.1:9050"), plan.setup_steps)
def test_adopted_tor_launch_spec_sets_socks_remote_dns(self) -> None:
status = TransportStatus(
transport="tor",
installed=True,
running=True,
endpoint="socks5://127.0.0.1:9050",
adoptable=True,
manage_supported=True,
note="Tor SOCKS proxy",
)
config = AppConfig(runtime_path="/opt/ampb/firefox", transport_modes={})
with patch("ampbrowser.plan.inspect_transport", return_value=status):
plan = prepare_open("http://example.onion/", config=config)
self.assertEqual("ready", plan.status)
self.assertIsNotNone(plan.launch_spec)
self.assertIn('user_pref("network.proxy.socks", "127.0.0.1");', plan.launch_spec.prefs)
self.assertIn('user_pref("network.proxy.socks_port", 9050);', plan.launch_spec.prefs)
self.assertIn('user_pref("network.proxy.socks_remote_dns", true);', plan.launch_spec.prefs)
def test_execute_open_writes_profile_and_launches_when_ready(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
runtime = root / "firefox"
runtime.write_text("#!/bin/sh\n", encoding="utf-8")
config = AppConfig(runtime_path=str(runtime), transport_modes={})
plan = prepare_open("wownero.org", config=config)
with patch("ampbrowser.launch.subprocess.Popen") as popen:
launched = execute_open(plan, root=root)
self.assertEqual("launched", launched.status)
self.assertFalse(launched.dry_run)
popen.assert_called_once_with(
(str(runtime), "-no-remote", "-profile", ".ampb/profiles/clearnet", "https://wownero.org"),
cwd=str(root),
)
user_js = root / ".ampb/profiles/clearnet/user.js"
self.assertTrue(user_js.exists())
self.assertIn('user_pref("network.proxy.type", 0);', user_js.read_text(encoding="utf-8"))
def test_execute_open_blocks_when_runtime_is_missing(self) -> None:
config = AppConfig(runtime_path="/missing/ampb/firefox", transport_modes={})
plan = prepare_open("wownero.org", config=config)
launched = execute_open(plan, root=Path("/tmp"))
self.assertEqual("runtime-missing", launched.status)
self.assertIn("browser runtime not found", launched.message)
def test_custom_state_dir_changes_profile_path(self) -> None:
config = AppConfig(state_dir=".local/ampb", transport_modes={})