mirror of
https://github.com/Such-Software/adaptive-multi-protocol-browser.git
synced 2026-10-17 14:31:46 +02:00
Add bundled browser launch specs
This commit is contained in:
+21
-3
@@ -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}\""
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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={})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user