mirror of
https://github.com/Such-Software/adaptive-multi-protocol-browser.git
synced 2026-10-17 14:31:46 +02:00
Add on-demand transport prompt output
This commit is contained in:
@@ -39,6 +39,8 @@ python3 -m ampbrowser route ipfs://bafyexample/
|
||||
python3 -m ampbrowser inspect
|
||||
python3 -m ampbrowser plan gemini://wownero.org/
|
||||
python3 -m ampbrowser open https://wownero.org/
|
||||
python3 -m ampbrowser open http://example.onion/ --launch
|
||||
python3 -m ampbrowser open http://example.onion/ --yes --launch
|
||||
python3 -m ampbrowser open http://example.b32.i2p/ --yes
|
||||
python3 -m ampbrowser transport start tor
|
||||
python3 -m ampbrowser transport status tor
|
||||
|
||||
@@ -168,6 +168,9 @@ def _cmd_open(
|
||||
message=transport_result.message,
|
||||
transport_setup_status=transport_result.status,
|
||||
transport_setup_provider=transport_result.provider,
|
||||
transport_setup_owned=transport_result.owned,
|
||||
transport_setup_pid=transport_result.pid,
|
||||
transport_setup_endpoint=transport_result.endpoint,
|
||||
transport_setup_message=transport_result.message,
|
||||
)
|
||||
open_plan = execute_open(open_plan, root=Path.cwd())
|
||||
@@ -196,8 +199,15 @@ def _cmd_open(
|
||||
f"proxy={open_plan.proxy} "
|
||||
f"runtime_path={runtime_path} "
|
||||
f"user_js_path={user_js_path} "
|
||||
f"setup_prompt_title=\"{_safe(open_plan.setup_prompt_title)}\" "
|
||||
f"setup_prompt_body=\"{_safe(open_plan.setup_prompt_body)}\" "
|
||||
f"setup_prompt_approve_label=\"{_safe(open_plan.setup_prompt_approve_label)}\" "
|
||||
f"setup_prompt_approval_command=\"{_safe(open_plan.setup_prompt_approval_command)}\" "
|
||||
f"transport_setup_status={open_plan.transport_setup_status} "
|
||||
f"transport_setup_provider={open_plan.transport_setup_provider} "
|
||||
f"transport_setup_owned={str(open_plan.transport_setup_owned).lower()} "
|
||||
f"transport_setup_pid={open_plan.transport_setup_pid} "
|
||||
f"transport_setup_endpoint={open_plan.transport_setup_endpoint} "
|
||||
f"transport_setup_message=\"{_safe(open_plan.transport_setup_message)}\" "
|
||||
f"launch_command=\"{_safe(launch_command)}\" "
|
||||
f"setup_steps=\"{_safe(setup_steps)}\" "
|
||||
|
||||
@@ -5,6 +5,7 @@ from dataclasses import replace
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -34,8 +35,15 @@ class OpenPlan:
|
||||
message: str
|
||||
launch_spec: BrowserLaunchSpec | None = None
|
||||
browser_pid: int = 0
|
||||
setup_prompt_title: str = "-"
|
||||
setup_prompt_body: str = "-"
|
||||
setup_prompt_approve_label: str = "-"
|
||||
setup_prompt_approval_command: str = "-"
|
||||
transport_setup_status: str = "-"
|
||||
transport_setup_provider: str = "-"
|
||||
transport_setup_owned: bool = False
|
||||
transport_setup_pid: int = 0
|
||||
transport_setup_endpoint: str = "-"
|
||||
transport_setup_message: str = "-"
|
||||
|
||||
|
||||
@@ -68,6 +76,7 @@ def prepare_open(
|
||||
|
||||
if browse_plan.requires_consent:
|
||||
setup_steps = _setup_steps(browse_plan)
|
||||
setup_prompt = _setup_prompt(browse_plan, setup_steps)
|
||||
if not consent:
|
||||
return OpenPlan(
|
||||
browse_plan=browse_plan,
|
||||
@@ -79,6 +88,10 @@ def prepare_open(
|
||||
setup_steps=setup_steps,
|
||||
message=browse_plan.prompt,
|
||||
launch_spec=launch_spec,
|
||||
setup_prompt_title=setup_prompt.title,
|
||||
setup_prompt_body=setup_prompt.body,
|
||||
setup_prompt_approve_label=setup_prompt.approve_label,
|
||||
setup_prompt_approval_command=setup_prompt.approval_command,
|
||||
)
|
||||
return OpenPlan(
|
||||
browse_plan=browse_plan,
|
||||
@@ -90,6 +103,10 @@ def prepare_open(
|
||||
setup_steps=setup_steps,
|
||||
message="first-use setup approved; browser launch waits for transport readiness",
|
||||
launch_spec=launch_spec,
|
||||
setup_prompt_title=setup_prompt.title,
|
||||
setup_prompt_body=setup_prompt.body,
|
||||
setup_prompt_approve_label=setup_prompt.approve_label,
|
||||
setup_prompt_approval_command=setup_prompt.approval_command,
|
||||
)
|
||||
|
||||
return OpenPlan(
|
||||
@@ -166,6 +183,56 @@ def _setup_steps(browse_plan: BrowsePlan) -> tuple[str, ...]:
|
||||
return tuple(steps)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SetupPrompt:
|
||||
title: str
|
||||
body: str
|
||||
approve_label: str
|
||||
approval_command: str
|
||||
|
||||
|
||||
def _setup_prompt(browse_plan: BrowsePlan, setup_steps: tuple[str, ...]) -> _SetupPrompt:
|
||||
transport = browse_plan.route.transport
|
||||
label = _transport_label(transport)
|
||||
platform = browse_plan.platform_capability.platform
|
||||
endpoint = browse_plan.status.endpoint if browse_plan.status else "-"
|
||||
setup_summary = "; ".join(setup_steps) if setup_steps else "prepare the transport"
|
||||
if browse_plan.status and not browse_plan.status.installed:
|
||||
availability = f"{label} is not installed or running."
|
||||
else:
|
||||
availability = f"{label} is not running."
|
||||
if platform == "desktop":
|
||||
management = f"AMPB can start a managed {label} session and keep its state isolated."
|
||||
elif platform == "android":
|
||||
management = f"AMPB can start a visible Android foreground {label} session."
|
||||
elif platform == "ios":
|
||||
management = f"AMPB can start a foreground-only in-app {label} session."
|
||||
else:
|
||||
management = f"AMPB can prepare {label} for this platform."
|
||||
body = (
|
||||
f"{availability} {management} The browser will use {endpoint} for "
|
||||
f"{browse_plan.route.normalized}. Setup plan: {setup_summary}."
|
||||
)
|
||||
approval_command = shlex.join(("ampbrowser", "open", browse_plan.route.normalized, "--yes", "--launch"))
|
||||
return _SetupPrompt(
|
||||
title=f"Set up {label}?",
|
||||
body=body,
|
||||
approve_label=f"Start {label} and open",
|
||||
approval_command=approval_command,
|
||||
)
|
||||
|
||||
|
||||
def _transport_label(transport: str) -> str:
|
||||
labels = {
|
||||
"tor": "Tor",
|
||||
"i2p": "I2P",
|
||||
"ipfs": "IPFS",
|
||||
"gemini": "Gemini",
|
||||
"reticulum": "Reticulum",
|
||||
}
|
||||
return labels.get(transport, transport)
|
||||
|
||||
|
||||
def _launch_spec_for(
|
||||
browse_plan: BrowsePlan,
|
||||
*,
|
||||
|
||||
@@ -158,17 +158,21 @@ def stop_managed_transport(
|
||||
)
|
||||
|
||||
if not _pid_alive(pid):
|
||||
_unlink_state(state_path)
|
||||
cleaned = _unlink_state(state_path)
|
||||
return ManagedTransportResult(
|
||||
transport,
|
||||
provider,
|
||||
"stale",
|
||||
"stale" if cleaned else "stale-cleanup-failed",
|
||||
endpoint,
|
||||
True,
|
||||
pid,
|
||||
state_dir,
|
||||
command,
|
||||
f"removed stale AMPB-owned {transport} state",
|
||||
(
|
||||
f"removed stale AMPB-owned {transport} state"
|
||||
if cleaned
|
||||
else f"could not remove stale AMPB-owned {transport} state"
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -189,17 +193,21 @@ def stop_managed_transport(
|
||||
deadline = time.monotonic() + wait_seconds
|
||||
while time.monotonic() <= deadline:
|
||||
if not _pid_alive(pid):
|
||||
_unlink_state(state_path)
|
||||
cleaned = _unlink_state(state_path)
|
||||
return ManagedTransportResult(
|
||||
transport,
|
||||
provider,
|
||||
"stopped",
|
||||
"stopped" if cleaned else "stopped-cleanup-failed",
|
||||
endpoint,
|
||||
True,
|
||||
pid,
|
||||
state_dir,
|
||||
command,
|
||||
f"stopped AMPB-owned {transport}",
|
||||
(
|
||||
f"stopped AMPB-owned {transport}"
|
||||
if cleaned
|
||||
else f"stopped AMPB-owned {transport} but could not remove state"
|
||||
),
|
||||
)
|
||||
time.sleep(0.1)
|
||||
|
||||
@@ -404,17 +412,21 @@ def _owned_transport_result(
|
||||
command = tuple(str(part) for part in state.get("command", ()))
|
||||
|
||||
if not pid or not _pid_alive(pid):
|
||||
_unlink_state(state_path)
|
||||
cleaned = _unlink_state(state_path)
|
||||
return ManagedTransportResult(
|
||||
transport,
|
||||
provider,
|
||||
"stale",
|
||||
"stale" if cleaned else "stale-cleanup-failed",
|
||||
endpoint,
|
||||
True,
|
||||
pid,
|
||||
state_dir,
|
||||
command,
|
||||
f"removed stale AMPB-owned {transport} state",
|
||||
(
|
||||
f"removed stale AMPB-owned {transport} state"
|
||||
if cleaned
|
||||
else f"could not remove stale AMPB-owned {transport} state"
|
||||
),
|
||||
)
|
||||
|
||||
if _wait_for_endpoint(endpoint, timeout_seconds=wait_seconds):
|
||||
@@ -472,13 +484,14 @@ def _pid_alive(pid: int) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _unlink_state(path: Path) -> None:
|
||||
def _unlink_state(path: Path) -> bool:
|
||||
try:
|
||||
path.unlink()
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _endpoint_for(transport: str) -> str:
|
||||
|
||||
@@ -22,6 +22,14 @@ profile and launch the configured browser runtime when the selected route is alr
|
||||
ready. With `--launch --yes`, AMPB may start an approved managed transport before
|
||||
launching. Transport setup remains gated by first-use approval and readiness checks.
|
||||
|
||||
For a missing transport, `open --launch` does not start anything until approval is
|
||||
provided. The output includes `setup_prompt_title`, `setup_prompt_body`,
|
||||
`setup_prompt_approve_label`, and `setup_prompt_approval_command`; native shells should
|
||||
render those fields as the first-use popup. After approval, launch output includes
|
||||
`transport_setup_status`, `transport_setup_provider`, `transport_setup_owned`,
|
||||
`transport_setup_pid`, and `transport_setup_endpoint` so the shell can distinguish an
|
||||
adopted daemon from an AMPB-owned managed process.
|
||||
|
||||
## Examples
|
||||
|
||||
```sh
|
||||
@@ -31,5 +39,6 @@ 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
|
||||
python3 -m ampbrowser open http://example.onion/ --launch
|
||||
python3 -m ampbrowser open http://example.onion/ --yes --launch
|
||||
```
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import io
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from ampbrowser.cli import main
|
||||
from ampbrowser.transport_manager import ManagedTransportResult
|
||||
from ampbrowser.transports import TransportStatus
|
||||
|
||||
|
||||
class CliOpenTest(unittest.TestCase):
|
||||
def test_onion_launch_without_approval_prints_prompt_without_starting_transport(self) -> None:
|
||||
status = TransportStatus(
|
||||
transport="tor",
|
||||
installed=True,
|
||||
running=False,
|
||||
endpoint="socks5://127.0.0.1:9050",
|
||||
adoptable=False,
|
||||
manage_supported=True,
|
||||
note="Tor SOCKS proxy",
|
||||
)
|
||||
|
||||
with patch("ampbrowser.plan.inspect_transport", return_value=status):
|
||||
with patch("ampbrowser.cli.ensure_transport_ready") as ensure_transport_ready:
|
||||
with patch.object(sys, "stdout", new_callable=io.StringIO) as stdout:
|
||||
code = main(["open", "http://example.onion/", "--launch"])
|
||||
|
||||
self.assertEqual(0, code)
|
||||
ensure_transport_ready.assert_not_called()
|
||||
output = stdout.getvalue()
|
||||
self.assertIn("status=consent-required", output)
|
||||
self.assertIn('setup_prompt_title="Set up Tor?"', output)
|
||||
self.assertIn("setup_prompt_approval_command=", output)
|
||||
self.assertIn("transport_setup_status=-", output)
|
||||
|
||||
def test_onion_launch_with_approval_starts_transport_before_browser_launch(self) -> None:
|
||||
status = TransportStatus(
|
||||
transport="tor",
|
||||
installed=True,
|
||||
running=False,
|
||||
endpoint="socks5://127.0.0.1:9050",
|
||||
adoptable=False,
|
||||
manage_supported=True,
|
||||
note="Tor SOCKS proxy",
|
||||
)
|
||||
transport_result = ManagedTransportResult(
|
||||
transport="tor",
|
||||
provider="arti",
|
||||
status="started",
|
||||
endpoint="socks5://127.0.0.1:9050",
|
||||
owned=True,
|
||||
pid=1234,
|
||||
state_dir=".ampb/transports/tor",
|
||||
command=("/tmp/arti", "proxy"),
|
||||
message="started managed arti transport",
|
||||
)
|
||||
|
||||
def fake_execute(open_plan, *, root: Path):
|
||||
return replace(
|
||||
open_plan,
|
||||
status="launched",
|
||||
dry_run=False,
|
||||
browser_pid=5678,
|
||||
message="launched bundled browser",
|
||||
)
|
||||
|
||||
with patch("ampbrowser.plan.inspect_transport", return_value=status):
|
||||
with patch("ampbrowser.cli.ensure_transport_ready", return_value=transport_result) as ensure_transport_ready:
|
||||
with patch("ampbrowser.cli.execute_open", side_effect=fake_execute) as execute_open:
|
||||
with patch.object(sys, "stdout", new_callable=io.StringIO) as stdout:
|
||||
code = main(["open", "http://example.onion/", "--yes", "--launch"])
|
||||
|
||||
self.assertEqual(0, code)
|
||||
ensure_transport_ready.assert_called_once()
|
||||
execute_open.assert_called_once()
|
||||
output = stdout.getvalue()
|
||||
self.assertIn("status=launched", output)
|
||||
self.assertIn("browser_pid=5678", output)
|
||||
self.assertIn("transport_setup_status=started", output)
|
||||
self.assertIn("transport_setup_provider=arti", output)
|
||||
self.assertIn("transport_setup_owned=true", output)
|
||||
self.assertIn("transport_setup_pid=1234", output)
|
||||
self.assertIn("transport_setup_endpoint=socks5://127.0.0.1:9050", output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -49,6 +49,14 @@ class PrepareOpenTest(unittest.TestCase):
|
||||
plan.setup_steps,
|
||||
)
|
||||
self.assertIn("install and start", plan.message)
|
||||
self.assertEqual("Set up I2P?", plan.setup_prompt_title)
|
||||
self.assertIn("I2P is not installed or running.", plan.setup_prompt_body)
|
||||
self.assertIn("http://example.b32.i2p/", plan.setup_prompt_body)
|
||||
self.assertEqual("Start I2P and open", plan.setup_prompt_approve_label)
|
||||
self.assertEqual(
|
||||
"ampbrowser open http://example.b32.i2p/ --yes --launch",
|
||||
plan.setup_prompt_approval_command,
|
||||
)
|
||||
|
||||
def test_yes_approves_setup_without_executing_it(self) -> None:
|
||||
status = TransportStatus(
|
||||
@@ -68,6 +76,9 @@ class PrepareOpenTest(unittest.TestCase):
|
||||
self.assertTrue(plan.dry_run)
|
||||
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("Set up Tor?", plan.setup_prompt_title)
|
||||
self.assertIn("Tor is not running.", plan.setup_prompt_body)
|
||||
self.assertEqual("Start Tor and open", plan.setup_prompt_approve_label)
|
||||
|
||||
def test_adopted_tor_launch_spec_sets_socks_remote_dns(self) -> None:
|
||||
status = TransportStatus(
|
||||
|
||||
@@ -139,6 +139,20 @@ class TransportManagerTest(unittest.TestCase):
|
||||
self.assertEqual("stale", result.status)
|
||||
self.assertFalse(state_path.exists())
|
||||
|
||||
def test_transport_status_reports_stale_cleanup_failure(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
state_dir = root / ".ampb/transports/tor"
|
||||
state_dir.mkdir(parents=True)
|
||||
state_path = state_dir / "ampb-owned.json"
|
||||
state_path.write_text('{"pid": 1234, "provider": "arti", "endpoint": "socks5://127.0.0.1:9050"}\n', encoding="utf-8")
|
||||
with patch("ampbrowser.transport_manager._pid_alive", return_value=False):
|
||||
with patch("ampbrowser.transport_manager._unlink_state", return_value=False):
|
||||
result = transport_status("tor", config=AppConfig(transport_modes={}), root=root)
|
||||
|
||||
self.assertEqual("stale-cleanup-failed", result.status)
|
||||
self.assertIn("could not remove stale", result.message)
|
||||
|
||||
def test_stop_managed_transport_terminates_recorded_pid(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
|
||||
Reference in New Issue
Block a user