From 42131a5ea05829fb58873ad01c4aaac07ebb3e9f Mon Sep 17 00:00:00 2001 From: jwinterm Date: Wed, 8 Jul 2026 09:52:35 -0400 Subject: [PATCH] Make managed i2pd startup easier on macOS --- .gitignore | 1 + ampbrowser/transport_manager.py | 33 ++++++++++++++++++++++++--- tests/test_transport_manager.py | 40 +++++++++++++++++++++++++++++++-- 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 9e58a68..0ec1f6c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /.ampb/ /docs/private/ +.DS_Store __pycache__/ *.pyc diff --git a/ampbrowser/transport_manager.py b/ampbrowser/transport_manager.py index 196c9fc..c008106 100644 --- a/ampbrowser/transport_manager.py +++ b/ampbrowser/transport_manager.py @@ -395,7 +395,8 @@ def _start_i2p( 0, state_dir, (), - "I2P provider not found; install i2pd or set AMPB_I2PD_BIN or transports.i2p.binary_path", + "I2P provider not found; on macOS run: brew install i2pd. " + "Alternatively set AMPB_I2PD_BIN or transports.i2p.binary_path.", ) state_path = Path(state_dir) @@ -460,9 +461,37 @@ def _i2p_provider(config: AppConfig) -> I2PProvider | None: system_i2pd = shutil.which("i2pd") if system_i2pd: return I2PProvider("i2pd", system_i2pd) + + homebrew_i2pd = _homebrew_i2pd_path() + if homebrew_i2pd: + return I2PProvider("i2pd", homebrew_i2pd) return None +def _homebrew_i2pd_path() -> str: + brew = shutil.which("brew") + if not brew: + return "" + try: + result = subprocess.run( + [brew, "--prefix", "i2pd"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=2, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return "" + if result.returncode != 0: + return "" + prefix = Path(result.stdout.strip()) + for candidate in (prefix / "bin/i2pd", prefix / "sbin/i2pd"): + if candidate.exists() and candidate.is_file(): + return str(candidate) + return "" + + def _i2p_command(provider: I2PProvider, *, endpoint: str, state_path: Path) -> tuple[str, ...]: host, port = _endpoint_host_port(endpoint) data_dir = state_path / "i2pd-data" @@ -482,8 +511,6 @@ def _i2p_command(provider: I2PProvider, *, endpoint: str, state_path: Path) -> t str(tunnels_path), "--datadir", str(data_dir), - "--daemon=false", - "--service=false", ) diff --git a/tests/test_transport_manager.py b/tests/test_transport_manager.py index 119f116..8226cac 100644 --- a/tests/test_transport_manager.py +++ b/tests/test_transport_manager.py @@ -270,6 +270,42 @@ class TransportManagerTest(unittest.TestCase): self.assertEqual("missing-provider", result.status) self.assertFalse(result.ready) self.assertIn("I2P provider not found", result.message) + self.assertIn("brew install i2pd", result.message) + + def test_finds_homebrew_i2pd_provider_when_not_on_path(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) + prefix = root / "homebrew/opt/i2pd" + i2pd = prefix / "bin/i2pd" + i2pd.parent.mkdir(parents=True) + i2pd.write_text("#!/bin/sh\n", encoding="utf-8") + + class Result: + returncode = 0 + stdout = str(prefix) + "\n" + + def which(name: str) -> str | None: + 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._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]) def test_starts_configured_i2pd_provider_with_ampb_state(self) -> None: status = TransportStatus( @@ -300,12 +336,12 @@ class TransportManagerTest(unittest.TestCase): self.assertIn(str(root / ".ampb/transports/i2p/i2pd.conf"), result.command) self.assertIn("--datadir", result.command) self.assertIn(str(root / ".ampb/transports/i2p/i2pd-data"), result.command) - self.assertIn("--daemon=false", result.command) - self.assertIn("--service=false", result.command) config_text = (root / ".ampb/transports/i2p/i2pd.conf").read_text(encoding="utf-8") self.assertIn("[httpproxy]", config_text) self.assertIn("address = 127.0.0.1", config_text) self.assertIn("port = 4444", config_text) + self.assertIn("daemon = false", config_text) + self.assertIn("service = false", config_text) self.assertIn("notransit = true", config_text) state_path = root / ".ampb/transports/i2p/ampb-owned.json" self.assertTrue(state_path.exists())