Porthole: agent/MCP access to a running iOS app#131
Open
kyleve wants to merge 18 commits into
Open
Conversation
Pure-groundwork step (no behavior change): add .macOS(.v26) to the root Package.swift platforms so the forthcoming Porthole suite can ship macOS-capable libraries, a command-line tool, and a Mac Catalyst app. Note the macOS platform + Porthole targets in the root AGENTS.md deployment section. Closes plan step: groundwork-platforms
The shared foundation of the Porthole suite (pure Foundation + CryptoKit, iOS + macOS): PortholeValue (JSON-shaped tree with tagged data/date), PortholeSchema (JSON-Schema render + runtime validate), typed identifiers and descriptors, the request/response wire protocol, length-prefixed framing, the PortholeTransport abstraction + in-memory LoopbackTransport, the X25519/HKDF pairing handshake, the ChaCha20-Poly1305 PortholeSecureChannel, and a Keychain-backed PortholeCredentialStore. Whole stack (framing, pairing crypto, secure channel round-trip incl. tamper/replay/wrong-key rejection) is tested in-process over the loopback transport. Closes plan step: porthole-core
…uter The device-side runtime over PortholeCore: the @MainActor/@observable Porthole composition root, the PortholeConnector protocol (+ PortholeAction / PortholeDataSource), a main-actor ConnectorRegistry resolving to a Sendable dispatch snapshot, the actor-based PortholeSessionRouter (decode, validate against schema, dispatch, bounded drop-oldest subscription streaming), the built-in app connector (app-info + ping), and PortholeState for the UI. Networking is not wired yet; the @_spi(Testing) attach(transport:) seam serves a full session over LoopbackTransport, and PortholeKitTests exercise hello/list/invoke/query/subscribe end-to-end in-process. Closes plan step: porthole-kit-runtime
…sessions Wires Porthole.start()/stop()/revoke() to a real NWListener that advertises _porthole._tcp with a TXT record and accepts connections. Each connection runs DevicePairingManager (pure of Network types): the X25519/HKDF pairing handshake with a single active 6-digit code (accumulating attempts, 3-strike burn, 120s expiry) and the session re-derivation, then hands the same TransportFrameReader to a device-role PortholeSecureChannel feeding the session router. Adds TransportFrameReader to PortholeCore and a reader-based PortholeSecureChannel init so the plaintext handshake and encrypted session share one connection without a second stream iterator. NWConnectionTransport is a thin framing adapter; the handshake/pairing lifecycle is fully tested over LoopbackTransport (pairing success, wrong code, 3-strike burn, expiry, session key match, unknown pairing, revoke). Closes plan step: porthole-kit-network
The Mac client over PortholeCore: PortholeBrowser (Bonjour discovery), PortholePairingClient (pair handshake + login-keychain credential persistence), PortholeClient (re-discover + open a session), and the PortholeSession actor (id-matched request/response + subscription streams). ClientHandshake mirrors the device handshake and is pure of Network types. Promotes NWConnectionTransport into PortholeCore so both sides share it. PortholeClientKitTests run the real device (PortholeKit) and client stacks in one process over LoopbackTransport: pair, open an encrypted session, and invoke/query/subscribe through the secure channel, plus wrong-code and revoked-pairing rejection. Closes plan step: porthole-client-kit
Adds swift-argument-parser and PortholeCLICore holding all CLI logic over PortholeClientKit: devices, pair, unpair, connectors, actions, sources, call (with destructive confirmation + --out for data), query (with --all paging and table/json output), tail, and an mcp stub. Pure helpers (CLIValueParsing, OutputFormatting, AppResolution) are unit-tested without a device. The PortholeCLI Tuist command-line tool is a thin @main entry that parses and dispatches async commands explicitly (the availability-safe form). Adds PortholeClient.unpair. Closes plan step: porthole-cli
Adds the official MCP Swift SDK and PortholeMCP: MCPToolBuilder plans tools from a device manifest (porthole_overview, act_/query_/tail_<connector>_<member> with sanitized names), PortholeMCPDispatcher routes calls to a PortholeSessionProviding (PNG results -> image content, JSON otherwise, bounded tail windows, errors -> isError), and PortholeMCPServer wires both to the SDK's Server + StdioTransport. Implements the real porthole mcp subcommand. Planner and dispatcher are pure of MCP SDK types and unit-tested against a scripted fake session. The SDK is pinned to a main revision because 0.9.0's NetworkTransport fails Swift 6 strict-concurrency under the current toolchain (fixed on main). Closes plan step: porthole-mcp
PortholePairingView drives a Porthole's observable state: start/stop advertising, the large pending pairing-code display, active session count, and a paired-Macs list with swipe-to-revoke. Appearance lives in PortholeStylesheet (a Broadway BStylesheet) with portholeBroadwayRoot() seeding, mirroring PeriscopeTools; the pairing-code face widens tracking at accessibility sizes. Tests pin default tokens + environment fallback and host a probe confirming the trait-aware token resolves across the PortholeKitUI<->BroadwayUI boundary, plus a render smoke test. Closes plan step: porthole-kit-ui
A SwiftUI Mac Catalyst app (product Porthole) over PortholeClientKit: sidebar of paired/discovered apps with pairing (code-entry sheet), a connector browser, a paged data-source table with a live tail toggle, and a schema-generated action form. Unsandboxed so it shares the login keychain + metadata with the CLI and MCP. Logic lives in @observable models (AppModel/SourceTableModel/ActionFormModel); views render and route. Adds a macos CI job building PortholeCLI and the Catalyst PortholeApp, and includes PortholeApp in the Stuff-iOS-Tests build action so the iOS variant is built too. Closes plan step: porthole-mac-app
Adds the built-in ui connector to PortholeKit (iOS only, auto-registered): windows / view-tree / accessibility-tree data sources and screenshot (PNG) + open-url actions, all captured on the main actor into Sendable values. Hosted tests in StuffTestHost drive them over a loopback session: a tagged view appears in the tree, the screenshot decodes as a PNG, and an invalid URL is rejected. Closes plan step: connector-view-hierarchy
Adds the built-in files connector to PortholeKit (auto-registered with the configured App Group ids): roots + entries data sources and a read-file action (capped by maxBytes), read-only, with a mandatory path-traversal guard that resolves symlinks and rejects anything escaping the root. Where passes its App Group so the shared container is browsable. Also lets connector handlers throw precise PortholeErrors (e.g. the traversal guard's invalidParameters) instead of collapsing to handlerFailed. Tests cover roots/entries/read-file + truncation and the traversal, symlink-escape, and unknown-root rejections. Closes plan step: connector-files
Adds two built-in PortholeKit connectors, both behind injectable protocol seams (NotificationCenterProviding / PermissionsReading) so tests script them without touching the real frameworks. notifications: settings/pending/ delivered sources + a destructive remove-pending action over UNUserNotificationCenter. permissions: one row per notifications/location/ camera/microphone/photos authorization status, each framework guarded by canImport, and every read a status query that never prompts. Closes plan step: connector-system
New module with a lifecycle connector exposing a launch-state data source from an injected LifecycleRunner: phase (launching/running/failed/ready), reason, running step, and any launch failure. Reads the runner on the main actor and surfaces exactly what it publishes (no fabricated step list). No actions in v1. Closes plan step: connector-lifecycle
Makes SwiftDataInspectorReader (and its snapshots InspectorEntity/ InspectorRow/InspectorRowSet/InspectorRelatedRows) public + Sendable so a non-UI consumer (the forthcoming Porthole SwiftData connector) can browse a store without SwiftUI. The reflection and views stay internal; the SwiftUI surface is unchanged. Adds SwiftDataInspectorReaderTests over the public API and documents the headless surface. Closes plan step: swiftdata-reader-promotion
New module with SwiftDataConnector over SwiftDataInspector's headless reader: an entities data source (name/count/columns) and a rows data source (filter entity, cursor paging over disjoint rowLimit windows). Register one per ModelContainer with an explicit id. Read-only by construction. Closes plan step: connector-swiftdata
New module with a periscope connector over PeriscopeStore + Periscope: an events data source (LogQuery-backed, newest-first, limit+offset cursor, with minimumLevel/eventName/messageContains/date/afterSequence filters and decoded JSON payloads), a subscribable live-events tail off liveRecords(), and a scopes source. Read-only. Closes plan step: connector-periscope
New Where/WherePorthole module: WhereConnector (id where) over WhereServices — year-report, manual-days, evidence (metadata), preferences, and data-issues sources, plus scan-data-issues, capture-location-now, and attribute-coordinate actions. Preferences cross as a Sendable snapshot. Read-mostly, no destructive actions. Wires the whole Porthole suite into the app through WhereUI (the dynamic framework) — PortholeKit/KitUI/Periscope/SwiftData/Lifecycle/WherePorthole are added to WhereUI's deps only, so no duplicate copy can split Broadway's type-keyed metadata (the app target and WhereUITests add nothing). A DEBUG-only PortholeDeveloperView builds the Porthole, registers the Where/Periscope/ SwiftData connectors (built-ins self-register), and shows PortholePairingView from a new developer-menu row. Full Stuff-iOS-Tests scheme green, including the double-link regression guards. Also hardens the end-to-end test's #require(key(for:)) against a Swift 6.2 macro type-check fragility. Closes plan step: connector-where
Adds Shared/Porthole/TODOs.md (daemon/HTTP MCP, SPAKE2, blob transfer, write ops, App Intents bridge, AI connector-builder, iPad) and a top-level Shared/Porthole/README.md tying the modules together. Notes the macOS CI job in the root AGENTS.md. Pure docs — no behavior change. Closes plan step: docs-roadmap
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Porthole gives local agents (and humans) access to the data and operations inside a running iOS app from a Mac — over a paired Bonjour/TCP bridge — through an MCP server, a command-line tool, and a Mac Catalyst app. Apps expose surfaces by registering connectors (actions + data sources); the suite ships built-in connectors for app info, the view/accessibility hierarchy + screenshots, the file system, notifications, permissions, Periscope logs, SwiftData stores, and LifecycleKit launch state, and the Where app adds a domain-specific
WhereConnector.It ships in release but is inert until the app calls
start(); Where gates it behind a#if DEBUGdeveloper-menu toggle.Architecture
PortholeClientKitand connects directly; the MCP is a stdio subprocess. Credentials live in the login keychain, shared across surfaces.Modules
Device side:
PortholeCore(wire protocol, framing, crypto — iOS + macOS),PortholeKit(runtime + Bonjour listener + built-in connectors),PortholeKitUI(Broadway-styled pairing view),PortholePeriscope/PortholeSwiftData/PortholeLifecycle(library connectors),Where/WherePorthole(WhereConnector).Mac side:
PortholeClientKit(discovery/pairing/sessions),PortholeMCP(MCP stdio server on the official Swift SDK),PortholeCLICore+ thePortholeCLItool, andPortholeApp(the CatalystPortholeapp).Connectors
app(info + ping),ui(windows / view-tree / accessibility-tree + screenshot & open-url),files(sandbox + App Group roots, capped reads, tested path-traversal/symlink guard),notifications,permissions(reads never prompt).periscope(query history + live tail + scopes),swiftdata(entities + paged rows via a promoted headlessSwiftDataInspectorReader),lifecycle(launch state).where— year reports, manual days, evidence metadata, preferences, data issues; plusscan-data-issues,capture-location-now,attribute-coordinate. Read-mostly, no destructive actions.Testing
All protocol/runtime/connector logic runs in the iOS
Stuff-iOS-Testsscheme, hosted inStuffTestHost; the device and client stacks meet end-to-end in one process over aLoopbackTransport(pair → encrypted session → invoke/query/subscribe, plus wrong-code and revoked-pairing rejection). The full scheme is green, including the Broadway double-link regression guards (WhereStylesheetTests+ theresolvesTraitAwareTokenstests) — the whole suite reaches the app through WhereUI so no type-keyed metadata is duplicated.The macOS-only targets (
PortholeCLI,PortholeAppfor Mac Catalyst) build in a newmacosCI job.Notable decisions
mainrevision, not 0.9.0: that release'sNetworkTransport.swiftfails Swift 6 strict-concurrency under the current toolchain (fixed on main). Documented inPackage.swiftwith a note to move to the next tagged release.modelcontextprotocol/swift-sdk(productMCP) andswift-argument-parser.Package.swiftnow also targets.macOS(.v26).Follow-ups
Tracked in
Shared/Porthole/TODOs.md: a menubar daemon hosting a persistent HTTP MCP endpoint, SPAKE2 pairing, blob/backup transfer, write-capable file/preferences/store actions, an App Intents bridge, and an AI connector-builder in the Mac app. There are also two MCP content-API deprecation warnings worth a small follow-up.