Add mobile platform capability planning

This commit is contained in:
jwinterm
2026-07-05 20:53:12 -04:00
parent ba620e32ca
commit 6c209b6557
14 changed files with 353 additions and 28 deletions
+2
View File
@@ -36,6 +36,7 @@ python3 -m ampbrowser plan gemini://wownero.org/
python3 -m ampbrowser open https://wownero.org/
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 docs generate
python3 -m ampbrowser docs generate --check
python3 tools/docs_check.py
@@ -46,6 +47,7 @@ python3 -m unittest discover -s tests
- [Architecture](docs/architecture.md)
- [Configuration](docs/configuration.md)
- [Mobile browser](docs/mobile-browser.md)
- [Open flow](docs/open-flow.md)
- [Transport management](docs/transport-management.md)
- [Test fixtures](docs/test-fixtures.md)
+28 -6
View File
@@ -8,6 +8,7 @@ from .config import load_config
from .docsgen import generate_docs
from .launch import prepare_open
from .plan import plan_url
from .platforms import PLATFORM_CHOICES
from .routing import route_url
from .transports import inspect_transports
@@ -22,10 +23,12 @@ def main(argv: list[str] | None = None) -> int:
plan_parser = subcommands.add_parser("plan", help="Plan how a URL would be opened.")
plan_parser.add_argument("url")
plan_parser.add_argument("--config", type=Path, help="Path to an AMPB config file.")
plan_parser.add_argument("--platform", choices=PLATFORM_CHOICES, help="Target platform capability profile.")
open_parser = subcommands.add_parser("open", help="Prepare a transport-aware open plan.")
open_parser.add_argument("url")
open_parser.add_argument("--config", type=Path, help="Path to an AMPB config file.")
open_parser.add_argument("--platform", choices=PLATFORM_CHOICES, help="Target platform capability profile.")
open_parser.add_argument(
"--yes",
action="store_true",
@@ -50,9 +53,15 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "route":
return _cmd_route(args.url)
if args.command == "plan":
return _cmd_plan(args.url, config_path=args.config)
return _cmd_plan(args.url, config_path=args.config, platform=args.platform)
if args.command == "open":
return _cmd_open(args.url, consent=args.yes, dry_run=args.dry_run, config_path=args.config)
return _cmd_open(
args.url,
consent=args.yes,
dry_run=args.dry_run,
config_path=args.config,
platform=args.platform,
)
if args.command == "inspect":
return _cmd_inspect()
if args.command == "docs":
@@ -75,9 +84,9 @@ def _cmd_route(url: str) -> int:
return 0
def _cmd_plan(url: str, *, config_path: Path | None) -> int:
def _cmd_plan(url: str, *, config_path: Path | None, platform: str | None) -> int:
config = load_config(Path.cwd(), config_path)
plan = plan_url(url, config=config)
plan = plan_url(url, config=config, platform=platform)
endpoint = plan.status.endpoint if plan.status else "-"
running = str(plan.status.running).lower() if plan.status else "false"
installed = str(plan.status.installed).lower() if plan.status else "false"
@@ -90,6 +99,9 @@ def _cmd_plan(url: str, *, config_path: Path | None) -> int:
f"installed={installed} "
f"running={running} "
f"endpoint={endpoint} "
f"platform={plan.platform_capability.platform} "
f"platform_browse={plan.platform_capability.browse} "
f"platform_manage={plan.platform_capability.manage} "
f"policy={plan.policy_mode} "
f"requires_consent={str(plan.requires_consent).lower()} "
f"action=\"{plan.action}\" "
@@ -98,9 +110,16 @@ def _cmd_plan(url: str, *, config_path: Path | None) -> int:
return 0
def _cmd_open(url: str, *, consent: bool, dry_run: bool, config_path: Path | None) -> int:
def _cmd_open(
url: str,
*,
consent: bool,
dry_run: 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)
open_plan = prepare_open(url, consent=consent, dry_run=dry_run, config=config, platform=platform)
browse_plan = open_plan.browse_plan
setup_steps = "|".join(open_plan.setup_steps) if open_plan.setup_steps else "-"
message = _safe(open_plan.message)
@@ -111,6 +130,9 @@ def _cmd_open(url: str, *, consent: bool, dry_run: bool, config_path: Path | Non
f"profile={browse_plan.route.profile} "
f"status={open_plan.status} "
f"dry_run={str(open_plan.dry_run).lower()} "
f"platform={browse_plan.platform_capability.platform} "
f"platform_browse={browse_plan.platform_capability.browse} "
f"platform_manage={browse_plan.platform_capability.manage} "
f"policy={browse_plan.policy_mode} "
f"requires_consent={str(browse_plan.requires_consent).lower()} "
f"consent_granted={str(open_plan.consent_granted).lower()} "
+15
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from pathlib import Path
from .metadata import ROUTE_RULES, TRANSPORT_DEFINITIONS
from .platforms import PLATFORM_CAPABILITIES
GENERATED_DIR = Path("docs/generated")
@@ -12,6 +13,7 @@ def generate_docs(root: Path, *, check: bool = False) -> list[Path]:
docs = {
GENERATED_DIR / "route-rules.md": _route_rules_doc(),
GENERATED_DIR / "transports.md": _transports_doc(),
GENERATED_DIR / "platform-capabilities.md": _platform_capabilities_doc(),
}
changed: list[Path] = []
for rel_path, content in docs.items():
@@ -57,3 +59,16 @@ def _transports_doc() -> str:
f"`{transport.profile}` | {transport.note} |"
)
return _header("Generated Transports") + "\n".join(rows) + "\n"
def _platform_capabilities_doc() -> str:
rows = [
"| Transport | Platform | Browse | Adopt | Manage | Install | Note |",
"| --- | --- | --- | --- | --- | --- | --- |",
]
for capability in PLATFORM_CAPABILITIES:
rows.append(
f"| `{capability.transport}` | `{capability.platform}` | `{capability.browse}` | "
f"`{capability.adopt}` | `{capability.manage}` | `{capability.install}` | {capability.note} |"
)
return _header("Generated Platform Capabilities") + "\n".join(rows) + "\n"
+28 -8
View File
@@ -24,9 +24,10 @@ def prepare_open(
consent: bool = False,
dry_run: bool = True,
config: AppConfig | None = None,
platform: str | None = None,
) -> OpenPlan:
config = config or default_config()
browse_plan = plan_url(raw_url, config=config)
browse_plan = plan_url(raw_url, config=config, platform=platform)
profile_path = config.profile_path(browse_plan.route.profile)
proxy = _proxy_for(browse_plan)
@@ -89,12 +90,31 @@ def _setup_steps(browse_plan: BrowsePlan) -> tuple[str, ...]:
return ()
steps: list[str] = []
platform = browse_plan.platform_capability.platform
if not browse_plan.status.installed:
steps.append(f"install {browse_plan.route.transport}")
steps.extend(
[
f"start managed {browse_plan.route.transport}",
f"wait for {browse_plan.status.endpoint}",
]
)
if platform == "android":
steps.append(f"install or enable Android {browse_plan.route.transport} provider")
elif platform == "ios":
steps.append(f"enable foreground iOS {browse_plan.route.transport} session")
else:
steps.append(f"install {browse_plan.route.transport}")
if platform == "android":
steps.append("start visible Android foreground service")
steps.extend(
[
f"start managed Android {browse_plan.route.transport} transport",
f"wait for {browse_plan.status.endpoint}",
]
)
return tuple(steps)
elif platform == "ios":
steps.append("start foreground-only iOS session")
steps.extend(
[
f"start foreground-only iOS {browse_plan.route.transport} transport",
f"wait for {browse_plan.status.endpoint}",
]
)
return tuple(steps)
steps.extend([f"start managed {browse_plan.route.transport}", f"wait for {browse_plan.status.endpoint}"])
return tuple(steps)
+61 -13
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass
from .config import AppConfig, default_config
from .platforms import PlatformCapability, capability_for
from .routing import Route, route_url
from .transports import TransportStatus, inspect_transport
@@ -11,22 +12,39 @@ from .transports import TransportStatus, inspect_transport
class BrowsePlan:
route: Route
status: TransportStatus | None
platform_capability: PlatformCapability
action: str
requires_consent: bool = False
prompt: str = ""
policy_mode: str = "adopt-or-prompt-manage"
def plan_url(raw_url: str, *, config: AppConfig | None = None) -> BrowsePlan:
def plan_url(
raw_url: str,
*,
config: AppConfig | None = None,
platform: str | None = None,
) -> BrowsePlan:
config = config or default_config()
route = route_url(raw_url)
status = inspect_transport(route.transport)
policy_mode = config.transport_mode(route.transport)
platform_capability = capability_for(route.transport, platform)
if platform_capability.browse == "unsupported":
return BrowsePlan(
route=route,
status=status,
platform_capability=platform_capability,
action=f"blocked by platform: {route.transport} unsupported on {platform_capability.platform}",
policy_mode=policy_mode,
)
if policy_mode == "disabled":
return BrowsePlan(
route=route,
status=status,
platform_capability=platform_capability,
action=f"blocked by policy: {route.transport} disabled",
policy_mode=policy_mode,
)
@@ -34,6 +52,7 @@ def plan_url(raw_url: str, *, config: AppConfig | None = None) -> BrowsePlan:
return BrowsePlan(
route=route,
status=status,
platform_capability=platform_capability,
action="open with clearnet profile",
policy_mode=policy_mode,
)
@@ -41,6 +60,7 @@ def plan_url(raw_url: str, *, config: AppConfig | None = None) -> BrowsePlan:
return BrowsePlan(
route=route,
status=status,
platform_capability=platform_capability,
action="adopt existing transport",
policy_mode=policy_mode,
)
@@ -48,24 +68,51 @@ def plan_url(raw_url: str, *, config: AppConfig | None = None) -> BrowsePlan:
return BrowsePlan(
route=route,
status=status,
platform_capability=platform_capability,
action=f"blocked by policy: {route.transport} mode is adopt",
policy_mode=policy_mode,
)
if status and status.manage_supported:
action = "prompt to start managed transport"
prompt = (
f"{route.transport} is not running. AMPB can start a managed {route.transport} "
f"transport for {route.normalized}."
)
if not status.installed:
action = "prompt to install and start managed transport"
prompt = (
f"{route.transport} is not installed or running. AMPB can install and start "
f"a managed {route.transport} transport for {route.normalized}."
)
if status and status.manage_supported and not platform_capability.can_manage_setup:
return BrowsePlan(
route=route,
status=status,
platform_capability=platform_capability,
action=(
f"blocked by platform: {route.transport} managed setup is "
f"{platform_capability.manage} on {platform_capability.platform}"
),
policy_mode=policy_mode,
)
if status and status.manage_supported:
if platform_capability.manage == "foreground-only":
action = "prompt to start foreground-only transport session"
prompt = (
f"{route.transport} is not running. AMPB can start a foreground-only "
f"{route.transport} session for {route.normalized} on {platform_capability.platform}."
)
else:
action = "prompt to start managed transport"
prompt = (
f"{route.transport} is not running. AMPB can start a managed {route.transport} "
f"transport for {route.normalized}."
)
if not status.installed:
if platform_capability.manage == "foreground-only":
action = "prompt to enable foreground-only transport session"
prompt = (
f"{route.transport} is not available. AMPB can enable a foreground-only "
f"{route.transport} session for {route.normalized} on {platform_capability.platform}."
)
else:
action = "prompt to install and start managed transport"
prompt = (
f"{route.transport} is not installed or running. AMPB can install and start "
f"a managed {route.transport} transport for {route.normalized}."
)
return BrowsePlan(
route=route,
status=status,
platform_capability=platform_capability,
action=action,
requires_consent=True,
prompt=prompt,
@@ -74,6 +121,7 @@ def plan_url(raw_url: str, *, config: AppConfig | None = None) -> BrowsePlan:
return BrowsePlan(
route=route,
status=status,
platform_capability=platform_capability,
action="blocked until adapter is configured",
policy_mode=policy_mode,
)
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
from dataclasses import dataclass
import sys
PLATFORM_CHOICES = ("desktop", "android", "ios")
MANAGED_SETUP_VALUES = {"ready", "planned", "foreground-only"}
@dataclass(frozen=True)
class PlatformCapability:
transport: str
platform: str
browse: str
adopt: str
manage: str
install: str
note: str
@property
def can_manage_setup(self) -> bool:
return self.manage in MANAGED_SETUP_VALUES
PLATFORM_CAPABILITIES = (
PlatformCapability("clearnet", "desktop", "ready", "ready", "unsupported", "unsupported", "Use system browser or isolated desktop profile."),
PlatformCapability("clearnet", "android", "ready", "ready", "unsupported", "unsupported", "Use Android browser shell, WebView, or Custom Tabs."),
PlatformCapability("clearnet", "ios", "ready", "ready", "unsupported", "unsupported", "Use iOS browser shell with platform web constraints."),
PlatformCapability("tor", "desktop", "ready", "ready", "planned", "planned", "Adopt local Tor or prompt before managed desktop daemon setup."),
PlatformCapability("tor", "android", "planned", "planned", "planned", "planned", "Use an app-owned foreground service or compatible installed Tor provider."),
PlatformCapability("tor", "ios", "foreground-only", "planned", "foreground-only", "constrained", "Plan for in-app foreground sessions; do not assume always-on background service."),
PlatformCapability("i2p", "desktop", "ready", "ready", "planned", "planned", "Adopt local I2P proxy or prompt before managed desktop daemon setup."),
PlatformCapability("i2p", "android", "planned", "planned", "planned", "planned", "Use an app-owned foreground service or compatible installed I2P provider."),
PlatformCapability("i2p", "ios", "constrained", "planned", "foreground-only", "constrained", "Treat iOS I2P support as foreground-only until native adapter constraints are proven."),
PlatformCapability("gemini", "desktop", "ready", "ready", "ready", "unsupported", "Built-in fetch/render path can be shared by desktop shell."),
PlatformCapability("gemini", "android", "ready", "ready", "ready", "unsupported", "Built-in fetch/render path can be shared by Android shell."),
PlatformCapability("gemini", "ios", "ready", "ready", "ready", "unsupported", "Built-in fetch/render path can be shared by iOS shell."),
PlatformCapability("reticulum", "desktop", "planned", "planned", "planned", "planned", "Adapter and local interface readiness checks are planned."),
PlatformCapability("reticulum", "android", "planned", "planned", "planned", "planned", "Mobile adapter should use explicit foreground service consent."),
PlatformCapability("reticulum", "ios", "constrained", "planned", "foreground-only", "constrained", "Do not assume background Reticulum operation on iOS."),
PlatformCapability("unknown", "desktop", "unsupported", "unsupported", "unsupported", "unsupported", "No platform route is available."),
PlatformCapability("unknown", "android", "unsupported", "unsupported", "unsupported", "unsupported", "No platform route is available."),
PlatformCapability("unknown", "ios", "unsupported", "unsupported", "unsupported", "unsupported", "No platform route is available."),
)
def detect_platform() -> str:
if hasattr(sys, "getandroidapilevel"):
return "android"
if sys.platform in {"ios", "watchos", "tvos"}:
return "ios"
return "desktop"
def normalize_platform(platform: str | None) -> str:
selected = platform or detect_platform()
if selected not in PLATFORM_CHOICES:
choices = ", ".join(PLATFORM_CHOICES)
raise ValueError(f"platform must be one of: {choices}")
return selected
def capability_for(transport: str, platform: str | None = None) -> PlatformCapability:
selected = normalize_platform(platform)
for capability in PLATFORM_CAPABILITIES:
if capability.transport == transport and capability.platform == selected:
return capability
for capability in PLATFORM_CAPABILITIES:
if capability.transport == "unknown" and capability.platform == selected:
return capability
raise ValueError(f"no capability for transport={transport} platform={selected}")
+1
View File
@@ -22,6 +22,7 @@ browser engine until those contracts are stable.
- `ampbrowser.transports`: local readiness checks for Tor, I2P, Reticulum, and Gemini.
- `ampbrowser.plan`: dry-run action planner for browser launches.
- `ampbrowser.launch`: side-effect-free launch specs and first-use consent state.
- `ampbrowser.platforms`: platform capability matrix for desktop, Android, and iOS.
- `ampbrowser.docsgen`: generated public documentation from code-owned metadata.
## Browser Strategy
+26
View File
@@ -0,0 +1,26 @@
# Generated Platform Capabilities
> Status: generated | Updated by `python3 -m ampbrowser docs generate` | Applies to: AMPB
This file is generated from code. Do not edit it by hand.
| Transport | Platform | Browse | Adopt | Manage | Install | Note |
| --- | --- | --- | --- | --- | --- | --- |
| `clearnet` | `desktop` | `ready` | `ready` | `unsupported` | `unsupported` | Use system browser or isolated desktop profile. |
| `clearnet` | `android` | `ready` | `ready` | `unsupported` | `unsupported` | Use Android browser shell, WebView, or Custom Tabs. |
| `clearnet` | `ios` | `ready` | `ready` | `unsupported` | `unsupported` | Use iOS browser shell with platform web constraints. |
| `tor` | `desktop` | `ready` | `ready` | `planned` | `planned` | Adopt local Tor or prompt before managed desktop daemon setup. |
| `tor` | `android` | `planned` | `planned` | `planned` | `planned` | Use an app-owned foreground service or compatible installed Tor provider. |
| `tor` | `ios` | `foreground-only` | `planned` | `foreground-only` | `constrained` | Plan for in-app foreground sessions; do not assume always-on background service. |
| `i2p` | `desktop` | `ready` | `ready` | `planned` | `planned` | Adopt local I2P proxy or prompt before managed desktop daemon setup. |
| `i2p` | `android` | `planned` | `planned` | `planned` | `planned` | Use an app-owned foreground service or compatible installed I2P provider. |
| `i2p` | `ios` | `constrained` | `planned` | `foreground-only` | `constrained` | Treat iOS I2P support as foreground-only until native adapter constraints are proven. |
| `gemini` | `desktop` | `ready` | `ready` | `ready` | `unsupported` | Built-in fetch/render path can be shared by desktop shell. |
| `gemini` | `android` | `ready` | `ready` | `ready` | `unsupported` | Built-in fetch/render path can be shared by Android shell. |
| `gemini` | `ios` | `ready` | `ready` | `ready` | `unsupported` | Built-in fetch/render path can be shared by iOS shell. |
| `reticulum` | `desktop` | `planned` | `planned` | `planned` | `planned` | Adapter and local interface readiness checks are planned. |
| `reticulum` | `android` | `planned` | `planned` | `planned` | `planned` | Mobile adapter should use explicit foreground service consent. |
| `reticulum` | `ios` | `constrained` | `planned` | `foreground-only` | `constrained` | Do not assume background Reticulum operation on iOS. |
| `unknown` | `desktop` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | No platform route is available. |
| `unknown` | `android` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | No platform route is available. |
| `unknown` | `ios` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | No platform route is available. |
+25
View File
@@ -0,0 +1,25 @@
# Mobile Browser
> Status: draft | Updated 2026-07-06 | Applies to: AMPB mobile shells
Mobile is a first-class AMPB target. The core router, policy model, consent flow, profile
isolation, and fixture checks should stay shared across desktop, Android, and iOS. Native
mobile shells should implement only the platform-specific browser and transport pieces.
## Android
Android is the primary mobile target for full AMPB browsing. Tor, I2P, and Reticulum
adapters should run only after a URL selects that transport and the user approves setup.
Managed adapters must use visible, stoppable foreground services and app-owned state.
## iOS
iOS is a supported design target, but transport management is constrained. AMPB should
assume foreground-only sessions unless a reviewed platform capability proves otherwise.
The iOS shell should still share routing, policy, consent, fixture, Gemini, and clearnet
behavior with the rest of AMPB.
## Capability Matrix
Generated platform capability metadata lives in
[generated platform capabilities](generated/platform-capabilities.md).
+1
View File
@@ -27,4 +27,5 @@ python3 -m ampbrowser open https://wownero.org/
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
```
+5 -1
View File
@@ -14,7 +14,11 @@ class DocsGenTest(unittest.TestCase):
changed = generate_docs(root)
self.assertEqual(
{Path("docs/generated/route-rules.md"), Path("docs/generated/transports.md")},
{
Path("docs/generated/platform-capabilities.md"),
Path("docs/generated/route-rules.md"),
Path("docs/generated/transports.md"),
},
set(changed),
)
self.assertEqual([], generate_docs(root, check=True))
+25
View File
@@ -62,6 +62,31 @@ class PrepareOpenTest(unittest.TestCase):
self.assertEqual(".local/ampb/profiles/clearnet", plan.profile_path)
def test_android_setup_steps_include_foreground_service(self) -> None:
status = TransportStatus(
transport="i2p",
installed=False,
running=False,
endpoint="http://127.0.0.1:4444",
adoptable=False,
manage_supported=True,
note="I2P HTTP proxy",
)
with patch("ampbrowser.plan.inspect_transport", return_value=status):
plan = prepare_open("http://example.b32.i2p/", platform="android")
self.assertEqual("android", plan.browse_plan.platform_capability.platform)
self.assertEqual(
(
"install or enable Android i2p provider",
"start visible Android foreground service",
"start managed Android i2p transport",
"wait for http://127.0.0.1:4444",
),
plan.setup_steps,
)
if __name__ == "__main__":
unittest.main()
+35
View File
@@ -89,6 +89,41 @@ class PlanUrlTest(unittest.TestCase):
self.assertEqual("blocked by policy: tor mode is adopt", plan.action)
self.assertFalse(plan.requires_consent)
def test_android_platform_is_reported_on_plan(self) -> None:
status = TransportStatus(
transport="tor",
installed=False,
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):
plan = plan_url("http://example.onion/", platform="android")
self.assertEqual("android", plan.platform_capability.platform)
self.assertEqual("planned", plan.platform_capability.manage)
self.assertTrue(plan.requires_consent)
def test_ios_foreground_only_transport_prompt(self) -> None:
status = TransportStatus(
transport="tor",
installed=False,
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):
plan = plan_url("http://example.onion/", platform="ios")
self.assertEqual("prompt to enable foreground-only transport session", plan.action)
self.assertIn("foreground-only", plan.prompt)
if __name__ == "__main__":
unittest.main()
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
import unittest
from ampbrowser.platforms import capability_for, normalize_platform
class PlatformsTest(unittest.TestCase):
def test_android_tor_is_managed_setup_capable(self) -> None:
capability = capability_for("tor", "android")
self.assertEqual("android", capability.platform)
self.assertEqual("planned", capability.manage)
self.assertTrue(capability.can_manage_setup)
def test_ios_tor_is_foreground_only(self) -> None:
capability = capability_for("tor", "ios")
self.assertEqual("foreground-only", capability.manage)
self.assertTrue(capability.can_manage_setup)
self.assertIn("foreground", capability.note)
def test_unknown_platform_is_rejected(self) -> None:
with self.assertRaises(ValueError):
normalize_platform("watch")
if __name__ == "__main__":
unittest.main()