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
+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}")