diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c8c718b..bf298ad8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,3 +58,18 @@ jobs: path: diagnostics if-no-files-found: warn retention-days: 7 + + macos: + name: Build (macOS) + needs: format + runs-on: xcode-27 + steps: + - uses: actions/checkout@v4 + - uses: jdx/mise-action@v3 + # The Porthole suite adds macOS-only targets that the iOS test scheme + # can't build: the `porthole` command-line tool and the Mac Catalyst + # `Porthole` app. Build both so a break in the Mac client is caught. + - name: Build Porthole CLI (macOS) + run: mise exec -- tuist build PortholeCLI -- -destination 'platform=macOS' + - name: Build Porthole app (Mac Catalyst) + run: mise exec -- tuist build PortholeApp -- -destination 'platform=macOS,variant=Mac Catalyst' diff --git a/AGENTS.md b/AGENTS.md index a7d7cb31..c18ee6cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,6 +77,17 @@ by `./sync-agents`. | Platform | Minimum OS | |------------------------------|-------------| | iPhone, iPad, Mac Catalyst | iOS 26.0 | +| macOS (Catalyst app + CLI) | macOS 26.0 | + +The root [`Package.swift`](Package.swift) targets both `.iOS(.v26)` and +`.macOS(.v26)`: most library targets are iOS-first, but the **Porthole** suite +(under `Shared/Porthole/`, see [`Shared/Porthole/README.md`](Shared/Porthole/README.md)) +adds macOS-capable libraries plus a `.commandLineTool` (`porthole`) and a Mac +Catalyst app (`PortholeApp`, productName `Porthole`) in +[`Project.swift`](Project.swift). Those macOS-only targets aren't in the iOS +test scheme; CI builds them in a separate **`macos`** job +(`.github/workflows/ci.yml`) — `tuist build PortholeCLI` + +`tuist build PortholeApp` for Mac Catalyst. Install the Where app to a connected iPhone from the CLI (no Xcode UI) with `./Where/install` — it builds + code-signs Release and installs/launches via diff --git a/Package.resolved b/Package.resolved index 0755e113..3e560b43 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,77 @@ { - "originHash" : "290f4f4f567766c3af06cda2e9b5ef722f18cb0b6a0ccb699a3fdb0cbbf6d528", + "originHash" : "22881af06cc0e99c8102bb0b72ec8babd882ee339793d9a751c6e101d0f263c6", "pins" : [ + { + "identity" : "eventsource", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattt/eventsource.git", + "state" : { + "revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "a878e7f8f46cfc0e1125e565b5c08e7d5272dc9a", + "version" : "1.14.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "0b18836bd8b0162e7e17a995a3fbee20ed8f3b2b", + "version" : "2.101.3" + } + }, + { + "identity" : "swift-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/modelcontextprotocol/swift-sdk", + "state" : { + "revision" : "a0ae212ebf6eab5f754c3129608bc5557637e605" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "50688cacbd41d547e9eb9f7a213542340b7c442b", + "version" : "1.7.5" + } + }, { "identity" : "zipfoundation", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 5b23accf..7d6524cf 100644 --- a/Package.swift +++ b/Package.swift @@ -6,6 +6,7 @@ let package = Package( defaultLocalization: "en", platforms: [ .iOS(.v26), + .macOS(.v26), ], products: [ .library(name: "StuffCore", targets: ["StuffCore"]), @@ -22,9 +23,28 @@ let package = Package( .library(name: "WhereIntents", targets: ["WhereIntents"]), .library(name: "BroadwayCore", targets: ["BroadwayCore"]), .library(name: "BroadwayUI", targets: ["BroadwayUI"]), + .library(name: "PortholeCore", targets: ["PortholeCore"]), + .library(name: "PortholeKit", targets: ["PortholeKit"]), + .library(name: "PortholeKitUI", targets: ["PortholeKitUI"]), + .library(name: "PortholeClientKit", targets: ["PortholeClientKit"]), + .library(name: "PortholeMCP", targets: ["PortholeMCP"]), + .library(name: "PortholeCLICore", targets: ["PortholeCLICore"]), + .library(name: "PortholeLifecycle", targets: ["PortholeLifecycle"]), + .library(name: "PortholeSwiftData", targets: ["PortholeSwiftData"]), + .library(name: "PortholePeriscope", targets: ["PortholePeriscope"]), + .library(name: "WherePorthole", targets: ["WherePorthole"]), ], dependencies: [ .package(url: "https://github.com/weichsel/ZIPFoundation", from: "0.9.20"), + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.8.2"), + // Pinned to a main revision rather than 0.9.0: that release's + // NetworkTransport has a Swift 6 strict-concurrency error under the + // current toolchain, fixed on main (a `MainFlag` reference replaced a + // captured `var`). Move to the next tagged release that includes it. + .package( + url: "https://github.com/modelcontextprotocol/swift-sdk", + revision: "a0ae212ebf6eab5f754c3129608bc5557637e605", + ), ], targets: [ .target( @@ -108,6 +128,17 @@ let package = Package( .target(name: "PeriscopeUI"), .target(name: "RegionKit"), .target(name: "SwiftDataInspector"), + // Porthole (DEBUG developer surface). Everything reaches the app + // *through* WhereUI (a dynamic framework) so a duplicate copy of + // any of these can't split type-keyed metadata — see the root + // AGENTS.md "Targets" note. The app target and WhereUITests add + // nothing new. + .target(name: "PortholeKit"), + .target(name: "PortholeKitUI"), + .target(name: "PortholePeriscope"), + .target(name: "PortholeSwiftData"), + .target(name: "PortholeLifecycle"), + .target(name: "WherePorthole"), ], path: "Where/WhereUI/Sources", resources: [ @@ -138,5 +169,81 @@ let package = Package( ], path: "Shared/Broadway/BroadwayUI/Sources", ), + .target( + name: "PortholeCore", + path: "Shared/Porthole/PortholeCore/Sources", + ), + .target( + name: "PortholeKit", + dependencies: [ + .target(name: "PortholeCore"), + ], + path: "Shared/Porthole/PortholeKit/Sources", + ), + .target( + name: "PortholeKitUI", + dependencies: [ + .target(name: "PortholeKit"), + .target(name: "BroadwayCore"), + .target(name: "BroadwayUI"), + ], + path: "Shared/Porthole/PortholeKitUI/Sources", + ), + .target( + name: "PortholeClientKit", + dependencies: [ + .target(name: "PortholeCore"), + ], + path: "Shared/Porthole/PortholeClientKit/Sources", + ), + .target( + name: "PortholeMCP", + dependencies: [ + .target(name: "PortholeClientKit"), + .product(name: "MCP", package: "swift-sdk"), + ], + path: "Shared/Porthole/PortholeMCP/Sources", + ), + .target( + name: "PortholeCLICore", + dependencies: [ + .target(name: "PortholeClientKit"), + .target(name: "PortholeMCP"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ], + path: "Shared/Porthole/PortholeCLICore/Sources", + ), + .target( + name: "PortholeLifecycle", + dependencies: [ + .target(name: "PortholeKit"), + .target(name: "LifecycleKit"), + ], + path: "Shared/Porthole/PortholeLifecycle/Sources", + ), + .target( + name: "PortholeSwiftData", + dependencies: [ + .target(name: "PortholeKit"), + .target(name: "SwiftDataInspector"), + ], + path: "Shared/Porthole/PortholeSwiftData/Sources", + ), + .target( + name: "PortholePeriscope", + dependencies: [ + .target(name: "PortholeKit"), + .target(name: "PeriscopeCore"), + ], + path: "Shared/Porthole/PortholePeriscope/Sources", + ), + .target( + name: "WherePorthole", + dependencies: [ + .target(name: "PortholeKit"), + .target(name: "WhereCore"), + ], + path: "Where/WherePorthole/Sources", + ), ], ) diff --git a/Project.swift b/Project.swift index 97f39ba1..54f88047 100644 --- a/Project.swift +++ b/Project.swift @@ -391,6 +391,119 @@ let project = Project( "BroadwayCore", ], ), + unitTests( + name: "PortholeCoreTests", + bundleIdSuffix: "porthole.core", + productDependency: "PortholeCore", + sources: ["Shared/Porthole/PortholeCore/Tests/**"], + ), + unitTests( + name: "PortholeKitTests", + bundleIdSuffix: "porthole.kit", + productDependency: "PortholeKit", + sources: ["Shared/Porthole/PortholeKit/Tests/**"], + extraPackageProducts: ["PortholeCore"], + ), + unitTests( + name: "PortholeKitUITests", + bundleIdSuffix: "porthole.kit.ui", + productDependency: "PortholeKitUI", + sources: ["Shared/Porthole/PortholeKitUI/Tests/**"], + extraPackageProducts: ["PortholeKit", "PortholeCore"], + ), + unitTests( + name: "PortholeClientKitTests", + bundleIdSuffix: "porthole.client", + productDependency: "PortholeClientKit", + sources: ["Shared/Porthole/PortholeClientKit/Tests/**"], + extraPackageProducts: ["PortholeCore", "PortholeKit"], + ), + unitTests( + name: "PortholeMCPTests", + bundleIdSuffix: "porthole.mcp", + productDependency: "PortholeMCP", + sources: ["Shared/Porthole/PortholeMCP/Tests/**"], + extraPackageProducts: ["PortholeClientKit", "PortholeCore"], + ), + unitTests( + name: "PortholeLifecycleTests", + bundleIdSuffix: "porthole.lifecycle", + productDependency: "PortholeLifecycle", + sources: ["Shared/Porthole/PortholeLifecycle/Tests/**"], + extraPackageProducts: ["PortholeKit", "PortholeCore", "LifecycleKit"], + ), + unitTests( + name: "PortholeSwiftDataTests", + bundleIdSuffix: "porthole.swiftdata", + productDependency: "PortholeSwiftData", + sources: ["Shared/Porthole/PortholeSwiftData/Tests/**"], + extraPackageProducts: ["PortholeKit", "PortholeCore", "SwiftDataInspector"], + ), + unitTests( + name: "PortholePeriscopeTests", + bundleIdSuffix: "porthole.periscope", + productDependency: "PortholePeriscope", + sources: ["Shared/Porthole/PortholePeriscope/Tests/**"], + extraPackageProducts: ["PortholeKit", "PortholeCore", "PeriscopeCore"], + ), + unitTests( + name: "WherePortholeTests", + bundleIdSuffix: "where.porthole", + productDependency: "WherePorthole", + sources: ["Where/WherePorthole/Tests/**"], + extraPackageProducts: ["PortholeKit", "PortholeCore", "WhereCore", "RegionKit"], + ), + unitTests( + name: "PortholeCLICoreTests", + bundleIdSuffix: "porthole.cli", + productDependency: "PortholeCLICore", + sources: ["Shared/Porthole/PortholeCLICore/Tests/**"], + extraPackageProducts: ["PortholeClientKit", "PortholeCore"], + ), + .target( + name: "PortholeCLI", + destinations: [.mac], + product: .commandLineTool, + productName: "porthole", + bundleId: "com.stuff.porthole.cli", + deploymentTargets: .macOS("26.0"), + sources: ["Shared/Porthole/PortholeCLI/Sources/**"], + dependencies: [ + .package(product: "PortholeCLICore"), + ], + ), + .target( + name: "PortholeApp", + // The Porthole Mac client app. Catalyst so it shares SwiftUI + + // Broadway with the on-device UI and can later run on iPad; the built + // product is named "Porthole". Unsandboxed (a dev tool) so it shares + // the login keychain + metadata with the CLI and MCP. + destinations: [.iPhone, .iPad, .macCatalyst], + product: .app, + productName: "Porthole", + bundleId: "com.stuff.porthole", + deploymentTargets: deployment, + infoPlist: .extendingDefault(with: [ + "UILaunchScreen": .dictionary([:]), + "UIApplicationSupportsIndirectInputEvents": .boolean(true), + "NSLocalNetworkUsageDescription": .string( + "Porthole connects to paired apps advertising on your local network.", + ), + "NSBonjourServices": .array([.string("_porthole._tcp")]), + ]), + sources: ["Shared/Porthole/PortholeApp/Sources/**"], + dependencies: [ + .package(product: "PortholeClientKit"), + .package(product: "BroadwayCore"), + .package(product: "BroadwayUI"), + ], + // No custom app icon / accent color yet — clear the names actool + // otherwise requires (mirrors RegionViewer). + settings: .settings(base: [ + "ASSETCATALOG_COMPILER_APPICON_NAME": "", + "ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME": "", + ]), + ), ], // Tuist's autogeneration doesn't emit working standalone test actions for // these unit-test bundles (only the aggregate `Stuff-Workspace` scheme @@ -433,6 +546,17 @@ let project = Project( "BroadwayCoreTests", "BroadwayUITests", "BroadwayCatalogTests", + "PortholeApp", + "PortholeCoreTests", + "PortholeKitTests", + "PortholeKitUITests", + "PortholeClientKitTests", + "PortholeMCPTests", + "PortholeLifecycleTests", + "PortholeSwiftDataTests", + "PortholePeriscopeTests", + "WherePortholeTests", + "PortholeCLICoreTests", ]), testAction: .targets([ "StuffCoreTests", @@ -450,8 +574,30 @@ let project = Project( "BroadwayCoreTests", "BroadwayUITests", "BroadwayCatalogTests", + "PortholeCoreTests", + "PortholeKitTests", + "PortholeKitUITests", + "PortholeClientKitTests", + "PortholeMCPTests", + "PortholeLifecycleTests", + "PortholeSwiftDataTests", + "PortholePeriscopeTests", + "WherePortholeTests", + "PortholeCLICoreTests", ]), ), + .scheme( + name: "PortholeCLI", + shared: true, + buildAction: .buildAction(targets: ["PortholeCLI"]), + runAction: .runAction(executable: "PortholeCLI"), + ), + .scheme( + name: "PortholeApp", + shared: true, + buildAction: .buildAction(targets: ["PortholeApp"]), + runAction: .runAction(executable: "PortholeApp"), + ), testScheme(name: "StuffCoreTests"), testScheme(name: "LifecycleKitTests"), testScheme(name: "JournalKitTests"), @@ -467,5 +613,15 @@ let project = Project( testScheme(name: "BroadwayCoreTests"), testScheme(name: "BroadwayUITests"), testScheme(name: "BroadwayCatalogTests"), + testScheme(name: "PortholeCoreTests"), + testScheme(name: "PortholeKitTests"), + testScheme(name: "PortholeKitUITests"), + testScheme(name: "PortholeClientKitTests"), + testScheme(name: "PortholeMCPTests"), + testScheme(name: "PortholeLifecycleTests"), + testScheme(name: "PortholeSwiftDataTests"), + testScheme(name: "PortholePeriscopeTests"), + testScheme(name: "WherePortholeTests"), + testScheme(name: "PortholeCLICoreTests"), ], ) diff --git a/Shared/Porthole/PortholeApp/AGENTS.md b/Shared/Porthole/PortholeApp/AGENTS.md new file mode 100644 index 00000000..d35de485 --- /dev/null +++ b/Shared/Porthole/PortholeApp/AGENTS.md @@ -0,0 +1,34 @@ +# PortholeApp – Module Shape + +PortholeApp is the Porthole Mac client app (SwiftUI, Mac Catalyst, product name +`Porthole`): sidebar of paired/discovered apps, connector browser, paged +data-source table with a live tail toggle, and a schema-generated action form. +See [`README.md`](README.md). + +This file complements the root [`AGENTS.md`](../../../AGENTS.md) — read it first. + +## Scope & dependencies + +- A **Tuist app target** (in `Project.swift`), not an SPM library. Depends on + `PortholeClientKit` (discovery/pairing/sessions) + `BroadwayCore`/`BroadwayUI`. +- **Unsandboxed by design** — a dev tool that shares the login keychain + + Application Support metadata with the CLI and MCP. Don't add an App Sandbox + entitlement. +- Info.plist declares `NSLocalNetworkUsageDescription` + `NSBonjourServices` + (`_porthole._tcp`) so it can browse and connect. + +## Invariants + +- **Keep logic in `@Observable` models** (`AppModel`, `SourceTableModel`, + `ActionFormModel`); views render and route. The models orchestrate + `PortholeClientKit` — they don't reimplement protocol logic. +- **A live tail is torn down on view disappearance** (`SourceTableModel.teardown` + from `.onDisappear`) since a Swift 6 `deinit` can't touch main-actor state; + every start has a stop. +- **Pairing awaits the user's code** via a continuation the sheet resumes — the + device shows the code, the user types it, the pairing client consumes it. + +## Testing + +No unit-test bundle: the model logic is thin over `PortholeClientKit` (which is +end-to-end tested). CI builds this target for Mac Catalyst (`macos` job). diff --git a/Shared/Porthole/PortholeApp/README.md b/Shared/Porthole/PortholeApp/README.md new file mode 100644 index 00000000..e4c16eaa --- /dev/null +++ b/Shared/Porthole/PortholeApp/README.md @@ -0,0 +1,34 @@ +# PortholeApp + +PortholeApp is the Porthole Mac client — a SwiftUI app (Mac Catalyst, built +product named **Porthole**) for browsing and driving paired iOS apps, alongside +the CLI and MCP server. It's a Tuist app target (see +[`Project.swift`](../../../Project.swift)), not an SPM library. + +## What it does + +- **Sidebar** — paired apps (swipe to unpair) and live-discovered apps with a + **Pair** button. Pairing prompts for the 6-digit code the device shows. +- **Connector browser** — the selected app's connectors, each listing its data + sources and actions. +- **Data-source table** — a paged table (Load More) with a **Live** toggle for + subscribable sources that appends streamed events. +- **Action form** — a form generated from the action's parameter schema (with a + raw-JSON editor fallback), showing the JSON result. + +Built on [`PortholeClientKit`](../PortholeClientKit) for discovery, pairing, and +sessions, and [Broadway](../../Broadway) for styling. Credentials are shared with +the CLI and MCP via the login keychain. + +## Not sandboxed + +It's a developer tool, so it ships without the App Sandbox — that's what lets it +share the login keychain and `~/Library/Application Support/Porthole/` metadata +with the CLI and MCP server, and use the local network freely. + +## Testing + +App model/view logic is intentionally thin over `PortholeClientKit` (which is +end-to-end tested against the real device stack). CI builds this target for Mac +Catalyst (the `macos` job in [`ci.yml`](../../../.github/workflows/ci.yml)); it +has no separate unit-test bundle. diff --git a/Shared/Porthole/PortholeApp/Sources/Models/ActionFormModel.swift b/Shared/Porthole/PortholeApp/Sources/Models/ActionFormModel.swift new file mode 100644 index 00000000..1cd97c94 --- /dev/null +++ b/Shared/Porthole/PortholeApp/Sources/Models/ActionFormModel.swift @@ -0,0 +1,86 @@ +import Foundation +import PortholeClientKit +import PortholeCore + +/// Backs a schema-generated form for one action: it holds an editable field per +/// top-level parameter, invokes the action, and exposes the result. +@MainActor +@Observable +final class ActionFormModel { + let descriptor: PortholeActionDescriptor + private let ref: PortholeActionRef + private let session: PortholeSession + + /// One editable field per top-level object property. + struct Field: Identifiable { + let id: String + let kind: PortholeSchema.Kind + var stringValue: String = "" + var boolValue: Bool = false + } + + var fields: [Field] + /// Free-form JSON editor, used when the parameters aren't a flat object. + var rawJSON = "{}" + var usesRawEditor: Bool + private(set) var resultText: String? + private(set) var errorMessage: String? + private(set) var isRunning = false + + init( + descriptor: PortholeActionDescriptor, + connector: PortholeConnectorID, + session: PortholeSession, + ) { + self.descriptor = descriptor + ref = PortholeActionRef(connector: connector, action: descriptor.id) + self.session = session + + if descriptor.parameters.kind == .object, + let properties = descriptor.parameters.properties + { + fields = properties + .sorted { $0.key < $1.key } + .map { Field(id: $0.key, kind: $0.value.kind) } + usesRawEditor = false + } else { + fields = [] + usesRawEditor = true + } + } + + func run() async { + guard !isRunning else { return } + isRunning = true + defer { isRunning = false } + do { + let parameters = try buildParameters() + let result = try await session.invoke(ref, parameters: parameters) + resultText = Rendering.prettyJSON(result) + errorMessage = nil + } catch { + errorMessage = String(describing: error) + } + } + + private func buildParameters() throws -> PortholeValue { + if usesRawEditor { + guard let data = rawJSON.data(using: .utf8) else { return .object([:]) } + return try JSONDecoder().decode(PortholeValue.self, from: data) + } + var object: [String: PortholeValue] = [:] + for field in fields { + switch field.kind { + case .boolean: + object[field.id] = .bool(field.boolValue) + case .integer: + if let int = Int64(field.stringValue) { object[field.id] = .int(int) } + case .number: + if let double = Double(field.stringValue) { object[field.id] = .double(double) } + case .string, .data, .date, .array, .object: + if !field.stringValue.isEmpty { object[field.id] = .string(field.stringValue) } + } + } + return .object(object) + } +} diff --git a/Shared/Porthole/PortholeApp/Sources/Models/AppModel.swift b/Shared/Porthole/PortholeApp/Sources/Models/AppModel.swift new file mode 100644 index 00000000..64e93dcc --- /dev/null +++ b/Shared/Porthole/PortholeApp/Sources/Models/AppModel.swift @@ -0,0 +1,121 @@ +import Foundation +import PortholeClientKit +import PortholeCore + +/// The app's top-level coordinator: tracks paired and discovered apps, owns the +/// active session to the selected app, and drives pairing. `@MainActor` + +/// `@Observable` for SwiftUI. +@MainActor +@Observable +final class AppModel { + var pairedApps: [PairedApp] = [] + var discovered: [DiscoveredApp] = [] + var selection: UUID? + private(set) var manifests: [ConnectorManifest] = [] + private(set) var connectedApp: PairedApp? + var statusMessage: String? + + /// A pairing awaiting the user's code entry. + var pairingInProgress: DiscoveredApp? + private var codeContinuation: CheckedContinuation? + + private let client = PortholeClient() + private let pairingClient = PortholePairingClient() + private var session: PortholeSession? + private var browseTask: Task? + + func onAppear() { + reloadPaired() + startBrowsing() + } + + func reloadPaired() { + pairedApps = (try? client.pairedApps()) ?? [] + } + + private func startBrowsing() { + browseTask?.cancel() + browseTask = Task { [weak self] in + for await apps in PortholeBrowser().discovered() { + self?.discovered = apps + } + } + } + + /// Active `PortholeSession` for connector screens (nil until connected). + var activeSession: PortholeSession? { + session + } + + func select(_ paired: PairedApp) async { + selection = paired.pairingID + await disconnect() + statusMessage = "Connecting to \(paired.appName)…" + do { + let session = try await client.connect(to: paired) + self.session = session + connectedApp = paired + manifests = try await session.manifest() + statusMessage = nil + } catch { + statusMessage = "Couldn't connect: \(error)" + manifests = [] + connectedApp = nil + } + } + + func disconnect() async { + if let session { await session.close() } + session = nil + connectedApp = nil + manifests = [] + } + + // MARK: - Pairing + + func beginPairing(with app: DiscoveredApp) { + pairingInProgress = app + Task { [weak self] in + guard let self else { return } + do { + let paired = try await pairingClient.pair(with: app) { + await self.awaitCode() + } + reloadPaired() + pairingInProgress = nil + statusMessage = "Paired with \(paired.appName)." + } catch { + pairingInProgress = nil + codeContinuation = nil + statusMessage = "Pairing failed: \(error)" + } + } + } + + /// Called by the pairing sheet once the user enters the device's code. + func submitCode(_ code: String) { + codeContinuation?.resume(returning: code) + codeContinuation = nil + } + + func cancelPairing() { + codeContinuation?.resume(returning: "") + codeContinuation = nil + pairingInProgress = nil + } + + private func awaitCode() async -> String { + await withCheckedContinuation { continuation in + codeContinuation = continuation + } + } + + func unpair(_ paired: PairedApp) { + try? client.unpair(paired) + reloadPaired() + if selection == paired.pairingID { + selection = nil + Task { await disconnect() } + } + } +} diff --git a/Shared/Porthole/PortholeApp/Sources/Models/SourceTableModel.swift b/Shared/Porthole/PortholeApp/Sources/Models/SourceTableModel.swift new file mode 100644 index 00000000..2dd2adb0 --- /dev/null +++ b/Shared/Porthole/PortholeApp/Sources/Models/SourceTableModel.swift @@ -0,0 +1,86 @@ +import Foundation +import PortholeClientKit +import PortholeCore + +/// Loads and paginates one data source, and optionally tails it live. +@MainActor +@Observable +final class SourceTableModel { + let descriptor: PortholeDataSourceDescriptor + private let ref: PortholeDataSourceRef + private let session: PortholeSession + + private(set) var rows: [PortholeValue] = [] + private(set) var nextCursor: String? + private(set) var isLoading = false + private(set) var errorMessage: String? + var isLive = false { + didSet { + guard oldValue != isLive else { return } + isLive ? startTail() : stopTail() + } + } + + private var tailTask: Task? + + init( + descriptor: PortholeDataSourceDescriptor, + connector: PortholeConnectorID, + session: PortholeSession, + ) { + self.descriptor = descriptor + ref = PortholeDataSourceRef(connector: connector, source: descriptor.id) + self.session = session + } + + var columns: [String] { + Rendering.columns(for: rows) + } + + var canLoadMore: Bool { + nextCursor != nil + } + + func loadFirstPage() async { + rows = [] + nextCursor = nil + await loadMore() + } + + func loadMore() async { + guard !isLoading else { return } + isLoading = true + defer { isLoading = false } + do { + let page = try await session.query(ref, PortholeQuery(cursor: nextCursor)) + rows.append(contentsOf: page.rows) + nextCursor = page.nextCursor + errorMessage = nil + } catch { + errorMessage = String(describing: error) + } + } + + private func startTail() { + tailTask = Task { [weak self, ref, session] in + do { + let stream = try await session.subscribe(ref) + for try await value in stream { + self?.rows.append(value) + } + } catch { + self?.errorMessage = String(describing: error) + } + } + } + + private func stopTail() { + tailTask?.cancel() + tailTask = nil + } + + /// Cancels any live tail; call when the view goes away. + func teardown() { + stopTail() + } +} diff --git a/Shared/Porthole/PortholeApp/Sources/PortholeApp.swift b/Shared/Porthole/PortholeApp/Sources/PortholeApp.swift new file mode 100644 index 00000000..7dfaa1c6 --- /dev/null +++ b/Shared/Porthole/PortholeApp/Sources/PortholeApp.swift @@ -0,0 +1,127 @@ +import PortholeClientKit +import SwiftUI + +@main +struct PortholeApp: App { + @State private var model = AppModel() + + var body: some Scene { + WindowGroup { + RootView(model: model) + .portholeAppBroadwayRoot() + .task { model.onAppear() } + } + } +} + +struct RootView: View { + @Bindable var model: AppModel + + var body: some View { + NavigationSplitView { + SidebarView(model: model) + .navigationTitle("Porthole") + } detail: { + if let app = model.connectedApp { + ConnectorBrowserView(model: model, app: app) + } else { + ContentUnavailableView( + "Select a paired app", + systemImage: "app.connected.to.app.below.fill", + ) + } + } + .sheet(item: $model.pairingInProgress) { app in + PairSheet(model: model, app: app) + } + .safeAreaInset(edge: .bottom) { + if let status = model.statusMessage { + Text(status) + .font(.callout) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(.thinMaterial) + } + } + } +} + +struct SidebarView: View { + @Bindable var model: AppModel + + private var unpaired: [DiscoveredApp] { + let pairedBundles = Set(model.pairedApps.map(\.bundleID)) + return model.discovered.filter { !pairedBundles.contains($0.bundleID) } + } + + var body: some View { + List(selection: $model.selection) { + Section("Paired") { + if model.pairedApps.isEmpty { + Text("No paired apps").foregroundStyle(.secondary) + } + ForEach(model.pairedApps) { app in + VStack(alignment: .leading) { + Text(app.appName) + Text(app.deviceName).font(.caption).foregroundStyle(.secondary) + } + .tag(app.pairingID) + .swipeActions { + Button("Unpair", role: .destructive) { model.unpair(app) } + } + } + } + Section("Discovered") { + if unpaired.isEmpty { + Text("Searching…").foregroundStyle(.secondary) + } + ForEach(unpaired) { app in + HStack { + VStack(alignment: .leading) { + Text(app.appName) + Text(app.deviceName).font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button("Pair") { model.beginPairing(with: app) } + .buttonStyle(.borderedProminent) + } + } + } + } + .onChange(of: model.selection) { _, newValue in + guard let id = newValue, + let app = model.pairedApps.first(where: { $0.pairingID == id }) else { return } + Task { await model.select(app) } + } + } +} + +struct PairSheet: View { + @Bindable var model: AppModel + let app: DiscoveredApp + @State private var code = "" + + var body: some View { + NavigationStack { + Form { + Section { + Text( + "A 6-digit code is showing on \(app.appName) on \(app.deviceName). Enter it to pair.", + ) + TextField("Code", text: $code) + .font(.system(.title2, design: .monospaced)) + } + } + .navigationTitle("Pair with \(app.appName)") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { model.cancelPairing() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Connect") { model.submitCode(code) } + .disabled(code.isEmpty) + } + } + } + } +} diff --git a/Shared/Porthole/PortholeApp/Sources/Rendering.swift b/Shared/Porthole/PortholeApp/Sources/Rendering.swift new file mode 100644 index 00000000..1c6774b7 --- /dev/null +++ b/Shared/Porthole/PortholeApp/Sources/Rendering.swift @@ -0,0 +1,59 @@ +import Foundation +import PortholeCore + +/// Display helpers for `PortholeValue` in the app's tables and forms. +enum Rendering { + /// A compact one-line cell string for a value. + static func cell(_ value: PortholeValue?) -> String { + guard let value else { return "" } + switch value { + case .null: return "" + case let .bool(bool): return bool ? "true" : "false" + case let .int(int): return String(int) + case let .double(double): return String(double) + case let .string(string): return string + case let .data(data): return "<\(data.count) bytes>" + case let .date(date): return date.formatted(date: .abbreviated, time: .standard) + case .array, .object: return prettyJSON(value) + } + } + + /// The union of object-row keys across rows, in sorted order. + static func columns(for rows: [PortholeValue]) -> [String] { + var seen: Set = [] + var columns: [String] = [] + for row in rows { + guard case let .object(object) = row else { continue } + for key in object.keys.sorted() where !seen.contains(key) { + seen.insert(key) + columns.append(key) + } + } + return columns + } + + /// Pretty-printed JSON for a value (fragments allowed, sorted keys). + static func prettyJSON(_ value: PortholeValue) -> String { + guard let data = try? JSONSerialization.data( + withJSONObject: foundationObject(value), + options: [.prettyPrinted, .sortedKeys, .fragmentsAllowed], + ), let string = String(data: data, encoding: .utf8) else { + return String(describing: value) + } + return string + } + + private static func foundationObject(_ value: PortholeValue) -> Any { + switch value { + case .null: NSNull() + case let .bool(bool): bool + case let .int(int): int + case let .double(double): double + case let .string(string): string + case let .data(data): ["$data": data.base64EncodedString()] + case let .date(date): date.timeIntervalSince1970 + case let .array(array): array.map(foundationObject) + case let .object(object): object.mapValues(foundationObject) + } + } +} diff --git a/Shared/Porthole/PortholeApp/Sources/Styling/PortholeAppStylesheet.swift b/Shared/Porthole/PortholeApp/Sources/Styling/PortholeAppStylesheet.swift new file mode 100644 index 00000000..015a9e34 --- /dev/null +++ b/Shared/Porthole/PortholeApp/Sources/Styling/PortholeAppStylesheet.swift @@ -0,0 +1,34 @@ +import BroadwayCore +import BroadwayUI +import CoreGraphics +import SwiftUI + +/// The Porthole app's design tokens, as a Broadway ``BStylesheet``. Small for +/// now — mostly spacing — seeded at the app root by ``portholeAppBroadwayRoot()``. +struct PortholeAppStylesheet: BStylesheet { + var spacing = Spacing() + var monospacedBody: Font = .system(.body, design: .monospaced) + + init() {} + init(context _: SlicingContext) throws {} + + static let `default` = PortholeAppStylesheet() + + struct Spacing: Equatable { + var small: CGFloat = 8 + var medium: CGFloat = 16 + var large: CGFloat = 24 + } +} + +extension View { + func portholeAppBroadwayRoot() -> some View { + broadwayRoot(themes: BThemes()) + } +} + +extension EnvironmentValues { + var appStylesheet: PortholeAppStylesheet { + bContext.stylesheet(PortholeAppStylesheet.self, fallback: .default) + } +} diff --git a/Shared/Porthole/PortholeApp/Sources/Views/ActionFormView.swift b/Shared/Porthole/PortholeApp/Sources/Views/ActionFormView.swift new file mode 100644 index 00000000..211e60e6 --- /dev/null +++ b/Shared/Porthole/PortholeApp/Sources/Views/ActionFormView.swift @@ -0,0 +1,74 @@ +import PortholeCore +import SwiftUI + +/// A schema-generated form for invoking an action, showing the JSON result. +struct ActionFormView: View { + @State var model: ActionFormModel + + var body: some View { + Form { + Section { + Text(model.descriptor.summary).font(.callout) + if model.descriptor.isDestructive { + Label( + "Destructive — changes app state.", + systemImage: "exclamationmark.triangle", + ) + .foregroundStyle(.orange) + } + } + + Section("Parameters") { + if model.usesRawEditor { + TextEditor(text: $model.rawJSON) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 120) + } else if model.fields.isEmpty { + Text("No parameters").foregroundStyle(.secondary) + } else { + ForEach($model.fields) { $field in + fieldRow($field) + } + } + } + + Section { + Button { + Task { await model.run() } + } label: { + if model.isRunning { ProgressView() } else { Text("Run") } + } + .disabled(model.isRunning) + } + + if let error = model.errorMessage { + Section("Error") { + Text(error).foregroundStyle(.red).font(.system(.body, design: .monospaced)) + } + } + if let result = model.resultText { + Section("Result") { + Text(result).font(.system(.body, design: .monospaced)).textSelection(.enabled) + } + } + } + .navigationTitle(model.descriptor.title) + } + + @ViewBuilder private func fieldRow(_ field: Binding) -> some View { + switch field.wrappedValue.kind { + case .boolean: + Toggle(field.wrappedValue.id, isOn: field.boolValue) + case .integer, .number: + LabeledContent(field.wrappedValue.id) { + TextField("value", text: field.stringValue) + .multilineTextAlignment(.trailing) + } + case .string, .data, .date, .array, .object: + LabeledContent(field.wrappedValue.id) { + TextField("value", text: field.stringValue) + .multilineTextAlignment(.trailing) + } + } + } +} diff --git a/Shared/Porthole/PortholeApp/Sources/Views/ConnectorBrowserView.swift b/Shared/Porthole/PortholeApp/Sources/Views/ConnectorBrowserView.swift new file mode 100644 index 00000000..13eed982 --- /dev/null +++ b/Shared/Porthole/PortholeApp/Sources/Views/ConnectorBrowserView.swift @@ -0,0 +1,61 @@ +import PortholeClientKit +import PortholeCore +import SwiftUI + +/// Lists the connected app's connectors and routes into their actions and data +/// sources. +struct ConnectorBrowserView: View { + let model: AppModel + let app: PairedApp + + var body: some View { + NavigationStack { + List { + ForEach(model.manifests, id: \.connector.id) { manifest in + Section(manifest.connector.title) { + ForEach(manifest.dataSources, id: \.id) { source in + if let session = model.activeSession { + NavigationLink { + SourceTableView( + model: SourceTableModel( + descriptor: source, + connector: manifest.connector.id, + session: session, + ), + ) + } label: { + Label( + source.title, + systemImage: source + .supportsSubscription ? "dot.radiowaves.up.forward" : + "tablecells", + ) + } + } + } + ForEach(manifest.actions, id: \.id) { action in + if let session = model.activeSession { + NavigationLink { + ActionFormView( + model: ActionFormModel( + descriptor: action, + connector: manifest.connector.id, + session: session, + ), + ) + } label: { + Label( + action.title, + systemImage: action + .isDestructive ? "exclamationmark.triangle" : "bolt", + ) + } + } + } + } + } + } + .navigationTitle(app.appName) + } + } +} diff --git a/Shared/Porthole/PortholeApp/Sources/Views/SourceTableView.swift b/Shared/Porthole/PortholeApp/Sources/Views/SourceTableView.swift new file mode 100644 index 00000000..56a192d1 --- /dev/null +++ b/Shared/Porthole/PortholeApp/Sources/Views/SourceTableView.swift @@ -0,0 +1,65 @@ +import PortholeCore +import SwiftUI + +/// A paged table for a data source, with a live tail toggle for subscribable +/// sources. +struct SourceTableView: View { + @State var model: SourceTableModel + + var body: some View { + VStack(spacing: 0) { + if let error = model.errorMessage { + Text(error).foregroundStyle(.red).padding() + } + ScrollView([.horizontal, .vertical]) { + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { + GridRow { + ForEach(model.columns, id: \.self) { column in + Text(column).font(.caption.bold()) + } + } + Divider() + ForEach(Array(model.rows.enumerated()), id: \.offset) { _, row in + GridRow { + ForEach(model.columns, id: \.self) { column in + Text(Rendering.cell(row[column])) + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + } + } + } + } + .padding() + } + if model.canLoadMore { + Button("Load More") { Task { await model.loadMore() } } + .padding(8) + } + } + .overlay { + if model.isLoading, model.rows.isEmpty { + ProgressView() + } else if model.rows.isEmpty, model.errorMessage == nil { + ContentUnavailableView("No rows", systemImage: "tablecells") + } + } + .navigationTitle(model.descriptor.title) + .toolbar { + if model.descriptor.supportsSubscription { + ToolbarItem { + Toggle("Live", isOn: $model.isLive) + .toggleStyle(.switch) + } + } + ToolbarItem { + Button { + Task { await model.loadFirstPage() } + } label: { + Image(systemName: "arrow.clockwise") + } + } + } + .task { await model.loadFirstPage() } + .onDisappear { model.teardown() } + } +} diff --git a/Shared/Porthole/PortholeCLI/Sources/PortholeCLIEntry.swift b/Shared/Porthole/PortholeCLI/Sources/PortholeCLIEntry.swift new file mode 100644 index 00000000..2160558a --- /dev/null +++ b/Shared/Porthole/PortholeCLI/Sources/PortholeCLIEntry.swift @@ -0,0 +1,22 @@ +import ArgumentParser +import PortholeCLICore + +/// Entry point for the `porthole` tool. Parses the root command and dispatches +/// asynchronously — the explicit form (rather than `await PortholeCommand.main()`) +/// so the async subcommand `run()` methods are actually awaited. +@main +enum PortholeCLIEntry { + static func main() async { + do { + let command = try PortholeCommand.parseAsRoot() + if var asyncCommand = command as? any AsyncParsableCommand { + try await asyncCommand.run() + } else { + var syncCommand = command + try syncCommand.run() + } + } catch { + PortholeCommand.exit(withError: error) + } + } +} diff --git a/Shared/Porthole/PortholeCLICore/AGENTS.md b/Shared/Porthole/PortholeCLICore/AGENTS.md new file mode 100644 index 00000000..7b3f603a --- /dev/null +++ b/Shared/Porthole/PortholeCLICore/AGENTS.md @@ -0,0 +1,33 @@ +# PortholeCLICore – Module Shape + +PortholeCLICore is the logic behind the `porthole` CLI: the +`swift-argument-parser` command tree plus pure helpers for value parsing, output +formatting, and `--app` resolution. The `PortholeCLI` executable target is a +one-line `main.swift` over it. See [`README.md`](README.md) for the commands. + +This file complements the root [`AGENTS.md`](../../../AGENTS.md) — read it first. + +## Scope & dependencies + +- Depends on **PortholeClientKit** (+ `ArgumentParser`). No device/runtime code. +- The `mcp` subcommand is a stub here; its real implementation and the + `PortholeMCP` dependency are added in the MCP step. + +## Invariants + +- **Keep command logic thin and parsing/formatting pure.** `CLIValueParsing`, + `OutputFormatting`, and `AppResolution` are free of I/O so they're unit-tested + without a device; subcommands are glue that resolves an app, opens a + `PortholeSession`, and prints. Add new testable logic as pure functions, not + inside a `run()`. +- **Every session is closed** — `CLIRuntime.withSession` opens and always closes, + even on throw. +- **Destructive actions confirm** unless `--yes`; `--param`/`--filter` infer + scalar types with a string fallback, `--json` is the escape hatch. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PortholeCLICoreTests`, `extraPackageProducts: [PortholeClientKit, PortholeCore]`). +Cover parsing/formatting/resolution; the executable is built (not unit-tested) on +macOS via the `PortholeCLI` scheme. diff --git a/Shared/Porthole/PortholeCLICore/README.md b/Shared/Porthole/PortholeCLICore/README.md new file mode 100644 index 00000000..d6fa8cdc --- /dev/null +++ b/Shared/Porthole/PortholeCLICore/README.md @@ -0,0 +1,45 @@ +# PortholeCLICore + +PortholeCLICore holds all of the `porthole` command-line tool's logic. The +executable target (`PortholeCLI`, a Tuist `.commandLineTool`) is a one-line +`main.swift` that calls `PortholeCommand.main()`; everything testable lives here. + +Built on `swift-argument-parser` over `PortholeClientKit`. + +## Commands + +- `porthole devices [--timeout N]` — list discovered + paired apps. +- `porthole pair [--app ] [--timeout N]` — discover and pair (prompts for + the code shown on the device). +- `porthole unpair [--app ]` — forget a pairing locally. +- `porthole connectors [--app ]` — table of the app's connectors. +- `porthole actions [] [--app ]` — actions + parameter schemas. +- `porthole sources [] [--app ]` — data sources + filter schemas. +- `porthole call / [--param k=v ...] [--json ''] [--yes] [--out ]` + — invoke an action; destructive actions confirm unless `--yes`; a returned data + value writes to `--out` instead of printing base64. +- `porthole query / [--filter k=v ...] [--limit N] [--cursor C] [--all] [--format table|json]` + — page through a data source. +- `porthole tail / [--filter k=v ...]` — stream a subscribable + source until interrupted. +- `porthole mcp [--app ]` — serve the app as an MCP server over stdio (added + with PortholeMCP). + +`--app` is optional when exactly one app is paired; otherwise pass a bundle id or +an app/device-name substring that uniquely matches. + +`--param`/`--filter` values infer type: `true`/`false` → bool, integers → int, +decimals → double, everything else → string. Use `--json` for full control. + +## Permissions + +The first run prompts for macOS local-network access (Bonjour). Credentials are +shared with the app and MCP via the login keychain (see +[PortholeClientKit](../PortholeClientKit/README.md)). + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PortholeCLICoreTests`). The pure logic — ref/param/JSON parsing, table and JSON +formatting, and `--app` resolution — is covered without a device; the +device-talking commands are thin glue over `PortholeClientKit`. diff --git a/Shared/Porthole/PortholeCLICore/Sources/AppResolution.swift b/Shared/Porthole/PortholeCLICore/Sources/AppResolution.swift new file mode 100644 index 00000000..0788cb39 --- /dev/null +++ b/Shared/Porthole/PortholeCLICore/Sources/AppResolution.swift @@ -0,0 +1,47 @@ +import Foundation +import PortholeClientKit + +/// Resolves the `--app` selector against the set of paired apps. +public enum AppResolution { + public enum Failure: Error, Equatable, CustomStringConvertible { + case nonePaired + case ambiguous([String]) + case notFound(String) + + public var description: String { + switch self { + case .nonePaired: + "No paired apps. Run `porthole pair` first." + case let .ambiguous(matches): + "Ambiguous --app; matches: \(matches.joined(separator: ", ")). Be more specific." + case let .notFound(selector): + "No paired app matches `\(selector)`." + } + } + } + + /// Picks a paired app. With no `selector`, requires exactly one paired app. + /// With a `selector`, matches by bundle id (exact) or app name / device name + /// (case-insensitive substring); the match must be unique. + public static func resolve(selector: String?, from apps: [PairedApp]) throws -> PairedApp { + guard let selector else { + switch apps.count { + case 0: throw Failure.nonePaired + case 1: return apps[0] + default: throw Failure.ambiguous(apps.map(\.appName)) + } + } + + let lowered = selector.lowercased() + let matches = apps.filter { app in + app.bundleID == selector + || app.appName.lowercased().contains(lowered) + || app.deviceName.lowercased().contains(lowered) + } + switch matches.count { + case 0: throw Failure.notFound(selector) + case 1: return matches[0] + default: throw Failure.ambiguous(matches.map(\.appName)) + } + } +} diff --git a/Shared/Porthole/PortholeCLICore/Sources/CLIValueParsing.swift b/Shared/Porthole/PortholeCLICore/Sources/CLIValueParsing.swift new file mode 100644 index 00000000..f627bbbd --- /dev/null +++ b/Shared/Porthole/PortholeCLICore/Sources/CLIValueParsing.swift @@ -0,0 +1,111 @@ +import Foundation +import PortholeCore + +/// A parsed `connector/member` reference from the command line. +public struct ParsedRef: Equatable { + public var connector: String + public var member: String + + public init(connector: String, member: String) { + self.connector = connector + self.member = member + } + + public var actionRef: PortholeActionRef { + PortholeActionRef( + connector: PortholeConnectorID(connector), + action: PortholeActionID(member), + ) + } + + public var dataSourceRef: PortholeDataSourceRef { + PortholeDataSourceRef( + connector: PortholeConnectorID(connector), + source: PortholeDataSourceID(member), + ) + } +} + +/// Errors from parsing command-line values. +public enum CLIParsingError: Error, Equatable, CustomStringConvertible { + case malformedRef(String) + case malformedParameter(String) + case invalidJSON(String) + + public var description: String { + switch self { + case let .malformedRef(value): + "Expected `connector/name`, got `\(value)`." + case let .malformedParameter(value): + "Expected `key=value`, got `\(value)`." + case let .invalidJSON(value): + "Not valid JSON: \(value)" + } + } +} + +public enum CLIValueParsing { + /// Parses `connector/member` (exactly one slash, both sides non-empty). + public static func parseRef(_ string: String) throws -> ParsedRef { + let parts = string.split(separator: "/", omittingEmptySubsequences: false) + guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else { + throw CLIParsingError.malformedRef(string) + } + return ParsedRef(connector: String(parts[0]), member: String(parts[1])) + } + + /// Interprets a bare CLI token: `true`/`false` → bool, an integer → int, a + /// decimal → double, otherwise a string. + public static func parseScalar(_ string: String) -> PortholeValue { + if string == "true" { return .bool(true) } + if string == "false" { return .bool(false) } + if let int = Int64(string) { return .int(int) } + if let double = Double(string), + string.contains(where: { $0 == "." || $0 == "e" || $0 == "E" }) + { + return .double(double) + } + return .string(string) + } + + /// Parses one `key=value` pair; the value uses `parseScalar`. + public static func parseParameter(_ string: String) throws + -> (key: String, value: PortholeValue) + { + guard let separator = string.firstIndex(of: "=") else { + throw CLIParsingError.malformedParameter(string) + } + let key = String(string[string.startIndex ..< separator]) + guard !key.isEmpty else { throw CLIParsingError.malformedParameter(string) } + let rawValue = String(string[string.index(after: separator)...]) + return (key, parseScalar(rawValue)) + } + + /// Decodes a JSON string into a `PortholeValue`. + public static func parseJSON(_ string: String) throws -> PortholeValue { + guard let data = string.data(using: .utf8) + else { throw CLIParsingError.invalidJSON(string) } + do { + return try JSONDecoder().decode(PortholeValue.self, from: data) + } catch { + throw CLIParsingError.invalidJSON(string) + } + } + + /// Builds a parameters object from an optional base `--json` object plus + /// `--param key=value` overrides. + public static func buildParameters(json: String?, params: [String]) throws -> PortholeValue { + var object: [String: PortholeValue] = [:] + if let json { + guard case let .object(base) = try parseJSON(json) else { + throw CLIParsingError.invalidJSON("expected a JSON object") + } + object = base + } + for pair in params { + let (key, value) = try parseParameter(pair) + object[key] = value + } + return .object(object) + } +} diff --git a/Shared/Porthole/PortholeCLICore/Sources/DataCommands.swift b/Shared/Porthole/PortholeCLICore/Sources/DataCommands.swift new file mode 100644 index 00000000..2daf1cec --- /dev/null +++ b/Shared/Porthole/PortholeCLICore/Sources/DataCommands.swift @@ -0,0 +1,158 @@ +import ArgumentParser +import Foundation +import PortholeClientKit +import PortholeCore + +/// `porthole call /` — invoke an action. +struct CallCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "call", + abstract: "Invoke an action.", + ) + + @OptionGroup var appOption: AppOption + + @Argument(help: "The action, as connector/action.") + var ref: String + + @Option(name: .customLong("param"), help: "A parameter, as key=value (repeatable).") + var params: [String] = [] + + @Option(name: .long, help: "The full parameters object as JSON.") + var json: String? + + @Flag(name: .long, help: "Skip the confirmation prompt for destructive actions.") + var yes = false + + @Option( + name: .long, + help: "Write a returned data value to this file instead of printing base64.", + ) + var out: String? + + func run() async throws { + let parsed = try CLIValueParsing.parseRef(ref) + let parameters = try CLIValueParsing.buildParameters(json: json, params: params) + + let result = try await CLIRuntime.withSession(appOption.app) { session -> PortholeValue in + if !yes { + let manifests = try await session.manifest() + if isDestructive(parsed, in: manifests) { + FileHandle.standardError + .write(Data("Action `\(ref)` is destructive. Continue? [y/N] ".utf8)) + let answer = readLine(strippingNewline: true)?.lowercased() + guard answer == "y" || answer == "yes" else { + throw CleanExit.message("Cancelled.") + } + } + } + return try await session.invoke(parsed.actionRef, parameters: parameters) + } + + if let out, let data = extractData(result) { + try data.write(to: URL(fileURLWithPath: out)) + print("Wrote \(data.count) bytes to \(out).") + } else { + print(OutputFormatting.json(result)) + } + } + + private func isDestructive(_ ref: ParsedRef, in manifests: [ConnectorManifest]) -> Bool { + manifests + .first { $0.connector.id.rawValue == ref.connector }? + .actions.first { $0.id.rawValue == ref.member }? + .isDestructive ?? false + } + + private func extractData(_ value: PortholeValue) -> Data? { + if case let .data(data) = value { return data } + if case let .object(object) = value { + for member in object.values { + if case let .data(data) = member { return data } + } + } + return nil + } +} + +/// `porthole query /` — page through a data source. +struct QueryCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "query", + abstract: "Query a data source.", + ) + + @OptionGroup var appOption: AppOption + + @Argument(help: "The source, as connector/source.") + var ref: String + + @Option(name: .customLong("filter"), help: "A filter, as key=value (repeatable).") + var filters: [String] = [] + + @Option(name: .long, help: "Maximum rows per page.") + var limit: Int? + + @Option(name: .long, help: "Opaque cursor from a previous page.") + var cursor: String? + + @Flag(name: .long, help: "Follow nextCursor and return all rows.") + var all = false + + @Option(name: .long, help: "Output format.") + var format: OutputFormat = .table + + enum OutputFormat: String, ExpressibleByArgument { case table, json } + + func run() async throws { + let parsed = try CLIValueParsing.parseRef(ref) + let filterValue = try CLIValueParsing.buildParameters(json: nil, params: filters) + + let rows = try await CLIRuntime.withSession(appOption.app) { session -> [PortholeValue] in + var collected: [PortholeValue] = [] + var nextCursor = cursor + repeat { + let page = try await session.query( + parsed.dataSourceRef, + PortholeQuery(filters: filterValue, limit: limit, cursor: nextCursor), + ) + collected.append(contentsOf: page.rows) + nextCursor = page.nextCursor + } while all && nextCursor != nil + return collected + } + + switch format { + case .table: print(OutputFormatting.table(rows)) + case .json: print(OutputFormatting.json(.array(rows))) + } + } +} + +/// `porthole tail /` — stream a subscribable source. +struct TailCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "tail", + abstract: "Stream a subscribable data source until interrupted.", + ) + + @OptionGroup var appOption: AppOption + + @Argument(help: "The source, as connector/source.") + var ref: String + + @Option(name: .customLong("filter"), help: "A filter, as key=value (repeatable).") + var filters: [String] = [] + + func run() async throws { + let parsed = try CLIValueParsing.parseRef(ref) + _ = try CLIValueParsing.buildParameters(json: nil, params: filters) + + try await CLIRuntime.withSession(appOption.app) { session in + let stream = try await session.subscribe(parsed.dataSourceRef) + for try await value in stream { + print(OutputFormatting.json(value)) + } + } + } +} diff --git a/Shared/Porthole/PortholeCLICore/Sources/DiscoveryCommands.swift b/Shared/Porthole/PortholeCLICore/Sources/DiscoveryCommands.swift new file mode 100644 index 00000000..63e6b4a7 --- /dev/null +++ b/Shared/Porthole/PortholeCLICore/Sources/DiscoveryCommands.swift @@ -0,0 +1,133 @@ +import ArgumentParser +import Foundation +import PortholeClientKit + +/// `porthole devices` — browse Bonjour and list discovered + paired apps. +struct DevicesCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "devices", + abstract: "List discovered and paired apps.", + ) + + @Option(name: .long, help: "How long to browse, in seconds.") + var timeout: Double = 3 + + func run() async throws { + let discovered = await browse(seconds: timeout) + let paired = (try? CLIRuntime.makeClient().pairedApps()) ?? [] + let pairedBundles = Set(paired.map(\.bundleID)) + + if paired.isEmpty, discovered.isEmpty { + print("No apps found. Make sure a device app is advertising on the same network.") + return + } + if !paired.isEmpty { + print("Paired:") + for app in paired { + print(" \(app.appName) — \(app.bundleID) on \(app.deviceName)") + } + } + if !discovered.isEmpty { + print("Discovered:") + for app in discovered { + let mark = pairedBundles.contains(app.bundleID) ? " (paired)" : "" + print(" \(app.appName) — \(app.bundleID) on \(app.deviceName)\(mark)") + } + } + } +} + +/// `porthole pair` — discover a device app and pair with it. +struct PairCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "pair", + abstract: "Pair with a device app.", + ) + + @OptionGroup var appOption: AppOption + + @Option(name: .long, help: "How long to browse for the app, in seconds.") + var timeout: Double = 5 + + func run() async throws { + let discovered = await browse(seconds: timeout) + guard !discovered.isEmpty else { + throw ValidationError( + "No advertising apps found. Is a device app running with Porthole enabled?", + ) + } + let target = try pick(from: discovered, selector: appOption.app) + + let pairingClient = PortholePairingClient() + let paired = try await pairingClient.pair(with: target) { + await promptForCode() + } + print("Paired with \(paired.appName) on \(paired.deviceName).") + } + + private func pick(from apps: [DiscoveredApp], selector: String?) throws -> DiscoveredApp { + if let selector { + let lowered = selector.lowercased() + let matches = apps.filter { + $0.bundleID == selector || $0.appName.lowercased().contains(lowered) || $0 + .deviceName.lowercased().contains(lowered) + } + guard let match = matches.first, matches.count == 1 else { + throw ValidationError("--app did not uniquely match a discovered app.") + } + return match + } + guard apps.count == 1, let only = apps.first else { + let names = apps.map(\.appName).joined(separator: ", ") + throw ValidationError("Multiple apps found (\(names)); pass --app to choose one.") + } + return only + } + + private func promptForCode() async -> String { + FileHandle.standardError.write(Data("Code shown on device: ".utf8)) + return readLine(strippingNewline: true) ?? "" + } +} + +/// `porthole unpair` — forget a paired app locally. +struct UnpairCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "unpair", + abstract: "Forget a paired app.", + ) + + @OptionGroup var appOption: AppOption + + func run() async throws { + let client = CLIRuntime.makeClient() + let paired = try AppResolution.resolve(selector: appOption.app, from: client.pairedApps()) + try client.unpair(paired) + print("Forgot \(paired.appName) on \(paired.deviceName).") + } +} + +/// Browses for `seconds` and returns the last-seen set of discovered apps. +func browse(seconds: Double) async -> [DiscoveredApp] { + let browser = PortholeBrowser() + let collected = LatestApps() + let task = Task { + for await apps in browser.discovered() { + await collected.set(apps) + } + } + try? await Task.sleep(for: .seconds(seconds)) + task.cancel() + return await collected.get() +} + +private actor LatestApps { + private var apps: [DiscoveredApp] = [] + func set(_ newApps: [DiscoveredApp]) { + apps = newApps + } + + func get() -> [DiscoveredApp] { + apps + } +} diff --git a/Shared/Porthole/PortholeCLICore/Sources/IntrospectionCommands.swift b/Shared/Porthole/PortholeCLICore/Sources/IntrospectionCommands.swift new file mode 100644 index 00000000..58bbfc92 --- /dev/null +++ b/Shared/Porthole/PortholeCLICore/Sources/IntrospectionCommands.swift @@ -0,0 +1,79 @@ +import ArgumentParser +import Foundation +import PortholeCore + +/// `porthole connectors` — list the connectors a paired app exposes. +struct ConnectorsCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "connectors", + abstract: "List the app's connectors.", + ) + + @OptionGroup var appOption: AppOption + + func run() async throws { + let manifests = try await CLIRuntime.withSession(appOption.app) { try await $0.manifest() } + let rows = manifests.map { manifest in + PortholeValue.object([ + "id": .string(manifest.connector.id.rawValue), + "title": .string(manifest.connector.title), + "actions": .int(Int64(manifest.actions.count)), + "sources": .int(Int64(manifest.dataSources.count)), + "summary": .string(manifest.connector.summary), + ]) + } + print(OutputFormatting.table(rows)) + } +} + +/// `porthole actions [connector]` — list actions, with parameter schemas. +struct ActionsCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "actions", + abstract: "List actions and their parameters.", + ) + + @OptionGroup var appOption: AppOption + + @Argument(help: "Only actions for this connector id.") + var connector: String? + + func run() async throws { + let manifests = try await CLIRuntime.withSession(appOption.app) { try await $0.manifest() } + for manifest in manifests + where connector == nil || manifest.connector.id.rawValue == connector + { + for action in manifest.actions { + let destructive = action.isDestructive ? " [destructive]" : "" + print("\(manifest.connector.id)/\(action.id)\(destructive) — \(action.summary)") + print(" parameters: \(OutputFormatting.json(action.parameters.jsonSchema()))") + } + } + } +} + +/// `porthole sources [connector]` — list data sources, with row/filter schemas. +struct SourcesCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "sources", + abstract: "List data sources and their filters.", + ) + + @OptionGroup var appOption: AppOption + + @Argument(help: "Only sources for this connector id.") + var connector: String? + + func run() async throws { + let manifests = try await CLIRuntime.withSession(appOption.app) { try await $0.manifest() } + for manifest in manifests + where connector == nil || manifest.connector.id.rawValue == connector + { + for source in manifest.dataSources { + let live = source.supportsSubscription ? " [subscribable]" : "" + print("\(manifest.connector.id)/\(source.id)\(live) — \(source.summary)") + print(" filters: \(OutputFormatting.json(source.filters.jsonSchema()))") + } + } + } +} diff --git a/Shared/Porthole/PortholeCLICore/Sources/MCPCommand.swift b/Shared/Porthole/PortholeCLICore/Sources/MCPCommand.swift new file mode 100644 index 00000000..1afd25e0 --- /dev/null +++ b/Shared/Porthole/PortholeCLICore/Sources/MCPCommand.swift @@ -0,0 +1,22 @@ +import ArgumentParser +import Foundation +import PortholeMCP + +/// `porthole mcp` — serve the paired app as an MCP server over stdio, for an +/// agent (e.g. Cursor) to launch. +@available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) +struct MCPCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "mcp", + abstract: "Serve a paired app as an MCP server over stdio.", + ) + + @OptionGroup var appOption: AppOption + + func run() async throws { + let paired = try CLIRuntime.resolveApp(appOption.app) + let session = try await CLIRuntime.makeClient().connect(to: paired) + let server = PortholeMCPServer(session: session, serverName: "porthole-\(paired.appName)") + try await server.run() + } +} diff --git a/Shared/Porthole/PortholeCLICore/Sources/OutputFormatting.swift b/Shared/Porthole/PortholeCLICore/Sources/OutputFormatting.swift new file mode 100644 index 00000000..2241814d --- /dev/null +++ b/Shared/Porthole/PortholeCLICore/Sources/OutputFormatting.swift @@ -0,0 +1,111 @@ +import Foundation +import PortholeCore + +/// Renders Porthole values for the terminal: pretty JSON, or an aligned table of +/// object rows. Pure functions so they're unit-tested without a device. +public enum OutputFormatting { + /// Pretty-prints a value as JSON (sorted keys). `.data` becomes a + /// `{"$data": …}` marker; `.date` becomes an ISO-8601 string. + public static func json(_ value: PortholeValue) -> String { + let object = foundationObject(value) + guard let data = try? JSONSerialization.data( + withJSONObject: object, + options: [.prettyPrinted, .sortedKeys, .fragmentsAllowed], + ), let string = String(data: data, encoding: .utf8) else { + return String(describing: value) + } + return string + } + + /// Renders `.object` rows as an aligned column table; falls back to JSON for + /// non-object rows. Columns are the union of keys across rows, in first-seen + /// order. + public static func table(_ rows: [PortholeValue]) -> String { + guard !rows.isEmpty else { return "(no rows)" } + var columns: [String] = [] + var seen: Set = [] + for row in rows { + guard case let .object(object) = row + else { return rows.map(json).joined(separator: "\n") } + for key in object.keys.sorted() where !seen.contains(key) { + seen.insert(key) + columns.append(key) + } + } + + let cells: [[String]] = rows.map { row in + guard case let .object(object) = row else { return columns.map { _ in "" } } + return columns.map { cellString(object[$0]) } + } + + var widths = columns.map(\.count) + for row in cells { + for (index, cell) in row.enumerated() { + widths[index] = max(widths[index], cell.count) + } + } + + func pad(_ string: String, _ width: Int) -> String { + string + String(repeating: " ", count: max(0, width - string.count)) + } + + var lines: [String] = [] + lines.append(zip(columns, widths).map { pad($0, $1) }.joined(separator: " ")) + lines.append(widths.map { String(repeating: "-", count: $0) }.joined(separator: " ")) + for row in cells { + lines.append(zip(row, widths).map { pad($0, $1) }.joined(separator: " ")) + } + return lines.joined(separator: "\n") + } + + static func cellString(_ value: PortholeValue?) -> String { + guard let value else { return "" } + switch value { + case .null: return "" + case let .bool(bool): return bool ? "true" : "false" + case let .int(int): return String(int) + case let .double(double): return String(double) + case let .string(string): return string + case let .data(data): return "<\(data.count) bytes>" + case let .date(date): return PortholeISO8601Display.string(from: date) + case .array, .object: return compactJSON(value) + } + } + + private static func compactJSON(_ value: PortholeValue) -> String { + let object = foundationObject(value) + guard let data = try? JSONSerialization.data( + withJSONObject: object, + options: [.fragmentsAllowed], + ), + let string = String(data: data, encoding: .utf8) + else { return String(describing: value) } + return string + } + + static func foundationObject(_ value: PortholeValue) -> Any { + switch value { + case .null: NSNull() + case let .bool(bool): bool + case let .int(int): int + case let .double(double): double + case let .string(string): string + case let .data(data): ["$data": data.base64EncodedString()] + case let .date(date): PortholeISO8601Display.string(from: date) + case let .array(array): array.map { foundationObject($0) } + case let .object(object): object.mapValues { foundationObject($0) } + } + } +} + +enum PortholeISO8601Display { + private nonisolated(unsafe) static let formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + static func string(from date: Date) -> String { + formatter.string(from: date) + } +} diff --git a/Shared/Porthole/PortholeCLICore/Sources/PortholeCommand.swift b/Shared/Porthole/PortholeCLICore/Sources/PortholeCommand.swift new file mode 100644 index 00000000..fc18d6c3 --- /dev/null +++ b/Shared/Porthole/PortholeCLICore/Sources/PortholeCommand.swift @@ -0,0 +1,67 @@ +import ArgumentParser +import Foundation +import PortholeClientKit +import PortholeCore + +/// `porthole` — the command-line surface for talking to paired device apps. +@available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) +public struct PortholeCommand: AsyncParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "porthole", + abstract: "Access data and actions in a running iOS app over the local network.", + subcommands: [ + DevicesCommand.self, + PairCommand.self, + UnpairCommand.self, + ConnectorsCommand.self, + ActionsCommand.self, + SourcesCommand.self, + CallCommand.self, + QueryCommand.self, + TailCommand.self, + MCPCommand.self, + ], + ) + + public init() {} +} + +/// The shared `--app` selector for subcommands that target one paired app. +struct AppOption: ParsableArguments { + @Option( + name: .long, + help: "Which paired app (bundle id, or an app/device name substring). Optional when exactly one app is paired.", + ) + var app: String? +} + +enum CLIRuntime { + static func makeClient() -> PortholeClient { + PortholeClient() + } + + static func resolveApp(_ selector: String?) throws -> PairedApp { + try AppResolution.resolve(selector: selector, from: makeClient().pairedApps()) + } + + /// Resolves the app, opens a session, runs `body`, and always closes. + static func withSession( + _ selector: String?, + _ body: (PortholeSession) async throws -> T, + ) async throws -> T { + let paired = try resolveApp(selector) + let session = try await makeClient().connect(to: paired) + do { + let result = try await body(session) + await session.close() + return result + } catch { + await session.close() + throw error + } + } + + static func printError(_ message: String) { + FileHandle.standardError.write(Data((message + "\n").utf8)) + } +} diff --git a/Shared/Porthole/PortholeCLICore/Tests/AppResolutionTests.swift b/Shared/Porthole/PortholeCLICore/Tests/AppResolutionTests.swift new file mode 100644 index 00000000..d6e7014a --- /dev/null +++ b/Shared/Porthole/PortholeCLICore/Tests/AppResolutionTests.swift @@ -0,0 +1,63 @@ +import Foundation +@testable import PortholeCLICore +import PortholeClientKit +import Testing + +struct AppResolutionTests { + private func app(_ name: String, bundle: String, device: String) -> PairedApp { + PairedApp( + pairingID: UUID(), + appName: name, + bundleID: bundle, + deviceName: device, + pairedAt: Date(), + ) + } + + @Test func noSelectorRequiresExactlyOne() throws { + let only = app("Where", bundle: "com.stuff.where", device: "iPhone") + #expect(try AppResolution.resolve(selector: nil, from: [only]) == only) + + #expect(throws: AppResolution.Failure.self) { + _ = try AppResolution.resolve(selector: nil, from: []) + } + #expect(throws: AppResolution.Failure.self) { + _ = try AppResolution.resolve( + selector: nil, + from: [only, app("Other", bundle: "com.stuff.other", device: "iPad")], + ) + } + } + + @Test func selectorMatchesBundleExactly() throws { + let apps = [ + app("Where", bundle: "com.stuff.where", device: "iPhone"), + app("Other", bundle: "com.stuff.other", device: "iPad"), + ] + let match = try AppResolution.resolve(selector: "com.stuff.where", from: apps) + #expect(match.bundleID == "com.stuff.where") + } + + @Test func selectorMatchesNameSubstringCaseInsensitively() throws { + let apps = [app("Where", bundle: "com.stuff.where", device: "Kevin's iPhone")] + #expect(try AppResolution.resolve(selector: "wher", from: apps).appName == "Where") + #expect(try AppResolution.resolve(selector: "iphone", from: apps).appName == "Where") + } + + @Test func ambiguousSelectorThrows() { + let apps = [ + app("Where", bundle: "com.stuff.where", device: "iPhone"), + app("Where Dev", bundle: "com.stuff.where.dev", device: "iPad"), + ] + #expect(throws: AppResolution.Failure.self) { + _ = try AppResolution.resolve(selector: "where", from: apps) + } + } + + @Test func unknownSelectorThrows() { + let apps = [app("Where", bundle: "com.stuff.where", device: "iPhone")] + #expect(throws: AppResolution.Failure.self) { + _ = try AppResolution.resolve(selector: "nope", from: apps) + } + } +} diff --git a/Shared/Porthole/PortholeCLICore/Tests/CLIValueParsingTests.swift b/Shared/Porthole/PortholeCLICore/Tests/CLIValueParsingTests.swift new file mode 100644 index 00000000..1ceeec5d --- /dev/null +++ b/Shared/Porthole/PortholeCLICore/Tests/CLIValueParsingTests.swift @@ -0,0 +1,65 @@ +import Foundation +@testable import PortholeCLICore +import PortholeCore +import Testing + +struct CLIValueParsingTests { + @Test func parsesConnectorSlashMember() throws { + let ref = try CLIValueParsing.parseRef("where/year-report") + #expect(ref == ParsedRef(connector: "where", member: "year-report")) + #expect(ref.actionRef == PortholeActionRef(connector: "where", action: "year-report")) + #expect(ref.dataSourceRef == PortholeDataSourceRef( + connector: "where", + source: "year-report", + )) + } + + @Test func rejectsMalformedRefs() { + #expect(throws: CLIParsingError.self) { try CLIValueParsing.parseRef("noslash") } + #expect(throws: CLIParsingError.self) { try CLIValueParsing.parseRef("a/b/c") } + #expect(throws: CLIParsingError.self) { try CLIValueParsing.parseRef("/b") } + #expect(throws: CLIParsingError.self) { try CLIValueParsing.parseRef("a/") } + } + + @Test func parsesScalarsWithTypeInference() { + #expect(CLIValueParsing.parseScalar("true") == .bool(true)) + #expect(CLIValueParsing.parseScalar("false") == .bool(false)) + #expect(CLIValueParsing.parseScalar("42") == .int(42)) + #expect(CLIValueParsing.parseScalar("-7") == .int(-7)) + #expect(CLIValueParsing.parseScalar("1.5") == .double(1.5)) + #expect(CLIValueParsing.parseScalar("hello") == .string("hello")) + // A bare integer-looking string stays int; a version-like token is a string. + #expect(CLIValueParsing.parseScalar("1.2.3") == .string("1.2.3")) + } + + @Test func parsesKeyValueParameters() throws { + let (key, value) = try CLIValueParsing.parseParameter("year=2026") + #expect(key == "year") + #expect(value == .int(2026)) + // Values may contain '='. + let (k2, v2) = try CLIValueParsing.parseParameter("note=a=b") + #expect(k2 == "note") + #expect(v2 == .string("a=b")) + } + + @Test func rejectsMalformedParameters() { + #expect(throws: CLIParsingError.self) { try CLIValueParsing.parseParameter("noequals") } + #expect(throws: CLIParsingError.self) { try CLIValueParsing.parseParameter("=value") } + } + + @Test func buildsParametersMergingJSONAndParams() throws { + let value = try CLIValueParsing.buildParameters( + json: #"{"a": 1, "b": "x"}"#, + params: ["b=override", "c=true"], + ) + #expect(value["a"]?.intValue == 1) + #expect(value["b"]?.stringValue == "override") + #expect(value["c"]?.boolValue == true) + } + + @Test func buildParametersRejectsNonObjectJSON() { + #expect(throws: CLIParsingError.self) { + _ = try CLIValueParsing.buildParameters(json: "[1,2,3]", params: []) + } + } +} diff --git a/Shared/Porthole/PortholeCLICore/Tests/OutputFormattingTests.swift b/Shared/Porthole/PortholeCLICore/Tests/OutputFormattingTests.swift new file mode 100644 index 00000000..488b3473 --- /dev/null +++ b/Shared/Porthole/PortholeCLICore/Tests/OutputFormattingTests.swift @@ -0,0 +1,50 @@ +import Foundation +@testable import PortholeCLICore +import PortholeCore +import Testing + +struct OutputFormattingTests { + @Test func jsonRendersObjectWithSortedKeys() throws { + let value: PortholeValue = ["b": 2, "a": 1] + let json = OutputFormatting.json(value) + // Sorted keys put "a" before "b". + let aIndex = try #require(json.range(of: "\"a\"")) + let bIndex = try #require(json.range(of: "\"b\"")) + #expect(aIndex.lowerBound < bIndex.lowerBound) + } + + @Test func jsonRendersScalarFragments() { + #expect(OutputFormatting.json(.int(42)) == "42") + #expect(OutputFormatting.json(.string("hi")) == "\"hi\"") + } + + @Test func jsonRendersDataAsTaggedMarker() { + let value = PortholeValue.data(Data([0x01, 0x02])) + let json = OutputFormatting.json(value) + #expect(json.contains("$data")) + #expect(json.contains(Data([0x01, 0x02]).base64EncodedString())) + } + + @Test func tableAlignsColumnsAcrossRows() { + let rows: [PortholeValue] = [ + ["id": "a", "count": 1], + ["id": "bb", "count": 22], + ] + let table = OutputFormatting.table(rows) + let lines = table.split(separator: "\n").map(String.init) + #expect(lines.count == 4) // header, divider, 2 rows + // The header contains both columns (sorted: count, id). + #expect(lines[0].contains("count")) + #expect(lines[0].contains("id")) + } + + @Test func tableHandlesEmptyRows() { + #expect(OutputFormatting.table([]) == "(no rows)") + } + + @Test func cellStringSummarizesData() { + #expect(OutputFormatting.cellString(.data(Data(count: 5))) == "<5 bytes>") + #expect(OutputFormatting.cellString(.bool(true)) == "true") + #expect(OutputFormatting.cellString(nil) == "") + } +} diff --git a/Shared/Porthole/PortholeClientKit/AGENTS.md b/Shared/Porthole/PortholeClientKit/AGENTS.md new file mode 100644 index 00000000..319652b7 --- /dev/null +++ b/Shared/Porthole/PortholeClientKit/AGENTS.md @@ -0,0 +1,41 @@ +# PortholeClientKit – Module Shape + +PortholeClientKit is the Mac-side Porthole client: `PortholeBrowser` (Bonjour +discovery), `PortholePairingClient` (pairing + credential persistence), +`PortholeClient` (open a session to a paired app), and the `PortholeSession` +actor (request/response + subscription streams). See [`README.md`](README.md). + +This file complements the root [`AGENTS.md`](../../../AGENTS.md) — read it first. + +## Scope & dependencies + +- Depends only on **PortholeCore** (+ `Network`/`Security`/`CryptoKit` from the + SDK). It must **not** depend on PortholeKit (the device runtime) — the two + sides meet only over the wire. Compiles for macOS *and* iOS/Catalyst; the CLI, + MCP server, and app all link it. +- Credentials use the shared login keychain under + `com.stuff.porthole.client` (`PortholeCredentialService.client`), so a pairing + made by one surface is visible to the others. `PairedApp` is the metadata blob; + the PSK sits beside it. + +## Invariants + +- **The client handshake mirrors the device's** (`ClientHandshake`): X25519 + + HKDF over the code for pairing, HKDF over the nonces for the session key. It's + pure of `Network` types so it runs over `LoopbackTransport` in tests. +- **`PortholeSession` matches responses to requests by id** and routes + unsolicited `.event` frames to the subscription stream that requested them; a + `.failure` response throws the carried `PortholeError`. Cancelling a + subscription stream unsubscribes on the device (every start has a stop). +- **Connecting re-derives a fresh session key** and verifies protocol version in + the app-level hello before returning the session. +- **Discovery/connection Network code is thin**; the tested logic is the + handshake + session, exercised end-to-end against the real device stack over + loopback via the `@_spi(Testing)` `connect(over:)` / `pair(over:)` seams. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PortholeClientKitTests`, `extraPackageProducts: [PortholeCore, PortholeKit]`). +The `DeviceHarness` drives the real device side over loopback; tests wait on +delivered events and never sleep. diff --git a/Shared/Porthole/PortholeClientKit/README.md b/Shared/Porthole/PortholeClientKit/README.md new file mode 100644 index 00000000..654f7969 --- /dev/null +++ b/Shared/Porthole/PortholeClientKit/README.md @@ -0,0 +1,50 @@ +# PortholeClientKit + +PortholeClientKit is the Mac-side client of the [Porthole](../) suite: discover +device apps over Bonjour, pair with them, and open authenticated sessions to +invoke actions, query data sources, and tail live streams. It is pure +Foundation + Network + CryptoKit (via PortholeCore), so the same framework backs +the CLI, the MCP server, and the Catalyst app. + +## Using it + +```swift +// Discover +for await apps in PortholeBrowser().discovered() { /* show apps */ } + +// Pair (persists the credential in the shared login keychain) +let paired = try await PortholePairingClient().pair(with: app) { + await promptUserForCode() // read the code shown on the device +} + +// Connect and use +let session = try await PortholeClient().connect(to: paired) +let manifests = try await session.manifest() +let result = try await session.invoke(.init(connector: "app", action: "ping"), + parameters: ["message": "hi"]) +for try await event in try await session.subscribe(.init(connector: "periscope", source: "live-events")) { + print(event) +} +``` + +Credentials live in the login keychain under +`com.stuff.porthole.client`, so pairing once from any surface (CLI, app, MCP) +works for all of them. + +### Keychain prompt caveat + +macOS login-keychain items carry per-application ACLs. The first time a +*different* Mac surface reads a PSK another created, macOS shows a +keychain-access prompt — click **Always Allow**. The `PairedApp` metadata is +plain shared state and needs no prompt. This is acceptable for a developer tool +and isn't worked around in v1. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PortholeClientKitTests`). The marquee suite runs the *real* device +(PortholeKit) and client stacks in one process over a `LoopbackTransport`: it +pairs, opens an encrypted session, and exercises invoke/query/subscribe through +the secure channel, plus wrong-code and revoked-pairing rejection. The +`@_spi(Testing)` `connect(over:)` / `pair(over:)` seams bypass Bonjour so no +networking is involved. diff --git a/Shared/Porthole/PortholeClientKit/Sources/ClientHandshake.swift b/Shared/Porthole/PortholeClientKit/Sources/ClientHandshake.swift new file mode 100644 index 00000000..bcf448cf --- /dev/null +++ b/Shared/Porthole/PortholeClientKit/Sources/ClientHandshake.swift @@ -0,0 +1,114 @@ +import CryptoKit +import Foundation +import PortholeCore + +/// The Mac-client side of the pairing/session handshake — the mirror of the +/// device's `DevicePairingManager`. Pure of `Network` types so it runs over a +/// `LoopbackTransport` in tests. +enum ClientHandshake { + struct PairResult { + var pairingID: UUID + var psk: SymmetricKey + } + + /// Runs the pair flow, obtaining the human code via `code` (read from stdin + /// in the CLI, a text field in the app). + static func pair( + reader: TransportFrameReader, + send: @Sendable (Data) async throws -> Void, + clientName: String, + code: @Sendable () async -> String, + ) async throws -> PairResult { + let clientPrivateKey = Curve25519.KeyAgreement.PrivateKey() + let clientPublicKey = clientPrivateKey.publicKey.rawRepresentation + + try await send(encode(.clientHello(mode: .pair( + clientName: clientName, + clientPublicKey: clientPublicKey, + )))) + guard case let .pairChallenge(devicePublicKey, salt) = try await receive(reader) else { + throw PortholeClientError.unexpectedResponse + } + + let codeValue = await code() + let devicePub = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: devicePublicKey) + let psk = try PairingCryptography.pairingKey( + ownPrivateKey: clientPrivateKey, + peerPublicKey: devicePub, + salt: salt, + code: codeValue, + ) + let mac = PairingCryptography.confirmationCode( + key: psk, + clientPublicKey: clientPublicKey, + devicePublicKey: devicePublicKey, + salt: salt, + ) + try await send(encode(.pairConfirm(mac: mac))) + + switch try await receive(reader) { + case let .pairAccepted(pairingID, acceptMac): + guard PairingCryptography.isValidAcceptance( + acceptMac, + key: psk, + salt: salt, + clientPublicKey: clientPublicKey, + ) else { + throw PortholeClientError.acceptanceProofFailed + } + return PairResult(pairingID: pairingID, psk: psk) + case let .failure(error): + throw error + default: + throw PortholeClientError.unexpectedResponse + } + } + + /// Runs the session flow, returning the per-session key both ends derive. + static func session( + reader: TransportFrameReader, + send: @Sendable (Data) async throws -> Void, + pairingID: UUID, + psk: SymmetricKey, + ) async throws -> SymmetricKey { + let clientNonce = PairingCryptography.randomBytes(count: PairingCryptography.nonceByteCount) + try await send(encode(.clientHello(mode: .session( + pairingID: pairingID, + clientNonce: clientNonce, + )))) + switch try await receive(reader) { + case let .serverHello(serverNonce): + return PairingCryptography.sessionKey( + psk: psk, + clientNonce: clientNonce, + serverNonce: serverNonce, + ) + case let .failure(error): + throw error + default: + throw PortholeClientError.unexpectedResponse + } + } + + private static func encode(_ message: PortholePairingMessage) throws -> Data { + try JSONEncoder().encode(message) + } + + private static func receive(_ reader: TransportFrameReader) async throws + -> PortholePairingMessage + { + guard let frame = try await reader.next() + else { throw PortholeClientError.connectionClosed } + return try JSONDecoder().decode(PortholePairingMessage.self, from: frame) + } +} + +/// Failures surfaced by the Mac client. +public enum PortholeClientError: Error, Sendable, Equatable { + case unexpectedResponse + case acceptanceProofFailed + case connectionClosed + case notPaired + case deviceNotFound + case connectionFailed(String) +} diff --git a/Shared/Porthole/PortholeClientKit/Sources/DiscoveredApp.swift b/Shared/Porthole/PortholeClientKit/Sources/DiscoveredApp.swift new file mode 100644 index 00000000..ae827071 --- /dev/null +++ b/Shared/Porthole/PortholeClientKit/Sources/DiscoveredApp.swift @@ -0,0 +1,73 @@ +import Foundation +import Network + +/// A Porthole-advertising app found on the local network. The `endpoint` is how +/// the client actually connects; the rest is decoded from the Bonjour TXT +/// record for display and matching. +public struct DiscoveredApp: Sendable, Identifiable, Equatable { + public var id: String { + endpointName + } + + public var endpointName: String + public var appName: String + public var bundleID: String + public var deviceName: String + public var protocolVersion: Int + + /// The network endpoint to connect to. Not part of identity/equality. + public var endpoint: NWEndpoint? + + public init( + endpointName: String, + appName: String, + bundleID: String, + deviceName: String, + protocolVersion: Int, + endpoint: NWEndpoint? = nil, + ) { + self.endpointName = endpointName + self.appName = appName + self.bundleID = bundleID + self.deviceName = deviceName + self.protocolVersion = protocolVersion + self.endpoint = endpoint + } + + public static func == (lhs: DiscoveredApp, rhs: DiscoveredApp) -> Bool { + lhs.endpointName == rhs.endpointName + && lhs.appName == rhs.appName + && lhs.bundleID == rhs.bundleID + && lhs.deviceName == rhs.deviceName + && lhs.protocolVersion == rhs.protocolVersion + } +} + +/// A stored pairing: enough to reconnect (by re-discovering the endpoint) and to +/// show in a list. Persisted as the credential store's metadata blob; the PSK +/// lives beside it under the same pairing id. +public struct PairedApp: Sendable, Codable, Equatable, Identifiable { + public var id: UUID { + pairingID + } + + public var pairingID: UUID + public var appName: String + public var bundleID: String + public var deviceName: String + public var pairedAt: Date + + public init( + pairingID: UUID, + appName: String, + bundleID: String, + deviceName: String, + pairedAt: Date, + ) { + self.pairingID = pairingID + self.appName = appName + self.bundleID = bundleID + self.deviceName = deviceName + self.pairedAt = pairedAt + } +} diff --git a/Shared/Porthole/PortholeClientKit/Sources/PortholeBrowser.swift b/Shared/Porthole/PortholeClientKit/Sources/PortholeBrowser.swift new file mode 100644 index 00000000..195032ea --- /dev/null +++ b/Shared/Porthole/PortholeClientKit/Sources/PortholeBrowser.swift @@ -0,0 +1,54 @@ +import Foundation +import Network +import PortholeCore + +/// Discovers Porthole-advertising apps on the local network via Bonjour. Each +/// change re-yields the full current set. +public final class PortholeBrowser: @unchecked Sendable { + private let serviceType: String + + public init(serviceType: String = "_porthole._tcp") { + self.serviceType = serviceType + } + + /// A stream of the current set of discovered apps, updated as they come and + /// go. Cancelling the stream stops browsing. + public func discovered() -> AsyncStream<[DiscoveredApp]> { + AsyncStream { continuation in + let parameters = NWParameters() + parameters.includePeerToPeer = true + let browser = NWBrowser( + for: .bonjourWithTXTRecord(type: serviceType, domain: nil), + using: parameters, + ) + browser.browseResultsChangedHandler = { results, _ in + continuation.yield(results.compactMap { Self.discoveredApp(from: $0) }) + } + browser.stateUpdateHandler = { state in + if case let .failed(error) = state { + PortholeClientLog.discovery + .error("Browser failed: \(String(describing: error), privacy: .public)") + continuation.finish() + } + } + continuation.onTermination = { _ in browser.cancel() } + browser.start(queue: .global(qos: .userInitiated)) + } + } + + private static func discoveredApp(from result: NWBrowser.Result) -> DiscoveredApp? { + guard case let .service(name, _, _, _) = result.endpoint else { return nil } + var txt: [String: String] = [:] + if case let .bonjour(record) = result.metadata { + txt = record.dictionary + } + return DiscoveredApp( + endpointName: name, + appName: txt["name"] ?? name, + bundleID: txt["bundle"] ?? "", + deviceName: txt["device"] ?? "", + protocolVersion: Int(txt["ver"] ?? "") ?? 0, + endpoint: result.endpoint, + ) + } +} diff --git a/Shared/Porthole/PortholeClientKit/Sources/PortholeClient.swift b/Shared/Porthole/PortholeClientKit/Sources/PortholeClient.swift new file mode 100644 index 00000000..07a524f1 --- /dev/null +++ b/Shared/Porthole/PortholeClientKit/Sources/PortholeClient.swift @@ -0,0 +1,109 @@ +import CryptoKit +import Foundation +import Network +import PortholeCore + +/// The entry point for talking to paired device apps from the Mac. Lists stored +/// pairings, and opens an authenticated ``PortholeSession`` to one by +/// re-discovering its endpoint and running the session handshake. +public final class PortholeClient: Sendable { + private let credentials: PortholeCredentialStore + private let clientName: String + private let serviceType: String + + public init( + credentials: PortholeCredentialStore, + clientName: String = defaultPortholeClientName(), + serviceType: String = "_porthole._tcp", + ) { + self.credentials = credentials + self.clientName = clientName + self.serviceType = serviceType + } + + /// Convenience: a client backed by the shared login keychain. + public convenience init(clientName: String = defaultPortholeClientName()) { + self.init( + credentials: KeychainCredentialStore(service: PortholeCredentialService.client), + clientName: clientName, + ) + } + + /// The apps this Mac has paired with. + public func pairedApps() throws -> [PairedApp] { + try credentials.all().compactMap { try? JSONDecoder().decode( + PairedApp.self, + from: $0.metadata, + ) } + } + + /// Forgets a pairing locally (removes the stored credential). + public func unpair(_ paired: PairedApp) throws { + try credentials.delete(pairingID: paired.pairingID) + } + + /// Re-discovers the paired app on the network and opens a session. + public func connect( + to paired: PairedApp, + discoveryTimeout: Duration = .seconds(5), + ) async throws -> PortholeSession { + guard let psk = try credentials.key(for: paired.pairingID) + else { throw PortholeClientError.notPaired } + let endpoint = try await findEndpoint(for: paired, timeout: discoveryTimeout) + let transport = makeConnectionTransport(to: endpoint) + return try await establishSession(over: transport, pairingID: paired.pairingID, psk: psk) + } + + /// Testing/advanced seam: open a session over an arbitrary transport with a + /// known PSK, bypassing discovery. + @_spi(Testing) + public func connect( + over transport: some PortholeTransport, + pairingID: UUID, + psk: SymmetricKey, + ) async throws -> PortholeSession { + try await establishSession(over: transport, pairingID: pairingID, psk: psk) + } + + private func establishSession( + over transport: some PortholeTransport, + pairingID: UUID, + psk: SymmetricKey, + ) async throws -> PortholeSession { + let reader = TransportFrameReader(transport) + let sender: @Sendable (Data) async throws -> Void = { try await transport.send($0) } + let sessionKey = try await ClientHandshake.session( + reader: reader, + send: sender, + pairingID: pairingID, + psk: psk, + ) + let channel = PortholeSecureChannel( + reader: reader, + send: sender, + close: { await transport.close() }, + key: sessionKey, + role: .client, + ) + let session = PortholeSession(transport: channel) + try await session.start(clientName: clientName) + return session + } + + private func findEndpoint(for paired: PairedApp, timeout: Duration) async throws -> NWEndpoint { + let browser = PortholeBrowser(serviceType: serviceType) + return try await withDeadline(timeout) { + for await apps in browser.discovered() { + if let match = apps + .first(where: { + $0.bundleID == paired.bundleID && $0.deviceName == paired.deviceName + }), + let endpoint = match.endpoint + { + return endpoint + } + } + throw PortholeClientError.deviceNotFound + } + } +} diff --git a/Shared/Porthole/PortholeClientKit/Sources/PortholeClientSupport.swift b/Shared/Porthole/PortholeClientKit/Sources/PortholeClientSupport.swift new file mode 100644 index 00000000..39aa9dc0 --- /dev/null +++ b/Shared/Porthole/PortholeClientKit/Sources/PortholeClientSupport.swift @@ -0,0 +1,50 @@ +import Foundation +import Network +import os +import PortholeCore + +/// Client-side logging (independent of Periscope — this is the Mac side). +enum PortholeClientLog { + static let discovery = Logger(subsystem: "com.stuff.porthole.client", category: "discovery") + static let session = Logger(subsystem: "com.stuff.porthole.client", category: "session") +} + +/// Keychain service strings namespacing the two sides in a shared login keychain. +public enum PortholeCredentialService { + public static let client = "com.stuff.porthole.client" + public static let device = "com.stuff.porthole.device" +} + +/// A default human-readable name for this Mac. +public func defaultPortholeClientName() -> String { + ProcessInfo.processInfo.hostName +} + +/// Opens an `NWConnection` to `endpoint` and wraps it in a frame transport. +func makeConnectionTransport(to endpoint: NWEndpoint) -> NWConnectionTransport { + let parameters = NWParameters.tcp + parameters.includePeerToPeer = true + let connection = NWConnection(to: endpoint, using: parameters) + let queue = DispatchQueue(label: "com.stuff.porthole.client.connection") + return NWConnectionTransport(connection: connection, queue: queue) +} + +struct PortholeClientTimeout: Error {} + +/// Runs `operation` with a deadline, throwing `PortholeClientTimeout` if it +/// doesn't finish in time. +func withDeadline( + _ duration: Duration, + _ operation: @escaping @Sendable () async throws -> T, +) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(for: duration) + throw PortholeClientTimeout() + } + let result = try await group.next()! + group.cancelAll() + return result + } +} diff --git a/Shared/Porthole/PortholeClientKit/Sources/PortholePairingClient.swift b/Shared/Porthole/PortholeClientKit/Sources/PortholePairingClient.swift new file mode 100644 index 00000000..e8e5fa47 --- /dev/null +++ b/Shared/Porthole/PortholeClientKit/Sources/PortholePairingClient.swift @@ -0,0 +1,68 @@ +import Foundation +import PortholeCore + +/// Pairs the Mac with a discovered device app: runs the pair handshake and +/// persists the resulting credential so every local surface (CLI, MCP, app) can +/// connect afterward. +public final class PortholePairingClient: Sendable { + private let credentials: PortholeCredentialStore + private let clientName: String + + public init( + credentials: PortholeCredentialStore, + clientName: String = defaultPortholeClientName(), + ) { + self.credentials = credentials + self.clientName = clientName + } + + /// Convenience: a pairing client backed by the shared login keychain. + public convenience init(clientName: String = defaultPortholeClientName()) { + self.init( + credentials: KeychainCredentialStore(service: PortholeCredentialService.client), + clientName: clientName, + ) + } + + /// Connects to `app` and pairs, prompting for the device's code via + /// `codeProvider`. Persists the credential and returns the stored pairing. + public func pair( + with app: DiscoveredApp, + codeProvider: @escaping @Sendable () async -> String, + ) async throws -> PairedApp { + guard let endpoint = app.endpoint else { throw PortholeClientError.deviceNotFound } + let transport = makeConnectionTransport(to: endpoint) + return try await pair(over: transport, app: app, codeProvider: codeProvider) + } + + /// Testing/advanced seam: run pairing over an arbitrary transport. + @_spi(Testing) + public func pair( + over transport: some PortholeTransport, + app: DiscoveredApp, + codeProvider: @escaping @Sendable () async -> String, + ) async throws -> PairedApp { + let reader = TransportFrameReader(transport) + let sender: @Sendable (Data) async throws -> Void = { try await transport.send($0) } + let result = try await ClientHandshake.pair( + reader: reader, + send: sender, + clientName: clientName, + code: codeProvider, + ) + let paired = PairedApp( + pairingID: result.pairingID, + appName: app.appName, + bundleID: app.bundleID, + deviceName: app.deviceName, + pairedAt: Date(), + ) + try credentials.save( + pairingID: result.pairingID, + key: result.psk, + metadata: JSONEncoder().encode(paired), + ) + await transport.close() + return paired + } +} diff --git a/Shared/Porthole/PortholeClientKit/Sources/PortholeSession.swift b/Shared/Porthole/PortholeClientKit/Sources/PortholeSession.swift new file mode 100644 index 00000000..0a2a0804 --- /dev/null +++ b/Shared/Porthole/PortholeClientKit/Sources/PortholeSession.swift @@ -0,0 +1,156 @@ +import Foundation +import PortholeCore + +/// A live, authenticated session with one device app. Send requests +/// (`manifest`, `invoke`, `query`, `subscribe`); a background read loop matches +/// responses to requests by id and routes unsolicited events into the +/// subscription streams it vends. +public actor PortholeSession { + private let transport: any PortholeTransport + private var pending: [UInt64: CheckedContinuation] = [:] + private var subscriptions: [UInt64: AsyncThrowingStream.Continuation] = + [:] + private var nextRequestID: UInt64 = 1 + private var readTask: Task? + private var isClosed = false + + /// The device's hello reply, available after `start()` completes the + /// protocol handshake. + public private(set) var deviceInfo: HelloReply? + + init(transport: some PortholeTransport) { + self.transport = transport + } + + /// Starts the read loop and performs the app-level hello (verifying protocol + /// compatibility). Call once, before any request. + func start(clientName: String) async throws { + readTask = Task { await self.readLoop() } + let response = try await request(.hello(HelloRequest(clientName: clientName))) + guard case let .helloReply(reply) = response else { + throw PortholeClientError.unexpectedResponse + } + guard reply.protocolVersion == portholeProtocolVersion else { + throw PortholeError.protocolMismatch( + theirs: reply.protocolVersion, + ours: portholeProtocolVersion, + ) + } + deviceInfo = reply + } + + public func manifest() async throws -> [ConnectorManifest] { + let response = try await request(.listConnectors) + guard case let .connectors(manifests) = response + else { throw PortholeClientError.unexpectedResponse } + return manifests + } + + public func invoke( + _ ref: PortholeActionRef, + parameters: PortholeValue, + ) async throws -> PortholeValue { + let response = try await request(.invokeAction(ref: ref, parameters: parameters)) + guard case let .actionResult(value) = response + else { throw PortholeClientError.unexpectedResponse } + return value + } + + public func query( + _ ref: PortholeDataSourceRef, + _ query: PortholeQuery, + ) async throws -> PortholePage { + let response = try await request(.query(ref: ref, query: query)) + guard case let .queryResult(page) = response + else { throw PortholeClientError.unexpectedResponse } + return page + } + + /// Subscribes to a data source's live stream. Finishing/cancelling the + /// returned stream unsubscribes on the device. + public func subscribe(_ ref: PortholeDataSourceRef) async throws + -> AsyncThrowingStream + { + let response = try await request(.subscribe(ref: ref)) + guard case let .subscribed(subscriptionID) = response + else { throw PortholeClientError.unexpectedResponse } + let (stream, continuation) = AsyncThrowingStream.makeStream() + subscriptions[subscriptionID] = continuation + continuation.onTermination = { [weak self] _ in + Task { await self?.unsubscribe(subscriptionID) } + } + return stream + } + + private func unsubscribe(_ subscriptionID: UInt64) async { + guard subscriptions.removeValue(forKey: subscriptionID) != nil, !isClosed else { return } + _ = try? await request(.unsubscribe(subscriptionID: subscriptionID)) + } + + public func close() async { + isClosed = true + readTask?.cancel() + await transport.close() + for (_, continuation) in subscriptions { + continuation.finish() + } + subscriptions.removeAll() + for (_, continuation) in pending { + continuation.resume(throwing: PortholeClientError.connectionClosed) + } + pending.removeAll() + } + + private func request(_ request: PortholeRequest) async throws -> PortholeResponse { + let id = nextRequestID + nextRequestID += 1 + let response: PortholeResponse = try await withCheckedThrowingContinuation { continuation in + pending[id] = continuation + Task { + do { + let frame = try JSONEncoder().encode(PortholeRequestEnvelope( + id: id, + request: request, + )) + try await transport.send(frame) + } catch { + resumePending(id, throwing: error) + } + } + } + if case let .failure(error) = response { throw error } + return response + } + + private func resumePending(_ id: UInt64, throwing error: Error) { + pending.removeValue(forKey: id)?.resume(throwing: error) + } + + private func readLoop() async { + do { + for try await frame in transport.incoming { + let envelope = try JSONDecoder().decode(PortholeResponseEnvelope.self, from: frame) + if let id = envelope.requestID { + pending.removeValue(forKey: id)?.resume(returning: envelope.response) + } else if case let .event(subscriptionID, value) = envelope.response { + subscriptions[subscriptionID]?.yield(value) + } + } + } catch { + failAll(with: error) + return + } + failAll(with: PortholeClientError.connectionClosed) + } + + private func failAll(with error: Error) { + for (_, continuation) in pending { + continuation.resume(throwing: error) + } + pending.removeAll() + for (_, continuation) in subscriptions { + continuation.finish(throwing: error) + } + subscriptions.removeAll() + } +} diff --git a/Shared/Porthole/PortholeClientKit/Tests/EndToEndTestSupport.swift b/Shared/Porthole/PortholeClientKit/Tests/EndToEndTestSupport.swift new file mode 100644 index 00000000..2849fef7 --- /dev/null +++ b/Shared/Porthole/PortholeClientKit/Tests/EndToEndTestSupport.swift @@ -0,0 +1,156 @@ +import CryptoKit +import Foundation +@_spi(Testing) import PortholeCore +@testable import PortholeKit + +/// A fixture connector on the device side for end-to-end tests: an `echo` action +/// and a subscribable `ticks` source. +final class E2EConnector: PortholeConnector { + let descriptor = PortholeConnectorDescriptor( + id: "e2e", + title: "E2E", + summary: "Fixture.", + version: 1, + ) + + func actions() -> [PortholeAction] { + [ + PortholeAction( + descriptor: PortholeActionDescriptor( + id: "echo", + title: "Echo", + summary: "Echoes a value.", + parameters: .object(["value": .integer()], required: ["value"]), + isDestructive: false, + ), + handler: { .object(["echoed": $0["value"] ?? .null]) }, + ), + ] + } + + func dataSources() -> [PortholeDataSource] { + [ + PortholeDataSource( + descriptor: PortholeDataSourceDescriptor( + id: "ticks", + title: "Ticks", + summary: "Counter.", + rowSchema: .object(["tick": .integer()]), + filters: .object([:]), + supportsSubscription: true, + ), + fetch: { _ in PortholePage(rows: []) }, + subscribe: { + AsyncStream { continuation in + let task = Task { + var tick = 0 + while !Task.isCancelled { + continuation.yield(.object(["tick": .int(Int64(tick))])) + tick += 1 + try? await Task.sleep(for: .milliseconds(2)) + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + }, + ), + ] + } +} + +/// Collects the device's pending pairing code so the client can read it. +actor CodeCollector { + private var code: String? + private var waiters: [CheckedContinuation] = [] + + func set(_ newCode: String?) { + guard let newCode else { return } + code = newCode + for waiter in waiters { + waiter.resume(returning: newCode) + } + waiters.removeAll() + } + + func waitForCode() async -> String { + if let code { return code } + return await withCheckedContinuation { waiters.append($0) } + } +} + +/// Drives the device side of a full session (handshake → secure channel → +/// router) over a transport, using the real PortholeKit types. The mirror of +/// `PortholeServer.accept` minus `NWConnection`. +@MainActor +final class DeviceHarness { + let porthole: Porthole + let manager: DevicePairingManager + let store = InMemoryCredentialStore() + let codeCollector = CodeCollector() + + init() { + // The porthole here is only a source of resolved connectors + hello; + // its own (unused) credential store never opens because start() isn't called. + porthole = Porthole(configuration: PortholeConfiguration( + appName: "E2E", + bundleID: "com.stuff.e2e", + )) + porthole.register(E2EConnector()) + let collector = codeCollector + manager = DevicePairingManager( + credentials: store, + onCodeChange: { await collector.set($0) }, + ) + } + + /// Revokes a pairing on the device side. + func revoke(_ pairingID: UUID) async throws { + try await manager.revoke(pairingID) + } + + /// Serves one connection to completion: runs the handshake and, on a session, + /// the router over a device-role secure channel. + func serve(transport: some PortholeTransport) -> Task { + let connectors = porthole.resolvedConnectors() + let hello = porthole.helloReply + let manager = manager + return Task { + let reader = TransportFrameReader(transport) + let sender: @Sendable (Data) async throws -> Void = { try await transport.send($0) } + let result = await manager.handshake(reader: reader, send: sender) + guard case let .session(key, _) = result else { return } + let channel = PortholeSecureChannel( + reader: reader, + send: sender, + close: { await transport.close() }, + key: key, + role: .device, + ) + let router = PortholeSessionRouter( + transport: channel, + connectors: connectors, + hello: hello, + ) + await router.run() + } + } +} + +struct E2ETimeout: Error {} + +func withE2ETimeout( + _ duration: Duration = .seconds(3), + _ operation: @escaping @Sendable () async throws -> T, +) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(for: duration) + throw E2ETimeout() + } + let result = try await group.next()! + group.cancelAll() + return result + } +} diff --git a/Shared/Porthole/PortholeClientKit/Tests/PortholeEndToEndTests.swift b/Shared/Porthole/PortholeClientKit/Tests/PortholeEndToEndTests.swift new file mode 100644 index 00000000..beb66e97 --- /dev/null +++ b/Shared/Porthole/PortholeClientKit/Tests/PortholeEndToEndTests.swift @@ -0,0 +1,189 @@ +import CryptoKit +import Foundation +@_spi(Testing) import PortholeClientKit +@_spi(Testing) import PortholeCore +import Testing + +/// End-to-end tests running the *real* device (PortholeKit) and client +/// (PortholeClientKit) stacks in one process over a `LoopbackTransport`: pairing, +/// the encrypted session handshake, and request/response/streaming through the +/// secure channel. +@MainActor +struct PortholeEndToEndTests { + private nonisolated func discoveredApp() -> DiscoveredApp { + DiscoveredApp( + endpointName: "E2E", + appName: "E2E", + bundleID: "com.stuff.e2e", + deviceName: "TestDevice", + protocolVersion: portholeProtocolVersion, + ) + } + + /// Pairs over one loopback pair; returns the harness, client store, and the + /// resulting PairedApp so a session test can follow. + private func pair( + harness: DeviceHarness, + clientStore: InMemoryCredentialStore, + ) async throws -> PairedApp { + let (device, client) = LoopbackTransport.makePair() + let deviceTask = harness.serve(transport: device) + let pairingClient = PortholePairingClient(credentials: clientStore, clientName: "TestMac") + let collector = harness.codeCollector + let paired = try await withE2ETimeout { + try await pairingClient.pair( + over: client, + app: discoveredApp(), + codeProvider: { await collector.waitForCode() }, + ) + } + _ = await deviceTask.value + return paired + } + + @Test func pairsAndOpensAnAuthenticatedSession() async throws { + let harness = DeviceHarness() + let clientStore = InMemoryCredentialStore() + let paired = try await pair(harness: harness, clientStore: clientStore) + + // The client persisted the pairing. + #expect(paired.bundleID == "com.stuff.e2e") + #expect(try clientStore.key(for: paired.pairingID) != nil) + + // Open a session over a fresh connection. + let (device, client) = LoopbackTransport.makePair() + let deviceTask = harness.serve(transport: device) + let session = try await withE2ETimeout { + let psk = try clientStore.key(for: paired.pairingID)! + return try await PortholeClient(credentials: clientStore, clientName: "TestMac") + .connect(over: client, pairingID: paired.pairingID, psk: psk) + } + + let deviceInfo = await session.deviceInfo + #expect(deviceInfo?.appName == "E2E") + + let manifests = try await withE2ETimeout { try await session.manifest() } + let ids = Set(manifests.map(\.connector.id)) + #expect(ids.contains("app")) + #expect(ids.contains("e2e")) + + await session.close() + deviceTask.cancel() + } + + @Test func invokeAndQueryAcrossTheSecureChannel() async throws { + let harness = DeviceHarness() + let clientStore = InMemoryCredentialStore() + let paired = try await pair(harness: harness, clientStore: clientStore) + + let (device, client) = LoopbackTransport.makePair() + let deviceTask = harness.serve(transport: device) + let storedKey = try clientStore.key(for: paired.pairingID) + let psk = try #require(storedKey) + let session = try await withE2ETimeout { + try await PortholeClient(credentials: clientStore).connect( + over: client, + pairingID: paired.pairingID, + psk: psk, + ) + } + + let echoed = try await withE2ETimeout { + try await session.invoke( + .init(connector: "e2e", action: "echo"), + parameters: ["value": 21], + ) + } + #expect(echoed["echoed"]?.intValue == 21) + + let page = try await withE2ETimeout { + try await session.query(.init(connector: "app", source: "app-info"), PortholeQuery()) + } + #expect(page.rows.first?["bundleID"]?.stringValue == "com.stuff.e2e") + + await session.close() + deviceTask.cancel() + } + + @Test func subscribeStreamsEventsAcrossTheSecureChannel() async throws { + let harness = DeviceHarness() + let clientStore = InMemoryCredentialStore() + let paired = try await pair(harness: harness, clientStore: clientStore) + + let (device, client) = LoopbackTransport.makePair() + let deviceTask = harness.serve(transport: device) + let storedKey = try clientStore.key(for: paired.pairingID) + let psk = try #require(storedKey) + let session = try await withE2ETimeout { + try await PortholeClient(credentials: clientStore).connect( + over: client, + pairingID: paired.pairingID, + psk: psk, + ) + } + + let ticks = try await withE2ETimeout { + try await session.subscribe(.init(connector: "e2e", source: "ticks")) + } + let firstThree = try await withE2ETimeout { () -> [Int64] in + var collected: [Int64] = [] + for try await value in ticks { + if let tick = value["tick"]?.intValue { collected.append(tick) } + if collected.count == 3 { break } + } + return collected + } + #expect(firstThree == [0, 1, 2]) + + await session.close() + deviceTask.cancel() + } + + @Test func wrongCodeFailsPairing() async throws { + let harness = DeviceHarness() + let clientStore = InMemoryCredentialStore() + let (device, client) = LoopbackTransport.makePair() + let deviceTask = harness.serve(transport: device) + let pairingClient = PortholePairingClient(credentials: clientStore, clientName: "TestMac") + let collector = harness.codeCollector + + await #expect(throws: PortholeError.self) { + try await withE2ETimeout { + try await pairingClient.pair( + over: client, + app: self.discoveredApp(), + codeProvider: { + let real = await collector.waitForCode() + return (Int(real) ?? 0) == 0 ? "111111" : "000000" + }, + ) + } + } + _ = await deviceTask.value + #expect(try clientStore.all().isEmpty) + } + + @Test func revokedPairingCannotOpenASession() async throws { + let harness = DeviceHarness() + let clientStore = InMemoryCredentialStore() + let paired = try await pair(harness: harness, clientStore: clientStore) + + // Revoke on the device side. + try await harness.revoke(paired.pairingID) + + let (device, client) = LoopbackTransport.makePair() + let deviceTask = harness.serve(transport: device) + let storedKey = try clientStore.key(for: paired.pairingID) + let psk = try #require(storedKey) + await #expect(throws: PortholeError.self) { + try await withE2ETimeout { + _ = try await PortholeClient(credentials: clientStore).connect( + over: client, + pairingID: paired.pairingID, + psk: psk, + ) + } + } + deviceTask.cancel() + } +} diff --git a/Shared/Porthole/PortholeCore/AGENTS.md b/Shared/Porthole/PortholeCore/AGENTS.md new file mode 100644 index 00000000..14bd87f4 --- /dev/null +++ b/Shared/Porthole/PortholeCore/AGENTS.md @@ -0,0 +1,47 @@ +# PortholeCore – Module Shape + +PortholeCore is the shared foundation of the Porthole suite: the wire +values/schema, the request/response protocol, framing, the pairing/session +cryptography, and the credential store — everything both the device runtime and +the Mac surfaces must agree on. See [`README.md`](README.md) for the full type +tour and the pairing handshake spec. + +This file complements the root [`AGENTS.md`](../../../AGENTS.md), which owns the +build system, formatting, and global conventions. Read that first. + +## Scope & dependencies + +- **Foundation + CryptoKit + Security only.** No UI, no `Network`, no logging + stack. It must compile for **iOS and macOS** (every Mac surface links it), so + never reach for a UIKit/iOS-only API here — platform-specific code lives in + `PortholeKit` (device) or `PortholeClientKit` (Mac). +- Nothing in the suite may duplicate these types; connectors and both runtimes + depend on this module for them. + +## Invariants + +- **`PortholeValue` is the only thing that crosses the wire.** Its `.data` / + `.date` tagged encodings (`{"$data":…}` / `{"$date":…}`) are the reason binary + and timestamps survive a JSON round-trip — keep the hand-written `Codable` + (documented on the conformance; it is exception (a), the single-value wire + shape). Everything else prefers synthesized `Codable`. +- **The device validates parameters against `PortholeSchema` before invoking a + handler.** `jsonSchema()` and `validate(_:)` derive from the *same* schema, so + the MCP tool contract and the runtime check can't drift. +- **Framing caps every frame at 32 MiB** (`PortholeFraming`); a larger declared + length throws rather than allocating. `PortholeFramer` tolerates arbitrary + chunking — one per connection/direction, not thread-safe. +- **Security is application-layer, deterministic, and loopback-testable.** + Pairing derives the PSK from X25519 + HKDF over the 6-digit code; + `PortholeSecureChannel` seals frames with ChaCha20-Poly1305 under a + counter-derived, never-transmitted nonce, so replay/reorder/tamper all fail to + open. The 6-digit code's MITM limitation is documented in the README — don't + quietly "fix" it with a weaker scheme. +- **Credentials never fail silently.** `PortholeCredentialStore` throws on + keychain errors (a swallowed failure would read as "not paired"). + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PortholeCoreTests`). Prefer exercising the real stack over `LoopbackTransport` +to network mocks; pairing/session tests inject a seeded RNG and never sleep. diff --git a/Shared/Porthole/PortholeCore/README.md b/Shared/Porthole/PortholeCore/README.md new file mode 100644 index 00000000..edffb2f7 --- /dev/null +++ b/Shared/Porthole/PortholeCore/README.md @@ -0,0 +1,77 @@ +# PortholeCore + +PortholeCore is the shared foundation of the [Porthole](../) suite — the types +and protocol that both ends of the bridge speak. It has no UI and no +networking; it is pure Foundation + CryptoKit and compiles for iOS and macOS, +so the device runtime (`PortholeKit`) and every Mac surface (`PortholeClientKit`, +the CLI, the MCP server, the app) build against exactly the same wire contract. + +## What's in here + +- **`PortholeValue`** — the single data currency. A JSON-shaped tree with two + extensions over plain JSON, `.data` and `.date`, that round-trip binary and + timestamps unambiguously (tagged as `{"$data": …}` / `{"$date": …}`). +- **`PortholeSchema`** — a small JSON-Schema subset describing action + parameters and data-source rows/filters. It renders to a JSON-Schema object + (`jsonSchema()`, feeds an MCP tool's `inputSchema`) and validates values at + runtime (`validate(_:)`, applied on the device before a handler runs). +- **Identifiers & descriptors** — typed `PortholeConnectorID` / + `PortholeActionID` / `PortholeDataSourceID`, their refs, and the + `ConnectorManifest` that advertises a connector's surface. +- **Query & page** — `PortholeQuery` (filters + limit + opaque cursor) and + `PortholePage` (rows + `nextCursor`). +- **Wire protocol** — `PortholeRequest` / `PortholeResponse` and their + envelopes, `PortholeError`, and `portholeProtocolVersion`. +- **Framing & transport** — `PortholeFraming` / `PortholeFramer` (4-byte + big-endian length prefix, 32 MiB cap), the `PortholeTransport` protocol, and + the in-memory `LoopbackTransport` test seam. +- **Pairing & session security** — `PortholePairingMessage`, + `PairingCryptography` (X25519 + HKDF-SHA256 + HMAC), and + `PortholeSecureChannel` (ChaCha20-Poly1305 record layer). +- **Credentials** — `PortholeCredentialStore` with a Keychain-backed + implementation (`KeychainCredentialStore`) shared by both sides under + different service strings. + +## The pairing & session handshake + +All messages are length-prefixed frames. Pairing frames are **plaintext** +(`PortholePairingMessage`); once a session key exists, every frame is sealed by +`PortholeSecureChannel`. The client is the Mac, the device is the iOS app. + +**Pairing** (establishes a long-term PSK): + +1. Client → `clientHello(.pair(clientName, clientPublicKey))`. +2. Device shows a fresh 6-digit code, and replies + `pairChallenge(devicePublicKey, salt)`. +3. Both compute `psk = HKDF-SHA256(X25519(shared), salt, info: + "porthole-pair-v1|" + code)`. +4. Client → `pairConfirm(mac = HMAC(psk, clientPub ‖ devicePub ‖ salt))`. + Device verifies; wrong code fails, and after 3 attempts the code is burned. +5. Device mints a `pairingID`, stores `(pairingID, psk)`, and replies + `pairAccepted(pairingID, mac = HMAC(psk, salt ‖ clientPub))` (mutual proof). + Both persist the credential; the connection then closes. + +**Session** (per connection, re-derives a fresh record key): + +1. Client → `clientHello(.session(pairingID, clientNonce))`. +2. Device looks up the PSK (unknown → `notPaired`) and replies + `serverHello(serverNonce)`. +3. Both derive `sessionKey = HKDF-SHA256(psk, salt: clientNonce ‖ serverNonce, + info: "porthole-session-v1")`. Every later frame is + `ChaChaPoly.seal(…, nonce: directionTag ‖ counter)` — the nonce is derived, + never sent, so any reorder/replay/tamper fails to open and closes the + connection. + +### Known limitation + +The 6-digit code is a usability/security trade-off for a **LAN developer tool**: +it does not resist an active man-in-the-middle who can grind the code offline +during steps 3–4. A SPAKE2 upgrade is on the [roadmap](../TODOs.md). Do not use +Porthole to expose sensitive production data over untrusted networks. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PortholeCoreTests`). The whole stack — framing, the pairing crypto, and the +secure channel — is exercised in-process over `LoopbackTransport`, so there is +no networking in the test path. diff --git a/Shared/Porthole/PortholeCore/Sources/LoopbackTransport.swift b/Shared/Porthole/PortholeCore/Sources/LoopbackTransport.swift new file mode 100644 index 00000000..cebb573e --- /dev/null +++ b/Shared/Porthole/PortholeCore/Sources/LoopbackTransport.swift @@ -0,0 +1,48 @@ +import Foundation + +/// An in-memory ``PortholeTransport`` pair whose two endpoints deliver each +/// other's frames directly — no sockets, no framing, fully deterministic. This +/// is the seam that lets the entire client/device stack (secure channel, +/// pairing, session routing) run end-to-end inside one process in tests. +public final class LoopbackTransport: PortholeTransport, @unchecked Sendable { + public let incoming: AsyncThrowingStream + private let peerContinuation: AsyncThrowingStream.Continuation + private let lock = NSLock() + private var isClosed = false + + private init( + incoming: AsyncThrowingStream, + peerContinuation: AsyncThrowingStream.Continuation, + ) { + self.incoming = incoming + self.peerContinuation = peerContinuation + } + + public func send(_ frame: Data) async throws { + let closed = lock.withLock { isClosed } + guard !closed else { throw PortholeTransportError.closed } + peerContinuation.yield(frame) + } + + public func close() async { + let shouldFinish = lock.withLock { () -> Bool in + guard !isClosed else { return false } + isClosed = true + return true + } + // End the peer's incoming stream; our own end stops when we stop reading. + if shouldFinish { peerContinuation.finish() } + } + + /// Creates two connected endpoints: a frame sent on one arrives on the + /// other's `incoming`. + public static func makePair() -> (PortholeTransport, PortholeTransport) { + var continuationA: AsyncThrowingStream.Continuation! + let streamA = AsyncThrowingStream { continuationA = $0 } + var continuationB: AsyncThrowingStream.Continuation! + let streamB = AsyncThrowingStream { continuationB = $0 } + let a = LoopbackTransport(incoming: streamA, peerContinuation: continuationB) + let b = LoopbackTransport(incoming: streamB, peerContinuation: continuationA) + return (a, b) + } +} diff --git a/Shared/Porthole/PortholeCore/Sources/NWConnectionTransport.swift b/Shared/Porthole/PortholeCore/Sources/NWConnectionTransport.swift new file mode 100644 index 00000000..ccca2819 --- /dev/null +++ b/Shared/Porthole/PortholeCore/Sources/NWConnectionTransport.swift @@ -0,0 +1,87 @@ +import Foundation +import Network + +/// A ``PortholeTransport`` over an `NWConnection`. It applies the length-prefix +/// framing on the way out and reassembles whole frames on the way in, so the +/// layers above it (handshake, secure channel, session, router) speak only in +/// whole frames. Shared by the device (`PortholeServer`) and the Mac client. +/// +/// Deliberately thin: all protocol logic lives above it and is tested over +/// `LoopbackTransport`, so this adapter is exercised only in real end-to-end use. +public final class NWConnectionTransport: PortholeTransport, @unchecked Sendable { + public let incoming: AsyncThrowingStream + + private let connection: NWConnection + private let continuation: AsyncThrowingStream.Continuation + private let framerLock = NSLock() + private var framer = PortholeFramer() + + public init(connection: NWConnection, queue: DispatchQueue) { + self.connection = connection + (incoming, continuation) = AsyncThrowingStream.makeStream() + + connection.stateUpdateHandler = { [weak self] state in + switch state { + case let .failed(error): + self?.continuation.finish(throwing: error) + case .cancelled: + self?.continuation.finish() + default: + break + } + } + connection.start(queue: queue) + receiveNext() + } + + private func receiveNext() { + connection + .receive( + minimumIncompleteLength: 1, + maximumLength: 65536, + ) { [weak self] data, _, isComplete, error in + guard let self else { return } + if let data, !data.isEmpty { + do { + let frames = try framerLock.withLock { try framer.ingest(data) } + for frame in frames { + continuation.yield(frame) + } + } catch { + continuation.finish(throwing: error) + connection.cancel() + return + } + } + if let error { + continuation.finish(throwing: error) + return + } + if isComplete { + continuation.finish() + return + } + receiveNext() + } + } + + public func send(_ frame: Data) async throws { + let framed = try PortholeFraming.encode(frame) + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + Void, + Error + >) in + connection.send(content: framed, completion: .contentProcessed { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + }) + } + } + + public func close() async { + connection.cancel() + } +} diff --git a/Shared/Porthole/PortholeCore/Sources/PairingProtocol.swift b/Shared/Porthole/PortholeCore/Sources/PairingProtocol.swift new file mode 100644 index 00000000..1f0df281 --- /dev/null +++ b/Shared/Porthole/PortholeCore/Sources/PairingProtocol.swift @@ -0,0 +1,150 @@ +import CryptoKit +import Foundation + +/// The plaintext handshake messages exchanged *before* a secure channel exists. +/// They ride the same length-prefixed framing as everything else but are never +/// encrypted — they establish (pair mode) or re-derive (session mode) the key +/// the ``PortholeSecureChannel`` then uses. See the pairing spec in +/// `PortholeCore`'s README. +public enum PortholePairingMessage: Codable, Sendable, Equatable { + // client → device + case clientHello(mode: ClientHelloMode) + case pairConfirm(mac: Data) + // device → client + case pairChallenge(devicePublicKey: Data, salt: Data) + case pairAccepted(pairingID: UUID, mac: Data) + case serverHello(serverNonce: Data) + case failure(PortholeError) +} + +/// The first frame on any connection: either begin a fresh pairing or resume a +/// session against an existing pairing. +public enum ClientHelloMode: Codable, Sendable, Equatable { + case pair(clientName: String, clientPublicKey: Data) + case session(pairingID: UUID, clientNonce: Data) +} + +/// Pure, deterministic cryptographic building blocks for pairing and session +/// key derivation — no I/O, no shared state, so the whole handshake is testable +/// in-process. All key agreement is X25519; all derivation is HKDF-SHA256; the +/// session record cipher is ChaCha20-Poly1305 (see ``PortholeSecureChannel``). +public enum PairingCryptography { + /// Length in bytes of the salt and per-side session nonces. + public static let saltByteCount = 16 + public static let nonceByteCount = 16 + + /// Derives the long-term pre-shared key from the X25519 agreement, the + /// device's random salt, and the human-entered 6-digit code. Both ends + /// compute the same key iff they agree on the code. + public static func pairingKey( + ownPrivateKey: Curve25519.KeyAgreement.PrivateKey, + peerPublicKey: Curve25519.KeyAgreement.PublicKey, + salt: Data, + code: String, + ) throws -> SymmetricKey { + let shared = try ownPrivateKey.sharedSecretFromKeyAgreement(with: peerPublicKey) + let info = Data("porthole-pair-v1|\(code)".utf8) + return shared.hkdfDerivedSymmetricKey( + using: SHA256.self, + salt: salt, + sharedInfo: info, + outputByteCount: 32, + ) + } + + /// The client's proof it derived the same key: HMAC over + /// `clientPublicKey || devicePublicKey || salt`. + public static func confirmationCode( + key: SymmetricKey, + clientPublicKey: Data, + devicePublicKey: Data, + salt: Data, + ) -> Data { + var message = Data() + message.append(clientPublicKey) + message.append(devicePublicKey) + message.append(salt) + return Data(HMAC.authenticationCode(for: message, using: key)) + } + + public static func isValidConfirmation( + _ mac: Data, + key: SymmetricKey, + clientPublicKey: Data, + devicePublicKey: Data, + salt: Data, + ) -> Bool { + var message = Data() + message.append(clientPublicKey) + message.append(devicePublicKey) + message.append(salt) + return HMAC.isValidAuthenticationCode(mac, authenticating: message, using: key) + } + + /// The device's mutual proof, returned on acceptance: HMAC over + /// `salt || clientPublicKey`. + public static func acceptanceCode( + key: SymmetricKey, + salt: Data, + clientPublicKey: Data, + ) -> Data { + var message = Data() + message.append(salt) + message.append(clientPublicKey) + return Data(HMAC.authenticationCode(for: message, using: key)) + } + + public static func isValidAcceptance( + _ mac: Data, + key: SymmetricKey, + salt: Data, + clientPublicKey: Data, + ) -> Bool { + var message = Data() + message.append(salt) + message.append(clientPublicKey) + return HMAC.isValidAuthenticationCode(mac, authenticating: message, using: key) + } + + /// Derives the per-session record-cipher key from the pairing PSK and both + /// sides' fresh nonces, so a stolen session key never exposes the PSK and + /// each session is independently keyed. + public static func sessionKey( + psk: SymmetricKey, + clientNonce: Data, + serverNonce: Data, + ) -> SymmetricKey { + var salt = Data() + salt.append(clientNonce) + salt.append(serverNonce) + return HKDF.deriveKey( + inputKeyMaterial: psk, + salt: salt, + info: Data("porthole-session-v1".utf8), + outputByteCount: 32, + ) + } + + /// A fresh zero-padded 6-digit pairing code. + public static func makePairingCode() -> String { + var generator = SystemRandomNumberGenerator() + return makePairingCode(using: &generator) + } + + /// Seedable variant for deterministic tests. + public static func makePairingCode(using generator: inout some RandomNumberGenerator) + -> String + { + String(format: "%06d", Int.random(in: 0 ... 999_999, using: &generator)) + } + + /// Cryptographically-random bytes (salt, nonces). + public static func randomBytes(count: Int) -> Data { + var bytes = [UInt8](repeating: 0, count: count) + var generator = SystemRandomNumberGenerator() + for index in bytes.indices { + bytes[index] = generator.next() + } + return Data(bytes) + } +} diff --git a/Shared/Porthole/PortholeCore/Sources/PortholeCredentialStore.swift b/Shared/Porthole/PortholeCore/Sources/PortholeCredentialStore.swift new file mode 100644 index 00000000..fd1dd19d --- /dev/null +++ b/Shared/Porthole/PortholeCore/Sources/PortholeCredentialStore.swift @@ -0,0 +1,187 @@ +import CryptoKit +import Foundation +import Security + +/// One stored pairing without its secret: the pairing id and an opaque metadata +/// blob (each side encodes its own record type into it — `PairedHost` on the +/// device, `PairedApp` on the client). +public struct PortholeCredentialRecord: Sendable, Equatable { + public var pairingID: UUID + public var metadata: Data + + public init(pairingID: UUID, metadata: Data) { + self.pairingID = pairingID + self.metadata = metadata + } +} + +/// Persists pairing secrets (the per-pairing PSK) and a small metadata blob, +/// keyed by pairing id. Both the device and the Mac client store to it; the +/// production implementation is Keychain-backed, tests inject an in-memory +/// double. Failures throw — a credential that silently fails to load would read +/// as "not paired", so callers must see the difference. +public protocol PortholeCredentialStore: Sendable { + func save(pairingID: UUID, key: SymmetricKey, metadata: Data) throws + func key(for pairingID: UUID) throws -> SymmetricKey? + func metadata(for pairingID: UUID) throws -> Data? + func all() throws -> [PortholeCredentialRecord] + func delete(pairingID: UUID) throws +} + +/// A failure talking to the system keychain, carrying the raw `OSStatus` so the +/// cause is never swallowed. +public struct PortholeKeychainError: Error, Sendable, Equatable { + public var status: OSStatus + public var operation: String + + public init(status: OSStatus, operation: String) { + self.status = status + self.operation = operation + } +} + +/// A ``PortholeCredentialStore`` backed by a generic-password keychain item per +/// pairing. The `service` string namespaces the two sides (the device and the +/// Mac client use different services) so they never collide in a shared login +/// keychain. +public struct KeychainCredentialStore: PortholeCredentialStore { + private let service: String + + public init(service: String) { + self.service = service + } + + public func save(pairingID: UUID, key: SymmetricKey, metadata: Data) throws { + try? delete(pairingID: pairingID) + let keyData = key.withUnsafeBytes { Data($0) } + let attributes: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: pairingID.uuidString, + kSecValueData as String: keyData, + kSecAttrGeneric as String: metadata, + ] + let status = SecItemAdd(attributes as CFDictionary, nil) + guard status == errSecSuccess else { + throw PortholeKeychainError(status: status, operation: "save") + } + } + + public func key(for pairingID: UUID) throws -> SymmetricKey? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: pairingID.uuidString, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { + throw PortholeKeychainError(status: status, operation: "key") + } + return SymmetricKey(data: data) + } + + public func metadata(for pairingID: UUID) throws -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: pairingID.uuidString, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let attributes = result as? [String: Any] else { + throw PortholeKeychainError(status: status, operation: "metadata") + } + return attributes[kSecAttrGeneric as String] as? Data ?? Data() + } + + public func all() throws -> [PortholeCredentialRecord] { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitAll, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return [] } + guard status == errSecSuccess, let items = result as? [[String: Any]] else { + throw PortholeKeychainError(status: status, operation: "all") + } + return items.compactMap { attributes in + guard let account = attributes[kSecAttrAccount as String] as? String, + let pairingID = UUID(uuidString: account) + else { return nil } + let metadata = attributes[kSecAttrGeneric as String] as? Data ?? Data() + return PortholeCredentialRecord(pairingID: pairingID, metadata: metadata) + } + } + + public func delete(pairingID: UUID) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: pairingID.uuidString, + ] + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw PortholeKeychainError(status: status, operation: "delete") + } + } +} + +#if DEBUG + /// An in-memory ``PortholeCredentialStore`` for tests and previews — never + /// ships in release. + @_spi(Testing) + public final class InMemoryCredentialStore: PortholeCredentialStore, @unchecked Sendable { + private struct Entry { + var key: SymmetricKey + var metadata: Data + } + + private let lock = NSLock() + private var entries: [UUID: Entry] = [:] + + public init() {} + + public func save(pairingID: UUID, key: SymmetricKey, metadata: Data) throws { + lock.lock() + defer { lock.unlock() } + entries[pairingID] = Entry(key: key, metadata: metadata) + } + + public func key(for pairingID: UUID) throws -> SymmetricKey? { + lock.lock() + defer { lock.unlock() } + return entries[pairingID]?.key + } + + public func metadata(for pairingID: UUID) throws -> Data? { + lock.lock() + defer { lock.unlock() } + return entries[pairingID]?.metadata + } + + public func all() throws -> [PortholeCredentialRecord] { + lock.lock() + defer { lock.unlock() } + return entries.map { PortholeCredentialRecord( + pairingID: $0.key, + metadata: $0.value.metadata, + ) } + } + + public func delete(pairingID: UUID) throws { + lock.lock() + defer { lock.unlock() } + entries[pairingID] = nil + } + } +#endif diff --git a/Shared/Porthole/PortholeCore/Sources/PortholeDescriptors.swift b/Shared/Porthole/PortholeCore/Sources/PortholeDescriptors.swift new file mode 100644 index 00000000..c28e7c23 --- /dev/null +++ b/Shared/Porthole/PortholeCore/Sources/PortholeDescriptors.swift @@ -0,0 +1,90 @@ +import Foundation + +/// Describes a connector to a client (and, through it, to an agent) without +/// exposing its handlers — the manifest half of a connector. +public struct PortholeConnectorDescriptor: Sendable, Codable, Equatable { + public var id: PortholeConnectorID + public var title: String + public var summary: String + public var version: Int + + public init(id: PortholeConnectorID, title: String, summary: String, version: Int) { + self.id = id + self.title = title + self.summary = summary + self.version = version + } +} + +/// Describes one action: its address-local id, human/LLM-facing copy, parameter +/// schema, and whether invoking it mutates state (surfaces as an MCP +/// destructive hint and a CLI confirmation). +public struct PortholeActionDescriptor: Sendable, Codable, Equatable { + public var id: PortholeActionID + public var title: String + /// Written for an LLM audience — becomes the MCP tool description. + public var summary: String + public var parameters: PortholeSchema + public var isDestructive: Bool + + public init( + id: PortholeActionID, + title: String, + summary: String, + parameters: PortholeSchema, + isDestructive: Bool, + ) { + self.id = id + self.title = title + self.summary = summary + self.parameters = parameters + self.isDestructive = isDestructive + } +} + +/// Describes one data source: its row shape, the filters it accepts, and whether +/// it can be subscribed to for a live stream. +public struct PortholeDataSourceDescriptor: Sendable, Codable, Equatable { + public var id: PortholeDataSourceID + public var title: String + public var summary: String + public var rowSchema: PortholeSchema + /// Object schema describing the accepted filter keys. + public var filters: PortholeSchema + public var supportsSubscription: Bool + + public init( + id: PortholeDataSourceID, + title: String, + summary: String, + rowSchema: PortholeSchema, + filters: PortholeSchema, + supportsSubscription: Bool, + ) { + self.id = id + self.title = title + self.summary = summary + self.rowSchema = rowSchema + self.filters = filters + self.supportsSubscription = supportsSubscription + } +} + +/// The full advertised surface of one connector: its descriptor plus the +/// descriptors of every action and data source it exposes. Returned by +/// `listConnectors` and the client `manifest()`. +public struct ConnectorManifest: Sendable, Codable, Equatable { + public var connector: PortholeConnectorDescriptor + public var actions: [PortholeActionDescriptor] + public var dataSources: [PortholeDataSourceDescriptor] + + public init( + connector: PortholeConnectorDescriptor, + actions: [PortholeActionDescriptor], + dataSources: [PortholeDataSourceDescriptor], + ) { + self.connector = connector + self.actions = actions + self.dataSources = dataSources + } +} diff --git a/Shared/Porthole/PortholeCore/Sources/PortholeFramer.swift b/Shared/Porthole/PortholeCore/Sources/PortholeFramer.swift new file mode 100644 index 00000000..9be06651 --- /dev/null +++ b/Shared/Porthole/PortholeCore/Sources/PortholeFramer.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Length-prefixed framing for the Porthole byte stream: each frame is a 4-byte +/// big-endian `UInt32` payload length followed by that many payload bytes. +public enum PortholeFraming { + /// Frames larger than this are rejected on both encode and decode — a guard + /// against a corrupt or hostile length prefix allocating unbounded memory. + public static let maximumFrameByteCount = 32 * 1024 * 1024 + + /// Wraps a payload in a length-prefixed frame. + public static func encode(_ payload: Data) throws -> Data { + guard payload.count <= maximumFrameByteCount else { + throw PortholeError.frameTooLarge(payload.count) + } + var length = UInt32(payload.count).bigEndian + var frame = Data(bytes: &length, count: 4) + frame.append(payload) + return frame + } +} + +/// A stateful decoder that reassembles whole frames from arbitrarily-chunked +/// stream bytes. Feed it whatever a transport delivers; it emits every complete +/// frame and retains any partial remainder for the next chunk. +/// +/// Not thread-safe by design — own one per connection/direction. +public struct PortholeFramer { + private var buffer = Data() + + public init() {} + + /// Appends `chunk` and returns every frame now complete, in order. + /// Throws ``PortholeError/frameTooLarge(_:)`` if a length prefix exceeds the + /// maximum (the stream is then unusable — close it). + public mutating func ingest(_ chunk: Data) throws -> [Data] { + buffer.append(chunk) + var frames: [Data] = [] + while buffer.count >= 4 { + let frameLength = Int(buffer.prefix(4).reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }) + guard frameLength <= PortholeFraming.maximumFrameByteCount else { + throw PortholeError.frameTooLarge(frameLength) + } + guard buffer.count - 4 >= frameLength else { break } + let start = buffer.index(buffer.startIndex, offsetBy: 4) + let end = buffer.index(start, offsetBy: frameLength) + frames.append(Data(buffer[start ..< end])) + buffer = Data(buffer[end...]) + } + return frames + } +} diff --git a/Shared/Porthole/PortholeCore/Sources/PortholeIdentifiers.swift b/Shared/Porthole/PortholeCore/Sources/PortholeIdentifiers.swift new file mode 100644 index 00000000..449ed8a1 --- /dev/null +++ b/Shared/Porthole/PortholeCore/Sources/PortholeIdentifiers.swift @@ -0,0 +1,126 @@ +import Foundation + +/// Identifies a connector within one app (e.g. `app`, `ui`, `where`). Typed so a +/// new id can't silently typo into an untracked one; lowercase-kebab by +/// convention. +public struct PortholeConnectorID: RawRepresentable, Hashable, Sendable, Codable, + ExpressibleByStringLiteral, CustomStringConvertible +{ + public let rawValue: String + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(_ rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + rawValue = value + } + + public init(from decoder: Decoder) throws { + rawValue = try decoder.singleValueContainer().decode(String.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + public var description: String { + rawValue + } +} + +/// Identifies an action within its connector. +public struct PortholeActionID: RawRepresentable, Hashable, Sendable, Codable, + ExpressibleByStringLiteral, CustomStringConvertible +{ + public let rawValue: String + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(_ rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + rawValue = value + } + + public init(from decoder: Decoder) throws { + rawValue = try decoder.singleValueContainer().decode(String.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + public var description: String { + rawValue + } +} + +/// Identifies a data source within its connector. +public struct PortholeDataSourceID: RawRepresentable, Hashable, Sendable, Codable, + ExpressibleByStringLiteral, CustomStringConvertible +{ + public let rawValue: String + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(_ rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + rawValue = value + } + + public init(from decoder: Decoder) throws { + rawValue = try decoder.singleValueContainer().decode(String.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + public var description: String { + rawValue + } +} + +/// A fully-qualified action address: which connector, which action. +public struct PortholeActionRef: Hashable, Sendable, Codable, CustomStringConvertible { + public var connector: PortholeConnectorID + public var action: PortholeActionID + + public init(connector: PortholeConnectorID, action: PortholeActionID) { + self.connector = connector + self.action = action + } + + public var description: String { + "\(connector)/\(action)" + } +} + +/// A fully-qualified data-source address: which connector, which source. +public struct PortholeDataSourceRef: Hashable, Sendable, Codable, CustomStringConvertible { + public var connector: PortholeConnectorID + public var source: PortholeDataSourceID + + public init(connector: PortholeConnectorID, source: PortholeDataSourceID) { + self.connector = connector + self.source = source + } + + public var description: String { + "\(connector)/\(source)" + } +} diff --git a/Shared/Porthole/PortholeCore/Sources/PortholeMessages.swift b/Shared/Porthole/PortholeCore/Sources/PortholeMessages.swift new file mode 100644 index 00000000..784f2052 --- /dev/null +++ b/Shared/Porthole/PortholeCore/Sources/PortholeMessages.swift @@ -0,0 +1,140 @@ +import Foundation + +/// The wire protocol version both ends exchange in the hello handshake. Bump on +/// any breaking change to the message shapes. +public let portholeProtocolVersion = 1 + +// MARK: - Hello + +public struct HelloRequest: Codable, Sendable, Equatable { + public var protocolVersion: Int + public var clientName: String + + public init(protocolVersion: Int = portholeProtocolVersion, clientName: String) { + self.protocolVersion = protocolVersion + self.clientName = clientName + } +} + +public struct HelloReply: Codable, Sendable, Equatable { + public var protocolVersion: Int + public var appName: String + public var bundleID: String + public var deviceName: String + + public init( + protocolVersion: Int = portholeProtocolVersion, + appName: String, + bundleID: String, + deviceName: String, + ) { + self.protocolVersion = protocolVersion + self.appName = appName + self.bundleID = bundleID + self.deviceName = deviceName + } +} + +// MARK: - Errors + +/// Why a pairing attempt failed, distinct from other protocol errors so the UI +/// can react precisely (re-prompt vs. give up). +public enum PairingFailureReason: String, Codable, Sendable, Equatable { + case wrongCode + case expired + case tooManyAttempts +} + +/// Every failure the protocol can report. Codable so it rides the wire in a +/// `.failure` response; `Equatable` for test assertions. +public enum PortholeError: Error, Codable, Sendable, Equatable { + case protocolMismatch(theirs: Int, ours: Int) + case connectorNotFound(PortholeConnectorID) + case actionNotFound(PortholeActionRef) + case sourceNotFound(PortholeDataSourceRef) + case invalidParameters(String) + case subscriptionNotSupported(PortholeDataSourceRef) + case handlerFailed(String) + case notPaired + case pairingFailed(PairingFailureReason) + case frameTooLarge(Int) +} + +extension PortholeError: LocalizedError { + public var errorDescription: String? { + switch self { + case let .protocolMismatch(theirs, ours): + "Protocol mismatch: peer speaks v\(theirs), this build speaks v\(ours)." + case let .connectorNotFound(id): + "No connector with id `\(id)`." + case let .actionNotFound(ref): + "No action `\(ref)`." + case let .sourceNotFound(ref): + "No data source `\(ref)`." + case let .invalidParameters(message): + "Invalid parameters: \(message)" + case let .subscriptionNotSupported(ref): + "Data source `\(ref)` does not support subscriptions." + case let .handlerFailed(message): + "The connector handler failed: \(message)" + case .notPaired: + "This client is not paired with the device." + case let .pairingFailed(reason): + "Pairing failed: \(reason.rawValue)." + case let .frameTooLarge(byteCount): + "A message frame of \(byteCount) bytes exceeds the maximum." + } + } +} + +// MARK: - Requests + +/// A client → device request. Synthesized `Codable` (a keyed enum shape) — both +/// ends share this module, so the exact JSON keys are internal to the protocol. +public enum PortholeRequest: Codable, Sendable, Equatable { + case hello(HelloRequest) + case listConnectors + case invokeAction(ref: PortholeActionRef, parameters: PortholeValue) + case query(ref: PortholeDataSourceRef, query: PortholeQuery) + case subscribe(ref: PortholeDataSourceRef) + case unsubscribe(subscriptionID: UInt64) + case ping +} + +/// Wraps a request with the id the matching response echoes back. +public struct PortholeRequestEnvelope: Codable, Sendable, Equatable { + public var id: UInt64 + public var request: PortholeRequest + + public init(id: UInt64, request: PortholeRequest) { + self.id = id + self.request = request + } +} + +// MARK: - Responses + +/// A device → client response. `.event` is the only unsolicited case (its +/// envelope carries a nil `requestID`); everything else answers a request. +public enum PortholeResponse: Codable, Sendable, Equatable { + case helloReply(HelloReply) + case connectors([ConnectorManifest]) + case actionResult(PortholeValue) + case queryResult(PortholePage) + case subscribed(subscriptionID: UInt64) + case event(subscriptionID: UInt64, value: PortholeValue) + case failure(PortholeError) + case pong +} + +/// Wraps a response with the request id it answers (nil for unsolicited +/// `.event` frames). +public struct PortholeResponseEnvelope: Codable, Sendable, Equatable { + public var requestID: UInt64? + public var response: PortholeResponse + + public init(requestID: UInt64?, response: PortholeResponse) { + self.requestID = requestID + self.response = response + } +} diff --git a/Shared/Porthole/PortholeCore/Sources/PortholeQuery.swift b/Shared/Porthole/PortholeCore/Sources/PortholeQuery.swift new file mode 100644 index 00000000..7357bff3 --- /dev/null +++ b/Shared/Porthole/PortholeCore/Sources/PortholeQuery.swift @@ -0,0 +1,34 @@ +import Foundation + +/// A request for a page of rows from a data source: opaque filters (validated +/// against the source's `filters` schema), an optional page size, and an opaque +/// cursor minted by the source. +public struct PortholeQuery: Sendable, Codable, Equatable { + /// An `.object` of filter keys; validated against the source descriptor's + /// `filters` schema before the source runs. + public var filters: PortholeValue + public var limit: Int? + /// Opaque continuation token from a prior page's `nextCursor`. + public var cursor: String? + + public init(filters: PortholeValue = .object([:]), limit: Int? = nil, cursor: String? = nil) { + self.filters = filters + self.limit = limit + self.cursor = cursor + } +} + +/// One page of data-source results: the rows, a cursor for the next page (nil +/// when exhausted), and an optional total count (nil when counting is +/// expensive). +public struct PortholePage: Sendable, Codable, Equatable { + public var rows: [PortholeValue] + public var nextCursor: String? + public var totalCount: Int? + + public init(rows: [PortholeValue], nextCursor: String? = nil, totalCount: Int? = nil) { + self.rows = rows + self.nextCursor = nextCursor + self.totalCount = totalCount + } +} diff --git a/Shared/Porthole/PortholeCore/Sources/PortholeSchema.swift b/Shared/Porthole/PortholeCore/Sources/PortholeSchema.swift new file mode 100644 index 00000000..148505dd --- /dev/null +++ b/Shared/Porthole/PortholeCore/Sources/PortholeSchema.swift @@ -0,0 +1,220 @@ +import Foundation + +/// A small, self-describing shape for a connector's action parameters and a +/// data source's rows/filters. It exists so two things can be derived from one +/// source of truth: a JSON-Schema object (``jsonSchema()``) that feeds an MCP +/// tool's `inputSchema`, and a runtime ``validate(_:)`` the device applies to +/// incoming parameters before handing them to a connector handler. +/// +/// It is deliberately a subset of JSON Schema — enough to describe the +/// descriptive payloads Porthole carries, not a general validator. +public struct PortholeSchema: Sendable, Equatable, Codable { + public enum Kind: String, Sendable, Codable { + case object, array, string, integer, number, boolean, data, date + } + + public var kind: Kind + /// Rendered as JSON Schema `description`; write it for an LLM reader. + public var summary: String? + /// Member schemas when `kind == .object`. + public var properties: [String: PortholeSchema]? + /// Required member keys when `kind == .object`. + public var required: [String]? + /// Permitted string values (JSON Schema `enum`) when `kind == .string`. + public var allowedValues: [String]? + + /// Element schema when `kind == .array`, boxed so this value type can + /// describe itself recursively. + public var items: PortholeSchema? { + get { itemsBox?.schema } + set { itemsBox = newValue.map(Box.init) } + } + + private var itemsBox: Box? + + private enum CodingKeys: String, CodingKey { + case kind, summary, properties, required, allowedValues + case itemsBox = "items" + } + + public init( + kind: Kind, + summary: String? = nil, + properties: [String: PortholeSchema]? = nil, + required: [String]? = nil, + allowedValues: [String]? = nil, + items: PortholeSchema? = nil, + ) { + self.kind = kind + self.summary = summary + self.properties = properties + self.required = required + self.allowedValues = allowedValues + itemsBox = items.map(Box.init) + } + + /// Boxes a child schema so `PortholeSchema` (a value type) can hold one + /// recursively; encodes transparently as the wrapped schema. + private final class Box: Sendable, Equatable, Codable { + let schema: PortholeSchema + init(_ schema: PortholeSchema) { + self.schema = schema + } + + static func == (lhs: Box, rhs: Box) -> Bool { + lhs.schema == rhs.schema + } + + init(from decoder: Decoder) throws { + schema = try decoder.singleValueContainer().decode(PortholeSchema.self) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(schema) + } + } +} + +// MARK: - Builders + +extension PortholeSchema { + public static func object( + _ properties: [String: PortholeSchema], + required: [String] = [], + summary: String? = nil, + ) -> PortholeSchema { + PortholeSchema(kind: .object, summary: summary, properties: properties, required: required) + } + + public static func array(of items: PortholeSchema, summary: String? = nil) -> PortholeSchema { + PortholeSchema(kind: .array, summary: summary, items: items) + } + + public static func string( + _ summary: String? = nil, + allowedValues: [String]? = nil, + ) -> PortholeSchema { + PortholeSchema(kind: .string, summary: summary, allowedValues: allowedValues) + } + + public static func integer(_ summary: String? = nil) -> PortholeSchema { + PortholeSchema(kind: .integer, summary: summary) + } + + public static func number(_ summary: String? = nil) -> PortholeSchema { + PortholeSchema(kind: .number, summary: summary) + } + + public static func boolean(_ summary: String? = nil) -> PortholeSchema { + PortholeSchema(kind: .boolean, summary: summary) + } + + public static func data(_ summary: String? = nil) -> PortholeSchema { + PortholeSchema(kind: .data, summary: summary) + } + + public static func date(_ summary: String? = nil) -> PortholeSchema { + PortholeSchema(kind: .date, summary: summary) + } +} + +// MARK: - JSON Schema rendering + +extension PortholeSchema { + /// Renders a JSON-Schema object as a `PortholeValue` — the shape an MCP + /// tool's `inputSchema` expects. + public func jsonSchema() -> PortholeValue { + var object: [String: PortholeValue] = [:] + if let summary { object["description"] = .string(summary) } + + switch kind { + case .object: + object["type"] = "object" + if let properties { + object["properties"] = .object(properties.mapValues { $0.jsonSchema() }) + } + if let required, !required.isEmpty { + object["required"] = .array(required.map(PortholeValue.string)) + } + case .array: + object["type"] = "array" + if let items { object["items"] = items.jsonSchema() } + case .string: + object["type"] = "string" + if let allowedValues { + object["enum"] = .array(allowedValues.map(PortholeValue.string)) + } + case .integer: + object["type"] = "integer" + case .number: + object["type"] = "number" + case .boolean: + object["type"] = "boolean" + case .data: + object["type"] = "string" + object["contentEncoding"] = "base64" + case .date: + object["type"] = "string" + object["format"] = "date-time" + } + return .object(object) + } +} + +// MARK: - Validation + +extension PortholeSchema { + /// Validates `value` against this schema, throwing + /// ``PortholeError/invalidParameters(_:)`` with a human-readable path on the + /// first mismatch. Known object members are type-checked; unknown members + /// are ignored (forward-compatible), but every `required` member must be + /// present. The device runtime calls this before invoking a handler. + public func validate(_ value: PortholeValue) throws { + try validate(value, path: "") + } + + private func validate(_ value: PortholeValue, path: String) throws { + func fail(_ reason: String) -> PortholeError { + let location = path.isEmpty ? "value" : "`\(path)`" + return PortholeError.invalidParameters("\(location) \(reason)") + } + + switch kind { + case .object: + guard case let .object(members) = value else { throw fail("must be an object") } + for key in required ?? [] where members[key] == nil { + throw fail("is missing required member `\(key)`") + } + for (key, subschema) in properties ?? [:] { + if let member = members[key] { + try subschema.validate(member, path: path.isEmpty ? key : "\(path).\(key)") + } + } + case .array: + guard case let .array(elements) = value else { throw fail("must be an array") } + if let items { + for (index, element) in elements.enumerated() { + try items.validate(element, path: "\(path)[\(index)]") + } + } + case .string: + guard case let .string(string) = value else { throw fail("must be a string") } + if let allowedValues, !allowedValues.contains(string) { + throw fail( + "must be one of \(allowedValues.map { "\"\($0)\"" }.joined(separator: ", "))", + ) + } + case .integer: + guard value.intValue != nil else { throw fail("must be an integer") } + case .number: + guard value.doubleValue != nil else { throw fail("must be a number") } + case .boolean: + guard value.boolValue != nil else { throw fail("must be a boolean") } + case .data: + guard value.dataValue != nil else { throw fail("must be binary data") } + case .date: + guard value.dateValue != nil else { throw fail("must be a date") } + } + } +} diff --git a/Shared/Porthole/PortholeCore/Sources/PortholeSecureChannel.swift b/Shared/Porthole/PortholeCore/Sources/PortholeSecureChannel.swift new file mode 100644 index 00000000..21fc4368 --- /dev/null +++ b/Shared/Porthole/PortholeCore/Sources/PortholeSecureChannel.swift @@ -0,0 +1,137 @@ +import CryptoKit +import Foundation + +/// Wraps any ``PortholeTransport`` in a ChaCha20-Poly1305 record layer keyed by +/// the per-session key. It is itself a `PortholeTransport`, so everything above +/// it is oblivious to encryption. +/// +/// The 12-byte nonce is derived, never transmitted: a 4-byte direction tag +/// (`c2d`/`d2c`, so the two directions never share a nonce under the same key) +/// followed by an 8-byte big-endian per-direction counter. Because the receiver +/// reconstructs the nonce from its own strictly-increasing expected counter, any +/// reorder, replay, gap, or tamper yields the wrong nonce (or a bad tag) and the +/// open fails — which finishes the `incoming` stream with an error and closes +/// the connection. Order and integrity thus fall out of the cipher itself. +public final class PortholeSecureChannel: PortholeTransport, @unchecked Sendable { + public enum Role: Sendable { + case client + case device + + var sendTag: [UInt8] { + switch self { + case .client: Array("c2d\u{0}".utf8) + case .device: Array("d2c\u{0}".utf8) + } + } + + var receiveTag: [UInt8] { + switch self { + case .client: Array("d2c\u{0}".utf8) + case .device: Array("c2d\u{0}".utf8) + } + } + } + + public let incoming: AsyncThrowingStream + + private let sendClosure: @Sendable (Data) async throws -> Void + private let closeClosure: @Sendable () async -> Void + private let key: SymmetricKey + private let sendTag: [UInt8] + private let sendLock = NSLock() + private var sendCounter: UInt64 = 0 + + /// Wraps a whole transport dedicated to this channel: the channel owns the + /// only reader over its `incoming`. + public convenience init(wrapping inner: some PortholeTransport, key: SymmetricKey, role: Role) { + self.init( + reader: TransportFrameReader(inner), + send: { try await inner.send($0) }, + close: { await inner.close() }, + key: key, + role: role, + ) + } + + /// Builds a channel over an existing frame reader — the handoff a plaintext + /// handshake uses to continue the *same* connection as an encrypted session. + public init( + reader: TransportFrameReader, + send: @escaping @Sendable (Data) async throws -> Void, + close: @escaping @Sendable () async -> Void, + key: SymmetricKey, + role: Role, + ) { + sendClosure = send + closeClosure = close + self.key = key + sendTag = role.sendTag + let receiveTag = role.receiveTag + + let (stream, continuation) = AsyncThrowingStream.makeStream() + incoming = stream + + Task { + var counter: UInt64 = 0 + do { + while let sealed = try await reader.next() { + let plaintext = try Self.open( + sealed, + key: key, + tag: receiveTag, + counter: counter, + ) + counter &+= 1 + continuation.yield(plaintext) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + + public func send(_ frame: Data) async throws { + let counter = sendLock.withLock { () -> UInt64 in + let current = sendCounter + sendCounter &+= 1 + return current + } + + let nonce = try Self.nonce(tag: sendTag, counter: counter) + let box = try ChaChaPoly.seal(frame, using: key, nonce: nonce) + var payload = Data() + payload.append(box.ciphertext) + payload.append(box.tag) + try await sendClosure(payload) + } + + public func close() async { + await closeClosure() + } + + // MARK: - Record cipher + + /// Poly1305 tag length appended after the ciphertext. + private static let tagByteCount = 16 + + private static func nonce(tag: [UInt8], counter: UInt64) throws -> ChaChaPoly.Nonce { + var bytes = tag + withUnsafeBytes(of: counter.bigEndian) { bytes.append(contentsOf: $0) } + return try ChaChaPoly.Nonce(data: Data(bytes)) + } + + private static func open( + _ sealed: Data, + key: SymmetricKey, + tag: [UInt8], + counter: UInt64, + ) throws -> Data { + guard sealed.count >= tagByteCount else { throw PortholeTransportError.closed } + let ciphertext = sealed.prefix(sealed.count - tagByteCount) + let poly1305Tag = sealed.suffix(tagByteCount) + let nonce = try nonce(tag: tag, counter: counter) + let box = try ChaChaPoly.SealedBox(nonce: nonce, ciphertext: ciphertext, tag: poly1305Tag) + return try ChaChaPoly.open(box, using: key) + } +} diff --git a/Shared/Porthole/PortholeCore/Sources/PortholeTransport.swift b/Shared/Porthole/PortholeCore/Sources/PortholeTransport.swift new file mode 100644 index 00000000..cc6a41fc --- /dev/null +++ b/Shared/Porthole/PortholeCore/Sources/PortholeTransport.swift @@ -0,0 +1,28 @@ +import Foundation + +/// A bidirectional, frame-oriented byte pipe. Everything above it — the secure +/// channel, the session router, the client — speaks in whole frames and never +/// touches sockets directly, so the same logic runs over a real +/// `NWConnection`-backed transport in production and an in-memory +/// ``LoopbackTransport`` in tests. +/// +/// `incoming` yields whole frames (the implementation owns any reassembly). It +/// is single-consumer: iterate it exactly once. +public protocol PortholeTransport: Sendable { + /// Sends one whole frame. Throws if the transport is closed or the write + /// fails. + func send(_ frame: Data) async throws + + /// The stream of whole frames arriving from the peer. Iterate once; it + /// finishes when the peer closes (or throws on transport failure). + var incoming: AsyncThrowingStream { get } + + /// Closes the transport, ending the peer's `incoming` stream. + func close() async +} + +/// Failures a transport can surface directly (protocol-level failures are +/// ``PortholeError``). +public enum PortholeTransportError: Error, Sendable, Equatable { + case closed +} diff --git a/Shared/Porthole/PortholeCore/Sources/PortholeValue.swift b/Shared/Porthole/PortholeCore/Sources/PortholeValue.swift new file mode 100644 index 00000000..3e2f4584 --- /dev/null +++ b/Shared/Porthole/PortholeCore/Sources/PortholeValue.swift @@ -0,0 +1,274 @@ +import Foundation + +/// The single data currency that crosses the Porthole wire: every action +/// parameter and result, every data-source row, and every stream event is a +/// `PortholeValue`. +/// +/// It is a small, self-describing JSON-shaped tree with two extensions over +/// plain JSON — `.data` and `.date` — so binary blobs and timestamps survive a +/// round-trip unambiguously instead of decaying into strings the far side has +/// to guess about. +/// +/// ## Wire shape +/// +/// Scalars encode as the bare JSON scalar (`true`, `42`, `1.5`, `"hi"`, `null`). +/// `.data` and `.date` encode as tagged single-key objects — `{"$data": +/// ""}` and `{"$date": ""}` — which is what lets the decoder +/// tell them apart from an ordinary `.object`. A genuine object whose *only* +/// key is `$data`/`$date` would be read back as the tagged form; that ambiguity +/// is deliberate and harmless for the descriptive payloads Porthole carries. +public enum PortholeValue: Sendable, Equatable { + case null + case bool(Bool) + case int(Int64) + case double(Double) + case string(String) + case data(Data) + case date(Date) + case array([PortholeValue]) + case object([String: PortholeValue]) +} + +// MARK: - Convenience accessors + +extension PortholeValue { + public var isNull: Bool { + self == .null + } + + public var boolValue: Bool? { + if case let .bool(value) = self { return value } + return nil + } + + /// The integer value, widening `.int` and accepting a whole-numbered + /// `.double` (JSON has one number type, so an integer can arrive as either). + public var intValue: Int64? { + switch self { + case let .int(value): value + case let .double(value) where value.rounded() == value: Int64(value) + default: nil + } + } + + public var doubleValue: Double? { + switch self { + case let .double(value): value + case let .int(value): Double(value) + default: nil + } + } + + public var stringValue: String? { + if case let .string(value) = self { return value } + return nil + } + + public var dataValue: Data? { + if case let .data(value) = self { return value } + return nil + } + + public var dateValue: Date? { + if case let .date(value) = self { return value } + return nil + } + + public var arrayValue: [PortholeValue]? { + if case let .array(value) = self { return value } + return nil + } + + public var objectValue: [String: PortholeValue]? { + if case let .object(value) = self { return value } + return nil + } + + /// Reads a member of an `.object`; `nil` for any other case or a missing key. + public subscript(key: String) -> PortholeValue? { + objectValue?[key] + } + + /// Reads an element of an `.array`; `nil` for any other case or out-of-range. + public subscript(index: Int) -> PortholeValue? { + guard let array = arrayValue, array.indices.contains(index) else { return nil } + return array[index] + } +} + +// MARK: - Literal conformances (test & call-site ergonomics) + +extension PortholeValue: ExpressibleByNilLiteral { + public init(nilLiteral _: ()) { + self = .null + } +} + +extension PortholeValue: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + +extension PortholeValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int64) { + self = .int(value) + } +} + +extension PortholeValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .double(value) + } +} + +extension PortholeValue: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .string(value) + } +} + +extension PortholeValue: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: PortholeValue...) { + self = .array(elements) + } +} + +extension PortholeValue: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, PortholeValue)...) { + self = .object(Dictionary(elements) { first, _ in first }) + } +} + +// MARK: - Codable + +extension PortholeValue: Codable { + /// Dynamic key so an `.object` (and the `$data`/`$date` tags) can use a + /// keyed container without a fixed `CodingKeys` set. Named to avoid shadowing + /// the `ExpressibleByDictionaryLiteral.Key` associated type. + private struct DynamicCodingKey: CodingKey { + var stringValue: String + var intValue: Int? { + nil + } + + init(_ stringValue: String) { + self.stringValue = stringValue + } + + init?(stringValue: String) { + self.stringValue = stringValue + } + + init?(intValue _: Int) { + nil + } + + static let data = DynamicCodingKey("$data") + static let date = DynamicCodingKey("$date") + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .null: + var container = encoder.singleValueContainer() + try container.encodeNil() + case let .bool(value): + var container = encoder.singleValueContainer() + try container.encode(value) + case let .int(value): + var container = encoder.singleValueContainer() + try container.encode(value) + case let .double(value): + var container = encoder.singleValueContainer() + try container.encode(value) + case let .string(value): + var container = encoder.singleValueContainer() + try container.encode(value) + case let .data(value): + var container = encoder.container(keyedBy: DynamicCodingKey.self) + try container.encode(value.base64EncodedString(), forKey: .data) + case let .date(value): + var container = encoder.container(keyedBy: DynamicCodingKey.self) + try container.encode(PortholeISO8601.string(from: value), forKey: .date) + case let .array(value): + var container = encoder.unkeyedContainer() + for element in value { + try container.encode(element) + } + case let .object(value): + var container = encoder.container(keyedBy: DynamicCodingKey.self) + for (key, element) in value { + try container.encode(element, forKey: DynamicCodingKey(key)) + } + } + } + + public init(from decoder: Decoder) throws { + if let single = try? decoder.singleValueContainer(), single.decodeNil() { + self = .null + return + } + if let single = try? decoder.singleValueContainer() { + if let value = try? single.decode(Bool.self) { self = .bool(value); return } + if let value = try? single.decode(Int64.self) { self = .int(value); return } + if let value = try? single.decode(Double.self) { self = .double(value); return } + if let value = try? single.decode(String.self) { self = .string(value); return } + } + if let keyed = try? decoder.container(keyedBy: DynamicCodingKey.self) { + let keys = keyed.allKeys.map(\.stringValue) + if keys == ["$data"], let base64 = try? keyed.decode(String.self, forKey: .data), + let data = Data(base64Encoded: base64) + { + self = .data(data) + return + } + if keys == ["$date"], let iso = try? keyed.decode(String.self, forKey: .date), + let date = PortholeISO8601.date(from: iso) + { + self = .date(date) + return + } + var object: [String: PortholeValue] = [:] + for key in keyed.allKeys { + object[key.stringValue] = try keyed.decode(PortholeValue.self, forKey: key) + } + self = .object(object) + return + } + if var unkeyed = try? decoder.unkeyedContainer() { + var array: [PortholeValue] = [] + if let count = unkeyed.count { array.reserveCapacity(count) } + while !unkeyed.isAtEnd { + try array.append(unkeyed.decode(PortholeValue.self)) + } + self = .array(array) + return + } + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: "Unrecognized PortholeValue shape", + ), + ) + } +} + +/// Shared ISO 8601 formatting for `.date` wire values (millisecond precision). +enum PortholeISO8601 { + /// `ISO8601DateFormatter` is immutable after configuration and thread-safe + /// for concurrent formatting/parsing, so a shared instance is safe here. + private nonisolated(unsafe) static let formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + static func string(from date: Date) -> String { + formatter.string(from: date) + } + + static func date(from string: String) -> Date? { + formatter.date(from: string) + } +} diff --git a/Shared/Porthole/PortholeCore/Sources/TransportFrameReader.swift b/Shared/Porthole/PortholeCore/Sources/TransportFrameReader.swift new file mode 100644 index 00000000..fad4f154 --- /dev/null +++ b/Shared/Porthole/PortholeCore/Sources/TransportFrameReader.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Serializes a transport's `incoming` stream into pull-based `next()` calls so a +/// plaintext handshake can read a few frames off a connection and then hand the +/// *same* underlying stream to a ``PortholeSecureChannel`` for the encrypted +/// session — without ever creating a second, competing iterator over a +/// single-consumer `AsyncThrowingStream`. +/// +/// A background pump owns the one true iterator and feeds a small buffer; +/// `next()` either takes a buffered frame or parks until one arrives (or the +/// stream ends/fails). +public actor TransportFrameReader { + private var buffer: [Data] = [] + private var waiters: [CheckedContinuation] = [] + private var isFinished = false + private var failure: Error? + + public init(_ transport: some PortholeTransport) { + let stream = transport.incoming + Task { await self.pump(stream) } + } + + /// The next whole frame, or `nil` when the stream has finished. Throws if the + /// transport failed. + public func next() async throws -> Data? { + if !buffer.isEmpty { return buffer.removeFirst() } + if isFinished { + if let failure { throw failure } + return nil + } + return try await withCheckedThrowingContinuation { waiters.append($0) } + } + + private func pump(_ stream: AsyncThrowingStream) async { + do { + for try await frame in stream { + deliver(frame) + } + finish(nil) + } catch { + finish(error) + } + } + + private func deliver(_ frame: Data) { + if waiters.isEmpty { + buffer.append(frame) + } else { + waiters.removeFirst().resume(returning: frame) + } + } + + private func finish(_ error: Error?) { + isFinished = true + failure = error + for waiter in waiters { + if let error { + waiter.resume(throwing: error) + } else { + waiter.resume(returning: nil) + } + } + waiters.removeAll() + } +} diff --git a/Shared/Porthole/PortholeCore/Tests/LoopbackTransportTests.swift b/Shared/Porthole/PortholeCore/Tests/LoopbackTransportTests.swift new file mode 100644 index 00000000..537d8f7b --- /dev/null +++ b/Shared/Porthole/PortholeCore/Tests/LoopbackTransportTests.swift @@ -0,0 +1,33 @@ +import Foundation +@testable import PortholeCore +import Testing + +struct LoopbackTransportTests { + @Test func framesDeliverToThePeer() async throws { + let (a, b) = LoopbackTransport.makePair() + try await a.send(Data("ping".utf8)) + #expect(try await firstFrame(from: b) == Data("ping".utf8)) + + try await b.send(Data("pong".utf8)) + #expect(try await firstFrame(from: a) == Data("pong".utf8)) + } + + @Test func closingEndsThePeerStream() async throws { + let (a, b) = LoopbackTransport.makePair() + await a.close() + + try await withTimeout { + var iterator = b.incoming.makeAsyncIterator() + let next = try await iterator.next() + #expect(next == nil) // finished, not a value + } + } + + @Test func sendingOnAClosedTransportThrows() async throws { + let (a, _) = LoopbackTransport.makePair() + await a.close() + await #expect(throws: PortholeTransportError.self) { + try await a.send(Data("late".utf8)) + } + } +} diff --git a/Shared/Porthole/PortholeCore/Tests/PairingCryptographyTests.swift b/Shared/Porthole/PortholeCore/Tests/PairingCryptographyTests.swift new file mode 100644 index 00000000..2870ef7f --- /dev/null +++ b/Shared/Porthole/PortholeCore/Tests/PairingCryptographyTests.swift @@ -0,0 +1,153 @@ +import CryptoKit +import Foundation +@testable import PortholeCore +import Testing + +struct PairingCryptographyTests { + @Test func bothSidesDeriveTheSamePairingKeyWithTheSameCode() throws { + let client = Curve25519.KeyAgreement.PrivateKey() + let device = Curve25519.KeyAgreement.PrivateKey() + let salt = PairingCryptography.randomBytes(count: PairingCryptography.saltByteCount) + let code = "123456" + + let clientKey = try PairingCryptography.pairingKey( + ownPrivateKey: client, + peerPublicKey: device.publicKey, + salt: salt, + code: code, + ) + let deviceKey = try PairingCryptography.pairingKey( + ownPrivateKey: device, + peerPublicKey: client.publicKey, + salt: salt, + code: code, + ) + #expect(clientKey == deviceKey) + } + + @Test func aDifferentCodeYieldsADifferentKey() throws { + let client = Curve25519.KeyAgreement.PrivateKey() + let device = Curve25519.KeyAgreement.PrivateKey() + let salt = PairingCryptography.randomBytes(count: PairingCryptography.saltByteCount) + + let right = try PairingCryptography.pairingKey( + ownPrivateKey: client, + peerPublicKey: device.publicKey, + salt: salt, + code: "000000", + ) + let wrong = try PairingCryptography.pairingKey( + ownPrivateKey: device, + peerPublicKey: client.publicKey, + salt: salt, + code: "999999", + ) + #expect(right != wrong) + } + + @Test func confirmationCodeVerifies() { + let key = SymmetricKey(size: .bits256) + let clientPub = Data("client".utf8) + let devicePub = Data("device".utf8) + let salt = Data("salt".utf8) + + let mac = PairingCryptography.confirmationCode( + key: key, + clientPublicKey: clientPub, + devicePublicKey: devicePub, + salt: salt, + ) + #expect(PairingCryptography.isValidConfirmation( + mac, + key: key, + clientPublicKey: clientPub, + devicePublicKey: devicePub, + salt: salt, + )) + // A different key must not verify. + #expect(!PairingCryptography.isValidConfirmation( + mac, + key: SymmetricKey(size: .bits256), + clientPublicKey: clientPub, + devicePublicKey: devicePub, + salt: salt, + )) + } + + @Test func acceptanceCodeVerifies() { + let key = SymmetricKey(size: .bits256) + let clientPub = Data("client".utf8) + let salt = Data("salt".utf8) + + let mac = PairingCryptography.acceptanceCode( + key: key, + salt: salt, + clientPublicKey: clientPub, + ) + #expect(PairingCryptography.isValidAcceptance( + mac, + key: key, + salt: salt, + clientPublicKey: clientPub, + )) + #expect(!PairingCryptography.isValidAcceptance( + mac, + key: SymmetricKey(size: .bits256), + salt: salt, + clientPublicKey: clientPub, + )) + } + + @Test func bothSidesDeriveTheSameSessionKey() { + let psk = SymmetricKey(size: .bits256) + let clientNonce = PairingCryptography.randomBytes(count: PairingCryptography.nonceByteCount) + let serverNonce = PairingCryptography.randomBytes(count: PairingCryptography.nonceByteCount) + + let a = PairingCryptography.sessionKey( + psk: psk, + clientNonce: clientNonce, + serverNonce: serverNonce, + ) + let b = PairingCryptography.sessionKey( + psk: psk, + clientNonce: clientNonce, + serverNonce: serverNonce, + ) + #expect(a == b) + + let different = PairingCryptography.sessionKey( + psk: psk, + clientNonce: PairingCryptography.randomBytes(count: PairingCryptography.nonceByteCount), + serverNonce: serverNonce, + ) + #expect(a != different) + } + + @Test func pairingCodeIsSixDigitsAndSeedDeterministic() { + var generator = SeededGenerator(seed: 42) + let first = PairingCryptography.makePairingCode(using: &generator) + let isAllDigits = first.allSatisfy(\.isNumber) + #expect(first.count == 6) + #expect(isAllDigits) + + var same = SeededGenerator(seed: 42) + let repeated = PairingCryptography.makePairingCode(using: &same) + #expect(repeated == first) + } +} + +/// Deterministic RNG for reproducible pairing-code tests. +struct SeededGenerator: RandomNumberGenerator { + private var state: UInt64 + init(seed: UInt64) { + state = seed &+ 0x9E37_79B9_7F4A_7C15 + } + + mutating func next() -> UInt64 { + state = state &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 + z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB + return z ^ (z >> 31) + } +} diff --git a/Shared/Porthole/PortholeCore/Tests/PairingProtocolTests.swift b/Shared/Porthole/PortholeCore/Tests/PairingProtocolTests.swift new file mode 100644 index 00000000..7017c009 --- /dev/null +++ b/Shared/Porthole/PortholeCore/Tests/PairingProtocolTests.swift @@ -0,0 +1,32 @@ +import CryptoKit +import Foundation +@testable import PortholeCore +import Testing + +struct PairingProtocolTests { + @Test func pairingMessagesRoundTrip() throws { + let clientKey = Curve25519.KeyAgreement.PrivateKey() + let messages: [PortholePairingMessage] = [ + .clientHello(mode: .pair( + clientName: "cli", + clientPublicKey: clientKey.publicKey.rawRepresentation, + )), + .clientHello(mode: .session(pairingID: UUID(), clientNonce: Data([1, 2, 3]))), + .pairConfirm(mac: Data([9, 9, 9])), + .pairChallenge(devicePublicKey: Data([4, 5, 6]), salt: Data([7, 8])), + .pairAccepted(pairingID: UUID(), mac: Data([1])), + .serverHello(serverNonce: Data([2, 2])), + .failure(.pairingFailed(.tooManyAttempts)), + ] + for message in messages { + #expect(try jsonRoundTrip(message) == message) + } + } + + @Test func publicKeysSurviveRawRepresentationRoundTrip() throws { + let key = Curve25519.KeyAgreement.PrivateKey() + let raw = key.publicKey.rawRepresentation + let restored = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: raw) + #expect(restored.rawRepresentation == raw) + } +} diff --git a/Shared/Porthole/PortholeCore/Tests/PortholeCoreTestSupport.swift b/Shared/Porthole/PortholeCore/Tests/PortholeCoreTestSupport.swift new file mode 100644 index 00000000..1eb78e82 --- /dev/null +++ b/Shared/Porthole/PortholeCore/Tests/PortholeCoreTestSupport.swift @@ -0,0 +1,65 @@ +import Foundation +@testable import PortholeCore + +/// Encodes then decodes `value` as JSON — the round-trip the real wire performs. +func jsonRoundTrip(_ value: T) throws -> T { + let data = try JSONEncoder().encode(value) + return try JSONDecoder().decode(T.self, from: data) +} + +/// Round-trips a `PortholeValue` nested inside an object, so scalars don't rely +/// on top-level JSON-fragment support and the test mirrors real (always-nested) +/// wire use. +func wireRoundTrip(_ value: PortholeValue) throws -> PortholeValue { + let wrapped = PortholeValue.object(["v": value]) + let data = try JSONEncoder().encode(wrapped) + let decoded = try JSONDecoder().decode(PortholeValue.self, from: data) + guard let inner = decoded["v"] else { + throw WireRoundTripError.missingValue + } + return inner +} + +/// The raw JSON object a `PortholeValue` encodes to, for shape assertions. +func jsonObject(_ value: PortholeValue) throws -> [String: Any] { + let data = try JSONEncoder().encode(value) + let object = try JSONSerialization.jsonObject(with: data) + guard let dictionary = object as? [String: Any] else { + throw WireRoundTripError.notAnObject + } + return dictionary +} + +enum WireRoundTripError: Error { + case missingValue + case notAnObject +} + +struct TimeoutError: Error {} + +/// Runs `operation` with a wall-clock ceiling so a stuck async read fails the +/// test instead of hanging the suite. +func withTimeout( + _ duration: Duration = .seconds(2), + _ operation: @escaping @Sendable () async throws -> T, +) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(for: duration) + throw TimeoutError() + } + let result = try await group.next()! + group.cancelAll() + return result + } +} + +/// Awaits the first frame delivered to a transport's `incoming`, with a timeout. +func firstFrame(from transport: some PortholeTransport) async throws -> Data { + try await withTimeout { + var iterator = transport.incoming.makeAsyncIterator() + guard let frame = try await iterator.next() else { throw TimeoutError() } + return frame + } +} diff --git a/Shared/Porthole/PortholeCore/Tests/PortholeCredentialStoreTests.swift b/Shared/Porthole/PortholeCore/Tests/PortholeCredentialStoreTests.swift new file mode 100644 index 00000000..ff7a465f --- /dev/null +++ b/Shared/Porthole/PortholeCore/Tests/PortholeCredentialStoreTests.swift @@ -0,0 +1,56 @@ +import CryptoKit +import Foundation +@_spi(Testing) import PortholeCore +import Testing + +struct PortholeCredentialStoreTests { + @Test func savesAndReadsBackKeyAndMetadata() throws { + let store = InMemoryCredentialStore() + let id = UUID() + let key = SymmetricKey(size: .bits256) + let metadata = Data("device-name".utf8) + + try store.save(pairingID: id, key: key, metadata: metadata) + #expect(try store.key(for: id) == key) + #expect(try store.metadata(for: id) == metadata) + } + + @Test func listsAllRecordsWithoutSecrets() throws { + let store = InMemoryCredentialStore() + let first = UUID() + let second = UUID() + try store.save( + pairingID: first, + key: SymmetricKey(size: .bits256), + metadata: Data("a".utf8), + ) + try store.save( + pairingID: second, + key: SymmetricKey(size: .bits256), + metadata: Data("b".utf8), + ) + + let records = try store.all() + .sorted { + $0.metadata.count < $1.metadata.count || $0.pairingID.uuidString < $1.pairingID + .uuidString + } + #expect(Set(records.map(\.pairingID)) == [first, second]) + #expect(Set(records.map(\.metadata)) == [Data("a".utf8), Data("b".utf8)]) + } + + @Test func deleteRemovesTheEntry() throws { + let store = InMemoryCredentialStore() + let id = UUID() + try store.save(pairingID: id, key: SymmetricKey(size: .bits256), metadata: Data()) + try store.delete(pairingID: id) + #expect(try store.key(for: id) == nil) + #expect(try store.all().isEmpty) + } + + @Test func missingKeyReturnsNil() throws { + let store = InMemoryCredentialStore() + #expect(try store.key(for: UUID()) == nil) + #expect(try store.metadata(for: UUID()) == nil) + } +} diff --git a/Shared/Porthole/PortholeCore/Tests/PortholeFramerTests.swift b/Shared/Porthole/PortholeCore/Tests/PortholeFramerTests.swift new file mode 100644 index 00000000..04195ad1 --- /dev/null +++ b/Shared/Porthole/PortholeCore/Tests/PortholeFramerTests.swift @@ -0,0 +1,55 @@ +import Foundation +@testable import PortholeCore +import Testing + +struct PortholeFramerTests { + @Test func encodesAndDecodesOneFrame() throws { + let payload = Data("hello".utf8) + let frame = try PortholeFraming.encode(payload) + #expect(frame.count == 4 + payload.count) + + var framer = PortholeFramer() + let frames = try framer.ingest(frame) + #expect(frames == [payload]) + } + + @Test func reassemblesAcrossPartialChunks() throws { + let payload = Data("a longer payload split across chunks".utf8) + let frame = try PortholeFraming.encode(payload) + + var framer = PortholeFramer() + // Split mid-header and mid-body. + #expect(try framer.ingest(frame.prefix(2)).isEmpty) + #expect(try framer.ingest(frame[2 ..< 6]).isEmpty) + let frames = try framer.ingest(frame.suffix(from: 6)) + #expect(frames == [payload]) + } + + @Test func emitsMultipleFramesFromOneChunk() throws { + let a = Data("first".utf8) + let b = Data("second".utf8) + var combined = try PortholeFraming.encode(a) + try combined.append(PortholeFraming.encode(b)) + + var framer = PortholeFramer() + #expect(try framer.ingest(combined) == [a, b]) + } + + @Test func rejectsOversizeLengthPrefixOnDecode() { + let bogusLength = UInt32(PortholeFraming.maximumFrameByteCount + 1).bigEndian + var header = Data() + withUnsafeBytes(of: bogusLength) { header.append(contentsOf: $0) } + + var framer = PortholeFramer() + #expect(throws: PortholeError.self) { + try framer.ingest(header) + } + } + + @Test func rejectsOversizePayloadOnEncode() { + let tooBig = Data(count: PortholeFraming.maximumFrameByteCount + 1) + #expect(throws: PortholeError.self) { + try PortholeFraming.encode(tooBig) + } + } +} diff --git a/Shared/Porthole/PortholeCore/Tests/PortholeMessagesTests.swift b/Shared/Porthole/PortholeCore/Tests/PortholeMessagesTests.swift new file mode 100644 index 00000000..ae02ad2e --- /dev/null +++ b/Shared/Porthole/PortholeCore/Tests/PortholeMessagesTests.swift @@ -0,0 +1,92 @@ +import Foundation +@testable import PortholeCore +import Testing + +struct PortholeMessagesTests { + @Test func requestEnvelopeRoundTrips() throws { + let envelopes: [PortholeRequestEnvelope] = [ + .init(id: 1, request: .hello(HelloRequest(clientName: "cli"))), + .init(id: 2, request: .listConnectors), + .init(id: 3, request: .invokeAction( + ref: .init(connector: "app", action: "ping"), + parameters: ["message": "hi"], + )), + .init(id: 4, request: .query( + ref: .init(connector: "where", source: "year-report"), + query: PortholeQuery(filters: ["year": 2026], limit: 50), + )), + .init( + id: 5, + request: .subscribe(ref: .init(connector: "periscope", source: "live-events")), + ), + .init(id: 6, request: .unsubscribe(subscriptionID: 99)), + .init(id: 7, request: .ping), + ] + for envelope in envelopes { + #expect(try jsonRoundTrip(envelope) == envelope) + } + } + + @Test func responseEnvelopeRoundTrips() throws { + let manifest = ConnectorManifest( + connector: .init(id: "app", title: "App", summary: "App info", version: 1), + actions: [.init( + id: "ping", + title: "Ping", + summary: "Echoes", + parameters: .object([:]), + isDestructive: false, + )], + dataSources: [], + ) + let envelopes: [PortholeResponseEnvelope] = [ + .init( + requestID: 1, + response: .helloReply(HelloReply( + appName: "Where", + bundleID: "com.stuff.where", + deviceName: "iPhone", + )), + ), + .init(requestID: 2, response: .connectors([manifest])), + .init(requestID: 3, response: .actionResult(["ok": true])), + .init( + requestID: 4, + response: .queryResult(PortholePage(rows: [["a": 1]], nextCursor: "c2")), + ), + .init(requestID: 5, response: .subscribed(subscriptionID: 7)), + .init(requestID: nil, response: .event(subscriptionID: 7, value: ["tick": 1])), + .init(requestID: 6, response: .failure(.notPaired)), + .init(requestID: 7, response: .pong), + ] + for envelope in envelopes { + #expect(try jsonRoundTrip(envelope) == envelope) + } + } + + @Test func errorsRoundTripWithAssociatedValues() throws { + let errors: [PortholeError] = [ + .protocolMismatch(theirs: 2, ours: 1), + .connectorNotFound("ghost"), + .actionNotFound(.init(connector: "app", action: "nope")), + .sourceNotFound(.init(connector: "app", source: "nope")), + .invalidParameters("`year` is missing"), + .subscriptionNotSupported(.init(connector: "app", source: "app-info")), + .handlerFailed("boom"), + .notPaired, + .pairingFailed(.wrongCode), + .frameTooLarge(1234), + ] + for error in errors { + #expect(try jsonRoundTrip(error) == error) + } + } + + @Test func identifiersEncodeAsBareStrings() throws { + let ref = PortholeActionRef(connector: "where", action: "scan-data-issues") + let data = try JSONEncoder().encode(ref) + let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] + #expect(object?["connector"] as? String == "where") + #expect(object?["action"] as? String == "scan-data-issues") + } +} diff --git a/Shared/Porthole/PortholeCore/Tests/PortholeSchemaTests.swift b/Shared/Porthole/PortholeCore/Tests/PortholeSchemaTests.swift new file mode 100644 index 00000000..028c974d --- /dev/null +++ b/Shared/Porthole/PortholeCore/Tests/PortholeSchemaTests.swift @@ -0,0 +1,86 @@ +import Foundation +@testable import PortholeCore +import Testing + +struct PortholeSchemaTests { + @Test func objectSchemaRendersJSONSchema() { + let schema = PortholeSchema.object( + ["year": .integer("Gregorian year")], + required: ["year"], + summary: "A year report request", + ) + let json = schema.jsonSchema() + #expect(json["type"]?.stringValue == "object") + #expect(json["description"]?.stringValue == "A year report request") + #expect(json["required"] == .array(["year"])) + #expect(json["properties"]?["year"]?["type"]?.stringValue == "integer") + #expect(json["properties"]?["year"]?["description"]?.stringValue == "Gregorian year") + } + + @Test func dataAndDateRenderStringEncodings() { + #expect(PortholeSchema.data().jsonSchema()["type"]?.stringValue == "string") + #expect(PortholeSchema.data().jsonSchema()["contentEncoding"]?.stringValue == "base64") + #expect(PortholeSchema.date().jsonSchema()["type"]?.stringValue == "string") + #expect(PortholeSchema.date().jsonSchema()["format"]?.stringValue == "date-time") + } + + @Test func stringEnumRendersAllowedValues() { + let schema = PortholeSchema.string("level", allowedValues: ["info", "warning"]) + #expect(schema.jsonSchema()["enum"] == .array(["info", "warning"])) + } + + @Test func arraySchemaRoundTripsWithRecursiveItems() throws { + let schema = PortholeSchema.array(of: .object(["id": .string()], required: ["id"])) + let restored = try jsonRoundTrip(schema) + #expect(restored == schema) + #expect(restored.items?.kind == .object) + #expect(restored.jsonSchema()["items"]?["type"]?.stringValue == "object") + } + + @Test func validateAcceptsWellFormedObject() throws { + let schema = PortholeSchema.object( + ["year": .integer(), "note": .string()], + required: ["year"], + ) + try schema.validate(["year": 2026, "note": "hi"]) + // Missing optional member is fine; unknown members are ignored. + try schema.validate(["year": 2026, "extra": true]) + } + + @Test func validateRejectsMissingRequiredMember() { + let schema = PortholeSchema.object(["year": .integer()], required: ["year"]) + #expect(throws: PortholeError.self) { + try schema.validate(["note": "hi"]) + } + } + + @Test func validateRejectsWrongScalarType() { + let schema = PortholeSchema.object(["year": .integer()], required: ["year"]) + #expect(throws: PortholeError.self) { + try schema.validate(["year": "not a number"]) + } + } + + @Test func validateEnforcesAllowedValues() throws { + let schema = PortholeSchema.object( + ["level": .string(allowedValues: ["info", "warning"])], + required: ["level"], + ) + try schema.validate(["level": "warning"]) + #expect(throws: PortholeError.self) { try schema.validate(["level": "verbose"]) } + } + + @Test func validateRecursesIntoArrayElements() throws { + let schema = PortholeSchema.object( + ["ids": .array(of: .integer())], + required: ["ids"], + ) + try schema.validate(["ids": .array([1, 2, 3])]) + #expect(throws: PortholeError.self) { try schema.validate(["ids": .array([1, "two"])]) } + } + + @Test func validateAcceptsWholeNumberedDoubleAsInteger() throws { + let schema = PortholeSchema.object(["n": .integer()], required: ["n"]) + try schema.validate(["n": .double(5.0)]) + } +} diff --git a/Shared/Porthole/PortholeCore/Tests/PortholeSecureChannelTests.swift b/Shared/Porthole/PortholeCore/Tests/PortholeSecureChannelTests.swift new file mode 100644 index 00000000..87b2f53e --- /dev/null +++ b/Shared/Porthole/PortholeCore/Tests/PortholeSecureChannelTests.swift @@ -0,0 +1,102 @@ +import CryptoKit +import Foundation +@testable import PortholeCore +import Testing + +struct PortholeSecureChannelTests { + @Test func roundTripsPlaintextInBothDirections() async throws { + let key = SymmetricKey(size: .bits256) + let (rawClient, rawDevice) = LoopbackTransport.makePair() + let client = PortholeSecureChannel(wrapping: rawClient, key: key, role: .client) + let device = PortholeSecureChannel(wrapping: rawDevice, key: key, role: .device) + + try await client.send(Data("request".utf8)) + #expect(try await firstFrame(from: device) == Data("request".utf8)) + + try await device.send(Data("response".utf8)) + #expect(try await firstFrame(from: client) == Data("response".utf8)) + } + + @Test func multipleFramesDecryptInOrder() async throws { + let key = SymmetricKey(size: .bits256) + let (rawClient, rawDevice) = LoopbackTransport.makePair() + let client = PortholeSecureChannel(wrapping: rawClient, key: key, role: .client) + let device = PortholeSecureChannel(wrapping: rawDevice, key: key, role: .device) + + for index in 0 ..< 5 { + try await client.send(Data("f\(index)".utf8)) + } + + try await withTimeout { + var iterator = device.incoming.makeAsyncIterator() + for index in 0 ..< 5 { + let frame = try await iterator.next() + #expect(frame == Data("f\(index)".utf8)) + } + } + } + + @Test func aTamperedFrameFailsToOpenAndEndsTheStream() async throws { + let key = SymmetricKey(size: .bits256) + let (rawClient, rawDevice) = LoopbackTransport.makePair() + let device = PortholeSecureChannel(wrapping: rawDevice, key: key, role: .device) + + // Feed the device raw garbage instead of a validly sealed frame. + try await rawClient.send(Data("not a valid sealed frame".utf8)) + + await #expect(throws: (any Error).self) { + try await withTimeout { + var iterator = device.incoming.makeAsyncIterator() + _ = try await iterator.next() + } + } + } + + @Test func aReplayedFrameIsRejected() async throws { + let key = SymmetricKey(size: .bits256) + + // Capture one genuinely-sealed frame (counter 0) from a client channel. + let (rawClient, rawDeviceObserver) = LoopbackTransport.makePair() + let client = PortholeSecureChannel(wrapping: rawClient, key: key, role: .client) + try await client.send(Data("payload".utf8)) + let sealed = try await firstFrame(from: rawDeviceObserver) + + // Deliver it twice to a fresh device channel: the first opens (counter 0), + // the replay expects counter 1 and must fail. + let (rawInjector, rawDevice) = LoopbackTransport.makePair() + let device = PortholeSecureChannel(wrapping: rawDevice, key: key, role: .device) + + try await withTimeout { + var iterator = device.incoming.makeAsyncIterator() + try await rawInjector.send(sealed) + #expect(try await iterator.next() == Data("payload".utf8)) + + try await rawInjector.send(sealed) + await #expect(throws: (any Error).self) { + _ = try await iterator.next() + } + } + } + + @Test func theWrongKeyFailsToOpen() async throws { + let (rawClient, rawDevice) = LoopbackTransport.makePair() + let client = PortholeSecureChannel( + wrapping: rawClient, + key: SymmetricKey(size: .bits256), + role: .client, + ) + let device = PortholeSecureChannel( + wrapping: rawDevice, + key: SymmetricKey(size: .bits256), + role: .device, + ) + + try await client.send(Data("secret".utf8)) + await #expect(throws: (any Error).self) { + try await withTimeout { + var iterator = device.incoming.makeAsyncIterator() + _ = try await iterator.next() + } + } + } +} diff --git a/Shared/Porthole/PortholeCore/Tests/PortholeValueTests.swift b/Shared/Porthole/PortholeCore/Tests/PortholeValueTests.swift new file mode 100644 index 00000000..40a5b17b --- /dev/null +++ b/Shared/Porthole/PortholeCore/Tests/PortholeValueTests.swift @@ -0,0 +1,80 @@ +import Foundation +@testable import PortholeCore +import Testing + +struct PortholeValueTests { + @Test func scalarsRoundTrip() throws { + #expect(try wireRoundTrip(.null) == .null) + #expect(try wireRoundTrip(.bool(true)) == .bool(true)) + #expect(try wireRoundTrip(.bool(false)) == .bool(false)) + #expect(try wireRoundTrip(.int(42)) == .int(42)) + #expect(try wireRoundTrip(.int(-7)) == .int(-7)) + #expect(try wireRoundTrip(.double(1.5)) == .double(1.5)) + #expect(try wireRoundTrip(.string("hi")) == .string("hi")) + } + + @Test func dataRoundTripsAsTaggedObject() throws { + let value = PortholeValue.data(Data([0x00, 0x01, 0xFF, 0x10])) + #expect(try wireRoundTrip(value) == value) + + let shape = try jsonObject(value) + #expect(shape.count == 1) + #expect(shape["$data"] as? String == Data([0x00, 0x01, 0xFF, 0x10]).base64EncodedString()) + } + + @Test func dateRoundTripsAtMillisecondPrecision() throws { + let date = Date(timeIntervalSince1970: 1_700_000_000.123) + let value = PortholeValue.date(date) + let restored = try wireRoundTrip(value) + let restoredDate = try #require(restored.dateValue) + #expect(abs(restoredDate.timeIntervalSince1970 - date.timeIntervalSince1970) < 0.001) + + let shape = try jsonObject(value) + #expect(shape.count == 1) + #expect(shape["$date"] is String) + } + + @Test func nestedContainersRoundTrip() throws { + let value = PortholeValue.object([ + "name": "Where", + "count": 3, + "ratio": 0.25, + "tags": .array(["a", "b"]), + "nested": .object(["flag": true, "empty": .null]), + ]) + #expect(try wireRoundTrip(value) == value) + } + + @Test func integerAccessorAcceptsWholeDouble() { + #expect(PortholeValue.int(5).intValue == 5) + #expect(PortholeValue.double(5.0).intValue == 5) + #expect(PortholeValue.double(5.5).intValue == nil) + #expect(PortholeValue.string("5").intValue == nil) + } + + @Test func accessorsAndSubscripts() { + let value: PortholeValue = ["items": .array([1, 2, 3]), "label": "hi"] + #expect(value["label"]?.stringValue == "hi") + #expect(value["items"]?[1]?.intValue == 2) + #expect(value["missing"] == nil) + #expect(value["items"]?[9] == nil) + #expect(value.objectValue?.count == 2) + } + + @Test func literalConformances() { + let value: PortholeValue = [ + "n": nil, + "b": true, + "i": 7, + "d": 1.5, + "s": "x", + "arr": [1, 2], + ] + #expect(value["n"] == .null) + #expect(value["b"] == .bool(true)) + #expect(value["i"] == .int(7)) + #expect(value["d"] == .double(1.5)) + #expect(value["s"] == .string("x")) + #expect(value["arr"] == .array([.int(1), .int(2)])) + } +} diff --git a/Shared/Porthole/PortholeKit/AGENTS.md b/Shared/Porthole/PortholeKit/AGENTS.md new file mode 100644 index 00000000..8099068c --- /dev/null +++ b/Shared/Porthole/PortholeKit/AGENTS.md @@ -0,0 +1,57 @@ +# PortholeKit – Module Shape + +PortholeKit is the device-side Porthole runtime: the `Porthole` composition +root, the `PortholeConnector` protocol (+ `PortholeAction` / `PortholeDataSource`), +the connector registry, the per-connection session router, the built-in `app` +connector, and (in the network layer) the Bonjour listener and pairing manager. +See [`README.md`](README.md) for usage and how to write a connector. + +This file complements the root [`AGENTS.md`](../../../AGENTS.md) — read that +first. + +## Scope & dependencies + +- Depends only on **PortholeCore** (+ `Network`/`UIKit` from the SDK). It must + **not** depend on PeriscopeCore or any app module — the Periscope, SwiftData, + and Where connectors are *separate* modules that depend on PortholeKit, never + the reverse. Logging is PortholeKit's own `os.Logger` (`PortholeLog`), not + Periscope. +- Built-in connectors live in `Sources/Connectors/`. `app` (AppInfoConnector) + ships here; `ui`, `files`, `notifications`, `permissions` are added in their + own steps and self-register in `Porthole.init`. iOS-only connectors are + wrapped in `#if canImport(UIKit)`. + +## Invariants + +- **One `Porthole` per app, created at the composition root and injected** — no + singleton. It is `@MainActor`/`@Observable`; UI binds to `state`. +- **A duplicate connector id is a programmer error** (`assertionFailure` in + debug, ignored in release) — connector ids are a fixed curated set. +- **The runtime validates parameters/filters against the connector's + `PortholeSchema` before dispatch**; a handler `throw` becomes + `.failure(.handlerFailed)`, never a crash and never a swallowed error. +- **Dispatch runs off a `Sendable` snapshot** (`ResolvedConnectors`) built once + per session — derive, don't re-resolve per request or hop to the main actor. +- **Subscriptions are bounded** (drop-oldest at 256) so a fast producer can't + grow memory without limit; every subscription's tasks are cancelled when it's + unsubscribed or the connection closes (every start has a stop). +- **The request router is transport-agnostic** and tested over + `LoopbackTransport` via `attach(transport:)`; keep the real network transport + (`NWConnectionTransport`) and Bonjour listener (`PortholeServer`) thin adapters + so the tested core carries the logic. +- **The pairing/session handshake (`DevicePairingManager`) is pure of `Network` + types** so it runs over loopback in tests: one human code active at a time, + wrong guesses accumulate and burn it after 3, it expires after 120 s, and the + code is published (awaited) *before* the challenge is sent. A session + re-derives a fresh key from the stored PSK; the `PortholeSecureChannel` then + continues the *same* frame reader (see `TransportFrameReader`) so the plaintext + handshake and the encrypted session share one connection without a second + iterator. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PortholeKitTests`). Drive sessions in-process via `attach(transport:)` and the +`TestSessionClient` helper; wait on delivered events, never sleep. The +duplicate-id assertion is intentionally not unit-tested (it traps in debug, as +designed for a programmer error). diff --git a/Shared/Porthole/PortholeKit/README.md b/Shared/Porthole/PortholeKit/README.md new file mode 100644 index 00000000..a322c5b8 --- /dev/null +++ b/Shared/Porthole/PortholeKit/README.md @@ -0,0 +1,49 @@ +# PortholeKit + +PortholeKit is the device-side runtime of the [Porthole](../) suite — the +framework an iOS app embeds to expose its data and operations to a paired Mac. +An app creates one `Porthole`, registers connectors, and calls `start()`; the +runtime advertises over Bonjour, runs the pairing/session handshake, and routes +requests to the connectors. + +## Using it + +```swift +let porthole = Porthole(configuration: .init(appName: "Where")) +porthole.register(WhereConnector(services: services)) +try porthole.start() // advertises + accepts connections +// porthole.state drives the pairing UI (see PortholeKitUI) +``` + +`Porthole` is `@MainActor` + `@Observable`; bind UI to `porthole.state` +(`isAdvertising`, `pendingPairingCode`, `pairedHosts`, `activeSessionCount`). +The built-in `app` connector (app/device info + `ping`) is registered +automatically. + +## Writing a connector + +Conform to `PortholeConnector` and return `PortholeAction`s and +`PortholeDataSource`s. Each action has a `PortholeSchema` for its parameters +(validated by the runtime before your handler runs) and an `isDestructive` +flag; each data source has a paginated `fetch` and, optionally, a `subscribe` +that vends a live `AsyncStream` of rows. + +## Host app requirements + +Advertising needs no user prompt. A host app should still declare, in its +Info.plist, that it uses Bonjour if it also *browses* (it normally doesn't): + +- `NSBonjourServices` = `["_porthole._tcp"]` +- `NSLocalNetworkUsageDescription` = a short reason string + +Gate `start()` behind `#if DEBUG` (or a developer toggle) — Porthole ships in +release inert, but you rarely want it advertising in a shipping build. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PortholeKitTests`). The `@_spi(Testing)` `Porthole.attach(transport:)` seam +serves a full session over a `LoopbackTransport`, so the request router, +schema validation, dispatch, and subscription streaming are all exercised +in-process with no networking. The network layer (`PortholeServer`, +`NWConnectionTransport`) is deliberately thin over that tested core. diff --git a/Shared/Porthole/PortholeKit/Sources/ConnectorRegistry.swift b/Shared/Porthole/PortholeKit/Sources/ConnectorRegistry.swift new file mode 100644 index 00000000..6dfb1800 --- /dev/null +++ b/Shared/Porthole/PortholeKit/Sources/ConnectorRegistry.swift @@ -0,0 +1,81 @@ +import Foundation +import PortholeCore + +/// An immutable, `Sendable` dispatch snapshot the session router uses off the +/// main actor: the advertised manifests plus ref → handler lookup tables. Built +/// once per session from the registry (derive, don't re-derive) so request +/// dispatch never hops back to the main actor to resolve a connector. +struct ResolvedConnectors { + var manifests: [ConnectorManifest] + var actions: [PortholeActionRef: PortholeAction] + var dataSources: [PortholeDataSourceRef: PortholeDataSource] + + func action( + _ ref: PortholeActionRef, + hasConnector: (PortholeConnectorID) -> Bool, + ) -> Result { + if let action = actions[ref] { return .success(action) } + return .failure(hasConnector(ref.connector) ? .actionNotFound(ref) : + .connectorNotFound(ref.connector)) + } + + func dataSource( + _ ref: PortholeDataSourceRef, + hasConnector: (PortholeConnectorID) -> Bool, + ) -> Result { + if let source = dataSources[ref] { return .success(source) } + return .failure(hasConnector(ref.connector) ? .sourceNotFound(ref) : + .connectorNotFound(ref.connector)) + } + + var connectorIDs: Set { + Set(manifests.map(\.connector.id)) + } +} + +/// Holds the app's registered connectors on the main actor and resolves them +/// into a `Sendable` dispatch snapshot. A duplicate connector id is a programmer +/// error (`assertionFailure` in debug, ignored in release) — connector ids are a +/// fixed, curated set. +@MainActor +final class ConnectorRegistry { + private var connectors: [PortholeConnectorID: any PortholeConnector] = [:] + private var order: [PortholeConnectorID] = [] + + func register(_ connector: some PortholeConnector) { + let id = connector.descriptor.id + guard connectors[id] == nil else { + assertionFailure("Duplicate Porthole connector id `\(id)`") + PortholeLog.runtime + .error("Ignoring duplicate Porthole connector id \(id.rawValue, privacy: .public)") + return + } + connectors[id] = connector + order.append(id) + } + + func resolve() -> ResolvedConnectors { + var manifests: [ConnectorManifest] = [] + var actions: [PortholeActionRef: PortholeAction] = [:] + var dataSources: [PortholeDataSourceRef: PortholeDataSource] = [:] + + for id in order { + guard let connector = connectors[id] else { continue } + let connectorActions = connector.actions() + let connectorSources = connector.dataSources() + for action in connectorActions { + actions[PortholeActionRef(connector: id, action: action.descriptor.id)] = action + } + for source in connectorSources { + dataSources[PortholeDataSourceRef(connector: id, source: source.descriptor.id)] = + source + } + manifests.append(ConnectorManifest( + connector: connector.descriptor, + actions: connectorActions.map(\.descriptor), + dataSources: connectorSources.map(\.descriptor), + )) + } + return ResolvedConnectors(manifests: manifests, actions: actions, dataSources: dataSources) + } +} diff --git a/Shared/Porthole/PortholeKit/Sources/Connectors/AppInfoConnector.swift b/Shared/Porthole/PortholeKit/Sources/Connectors/AppInfoConnector.swift new file mode 100644 index 00000000..f34cbdc7 --- /dev/null +++ b/Shared/Porthole/PortholeKit/Sources/Connectors/AppInfoConnector.swift @@ -0,0 +1,91 @@ +import Foundation +import PortholeCore + +/// The always-present built-in connector (id `app`): a one-row `app-info` data +/// source describing the running app/device, and a `ping` action that echoes its +/// input with a device timestamp — the end-to-end smoke test for every surface. +public final class AppInfoConnector: PortholeConnector { + public let descriptor = PortholeConnectorDescriptor( + id: "app", + title: "App", + summary: "Basic facts about the running app and device, plus a connectivity ping.", + version: 1, + ) + + private let appName: String + private let bundleID: String + private let processStart = Date() + + public init(appName: String, bundleID: String) { + self.appName = appName + self.bundleID = bundleID + } + + public func actions() -> [PortholeAction] { + [ + PortholeAction( + descriptor: PortholeActionDescriptor( + id: "ping", + title: "Ping", + summary: "Echoes the given message back with the device's current timestamp. Use to confirm the bridge is live.", + parameters: .object(["message": .string("Any text to echo back")]), + isDestructive: false, + ), + handler: { parameters in + .object([ + "message": parameters["message"] ?? .null, + "timestamp": .date(Date()), + ]) + }, + ), + ] + } + + public func dataSources() -> [PortholeDataSource] { + let appName = appName + let bundleID = bundleID + let processStart = processStart + return [ + PortholeDataSource( + descriptor: PortholeDataSourceDescriptor( + id: "app-info", + title: "App info", + summary: "A single row describing the running app, device, and OS.", + rowSchema: .object([ + "appName": .string(), + "bundleID": .string(), + "version": .string(), + "build": .string(), + "deviceName": .string(), + "deviceModel": .string(), + "systemName": .string(), + "systemVersion": .string(), + "locale": .string(), + "uptimeSeconds": .number(), + "isDebugBuild": .boolean(), + ]), + filters: .object([:]), + supportsSubscription: false, + ), + fetch: { _ in + let info = Bundle.main.infoDictionary + let row: PortholeValue = .object([ + "appName": .string(appName), + "bundleID": .string(bundleID), + "version": .string(info?["CFBundleShortVersionString"] as? String ?? + "unknown"), + "build": .string(info?["CFBundleVersion"] as? String ?? "unknown"), + "deviceName": .string(DeviceInfo.deviceName), + "deviceModel": .string(DeviceInfo.model), + "systemName": .string(DeviceInfo.systemName), + "systemVersion": .string(DeviceInfo.systemVersion), + "locale": .string(Locale.current.identifier), + "uptimeSeconds": .double(Date().timeIntervalSince(processStart)), + "isDebugBuild": .bool(DeviceInfo.isDebugBuild), + ]) + return PortholePage(rows: [row], nextCursor: nil, totalCount: 1) + }, + ), + ] + } +} diff --git a/Shared/Porthole/PortholeKit/Sources/Connectors/FileBrowserConnector.swift b/Shared/Porthole/PortholeKit/Sources/Connectors/FileBrowserConnector.swift new file mode 100644 index 00000000..db869f87 --- /dev/null +++ b/Shared/Porthole/PortholeKit/Sources/Connectors/FileBrowserConnector.swift @@ -0,0 +1,219 @@ +import Foundation +import PortholeCore + +/// The built-in `files` connector: browse the app's sandbox roots (and any +/// configured App Group containers) and read small files. Read-only in v1, with +/// a mandatory path-traversal guard. Auto-registered by ``Porthole``. +public final class FileBrowserConnector: PortholeConnector { + /// A named, browsable root directory. + struct Root { + let name: String + let url: URL + } + + public let descriptor = PortholeConnectorDescriptor( + id: "files", + title: "Files", + summary: "Browse the app's sandbox directories and App Group containers, and read small files.", + version: 1, + ) + + private let roots: [Root] + + /// The standard sandbox roots plus one per App Group container id. + public convenience init(appGroupIdentifiers: [String]) { + self.init(roots: Self.standardRoots(appGroupIdentifiers: appGroupIdentifiers)) + } + + /// Testing seam: browse an explicit set of roots. + @_spi(Testing) + public convenience init(roots: [String: URL]) { + self.init(roots: roots.sorted { $0.key < $1.key }.map { Root(name: $0.key, url: $0.value) }) + } + + private init(roots: [Root]) { + self.roots = roots + } + + private static func standardRoots(appGroupIdentifiers: [String]) -> [Root] { + let manager = FileManager.default + var roots: [Root] = [] + func add(_ name: String, _ directory: FileManager.SearchPathDirectory) { + if let url = manager.urls(for: directory, in: .userDomainMask).first { + roots.append(Root(name: name, url: url)) + } + } + add("documents", .documentDirectory) + add("library", .libraryDirectory) + add("application-support", .applicationSupportDirectory) + add("caches", .cachesDirectory) + roots.append(Root(name: "tmp", url: URL(fileURLWithPath: NSTemporaryDirectory()))) + for group in appGroupIdentifiers { + if let url = manager.containerURL(forSecurityApplicationGroupIdentifier: group) { + roots.append(Root(name: group, url: url)) + } + } + return roots + } + + public func actions() -> [PortholeAction] { + let roots = roots + return [ + PortholeAction( + descriptor: PortholeActionDescriptor( + id: "read-file", + title: "Read file", + summary: "Read up to maxBytes of a file under a root, returned as data.", + parameters: .object([ + "root": .string("Root name", allowedValues: roots.map(\.name)), + "path": .string("Path relative to the root"), + "maxBytes": .integer("Maximum bytes to read (default 262144)"), + ], required: ["root", "path"]), + isDestructive: false, + ), + handler: { parameters in + try Self.readFile(parameters, roots: roots) + }, + ), + ] + } + + public func dataSources() -> [PortholeDataSource] { + let roots = roots + return [ + PortholeDataSource( + descriptor: PortholeDataSourceDescriptor( + id: "roots", + title: "Roots", + summary: "The browsable root directories.", + rowSchema: .object([ + "name": .string(), + "path": .string(), + "exists": .boolean(), + ]), + filters: .object([:]), + supportsSubscription: false, + ), + fetch: { _ in + let manager = FileManager.default + let rows = roots.map { root in + PortholeValue.object([ + "name": .string(root.name), + "path": .string(root.url.path), + "exists": .bool(manager.fileExists(atPath: root.url.path)), + ]) + } + return PortholePage(rows: rows, totalCount: rows.count) + }, + ), + PortholeDataSource( + descriptor: PortholeDataSourceDescriptor( + id: "entries", + title: "Entries", + summary: "The directory entries at a path under a root.", + rowSchema: .object([ + "name": .string(), + "isDirectory": .boolean(), + "size": .integer(), + "modifiedAt": .date(), + ]), + filters: .object([ + "root": .string("Root name", allowedValues: roots.map(\.name)), + "path": .string("Path relative to the root (default the root)"), + ], required: ["root"]), + supportsSubscription: false, + ), + fetch: { query in + try Self.entries(query.filters, roots: roots) + }, + ), + ] + } + + // MARK: - Implementation + + private static func resolve( + root name: String, + path: String, + roots: [Root], + ) throws -> (root: Root, url: URL) { + guard let root = roots.first(where: { $0.name == name }) else { + throw PortholeError.invalidParameters("Unknown root `\(name)`") + } + let base = root.url.standardizedFileURL.resolvingSymlinksInPath() + let target = base.appendingPathComponent(path).standardizedFileURL.resolvingSymlinksInPath() + // Traversal guard: the resolved target must stay within the root. + let basePath = base.path.hasSuffix("/") ? base.path : base.path + "/" + guard target.path == base.path || target.path.hasPrefix(basePath) else { + throw PortholeError.invalidParameters("Path escapes the root") + } + return (root, target) + } + + private static func entries(_ filters: PortholeValue, roots: [Root]) throws -> PortholePage { + let name = filters["root"]?.stringValue ?? "" + let path = filters["path"]?.stringValue ?? "" + let (_, url) = try resolve(root: name, path: path, roots: roots) + + let manager = FileManager.default + var isDirectory: ObjCBool = false + guard manager.fileExists(atPath: url.path, isDirectory: &isDirectory), + isDirectory.boolValue + else { + throw PortholeError.handlerFailed("Not a directory: \(path)") + } + let contents = try manager.contentsOfDirectory( + at: url, + includingPropertiesForKeys: [ + .isDirectoryKey, + .fileSizeKey, + .contentModificationDateKey, + ], + ) + let rows = contents.sorted { $0.lastPathComponent < $1.lastPathComponent } + .map { entry -> PortholeValue in + let values = try? entry.resourceValues(forKeys: [ + .isDirectoryKey, + .fileSizeKey, + .contentModificationDateKey, + ]) + var object: [String: PortholeValue] = [ + "name": .string(entry.lastPathComponent), + "isDirectory": .bool(values?.isDirectory ?? false), + ] + if let size = values?.fileSize { object["size"] = .int(Int64(size)) } + if let modified = values? + .contentModificationDate { object["modifiedAt"] = .date(modified) } + return .object(object) + } + return PortholePage(rows: rows, totalCount: rows.count) + } + + private static func readFile( + _ parameters: PortholeValue, + roots: [Root], + ) throws -> PortholeValue { + let name = parameters["root"]?.stringValue ?? "" + let path = parameters["path"]?.stringValue ?? "" + let maxBytes = Int(parameters["maxBytes"]?.intValue ?? 262_144) + let (_, url) = try resolve(root: name, path: path, roots: roots) + + let manager = FileManager.default + var isDirectory: ObjCBool = false + guard manager.fileExists(atPath: url.path, isDirectory: &isDirectory), + !isDirectory.boolValue + else { + throw PortholeError.handlerFailed("Not a readable file: \(path)") + } + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + let data = try handle.read(upToCount: max(0, maxBytes)) ?? Data() + let attributes = try? manager.attributesOfItem(atPath: url.path) + let totalSize = (attributes?[.size] as? Int) ?? data.count + return .object([ + "data": .data(data), + "truncated": .bool(data.count < totalSize), + "totalSize": .int(Int64(totalSize)), + ]) + } +} diff --git a/Shared/Porthole/PortholeKit/Sources/Connectors/NotificationsConnector.swift b/Shared/Porthole/PortholeKit/Sources/Connectors/NotificationsConnector.swift new file mode 100644 index 00000000..19b94b90 --- /dev/null +++ b/Shared/Porthole/PortholeKit/Sources/Connectors/NotificationsConnector.swift @@ -0,0 +1,287 @@ +#if canImport(UserNotifications) + import Foundation + import PortholeCore + import UserNotifications + + /// A settings snapshot, minus the framework types, so it's `Sendable` and + /// scriptable in tests. + public struct NotificationSettingsSnapshot: Sendable, Equatable { + public var authorizationStatus: String + public var alertSetting: String + public var badgeSetting: String + public var soundSetting: String + public var lockScreenSetting: String + + public init( + authorizationStatus: String, + alertSetting: String, + badgeSetting: String, + soundSetting: String, + lockScreenSetting: String, + ) { + self.authorizationStatus = authorizationStatus + self.alertSetting = alertSetting + self.badgeSetting = badgeSetting + self.soundSetting = soundSetting + self.lockScreenSetting = lockScreenSetting + } + } + + public struct PendingNotificationInfo: Sendable, Equatable { + public var identifier: String + public var title: String + public var body: String + public var triggerDescription: String + public var nextFireDate: Date? + + public init( + identifier: String, + title: String, + body: String, + triggerDescription: String, + nextFireDate: Date?, + ) { + self.identifier = identifier + self.title = title + self.body = body + self.triggerDescription = triggerDescription + self.nextFireDate = nextFireDate + } + } + + public struct DeliveredNotificationInfo: Sendable, Equatable { + public var identifier: String + public var title: String + public var body: String + public var date: Date + + public init(identifier: String, title: String, body: String, date: Date) { + self.identifier = identifier + self.title = title + self.body = body + self.date = date + } + } + + /// The notification-center surface the connector needs, so tests inject a + /// fake instead of the real `UNUserNotificationCenter`. + public protocol NotificationCenterProviding: Sendable { + func settingsSnapshot() async -> NotificationSettingsSnapshot + func pendingRequests() async -> [PendingNotificationInfo] + func deliveredNotifications() async -> [DeliveredNotificationInfo] + func removePending(identifiers: [String]) async + } + + /// The built-in `notifications` connector: authorization/settings, pending and + /// delivered notifications, and a remove-pending action. Auto-registered by + /// ``Porthole``. + public final class NotificationsConnector: PortholeConnector { + public let descriptor = PortholeConnectorDescriptor( + id: "notifications", + title: "Notifications", + summary: "Notification authorization and settings, pending and delivered notifications, and removing pending ones.", + version: 1, + ) + + private let center: NotificationCenterProviding + + public init() { + center = SystemNotificationCenter() + } + + @_spi(Testing) + public init(center: NotificationCenterProviding) { + self.center = center + } + + public func actions() -> [PortholeAction] { + let center = center + return [ + PortholeAction( + descriptor: PortholeActionDescriptor( + id: "remove-pending", + title: "Remove pending", + summary: "Cancel pending notification requests by identifier.", + parameters: .object( + ["identifiers": .array(of: .string())], + required: ["identifiers"], + ), + isDestructive: true, + ), + handler: { parameters in + let identifiers = (parameters["identifiers"]?.arrayValue ?? []) + .compactMap(\.stringValue) + await center.removePending(identifiers: identifiers) + return .object(["removed": .int(Int64(identifiers.count))]) + }, + ), + ] + } + + public func dataSources() -> [PortholeDataSource] { + let center = center + return [ + PortholeDataSource( + descriptor: PortholeDataSourceDescriptor( + id: "settings", + title: "Settings", + summary: "A single row with authorization status and per-kind settings.", + rowSchema: .object([ + "authorizationStatus": .string(), + "alertSetting": .string(), + "badgeSetting": .string(), + "soundSetting": .string(), + "lockScreenSetting": .string(), + ]), + filters: .object([:]), + supportsSubscription: false, + ), + fetch: { _ in + let snapshot = await center.settingsSnapshot() + return PortholePage(rows: [.object([ + "authorizationStatus": .string(snapshot.authorizationStatus), + "alertSetting": .string(snapshot.alertSetting), + "badgeSetting": .string(snapshot.badgeSetting), + "soundSetting": .string(snapshot.soundSetting), + "lockScreenSetting": .string(snapshot.lockScreenSetting), + ])], totalCount: 1) + }, + ), + PortholeDataSource( + descriptor: PortholeDataSourceDescriptor( + id: "pending", + title: "Pending", + summary: "Scheduled notification requests not yet delivered.", + rowSchema: .object([ + "identifier": .string(), + "title": .string(), + "body": .string(), + "trigger": .string(), + "nextFireDate": .date(), + ]), + filters: .object([:]), + supportsSubscription: false, + ), + fetch: { _ in + let rows = await center.pendingRequests().map { request -> PortholeValue in + var object: [String: PortholeValue] = [ + "identifier": .string(request.identifier), + "title": .string(request.title), + "body": .string(request.body), + "trigger": .string(request.triggerDescription), + ] + if let fireDate = request + .nextFireDate { object["nextFireDate"] = .date(fireDate) } + return .object(object) + } + return PortholePage(rows: rows, totalCount: rows.count) + }, + ), + PortholeDataSource( + descriptor: PortholeDataSourceDescriptor( + id: "delivered", + title: "Delivered", + summary: "Notifications currently in Notification Center.", + rowSchema: .object([ + "identifier": .string(), + "title": .string(), + "body": .string(), + "date": .date(), + ]), + filters: .object([:]), + supportsSubscription: false, + ), + fetch: { _ in + let rows = await center.deliveredNotifications() + .map { notification -> PortholeValue in + .object([ + "identifier": .string(notification.identifier), + "title": .string(notification.title), + "body": .string(notification.body), + "date": .date(notification.date), + ]) + } + return PortholePage(rows: rows, totalCount: rows.count) + }, + ), + ] + } + } + + /// The production ``NotificationCenterProviding`` over `UNUserNotificationCenter`. + struct SystemNotificationCenter: NotificationCenterProviding { + private var center: UNUserNotificationCenter { + .current() + } + + func settingsSnapshot() async -> NotificationSettingsSnapshot { + let settings = await center.notificationSettings() + return NotificationSettingsSnapshot( + authorizationStatus: Self.string(settings.authorizationStatus), + alertSetting: Self.string(settings.alertSetting), + badgeSetting: Self.string(settings.badgeSetting), + soundSetting: Self.string(settings.soundSetting), + lockScreenSetting: Self.string(settings.lockScreenSetting), + ) + } + + func pendingRequests() async -> [PendingNotificationInfo] { + await center.pendingNotificationRequests().map { request in + PendingNotificationInfo( + identifier: request.identifier, + title: request.content.title, + body: request.content.body, + triggerDescription: request.trigger + .map { String(describing: type(of: $0)) } ?? "none", + nextFireDate: Self.nextFireDate(for: request.trigger), + ) + } + } + + func deliveredNotifications() async -> [DeliveredNotificationInfo] { + await center.deliveredNotifications().map { notification in + DeliveredNotificationInfo( + identifier: notification.request.identifier, + title: notification.request.content.title, + body: notification.request.content.body, + date: notification.date, + ) + } + } + + func removePending(identifiers: [String]) async { + center.removePendingNotificationRequests(withIdentifiers: identifiers) + } + + private static func nextFireDate(for trigger: UNNotificationTrigger?) -> Date? { + switch trigger { + case let calendar as UNCalendarNotificationTrigger: + calendar.nextTriggerDate() + case let interval as UNTimeIntervalNotificationTrigger: + interval.nextTriggerDate() + default: + nil + } + } + + private static func string(_ status: UNAuthorizationStatus) -> String { + switch status { + case .notDetermined: "notDetermined" + case .denied: "denied" + case .authorized: "authorized" + case .provisional: "provisional" + case .ephemeral: "ephemeral" + @unknown default: "unknown" + } + } + + private static func string(_ setting: UNNotificationSetting) -> String { + switch setting { + case .notSupported: "notSupported" + case .disabled: "disabled" + case .enabled: "enabled" + @unknown default: "unknown" + } + } + } +#endif diff --git a/Shared/Porthole/PortholeKit/Sources/Connectors/PermissionsConnector.swift b/Shared/Porthole/PortholeKit/Sources/Connectors/PermissionsConnector.swift new file mode 100644 index 00000000..baca206f --- /dev/null +++ b/Shared/Porthole/PortholeKit/Sources/Connectors/PermissionsConnector.swift @@ -0,0 +1,161 @@ +import Foundation +import PortholeCore +#if canImport(AVFoundation) + import AVFoundation +#endif +#if canImport(CoreLocation) + import CoreLocation +#endif +#if canImport(Photos) + import Photos +#endif +#if canImport(UserNotifications) + import UserNotifications +#endif + +/// One permission's current authorization status. +public struct PermissionStatus: Sendable, Equatable { + public var permission: String + public var status: String + + public init(permission: String, status: String) { + self.permission = permission + self.status = status + } +} + +/// Reads permission statuses. A protocol so tests inject scripted values instead +/// of touching the real frameworks. +public protocol PermissionsReading: Sendable { + /// Reads every known permission's status. Must never trigger a prompt. + func statuses() async -> [PermissionStatus] +} + +/// The built-in `permissions` connector: a single row per permission with its +/// authorization status. Reading never prompts. Auto-registered by ``Porthole``. +public final class PermissionsConnector: PortholeConnector { + public let descriptor = PortholeConnectorDescriptor( + id: "permissions", + title: "Permissions", + summary: "The app's current authorization status for notifications, location, camera, microphone, and photos. Reading never prompts.", + version: 1, + ) + + private let reader: PermissionsReading + + public init() { + reader = SystemPermissionsReader() + } + + @_spi(Testing) + public init(reader: PermissionsReading) { + self.reader = reader + } + + public func dataSources() -> [PortholeDataSource] { + let reader = reader + return [ + PortholeDataSource( + descriptor: PortholeDataSourceDescriptor( + id: "permissions", + title: "Permissions", + summary: "One row per permission with its authorization status.", + rowSchema: .object(["permission": .string(), "status": .string()]), + filters: .object([:]), + supportsSubscription: false, + ), + fetch: { _ in + let rows = await reader.statuses().map { status in + PortholeValue.object([ + "permission": .string(status.permission), + "status": .string(status.status), + ]) + } + return PortholePage(rows: rows, totalCount: rows.count) + }, + ), + ] + } +} + +/// The production reader: each framework is behind its own `canImport`, and every +/// read is a status query that never surfaces a prompt. +public struct SystemPermissionsReader: PermissionsReading { + public init() {} + + public func statuses() async -> [PermissionStatus] { + var statuses: [PermissionStatus] = [] + await statuses.append(notificationStatus()) + statuses.append(contentsOf: captureStatuses()) + statuses.append(locationStatus()) + statuses.append(photosStatus()) + return statuses + } + + private func notificationStatus() async -> PermissionStatus { + #if canImport(UserNotifications) + let settings = await UNUserNotificationCenter.current().notificationSettings() + let value = switch settings.authorizationStatus { + case .notDetermined: "notDetermined" + case .denied: "denied" + case .authorized: "authorized" + case .provisional: "provisional" + case .ephemeral: "ephemeral" + @unknown default: "unknown" + } + return PermissionStatus(permission: "notifications", status: value) + #else + return PermissionStatus(permission: "notifications", status: "unavailable") + #endif + } + + private func captureStatuses() -> [PermissionStatus] { + #if canImport(AVFoundation) + return [(AVMediaType.video, "camera"), (AVMediaType.audio, "microphone")] + .map { type, name in + let value = switch AVCaptureDevice.authorizationStatus(for: type) { + case .notDetermined: "notDetermined" + case .restricted: "restricted" + case .denied: "denied" + case .authorized: "authorized" + @unknown default: "unknown" + } + return PermissionStatus(permission: name, status: value) + } + #else + return [] + #endif + } + + private func locationStatus() -> PermissionStatus { + #if canImport(CoreLocation) + let value = switch CLLocationManager().authorizationStatus { + case .notDetermined: "notDetermined" + case .restricted: "restricted" + case .denied: "denied" + case .authorizedAlways: "authorizedAlways" + case .authorizedWhenInUse: "authorizedWhenInUse" + @unknown default: "unknown" + } + return PermissionStatus(permission: "location", status: value) + #else + return PermissionStatus(permission: "location", status: "unavailable") + #endif + } + + private func photosStatus() -> PermissionStatus { + #if canImport(Photos) + let value = switch PHPhotoLibrary.authorizationStatus(for: .readWrite) { + case .notDetermined: "notDetermined" + case .restricted: "restricted" + case .denied: "denied" + case .authorized: "authorized" + case .limited: "limited" + @unknown default: "unknown" + } + return PermissionStatus(permission: "photos", status: value) + #else + return PermissionStatus(permission: "photos", status: "unavailable") + #endif + } +} diff --git a/Shared/Porthole/PortholeKit/Sources/Connectors/ViewTreeConnector.swift b/Shared/Porthole/PortholeKit/Sources/Connectors/ViewTreeConnector.swift new file mode 100644 index 00000000..b92235fa --- /dev/null +++ b/Shared/Porthole/PortholeKit/Sources/Connectors/ViewTreeConnector.swift @@ -0,0 +1,237 @@ +#if canImport(UIKit) + import Foundation + import PortholeCore + import UIKit + + /// The built-in `ui` connector (iOS only): inspect the window/view hierarchy + /// and accessibility tree, capture a screenshot, and open a URL in the app. + /// Auto-registered by ``Porthole`` on iOS. + public final class ViewTreeConnector: PortholeConnector { + public let descriptor = PortholeConnectorDescriptor( + id: "ui", + title: "UI", + summary: "Inspect the app's window and view hierarchy, its accessibility tree, take a screenshot, or open a URL.", + version: 1, + ) + + public init() {} + + public func actions() -> [PortholeAction] { + [ + PortholeAction( + descriptor: PortholeActionDescriptor( + id: "screenshot", + title: "Screenshot", + summary: "Render a window to a PNG image so you can see the current screen.", + parameters: .object(["windowIndex": .integer("Which window (default 0)")]), + isDestructive: false, + ), + handler: { parameters in + let index = Int(parameters["windowIndex"]?.intValue ?? 0) + return try await MainActor.run { try Self.screenshot(windowIndex: index) } + }, + ), + PortholeAction( + descriptor: PortholeActionDescriptor( + id: "open-url", + title: "Open URL", + summary: "Open a URL in the app — deep links, universal links, and custom schemes all navigate the app.", + parameters: .object(["url": .string("The URL to open")], required: ["url"]), + isDestructive: false, + ), + handler: { parameters in + guard let string = parameters["url"]?.stringValue, + let url = URL(string: string) + else { + throw PortholeError.invalidParameters("`url` is not a valid URL") + } + return await .object(["opened": .bool(Self.openURL(url))]) + }, + ), + ] + } + + public func dataSources() -> [PortholeDataSource] { + [ + PortholeDataSource( + descriptor: PortholeDataSourceDescriptor( + id: "windows", + title: "Windows", + summary: "One row per on-screen window.", + rowSchema: .object([ + "index": .integer(), + "class": .string(), + "isKeyWindow": .boolean(), + ]), + filters: .object([:]), + supportsSubscription: false, + ), + fetch: { _ in await MainActor.run { Self.windowsPage() } }, + ), + PortholeDataSource( + descriptor: PortholeDataSourceDescriptor( + id: "view-tree", + title: "View tree", + summary: "The recursive view hierarchy of a window as a single nested row.", + rowSchema: .object([ + "class": .string(), + "children": .array(of: .object([:])), + ]), + filters: .object([ + "windowIndex": .integer("Which window (default 0)"), + "maxDepth": .integer("Maximum depth (default 50)"), + ]), + supportsSubscription: false, + ), + fetch: { query in + let windowIndex = Int(query.filters["windowIndex"]?.intValue ?? 0) + let maxDepth = Int(query.filters["maxDepth"]?.intValue ?? 50) + return await MainActor.run { Self.viewTreePage( + windowIndex: windowIndex, + maxDepth: maxDepth, + ) } + }, + ), + PortholeDataSource( + descriptor: PortholeDataSourceDescriptor( + id: "accessibility-tree", + title: "Accessibility tree", + summary: "The accessibility element hierarchy of a window as a single nested row.", + rowSchema: .object([ + "label": .string(), + "children": .array(of: .object([:])), + ]), + filters: .object(["windowIndex": .integer("Which window (default 0)")]), + supportsSubscription: false, + ), + fetch: { query in + let windowIndex = Int(query.filters["windowIndex"]?.intValue ?? 0) + return await MainActor + .run { Self.accessibilityTreePage(windowIndex: windowIndex) } + }, + ), + ] + } + + // MARK: - Capture (main actor) + + @MainActor private static func openURL(_ url: URL) async -> Bool { + await withCheckedContinuation { continuation in + UIApplication.shared.open(url, options: [:]) { continuation.resume(returning: $0) } + } + } + + @MainActor private static func windows() -> [UIWindow] { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + } + + @MainActor private static func window(at index: Int) -> UIWindow? { + let all = windows() + return all.indices.contains(index) ? all[index] : nil + } + + @MainActor private static func windowsPage() -> PortholePage { + let rows = windows().enumerated().map { index, window in + PortholeValue.object([ + "index": .int(Int64(index)), + "class": .string(String(describing: type(of: window))), + "frame": frame(window.frame), + "isKeyWindow": .bool(window.isKeyWindow), + ]) + } + return PortholePage(rows: rows, totalCount: rows.count) + } + + @MainActor private static func viewTreePage( + windowIndex: Int, + maxDepth: Int, + ) -> PortholePage { + guard let window = window(at: windowIndex) else { return PortholePage(rows: []) } + return PortholePage( + rows: [viewNode(window, depth: 0, maxDepth: maxDepth)], + totalCount: 1, + ) + } + + @MainActor private static func viewNode( + _ view: UIView, + depth: Int, + maxDepth: Int, + ) -> PortholeValue { + var object: [String: PortholeValue] = [ + "class": .string(String(describing: type(of: view))), + "frame": frame(view.frame), + "isHidden": .bool(view.isHidden), + "alpha": .double(Double(view.alpha)), + ] + if let id = view + .accessibilityIdentifier { object["accessibilityIdentifier"] = .string(id) } + if let label = view.accessibilityLabel { object["accessibilityLabel"] = .string(label) } + if depth < maxDepth, !view.subviews.isEmpty { + object["children"] = .array(view.subviews.map { viewNode( + $0, + depth: depth + 1, + maxDepth: maxDepth, + ) }) + } + return .object(object) + } + + @MainActor private static func accessibilityTreePage(windowIndex: Int) -> PortholePage { + guard let window = window(at: windowIndex) else { return PortholePage(rows: []) } + return PortholePage(rows: [accessibilityNode(window)], totalCount: 1) + } + + @MainActor private static func accessibilityNode(_ object: NSObject) -> PortholeValue { + var node: [String: PortholeValue] = [ + "class": .string(String(describing: type(of: object))), + ] + if let element = object as? UIAccessibilityIdentification, + let id = element.accessibilityIdentifier + { + node["accessibilityIdentifier"] = .string(id) + } + if object.isAccessibilityElement { + if let label = object.accessibilityLabel { node["label"] = .string(label) } + if let value = object.accessibilityValue { node["value"] = .string(value) } + node["isAccessibilityElement"] = .bool(true) + } + let children = object.accessibilityElements as? [NSObject] + ?? (object as? UIView)?.subviews ?? [] + if !children.isEmpty { + node["children"] = .array(children.map(accessibilityNode)) + } + return .object(node) + } + + @MainActor private static func screenshot(windowIndex: Int) throws -> PortholeValue { + guard let window = window(at: windowIndex) else { + throw PortholeError.invalidParameters("No window at index \(windowIndex)") + } + let renderer = UIGraphicsImageRenderer(bounds: window.bounds) + let image = renderer.image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + guard let png = image.pngData() else { + throw PortholeError.handlerFailed("Failed to render PNG") + } + return .object([ + "image": .data(png), + "width": .int(Int64(image.size.width * image.scale)), + "height": .int(Int64(image.size.height * image.scale)), + "scale": .double(Double(image.scale)), + ]) + } + + private static func frame(_ rect: CGRect) -> PortholeValue { + .object([ + "x": .double(Double(rect.origin.x)), + "y": .double(Double(rect.origin.y)), + "width": .double(Double(rect.width)), + "height": .double(Double(rect.height)), + ]) + } + } +#endif diff --git a/Shared/Porthole/PortholeKit/Sources/DeviceInfo.swift b/Shared/Porthole/PortholeKit/Sources/DeviceInfo.swift new file mode 100644 index 00000000..fe47091d --- /dev/null +++ b/Shared/Porthole/PortholeKit/Sources/DeviceInfo.swift @@ -0,0 +1,49 @@ +import Foundation +#if canImport(UIKit) + import UIKit +#endif + +/// Cross-platform device/host facts for the hello handshake and the built-in +/// app-info connector. iOS reads `UIDevice`; other platforms fall back to +/// `ProcessInfo`/`Host`. +enum DeviceInfo { + static var deviceName: String { + #if canImport(UIKit) + return UIDevice.current.name + #else + return ProcessInfo.processInfo.hostName + #endif + } + + static var model: String { + #if canImport(UIKit) + return UIDevice.current.model + #else + return "Mac" + #endif + } + + static var systemName: String { + #if canImport(UIKit) + return UIDevice.current.systemName + #else + return "macOS" + #endif + } + + static var systemVersion: String { + #if canImport(UIKit) + return UIDevice.current.systemVersion + #else + return ProcessInfo.processInfo.operatingSystemVersionString + #endif + } + + static var isDebugBuild: Bool { + #if DEBUG + return true + #else + return false + #endif + } +} diff --git a/Shared/Porthole/PortholeKit/Sources/DevicePairingManager.swift b/Shared/Porthole/PortholeKit/Sources/DevicePairingManager.swift new file mode 100644 index 00000000..8ea90f98 --- /dev/null +++ b/Shared/Porthole/PortholeKit/Sources/DevicePairingManager.swift @@ -0,0 +1,263 @@ +import CryptoKit +import Foundation +import PortholeCore + +/// Metadata persisted alongside a pairing PSK (encoded into the credential +/// store's opaque blob) so paired hosts can be listed without the secret. +struct PairedHostMetadata: Codable { + var name: String + var createdAt: Date +} + +/// The outcome of a connection's handshake. +enum HandshakeResult { + /// Proceed to an encrypted session with this key. + case session(key: SymmetricKey, pairingID: UUID) + /// A pairing completed; the client will reconnect for a session. Close now. + case paired(pairingID: UUID) + /// The handshake failed (the reason was already sent to the peer). Close. + case rejected(PortholeError) +} + +/// Owns the device side of the pairing/session handshake and the pending-code +/// lifecycle. Pure of `Network` types so it can be driven over a +/// `LoopbackTransport` in tests. One human code is active at a time; wrong +/// guesses accumulate across attempts and burn it after `maxAttempts`, and it +/// expires after `codeLifetime`. +actor DevicePairingManager { + private struct ActiveCode { + var code: String + var attempts: Int + var expiresAt: Date + } + + private let credentials: PortholeCredentialStore + private let maxAttempts: Int + private let codeLifetime: TimeInterval + private let now: @Sendable () -> Date + private let onCodeChange: @Sendable (String?) async -> Void + private let onPairedHostsChange: @Sendable () async -> Void + + private var activeCode: ActiveCode? + + init( + credentials: PortholeCredentialStore, + maxAttempts: Int = 3, + codeLifetime: TimeInterval = 120, + now: @escaping @Sendable () -> Date = { Date() }, + onCodeChange: @escaping @Sendable (String?) async -> Void = { _ in }, + onPairedHostsChange: @escaping @Sendable () async -> Void = {}, + ) { + self.credentials = credentials + self.maxAttempts = maxAttempts + self.codeLifetime = codeLifetime + self.now = now + self.onCodeChange = onCodeChange + self.onPairedHostsChange = onPairedHostsChange + } + + /// The current paired hosts, read from the credential store. + func pairedHosts() -> [PairedHost] { + (try? credentials.all())?.compactMap { record in + guard let metadata = try? JSONDecoder().decode( + PairedHostMetadata.self, + from: record.metadata, + ) else { + return PairedHost( + pairingID: record.pairingID, + name: "Unknown", + createdAt: .distantPast, + ) + } + return PairedHost( + pairingID: record.pairingID, + name: metadata.name, + createdAt: metadata.createdAt, + ) + } ?? [] + } + + func revoke(_ pairingID: UUID) throws { + try credentials.delete(pairingID: pairingID) + } + + /// Runs the handshake to completion over an established frame reader and raw + /// frame sender. Sends plaintext pairing frames; on session success the + /// caller wraps the same reader/sender in a `PortholeSecureChannel`. + func handshake( + reader: TransportFrameReader, + send: @escaping @Sendable (Data) async throws -> Void, + ) async -> HandshakeResult { + do { + guard let helloFrame = try await reader.next() else { + return .rejected(.notPaired) + } + let hello = try JSONDecoder().decode(PortholePairingMessage.self, from: helloFrame) + guard case let .clientHello(mode) = hello else { + return .rejected(.invalidParameters("Expected clientHello")) + } + switch mode { + case let .pair(clientName, clientPublicKey): + return try await runPairing( + clientName: clientName, + clientPublicKey: clientPublicKey, + reader: reader, + send: send, + ) + case let .session(pairingID, clientNonce): + return try await runSession( + pairingID: pairingID, + clientNonce: clientNonce, + send: send, + ) + } + } catch { + PortholeLog.network + .error("Handshake failed: \(String(describing: error), privacy: .public)") + return .rejected(.invalidParameters(String(describing: error))) + } + } + + // MARK: - Pairing + + private func runPairing( + clientName: String, + clientPublicKey: Data, + reader: TransportFrameReader, + send: @escaping @Sendable (Data) async throws -> Void, + ) async throws -> HandshakeResult { + let (code, isNew) = ensureActiveCode() + if isNew { await onCodeChange(code) } + let devicePrivateKey = Curve25519.KeyAgreement.PrivateKey() + let salt = PairingCryptography.randomBytes(count: PairingCryptography.saltByteCount) + + try await send(encode(.pairChallenge( + devicePublicKey: devicePrivateKey.publicKey.rawRepresentation, + salt: salt, + ))) + + guard let confirmFrame = try await reader.next() else { + return .rejected(.pairingFailed(.wrongCode)) + } + let confirm = try JSONDecoder().decode(PortholePairingMessage.self, from: confirmFrame) + guard case let .pairConfirm(mac) = confirm else { + return .rejected(.invalidParameters("Expected pairConfirm")) + } + + if isExpired() { + burnCode(); await onCodeChange(nil) + try await send(encode(.failure(.pairingFailed(.expired)))) + return .rejected(.pairingFailed(.expired)) + } + + let clientPub = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: clientPublicKey) + let psk = try PairingCryptography.pairingKey( + ownPrivateKey: devicePrivateKey, + peerPublicKey: clientPub, + salt: salt, + code: code, + ) + let valid = PairingCryptography.isValidConfirmation( + mac, + key: psk, + clientPublicKey: clientPublicKey, + devicePublicKey: devicePrivateKey.publicKey.rawRepresentation, + salt: salt, + ) + guard valid else { + let burned = recordFailedAttempt() + if burned { await onCodeChange(nil) } + let reason: PairingFailureReason = burned ? .tooManyAttempts : .wrongCode + try await send(encode(.failure(.pairingFailed(reason)))) + return .rejected(.pairingFailed(reason)) + } + + let pairingID = UUID() + let metadata = try JSONEncoder().encode(PairedHostMetadata( + name: clientName, + createdAt: now(), + )) + try credentials.save(pairingID: pairingID, key: psk, metadata: metadata) + + let acceptanceMac = PairingCryptography.acceptanceCode( + key: psk, + salt: salt, + clientPublicKey: clientPublicKey, + ) + try await send(encode(.pairAccepted(pairingID: pairingID, mac: acceptanceMac))) + + burnCode() + await onCodeChange(nil) + await onPairedHostsChange() + return .paired(pairingID: pairingID) + } + + // MARK: - Session + + private func runSession( + pairingID: UUID, + clientNonce: Data, + send: @escaping @Sendable (Data) async throws -> Void, + ) async throws -> HandshakeResult { + guard let psk = try credentials.key(for: pairingID) else { + try await send(encode(.failure(.notPaired))) + return .rejected(.notPaired) + } + let serverNonce = PairingCryptography.randomBytes(count: PairingCryptography.nonceByteCount) + try await send(encode(.serverHello(serverNonce: serverNonce))) + let sessionKey = PairingCryptography.sessionKey( + psk: psk, + clientNonce: clientNonce, + serverNonce: serverNonce, + ) + return .session(key: sessionKey, pairingID: pairingID) + } + + // MARK: - Code lifecycle + + /// Returns the active code (reusing an unexpired one so attempts accumulate), + /// and whether it was newly minted (so the caller can publish it). + private func ensureActiveCode() -> (code: String, isNew: Bool) { + if let active = activeCode, !isExpired() { + return (active.code, false) + } + let code = PairingCryptography.makePairingCode() + activeCode = ActiveCode( + code: code, + attempts: 0, + expiresAt: now().addingTimeInterval(codeLifetime), + ) + return (code, true) + } + + private func isExpired() -> Bool { + guard let active = activeCode else { return true } + return now() > active.expiresAt + } + + /// Records a wrong-code attempt; returns whether the code was burned. + private func recordFailedAttempt() -> Bool { + guard var active = activeCode else { return true } + active.attempts += 1 + activeCode = active + if active.attempts >= maxAttempts { + burnCode() + return true + } + return false + } + + private func burnCode() { + activeCode = nil + } + + /// Publishes the current pending code (used to seed UI state on start). + func currentPendingCode() -> String? { + guard let active = activeCode, !isExpired() else { return nil } + return active.code + } + + private func encode(_ message: PortholePairingMessage) throws -> Data { + try JSONEncoder().encode(message) + } +} diff --git a/Shared/Porthole/PortholeKit/Sources/Porthole.swift b/Shared/Porthole/PortholeKit/Sources/Porthole.swift new file mode 100644 index 00000000..b84856f1 --- /dev/null +++ b/Shared/Porthole/PortholeKit/Sources/Porthole.swift @@ -0,0 +1,162 @@ +import Foundation +import PortholeCore + +/// The device-side composition root of Porthole. An app creates exactly one, +/// registers its connectors, and calls ``start()`` to advertise and accept +/// connections. Everything a client can reach flows through the connectors +/// registered here. +/// +/// `@MainActor` + `@Observable` so SwiftUI (the pairing UI) can bind to +/// ``state`` directly. The built-in `app` connector is registered automatically; +/// other built-ins (`ui`, `files`, `notifications`, `permissions`) register +/// themselves as they are added to the suite. +@MainActor +@Observable +public final class Porthole { + public let configuration: PortholeConfiguration + public let state = PortholeState() + + private let registry = ConnectorRegistry() + private let credentials: PortholeCredentialStore + private var sessionTasks: [Task] = [] + private var server: PortholeServer? + private var pairingManager: DevicePairingManager? + + public convenience init(configuration: PortholeConfiguration) { + self.init( + configuration: configuration, + credentials: KeychainCredentialStore(service: "com.stuff.porthole.device"), + ) + } + + /// Testing/advanced seam: inject a credential store (e.g. in-memory) instead + /// of the login Keychain. + @_spi(Testing) + public init(configuration: PortholeConfiguration, credentials: PortholeCredentialStore) { + self.configuration = configuration + self.credentials = credentials + register(AppInfoConnector(appName: configuration.appName, bundleID: configuration.bundleID)) + register(FileBrowserConnector(appGroupIdentifiers: configuration.appGroupIdentifiers)) + register(PermissionsConnector()) + #if canImport(UserNotifications) + register(NotificationsConnector()) + #endif + #if canImport(UIKit) + register(ViewTreeConnector()) + #endif + } + + /// Registers a connector. A duplicate id is a programmer error (asserts in + /// debug, ignored in release). + public func register(_ connector: some PortholeConnector) { + registry.register(connector) + } + + /// Starts advertising over Bonjour and accepting connections. + public func start() throws { + guard server == nil else { return } + let manager = DevicePairingManager( + credentials: credentials, + onCodeChange: { [weak self] code in await self?.setPendingCode(code) }, + onPairedHostsChange: { [weak self] in await self?.refreshPairedHosts() }, + ) + let server = PortholeServer( + configuration: configuration, + pairingManager: manager, + connectors: registry.resolve(), + hello: helloReply, + onSessionCountChange: { [weak self] count in await self?.setSessionCount(count) }, + ) + try server.start() + pairingManager = manager + self.server = server + state.isAdvertising = true + Task { await refreshPairedHosts() } + } + + /// Stops advertising and closes active sessions. + public func stop() { + server?.stop() + server = nil + pairingManager = nil + for task in sessionTasks { + task.cancel() + } + sessionTasks.removeAll() + state.activeSessionCount = 0 + state.pendingPairingCode = nil + state.isAdvertising = false + } + + /// Revokes a pairing so the host can no longer connect. + public func revoke(_ pairingID: UUID) async throws { + try credentials.delete(pairingID: pairingID) + await refreshPairedHosts() + } + + private func setPendingCode(_ code: String?) { + state.pendingPairingCode = code + } + + private func setSessionCount(_ count: Int) { + state.activeSessionCount = count + } + + private func refreshPairedHosts() async { + if let pairingManager { + state.pairedHosts = await pairingManager.pairedHosts() + } else { + state.pairedHosts = loadPairedHosts() + } + } + + private func loadPairedHosts() -> [PairedHost] { + (try? credentials.all())?.compactMap { record in + guard let metadata = try? JSONDecoder().decode( + PairedHostMetadata.self, + from: record.metadata, + ) else { + return PairedHost( + pairingID: record.pairingID, + name: "Unknown", + createdAt: .distantPast, + ) + } + return PairedHost( + pairingID: record.pairingID, + name: metadata.name, + createdAt: metadata.createdAt, + ) + } ?? [] + } + + var helloReply: HelloReply { + HelloReply( + appName: configuration.appName, + bundleID: configuration.bundleID, + deviceName: DeviceInfo.deviceName, + ) + } + + func resolvedConnectors() -> ResolvedConnectors { + registry.resolve() + } + + /// Serves a session over an already-authenticated transport, bypassing the + /// network/handshake layer. The seam that lets tests drive the full request + /// router in-process over a `LoopbackTransport`. + @_spi(Testing) + public func attach(transport: some PortholeTransport, authenticated _: Bool = true) { + let router = PortholeSessionRouter( + transport: transport, + connectors: registry.resolve(), + hello: helloReply, + ) + state.activeSessionCount += 1 + let task = Task { @MainActor [weak self] in + await router.run() + self?.state.activeSessionCount -= 1 + } + sessionTasks.append(task) + } +} diff --git a/Shared/Porthole/PortholeKit/Sources/PortholeConfiguration.swift b/Shared/Porthole/PortholeKit/Sources/PortholeConfiguration.swift new file mode 100644 index 00000000..44a0c426 --- /dev/null +++ b/Shared/Porthole/PortholeKit/Sources/PortholeConfiguration.swift @@ -0,0 +1,30 @@ +import Foundation + +/// How a host app configures its ``Porthole`` runtime. `appName` is required; +/// the rest are convenience defaults (the app's own bundle id, the standard +/// Bonjour service type, an ephemeral port, no extra file roots). +public struct PortholeConfiguration: Sendable { + public var appName: String + public var bundleID: String + /// The Bonjour service type advertised and browsed for. + public var serviceType: String + /// nil = let the system pick an ephemeral port. + public var port: UInt16? + /// Extra roots exposed by the built-in file connector — App Group container + /// identifiers the app wants browsable alongside its own sandbox. + public var appGroupIdentifiers: [String] + + public init( + appName: String, + bundleID: String = Bundle.main.bundleIdentifier ?? "unknown", + serviceType: String = "_porthole._tcp", + port: UInt16? = nil, + appGroupIdentifiers: [String] = [], + ) { + self.appName = appName + self.bundleID = bundleID + self.serviceType = serviceType + self.port = port + self.appGroupIdentifiers = appGroupIdentifiers + } +} diff --git a/Shared/Porthole/PortholeKit/Sources/PortholeConnector.swift b/Shared/Porthole/PortholeKit/Sources/PortholeConnector.swift new file mode 100644 index 00000000..d14c6005 --- /dev/null +++ b/Shared/Porthole/PortholeKit/Sources/PortholeConnector.swift @@ -0,0 +1,59 @@ +import Foundation +import PortholeCore + +/// A source of app data and operations that an app registers with ``Porthole``. +/// Conform a type (e.g. `WhereConnector`) and return its actions and data +/// sources; the runtime advertises their descriptors and routes requests to the +/// handlers. Reference type so a connector can hold onto app services. +public protocol PortholeConnector: AnyObject, Sendable { + var descriptor: PortholeConnectorDescriptor { get } + func actions() -> [PortholeAction] + func dataSources() -> [PortholeDataSource] +} + +extension PortholeConnector { + public func actions() -> [PortholeAction] { + [] + } + + public func dataSources() -> [PortholeDataSource] { + [] + } +} + +/// One invocable operation: its descriptor (id, LLM-facing summary, parameter +/// schema, destructive flag) and the handler the runtime calls after validating +/// the parameters against the schema. +public struct PortholeAction: Sendable { + public var descriptor: PortholeActionDescriptor + public var handler: @Sendable (PortholeValue) async throws -> PortholeValue + + public init( + descriptor: PortholeActionDescriptor, + handler: @escaping @Sendable (PortholeValue) async throws -> PortholeValue, + ) { + self.descriptor = descriptor + self.handler = handler + } +} + +/// One queryable (optionally subscribable) source of rows: its descriptor, a +/// paginated `fetch`, and — when `descriptor.supportsSubscription` is true — a +/// `subscribe` that vends a live stream of row values. +public struct PortholeDataSource: Sendable { + public var descriptor: PortholeDataSourceDescriptor + public var fetch: @Sendable (PortholeQuery) async throws -> PortholePage + /// Non-nil iff `descriptor.supportsSubscription` — the runtime rejects a + /// subscribe request for a source without it. + public var subscribe: (@Sendable () -> AsyncStream)? + + public init( + descriptor: PortholeDataSourceDescriptor, + fetch: @escaping @Sendable (PortholeQuery) async throws -> PortholePage, + subscribe: (@Sendable () -> AsyncStream)? = nil, + ) { + self.descriptor = descriptor + self.fetch = fetch + self.subscribe = subscribe + } +} diff --git a/Shared/Porthole/PortholeKit/Sources/PortholeLog.swift b/Shared/Porthole/PortholeKit/Sources/PortholeLog.swift new file mode 100644 index 00000000..4bc3efad --- /dev/null +++ b/Shared/Porthole/PortholeKit/Sources/PortholeLog.swift @@ -0,0 +1,11 @@ +import os + +/// PortholeKit's own lightweight logging, deliberately independent of Periscope: +/// the device runtime must not depend on the logging stack it can *expose* as a +/// connector. Failures (bad frames, handshake errors, dropped events) log here +/// rather than being swallowed. +enum PortholeLog { + static let runtime = Logger(subsystem: "com.stuff.porthole", category: "runtime") + static let session = Logger(subsystem: "com.stuff.porthole", category: "session") + static let network = Logger(subsystem: "com.stuff.porthole", category: "network") +} diff --git a/Shared/Porthole/PortholeKit/Sources/PortholeServer.swift b/Shared/Porthole/PortholeKit/Sources/PortholeServer.swift new file mode 100644 index 00000000..4381efd0 --- /dev/null +++ b/Shared/Porthole/PortholeKit/Sources/PortholeServer.swift @@ -0,0 +1,115 @@ +import Foundation +import Network +import PortholeCore + +/// Owns the Bonjour `NWListener`, accepts connections, and for each one runs the +/// handshake and (on session success) a `PortholeSessionRouter` over a +/// `PortholeSecureChannel`. A thin coordinator: the handshake, crypto, and +/// dispatch it wires together are all tested independently over loopback. +final class PortholeServer: @unchecked Sendable { + private let configuration: PortholeConfiguration + private let pairingManager: DevicePairingManager + private let connectors: ResolvedConnectors + private let hello: HelloReply + private let onSessionCountChange: @Sendable (Int) async -> Void + + private let queue = DispatchQueue(label: "com.stuff.porthole.server") + private let sessionLock = NSLock() + private var sessionCount = 0 + private var listener: NWListener? + + init( + configuration: PortholeConfiguration, + pairingManager: DevicePairingManager, + connectors: ResolvedConnectors, + hello: HelloReply, + onSessionCountChange: @escaping @Sendable (Int) async -> Void, + ) { + self.configuration = configuration + self.pairingManager = pairingManager + self.connectors = connectors + self.hello = hello + self.onSessionCountChange = onSessionCountChange + } + + func start() throws { + let parameters = NWParameters.tcp + parameters.includePeerToPeer = true + let listener: NWListener = if let port = configuration.port, + let nwPort = NWEndpoint.Port(rawValue: port) + { + try NWListener(using: parameters, on: nwPort) + } else { + try NWListener(using: parameters) + } + + let txt = NWTXTRecord([ + "name": configuration.appName, + "bundle": configuration.bundleID, + "device": DeviceInfo.deviceName, + "ver": String(portholeProtocolVersion), + ]) + listener.service = NWListener.Service( + name: configuration.appName, + type: configuration.serviceType, + txtRecord: txt, + ) + listener.newConnectionHandler = { [weak self] connection in + self?.accept(connection) + } + listener.stateUpdateHandler = { state in + if case let .failed(error) = state { + PortholeLog.network + .error("Listener failed: \(String(describing: error), privacy: .public)") + } + } + listener.start(queue: queue) + self.listener = listener + } + + func stop() { + listener?.cancel() + listener = nil + } + + private func accept(_ connection: NWConnection) { + let transport = NWConnectionTransport(connection: connection, queue: queue) + let pairingManager = pairingManager + let connectors = connectors + let hello = hello + Task { + let reader = TransportFrameReader(transport) + let sender: @Sendable (Data) async throws -> Void = { try await transport.send($0) } + let result = await pairingManager.handshake(reader: reader, send: sender) + switch result { + case let .session(key, _): + await self.changeSessionCount(by: 1) + let channel = PortholeSecureChannel( + reader: reader, + send: sender, + close: { await transport.close() }, + key: key, + role: .device, + ) + let router = PortholeSessionRouter( + transport: channel, + connectors: connectors, + hello: hello, + ) + await router.run() + await transport.close() + await self.changeSessionCount(by: -1) + case .paired, .rejected: + await transport.close() + } + } + } + + private func changeSessionCount(by delta: Int) async { + let newCount = sessionLock.withLock { () -> Int in + sessionCount += delta + return sessionCount + } + await onSessionCountChange(newCount) + } +} diff --git a/Shared/Porthole/PortholeKit/Sources/PortholeSessionRouter.swift b/Shared/Porthole/PortholeKit/Sources/PortholeSessionRouter.swift new file mode 100644 index 00000000..da2e45f3 --- /dev/null +++ b/Shared/Porthole/PortholeKit/Sources/PortholeSessionRouter.swift @@ -0,0 +1,201 @@ +import Foundation +import PortholeCore + +/// Serves one authenticated connection: it decodes request envelopes from the +/// transport, validates action/query parameters against the connector schemas, +/// dispatches to the resolved handlers, and streams subscription events back. +/// +/// An `actor` so its subscription table is race-free; request handling is +/// serialized per connection, but actor reentrancy lets subscription-event sends +/// interleave at the `await` in a slow handler. +actor PortholeSessionRouter { + private let transport: any PortholeTransport + private let connectors: ResolvedConnectors + private let hello: HelloReply + private let connectorIDs: Set + + private var subscriptions: [UInt64: SubscriptionTasks] = [:] + private var nextSubscriptionID: UInt64 = 1 + + private struct SubscriptionTasks { + var feeder: Task + var pump: Task + } + + /// Bounds per-subscription buffering so a fast producer can't grow memory + /// without limit; the oldest queued events are dropped when full. + private static let subscriptionBufferSize = 256 + + init(transport: some PortholeTransport, connectors: ResolvedConnectors, hello: HelloReply) { + self.transport = transport + self.connectors = connectors + self.hello = hello + connectorIDs = connectors.connectorIDs + } + + /// Reads and services frames until the transport closes, then cancels any + /// open subscriptions. Returns when the connection ends. + func run() async { + do { + for try await frame in transport.incoming { + await handle(frame) + } + } catch { + PortholeLog.session + .error("Session read failed: \(String(describing: error), privacy: .public)") + } + for (_, tasks) in subscriptions { + tasks.feeder.cancel() + tasks.pump.cancel() + } + subscriptions.removeAll() + } + + private func handle(_ frame: Data) async { + let envelope: PortholeRequestEnvelope + do { + envelope = try JSONDecoder().decode(PortholeRequestEnvelope.self, from: frame) + } catch { + PortholeLog.session + .error( + "Dropping undecodable request frame: \(String(describing: error), privacy: .public)", + ) + return + } + let response = await response(for: envelope.request) + await send(PortholeResponseEnvelope(requestID: envelope.id, response: response)) + } + + private func response(for request: PortholeRequest) async -> PortholeResponse { + switch request { + case .hello: + return .helloReply(hello) + case .listConnectors: + return .connectors(connectors.manifests) + case .ping: + return .pong + case let .invokeAction(ref, parameters): + return await invoke(ref, parameters: parameters) + case let .query(ref, query): + return await runQuery(ref, query: query) + case let .subscribe(ref): + return subscribe(ref) + case let .unsubscribe(subscriptionID): + cancelSubscription(subscriptionID) + return .pong + } + } + + private func invoke( + _ ref: PortholeActionRef, + parameters: PortholeValue, + ) async -> PortholeResponse { + switch connectors.action(ref, hasConnector: connectorIDs.contains) { + case let .failure(error): + return .failure(error) + case let .success(action): + do { + try action.descriptor.parameters.validate(parameters) + } catch let error as PortholeError { + return .failure(error) + } catch { + return .failure(.invalidParameters(String(describing: error))) + } + do { + return try await .actionResult(action.handler(parameters)) + } catch let error as PortholeError { + return .failure(error) + } catch { + return .failure(.handlerFailed(String(describing: error))) + } + } + } + + private func runQuery( + _ ref: PortholeDataSourceRef, + query: PortholeQuery, + ) async -> PortholeResponse { + switch connectors.dataSource(ref, hasConnector: connectorIDs.contains) { + case let .failure(error): + return .failure(error) + case let .success(source): + do { + try source.descriptor.filters.validate(query.filters) + } catch let error as PortholeError { + return .failure(error) + } catch { + return .failure(.invalidParameters(String(describing: error))) + } + do { + return try await .queryResult(source.fetch(query)) + } catch let error as PortholeError { + return .failure(error) + } catch { + return .failure(.handlerFailed(String(describing: error))) + } + } + } + + private func subscribe(_ ref: PortholeDataSourceRef) -> PortholeResponse { + switch connectors.dataSource(ref, hasConnector: connectorIDs.contains) { + case let .failure(error): + return .failure(error) + case let .success(source): + guard source.descriptor.supportsSubscription, + let makeStream = source.subscribe + else { + return .failure(.subscriptionNotSupported(ref)) + } + let subscriptionID = nextSubscriptionID + nextSubscriptionID += 1 + start(subscriptionID: subscriptionID, makeStream: makeStream) + return .subscribed(subscriptionID: subscriptionID) + } + } + + private func start( + subscriptionID: UInt64, + makeStream: @escaping @Sendable () -> AsyncStream, + ) { + let source = makeStream() + // Re-buffer through a bounded stream so our memory ceiling holds + // regardless of the connector's own buffering policy (drop-oldest). + let (bounded, continuation) = AsyncStream.makeStream( + of: PortholeValue.self, + bufferingPolicy: .bufferingNewest(Self.subscriptionBufferSize), + ) + let feeder = Task { + for await value in source { + if Task.isCancelled { break } + continuation.yield(value) + } + continuation.finish() + } + let pump = Task { [weak self] in + for await value in bounded { + if Task.isCancelled { break } + await self?.send(PortholeResponseEnvelope( + requestID: nil, + response: .event(subscriptionID: subscriptionID, value: value), + )) + } + } + subscriptions[subscriptionID] = SubscriptionTasks(feeder: feeder, pump: pump) + } + + private func cancelSubscription(_ subscriptionID: UInt64) { + guard let tasks = subscriptions.removeValue(forKey: subscriptionID) else { return } + tasks.feeder.cancel() + tasks.pump.cancel() + } + + private func send(_ envelope: PortholeResponseEnvelope) async { + do { + let frame = try JSONEncoder().encode(envelope) + try await transport.send(frame) + } catch { + PortholeLog.session + .error("Failed to send response: \(String(describing: error), privacy: .public)") + } + } +} diff --git a/Shared/Porthole/PortholeKit/Sources/PortholeState.swift b/Shared/Porthole/PortholeKit/Sources/PortholeState.swift new file mode 100644 index 00000000..36d8e1be --- /dev/null +++ b/Shared/Porthole/PortholeKit/Sources/PortholeState.swift @@ -0,0 +1,38 @@ +import Foundation + +/// A host paired with this device, minus any secret — safe to show in the +/// pairing UI. +public struct PairedHost: Identifiable, Sendable, Equatable { + public var id: UUID { + pairingID + } + + public var pairingID: UUID + public var name: String + public var createdAt: Date + + public init(pairingID: UUID, name: String, createdAt: Date) { + self.pairingID = pairingID + self.name = name + self.createdAt = createdAt + } +} + +/// The observable runtime state of a ``Porthole``: what the pairing/status UI +/// binds to. Mutated only by the runtime (hence `internal(set)`); the network +/// layer (advertising, pairing, sessions) fills it in. +@MainActor +@Observable +public final class PortholeState { + /// Whether the device is currently advertising over Bonjour. + public internal(set) var isAdvertising: Bool = false + /// The 6-digit code to read out, non-nil only while a pairing awaits + /// confirmation. + public internal(set) var pendingPairingCode: String? + /// Hosts with a stored pairing. + public internal(set) var pairedHosts: [PairedHost] = [] + /// How many client sessions are currently connected. + public internal(set) var activeSessionCount: Int = 0 + + public init() {} +} diff --git a/Shared/Porthole/PortholeKit/Tests/DevicePairingManagerTests.swift b/Shared/Porthole/PortholeKit/Tests/DevicePairingManagerTests.swift new file mode 100644 index 00000000..7fc8ba4c --- /dev/null +++ b/Shared/Porthole/PortholeKit/Tests/DevicePairingManagerTests.swift @@ -0,0 +1,203 @@ +import CryptoKit +import Foundation +@_spi(Testing) import PortholeCore +@testable import PortholeKit +import Testing + +struct DevicePairingManagerTests { + private func runHandshake( + _ manager: DevicePairingManager, + device: some PortholeTransport, + ) -> Task { + let reader = TransportFrameReader(device) + return Task { await manager.handshake(reader: reader, send: { try await device.send($0) }) } + } + + @Test func pairingSucceedsAndStoresCredential() async throws { + let store = InMemoryCredentialStore() + let codeBox = CodeBox() + let manager = DevicePairingManager( + credentials: store, + onCodeChange: { await codeBox.set($0) }, + ) + let (device, client) = LoopbackTransport.makePair() + + let deviceResult = runHandshake(manager, device: device) + let outcome = try await TestPairingClient.pair( + transport: client, + clientName: "MacBook", + code: { await codeBox.waitForCode() }, + ) + + let result = await deviceResult.value + guard case let .paired(pairingID) = result else { + Issue.record("Expected .paired, got \(result)") + return + } + #expect(pairingID == outcome.pairingID) + #expect(try store.key(for: pairingID) == outcome.psk) + + let hosts = await manager.pairedHosts() + #expect(hosts.contains { $0.pairingID == pairingID && $0.name == "MacBook" }) + } + + @Test func wrongCodeIsRejected() async throws { + let store = InMemoryCredentialStore() + let codeBox = CodeBox() + let manager = DevicePairingManager( + credentials: store, + onCodeChange: { await codeBox.set($0) }, + ) + let (device, client) = LoopbackTransport.makePair() + + let deviceResult = runHandshake(manager, device: device) + await #expect(throws: PortholeError.self) { + _ = try await TestPairingClient.pair( + transport: client, + clientName: "cli", + code: { await codeBox.waitForCode() }, + overrideCode: { TestPairingClient.differentCode(from: $0) }, + ) + } + + let result = await deviceResult.value + guard case .rejected(.pairingFailed(.wrongCode)) = result else { + Issue.record("Expected wrongCode, got \(result)") + return + } + #expect(try store.all().isEmpty) + } + + @Test func threeWrongAttemptsBurnTheCode() async { + let store = InMemoryCredentialStore() + let codeBox = CodeBox() + let manager = DevicePairingManager( + credentials: store, + onCodeChange: { await codeBox.set($0) }, + ) + + var lastResult: HandshakeResult? + for _ in 0 ..< 3 { + let (device, client) = LoopbackTransport.makePair() + let deviceResult = runHandshake(manager, device: device) + _ = try? await TestPairingClient.pair( + transport: client, + clientName: "cli", + code: { await codeBox.waitForCode() }, + overrideCode: { TestPairingClient.differentCode(from: $0) }, + ) + lastResult = await deviceResult.value + } + guard case .rejected(.pairingFailed(.tooManyAttempts)) = lastResult else { + Issue + .record( + "Expected tooManyAttempts on the third attempt, got \(String(describing: lastResult))", + ) + return + } + } + + @Test func expiredCodeIsRejected() async throws { + let store = InMemoryCredentialStore() + let clock = MutableClock(Date()) + let codeBox = CodeBox() + let manager = DevicePairingManager( + credentials: store, + codeLifetime: 120, + now: clock.nowClosure(), + onCodeChange: { await codeBox.set($0) }, + ) + let (device, client) = LoopbackTransport.makePair() + + let deviceResult = runHandshake(manager, device: device) + await #expect(throws: PortholeError.self) { + _ = try await TestPairingClient.pair( + transport: client, + clientName: "cli", + code: { await codeBox.waitForCode() }, + beforeConfirm: { clock.advance(200) }, + ) + } + let result = await deviceResult.value + guard case .rejected(.pairingFailed(.expired)) = result else { + Issue.record("Expected expired, got \(result)") + return + } + } + + @Test func sessionRederivesMatchingKey() async throws { + // First pair. + let store = InMemoryCredentialStore() + let codeBox = CodeBox() + let manager = DevicePairingManager( + credentials: store, + onCodeChange: { await codeBox.set($0) }, + ) + let (pairDevice, pairClient) = LoopbackTransport.makePair() + let pairDeviceResult = runHandshake(manager, device: pairDevice) + let outcome = try await TestPairingClient.pair( + transport: pairClient, + clientName: "cli", + code: { await codeBox.waitForCode() }, + ) + _ = await pairDeviceResult.value + + // Then open a session and confirm both sides derive the same key. + let (device, client) = LoopbackTransport.makePair() + let deviceResult = runHandshake(manager, device: device) + let clientKey = try await TestPairingClient.session( + transport: client, + pairingID: outcome.pairingID, + psk: outcome.psk, + ) + + let result = await deviceResult.value + guard case let .session(deviceKey, pairingID) = result else { + Issue.record("Expected .session, got \(result)") + return + } + #expect(pairingID == outcome.pairingID) + #expect(deviceKey == clientKey) + } + + @Test func sessionForUnknownPairingIsRejected() async throws { + let store = InMemoryCredentialStore() + let manager = DevicePairingManager(credentials: store) + let (device, client) = LoopbackTransport.makePair() + + let deviceResult = runHandshake(manager, device: device) + await #expect(throws: PortholeError.self) { + _ = try await TestPairingClient.session( + transport: client, + pairingID: UUID(), + psk: SymmetricKey(size: .bits256), + ) + } + let result = await deviceResult.value + guard case .rejected(.notPaired) = result else { + Issue.record("Expected notPaired, got \(result)") + return + } + } + + @Test func revokeRemovesPairing() async throws { + let store = InMemoryCredentialStore() + let codeBox = CodeBox() + let manager = DevicePairingManager( + credentials: store, + onCodeChange: { await codeBox.set($0) }, + ) + let (device, client) = LoopbackTransport.makePair() + let deviceResult = runHandshake(manager, device: device) + let outcome = try await TestPairingClient.pair( + transport: client, + clientName: "cli", + code: { await codeBox.waitForCode() }, + ) + _ = await deviceResult.value + + try await manager.revoke(outcome.pairingID) + #expect(await manager.pairedHosts().isEmpty) + #expect(try store.key(for: outcome.pairingID) == nil) + } +} diff --git a/Shared/Porthole/PortholeKit/Tests/FileBrowserConnectorTests.swift b/Shared/Porthole/PortholeKit/Tests/FileBrowserConnectorTests.swift new file mode 100644 index 00000000..95442cd6 --- /dev/null +++ b/Shared/Porthole/PortholeKit/Tests/FileBrowserConnectorTests.swift @@ -0,0 +1,115 @@ +import Foundation +import PortholeCore +@_spi(Testing) import PortholeKit +import Testing + +struct FileBrowserConnectorTests { + /// Builds a temp tree: `/notes.txt`, `/sub/child.txt`. + private func makeTree() throws -> URL { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("porthole-files-\(UUID().uuidString)") + let manager = FileManager.default + try manager.createDirectory( + at: root.appendingPathComponent("sub"), + withIntermediateDirectories: true, + ) + try Data("hello world".utf8).write(to: root.appendingPathComponent("notes.txt")) + try Data("child".utf8).write(to: root.appendingPathComponent("sub/child.txt")) + return root + } + + private func connector(root: URL) -> FileBrowserConnector { + FileBrowserConnector(roots: ["test": root]) + } + + private func source( + _ connector: FileBrowserConnector, + _ id: PortholeDataSourceID, + ) -> PortholeDataSource { + connector.dataSources().first { $0.descriptor.id == id }! + } + + private func action( + _ connector: FileBrowserConnector, + _ id: PortholeActionID, + ) -> PortholeAction { + connector.actions().first { $0.descriptor.id == id }! + } + + @Test func rootsSourceListsConfiguredRoots() async throws { + let tree = try makeTree() + defer { try? FileManager.default.removeItem(at: tree) } + let page = try await source(connector(root: tree), "roots").fetch(PortholeQuery()) + #expect(page.rows.contains { $0["name"]?.stringValue == "test" }) + #expect(page.rows.first?["exists"]?.boolValue == true) + } + + @Test func entriesListsDirectoryContents() async throws { + let tree = try makeTree() + defer { try? FileManager.default.removeItem(at: tree) } + let page = try await source(connector(root: tree), "entries") + .fetch(PortholeQuery(filters: ["root": "test"])) + let names = Set(page.rows.compactMap { $0["name"]?.stringValue }) + #expect(names == ["notes.txt", "sub"]) + let sub = page.rows.first { $0["name"]?.stringValue == "sub" } + #expect(sub?["isDirectory"]?.boolValue == true) + } + + @Test func readFileReturnsContentAndTruncates() async throws { + let tree = try makeTree() + defer { try? FileManager.default.removeItem(at: tree) } + let readFile = action(connector(root: tree), "read-file") + + let full = try await readFile.handler(.object(["root": "test", "path": "notes.txt"])) + #expect(full["data"]?.dataValue == Data("hello world".utf8)) + #expect(full["truncated"]?.boolValue == false) + #expect(full["totalSize"]?.intValue == 11) + + let truncated = try await readFile.handler(.object([ + "root": "test", + "path": "notes.txt", + "maxBytes": 5, + ])) + #expect(truncated["data"]?.dataValue == Data("hello".utf8)) + #expect(truncated["truncated"]?.boolValue == true) + #expect(truncated["totalSize"]?.intValue == 11) + } + + @Test func traversalOutsideRootIsRejected() async throws { + let tree = try makeTree() + defer { try? FileManager.default.removeItem(at: tree) } + let readFile = action(connector(root: tree), "read-file") + await #expect(throws: PortholeError.self) { + _ = try await readFile.handler(.object(["root": "test", "path": "../../etc/passwd"])) + } + } + + @Test func symlinkEscapingRootIsRejected() async throws { + let tree = try makeTree() + defer { try? FileManager.default.removeItem(at: tree) } + // A symlink inside the root pointing outside it must not be followable. + let outside = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("porthole-outside-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: true) + try Data("secret".utf8).write(to: outside.appendingPathComponent("secret.txt")) + defer { try? FileManager.default.removeItem(at: outside) } + try FileManager.default.createSymbolicLink( + at: tree.appendingPathComponent("link"), + withDestinationURL: outside, + ) + + let readFile = action(connector(root: tree), "read-file") + await #expect(throws: PortholeError.self) { + _ = try await readFile.handler(.object(["root": "test", "path": "link/secret.txt"])) + } + } + + @Test func unknownRootIsRejected() async throws { + let tree = try makeTree() + defer { try? FileManager.default.removeItem(at: tree) } + let readFile = action(connector(root: tree), "read-file") + await #expect(throws: PortholeError.self) { + _ = try await readFile.handler(.object(["root": "ghost", "path": "notes.txt"])) + } + } +} diff --git a/Shared/Porthole/PortholeKit/Tests/NotificationsConnectorTests.swift b/Shared/Porthole/PortholeKit/Tests/NotificationsConnectorTests.swift new file mode 100644 index 00000000..9e223e99 --- /dev/null +++ b/Shared/Porthole/PortholeKit/Tests/NotificationsConnectorTests.swift @@ -0,0 +1,96 @@ +#if canImport(UserNotifications) + import Foundation + import PortholeCore + @_spi(Testing) import PortholeKit + import Testing + + private final class FakeNotificationCenter: NotificationCenterProviding, @unchecked Sendable { + var settings = NotificationSettingsSnapshot( + authorizationStatus: "authorized", + alertSetting: "enabled", + badgeSetting: "enabled", + soundSetting: "disabled", + lockScreenSetting: "enabled", + ) + var pending: [PendingNotificationInfo] = [] + var delivered: [DeliveredNotificationInfo] = [] + private(set) var removed: [String] = [] + + func settingsSnapshot() async -> NotificationSettingsSnapshot { + settings + } + + func pendingRequests() async -> [PendingNotificationInfo] { + pending + } + + func deliveredNotifications() async -> [DeliveredNotificationInfo] { + delivered + } + + func removePending(identifiers: [String]) async { + removed.append(contentsOf: identifiers) + } + } + + struct NotificationsConnectorTests { + private func source( + _ connector: NotificationsConnector, + _ id: PortholeDataSourceID, + ) -> PortholeDataSource { + connector.dataSources().first { $0.descriptor.id == id }! + } + + @Test func settingsRowMapsSnapshot() async throws { + let center = FakeNotificationCenter() + let connector = NotificationsConnector(center: center) + let page = try await source(connector, "settings").fetch(PortholeQuery()) + #expect(page.rows.count == 1) + #expect(page.rows.first?["authorizationStatus"]?.stringValue == "authorized") + #expect(page.rows.first?["soundSetting"]?.stringValue == "disabled") + } + + @Test func pendingRowsIncludeTriggerAndFireDate() async throws { + let center = FakeNotificationCenter() + let fireDate = Date(timeIntervalSince1970: 1_700_000_000) + center.pending = [ + PendingNotificationInfo( + identifier: "a", + title: "T", + body: "B", + triggerDescription: "UNCalendarNotificationTrigger", + nextFireDate: fireDate, + ), + ] + let connector = NotificationsConnector(center: center) + let page = try await source(connector, "pending").fetch(PortholeQuery()) + #expect(page.rows.first?["identifier"]?.stringValue == "a") + #expect(page.rows.first?["trigger"]?.stringValue == "UNCalendarNotificationTrigger") + #expect(page.rows.first?["nextFireDate"]?.dateValue != nil) + } + + @Test func deliveredRowsMap() async throws { + let center = FakeNotificationCenter() + center.delivered = [DeliveredNotificationInfo( + identifier: "d", + title: "T", + body: "B", + date: Date(), + )] + let connector = NotificationsConnector(center: center) + let page = try await source(connector, "delivered").fetch(PortholeQuery()) + #expect(page.rows.first?["identifier"]?.stringValue == "d") + } + + @Test func removePendingCallsThroughAndCounts() async throws { + let center = FakeNotificationCenter() + let connector = NotificationsConnector(center: center) + let action = try #require(connector.actions() + .first { $0.descriptor.id == "remove-pending" }) + #expect(action.descriptor.isDestructive) + let result = try await action.handler(.object(["identifiers": .array(["x", "y"])])) + #expect(result["removed"]?.intValue == 2) + #expect(center.removed == ["x", "y"]) + } + } +#endif diff --git a/Shared/Porthole/PortholeKit/Tests/PairingTestSupport.swift b/Shared/Porthole/PortholeKit/Tests/PairingTestSupport.swift new file mode 100644 index 00000000..8b071520 --- /dev/null +++ b/Shared/Porthole/PortholeKit/Tests/PairingTestSupport.swift @@ -0,0 +1,165 @@ +import CryptoKit +import Foundation +import PortholeCore +@testable import PortholeKit + +/// A mutable, injectable clock for deterministic expiry tests. +final class MutableClock: @unchecked Sendable { + private let lock = NSLock() + private var current: Date + + init(_ start: Date) { + current = start + } + + var now: Date { + lock.withLock { current } + } + + func advance(_ interval: TimeInterval) { + lock.withLock { current += interval } + } + + func nowClosure() -> @Sendable () -> Date { + { [weak self] in self?.now ?? Date() } + } +} + +/// Collects the device's published pairing code and lets a test client await it. +actor CodeBox { + private var code: String? + private var waiters: [CheckedContinuation] = [] + + func set(_ newCode: String?) { + guard let newCode else { return } + code = newCode + for waiter in waiters { + waiter.resume(returning: newCode) + } + waiters.removeAll() + } + + func waitForCode() async -> String { + if let code { return code } + return await withCheckedContinuation { waiters.append($0) } + } +} + +/// A minimal in-process client side of the handshake, for driving +/// `DevicePairingManager` in tests. (The production client is `PortholeClientKit`.) +enum TestPairingClient { + struct PairingOutcome { + var pairingID: UUID + var psk: SymmetricKey + } + + static func send( + _ transport: some PortholeTransport, + _ message: PortholePairingMessage, + ) async throws { + try await transport.send(JSONEncoder().encode(message)) + } + + static func receive(_ reader: TransportFrameReader) async throws -> PortholePairingMessage { + guard let frame = try await reader.next() else { throw PairingTestError.streamEnded } + return try JSONDecoder().decode(PortholePairingMessage.self, from: frame) + } + + /// Runs the pair flow, obtaining the code out-of-band via `code`. + static func pair( + transport: some PortholeTransport, + clientName: String, + code: @Sendable () async -> String, + overrideCode: (@Sendable (String) -> String)? = nil, + beforeConfirm: (@Sendable () async -> Void)? = nil, + ) async throws -> PairingOutcome { + let reader = TransportFrameReader(transport) + let clientPrivateKey = Curve25519.KeyAgreement.PrivateKey() + let clientPublicKey = clientPrivateKey.publicKey.rawRepresentation + + try await send( + transport, + .clientHello(mode: .pair(clientName: clientName, clientPublicKey: clientPublicKey)), + ) + guard case let .pairChallenge(devicePublicKey, salt) = try await receive(reader) else { + throw PairingTestError.unexpectedMessage + } + + var codeValue = await code() + if let overrideCode { codeValue = overrideCode(codeValue) } + await beforeConfirm?() + + let devicePub = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: devicePublicKey) + let psk = try PairingCryptography.pairingKey( + ownPrivateKey: clientPrivateKey, + peerPublicKey: devicePub, + salt: salt, + code: codeValue, + ) + let mac = PairingCryptography.confirmationCode( + key: psk, + clientPublicKey: clientPublicKey, + devicePublicKey: devicePublicKey, + salt: salt, + ) + try await send(transport, .pairConfirm(mac: mac)) + + switch try await receive(reader) { + case let .pairAccepted(pairingID, acceptMac): + guard PairingCryptography.isValidAcceptance( + acceptMac, + key: psk, + salt: salt, + clientPublicKey: clientPublicKey, + ) else { + throw PairingTestError.badAcceptance + } + return PairingOutcome(pairingID: pairingID, psk: psk) + case let .failure(error): + throw error + default: + throw PairingTestError.unexpectedMessage + } + } + + /// Runs the session flow with the paired PSK, returning the derived session + /// key (which should match the device's). + static func session( + transport: some PortholeTransport, + pairingID: UUID, + psk: SymmetricKey, + ) async throws -> SymmetricKey { + let reader = TransportFrameReader(transport) + let clientNonce = PairingCryptography.randomBytes(count: PairingCryptography.nonceByteCount) + try await send( + transport, + .clientHello(mode: .session(pairingID: pairingID, clientNonce: clientNonce)), + ) + switch try await receive(reader) { + case let .serverHello(serverNonce): + return PairingCryptography.sessionKey( + psk: psk, + clientNonce: clientNonce, + serverNonce: serverNonce, + ) + case let .failure(error): + throw error + default: + throw PairingTestError.unexpectedMessage + } + } +} + +extension TestPairingClient { + /// A 6-digit code guaranteed different from `code` (real code + 1, wrapping). + static func differentCode(from code: String) -> String { + let value = Int(code) ?? 0 + return String(format: "%06d", (value + 1) % 1_000_000) + } +} + +enum PairingTestError: Error { + case streamEnded + case unexpectedMessage + case badAcceptance +} diff --git a/Shared/Porthole/PortholeKit/Tests/PermissionsConnectorTests.swift b/Shared/Porthole/PortholeKit/Tests/PermissionsConnectorTests.swift new file mode 100644 index 00000000..992690b6 --- /dev/null +++ b/Shared/Porthole/PortholeKit/Tests/PermissionsConnectorTests.swift @@ -0,0 +1,52 @@ +import Foundation +import PortholeCore +@_spi(Testing) import PortholeKit +import Testing + +private struct FakePermissionsReader: PermissionsReading { + let values: [PermissionStatus] + func statuses() async -> [PermissionStatus] { + values + } +} + +struct PermissionsConnectorTests { + @Test func mapsEveryStatusToARow() async throws { + let reader = FakePermissionsReader(values: [ + PermissionStatus(permission: "notifications", status: "authorized"), + PermissionStatus(permission: "location", status: "denied"), + PermissionStatus(permission: "camera", status: "notDetermined"), + ]) + let connector = PermissionsConnector(reader: reader) + let source = try #require(connector.dataSources() + .first { $0.descriptor.id == "permissions" }) + let page = try await source.fetch(PortholeQuery()) + + #expect(page.rows.count == 3) + let byName = Dictionary(uniqueKeysWithValues: page.rows.compactMap { row -> ( + String, + String + )? in + guard let name = row["permission"]?.stringValue, + let status = row["status"]?.stringValue else { return nil } + return (name, status) + }) + #expect(byName["notifications"] == "authorized") + #expect(byName["location"] == "denied") + #expect(byName["camera"] == "notDetermined") + } + + @Test func systemReaderReturnsAllKnownPermissionsWithoutPrompting() async { + // The production reader must return a row for each known permission and + // never prompt (all reads are status queries). + let statuses = await SystemPermissionsReader().statuses() + let names = Set(statuses.map(\.permission)) + #expect(names.isSuperset(of: [ + "notifications", + "location", + "camera", + "microphone", + "photos", + ])) + } +} diff --git a/Shared/Porthole/PortholeKit/Tests/PortholeKitTestSupport.swift b/Shared/Porthole/PortholeKit/Tests/PortholeKitTestSupport.swift new file mode 100644 index 00000000..db8edcff --- /dev/null +++ b/Shared/Porthole/PortholeKit/Tests/PortholeKitTestSupport.swift @@ -0,0 +1,189 @@ +import Foundation +import PortholeCore +@testable import PortholeKit + +struct TestTimeoutError: Error {} + +func withTimeout( + _ duration: Duration = .seconds(2), + _ operation: @escaping @Sendable () async throws -> T, +) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(for: duration) + throw TestTimeoutError() + } + let result = try await group.next()! + group.cancelAll() + return result + } +} + +/// A minimal in-process client for exercising a `PortholeSessionRouter` over a +/// loopback transport: it matches responses to requests by id and funnels +/// unsolicited events into a stream. (The real client is `PortholeClientKit`; +/// this is just enough to drive the runtime's tests.) +actor TestSessionClient { + enum Failure: Error { case connectionClosed } + + private let transport: any PortholeTransport + private var pending: [UInt64: CheckedContinuation] = [:] + private var nextID: UInt64 = 1 + private var readTask: Task? + + let events: AsyncStream + private let eventsContinuation: AsyncStream.Continuation + + init(transport: some PortholeTransport) { + self.transport = transport + (events, eventsContinuation) = AsyncStream.makeStream() + } + + func start() { + readTask = Task { await self.readLoop() } + } + + private func readLoop() async { + do { + for try await frame in transport.incoming { + let envelope = try JSONDecoder().decode(PortholeResponseEnvelope.self, from: frame) + if let id = envelope.requestID { + pending.removeValue(forKey: id)?.resume(returning: envelope.response) + } else { + eventsContinuation.yield(envelope) + } + } + } catch {} + for (_, continuation) in pending { + continuation.resume(throwing: Failure.connectionClosed) + } + pending.removeAll() + eventsContinuation.finish() + } + + func send(_ request: PortholeRequest) async throws -> PortholeResponse { + let id = nextID + nextID += 1 + return try await withCheckedThrowingContinuation { continuation in + pending[id] = continuation + Task { + do { + let frame = try JSONEncoder().encode(PortholeRequestEnvelope( + id: id, + request: request, + )) + try await transport.send(frame) + } catch { + self.fail(id: id, with: error) + } + } + } + } + + private func fail(id: UInt64, with error: Error) { + pending.removeValue(forKey: id)?.resume(throwing: error) + } + + /// Reads the next `count` event payloads (subscription id + value). + func nextEvents(_ count: Int) async throws -> [(UInt64, PortholeValue)] { + try await withTimeout { + var collected: [(UInt64, PortholeValue)] = [] + for await envelope in self.events { + if case let .event(subscriptionID, value) = envelope.response { + collected.append((subscriptionID, value)) + if collected.count == count { break } + } + } + return collected + } + } +} + +/// A connector fixture exercising the router's dispatch, validation, error, and +/// subscription paths. +final class TestEchoConnector: PortholeConnector { + let descriptor = PortholeConnectorDescriptor( + id: "test", + title: "Test", + summary: "Fixture connector.", + version: 1, + ) + + func actions() -> [PortholeAction] { + [ + PortholeAction( + descriptor: PortholeActionDescriptor( + id: "echo", + title: "Echo", + summary: "Echoes the given integer.", + parameters: .object( + ["value": .integer("A value to echo")], + required: ["value"], + ), + isDestructive: false, + ), + handler: { parameters in + .object(["echoed": parameters["value"] ?? .null]) + }, + ), + PortholeAction( + descriptor: PortholeActionDescriptor( + id: "boom", + title: "Boom", + summary: "Always throws.", + parameters: .object([:]), + isDestructive: false, + ), + handler: { _ in throw TestConnectorError.boom }, + ), + ] + } + + func dataSources() -> [PortholeDataSource] { + [ + PortholeDataSource( + descriptor: PortholeDataSourceDescriptor( + id: "numbers", + title: "Numbers", + summary: "Returns count rows.", + rowSchema: .object(["n": .integer()]), + filters: .object(["count": .integer()]), + supportsSubscription: false, + ), + fetch: { query in + let count = Int(query.filters["count"]?.intValue ?? 0) + let rows = (0 ..< count).map { PortholeValue.object(["n": .int(Int64($0))]) } + return PortholePage(rows: rows, nextCursor: nil, totalCount: count) + }, + ), + PortholeDataSource( + descriptor: PortholeDataSourceDescriptor( + id: "ticks", + title: "Ticks", + summary: "Emits an incrementing counter.", + rowSchema: .object(["tick": .integer()]), + filters: .object([:]), + supportsSubscription: true, + ), + fetch: { _ in PortholePage(rows: [], nextCursor: nil) }, + subscribe: { + AsyncStream { continuation in + let task = Task { + var tick = 0 + while !Task.isCancelled { + continuation.yield(.object(["tick": .int(Int64(tick))])) + tick += 1 + try? await Task.sleep(for: .milliseconds(2)) + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + }, + ), + ] + } +} + +enum TestConnectorError: Error { case boom } diff --git a/Shared/Porthole/PortholeKit/Tests/PortholeSessionRouterTests.swift b/Shared/Porthole/PortholeKit/Tests/PortholeSessionRouterTests.swift new file mode 100644 index 00000000..973a15e6 --- /dev/null +++ b/Shared/Porthole/PortholeKit/Tests/PortholeSessionRouterTests.swift @@ -0,0 +1,197 @@ +import Foundation +import PortholeCore +@_spi(Testing) import PortholeKit +import Testing + +@MainActor +struct PortholeSessionRouterTests { + /// Wires a `Porthole` (with the echo fixture registered) to an in-process + /// client over a loopback pair. + private func makeSession() -> (Porthole, TestSessionClient) { + let porthole = Porthole(configuration: PortholeConfiguration( + appName: "TestApp", + bundleID: "com.stuff.test", + )) + porthole.register(TestEchoConnector()) + let (deviceTransport, clientTransport) = LoopbackTransport.makePair() + porthole.attach(transport: deviceTransport) + let client = TestSessionClient(transport: clientTransport) + return (porthole, client) + } + + @Test func helloReturnsAppAndDeviceInfo() async throws { + let (_, client) = makeSession() + await client.start() + let response = try await client.send(.hello(HelloRequest(clientName: "test"))) + guard case let .helloReply(reply) = response else { + Issue.record("Expected helloReply, got \(response)") + return + } + #expect(reply.appName == "TestApp") + #expect(reply.bundleID == "com.stuff.test") + #expect(reply.protocolVersion == portholeProtocolVersion) + } + + @Test func listConnectorsIncludesAppAndRegisteredConnectors() async throws { + let (_, client) = makeSession() + await client.start() + let response = try await client.send(.listConnectors) + guard case let .connectors(manifests) = response else { + Issue.record("Expected connectors, got \(response)") + return + } + let ids = Set(manifests.map(\.connector.id)) + #expect(ids.contains("app")) + #expect(ids.contains("test")) + } + + @Test func pingEchoesMessageWithTimestamp() async throws { + let (_, client) = makeSession() + await client.start() + let response = try await client.send(.invokeAction( + ref: .init(connector: "app", action: "ping"), + parameters: ["message": "hi"], + )) + guard case let .actionResult(value) = response else { + Issue.record("Expected actionResult, got \(response)") + return + } + #expect(value["message"]?.stringValue == "hi") + #expect(value["timestamp"]?.dateValue != nil) + } + + @Test func appInfoQueryReturnsOneRow() async throws { + let (_, client) = makeSession() + await client.start() + let response = try await client.send(.query( + ref: .init(connector: "app", source: "app-info"), + query: PortholeQuery(), + )) + guard case let .queryResult(page) = response else { + Issue.record("Expected queryResult, got \(response)") + return + } + #expect(page.rows.count == 1) + #expect(page.rows.first?["bundleID"]?.stringValue == "com.stuff.test") + #expect(page.rows.first?["appName"]?.stringValue == "TestApp") + } + + @Test func echoActionValidatesAndRuns() async throws { + let (_, client) = makeSession() + await client.start() + let response = try await client.send(.invokeAction( + ref: .init(connector: "test", action: "echo"), + parameters: ["value": 7], + )) + guard case let .actionResult(value) = response else { + Issue.record("Expected actionResult, got \(response)") + return + } + #expect(value["echoed"]?.intValue == 7) + } + + @Test func missingRequiredParameterFailsValidation() async throws { + let (_, client) = makeSession() + await client.start() + let response = try await client.send(.invokeAction( + ref: .init(connector: "test", action: "echo"), + parameters: .object([:]), + )) + guard case let .failure(error) = response, case .invalidParameters = error else { + Issue.record("Expected invalidParameters, got \(response)") + return + } + } + + @Test func throwingHandlerBecomesHandlerFailed() async throws { + let (_, client) = makeSession() + await client.start() + let response = try await client.send(.invokeAction( + ref: .init(connector: "test", action: "boom"), + parameters: .object([:]), + )) + guard case let .failure(error) = response, case .handlerFailed = error else { + Issue.record("Expected handlerFailed, got \(response)") + return + } + } + + @Test func unknownConnectorAndActionFail() async throws { + let (_, client) = makeSession() + await client.start() + + let unknownConnector = try await client.send(.invokeAction( + ref: .init(connector: "ghost", action: "x"), + parameters: .object([:]), + )) + #expect({ if case .failure(.connectorNotFound) = unknownConnector { true } else { false } + }()) + + let unknownAction = try await client.send(.invokeAction( + ref: .init(connector: "app", action: "nope"), + parameters: .object([:]), + )) + #expect({ if case .failure(.actionNotFound) = unknownAction { true } else { false } }()) + } + + @Test func numbersQueryReturnsRequestedRows() async throws { + let (_, client) = makeSession() + await client.start() + let response = try await client.send(.query( + ref: .init(connector: "test", source: "numbers"), + query: PortholeQuery(filters: ["count": 3]), + )) + guard case let .queryResult(page) = response else { + Issue.record("Expected queryResult, got \(response)") + return + } + #expect(page.rows.count == 3) + #expect(page.totalCount == 3) + } + + @Test func subscribeStreamsEventsAndSessionSurvivesUnsubscribe() async throws { + let (_, client) = makeSession() + await client.start() + + let subscribe = try await client.send(.subscribe(ref: .init( + connector: "test", + source: "ticks", + ))) + guard case let .subscribed(subscriptionID) = subscribe else { + Issue.record("Expected subscribed, got \(subscribe)") + return + } + + let events = try await client.nextEvents(3) + #expect(events.count == 3) + #expect(events.allSatisfy { $0.0 == subscriptionID }) + #expect(events.first?.1["tick"]?.intValue == 0) + + let unsubscribe = try await client.send(.unsubscribe(subscriptionID: subscriptionID)) + #expect({ if case .pong = unsubscribe { true } else { false } }()) + + // The session is still healthy after unsubscribing. + let ping = try await client.send(.ping) + #expect({ if case .pong = ping { true } else { false } }()) + } + + @Test func subscribingToANonSubscribableSourceFails() async throws { + let (_, client) = makeSession() + await client.start() + let response = try await client.send(.subscribe(ref: .init( + connector: "test", + source: "numbers", + ))) + guard case let .failure(error) = response, case .subscriptionNotSupported = error else { + Issue.record("Expected subscriptionNotSupported, got \(response)") + return + } + } + + @Test func activeSessionCountReflectsAttachedSessions() async throws { + let (porthole, client) = makeSession() + await client.start() + _ = try await client.send(.ping) + #expect(porthole.state.activeSessionCount == 1) + } +} diff --git a/Shared/Porthole/PortholeKit/Tests/ViewTreeConnectorTests.swift b/Shared/Porthole/PortholeKit/Tests/ViewTreeConnectorTests.swift new file mode 100644 index 00000000..ca2402db --- /dev/null +++ b/Shared/Porthole/PortholeKit/Tests/ViewTreeConnectorTests.swift @@ -0,0 +1,142 @@ +#if canImport(UIKit) + import Foundation + import PortholeCore + @_spi(Testing) import PortholeKit + import TestHostSupport + import Testing + import UIKit + + @MainActor + struct ViewTreeConnectorTests { + private func makeSession() -> (Porthole, TestSessionClient) { + let porthole = Porthole( + configuration: PortholeConfiguration( + appName: "UITest", + bundleID: "com.stuff.uitest", + ), + ) + let (deviceTransport, clientTransport) = LoopbackTransport.makePair() + porthole.attach(transport: deviceTransport) + return (porthole, TestSessionClient(transport: clientTransport)) + } + + /// Hosts `viewController` in the test host window across an async body + /// (the sync `show(_:perform:)` can't await). + private func hosted( + _ viewController: UIViewController, + _ body: () async throws -> Void, + ) async throws { + try waitFor { hostKeyWindow()?.rootViewController != nil } + guard let root = hostKeyWindow()?.rootViewController else { return } + root.addChild(viewController) + viewController.view.frame = root.view.bounds + root.view.addSubview(viewController.view) + viewController.didMove(toParent: root) + viewController.view.layoutIfNeeded() + defer { + viewController.willMove(toParent: nil) + viewController.view.removeFromSuperview() + viewController.removeFromParent() + } + try await body() + } + + @Test func windowsSourceReportsAtLeastTheHostWindow() async throws { + let (_, client) = makeSession() + try await hosted(UIViewController()) { + await client.start() + let response = try await client.send(.query( + ref: .init(connector: "ui", source: "windows"), + query: PortholeQuery(), + )) + guard case let .queryResult(page) = response else { + Issue.record("Expected queryResult, got \(response)") + return + } + #expect(!page.rows.isEmpty) + #expect(page.rows.first?["class"]?.stringValue != nil) + } + } + + @Test func viewTreeReflectsAKnownHierarchy() async throws { + let container = UIViewController() + let tagged = UIView() + tagged.accessibilityIdentifier = "porthole-probe-view" + container.view.addSubview(tagged) + + let (_, client) = makeSession() + try await hosted(container) { + await client.start() + let response = try await client.send(.query( + ref: .init(connector: "ui", source: "view-tree"), + query: PortholeQuery(filters: ["maxDepth": 50]), + )) + guard case let .queryResult(page) = response, let root = page.rows.first else { + Issue.record("Expected a view-tree row, got \(response)") + return + } + #expect(root["class"]?.stringValue != nil) + #expect(containsIdentifier("porthole-probe-view", in: root)) + } + } + + @Test func accessibilityTreeReturnsARootRow() async throws { + let (_, client) = makeSession() + try await hosted(UIViewController()) { + await client.start() + let response = try await client.send(.query( + ref: .init(connector: "ui", source: "accessibility-tree"), + query: PortholeQuery(), + )) + guard case let .queryResult(page) = response else { + Issue.record("Expected queryResult, got \(response)") + return + } + #expect(page.rows.count == 1) + } + } + + @Test func screenshotReturnsDecodablePNG() async throws { + let container = UIViewController() + container.view.backgroundColor = .systemBlue + let (_, client) = makeSession() + try await hosted(container) { + await client.start() + let response = try await client.send(.invokeAction( + ref: .init(connector: "ui", action: "screenshot"), + parameters: ["windowIndex": 0], + )) + guard case let .actionResult(value) = response else { + Issue.record("Expected actionResult, got \(response)") + return + } + let data = try #require(value["image"]?.dataValue) + #expect(UIImage(data: data) != nil) + #expect((value["width"]?.intValue ?? 0) > 0) + } + } + + @Test func openURLRejectsAnInvalidURL() async throws { + let (_, client) = makeSession() + try await hosted(UIViewController()) { + await client.start() + let response = try await client.send(.invokeAction( + ref: .init(connector: "ui", action: "open-url"), + parameters: ["url": ""], + )) + guard case .failure = response else { + Issue.record("Expected failure for an empty URL, got \(response)") + return + } + } + } + + private func containsIdentifier(_ id: String, in node: PortholeValue) -> Bool { + if node["accessibilityIdentifier"]?.stringValue == id { return true } + for child in node["children"]?.arrayValue ?? [] { + if containsIdentifier(id, in: child) { return true } + } + return false + } + } +#endif diff --git a/Shared/Porthole/PortholeKitUI/AGENTS.md b/Shared/Porthole/PortholeKitUI/AGENTS.md new file mode 100644 index 00000000..ec1a9a1c --- /dev/null +++ b/Shared/Porthole/PortholeKitUI/AGENTS.md @@ -0,0 +1,35 @@ +# PortholeKitUI – Module Shape + +PortholeKitUI is Porthole's on-device pairing/status UI: `PortholePairingView` +plus the Broadway `PortholeStylesheet` and `portholeBroadwayRoot()`. See +[`README.md`](README.md). + +This file complements the root [`AGENTS.md`](../../../AGENTS.md) — read it first. + +## Scope & dependencies + +- **SwiftUI + PortholeKit + BroadwayCore/BroadwayUI.** No app code, no client + side — it binds to a `Porthole`'s observable `state` and calls its + `start()`/`stop()`/`revoke(_:)`. +- Broadway is linked statically here (like PeriscopeTools), so the view can seed + its own root. If this ever becomes/embeds a dynamic framework, follow WhereUI's + rule: consumers must not re-link Broadway or the type-keyed environment splits. + +## Invariants + +- **Each public view seeds its own Broadway root** (`portholeBroadwayRoot()`) and + its content reads `@Environment(\.stylesheet)`; appearance tokens live in + `PortholeStylesheet`, not inline. The trait-reactive slice (pairing-code + tracking) applies only in `init(context:)` so a system context reproduces + `default`. +- **Bind to observable state, don't fake it.** The advertising control is a + start/stop action (start can throw → surfaced via an alert), not a two-way + `Bool` binding, since `state.isAdvertising` is `internal(set)`. No + `Binding(get:set:)`. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PortholeKitUITests`, `extraPackageProducts: [PortholeKit, PortholeCore]` — not +Broadway, which comes transitively). Pin default tokens; use a hosted probe for +the trait-aware boundary crossing and a render smoke test. diff --git a/Shared/Porthole/PortholeKitUI/README.md b/Shared/Porthole/PortholeKitUI/README.md new file mode 100644 index 00000000..b81e1b6f --- /dev/null +++ b/Shared/Porthole/PortholeKitUI/README.md @@ -0,0 +1,37 @@ +# PortholeKitUI + +PortholeKitUI is the on-device pairing/status UI for [Porthole](../), styled with +[Broadway](../../Broadway). Drop `PortholePairingView` into a developer menu to +start/stop advertising, show the pending pairing code, list paired Macs (swipe to +revoke), and see the active session count. + +## Using it + +```swift +import PortholeKitUI + +// `porthole` is your app's Porthole instance (see PortholeKit) +NavigationLink("Porthole") { + PortholePairingView(porthole: porthole) +} +``` + +The view seeds its own Broadway root (`portholeBroadwayRoot()`), so it styles +correctly whether or not the host app has a Broadway root of its own. Gate it +behind `#if DEBUG` — it's a developer surface. + +## Design system + +Appearance tokens live in `PortholeStylesheet` (a Broadway `BStylesheet`), read +via `@Environment(\.stylesheet)`; off the `View` tree use +`PortholeStylesheet.default`. Most tokens are fixed; the pairing-code face widens +its tracking at accessibility Dynamic Type sizes. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PortholeKitUITests`). Pins the default tokens and the environment fallback, and +hosts a probe (via `TestHostSupport`) to confirm the trait-aware token resolves +across the PortholeKitUI↔BroadwayUI boundary, plus a `PortholePairingView` +render smoke test. Broadway is reached transitively through PortholeKitUI — the +test bundle does not re-link it. diff --git a/Shared/Porthole/PortholeKitUI/Sources/PortholePairingView.swift b/Shared/Porthole/PortholeKitUI/Sources/PortholePairingView.swift new file mode 100644 index 00000000..970c8642 --- /dev/null +++ b/Shared/Porthole/PortholeKitUI/Sources/PortholePairingView.swift @@ -0,0 +1,151 @@ +import PortholeKit +import SwiftUI + +/// The device-side pairing and status surface: start/stop advertising, show the +/// pending pairing code, list paired hosts (swipe to revoke), and show the +/// active session count. Broadway-styled; drop it into a developer menu. +/// +/// Seeds its own Broadway root so it styles correctly with or without a host app +/// root; the content reads `@Environment(\.stylesheet)`. +public struct PortholePairingView: View { + private let porthole: Porthole + + public init(porthole: Porthole) { + self.porthole = porthole + } + + public var body: some View { + PairingContent(porthole: porthole) + .portholeBroadwayRoot() + } +} + +private struct PairingContent: View { + let porthole: Porthole + @Environment(\.stylesheet) private var stylesheet + @State private var isShowingError = false + @State private var errorMessage = "" + + var body: some View { + List { + if porthole.state.isAdvertising { + advertisingSection + pairedHostsSection + } else { + idleSection + } + } + .alert("Couldn't start Porthole", isPresented: $isShowingError) { + Button("OK", role: .cancel) {} + } message: { + Text(errorMessage) + } + } + + private var idleSection: some View { + Section { + Button("Start Pairing") { start() } + } footer: { + Text( + "Advertise \(porthole.configuration.appName) on the local network so a paired Mac can connect.", + ) + } + } + + private var advertisingSection: some View { + Section { + Label( + "Advertising as \(porthole.configuration.appName)", + systemImage: "dot.radiowaves.left.and.right", + ) + .foregroundStyle(stylesheet.palette.advertising) + if let code = porthole.state.pendingPairingCode { + PairingCodeDisplay(code: code) + } + HStack { + Text("Active sessions") + Spacer() + Text("\(porthole.state.activeSessionCount)") + .foregroundStyle(stylesheet.palette.sessionBadge) + .monospacedDigit() + } + .font(stylesheet.typography.body) + Button("Stop", role: .destructive) { porthole.stop() } + } footer: { + Text("Enter the code above on the Mac when it prompts you.") + } + } + + @ViewBuilder private var pairedHostsSection: some View { + if !porthole.state.pairedHosts.isEmpty { + Section("Paired Macs") { + ForEach(porthole.state.pairedHosts) { host in + PairedHostRow(host: host) + .swipeActions { + Button("Revoke", role: .destructive) { revoke(host) } + } + } + } + } + } + + private func start() { + do { + try porthole.start() + } catch { + errorMessage = String(describing: error) + isShowingError = true + } + } + + private func revoke(_ host: PairedHost) { + Task { + do { + try await porthole.revoke(host.pairingID) + } catch { + errorMessage = String(describing: error) + isShowingError = true + } + } + } +} + +/// The pending 6-digit pairing code, shown large and spaced for reading aloud. +private struct PairingCodeDisplay: View { + let code: String + @Environment(\.stylesheet) private var stylesheet + + var body: some View { + Text(code) + .font(stylesheet.code.font) + .tracking(stylesheet.code.digitTracking) + .monospacedDigit() + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, stylesheet.code.verticalPadding) + .accessibilityLabel("Pairing code \(code.map(String.init).joined(separator: " "))") + } +} + +/// One paired Mac row: name and when it paired. +private struct PairedHostRow: View { + let host: PairedHost + @Environment(\.stylesheet) private var stylesheet + + var body: some View { + VStack(alignment: .leading, spacing: stylesheet.spacing.small / 2) { + Text(host.name) + .font(stylesheet.typography.hostName) + Text("Paired \(host.createdAt.formatted(date: .abbreviated, time: .shortened))") + .font(stylesheet.typography.hostDetail) + .foregroundStyle(stylesheet.palette.idle) + } + } +} + +#if DEBUG + #Preview { + PortholePairingView( + porthole: Porthole(configuration: PortholeConfiguration(appName: "Where")), + ) + } +#endif diff --git a/Shared/Porthole/PortholeKitUI/Sources/Styling/PortholeStylesheet.swift b/Shared/Porthole/PortholeKitUI/Sources/Styling/PortholeStylesheet.swift new file mode 100644 index 00000000..ee96110a --- /dev/null +++ b/Shared/Porthole/PortholeKitUI/Sources/Styling/PortholeStylesheet.swift @@ -0,0 +1,97 @@ +import BroadwayCore +import BroadwayUI +import CoreGraphics +import SwiftUI + +/// PortholeKitUI's design tokens, resolved as a Broadway ``BStylesheet``. Most +/// tokens are fixed; a slice derives from the ``BContext`` traits (a roomier +/// pairing-code face at accessibility Dynamic Type sizes). Views read the active +/// tokens via `@Environment(\.stylesheet)` (seeded by +/// ``SwiftUI/View/portholeBroadwayRoot()``); off the `View` tree, use ``default``. +struct PortholeStylesheet: BStylesheet { + var spacing = Spacing() + var code = CodeStyle.standard + var palette = Palette.standard + var typography = Typography.standard + + init() {} + + init(context: SlicingContext) throws { + // Start from the fixed set, then adjust only the trait-reactive slice so + // a default/system context reproduces `default`. + if context.traits.contentSizeCategory.isAccessibilitySize { + code.digitTracking = 10 + } + } + + /// The fixed token set: the fallback off the `View` tree and when no Broadway + /// root has seeded a context. + static let `default` = PortholeStylesheet() +} + +extension PortholeStylesheet { + /// Generic spacing scale in points. + struct Spacing: Equatable { + var small: CGFloat = 8 + var medium: CGFloat = 16 + var large: CGFloat = 24 + } + + /// The large pairing-code display. + struct CodeStyle: Equatable { + var font: Font = .system(size: 44, weight: .bold, design: .monospaced) + /// Extra tracking between digits. + var digitTracking: CGFloat = 6 + var verticalPadding: CGFloat = 12 + + static let standard = CodeStyle() + } + + /// Semantic colors for status. + struct Palette: Equatable { + var advertising: Color = .green + var idle: Color = .secondary + var sessionBadge: Color = .blue + + static let standard = Palette() + } + + /// Display faces named by role. + struct Typography: Equatable { + var title: Font = .headline + var body: Font = .body + var caption: Font = .caption + var hostName: Font = .body.weight(.medium) + var hostDetail: Font = .caption + + static let standard = Typography() + } +} + +/// PortholeKitUI's Broadway themes, seeded at each root by +/// ``SwiftUI/View/portholeBroadwayRoot()``. Empty for now — the sheet derives +/// from traits, not themes. +enum PortholeThemes { + static var current: BThemes { + BThemes() + } +} + +extension View { + /// Seeds PortholeKitUI's Broadway context (live system traits plus + /// ``PortholeThemes``) so descendants resolve `@Environment(\.stylesheet)` + /// against real traits rather than ``PortholeStylesheet/default``. Applied on + /// each public view so it styles correctly with or without a host app root. + func portholeBroadwayRoot() -> some View { + broadwayRoot(themes: PortholeThemes.current) + } +} + +extension EnvironmentValues { + /// The active PortholeKitUI tokens, resolved from the Broadway `BContext` + /// seeded by ``SwiftUI/View/portholeBroadwayRoot()``; falls back to + /// ``PortholeStylesheet/default`` with no root present. + var stylesheet: PortholeStylesheet { + bContext.stylesheet(PortholeStylesheet.self, fallback: .default) + } +} diff --git a/Shared/Porthole/PortholeKitUI/Tests/PortholeKitUIHostedTests.swift b/Shared/Porthole/PortholeKitUI/Tests/PortholeKitUIHostedTests.swift new file mode 100644 index 00000000..74d369f0 --- /dev/null +++ b/Shared/Porthole/PortholeKitUI/Tests/PortholeKitUIHostedTests.swift @@ -0,0 +1,47 @@ +import BroadwayCore +import BroadwayUI +import PortholeKit +@testable import PortholeKitUI +import SwiftUI +import TestHostSupport +import Testing +import UIKit + +/// Covers the PortholeKitUI Broadway glue and that its views render — the +/// trait-aware slice resolves across the PortholeKitUI↔BroadwayUI boundary, and +/// `PortholePairingView` hosts without crashing. +@MainActor +struct PortholeKitUIHostedTests { + @Test func resolvesTraitAwareTokensFromTheBroadwayRoot() throws { + let box = StylesheetProbeBox() + let host = UIHostingController( + rootView: StylesheetProbe(box: box) + .bContentSizeCategory(.accessibilityLarge) + .portholeBroadwayRoot(), + ) + try show(host) { _ in + try waitFor { box.digitTracking == 10 } + } + } + + @Test func pairingViewHostsWithoutCrashing() throws { + let porthole = Porthole(configuration: PortholeConfiguration(appName: "TestApp")) + let host = UIHostingController(rootView: PortholePairingView(porthole: porthole)) + try show(host) { controller in + try waitFor { controller.view.window != nil } + } + } +} + +private final class StylesheetProbeBox { + var digitTracking: CGFloat? +} + +private struct StylesheetProbe: View { + let box: StylesheetProbeBox + @Environment(\.stylesheet) private var stylesheet + + var body: some View { + Color.clear.onAppear { box.digitTracking = stylesheet.code.digitTracking } + } +} diff --git a/Shared/Porthole/PortholeKitUI/Tests/PortholeStylesheetTests.swift b/Shared/Porthole/PortholeKitUI/Tests/PortholeStylesheetTests.swift new file mode 100644 index 00000000..819905e0 --- /dev/null +++ b/Shared/Porthole/PortholeKitUI/Tests/PortholeStylesheetTests.swift @@ -0,0 +1,20 @@ +@testable import PortholeKitUI +import SwiftUI +import Testing + +struct PortholeStylesheetTests { + @Test func defaultTokensArePinned() { + let sheet = PortholeStylesheet.default + #expect(sheet.spacing.small == 8) + #expect(sheet.spacing.medium == 16) + #expect(sheet.spacing.large == 24) + #expect(sheet.code.digitTracking == 6) + #expect(sheet.code.verticalPadding == 12) + #expect(sheet.palette.advertising == .green) + #expect(sheet.palette.sessionBadge == .blue) + } + + @Test func environmentFallsBackToDefaultWithoutAContext() { + #expect(EnvironmentValues().stylesheet == .default) + } +} diff --git a/Shared/Porthole/PortholeLifecycle/AGENTS.md b/Shared/Porthole/PortholeLifecycle/AGENTS.md new file mode 100644 index 00000000..6cbf33fc --- /dev/null +++ b/Shared/Porthole/PortholeLifecycle/AGENTS.md @@ -0,0 +1,25 @@ +# PortholeLifecycle – Module Shape + +PortholeLifecycle is the `lifecycle` Porthole connector: a `launch-state` data +source over a LifecycleKit `LifecycleRunner`. See [`README.md`](README.md). + +This file complements the root [`AGENTS.md`](../../../AGENTS.md) — read it first. + +## Scope & dependencies + +- Depends on **PortholeKit** + **LifecycleKit** only. A separate module (not part + of PortholeKit) because the runtime must not depend on LifecycleKit. +- **Inject the app's `LifecycleRunner`; never create one** (create-once/inject-down). + +## Invariants + +- **Reads the runner on the main actor** (it's `@MainActor`), snapshotting into a + Sendable row. It surfaces exactly what the runner publishes — `phase`, + `reason`, the running step id, and any failure — not a fabricated step list + (the runner doesn't expose its steps). No actions in v1. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PortholeLifecycleTests`, `extraPackageProducts: [PortholeKit, PortholeCore, +LifecycleKit]`). Drive a scripted `LifecycleSteps` and assert the row's phase. diff --git a/Shared/Porthole/PortholeLifecycle/README.md b/Shared/Porthole/PortholeLifecycle/README.md new file mode 100644 index 00000000..985529a6 --- /dev/null +++ b/Shared/Porthole/PortholeLifecycle/README.md @@ -0,0 +1,32 @@ +# PortholeLifecycle + +PortholeLifecycle is a [Porthole](../) connector that exposes an app's launch +state — from the [LifecycleKit](../../LifecycleKit) `LifecycleRunner` it already +owns — so an agent can answer "is the app ready?" before querying other +connectors. + +## Using it + +```swift +import PortholeLifecycle + +// `launchRunner` is the LifecycleRunner your app already drives. +porthole.register(LifecycleConnector(runner: launchRunner)) +``` + +Inject the runner — don't create one. The connector reads it on the main actor. + +## Surface (id `lifecycle`) + +- Data source `launch-state` — a single row: `phase` (launching / running / + failed / ready), `reason`, the `runningStep` (when a step is active), and + `failedStep` + `error` (when the launch failed). + +No actions in v1 (retry/teardown actions are on the roadmap). + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PortholeLifecycleTests`). Drives a scripted `LifecycleSteps` through its phases +and asserts the `launch-state` row tracks launching → ready and a failing step's +failed phase + step/error. diff --git a/Shared/Porthole/PortholeLifecycle/Sources/LifecycleConnector.swift b/Shared/Porthole/PortholeLifecycle/Sources/LifecycleConnector.swift new file mode 100644 index 00000000..06721e6f --- /dev/null +++ b/Shared/Porthole/PortholeLifecycle/Sources/LifecycleConnector.swift @@ -0,0 +1,70 @@ +import Foundation +import LifecycleKit +import PortholeCore +import PortholeKit + +/// A Porthole connector (id `lifecycle`) exposing an app's launch state from the +/// `LifecycleRunner` it already owns — inject that runner, never create one. +public final class LifecycleConnector: PortholeConnector { + public let descriptor = PortholeConnectorDescriptor( + id: "lifecycle", + title: "Lifecycle", + summary: "The app's launch phase, launch reason, the running step, and any launch failure.", + version: 1, + ) + + private let runner: LifecycleRunner + + public init(runner: LifecycleRunner) { + self.runner = runner + } + + public func dataSources() -> [PortholeDataSource] { + let runner = runner + return [ + PortholeDataSource( + descriptor: PortholeDataSourceDescriptor( + id: "launch-state", + title: "Launch state", + summary: "A single row describing the current launch phase and reason.", + rowSchema: .object([ + "phase": .string(), + "reason": .string(), + "runningStep": .string(), + "failedStep": .string(), + "error": .string(), + ]), + filters: .object([:]), + supportsSubscription: false, + ), + fetch: { _ in await Self.snapshot(runner) }, + ), + ] + } + + @MainActor + static func snapshot(_ runner: LifecycleRunner) -> PortholePage { + let phase = runner.phase + var object: [String: PortholeValue] = [ + "phase": .string(phaseName(phase)), + "reason": .string(String(describing: runner.reason)), + ] + if let running = phase.runningStepID { + object["runningStep"] = .string(String(describing: running)) + } + if let failure = phase.failure { + object["failedStep"] = .string(String(describing: failure.stepID)) + object["error"] = .string(String(describing: failure.error)) + } + return PortholePage(rows: [.object(object)], totalCount: 1) + } + + static func phaseName(_ phase: LifecyclePhase) -> String { + switch phase { + case .launching: "launching" + case .running: "running" + case .failed: "failed" + case .ready: "ready" + } + } +} diff --git a/Shared/Porthole/PortholeLifecycle/Tests/LifecycleConnectorTests.swift b/Shared/Porthole/PortholeLifecycle/Tests/LifecycleConnectorTests.swift new file mode 100644 index 00000000..58561101 --- /dev/null +++ b/Shared/Porthole/PortholeLifecycle/Tests/LifecycleConnectorTests.swift @@ -0,0 +1,44 @@ +import Foundation +import LifecycleKit +import PortholeCore +@testable import PortholeLifecycle +import Testing + +@MainActor +struct LifecycleConnectorTests { + private func launchState(_ runner: LifecycleRunner) async throws -> PortholeValue { + let connector = LifecycleConnector(runner: runner) + let source = connector.dataSources().first { $0.descriptor.id == "launch-state" }! + let page = try await source.fetch(PortholeQuery()) + return try #require(page.rows.first) + } + + @Test func reportsLaunchingBeforeRun() async throws { + let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { [] }) + let row = try await launchState(runner) + #expect(row["phase"]?.stringValue == "launching") + #expect(row["reason"]?.stringValue?.contains("userForeground") == true) + } + + @Test func reportsReadyAfterAllStepsFinish() async throws { + let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { + LifecycleStep.work("open-store") { _ in } + }) + await runner.run() + let row = try await launchState(runner) + #expect(row["phase"]?.stringValue == "ready") + } + + @Test func reportsFailureWithStepAndError() async throws { + let runner = LifecycleRunner(reason: .userForeground, sequence: LifecycleSteps { + LifecycleStep.work("bad-step") { _ in throw LaunchTestError.boom } + }) + await runner.run() + let row = try await launchState(runner) + #expect(row["phase"]?.stringValue == "failed") + #expect(row["failedStep"]?.stringValue?.contains("bad-step") == true) + #expect(row["error"]?.stringValue?.contains("boom") == true) + } +} + +private enum LaunchTestError: Error { case boom } diff --git a/Shared/Porthole/PortholeMCP/AGENTS.md b/Shared/Porthole/PortholeMCP/AGENTS.md new file mode 100644 index 00000000..0bbe0e2c --- /dev/null +++ b/Shared/Porthole/PortholeMCP/AGENTS.md @@ -0,0 +1,33 @@ +# PortholeMCP – Module Shape + +PortholeMCP maps a `PortholeSession` to an MCP stdio server: `MCPToolBuilder` +plans the tools from a manifest, `PortholeMCPDispatcher` routes calls to the +session, and `PortholeMCPServer` wires both to the official MCP Swift SDK. See +[`README.md`](README.md) for the tool surface. + +This file complements the root [`AGENTS.md`](../../../AGENTS.md) — read it first. + +## Scope & dependencies + +- Depends on **PortholeClientKit** and the external **MCP** product. It talks to + the device only through the `PortholeSessionProviding` seam (which + `PortholeSession` conforms to), so the tool logic is testable against a fake. +- MCP SDK types stay quarantined: only `MCPValueBridge` and `PortholeMCPServer` + import `MCP`. The planner and dispatcher are pure of SDK types (they speak + `PortholeValue` / `MCPCallResult`), which is what makes them unit-testable. + +## Invariants + +- **Tool names are sanitized** (`[a-z0-9_]`, lowercased) and namespaced: + `act_/query_/tail__`, plus `porthole_overview`. The + name → target map drives dispatch. +- **Tail is bounded** (duration ≤ 60 s, events ≤ 500) since MCP tools are + request/response; a PNG action result is returned as image content, other + results as JSON text, and a session error becomes an `isError` result — never + a crash. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PortholeMCPTests`, `extraPackageProducts: [PortholeClientKit, PortholeCore]`). +Drive the planner and dispatcher against `FakeSession`; no real MCP transport. diff --git a/Shared/Porthole/PortholeMCP/README.md b/Shared/Porthole/PortholeMCP/README.md new file mode 100644 index 00000000..bd3f92d8 --- /dev/null +++ b/Shared/Porthole/PortholeMCP/README.md @@ -0,0 +1,40 @@ +# PortholeMCP + +PortholeMCP exposes a connected Porthole session as a +[Model Context Protocol](https://modelcontextprotocol.io) server over stdio, so +an agent (Cursor, Claude, …) can drive a running iOS app. It's built on the +official [MCP Swift SDK](https://github.com/modelcontextprotocol/swift-sdk) over +`PortholeClientKit`, and is what `porthole mcp` runs. + +## Tools + +From the device manifest it generates: + +- `porthole_overview` — the whole manifest (connectors, actions, sources, + schemas) as JSON. An agent's entry point. +- `act__` — invoke an action; input is the action's parameter + schema. A returned PNG (a screenshot) comes back as MCP **image** content; + other results are JSON text. Destructive actions say so in their description. +- `query__` — a page of rows; input is the source's filters + plus `limit` and `cursor`. +- `tail__` — for subscribable sources; collects live events + for a bounded window (`durationSeconds` ≤ 60, `maxEvents` ≤ 500) and returns + them as an array (MCP tools are request/response). + +## Using it + +```swift +let session = try await PortholeClient().connect(to: pairedApp) +try await PortholeMCPServer(session: session, serverName: "porthole-Where").run() +``` + +Or, from the CLI, register `porthole mcp --app ` as an MCP server in your +agent. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PortholeMCPTests`). Tool planning (`MCPToolBuilder`) and call dispatch +(`PortholeMCPDispatcher`) are tested against a scripted `PortholeSessionProviding` +fake — no MCP client and no device — covering naming/sanitization, targets, +invoke/query/tail dispatch, PNG-to-image detection, and error mapping. diff --git a/Shared/Porthole/PortholeMCP/Sources/MCPToolBuilder.swift b/Shared/Porthole/PortholeMCP/Sources/MCPToolBuilder.swift new file mode 100644 index 00000000..58097b51 --- /dev/null +++ b/Shared/Porthole/PortholeMCP/Sources/MCPToolBuilder.swift @@ -0,0 +1,97 @@ +import Foundation +import PortholeCore + +/// What a generated MCP tool maps to on the device. +public enum MCPToolTarget: Equatable, Sendable { + case overview + case action(PortholeActionRef) + case query(PortholeDataSourceRef) + case tail(PortholeDataSourceRef) +} + +/// A tool the MCP server will expose, described independently of the MCP SDK +/// types so it can be planned and unit-tested purely. `inputSchema` is a +/// JSON-Schema object as a `PortholeValue`. +public struct PlannedTool: Equatable, Sendable { + public var name: String + public var description: String + public var inputSchema: PortholeValue + public var target: MCPToolTarget +} + +/// Turns a device manifest into the MCP tool list (+ dispatch targets). Pure. +public enum MCPToolBuilder { + /// Caps on the `tail_*` collection window, surfaced in the schema too. + public static let maxTailDurationSeconds = 60 + public static let maxTailEvents = 500 + + /// Lowercases and replaces every character outside `[a-z0-9_]` with `_`. + public static func sanitize(_ string: String) -> String { + String(string.lowercased().map { character in + character.isNumber || ("a" ... "z") + .contains(character) || character == "_" ? character : "_" + }) + } + + public static func plan(from manifests: [ConnectorManifest]) -> [PlannedTool] { + var tools: [PlannedTool] = [ + PlannedTool( + name: "porthole_overview", + description: "List every connector, action, and data source the app exposes, with their schemas. Call this first to discover what's available.", + inputSchema: PortholeSchema.object([:]).jsonSchema(), + target: .overview, + ), + ] + + for manifest in manifests { + let connector = manifest.connector.id.rawValue + for action in manifest.actions { + let name = "act_\(sanitize(connector))_\(sanitize(action.id.rawValue))" + var description = action.summary + if action.isDestructive { description += " (Destructive: this changes app state.)" } + tools.append(PlannedTool( + name: name, + description: description, + inputSchema: action.parameters.jsonSchema(), + target: .action(PortholeActionRef( + connector: manifest.connector.id, + action: action.id, + )), + )) + } + for source in manifest.dataSources { + let ref = PortholeDataSourceRef(connector: manifest.connector.id, source: source.id) + tools.append(PlannedTool( + name: "query_\(sanitize(connector))_\(sanitize(source.id.rawValue))", + description: "\(source.summary) Returns a page of rows.", + inputSchema: querySchema(filters: source.filters), + target: .query(ref), + )) + if source.supportsSubscription { + tools.append(PlannedTool( + name: "tail_\(sanitize(connector))_\(sanitize(source.id.rawValue))", + description: "\(source.summary) Collects live events for a bounded window and returns them as an array.", + inputSchema: tailSchema(filters: source.filters), + target: .tail(ref), + )) + } + } + } + return tools + } + + private static func querySchema(filters: PortholeSchema) -> PortholeValue { + var properties = filters.properties ?? [:] + properties["limit"] = .integer("Maximum number of rows to return") + properties["cursor"] = .string("Opaque pagination cursor from a previous page") + return PortholeSchema.object(properties).jsonSchema() + } + + private static func tailSchema(filters: PortholeSchema) -> PortholeValue { + var properties = filters.properties ?? [:] + properties["durationSeconds"] = + .integer("How long to collect events (max \(maxTailDurationSeconds))") + properties["maxEvents"] = .integer("Maximum events to collect (max \(maxTailEvents))") + return PortholeSchema.object(properties).jsonSchema() + } +} diff --git a/Shared/Porthole/PortholeMCP/Sources/MCPValueBridge.swift b/Shared/Porthole/PortholeMCP/Sources/MCPValueBridge.swift new file mode 100644 index 00000000..bc61129c --- /dev/null +++ b/Shared/Porthole/PortholeMCP/Sources/MCPValueBridge.swift @@ -0,0 +1,110 @@ +import Foundation +import MCP +import PortholeCore + +/// Converts between `PortholeValue` and the MCP SDK's `Value`, and renders +/// values as JSON text for tool results. +enum MCPValueBridge { + static func toMCP(_ value: PortholeValue) -> Value { + switch value { + case .null: .null + case let .bool(bool): .bool(bool) + case let .int(int): .int(Int(int)) + case let .double(double): .double(double) + case let .string(string): .string(string) + case let .data(data): .string(data.base64EncodedString()) + case let .date(date): .string(PortholeMCPISO8601.string(from: date)) + case let .array(array): .array(array.map(toMCP)) + case let .object(object): .object(object.mapValues(toMCP)) + } + } + + static func toPorthole(_ value: Value) -> PortholeValue { + switch value { + case .null: .null + case let .bool(bool): .bool(bool) + case let .int(int): .int(Int64(int)) + case let .double(double): .double(double) + case let .string(string): .string(string) + case let .data(_, data): .data(data) + case let .array(array): .array(array.map(toPorthole)) + case let .object(object): .object(object.mapValues(toPorthole)) + } + } + + /// Renders a value as JSON text (fragments allowed, sorted keys). + static func jsonString(_ value: PortholeValue) -> String { + let object = foundationObject(value) + guard let data = try? JSONSerialization.data( + withJSONObject: object, + options: [.prettyPrinted, .sortedKeys, .fragmentsAllowed], + ), let string = String(data: data, encoding: .utf8) else { + return String(describing: value) + } + return string + } + + private static func foundationObject(_ value: PortholeValue) -> Any { + switch value { + case .null: NSNull() + case let .bool(bool): bool + case let .int(int): int + case let .double(double): double + case let .string(string): string + case let .data(data): ["$data": data.base64EncodedString()] + case let .date(date): PortholeMCPISO8601.string(from: date) + case let .array(array): array.map(foundationObject) + case let .object(object): object.mapValues(foundationObject) + } + } + + /// Builds the `porthole_overview` payload from a manifest. + static func overview(_ manifests: [ConnectorManifest]) -> PortholeValue { + .array(manifests.map { manifest in + .object([ + "id": .string(manifest.connector.id.rawValue), + "title": .string(manifest.connector.title), + "summary": .string(manifest.connector.summary), + "version": .int(Int64(manifest.connector.version)), + "actions": .array(manifest.actions.map { action in + .object([ + "id": .string(action.id.rawValue), + "title": .string(action.title), + "summary": .string(action.summary), + "destructive": .bool(action.isDestructive), + "parameters": action.parameters.jsonSchema(), + ]) + }), + "dataSources": .array(manifest.dataSources.map { source in + .object([ + "id": .string(source.id.rawValue), + "title": .string(source.title), + "summary": .string(source.summary), + "subscribable": .bool(source.supportsSubscription), + "rowSchema": source.rowSchema.jsonSchema(), + "filters": source.filters.jsonSchema(), + ]) + }), + ]) + }) + } + + /// Whether a data value is a PNG (magic bytes) — returned as MCP image + /// content so an agent sees the screenshot directly. + static func isPNG(_ data: Data) -> Bool { + let signature: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] + return data.count >= signature.count && Array(data.prefix(signature.count)) == signature + } +} + +enum PortholeMCPISO8601 { + private nonisolated(unsafe) static let formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + static func string(from date: Date) -> String { + formatter.string(from: date) + } +} diff --git a/Shared/Porthole/PortholeMCP/Sources/PortholeMCPDispatcher.swift b/Shared/Porthole/PortholeMCP/Sources/PortholeMCPDispatcher.swift new file mode 100644 index 00000000..86dde489 --- /dev/null +++ b/Shared/Porthole/PortholeMCP/Sources/PortholeMCPDispatcher.swift @@ -0,0 +1,161 @@ +import Foundation +import PortholeCore + +/// The outcome of an MCP tool call, independent of the MCP SDK types so it can +/// be unit-tested. The server maps it to `CallTool.Result`. +public enum MCPCallResult: Sendable, Equatable { + case json(PortholeValue) + case image(Data) + case failure(String) +} + +/// Dispatches an MCP tool call to a `PortholeSessionProviding`. Holds the +/// manifest (for `porthole_overview`) and the tool-name → target map derived +/// from `MCPToolBuilder`. Pure of MCP SDK types. +public struct PortholeMCPDispatcher: Sendable { + private let session: any PortholeSessionProviding + private let manifests: [ConnectorManifest] + private let targets: [String: MCPToolTarget] + + public init(session: any PortholeSessionProviding, manifests: [ConnectorManifest]) { + self.session = session + self.manifests = manifests + targets = Dictionary( + MCPToolBuilder.plan(from: manifests).map { ($0.name, $0.target) }, + uniquingKeysWith: { first, _ in first }, + ) + } + + public func plannedTools() -> [PlannedTool] { + MCPToolBuilder.plan(from: manifests) + } + + public func call(name: String, arguments: PortholeValue) async -> MCPCallResult { + guard let target = targets[name] else { + return .failure("Unknown tool `\(name)`.") + } + switch target { + case .overview: + return .json(MCPValueBridge.overview(manifests)) + case let .action(ref): + return await invoke(ref, arguments: arguments) + case let .query(ref): + return await runQuery(ref, arguments: arguments) + case let .tail(ref): + return await runTail(ref, arguments: arguments) + } + } + + private func invoke(_ ref: PortholeActionRef, arguments: PortholeValue) async -> MCPCallResult { + do { + let result = try await session.invoke(ref, parameters: arguments) + if let png = extractPNG(result) { + return .image(png) + } + return .json(result) + } catch { + return .failure(String(describing: error)) + } + } + + private func runQuery( + _ ref: PortholeDataSourceRef, + arguments: PortholeValue, + ) async -> MCPCallResult { + var filters = arguments.objectValue ?? [:] + let limit = filters.removeValue(forKey: "limit")?.intValue.map { Int($0) } + let cursor = filters.removeValue(forKey: "cursor")?.stringValue + do { + let page = try await session.query( + ref, + PortholeQuery(filters: .object(filters), limit: limit, cursor: cursor), + ) + return .json(pageValue(page)) + } catch { + return .failure(String(describing: error)) + } + } + + private func runTail( + _ ref: PortholeDataSourceRef, + arguments: PortholeValue, + ) async -> MCPCallResult { + var filters = arguments.objectValue ?? [:] + let duration = min( + Int(filters.removeValue(forKey: "durationSeconds")?.intValue ?? 5), + MCPToolBuilder.maxTailDurationSeconds, + ) + let maxEvents = min( + Int(filters.removeValue(forKey: "maxEvents")?.intValue ?? 100), + MCPToolBuilder.maxTailEvents, + ) + do { + let stream = try await session.subscribe(ref) + let events = await collect( + stream, + forSeconds: max(1, duration), + maxEvents: max(1, maxEvents), + ) + return .json(.array(events)) + } catch { + return .failure(String(describing: error)) + } + } + + private func collect( + _ stream: AsyncThrowingStream, + forSeconds seconds: Int, + maxEvents: Int, + ) async -> [PortholeValue] { + let box = EventBox(maxEvents: maxEvents) + await withTaskGroup(of: Void.self) { group in + group.addTask { + do { + for try await value in stream { + if await box.append(value) { break } + } + } catch {} + } + group.addTask { + try? await Task.sleep(for: .seconds(seconds)) + } + await group.next() + group.cancelAll() + } + return await box.events + } + + private func pageValue(_ page: PortholePage) -> PortholeValue { + var object: [String: PortholeValue] = ["rows": .array(page.rows)] + if let cursor = page.nextCursor { object["nextCursor"] = .string(cursor) } + if let total = page.totalCount { object["totalCount"] = .int(Int64(total)) } + return .object(object) + } + + /// A bare PNG data value, or the first PNG data member of an object result. + private func extractPNG(_ value: PortholeValue) -> Data? { + if case let .data(data) = value, MCPValueBridge.isPNG(data) { return data } + if case let .object(object) = value { + for member in object.values { + if case let .data(data) = member, MCPValueBridge.isPNG(data) { return data } + } + } + return nil + } +} + +/// Bounds tail collection to a max event count. +private actor EventBox { + private let maxEvents: Int + private(set) var events: [PortholeValue] = [] + + init(maxEvents: Int) { + self.maxEvents = maxEvents + } + + /// Appends and returns whether the cap is reached. + func append(_ value: PortholeValue) -> Bool { + events.append(value) + return events.count >= maxEvents + } +} diff --git a/Shared/Porthole/PortholeMCP/Sources/PortholeMCPServer.swift b/Shared/Porthole/PortholeMCP/Sources/PortholeMCPServer.swift new file mode 100644 index 00000000..3c5066fa --- /dev/null +++ b/Shared/Porthole/PortholeMCP/Sources/PortholeMCPServer.swift @@ -0,0 +1,67 @@ +import Foundation +import MCP +import PortholeClientKit +import PortholeCore + +/// Serves a connected Porthole session as an MCP server over stdio: it turns the +/// device manifest into MCP tools (`porthole_overview`, `act_*`, `query_*`, +/// `tail_*`) and routes tool calls through a ``PortholeMCPDispatcher``. +public struct PortholeMCPServer: Sendable { + private let session: any PortholeSessionProviding + private let serverName: String + + public init(session: any PortholeSessionProviding, serverName: String) { + self.session = session + self.serverName = serverName + } + + /// Fetches the manifest, registers handlers, and serves stdio until the + /// input stream closes. + public func run() async throws { + let manifests = try await session.manifest() + let dispatcher = PortholeMCPDispatcher(session: session, manifests: manifests) + let plan = dispatcher.plannedTools() + + let server = Server( + name: serverName, + version: "1.0.0", + capabilities: .init(tools: .init(listChanged: false)), + ) + + let tools = plan.map { planned in + Tool( + name: planned.name, + description: planned.description, + inputSchema: MCPValueBridge.toMCP(planned.inputSchema), + ) + } + + await server.withMethodHandler(ListTools.self) { _ in + ListTools.Result(tools: tools) + } + + await server.withMethodHandler(CallTool.self) { params in + let arguments = PortholeValue + .object((params.arguments ?? [:]).mapValues(MCPValueBridge.toPorthole)) + let result = await dispatcher.call(name: params.name, arguments: arguments) + switch result { + case let .json(value): + return CallTool.Result( + content: [.text(text: MCPValueBridge.jsonString(value))], + isError: false, + ) + case let .image(data): + return CallTool.Result( + content: [.image(data: data.base64EncodedString(), mimeType: "image/png")], + isError: false, + ) + case let .failure(message): + return CallTool.Result(content: [.text(text: message)], isError: true) + } + } + + let transport = StdioTransport() + try await server.start(transport: transport) + await server.waitUntilCompleted() + } +} diff --git a/Shared/Porthole/PortholeMCP/Sources/PortholeSessionProviding.swift b/Shared/Porthole/PortholeMCP/Sources/PortholeSessionProviding.swift new file mode 100644 index 00000000..101ce6cd --- /dev/null +++ b/Shared/Porthole/PortholeMCP/Sources/PortholeSessionProviding.swift @@ -0,0 +1,15 @@ +import Foundation +import PortholeClientKit +import PortholeCore + +/// The slice of a live session the MCP server needs. A protocol so the tool +/// builder and dispatch can be tested against a scripted fake without a device. +public protocol PortholeSessionProviding: Sendable { + func manifest() async throws -> [ConnectorManifest] + func invoke(_ ref: PortholeActionRef, parameters: PortholeValue) async throws -> PortholeValue + func query(_ ref: PortholeDataSourceRef, _ query: PortholeQuery) async throws -> PortholePage + func subscribe(_ ref: PortholeDataSourceRef) async throws + -> AsyncThrowingStream +} + +extension PortholeSession: PortholeSessionProviding {} diff --git a/Shared/Porthole/PortholeMCP/Tests/MCPToolBuilderTests.swift b/Shared/Porthole/PortholeMCP/Tests/MCPToolBuilderTests.swift new file mode 100644 index 00000000..71429020 --- /dev/null +++ b/Shared/Porthole/PortholeMCP/Tests/MCPToolBuilderTests.swift @@ -0,0 +1,51 @@ +import Foundation +import PortholeCore +@testable import PortholeMCP +import Testing + +struct MCPToolBuilderTests { + @Test func sanitizesToolNames() { + #expect(MCPToolBuilder.sanitize("view-tree") == "view_tree") + #expect(MCPToolBuilder.sanitize("Year Report") == "year_report") + #expect(MCPToolBuilder.sanitize("a.b/c") == "a_b_c") + #expect(MCPToolBuilder.sanitize("already_ok9") == "already_ok9") + } + + @Test func plansOverviewActionQueryAndTailTools() { + let plan = MCPToolBuilder.plan(from: fixtureManifests()) + let names = Set(plan.map(\.name)) + #expect(names.contains("porthole_overview")) + #expect(names.contains("act_test_echo")) + #expect(names.contains("act_test_wipe")) + #expect(names.contains("query_test_rows")) + #expect(names.contains("query_test_ticks")) + #expect(names.contains("tail_test_ticks")) + // A non-subscribable source gets no tail tool. + #expect(!names.contains("tail_test_rows")) + } + + @Test func targetsMapToRefs() { + let plan = MCPToolBuilder.plan(from: fixtureManifests()) + let echo = plan.first { $0.name == "act_test_echo" } + #expect(echo?.target == .action(PortholeActionRef(connector: "test", action: "echo"))) + let rows = plan.first { $0.name == "query_test_rows" } + #expect(rows?.target == .query(PortholeDataSourceRef(connector: "test", source: "rows"))) + let ticks = plan.first { $0.name == "tail_test_ticks" } + #expect(ticks?.target == .tail(PortholeDataSourceRef(connector: "test", source: "ticks"))) + } + + @Test func destructiveActionNotedInDescription() { + let plan = MCPToolBuilder.plan(from: fixtureManifests()) + let wipe = plan.first { $0.name == "act_test_wipe" } + #expect(wipe?.description.contains("Destructive") == true) + } + + @Test func queryToolSchemaAddsLimitAndCursor() throws { + let plan = MCPToolBuilder.plan(from: fixtureManifests()) + let rows = try #require(plan.first { $0.name == "query_test_rows" }) + let properties = rows.inputSchema["properties"]?.objectValue ?? [:] + #expect(properties["count"] != nil) + #expect(properties["limit"] != nil) + #expect(properties["cursor"] != nil) + } +} diff --git a/Shared/Porthole/PortholeMCP/Tests/PortholeMCPDispatcherTests.swift b/Shared/Porthole/PortholeMCP/Tests/PortholeMCPDispatcherTests.swift new file mode 100644 index 00000000..54f6a9db --- /dev/null +++ b/Shared/Porthole/PortholeMCP/Tests/PortholeMCPDispatcherTests.swift @@ -0,0 +1,107 @@ +import Foundation +import PortholeCore +@testable import PortholeMCP +import Testing + +struct PortholeMCPDispatcherTests { + private func dispatcher(_ session: FakeSession) -> PortholeMCPDispatcher { + PortholeMCPDispatcher(session: session, manifests: session.manifests) + } + + @Test func overviewReturnsManifestJSON() async { + let result = await dispatcher(FakeSession(manifests: fixtureManifests())) + .call(name: "porthole_overview", arguments: .object([:])) + guard case let .json(value) = result else { + Issue.record("Expected json, got \(result)") + return + } + #expect(value.arrayValue?.first?["id"]?.stringValue == "test") + } + + @Test func actionInvokeReturnsResult() async { + var session = FakeSession(manifests: fixtureManifests()) + session.onInvoke = { ref, params in + #expect(ref.action.rawValue == "echo") + return .object(["echoed": params["value"] ?? .null]) + } + let result = await dispatcher(session).call(name: "act_test_echo", arguments: ["value": 5]) + guard case let .json(value) = result else { + Issue.record("Expected json, got \(result)") + return + } + #expect(value["echoed"]?.intValue == 5) + } + + @Test func pngResultBecomesImage() async { + var session = FakeSession(manifests: fixtureManifests()) + session.onInvoke = { _, _ in .object(["image": .data(pngData())]) } + let result = await dispatcher(session).call(name: "act_test_echo", arguments: ["value": 1]) + guard case let .image(data) = result else { + Issue.record("Expected image, got \(result)") + return + } + #expect(data == pngData()) + } + + @Test func querySplitsLimitAndCursorFromFilters() async { + var session = FakeSession(manifests: fixtureManifests()) + session.onQuery = { _, query in + #expect(query.limit == 10) + #expect(query.cursor == "c1") + #expect(query.filters["count"]?.intValue == 3) + #expect(query.filters["limit"] == nil) + return PortholePage(rows: [["n": 0]], nextCursor: "c2", totalCount: 99) + } + let result = await dispatcher(session).call( + name: "query_test_rows", + arguments: ["count": 3, "limit": 10, "cursor": "c1"], + ) + guard case let .json(value) = result else { + Issue.record("Expected json, got \(result)") + return + } + #expect(value["rows"]?.arrayValue?.count == 1) + #expect(value["nextCursor"]?.stringValue == "c2") + #expect(value["totalCount"]?.intValue == 99) + } + + @Test func tailCollectsBoundedEvents() async { + var session = FakeSession(manifests: fixtureManifests()) + session.onSubscribe = { _ in + AsyncThrowingStream { continuation in + for tick in 0 ..< 3 { + continuation.yield(.object(["tick": .int(Int64(tick))])) + } + continuation.finish() + } + } + let result = await dispatcher(session).call( + name: "tail_test_ticks", + arguments: ["durationSeconds": 2, "maxEvents": 100], + ) + guard case let .json(value) = result else { + Issue.record("Expected json, got \(result)") + return + } + #expect(value.arrayValue?.count == 3) + } + + @Test func unknownToolFails() async { + let result = await dispatcher(FakeSession(manifests: fixtureManifests())) + .call(name: "act_ghost_nope", arguments: .object([:])) + guard case .failure = result else { + Issue.record("Expected failure, got \(result)") + return + } + } + + @Test func handlerErrorBecomesFailure() async { + var session = FakeSession(manifests: fixtureManifests()) + session.onInvoke = { _, _ in throw PortholeError.handlerFailed("boom") } + let result = await dispatcher(session).call(name: "act_test_echo", arguments: ["value": 1]) + guard case .failure = result else { + Issue.record("Expected failure, got \(result)") + return + } + } +} diff --git a/Shared/Porthole/PortholeMCP/Tests/PortholeMCPTestSupport.swift b/Shared/Porthole/PortholeMCP/Tests/PortholeMCPTestSupport.swift new file mode 100644 index 00000000..9da3f8ad --- /dev/null +++ b/Shared/Porthole/PortholeMCP/Tests/PortholeMCPTestSupport.swift @@ -0,0 +1,89 @@ +import Foundation +import PortholeCore +@testable import PortholeMCP + +/// A scripted `PortholeSessionProviding` for exercising the tool builder and +/// dispatcher without a device. +struct FakeSession: PortholeSessionProviding { + var manifests: [ConnectorManifest] + var onInvoke: @Sendable (PortholeActionRef, PortholeValue) async throws + -> PortholeValue = { _, _ in .null } + var onQuery: @Sendable (PortholeDataSourceRef, PortholeQuery) async throws + -> PortholePage = { _, _ in PortholePage(rows: []) } + var onSubscribe: @Sendable (PortholeDataSourceRef) + -> AsyncThrowingStream = { _ in + AsyncThrowingStream { $0.finish() } + } + + func manifest() async throws -> [ConnectorManifest] { + manifests + } + + func invoke(_ ref: PortholeActionRef, parameters: PortholeValue) async throws -> PortholeValue { + try await onInvoke(ref, parameters) + } + + func query(_ ref: PortholeDataSourceRef, _ query: PortholeQuery) async throws -> PortholePage { + try await onQuery(ref, query) + } + + func subscribe(_ ref: PortholeDataSourceRef) async throws + -> AsyncThrowingStream + { + onSubscribe(ref) + } +} + +/// A one-connector manifest fixture: `test` with an `echo` action and a +/// subscribable `ticks` source plus a paged `rows` source. +func fixtureManifests() -> [ConnectorManifest] { + [ + ConnectorManifest( + connector: PortholeConnectorDescriptor( + id: "test", + title: "Test", + summary: "Fixture.", + version: 1, + ), + actions: [ + PortholeActionDescriptor( + id: "echo", + title: "Echo", + summary: "Echoes a value.", + parameters: .object(["value": .integer()], required: ["value"]), + isDestructive: false, + ), + PortholeActionDescriptor( + id: "wipe", + title: "Wipe", + summary: "Deletes stuff.", + parameters: .object([:]), + isDestructive: true, + ), + ], + dataSources: [ + PortholeDataSourceDescriptor( + id: "rows", + title: "Rows", + summary: "Paged rows.", + rowSchema: .object(["n": .integer()]), + filters: .object(["count": .integer()]), + supportsSubscription: false, + ), + PortholeDataSourceDescriptor( + id: "ticks", + title: "Ticks", + summary: "Live counter.", + rowSchema: .object(["tick": .integer()]), + filters: .object([:]), + supportsSubscription: true, + ), + ], + ), + ] +} + +/// A minimal valid PNG (signature + IHDR-ish bytes) for image-detection tests. +func pngData() -> Data { + Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x01, 0x02, 0x03]) +} diff --git a/Shared/Porthole/PortholePeriscope/AGENTS.md b/Shared/Porthole/PortholePeriscope/AGENTS.md new file mode 100644 index 00000000..c040a4b1 --- /dev/null +++ b/Shared/Porthole/PortholePeriscope/AGENTS.md @@ -0,0 +1,28 @@ +# PortholePeriscope – Module Shape + +PortholePeriscope is the `periscope` Porthole connector: `events` (queryable +history), `live-events` (subscribable tail), and `scopes` over a +`PeriscopeStore` + `Periscope`. See [`README.md`](README.md). + +This file complements the root [`AGENTS.md`](../../../AGENTS.md) — read it first. + +## Scope & dependencies + +- Depends on **PortholeKit** + **PeriscopeCore** only. It reads Periscope's + public store/live APIs (`events(matching:)`, `scopes()`, `liveRecords()`) — it + never records events of its own. + +## Invariants + +- **`events` pages newest-first via limit + offset cursor** (the cursor is the + next offset); `afterSequence` is also exposed as a filter for incremental + live-forward reads. Payloads are decoded to JSON when possible. +- **`live-events` pumps `system.liveRecords()`** into the subscription and + cancels the pump when the subscription ends. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PortholePeriscopeTests`, `extraPackageProducts: [PortholeKit, PortholeCore, +PeriscopeCore]`). Seed an in-memory store; drive a live `Periscope` for the tail +and poll for the emitted record rather than sleeping. diff --git a/Shared/Porthole/PortholePeriscope/README.md b/Shared/Porthole/PortholePeriscope/README.md new file mode 100644 index 00000000..b285d6d9 --- /dev/null +++ b/Shared/Porthole/PortholePeriscope/README.md @@ -0,0 +1,32 @@ +# PortholePeriscope + +PortholePeriscope is a [Porthole](../) connector that exposes an app's +[Periscope](../../Periscope) logs over the bridge — a queryable event history, a +live event tail, and the scope tree. + +## Using it + +```swift +import PortholePeriscope + +porthole.register(PeriscopeConnector(store: logStore, system: .shared)) +``` + +## Surface (id `periscope`) + +- Data source `events` — stored log events, newest first. Filters: + `minimumLevel`, `eventName`, `messageContains`, `start`, `end`, + `afterSequence`. Page with `limit` + `cursor` (an offset). +- Data source `live-events` (subscribable) — a live stream of events as they're + emitted. +- Data source `scopes` — the scope hierarchy (id, name, parent). + +Rows carry the decoded event payload as JSON when it decodes, plus level, +message, scopes, and the `externalID` (the `store://` / `region://` object key). + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PortholePeriscopeTests`). Seeds an in-memory `PeriscopeStore`, asserts the +event/level/message filters and the scope list, and drives a live `Periscope` to +confirm `live-events` streams an emitted record. diff --git a/Shared/Porthole/PortholePeriscope/Sources/PeriscopeConnector.swift b/Shared/Porthole/PortholePeriscope/Sources/PeriscopeConnector.swift new file mode 100644 index 00000000..d114aacb --- /dev/null +++ b/Shared/Porthole/PortholePeriscope/Sources/PeriscopeConnector.swift @@ -0,0 +1,169 @@ +import Foundation +import PeriscopeCore +import PortholeCore +import PortholeKit + +/// A Porthole connector (id `periscope`) exposing the app's Periscope logs: a +/// queryable event history, a live event tail, and the scope tree. +public final class PeriscopeConnector: PortholeConnector { + public let descriptor = PortholeConnectorDescriptor( + id: "periscope", + title: "Periscope", + summary: "The app's structured logs: query history, tail live events, and browse the scope tree.", + version: 1, + ) + + private let store: PeriscopeStore + private let system: Periscope + + private static let levelsByName: [String: LogLevel] = Dictionary( + uniqueKeysWithValues: LogLevel.standardLevels.map { ($0.name, $0) }, + ) + + public init(store: PeriscopeStore, system: Periscope) { + self.store = store + self.system = system + } + + public func dataSources() -> [PortholeDataSource] { + let store = store + let system = system + return [ + PortholeDataSource( + descriptor: PortholeDataSourceDescriptor( + id: "events", + title: "Events", + summary: "Stored log events, newest first. Page with limit + cursor.", + rowSchema: eventRowSchema, + filters: .object([ + "minimumLevel": .string( + "Minimum level", + allowedValues: LogLevel.standardLevels.map(\.name), + ), + "eventName": .string("Exact event type name"), + "messageContains": .string("Substring to match in the message"), + "start": .date("Only events at/after this date"), + "end": .date("Only events before this date"), + "afterSequence": .integer( + "Only events with a higher sequence (live cursor)", + ), + ]), + supportsSubscription: false, + ), + fetch: { query in + let limit = query.limit ?? 100 + let offset = Int(query.cursor ?? "0") ?? 0 + let logQuery = Self.logQuery(from: query.filters, limit: limit, offset: offset) + let events = try await store.events(matching: logQuery) + let rows = events.map(Self.eventRow) + return PortholePage( + rows: rows, + nextCursor: events.count == limit ? String(offset + limit) : nil, + ) + }, + ), + PortholeDataSource( + descriptor: PortholeDataSourceDescriptor( + id: "live-events", + title: "Live events", + summary: "A live stream of log events as they're emitted.", + rowSchema: eventRowSchema, + filters: .object([:]), + supportsSubscription: true, + ), + fetch: { _ in PortholePage(rows: []) }, + subscribe: { + AsyncStream { continuation in + let task = Task { + for await record in system.liveRecords() { + if Task.isCancelled { break } + continuation.yield(Self.liveRow(record)) + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + }, + ), + PortholeDataSource( + descriptor: PortholeDataSourceDescriptor( + id: "scopes", + title: "Scopes", + summary: "The log scope hierarchy (id, name, parent).", + rowSchema: .object(["id": .string(), "name": .string(), "parentID": .string()]), + filters: .object([:]), + supportsSubscription: false, + ), + fetch: { _ in + let scopes = try await store.scopes() + let rows = scopes.map { scope -> PortholeValue in + var object: [String: PortholeValue] = [ + "id": .string(scope.id.description), + "name": .string(scope.name), + ] + if let parent = scope + .parentID { object["parentID"] = .string(parent.description) } + return .object(object) + } + return PortholePage(rows: rows, totalCount: rows.count) + }, + ), + ] + } + + private var eventRowSchema: PortholeSchema { + .object([ + "date": .date(), + "sequence": .integer(), + "level": .string(), + "eventName": .string(), + "message": .string(), + "scopes": .array(of: .string()), + "externalID": .string(), + ]) + } + + private static func logQuery(from filters: PortholeValue, limit: Int, offset: Int) -> LogQuery { + var query = LogQuery() + query.limit = limit + query.offset = offset + if let level = filters["minimumLevel"]? + .stringValue { query.minimumLevel = levelsByName[level] } + if let name = filters["eventName"]?.stringValue { query.eventName = name } + if let message = filters["messageContains"]?.stringValue { query.messageContains = message } + if let start = filters["start"]?.dateValue { query.start = start } + if let end = filters["end"]?.dateValue { query.end = end } + if let after = filters["afterSequence"]?.intValue { query.afterSequence = Int(after) } + return query + } + + private static func eventRow(_ event: StoredLogEvent) -> PortholeValue { + var object: [String: PortholeValue] = [ + "date": .date(event.date), + "sequence": .int(Int64(event.sequence)), + "level": .string(event.level.name), + "eventName": .string(event.eventName), + "message": .string(event.message), + "scopes": .array(event.scopes.map { .string($0.description) }), + ] + if let externalID = event.externalID { object["externalID"] = .string(externalID) } + if !event.tags + .isEmpty { object["tags"] = .array(event.tags.map { .string(String(describing: $0)) }) } + if let payload = try? JSONDecoder().decode(PortholeValue.self, from: event.payload) { + object["payload"] = payload + } + return .object(object) + } + + private static func liveRow(_ record: LogRecord) -> PortholeValue { + var object: [String: PortholeValue] = [ + "date": .date(record.date), + "level": .string(record.level.name), + "eventName": .string(record.eventName), + "message": .string(record.message), + "scopes": .array(record.scopes.map { .string($0.description) }), + ] + if let externalID = record.externalID { object["externalID"] = .string(externalID) } + return .object(object) + } +} diff --git a/Shared/Porthole/PortholePeriscope/Tests/PeriscopeConnectorTests.swift b/Shared/Porthole/PortholePeriscope/Tests/PeriscopeConnectorTests.swift new file mode 100644 index 00000000..b4a0db2c --- /dev/null +++ b/Shared/Porthole/PortholePeriscope/Tests/PeriscopeConnectorTests.swift @@ -0,0 +1,152 @@ +import Foundation +@_spi(Testing) import PeriscopeCore +import PortholeCore +import PortholeKit +import PortholePeriscope +import Testing + +private struct AppLogs: LogEvent { + static let eventName = "AppLogs" + var level: LogLevel = .info + var message: String = "" +} + +struct PeriscopeConnectorTests { + private func makeStore() async throws -> (PeriscopeStore, LogScope) { + let session = LogSession( + id: UUID(), + startedAt: Date(timeIntervalSinceReferenceDate: 0), + appVersion: "1.0", + buildNumber: "1", + osVersion: "TestOS", + deviceModel: "Test", + ) + let store = try await PeriscopeStore.inMemory(session: session) + let root = LogScope.root(named: "app") + await store.defineScopes([root]) + return (store, root) + } + + private func source( + _ connector: PeriscopeConnector, + _ id: PortholeDataSourceID, + ) -> PortholeDataSource { + connector.dataSources().first { $0.descriptor.id == id }! + } + + private func connector(_ store: PeriscopeStore) -> PeriscopeConnector { + PeriscopeConnector( + store: store, + system: Periscope(configuration: Periscope.Configuration(), sinks: []), + ) + } + + @Test func eventsSourceReturnsRowsNewestFirst() async throws { + let (store, root) = try await makeStore() + await store.write([ + LogRecord( + date: Date(timeIntervalSinceReferenceDate: 1), + event: Message(level: .info, "first"), + scopes: [root.id], + ), + LogRecord( + date: Date(timeIntervalSinceReferenceDate: 2), + event: Message(level: .warning, "second"), + scopes: [root.id], + ), + ]) + let page = try await source(connector(store), "events").fetch(PortholeQuery()) + #expect(page.rows.compactMap { $0["message"]?.stringValue } == ["second", "first"]) + #expect(page.rows.first?["level"]?.stringValue == "warning") + #expect(page.rows.first?["sequence"]?.intValue != nil) + } + + @Test func minimumLevelFilters() async throws { + let (store, root) = try await makeStore() + await store.write([ + LogRecord( + date: Date(timeIntervalSinceReferenceDate: 1), + event: Message(level: .info, "info"), + scopes: [root.id], + ), + LogRecord( + date: Date(timeIntervalSinceReferenceDate: 2), + event: Message(level: .error, "error"), + scopes: [root.id], + ), + ]) + let page = try await source(connector(store), "events") + .fetch(PortholeQuery(filters: ["minimumLevel": "warning"])) + #expect(page.rows.compactMap { $0["message"]?.stringValue } == ["error"]) + } + + @Test func messageContainsFilters() async throws { + let (store, root) = try await makeStore() + await store.write([ + LogRecord( + date: Date(timeIntervalSinceReferenceDate: 1), + event: Message(level: .info, "load started"), + scopes: [root.id], + ), + LogRecord( + date: Date(timeIntervalSinceReferenceDate: 2), + event: Message(level: .info, "save done"), + scopes: [root.id], + ), + ]) + let page = try await source(connector(store), "events") + .fetch(PortholeQuery(filters: ["messageContains": "load"])) + #expect(page.rows.compactMap { $0["message"]?.stringValue } == ["load started"]) + } + + @Test func scopesSourceListsHierarchy() async throws { + let (store, root) = try await makeStore() + let child = root.child(named: "network") + await store.defineScopes([child]) + let page = try await source(connector(store), "scopes").fetch(PortholeQuery()) + let names = Set(page.rows.compactMap { $0["name"]?.stringValue }) + #expect(names.contains("app")) + #expect(names.contains("network")) + } + + @Test func liveEventsStreamsEmittedRecords() async throws { + let (store, _) = try await makeStore() + let system = Periscope(configuration: Periscope.Configuration(), sinks: []) + let connector = PeriscopeConnector(store: store, system: system) + let subscribe = try #require(source(connector, "live-events").subscribe) + let stream = subscribe() + + let emitter = Task { + let log = Log(system: system) + while !Task.isCancelled { + log.info("tick") + try? await Task.sleep(for: .milliseconds(5)) + } + } + defer { emitter.cancel() } + + let first = try await withTimeout { + var iterator = stream.makeAsyncIterator() + return await iterator.next() + } + #expect(first?["message"]?.stringValue == "tick") + } +} + +private struct PeriscopeTimeout: Error {} + +private func withTimeout( + _ duration: Duration = .seconds(3), + _ operation: @escaping @Sendable () async -> T, +) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { await operation() } + group.addTask { + try await Task.sleep(for: duration) + throw PeriscopeTimeout() + } + let result = try await group.next()! + group.cancelAll() + return result + } +} diff --git a/Shared/Porthole/PortholeSwiftData/AGENTS.md b/Shared/Porthole/PortholeSwiftData/AGENTS.md new file mode 100644 index 00000000..73e93c63 --- /dev/null +++ b/Shared/Porthole/PortholeSwiftData/AGENTS.md @@ -0,0 +1,30 @@ +# PortholeSwiftData – Module Shape + +PortholeSwiftData is the `SwiftDataConnector`: a read-only Porthole connector +over a `ModelContainer`, built on SwiftDataInspector's headless reader. See +[`README.md`](README.md). + +This file complements the root [`AGENTS.md`](../../../AGENTS.md) — read it first. + +## Scope & dependencies + +- Depends on **PortholeKit** + **SwiftDataInspector** only. It reuses the + promoted `SwiftDataInspectorReader` (entities + paged rows) rather than + re-implementing SwiftData reflection. +- **Read-only by construction** — the reader never mutates the store. + +## Invariants + +- **Register one connector per store, with an explicit `id`** (no default) — an + app may register several, and a duplicate id is a programmer error at the + registry. +- **Rows page via `nextCursor`** (a page-count string): the underlying reader + returns a growing prefix, so the connector slices each page's `rowLimit`-sized + window to keep pages disjoint. A missing/unknown `entity` filter throws + `invalidParameters`. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PortholeSwiftDataTests`, `extraPackageProducts: [PortholeKit, PortholeCore, +SwiftDataInspector]`). In-memory container + `@Model` fixture. diff --git a/Shared/Porthole/PortholeSwiftData/README.md b/Shared/Porthole/PortholeSwiftData/README.md new file mode 100644 index 00000000..2c71f6d9 --- /dev/null +++ b/Shared/Porthole/PortholeSwiftData/README.md @@ -0,0 +1,38 @@ +# PortholeSwiftData + +PortholeSwiftData is a [Porthole](../) connector that exposes a SwiftData store, +read-only, over the bridge — built on +[SwiftDataInspector](../../SwiftDataInspector)'s headless reader. + +## Using it + +```swift +import PortholeSwiftData + +porthole.register(SwiftDataConnector( + id: "swiftdata", + title: "SwiftData", + container: services.modelContainer, + modelTypes: SwiftDataStore.inspectorModelTypes, // or nil to discover from the schema + rowLimit: 500, +)) +``` + +Register one per `ModelContainer`; the `id` is explicit (no default) because an +app may register several. + +## Surface + +- Data source `entities` — one row per entity: `name`, `count`, `columns`. +- Data source `rows` — filter `entity` (required); a page of that entity's rows + as objects keyed by column. Follow `nextCursor` for more pages (disjoint + windows of `rowLimit` rows each). + +Read-only by construction — the reader never mutates the store. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PortholeSwiftDataTests`). Uses an in-memory `ModelContainer` with a local +`@Model` fixture and asserts entity rows, paged rows via cursor, and +missing/unknown-entity rejection. diff --git a/Shared/Porthole/PortholeSwiftData/Sources/SwiftDataConnector.swift b/Shared/Porthole/PortholeSwiftData/Sources/SwiftDataConnector.swift new file mode 100644 index 00000000..4a38c8ee --- /dev/null +++ b/Shared/Porthole/PortholeSwiftData/Sources/SwiftDataConnector.swift @@ -0,0 +1,104 @@ +import Foundation +import PortholeCore +import PortholeKit +import SwiftData +import SwiftDataInspector + +/// A Porthole connector that exposes a SwiftData store, read-only, using +/// SwiftDataInspector's headless reader. Register one per `ModelContainer`; the +/// `id` is explicit (no default) since an app may register several. +public final class SwiftDataConnector: PortholeConnector { + public let descriptor: PortholeConnectorDescriptor + private let reader: SwiftDataInspectorReader + private let rowLimit: Int + + public init( + id: PortholeConnectorID, + title: String, + container: ModelContainer, + modelTypes: [any PersistentModel.Type]?, + rowLimit: Int, + ) { + descriptor = PortholeConnectorDescriptor( + id: id, + title: title, + summary: "Browse the app's SwiftData store — entities and their rows, read-only.", + version: 1, + ) + self.rowLimit = rowLimit + reader = SwiftDataInspectorReader( + container: container, + modelTypes: modelTypes, + rowLimit: rowLimit, + valueFormatter: nil, + ) + } + + public func dataSources() -> [PortholeDataSource] { + let reader = reader + let rowLimit = rowLimit + return [ + PortholeDataSource( + descriptor: PortholeDataSourceDescriptor( + id: "entities", + title: "Entities", + summary: "One row per SwiftData entity, with its row count and columns.", + rowSchema: .object([ + "name": .string(), + "count": .integer(), + "columns": .array(of: .string()), + ]), + filters: .object([:]), + supportsSubscription: false, + ), + fetch: { _ in + let entities = await reader.loadEntities() + let rows = entities.map { entity in + PortholeValue.object([ + "name": .string(entity.name), + "count": .int(Int64(entity.count)), + "columns": .array(entity.columns.map(PortholeValue.string)), + ]) + } + return PortholePage(rows: rows, totalCount: rows.count) + }, + ), + PortholeDataSource( + descriptor: PortholeDataSourceDescriptor( + id: "rows", + title: "Rows", + summary: "A page of rows for an entity. Follow nextCursor for more.", + rowSchema: .object([:]), + filters: .object([ + "entity": .string("The entity name (from the entities source)"), + ], required: ["entity"]), + supportsSubscription: false, + ), + fetch: { query in + guard let name = query.filters["entity"]?.stringValue else { + throw PortholeError.invalidParameters("`entity` is required") + } + let page = Int(query.cursor ?? "1") ?? 1 + guard let entity = await reader.loadEntities() + .first(where: { $0.name == name }) + else { + throw PortholeError.invalidParameters("Unknown entity `\(name)`") + } + let set = await reader.rows(for: entity, pageCount: max(1, page)) + // The reader returns a growing prefix; slice this page's window + // so pages are disjoint. + let start = (max(1, page) - 1) * rowLimit + let windowed = start < set.rows.count ? Array(set.rows[start...]) : [] + let rows = windowed.map { row in + PortholeValue.object(row.cells.mapValues(PortholeValue.string)) + } + return PortholePage( + rows: rows, + nextCursor: set.isTruncated ? String(max(1, page) + 1) : nil, + totalCount: set.totalCount, + ) + }, + ), + ] + } +} diff --git a/Shared/Porthole/PortholeSwiftData/Tests/SwiftDataConnectorTests.swift b/Shared/Porthole/PortholeSwiftData/Tests/SwiftDataConnectorTests.swift new file mode 100644 index 00000000..f56b3845 --- /dev/null +++ b/Shared/Porthole/PortholeSwiftData/Tests/SwiftDataConnectorTests.swift @@ -0,0 +1,94 @@ +import Foundation +import PortholeCore +import PortholeKit +@testable import PortholeSwiftData +import SwiftData +import Testing + +@Model +final class SDConnectorWidget { + var name: String? + var quantity: Int? + + init(name: String?, quantity: Int?) { + self.name = name + self.quantity = quantity + } +} + +struct SwiftDataConnectorTests { + private func makeContainer(count: Int) throws -> ModelContainer { + let schema = Schema([SDConnectorWidget.self]) + let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + let container = try ModelContainer(for: schema, configurations: [configuration]) + let context = ModelContext(container) + for index in 0 ..< count { + context.insert(SDConnectorWidget(name: "Widget \(index)", quantity: index)) + } + try context.save() + return container + } + + private func connector(_ container: ModelContainer, rowLimit: Int = 500) -> SwiftDataConnector { + SwiftDataConnector( + id: "swiftdata", + title: "SwiftData", + container: container, + modelTypes: nil, + rowLimit: rowLimit, + ) + } + + private func source( + _ connector: SwiftDataConnector, + _ id: PortholeDataSourceID, + ) -> PortholeDataSource { + connector.dataSources().first { $0.descriptor.id == id }! + } + + @Test func entitiesSourceReportsNameCountAndColumns() async throws { + let container = try makeContainer(count: 4) + let page = try await source(connector(container), "entities").fetch(PortholeQuery()) + let widget = try #require(page.rows + .first { $0["name"]?.stringValue == "SDConnectorWidget" }) + #expect(widget["count"]?.intValue == 4) + #expect(widget["columns"]?.arrayValue?.contains(.string("name")) == true) + } + + @Test func rowsSourceReturnsRowsForEntity() async throws { + let container = try makeContainer(count: 3) + let page = try await source(connector(container), "rows") + .fetch(PortholeQuery(filters: ["entity": "SDConnectorWidget"])) + #expect(page.rows.count == 3) + #expect(page.totalCount == 3) + #expect(page.nextCursor == nil) + #expect(page.rows.contains { $0["name"]?.stringValue == "Widget 0" }) + } + + @Test func rowsSourcePagesWithCursor() async throws { + let container = try makeContainer(count: 10) + let rows = source(connector(container, rowLimit: 4), "rows") + + let firstPage = try await rows + .fetch(PortholeQuery(filters: ["entity": "SDConnectorWidget"])) + #expect(firstPage.rows.count == 4) + #expect(firstPage.nextCursor == "2") + + let secondPage = try await rows.fetch( + PortholeQuery(filters: ["entity": "SDConnectorWidget"], cursor: firstPage.nextCursor), + ) + #expect(secondPage.rows.count == 4) + #expect(secondPage.nextCursor == "3") + } + + @Test func rowsSourceRejectsMissingOrUnknownEntity() async throws { + let container = try makeContainer(count: 1) + let rows = source(connector(container), "rows") + await #expect(throws: PortholeError.self) { + _ = try await rows.fetch(PortholeQuery()) + } + await #expect(throws: PortholeError.self) { + _ = try await rows.fetch(PortholeQuery(filters: ["entity": "Ghost"])) + } + } +} diff --git a/Shared/Porthole/README.md b/Shared/Porthole/README.md new file mode 100644 index 00000000..95384fca --- /dev/null +++ b/Shared/Porthole/README.md @@ -0,0 +1,61 @@ +# Porthole + +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 app. App developers expose +surfaces by registering **connectors** (actions + data sources); built-in +connectors cover app info, the view/accessibility hierarchy + screenshots, the +file system, notifications, permissions, Periscope logs, SwiftData stores, and +LifecycleKit launch state. The Where app adds a domain-specific `WhereConnector`. + +## Modules + +Device side (embedded in the iOS app): + +- **[PortholeCore](PortholeCore)** — shared wire protocol, values/schema, + framing, pairing/session crypto, transports. iOS + macOS. +- **[PortholeKit](PortholeKit)** — the device runtime: `Porthole` composition + root, the `PortholeConnector` protocol, the Bonjour listener + secure sessions, + and the built-in `app` / `ui` / `files` / `notifications` / `permissions` + connectors. +- **[PortholeKitUI](PortholeKitUI)** — the Broadway-styled `PortholePairingView`. +- **[PortholePeriscope](PortholePeriscope)** / **[PortholeSwiftData](PortholeSwiftData)** + / **[PortholeLifecycle](PortholeLifecycle)** — library connectors over the + respective frameworks. (`WherePorthole` lives under `Where/`.) + +Mac side: + +- **[PortholeClientKit](PortholeClientKit)** — discovery, pairing, and + authenticated sessions. Backs all three surfaces. +- **[PortholeMCP](PortholeMCP)** — maps a session to an MCP stdio server. +- **[PortholeCLICore](PortholeCLICore)** — the `porthole` CLI logic (the + `PortholeCLI` executable is a thin `@main`). +- **PortholeApp** — the Mac Catalyst client app (`Porthole`). + +## How it fits together + +``` +iOS app: Porthole(configuration:) + register(connectors) + start() + │ advertises _porthole._tcp, accepts paired, encrypted sessions + ▼ +Mac: PortholeClientKit ──► porthole CLI / porthole mcp (MCP) / Porthole.app +``` + +Pairing is a one-time 6-digit code exchange; credentials live in the login +keychain, shared across all Mac surfaces. Everything ships in release but is +inert until the app calls `start()` (Where gates it behind a `#if DEBUG` +developer-menu toggle). + +## Security & scope + +Porthole is a **developer tool for a trusted LAN**. The pairing code doesn't +resist an active MITM (see [PortholeCore](PortholeCore/README.md#known-limitation)); +a SPAKE2 upgrade and other follow-ups are in [TODOs.md](TODOs.md). + +## Building + +The Mac targets are macOS-only (see the root +[`AGENTS.md`](../../AGENTS.md)): `mise exec -- tuist build PortholeCLI` and +`mise exec -- tuist build PortholeApp -- -destination 'platform=macOS,variant=Mac Catalyst'`. +The library logic is tested in the iOS `Stuff-iOS-Tests` scheme; CI builds the +Mac targets in a separate `macos` job. diff --git a/Shared/Porthole/TODOs.md b/Shared/Porthole/TODOs.md new file mode 100644 index 00000000..063ab684 --- /dev/null +++ b/Shared/Porthole/TODOs.md @@ -0,0 +1,50 @@ +# Porthole – Roadmap / Deferred + +Tracked follow-ups for the [Porthole](README.md) suite. These were deliberately +left out of v1; each notes why and roughly what it entails. + +## Transport & hosting + +- **Mac daemon / menubar host with a persistent HTTP MCP endpoint.** v1 has no + always-on Mac process: each surface (CLI, MCP, app) links `PortholeClientKit` + and connects directly, and the MCP is a stdio subprocess. A menubar app + + launchd daemon hosting a shared, always-on HTTP MCP endpoint would give + always-available access and connection sharing across surfaces. The client and + wire protocol already support concurrent sessions, so this is additive. +- **SPAKE2 pairing.** The 6-digit code is a usability/security trade-off for a + LAN dev tool and doesn't resist an active MITM grinding the code offline + during the exchange (documented in `PortholeCore/README.md`). A SPAKE2 (or + similar PAKE) upgrade would close that gap. It's a `PortholeCore` change behind + the same `PortholePairingMessage` shape. + +## Connectors + +- **Screenshot beyond a single window / device screen capture.** The `ui` + connector renders one window; full-device or scene-composited capture is more. +- **Write-capable file operations.** `files` is read-only in v1; write/delete + would need careful guarding (and probably an explicit opt-in per host app). +- **Write-capable preferences / store mutation for Where.** `WhereConnector` is + read-mostly with no destructive actions. Editing preferences, or mutating the + store (manual-day edits, reset), needs destructive-action UX and care. +- **Evidence / backup blob transfer.** `WhereConnector.evidence` returns + metadata only, and there's no `export-backup` action, because moving large + binary blobs over the wire needs a streaming/chunked story (the frame cap is + 32 MiB). Design a blob-transfer path, then add `export-backup` + evidence + bytes. +- **Lifecycle retry / teardown actions.** `PortholeLifecycle` is read-only + (`launch-state`); exposing `retry()` / `teardown()` as actions would let an + agent drive relaunch, but they're destructive and need care. +- **App Intents bridge connector.** Enumerate and invoke an app's App Intents + through Porthole. Heavier (intent parameter resolution, entities), so deferred. + +## Tooling + +- **AI connector-builder in the Mac app.** With a user-entered API key + (Cursor / Anthropic / OpenAI), a prompt scaffold that feeds a target module's + source plus the connector protocol/spec to a model to draft a new connector. + +## UI / platform + +- **Embed the client UI in an iOS app** (e.g. run the browser on-device for + debugging), and a first-class **iPad build** of the Porthole app (Catalyst + already keeps this plausible). diff --git a/Shared/SwiftDataInspector/AGENTS.md b/Shared/SwiftDataInspector/AGENTS.md index 2b98930a..bf008419 100644 --- a/Shared/SwiftDataInspector/AGENTS.md +++ b/Shared/SwiftDataInspector/AGENTS.md @@ -14,8 +14,12 @@ system, formatting, and global conventions. Read that first. - Pure **SwiftUI + SwiftData + Foundation + Observation** (UIKit only for font metrics). It must **not** import WhereCore or any app code — app wiring comes in via the configuration. -- Only `SwiftDataInspectorConfiguration` and `SwiftDataInspectorView` are - `public`; everything else is internal. The root view expects an ambient +- `SwiftDataInspectorConfiguration` and `SwiftDataInspectorView` are the SwiftUI + surface. The headless engine is also public: `SwiftDataInspectorReader` (an + `actor`) plus its `Sendable` snapshots (`InspectorEntity`, `InspectorRow`, + `InspectorRowSet`, `InspectorRelatedRows`) — so a non-UI consumer (the Porthole + SwiftData connector) can browse a store without SwiftUI. The reflection and the + SwiftUI views remain internal. The root view expects an ambient `NavigationStack` the consumer owns. - **Intended for DEBUG / developer surfaces** — it uses contained private-API reflection and shows raw stored data, so consumers gate it behind @@ -48,5 +52,6 @@ system, formatting, and global conventions. Read that first. Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost`: build an in-memory `ModelContainer` with local `@Model` fixtures, drive -`SwiftDataInspectorModel`, and assert on the returned snapshots. Real-app -wiring is covered from the consumer side (`WhereUITests`). +`SwiftDataInspectorModel` (UI) or `SwiftDataInspectorReader` (headless), and +assert on the returned snapshots. Real-app wiring is covered from the consumer +side (`WhereUITests`). diff --git a/Shared/SwiftDataInspector/README.md b/Shared/SwiftDataInspector/README.md index 540d8163..b5bfd8f4 100644 --- a/Shared/SwiftDataInspector/README.md +++ b/Shared/SwiftDataInspector/README.md @@ -142,7 +142,16 @@ work lives on a background `SwiftDataInspectorReader` actor that owns a fresh, read-only `ModelContext` per call. Because `ModelContext`/`PersistentModel` aren't `Sendable`, the context never leaves the actor — only `Sendable` snapshots (`InspectorEntity`, `InspectorRowSet`, `InspectorRelatedRows`) come back to the -UI. The result: opening even a 500-row entity (or resolving a relationship) does +UI. + +### Headless use + +`SwiftDataInspectorReader` and its snapshot types are **public**, so a non-UI +consumer can browse a store without SwiftUI: construct a reader with the same +inputs as the configuration (`container`, optional `modelTypes`, `rowLimit`, +`valueFormatter`), then call `loadEntities()` and `rows(for:pageCount:)`. The +Porthole SwiftData connector uses exactly this to expose entities and paged rows +over the network; the reflection and the SwiftUI views stay internal. The result: opening even a 500-row entity (or resolving a relationship) does its DB fetch and reflection off main, and the main thread only renders (showing a `ProgressView` while loading). diff --git a/Shared/SwiftDataInspector/Sources/InspectorEntity.swift b/Shared/SwiftDataInspector/Sources/InspectorEntity.swift index 7d0b19f2..7422c8c1 100644 --- a/Shared/SwiftDataInspector/Sources/InspectorEntity.swift +++ b/Shared/SwiftDataInspector/Sources/InspectorEntity.swift @@ -6,26 +6,26 @@ import SwiftData /// `Sendable` so it can be produced on the background reader actor and handed /// back to it when loading rows. The only non-value field is `type`, a metatype, /// which is safe to share across actors. -struct InspectorEntity: Identifiable { +public struct InspectorEntity: Identifiable, Sendable { /// The entity / model name (e.g. "SDEvidence"). Drives the row title and the /// list's stable identity. - let name: String + public let name: String /// The concrete model type, used to fetch the entity's rows and count. - let type: any PersistentModel.Type + public let type: any PersistentModel.Type /// Number of persisted rows. - let count: Int + public let count: Int /// Ordered column names (attributes, then relationships) for the detail /// table's headers. - let columns: [String] + public let columns: [String] /// Columns whose attribute type is `Data` / `Data?`. Rendered as a size or a /// placeholder so external-storage blobs are never materialized just to /// display them. - let binaryColumns: Set + public let binaryColumns: Set /// Columns that model a relationship. Rendered as a placeholder so the /// related object graph is never faulted in just to display a row. - let relationshipColumns: Set + public let relationshipColumns: Set - var id: String { + public var id: String { name } } diff --git a/Shared/SwiftDataInspector/Sources/InspectorRow.swift b/Shared/SwiftDataInspector/Sources/InspectorRow.swift index 8e5aff11..390dbb3a 100644 --- a/Shared/SwiftDataInspector/Sources/InspectorRow.swift +++ b/Shared/SwiftDataInspector/Sources/InspectorRow.swift @@ -8,14 +8,14 @@ import SwiftData /// to the main actor — `PersistentIdentifier` is itself `Sendable`, so it is the /// stable identity that survives pagination appends and lets the detail flow /// re-fetch this exact model to resolve its relationships. -struct InspectorRow: Identifiable, Hashable { +public struct InspectorRow: Identifiable, Hashable, Sendable { /// The model's persistent identity. Stable across fetches and contexts (for /// saved rows), so it both keys the list and lets the reader re-fetch the /// model to resolve relationships on demand. - let persistentID: PersistentIdentifier - let cells: [String: String] + public let persistentID: PersistentIdentifier + public let cells: [String: String] - var id: PersistentIdentifier { + public var id: PersistentIdentifier { persistentID } } @@ -23,17 +23,17 @@ struct InspectorRow: Identifiable, Hashable { /// The result of loading one page of an entity's rows: the page plus enough /// context for the detail view to size columns and decide whether more rows /// remain to "load more". -struct InspectorRowSet { - let rows: [InspectorRow] +public struct InspectorRowSet: Sendable { + public let rows: [InspectorRow] /// Total persisted rows for the entity (may exceed the rows loaded so far). - let totalCount: Int + public let totalCount: Int /// `true` when the loaded prefix is shorter than `totalCount`, i.e. the table /// should offer "load more". - let isTruncated: Bool + public let isTruncated: Bool /// Longest cell string (in characters) seen per column across this page, /// computed on the reader so the view can size monospaced columns without /// re-scanning every cell on the main thread. - let columnCharacterCounts: [String: Int] + public let columnCharacterCounts: [String: Int] } /// The resolved contents of one relationship, produced on the reader for the @@ -43,14 +43,14 @@ struct InspectorRowSet { /// `Sendable` so it can cross from the reader actor back to the UI. `entity` is /// `nil` when nothing resolved (an empty or unreadable relationship), in which /// case `rows` is empty and the view shows an empty state. -struct InspectorRelatedRows { - let entity: InspectorEntity? - let rows: [InspectorRow] +public struct InspectorRelatedRows: Sendable { + public let entity: InspectorEntity? + public let rows: [InspectorRow] /// `true` for a to-many relationship (the UI lists the rows); `false` for a /// to-one (the UI drills straight into the single related row). - let isToMany: Bool + public let isToMany: Bool /// How many rows the relationship references in total. `rows` is capped to the /// reader's `rowLimit`, so this may exceed `rows.count`; the UI notes the /// shortfall rather than silently showing a partial set. - let totalCount: Int + public let totalCount: Int } diff --git a/Shared/SwiftDataInspector/Sources/SwiftDataInspectorReader.swift b/Shared/SwiftDataInspector/Sources/SwiftDataInspectorReader.swift index 17a5afbf..a7738046 100644 --- a/Shared/SwiftDataInspector/Sources/SwiftDataInspectorReader.swift +++ b/Shared/SwiftDataInspector/Sources/SwiftDataInspectorReader.swift @@ -10,13 +10,16 @@ import SwiftData /// stay on a single actor, so the context is created and used entirely inside /// here and never escapes. Each call opens a fresh read-only context, so results /// reflect the latest committed store state and nothing is ever mutated. -actor SwiftDataInspectorReader { +public actor SwiftDataInspectorReader { private let container: ModelContainer private let modelTypes: [any PersistentModel.Type]? private let rowLimit: Int? private let valueFormatter: (@Sendable (Any) -> String?)? - init( + /// Creates a read-only reader over `container`. Mirrors + /// ``SwiftDataInspectorConfiguration``'s fields so a headless consumer (e.g. + /// a Porthole connector) can browse a store without the SwiftUI layer. + public init( container: ModelContainer, modelTypes: [any PersistentModel.Type]?, rowLimit: Int?, @@ -30,7 +33,7 @@ actor SwiftDataInspectorReader { /// Enumerate the store's entities with live counts and column headers, sorted /// by name. - func loadEntities() -> [InspectorEntity] { + public func loadEntities() -> [InspectorEntity] { let context = ModelContext(container) let entitiesByName = Self.entitiesByName(in: container.schema) @@ -57,7 +60,7 @@ actor SwiftDataInspectorReader { /// view needs to size columns. `pageCount` powers "load more": the table /// re-requests with a higher count and replaces its rows with this longer, /// single-fetch prefix. `isTruncated` reports whether rows remain beyond it. - func rows(for entity: InspectorEntity, pageCount: Int = 1) -> InspectorRowSet { + public func rows(for entity: InspectorEntity, pageCount: Int = 1) -> InspectorRowSet { let context = ModelContext(container) let limit = rowLimit.map { $0 * max(pageCount, 1) } let models = context.inspectorFetch(entity.type, limit: limit) @@ -97,7 +100,7 @@ actor SwiftDataInspectorReader { /// Returns an empty result (no entity, no rows) if the source row is gone or /// the relationship is empty/unreadable, so the detail view degrades to an /// empty state rather than trapping. - func relatedRows( + public func relatedRows( of rowID: PersistentIdentifier, relationship name: String, sourceType: any PersistentModel.Type, diff --git a/Shared/SwiftDataInspector/Tests/SwiftDataInspectorReaderTests.swift b/Shared/SwiftDataInspector/Tests/SwiftDataInspectorReaderTests.swift new file mode 100644 index 00000000..5f5fb682 --- /dev/null +++ b/Shared/SwiftDataInspector/Tests/SwiftDataInspectorReaderTests.swift @@ -0,0 +1,89 @@ +import Foundation +import SwiftData +import SwiftDataInspector +import Testing + +@Model +final class ReaderWidget { + var name: String? + var quantity: Int? + + init(name: String?, quantity: Int?) { + self.name = name + self.quantity = quantity + } +} + +/// Exercises the promoted, headless public `SwiftDataInspectorReader` API (the +/// non-UI surface the Porthole SwiftData connector uses). +struct SwiftDataInspectorReaderTests { + private func makeContainer() throws -> ModelContainer { + let schema = Schema([ReaderWidget.self]) + let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return try ModelContainer(for: schema, configurations: [configuration]) + } + + private func seed(_ container: ModelContainer, count: Int) { + let context = ModelContext(container) + for index in 0 ..< count { + context.insert(ReaderWidget(name: "Widget \(index)", quantity: index)) + } + try? context.save() + } + + @Test func loadEntitiesReportsNameCountAndColumns() async throws { + let container = try makeContainer() + seed(container, count: 5) + let reader = SwiftDataInspectorReader( + container: container, + modelTypes: nil, + rowLimit: 500, + valueFormatter: nil, + ) + + let entities = await reader.loadEntities() + let widget = try #require(entities.first { $0.name == "ReaderWidget" }) + #expect(widget.count == 5) + #expect(widget.columns.contains("name")) + #expect(widget.columns.contains("quantity")) + } + + @Test func rowsReturnFormattedCells() async throws { + let container = try makeContainer() + seed(container, count: 3) + let reader = SwiftDataInspectorReader( + container: container, + modelTypes: nil, + rowLimit: 500, + valueFormatter: nil, + ) + + let widget = try #require(await reader.loadEntities().first { $0.name == "ReaderWidget" }) + let page = await reader.rows(for: widget) + #expect(page.rows.count == 3) + #expect(page.totalCount == 3) + #expect(page.isTruncated == false) + #expect(page.rows.contains { $0.cells["name"] == "Widget 0" }) + } + + @Test func rowLimitTruncatesAndPagesGrow() async throws { + let container = try makeContainer() + seed(container, count: 10) + let reader = SwiftDataInspectorReader( + container: container, + modelTypes: nil, + rowLimit: 4, + valueFormatter: nil, + ) + + let widget = try #require(await reader.loadEntities().first { $0.name == "ReaderWidget" }) + let firstPage = await reader.rows(for: widget, pageCount: 1) + #expect(firstPage.rows.count == 4) + #expect(firstPage.totalCount == 10) + #expect(firstPage.isTruncated) + + let secondPage = await reader.rows(for: widget, pageCount: 2) + #expect(secondPage.rows.count == 8) + #expect(secondPage.isTruncated) + } +} diff --git a/Where/WherePorthole/AGENTS.md b/Where/WherePorthole/AGENTS.md new file mode 100644 index 00000000..c9085a44 --- /dev/null +++ b/Where/WherePorthole/AGENTS.md @@ -0,0 +1,35 @@ +# WherePorthole – Module Shape + +WherePorthole is the Where feature's Porthole connector: `WhereConnector` over +`WhereServices`, exposing residency data and a few safe actions. See +[`README.md`](README.md). + +This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature +[`Where/AGENTS.md`](../AGENTS.md) — read those first. + +## Scope & dependencies + +- Depends on **PortholeKit** + **WhereCore** (RegionKit transitively). It reads + *only* through `WhereServices` collaborators (`reports`, `evidence`, + `resolution`, `ingestor`) — no new domain logic, no store I/O of its own. +- **Read-mostly, no destructive actions in v1.** `scan-data-issues`, + `capture-location-now`, and `attribute-coordinate` are all safe. Backup export + and store mutation are deliberately out (they need blob transfer / are + destructive) — see the suite `TODOs.md`. + +## Invariants + +- **Preferences cross as a `Sendable` snapshot** (`WherePreferencesSnapshot`), + not the non-Sendable `WherePreferences`, so handlers stay `@Sendable`. +- **Wired into the app through WhereUI, not the app target.** WhereUI (a dynamic + framework) links this and every Porthole product; the `Where` app and + `WhereUITests` add nothing, so no duplicate copy can split type-keyed metadata + (root AGENTS.md "Targets"). The `DEBUG`-only `PortholeDeveloperView` in WhereUI + builds the `Porthole` and registers the connectors. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`WherePortholeTests`, `extraPackageProducts: [PortholeKit, PortholeCore, +WhereCore, RegionKit]`). Build `WhereServices` over `SwiftDataStore.inMemory()` + +`IdleLocationSource` and seed via `journal`. diff --git a/Where/WherePorthole/README.md b/Where/WherePorthole/README.md new file mode 100644 index 00000000..5401c747 --- /dev/null +++ b/Where/WherePorthole/README.md @@ -0,0 +1,42 @@ +# WherePorthole + +WherePorthole is the Where feature's [Porthole](../../Shared/Porthole) connector: +read-mostly access to residency data through the existing `WhereServices` +collaborators, plus a couple of safe actions. It adds no domain logic and no +destructive actions. + +## Using it + +```swift +import WherePorthole + +porthole.register(WhereConnector( + services: services, + preferences: WherePreferencesSnapshot(/* … from WherePreferences … */), +)) +``` + +`WherePreferences` isn't `Sendable`, so the connector takes a `Sendable` +snapshot of the preference values it exposes and uses the drift threshold for +scans. + +## Surface (id `where`) + +- Data source `year-report` (filter `year`) — per-region day counts. +- Data source `manual-days` (filter `year`) — user-asserted day entries. +- Data source `evidence` (filter `year`) — evidence metadata (no blob bytes). +- Data source `preferences` — a single-row preferences snapshot. +- Data source `data-issues` (filter `year`) — data-quality issue counts by + category (cached scan). +- Action `scan-data-issues` (`year`) — force a fresh scan; returns counts. +- Action `capture-location-now` — one-shot GPS fix, or null. +- Action `attribute-coordinate` (`latitude`, `longitude`) — the region a point + falls in. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`WherePortholeTests`). Builds `WhereServices` over `SwiftDataStore.inMemory()` + +`IdleLocationSource`, seeds a manual day, and asserts the year-report/manual-days +/preferences/data-issues rows and the three actions (including an +`attribute-coordinate` spot-check for California). diff --git a/Where/WherePorthole/Sources/WhereConnector.swift b/Where/WherePorthole/Sources/WhereConnector.swift new file mode 100644 index 00000000..a2786629 --- /dev/null +++ b/Where/WherePorthole/Sources/WhereConnector.swift @@ -0,0 +1,297 @@ +import Foundation +import PortholeCore +import PortholeKit +import RegionKit +import WhereCore + +/// A Sendable snapshot of the app's preferences, so the connector can expose +/// them (and use the drift threshold for scans) without capturing the +/// non-Sendable `WherePreferences` in its handlers. +public struct WherePreferencesSnapshot: Sendable { + public var hasOnboarded: Bool + public var wantsTracking: Bool + public var remindersEnabled: Bool + public var summaryEnabled: Bool + public var issueAlertsEnabled: Bool + public var driftThresholdMeters: Int + + public init( + hasOnboarded: Bool, + wantsTracking: Bool, + remindersEnabled: Bool, + summaryEnabled: Bool, + issueAlertsEnabled: Bool, + driftThresholdMeters: Int, + ) { + self.hasOnboarded = hasOnboarded + self.wantsTracking = wantsTracking + self.remindersEnabled = remindersEnabled + self.summaryEnabled = summaryEnabled + self.issueAlertsEnabled = issueAlertsEnabled + self.driftThresholdMeters = driftThresholdMeters + } +} + +/// The Where feature's Porthole connector (id `where`): read-mostly access to +/// year reports, manual days, evidence metadata, preferences, and data issues, +/// plus a couple of safe actions — everything through the existing +/// `WhereServices` collaborators. No new domain logic and no destructive +/// actions in v1. +public final class WhereConnector: PortholeConnector { + public let descriptor = PortholeConnectorDescriptor( + id: "where", + title: "Where", + summary: "Residency data: year reports, manual days, evidence, preferences, and data issues.", + version: 1, + ) + + private let services: WhereServices + private let preferences: WherePreferencesSnapshot + + public init(services: WhereServices, preferences: WherePreferencesSnapshot) { + self.services = services + self.preferences = preferences + } + + public func actions() -> [PortholeAction] { + let services = services + let preferences = preferences + return [ + PortholeAction( + descriptor: PortholeActionDescriptor( + id: "scan-data-issues", + title: "Scan data issues", + summary: "Force a fresh data-quality scan for a year and return issue counts by category.", + parameters: .object(["year": .integer("Gregorian year")], required: ["year"]), + isDestructive: false, + ), + handler: { parameters in + let year = Int(parameters["year"]?.intValue ?? 0) + let report = try await services.reports.yearReport(for: year) + let issues = try await services.resolution.issues( + year: year, + primaryRegions: Region.primaryRegions(in: report.totals), + driftThresholdMeters: Double(preferences.driftThresholdMeters), + force: true, + ) + return .object(Self.issueCounts(issues)) + }, + ), + PortholeAction( + descriptor: PortholeActionDescriptor( + id: "capture-location-now", + title: "Capture location now", + summary: "Take a one-shot GPS fix and return the sample, or null if none is available.", + parameters: .object([:]), + isDestructive: false, + ), + handler: { _ in + guard let sample = await services.ingestor.currentLocation() + else { return .null } + return Self.sampleRow(sample) + }, + ), + PortholeAction( + descriptor: PortholeActionDescriptor( + id: "attribute-coordinate", + title: "Attribute coordinate", + summary: "Return the region a latitude/longitude falls in.", + parameters: .object([ + "latitude": .number("Degrees"), + "longitude": .number("Degrees"), + ], required: ["latitude", "longitude"]), + isDestructive: false, + ), + handler: { parameters in + guard let latitude = parameters["latitude"]?.doubleValue, + let longitude = parameters["longitude"]?.doubleValue + else { + throw PortholeError + .invalidParameters("latitude and longitude are required") + } + let region = RegionAttributor.all.region( + at: Coordinate(latitude: latitude, longitude: longitude), + ) + return .object([ + "region": .string(region.rawValue), + "name": .string(region.localizedName), + ]) + }, + ), + ] + } + + public func dataSources() -> [PortholeDataSource] { + let services = services + let preferences = preferences + return [ + PortholeDataSource( + descriptor: PortholeDataSourceDescriptor( + id: "year-report", + title: "Year report", + summary: "Per-region day counts for a year.", + rowSchema: .object([ + "region": .string(), + "name": .string(), + "days": .integer(), + ]), + filters: .object(["year": .integer("Gregorian year")], required: ["year"]), + supportsSubscription: false, + ), + fetch: { query in + let year = try Self.year(query.filters) + let report = try await services.reports.yearReport(for: year) + let rows = report.totals + .sorted { $0.value > $1.value } + .map { region, days in + PortholeValue.object([ + "region": .string(region.rawValue), + "name": .string(region.localizedName), + "days": .int(Int64(days)), + ]) + } + return PortholePage(rows: rows, totalCount: rows.count) + }, + ), + PortholeDataSource( + descriptor: PortholeDataSourceDescriptor( + id: "manual-days", + title: "Manual days", + summary: "User-asserted day entries for a year.", + rowSchema: .object([ + "day": .string(), + "regions": .array(of: .string()), + "isAuthoritative": .boolean(), + ]), + filters: .object(["year": .integer("Gregorian year")], required: ["year"]), + supportsSubscription: false, + ), + fetch: { query in + let year = try Self.year(query.filters) + let days = try await services.reports.manualDays(inYear: year) + let rows = days.map { presence in + PortholeValue.object([ + "day": .string(String(describing: presence.day)), + "regions": .array(presence.regions.map { .string($0.rawValue) }), + "isAuthoritative": .bool(presence.isAuthoritative), + ]) + } + return PortholePage(rows: rows, totalCount: rows.count) + }, + ), + PortholeDataSource( + descriptor: PortholeDataSourceDescriptor( + id: "evidence", + title: "Evidence", + summary: "Metadata for user-attached evidence in a year (no blob bytes).", + rowSchema: .object([ + "id": .string(), + "kind": .string(), + "capturedAt": .date(), + "region": .string(), + "note": .string(), + ]), + filters: .object(["year": .integer("Gregorian year")], required: ["year"]), + supportsSubscription: false, + ), + fetch: { query in + let year = try Self.year(query.filters) + let evidence = try await services.evidence.list(for: year) + let rows = evidence.map { item -> PortholeValue in + var object: [String: PortholeValue] = [ + "id": .string(item.id.uuidString), + "kind": .string(String(describing: item.kind)), + "capturedAt": .date(item.capturedAt), + ] + if let region = item.region { object["region"] = .string(region.rawValue) } + if let note = item.note { object["note"] = .string(note) } + return .object(object) + } + return PortholePage(rows: rows, totalCount: rows.count) + }, + ), + PortholeDataSource( + descriptor: PortholeDataSourceDescriptor( + id: "preferences", + title: "Preferences", + summary: "A single-row snapshot of the app's preferences.", + rowSchema: .object([ + "hasOnboarded": .boolean(), + "wantsTracking": .boolean(), + "remindersEnabled": .boolean(), + "summaryEnabled": .boolean(), + "issueAlertsEnabled": .boolean(), + "driftThresholdMeters": .integer(), + ]), + filters: .object([:]), + supportsSubscription: false, + ), + fetch: { _ in + PortholePage(rows: [.object([ + "hasOnboarded": .bool(preferences.hasOnboarded), + "wantsTracking": .bool(preferences.wantsTracking), + "remindersEnabled": .bool(preferences.remindersEnabled), + "summaryEnabled": .bool(preferences.summaryEnabled), + "issueAlertsEnabled": .bool(preferences.issueAlertsEnabled), + "driftThresholdMeters": .int(Int64(preferences.driftThresholdMeters)), + ])], totalCount: 1) + }, + ), + PortholeDataSource( + descriptor: PortholeDataSourceDescriptor( + id: "data-issues", + title: "Data issues", + summary: "Counts of data-quality issues by category for a year (cached scan).", + rowSchema: .object(["category": .string(), "count": .integer()]), + filters: .object(["year": .integer("Gregorian year")], required: ["year"]), + supportsSubscription: false, + ), + fetch: { query in + let year = try Self.year(query.filters) + let report = try await services.reports.yearReport(for: year) + let issues = try await services.resolution.issues( + year: year, + primaryRegions: Region.primaryRegions(in: report.totals), + driftThresholdMeters: Double(preferences.driftThresholdMeters), + force: false, + ) + let counts = Self.issueCounts(issues) + let rows = counts.sorted { $0.key < $1.key }.map { category, count in + PortholeValue.object(["category": .string(category), "count": count]) + } + return PortholePage(rows: rows, totalCount: rows.count) + }, + ), + ] + } + + // MARK: - Helpers + + private static func year(_ filters: PortholeValue) throws -> Int { + guard let year = filters["year"]?.intValue else { + throw PortholeError.invalidParameters("`year` is required") + } + return Int(year) + } + + private static func issueCounts(_ issues: [any DataIssue]) -> [String: PortholeValue] { + var counts: [String: Int] = [:] + for category in DataIssueCategory.allCases { + counts[String(describing: category)] = 0 + } + for issue in issues { + counts[String(describing: issue.category), default: 0] += 1 + } + return counts.mapValues { .int(Int64($0)) } + } + + private static func sampleRow(_ sample: LocationSample) -> PortholeValue { + .object([ + "timestamp": .date(sample.timestamp), + "latitude": .double(sample.coordinate.latitude), + "longitude": .double(sample.coordinate.longitude), + "horizontalAccuracy": .double(sample.horizontalAccuracy), + "source": .string(String(describing: sample.source)), + ]) + } +} diff --git a/Where/WherePorthole/Tests/WhereConnectorTests.swift b/Where/WherePorthole/Tests/WhereConnectorTests.swift new file mode 100644 index 00000000..2b074d2c --- /dev/null +++ b/Where/WherePorthole/Tests/WhereConnectorTests.swift @@ -0,0 +1,110 @@ +import Foundation +import PortholeCore +import PortholeKit +import RegionKit +import Testing +@_spi(Testing) import WhereCore +@testable import WherePorthole + +@MainActor +struct WhereConnectorTests { + private func makeServices() async throws -> WhereServices { + let store = try SwiftDataStore.inMemory() + return try await WhereServices.make(store: store, locationSource: IdleLocationSource()) + } + + private func preferences() -> WherePreferencesSnapshot { + WherePreferencesSnapshot( + hasOnboarded: true, + wantsTracking: true, + remindersEnabled: false, + summaryEnabled: false, + issueAlertsEnabled: true, + driftThresholdMeters: 500, + ) + } + + private func source( + _ connector: WhereConnector, + _ id: PortholeDataSourceID, + ) -> PortholeDataSource { + connector.dataSources().first { $0.descriptor.id == id }! + } + + private func action(_ connector: WhereConnector, _ id: PortholeActionID) -> PortholeAction { + connector.actions().first { $0.descriptor.id == id }! + } + + @Test func yearReportReflectsSeededManualDay() async throws { + let services = try await makeServices() + let date = Date(timeIntervalSince1970: 1_735_732_800) // 2025-01-01 + try await services.journal.addManualDay(date: date, regions: [.california], audit: nil) + + let connector = WhereConnector(services: services, preferences: preferences()) + let page = try await source(connector, "year-report") + .fetch(PortholeQuery(filters: ["year": 2025])) + let california = page.rows.first { $0["region"]?.stringValue == Region.california.rawValue } + #expect(california != nil) + #expect((california?["days"]?.intValue ?? 0) >= 1) + } + + @Test func manualDaysSourceListsSeededDays() async throws { + let services = try await makeServices() + let date = Date(timeIntervalSince1970: 1_735_732_800) + try await services.journal.addManualDay(date: date, regions: [.newYork], audit: nil) + + let connector = WhereConnector(services: services, preferences: preferences()) + let page = try await source(connector, "manual-days") + .fetch(PortholeQuery(filters: ["year": 2025])) + #expect(!page.rows.isEmpty) + #expect(page.rows + .contains { + $0["regions"]?.arrayValue?.contains(.string(Region.newYork.rawValue)) == true + }) + } + + @Test func preferencesSourceReflectsSnapshot() async throws { + let services = try await makeServices() + let connector = WhereConnector(services: services, preferences: preferences()) + let page = try await source(connector, "preferences").fetch(PortholeQuery()) + let row = try #require(page.rows.first) + #expect(row["hasOnboarded"]?.boolValue == true) + #expect(row["driftThresholdMeters"]?.intValue == 500) + } + + @Test func dataIssuesSourceReturnsCategoryCounts() async throws { + let services = try await makeServices() + let connector = WhereConnector(services: services, preferences: preferences()) + let page = try await source(connector, "data-issues") + .fetch(PortholeQuery(filters: ["year": 2025])) + // Every category is represented (count may be zero). + let categories = Set(page.rows.compactMap { $0["category"]?.stringValue }) + #expect(categories.contains("missingDays")) + } + + @Test func scanDataIssuesActionReturnsCounts() async throws { + let services = try await makeServices() + let connector = WhereConnector(services: services, preferences: preferences()) + let result = try await action(connector, "scan-data-issues") + .handler(.object(["year": 2025])) + #expect(result["missingDays"]?.intValue != nil) + #expect(result["borderDrift"]?.intValue != nil) + } + + @Test func attributeCoordinateNamesTheRegion() async throws { + let services = try await makeServices() + let connector = WhereConnector(services: services, preferences: preferences()) + // A point in central California. + let result = try await action(connector, "attribute-coordinate") + .handler(.object(["latitude": 37.0, "longitude": -120.0])) + #expect(result["region"]?.stringValue == Region.california.rawValue) + #expect(result["name"]?.stringValue != nil) + } + + @Test func captureLocationNowReturnsNullWithoutAFix() async throws { + let services = try await makeServices() + let connector = WhereConnector(services: services, preferences: preferences()) + let result = try await action(connector, "capture-location-now").handler(.object([:])) + #expect(result == .null) + } +} diff --git a/Where/WhereUI/Sources/Developer/DeveloperToolsView.swift b/Where/WhereUI/Sources/Developer/DeveloperToolsView.swift index ef5a79a7..6a76170b 100644 --- a/Where/WhereUI/Sources/Developer/DeveloperToolsView.swift +++ b/Where/WhereUI/Sources/Developer/DeveloperToolsView.swift @@ -61,6 +61,15 @@ } label: { Label(Strings.developerRegionMapLink, systemImage: "map") } + + NavigationLink { + PortholeDeveloperView() + } label: { + Label( + Strings.developerPortholeLink, + systemImage: "point.3.connected.trianglepath.dotted", + ) + } } footer: { Text(Strings.developerFooter) } diff --git a/Where/WhereUI/Sources/Developer/PortholeDeveloperView.swift b/Where/WhereUI/Sources/Developer/PortholeDeveloperView.swift new file mode 100644 index 00000000..d49eb052 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/PortholeDeveloperView.swift @@ -0,0 +1,63 @@ +#if DEBUG + import PeriscopeCore + import PortholeKit + import PortholeKitUI + import PortholePeriscope + import PortholeSwiftData + import SwiftUI + import WhereCore + import WherePorthole + + /// The DEBUG-only Porthole developer surface: lazily builds a `Porthole` with + /// the Where connectors registered (plus the built-ins) and shows the pairing + /// UI. Compiled out of release entirely. + struct PortholeDeveloperView: View { + @Environment(WhereModel.self) private var model: WhereModel? + @Environment(WhereSession.self) private var session: WhereSession? + @State private var porthole: Porthole? + + var body: some View { + Group { + if let porthole { + PortholePairingView(porthole: porthole) + } else { + ProgressView() + .task { porthole = makePorthole() } + } + } + } + + @MainActor + private func makePorthole() -> Porthole? { + guard let session else { return nil } + let porthole = Porthole(configuration: PortholeConfiguration( + appName: "Where", + appGroupIdentifiers: ["group.com.stuff.where"], + )) + porthole.register(WhereConnector( + services: session.services, + preferences: WherePreferencesSnapshot( + hasOnboarded: session.preferences.hasOnboarded, + wantsTracking: session.preferences.wantsTracking, + remindersEnabled: session.preferences.remindersEnabled, + summaryEnabled: session.preferences.summaryEnabled, + issueAlertsEnabled: session.preferences.issueAlertsEnabled, + driftThresholdMeters: session.preferences.driftThresholdMeters, + ), + )) + if let store = model?.logStore { + porthole.register(PeriscopeConnector(store: store, system: .shared)) + } + if let container = session.services.modelContainer { + porthole.register(SwiftDataConnector( + id: "swiftdata", + title: "SwiftData", + container: container, + modelTypes: SwiftDataStore.inspectorModelTypes, + rowLimit: 500, + )) + } + return porthole + } + } +#endif diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift index 6defd2d3..bc4710c5 100644 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -924,6 +924,10 @@ enum Strings { localized("developer.regionMapLink") } + static var developerPortholeLink: String { + String(localized: "developer.portholeLink", defaultValue: "Porthole", bundle: .module) + } + /// Accessibility label for the floating, collapsed developer button. static var developerButtonLabel: String { localized("developer.button.label")