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 import argparse
from pathlib import Path from pathlib import Path
import shlex
import sys import sys
from .config import load_config from .config import load_config
from .docsgen import generate_docs from .docsgen import generate_docs
from .fixtures import check_fixture_manifest from .fixtures import check_fixture_manifest
from .launch import prepare_open from .launch import execute_open, prepare_open
from .plan import plan_url from .plan import plan_url
from .platforms import PLATFORM_CHOICES from .platforms import PLATFORM_CHOICES
from .routing import route_url from .routing import route_url
@@ -35,12 +36,18 @@ def main(argv: list[str] | None = None) -> int:
action="store_true", action="store_true",
help="Approve first-use setup in the dry-run plan.", 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", "--dry-run",
action="store_true", action="store_true",
default=True, default=True,
help="Print the open plan without side effects. This is the current default.", 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.") subcommands.add_parser("inspect", help="Inspect local transport readiness.")
@@ -64,7 +71,8 @@ def main(argv: list[str] | None = None) -> int:
return _cmd_open( return _cmd_open(
args.url, args.url,
consent=args.yes, consent=args.yes,
dry_run=args.dry_run, dry_run=not args.launch,
launch=args.launch,
config_path=args.config, config_path=args.config,
platform=args.platform, platform=args.platform,
) )
@@ -123,13 +131,20 @@ def _cmd_open(
*, *,
consent: bool, consent: bool,
dry_run: bool, dry_run: bool,
launch: bool,
config_path: Path | None, config_path: Path | None,
platform: str | None, platform: str | None,
) -> int: ) -> int:
config = load_config(Path.cwd(), config_path) config = load_config(Path.cwd(), config_path)
open_plan = prepare_open(url, consent=consent, dry_run=dry_run, config=config, platform=platform) 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 browse_plan = open_plan.browse_plan
setup_steps = "|".join(open_plan.setup_steps) if open_plan.setup_steps else "-" 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) message = _safe(open_plan.message)
print( print(
"AMPBROWSER_OPEN " "AMPBROWSER_OPEN "
@@ -146,6 +161,9 @@ def _cmd_open(
f"consent_granted={str(open_plan.consent_granted).lower()} " f"consent_granted={str(open_plan.consent_granted).lower()} "
f"profile_path={open_plan.profile_path} " f"profile_path={open_plan.profile_path} "
f"proxy={open_plan.proxy} " 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"setup_steps=\"{_safe(setup_steps)}\" "
f"message=\"{message}\"" f"message=\"{message}\""
) )
+3
View File
@@ -24,6 +24,7 @@ class ConfigError(ValueError):
class AppConfig: class AppConfig:
state_dir: str = ".ampb" state_dir: str = ".ampb"
default_engine: str = "ampb-gecko" default_engine: str = "ampb-gecko"
runtime_path: str = ""
isolate_by_transport: bool = True isolate_by_transport: bool = True
transport_modes: dict[str, str] | None = None 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) state_dir = _string(browser, "state_dir", ".ampb", path)
default_engine = _string(browser, "default_engine", "ampb-gecko", 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) isolate_by_transport = _bool(profiles, "isolate_by_transport", True, path)
transport_modes = _transport_modes(transports, path) transport_modes = _transport_modes(transports, path)
return AppConfig( return AppConfig(
state_dir=state_dir, state_dir=state_dir,
default_engine=default_engine, default_engine=default_engine,
runtime_path=runtime_path,
isolate_by_transport=isolate_by_transport, isolate_by_transport=isolate_by_transport,
transport_modes=transport_modes, transport_modes=transport_modes,
) )
+166 -1
View File
@@ -1,12 +1,27 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass 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 .adapters import adapter_for
from .config import AppConfig, default_config from .config import AppConfig, default_config
from .plan import BrowsePlan, plan_url 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) @dataclass(frozen=True)
class OpenPlan: class OpenPlan:
browse_plan: BrowsePlan browse_plan: BrowsePlan
@@ -17,6 +32,7 @@ class OpenPlan:
proxy: str proxy: str
setup_steps: tuple[str, ...] setup_steps: tuple[str, ...]
message: str message: str
launch_spec: BrowserLaunchSpec | None = None
def prepare_open( def prepare_open(
@@ -31,6 +47,7 @@ def prepare_open(
browse_plan = plan_url(raw_url, config=config, platform=platform) browse_plan = plan_url(raw_url, config=config, platform=platform)
profile_path = config.profile_path(browse_plan.route.profile) profile_path = config.profile_path(browse_plan.route.profile)
proxy = _proxy_for(browse_plan) proxy = _proxy_for(browse_plan)
launch_spec = _launch_spec_for(browse_plan, config=config, profile_path=profile_path)
if browse_plan.action.startswith("blocked"): if browse_plan.action.startswith("blocked"):
return OpenPlan( return OpenPlan(
@@ -42,6 +59,7 @@ def prepare_open(
proxy=proxy, proxy=proxy,
setup_steps=(), setup_steps=(),
message=browse_plan.action, message=browse_plan.action,
launch_spec=launch_spec,
) )
if browse_plan.requires_consent: if browse_plan.requires_consent:
@@ -56,6 +74,7 @@ def prepare_open(
proxy=proxy, proxy=proxy,
setup_steps=setup_steps, setup_steps=setup_steps,
message=browse_plan.prompt, message=browse_plan.prompt,
launch_spec=launch_spec,
) )
return OpenPlan( return OpenPlan(
browse_plan=browse_plan, browse_plan=browse_plan,
@@ -65,7 +84,8 @@ def prepare_open(
profile_path=profile_path, profile_path=profile_path,
proxy=proxy, proxy=proxy,
setup_steps=setup_steps, 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( return OpenPlan(
@@ -77,9 +97,41 @@ def prepare_open(
proxy=proxy, proxy=proxy,
setup_steps=(), setup_steps=(),
message="ready to open with isolated profile", 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: def _proxy_for(browse_plan: BrowsePlan) -> str:
if browse_plan.route.transport in {"tor", "i2p"} and browse_plan.status: if browse_plan.route.transport in {"tor", "i2p"} and browse_plan.status:
return browse_plan.status.endpoint return browse_plan.status.endpoint
@@ -108,3 +160,116 @@ def _setup_steps(browse_plan: BrowsePlan) -> tuple[str, ...]:
return tuple(steps) return tuple(steps)
steps.extend(adapter.setup_steps(installed=browse_plan.status.installed, platform=platform)) steps.extend(adapter.setup_steps(installed=browse_plan.status.installed, platform=platform))
return tuple(steps) 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] [browser]
default_engine = "ampb-gecko" default_engine = "ampb-gecko"
state_dir = ".ampb" state_dir = ".ampb"
# Optional. Leave unset to use AMPB_BROWSER_BIN or the default /tmp build output.
runtime_path = ""
[transports.tor] [transports.tor]
enabled = true 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. 4. If the transport is missing, AMPB shows a prompt naming what will be installed or started.
5. AMPB proceeds only after approval. 5. AMPB proceeds only after approval.
Current `open` output is a dry-run launch spec. It is intentionally side-effect free while By default, `open` output is a dry-run launch spec. Use `--launch` to create the isolated
the transport adapters and browser launcher are being stabilized. 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 ## 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/ --yes
python3 -m ampbrowser open http://example.b32.i2p/ --config examples/config.toml 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 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 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. 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 `ampbrowser open` emits a dry-run launch spec by default. The spec includes the selected
profile, proxy endpoint, required setup steps, and whether user consent is still needed. 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. Transport policy comes from `.ampb/config.toml` or an explicit `--config` path.
## Lifecycle ## Lifecycle
+2
View File
@@ -1,6 +1,8 @@
[browser] [browser]
default_engine = "ampb-gecko" default_engine = "ampb-gecko"
state_dir = ".ampb" state_dir = ".ampb"
# Optional. Leave unset to use AMPB_BROWSER_BIN or the default /tmp build output.
runtime_path = ""
[transports.tor] [transports.tor]
enabled = true enabled = true
+3
View File
@@ -14,6 +14,7 @@ class ConfigTest(unittest.TestCase):
self.assertEqual(".ampb", config.state_dir) self.assertEqual(".ampb", config.state_dir)
self.assertEqual("ampb-gecko", config.default_engine) self.assertEqual("ampb-gecko", config.default_engine)
self.assertEqual("", config.runtime_path)
self.assertEqual("adopt-or-prompt-manage", config.transport_mode("tor")) self.assertEqual("adopt-or-prompt-manage", config.transport_mode("tor"))
self.assertEqual(".ampb/profiles/tor", config.profile_path("tor")) self.assertEqual(".ampb/profiles/tor", config.profile_path("tor"))
@@ -24,6 +25,7 @@ class ConfigTest(unittest.TestCase):
""" """
[browser] [browser]
state_dir = ".local/ampb" state_dir = ".local/ampb"
runtime_path = "/opt/ampb/firefox"
[transports.tor] [transports.tor]
mode = "adopt" mode = "adopt"
@@ -37,6 +39,7 @@ enabled = false
config = load_config(Path(tmp), path) config = load_config(Path(tmp), path)
self.assertEqual(".local/ampb", config.state_dir) self.assertEqual(".local/ampb", config.state_dir)
self.assertEqual("/opt/ampb/firefox", config.runtime_path)
self.assertEqual("adopt", config.transport_mode("tor")) self.assertEqual("adopt", config.transport_mode("tor"))
self.assertEqual("disabled", config.transport_mode("i2p")) self.assertEqual("disabled", config.transport_mode("i2p"))
self.assertEqual(".local/ampb/profiles/i2p", config.profile_path("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 import unittest
from unittest.mock import patch from unittest.mock import patch
import tempfile
from pathlib import Path
from ampbrowser.config import AppConfig from ampbrowser.config import AppConfig
from ampbrowser.launch import prepare_open from ampbrowser.launch import execute_open, prepare_open
from ampbrowser.transports import TransportStatus from ampbrowser.transports import TransportStatus
class PrepareOpenTest(unittest.TestCase): class PrepareOpenTest(unittest.TestCase):
def test_clearnet_is_ready_without_consent(self) -> None: 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.assertEqual("ready", plan.status)
self.assertFalse(plan.browse_plan.requires_consent) self.assertFalse(plan.browse_plan.requires_consent)
self.assertEqual(".ampb/profiles/clearnet", plan.profile_path) self.assertEqual(".ampb/profiles/clearnet", plan.profile_path)
self.assertEqual("-", plan.proxy) 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: def test_missing_i2p_requires_consent_before_setup(self) -> None:
status = TransportStatus( status = TransportStatus(
@@ -58,6 +69,57 @@ class PrepareOpenTest(unittest.TestCase):
self.assertTrue(plan.consent_granted) self.assertTrue(plan.consent_granted)
self.assertEqual(("start managed Arti SOCKS proxy", "wait for socks5://127.0.0.1:9050"), plan.setup_steps) 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: def test_custom_state_dir_changes_profile_path(self) -> None:
config = AppConfig(state_dir=".local/ampb", transport_modes={}) config = AppConfig(state_dir=".local/ampb", transport_modes={})