From e255093203ea9aabae382f94ba2366920f1ea720 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Wed, 8 Jul 2026 10:13:15 -0400 Subject: [PATCH] Model browser transport provider sources --- ampbrowser/cli.py | 2 + ampbrowser/docsgen.py | 27 ++++++++ ampbrowser/launch.py | 1 + ampbrowser/metadata.py | 107 +++++++++++++++++++++++++++++ ampbrowser/open_runner.py | 1 + ampbrowser/transport_manager.py | 64 ++++++++++++++--- docs/autodocs.md | 6 +- docs/generated/provider-sources.md | 27 ++++++++ tests/test_cli_open.py | 2 + tests/test_docsgen.py | 1 + tests/test_transport_manager.py | 42 ++++++++++- 11 files changed, 264 insertions(+), 16 deletions(-) create mode 100644 docs/generated/provider-sources.md diff --git a/ampbrowser/cli.py b/ampbrowser/cli.py index c8c6cef..66efbda 100644 --- a/ampbrowser/cli.py +++ b/ampbrowser/cli.py @@ -218,6 +218,7 @@ def _print_open_plan(prefix: str, open_plan, extra_fields: tuple[str, ...] = ()) 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_provider_source={open_plan.transport_setup_provider_source} " 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} " @@ -259,6 +260,7 @@ def _cmd_transport(args) -> int: "AMPBROWSER_TRANSPORT " f"transport={result.transport} " f"provider={result.provider} " + f"provider_source={result.provider_source} " f"status={result.status} " f"ready={str(result.ready).lower()} " f"owned={str(result.owned).lower()} " diff --git a/ampbrowser/docsgen.py b/ampbrowser/docsgen.py index ef64184..c29cbd2 100644 --- a/ampbrowser/docsgen.py +++ b/ampbrowser/docsgen.py @@ -5,6 +5,7 @@ from pathlib import Path from .adapters import ADAPTERS from .candidates import CANDIDATE_TRANSPORTS from .metadata import BROWSER_BACKENDS, ROUTE_RULES, TRANSPORT_DEFINITIONS +from .metadata import PROVIDER_SOURCE_DEFINITIONS, TRANSPORT_PROVIDER_DEFINITIONS from .platforms import PLATFORM_CAPABILITIES @@ -19,6 +20,7 @@ def generate_docs(root: Path, *, check: bool = False) -> list[Path]: GENERATED_DIR / "candidate-transports.md": _candidate_transports_doc(), GENERATED_DIR / "platform-capabilities.md": _platform_capabilities_doc(), GENERATED_DIR / "browser-strategy.md": _browser_strategy_doc(), + GENERATED_DIR / "provider-sources.md": _provider_sources_doc(), } changed: list[Path] = [] for rel_path, content in docs.items(): @@ -118,3 +120,28 @@ def _browser_strategy_doc() -> str: f"{backend.launch_mode} | {backend.privacy_posture} | {backend.note} |" ) return _header("Generated Browser Strategy") + "\n".join(rows) + "\n" + + +def _provider_sources_doc() -> str: + sections = [_header("Generated Provider Sources")] + sections.append("## Source Types\n") + sections.append("| Source | Discovery | Lifecycle | Platforms | Note |") + sections.append("| --- | --- | --- | --- | --- |") + for source in PROVIDER_SOURCE_DEFINITIONS: + sections.append( + f"| `{source.source}` | {source.discovery} | {source.lifecycle} | " + f"{source.platforms} | {source.note} |" + ) + sections.append("") + + sections.append("## Transport Providers\n") + sections.append("| Transport | Provider | Sources | Endpoint | Status | Note |") + sections.append("| --- | --- | --- | --- | --- | --- |") + for provider in TRANSPORT_PROVIDER_DEFINITIONS: + sources = ", ".join(f"`{source}`" for source in provider.sources) + sections.append( + f"| `{provider.transport}` | `{provider.provider}` | {sources} | " + f"`{provider.endpoint}` | `{provider.status}` | {provider.note} |" + ) + sections.append("") + return "\n".join(sections) diff --git a/ampbrowser/launch.py b/ampbrowser/launch.py index aeac923..b256c00 100644 --- a/ampbrowser/launch.py +++ b/ampbrowser/launch.py @@ -41,6 +41,7 @@ class OpenPlan: setup_prompt_approval_command: str = "-" transport_setup_status: str = "-" transport_setup_provider: str = "-" + transport_setup_provider_source: str = "-" transport_setup_owned: bool = False transport_setup_pid: int = 0 transport_setup_endpoint: str = "-" diff --git a/ampbrowser/metadata.py b/ampbrowser/metadata.py index 776ded0..bb9f7eb 100644 --- a/ampbrowser/metadata.py +++ b/ampbrowser/metadata.py @@ -31,6 +31,25 @@ class BrowserBackend: note: str +@dataclass(frozen=True) +class ProviderSourceDefinition: + source: str + discovery: str + lifecycle: str + platforms: str + note: str + + +@dataclass(frozen=True) +class TransportProviderDefinition: + transport: str + provider: str + sources: tuple[str, ...] + endpoint: str + status: str + note: str + + ROUTE_RULES = ( RouteRule("*.onion", "tor", "tor", "route through Tor SOCKS"), RouteRule("*.i2p", "i2p", "i2p", "route through I2P HTTP proxy"), @@ -125,3 +144,91 @@ BROWSER_BACKENDS = ( "Used for Gemini and similar lightweight document transports.", ), ) + +PROVIDER_SOURCE_DEFINITIONS = ( + ProviderSourceDefinition( + "configured", + "explicit config or environment variable", + "operator override; AMPB owns profile policy but not the binary provenance", + "desktop, android, ios", + "Used for development, custom bundles, and reviewed advanced installs.", + ), + ProviderSourceDefinition( + "bundled-sidecar", + "provider binary shipped with AMPB or the build workspace", + "AMPB-owned process and isolated state", + "desktop, android", + "Default desktop target for Tor, I2P, IPFS, Reticulum, and similar daemon-style transports.", + ), + ProviderSourceDefinition( + "embedded-library", + "transport library linked into the app process", + "AMPB-owned foreground session and app-private state", + "android, ios", + "Preferred mobile shape when platform policy makes sidecar daemons awkward.", + ), + ProviderSourceDefinition( + "system-adopted", + "healthy local proxy or service already running", + "operator-owned service; AMPB only routes browser traffic to it", + "desktop, android", + "Keeps power users and existing deployments working without duplicate daemons.", + ), + ProviderSourceDefinition( + "system-package", + "known package manager install such as brew, apt, pkg, or platform provider", + "installed only after user consent, then run with AMPB-owned state", + "desktop, android", + "Fallback when the app did not ship that provider yet.", + ), + ProviderSourceDefinition( + "builtin-renderer", + "browser-native renderer or fetch path", + "AMPB-owned code path with no external daemon", + "desktop, android, ios", + "Used for Gemini and other lightweight document transports.", + ), +) + +TRANSPORT_PROVIDER_DEFINITIONS = ( + TransportProviderDefinition( + "tor", + "arti/tor", + ("configured", "bundled-sidecar", "embedded-library", "system-adopted", "system-package"), + "socks5://127.0.0.1:9050", + "active", + "Desktop uses managed Arti/Tor sidecars today; mobile should prefer embedded or app-owned foreground providers.", + ), + TransportProviderDefinition( + "i2p", + "i2pd/i2p-router", + ("configured", "bundled-sidecar", "embedded-library", "system-adopted", "system-package"), + "http://127.0.0.1:4444", + "active", + "Desktop can adopt or manage i2pd; mobile provider packaging is planned behind the same contract.", + ), + TransportProviderDefinition( + "gemini", + "gemini-native-viewer", + ("builtin-renderer",), + "builtin://gemtext-renderer", + "planned", + "No daemon is required once the built-in fetch/render path exists.", + ), + TransportProviderDefinition( + "ipfs", + "kubo/ipfs", + ("configured", "bundled-sidecar", "system-adopted", "system-package"), + "http://127.0.0.1:8080", + "planned", + "Content-addressed browsing uses the same provider lifecycle without treating IPFS as anonymity.", + ), + TransportProviderDefinition( + "reticulum", + "rnsd/rnstatus", + ("configured", "bundled-sidecar", "system-adopted", "system-package"), + "rns://local", + "planned", + "Reticulum browsing may still need operator-owned physical or link-layer interface setup.", + ), +) diff --git a/ampbrowser/open_runner.py b/ampbrowser/open_runner.py index 945f69a..c87e656 100644 --- a/ampbrowser/open_runner.py +++ b/ampbrowser/open_runner.py @@ -23,6 +23,7 @@ def launch_open_plan(open_plan: OpenPlan, *, config: AppConfig, root: Path) -> O message=transport_result.message, transport_setup_status=transport_result.status, transport_setup_provider=transport_result.provider, + transport_setup_provider_source=transport_result.provider_source, transport_setup_owned=transport_result.owned, transport_setup_pid=transport_result.pid, transport_setup_endpoint=transport_result.endpoint, diff --git a/ampbrowser/transport_manager.py b/ampbrowser/transport_manager.py index c008106..0b44fad 100644 --- a/ampbrowser/transport_manager.py +++ b/ampbrowser/transport_manager.py @@ -16,6 +16,7 @@ from .config import AppConfig ARTI_RELATIVE_PATH = Path("providers/arti/bin/arti") +I2PD_RELATIVE_PATH = Path("providers/i2pd/bin/i2pd") OWNED_STATE_FILE = "ampb-owned.json" @@ -23,12 +24,14 @@ OWNED_STATE_FILE = "ampb-owned.json" class TorProvider: kind: str binary: str + source: str @dataclass(frozen=True) class I2PProvider: kind: str binary: str + source: str @dataclass(frozen=True) @@ -42,6 +45,7 @@ class ManagedTransportResult: state_dir: str command: tuple[str, ...] message: str + provider_source: str = "-" @property def ready(self) -> bool: @@ -76,6 +80,7 @@ def ensure_transport_ready( _managed_state_dir(root, config, transport), (), f"adopted existing {transport} transport", + provider_source="system-adopted", ) if transport == "tor": @@ -122,6 +127,7 @@ def transport_status( _managed_state_dir(root, config, transport), (), f"{transport} transport is running but not AMPB-owned", + provider_source="system-adopted", ) return ManagedTransportResult( @@ -149,6 +155,7 @@ def stop_managed_transport( endpoint = str(state.get("endpoint") or _endpoint_for(transport)) state_dir = _managed_state_dir(root, config, transport) provider = str(state.get("provider") or "-") + provider_source = str(state.get("provider_source") or "-") command = tuple(str(part) for part in state.get("command", ())) pid = _state_pid(state) @@ -163,6 +170,7 @@ def stop_managed_transport( state_dir, (), f"no AMPB-owned {transport} process is recorded", + provider_source=provider_source, ) if not _pid_alive(pid): @@ -181,6 +189,7 @@ def stop_managed_transport( if cleaned else f"could not remove stale AMPB-owned {transport} state" ), + provider_source=provider_source, ) try: @@ -196,6 +205,7 @@ def stop_managed_transport( state_dir, command, f"could not stop AMPB-owned {transport}: {exc}", + provider_source=provider_source, ) deadline = time.monotonic() + wait_seconds @@ -216,6 +226,7 @@ def stop_managed_transport( if cleaned else f"stopped AMPB-owned {transport} but could not remove state" ), + provider_source=provider_source, ) time.sleep(0.1) @@ -229,6 +240,7 @@ def stop_managed_transport( state_dir, command, f"AMPB-owned {transport} did not stop before timeout", + provider_source=provider_source, ) @@ -273,6 +285,7 @@ def _start_tor( state_dir, command, f"managed {provider.kind} could not start: {exc}", + provider_source=provider.source, ) if _wait_for_endpoint(endpoint, timeout_seconds=wait_seconds): @@ -286,6 +299,7 @@ def _start_tor( state_dir, command, f"started managed {provider.kind} transport", + provider_source=provider.source, ) _write_owned_state(root, config, result) return result @@ -301,31 +315,49 @@ def _start_tor( state_dir, command, f"managed {provider.kind} did not become ready at {endpoint}", + provider_source=provider.source, ) def _tor_provider(config: AppConfig) -> TorProvider | None: - arti_path = os.environ.get("AMPB_ARTI_BIN") or _bundled_arti_path() + arti_path = os.environ.get("AMPB_ARTI_BIN") if arti_path: - return TorProvider("arti", arti_path) + return TorProvider("arti", arti_path, "configured") tor_path = os.environ.get("AMPB_TOR_BIN") if tor_path: - return TorProvider("tor", tor_path) + return TorProvider("tor", tor_path, "configured") config_path = config.transport_binary("tor") if config_path: - return TorProvider(_infer_provider_kind(config_path), config_path) + return TorProvider(_infer_provider_kind(config_path), config_path, "configured") + + bundled_arti = _bundled_arti_path() + if bundled_arti: + return TorProvider("arti", bundled_arti, "bundled-sidecar") system_tor = shutil.which("tor") if system_tor: - return TorProvider("tor", system_tor) + return TorProvider("tor", system_tor, "system-package") return None def _bundled_arti_path() -> str: + return _bundled_provider_path(ARTI_RELATIVE_PATH) + + +def _bundled_i2pd_path() -> str: + return _bundled_provider_path(I2PD_RELATIVE_PATH) + + +def _bundled_provider_path(relative_path: Path) -> str: + provider_root = os.environ.get("AMPB_PROVIDER_ROOT") + if provider_root: + candidate = Path(provider_root) / relative_path + if candidate.exists() and candidate.is_file(): + return str(candidate) build_root = Path(os.environ.get("AMPB_BROWSER_BUILD_ROOT", "/tmp/ampb-browser-build")) - candidate = build_root / ARTI_RELATIVE_PATH + candidate = build_root / relative_path if candidate.exists() and candidate.is_file(): return str(candidate) return "" @@ -418,6 +450,7 @@ def _start_i2p( state_dir, command, f"managed {provider.kind} could not start: {exc}", + provider_source=provider.source, ) if _wait_for_endpoint(endpoint, timeout_seconds=wait_seconds): @@ -431,6 +464,7 @@ def _start_i2p( state_dir, command, f"started managed {provider.kind} transport", + provider_source=provider.source, ) _write_owned_state(root, config, result) return result @@ -446,25 +480,30 @@ def _start_i2p( state_dir, command, f"managed {provider.kind} did not become ready at {endpoint}", + provider_source=provider.source, ) def _i2p_provider(config: AppConfig) -> I2PProvider | None: env_path = os.environ.get("AMPB_I2PD_BIN") if env_path: - return I2PProvider("i2pd", env_path) + return I2PProvider("i2pd", env_path, "configured") config_path = config.transport_binary("i2p") if config_path: - return I2PProvider("i2pd", config_path) + return I2PProvider("i2pd", config_path, "configured") + + bundled_i2pd = _bundled_i2pd_path() + if bundled_i2pd: + return I2PProvider("i2pd", bundled_i2pd, "bundled-sidecar") system_i2pd = shutil.which("i2pd") if system_i2pd: - return I2PProvider("i2pd", system_i2pd) + return I2PProvider("i2pd", system_i2pd, "system-package") homebrew_i2pd = _homebrew_i2pd_path() if homebrew_i2pd: - return I2PProvider("i2pd", homebrew_i2pd) + return I2PProvider("i2pd", homebrew_i2pd, "system-package") return None @@ -575,6 +614,7 @@ def _write_owned_state(root: Path, config: AppConfig, result: ManagedTransportRe "pid": result.pid, "state_dir": result.state_dir, "command": list(result.command), + "provider_source": result.provider_source, "started_at": int(time.time()), } state_path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") @@ -595,6 +635,7 @@ def _owned_transport_result( pid = _state_pid(state) provider = str(state.get("provider") or "-") + provider_source = str(state.get("provider_source") or "-") endpoint = str(state.get("endpoint") or _endpoint_for(transport)) state_dir = str(state.get("state_dir") or _managed_state_dir(root, config, transport)) command = tuple(str(part) for part in state.get("command", ())) @@ -615,6 +656,7 @@ def _owned_transport_result( if cleaned else f"could not remove stale AMPB-owned {transport} state" ), + provider_source=provider_source, ) if _wait_for_endpoint(endpoint, timeout_seconds=wait_seconds): @@ -628,6 +670,7 @@ def _owned_transport_result( state_dir, command, f"using running AMPB-owned {transport}", + provider_source=provider_source, ) return ManagedTransportResult( @@ -640,6 +683,7 @@ def _owned_transport_result( state_dir, command, f"AMPB-owned {transport} process is running but endpoint is not ready", + provider_source=provider_source, ) diff --git a/docs/autodocs.md b/docs/autodocs.md index 839f4f4..b954204 100644 --- a/docs/autodocs.md +++ b/docs/autodocs.md @@ -21,9 +21,9 @@ python3 -m ampbrowser docs generate --check Generated files live under `docs/generated/` and must not be edited by hand. They cover route rules, active transports, browser backend strategy, adapter contracts, candidate -transports, and platform capabilities. Browser source trees, object directories, caches, -and artifacts belong under `/tmp/ampb-browser-build` or another explicit external build -workspace, not this repo. +transports, provider sources, and platform capabilities. Browser source trees, object +directories, caches, and artifacts belong under `/tmp/ampb-browser-build` or another +explicit external build workspace, not this repo. ## Manual Docs diff --git a/docs/generated/provider-sources.md b/docs/generated/provider-sources.md new file mode 100644 index 0000000..cafec6f --- /dev/null +++ b/docs/generated/provider-sources.md @@ -0,0 +1,27 @@ +# Generated Provider Sources + +> Status: generated | Updated by `python3 -m ampbrowser docs generate` | Applies to: AMPB + +This file is generated from code. Do not edit it by hand. + + +## Source Types + +| Source | Discovery | Lifecycle | Platforms | Note | +| --- | --- | --- | --- | --- | +| `configured` | explicit config or environment variable | operator override; AMPB owns profile policy but not the binary provenance | desktop, android, ios | Used for development, custom bundles, and reviewed advanced installs. | +| `bundled-sidecar` | provider binary shipped with AMPB or the build workspace | AMPB-owned process and isolated state | desktop, android | Default desktop target for Tor, I2P, IPFS, Reticulum, and similar daemon-style transports. | +| `embedded-library` | transport library linked into the app process | AMPB-owned foreground session and app-private state | android, ios | Preferred mobile shape when platform policy makes sidecar daemons awkward. | +| `system-adopted` | healthy local proxy or service already running | operator-owned service; AMPB only routes browser traffic to it | desktop, android | Keeps power users and existing deployments working without duplicate daemons. | +| `system-package` | known package manager install such as brew, apt, pkg, or platform provider | installed only after user consent, then run with AMPB-owned state | desktop, android | Fallback when the app did not ship that provider yet. | +| `builtin-renderer` | browser-native renderer or fetch path | AMPB-owned code path with no external daemon | desktop, android, ios | Used for Gemini and other lightweight document transports. | + +## Transport Providers + +| Transport | Provider | Sources | Endpoint | Status | Note | +| --- | --- | --- | --- | --- | --- | +| `tor` | `arti/tor` | `configured`, `bundled-sidecar`, `embedded-library`, `system-adopted`, `system-package` | `socks5://127.0.0.1:9050` | `active` | Desktop uses managed Arti/Tor sidecars today; mobile should prefer embedded or app-owned foreground providers. | +| `i2p` | `i2pd/i2p-router` | `configured`, `bundled-sidecar`, `embedded-library`, `system-adopted`, `system-package` | `http://127.0.0.1:4444` | `active` | Desktop can adopt or manage i2pd; mobile provider packaging is planned behind the same contract. | +| `gemini` | `gemini-native-viewer` | `builtin-renderer` | `builtin://gemtext-renderer` | `planned` | No daemon is required once the built-in fetch/render path exists. | +| `ipfs` | `kubo/ipfs` | `configured`, `bundled-sidecar`, `system-adopted`, `system-package` | `http://127.0.0.1:8080` | `planned` | Content-addressed browsing uses the same provider lifecycle without treating IPFS as anonymity. | +| `reticulum` | `rnsd/rnstatus` | `configured`, `bundled-sidecar`, `system-adopted`, `system-package` | `rns://local` | `planned` | Reticulum browsing may still need operator-owned physical or link-layer interface setup. | diff --git a/tests/test_cli_open.py b/tests/test_cli_open.py index c824875..0a80e1e 100644 --- a/tests/test_cli_open.py +++ b/tests/test_cli_open.py @@ -60,6 +60,7 @@ class CliOpenTest(unittest.TestCase): state_dir=".ampb/transports/tor", command=("/tmp/arti", "proxy"), message="started managed arti transport", + provider_source="bundled-sidecar", ) def fake_execute(open_plan, *, root: Path): @@ -85,6 +86,7 @@ class CliOpenTest(unittest.TestCase): self.assertIn("browser_pid=5678", output) self.assertIn("transport_setup_status=started", output) self.assertIn("transport_setup_provider=arti", output) + self.assertIn("transport_setup_provider_source=bundled-sidecar", 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) diff --git a/tests/test_docsgen.py b/tests/test_docsgen.py index e690456..e67e41a 100644 --- a/tests/test_docsgen.py +++ b/tests/test_docsgen.py @@ -19,6 +19,7 @@ class DocsGenTest(unittest.TestCase): Path("docs/generated/browser-strategy.md"), Path("docs/generated/candidate-transports.md"), Path("docs/generated/platform-capabilities.md"), + Path("docs/generated/provider-sources.md"), Path("docs/generated/route-rules.md"), Path("docs/generated/transports.md"), }, diff --git a/tests/test_transport_manager.py b/tests/test_transport_manager.py index 8226cac..79dae01 100644 --- a/tests/test_transport_manager.py +++ b/tests/test_transport_manager.py @@ -27,6 +27,7 @@ class TransportManagerTest(unittest.TestCase): self.assertEqual("ready", result.status) self.assertEqual("-", result.provider) + self.assertEqual("system-adopted", result.provider_source) self.assertFalse(result.owned) self.assertTrue(result.ready) self.assertEqual(0, result.pid) @@ -73,6 +74,7 @@ class TransportManagerTest(unittest.TestCase): self.assertEqual("started", result.status) self.assertEqual("arti", result.provider) + self.assertEqual("bundled-sidecar", result.provider_source) self.assertTrue(result.owned) self.assertEqual(1234, result.pid) self.assertEqual(str(root / ".ampb/transports/tor"), result.state_dir) @@ -84,6 +86,7 @@ class TransportManagerTest(unittest.TestCase): state_path = root / ".ampb/transports/tor/ampb-owned.json" self.assertTrue(state_path.exists()) self.assertIn('"provider": "arti"', state_path.read_text(encoding="utf-8")) + self.assertIn('"provider_source": "bundled-sidecar"', state_path.read_text(encoding="utf-8")) popen.assert_called_once() def test_reuses_recorded_ampb_owned_transport(self) -> None: @@ -122,6 +125,7 @@ class TransportManagerTest(unittest.TestCase): self.assertEqual("ready", result.status) self.assertEqual("arti", result.provider) + self.assertEqual("-", result.provider_source) self.assertTrue(result.owned) self.assertEqual(1234, result.pid) popen.assert_not_called() @@ -199,6 +203,7 @@ class TransportManagerTest(unittest.TestCase): self.assertEqual("started", result.status) self.assertEqual("tor", result.provider) + self.assertEqual("configured", result.provider_source) self.assertTrue(result.owned) self.assertEqual(1234, result.pid) self.assertEqual(str(root / ".ampb/transports/tor"), result.state_dir) @@ -226,6 +231,7 @@ class TransportManagerTest(unittest.TestCase): self.assertEqual("start-failed", result.status) self.assertEqual("tor", result.provider) + self.assertEqual("configured", result.provider_source) self.assertFalse(result.ready) self.assertIn("could not start", result.message) @@ -265,7 +271,8 @@ class TransportManagerTest(unittest.TestCase): with tempfile.TemporaryDirectory() as tmp: with patch("ampbrowser.transport_manager.shutil.which", return_value=None): - result = ensure_transport_ready("i2p", config=AppConfig(transport_modes={}), root=Path(tmp), status=status) + with patch("ampbrowser.transport_manager._bundled_i2pd_path", return_value=""): + result = ensure_transport_ready("i2p", config=AppConfig(transport_modes={}), root=Path(tmp), status=status) self.assertEqual("missing-provider", result.status) self.assertFalse(result.ready) @@ -298,14 +305,41 @@ class TransportManagerTest(unittest.TestCase): return "/opt/homebrew/bin/brew" if name == "brew" else None with patch("ampbrowser.transport_manager.shutil.which", side_effect=which): - with patch("ampbrowser.transport_manager.subprocess.run", return_value=Result()): + with patch("ampbrowser.transport_manager._bundled_i2pd_path", return_value=""): + with patch("ampbrowser.transport_manager.subprocess.run", return_value=Result()): + with patch("ampbrowser.transport_manager._wait_for_endpoint", return_value=True): + with patch("ampbrowser.transport_manager.subprocess.Popen") as popen: + popen.return_value.pid = 1234 + result = ensure_transport_ready("i2p", config=AppConfig(transport_modes={}), root=root, status=status) + + self.assertEqual("started", result.status) + self.assertEqual("system-package", result.provider_source) + self.assertEqual(str(i2pd), result.command[0]) + + def test_starts_bundled_i2pd_provider_before_system_provider(self) -> None: + status = TransportStatus( + transport="i2p", + installed=True, + running=False, + endpoint="http://127.0.0.1:4444", + adoptable=False, + manage_supported=True, + note="I2P HTTP proxy", + ) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + bundled = root / "providers/i2pd/bin/i2pd" + with patch("ampbrowser.transport_manager._bundled_i2pd_path", return_value=str(bundled)): + with patch("ampbrowser.transport_manager.shutil.which", return_value="/usr/bin/i2pd"): with patch("ampbrowser.transport_manager._wait_for_endpoint", return_value=True): with patch("ampbrowser.transport_manager.subprocess.Popen") as popen: popen.return_value.pid = 1234 result = ensure_transport_ready("i2p", config=AppConfig(transport_modes={}), root=root, status=status) self.assertEqual("started", result.status) - self.assertEqual(str(i2pd), result.command[0]) + self.assertEqual("bundled-sidecar", result.provider_source) + self.assertEqual(str(bundled), result.command[0]) def test_starts_configured_i2pd_provider_with_ampb_state(self) -> None: status = TransportStatus( @@ -328,6 +362,7 @@ class TransportManagerTest(unittest.TestCase): self.assertEqual("started", result.status) self.assertEqual("i2pd", result.provider) + self.assertEqual("configured", result.provider_source) self.assertTrue(result.owned) self.assertEqual(1234, result.pid) self.assertEqual(str(root / ".ampb/transports/i2p"), result.state_dir) @@ -346,6 +381,7 @@ class TransportManagerTest(unittest.TestCase): state_path = root / ".ampb/transports/i2p/ampb-owned.json" self.assertTrue(state_path.exists()) self.assertIn('"provider": "i2pd"', state_path.read_text(encoding="utf-8")) + self.assertIn('"provider_source": "configured"', state_path.read_text(encoding="utf-8")) popen.assert_called_once() def test_terminates_owned_i2pd_process_on_timeout(self) -> None: