From 5b8a1efc87228aafac8258c7afa83d130e483706 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 23 Jul 2026 18:06:16 -0700 Subject: [PATCH 01/18] Porthole groundwork: add macOS 26 platform Pure-groundwork step (no behavior change): add .macOS(.v26) to the root Package.swift platforms so the forthcoming Porthole suite can ship macOS-capable libraries, a command-line tool, and a Mac Catalyst app. Note the macOS platform + Porthole targets in the root AGENTS.md deployment section. Closes plan step: groundwork-platforms --- AGENTS.md | 7 +++++++ Package.swift | 1 + 2 files changed, 8 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index a7d7cb31..b62e41c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,6 +77,13 @@ 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/`) adds macOS-capable libraries plus a +`.commandLineTool` (`porthole`) and a Mac Catalyst app (`PortholeApp`, +productName `Porthole`) in [`Project.swift`](Project.swift). 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.swift b/Package.swift index 5b23accf..cc7e603f 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"]), From bf7436f93e696fda3aa267c6a78c54299a1b69d7 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 23 Jul 2026 18:25:53 -0700 Subject: [PATCH 02/18] PortholeCore: wire protocol, framing, pairing crypto, secure channel The shared foundation of the Porthole suite (pure Foundation + CryptoKit, iOS + macOS): PortholeValue (JSON-shaped tree with tagged data/date), PortholeSchema (JSON-Schema render + runtime validate), typed identifiers and descriptors, the request/response wire protocol, length-prefixed framing, the PortholeTransport abstraction + in-memory LoopbackTransport, the X25519/HKDF pairing handshake, the ChaCha20-Poly1305 PortholeSecureChannel, and a Keychain-backed PortholeCredentialStore. Whole stack (framing, pairing crypto, secure channel round-trip incl. tamper/replay/wrong-key rejection) is tested in-process over the loopback transport. Closes plan step: porthole-core --- Package.swift | 5 + Project.swift | 9 + Shared/Porthole/PortholeCore/AGENTS.md | 47 +++ Shared/Porthole/PortholeCore/README.md | 77 +++++ .../Sources/LoopbackTransport.swift | 48 +++ .../Sources/PairingProtocol.swift | 150 ++++++++++ .../Sources/PortholeCredentialStore.swift | 187 ++++++++++++ .../Sources/PortholeDescriptors.swift | 90 ++++++ .../PortholeCore/Sources/PortholeFramer.swift | 51 ++++ .../Sources/PortholeIdentifiers.swift | 126 ++++++++ .../Sources/PortholeMessages.swift | 140 +++++++++ .../PortholeCore/Sources/PortholeQuery.swift | 34 +++ .../PortholeCore/Sources/PortholeSchema.swift | 220 ++++++++++++++ .../Sources/PortholeSecureChannel.swift | 115 ++++++++ .../Sources/PortholeTransport.swift | 28 ++ .../PortholeCore/Sources/PortholeValue.swift | 274 ++++++++++++++++++ .../Tests/LoopbackTransportTests.swift | 33 +++ .../Tests/PairingCryptographyTests.swift | 153 ++++++++++ .../Tests/PairingProtocolTests.swift | 32 ++ .../Tests/PortholeCoreTestSupport.swift | 65 +++++ .../Tests/PortholeCredentialStoreTests.swift | 56 ++++ .../Tests/PortholeFramerTests.swift | 55 ++++ .../Tests/PortholeMessagesTests.swift | 92 ++++++ .../Tests/PortholeSchemaTests.swift | 86 ++++++ .../Tests/PortholeSecureChannelTests.swift | 102 +++++++ .../Tests/PortholeValueTests.swift | 80 +++++ 26 files changed, 2355 insertions(+) create mode 100644 Shared/Porthole/PortholeCore/AGENTS.md create mode 100644 Shared/Porthole/PortholeCore/README.md create mode 100644 Shared/Porthole/PortholeCore/Sources/LoopbackTransport.swift create mode 100644 Shared/Porthole/PortholeCore/Sources/PairingProtocol.swift create mode 100644 Shared/Porthole/PortholeCore/Sources/PortholeCredentialStore.swift create mode 100644 Shared/Porthole/PortholeCore/Sources/PortholeDescriptors.swift create mode 100644 Shared/Porthole/PortholeCore/Sources/PortholeFramer.swift create mode 100644 Shared/Porthole/PortholeCore/Sources/PortholeIdentifiers.swift create mode 100644 Shared/Porthole/PortholeCore/Sources/PortholeMessages.swift create mode 100644 Shared/Porthole/PortholeCore/Sources/PortholeQuery.swift create mode 100644 Shared/Porthole/PortholeCore/Sources/PortholeSchema.swift create mode 100644 Shared/Porthole/PortholeCore/Sources/PortholeSecureChannel.swift create mode 100644 Shared/Porthole/PortholeCore/Sources/PortholeTransport.swift create mode 100644 Shared/Porthole/PortholeCore/Sources/PortholeValue.swift create mode 100644 Shared/Porthole/PortholeCore/Tests/LoopbackTransportTests.swift create mode 100644 Shared/Porthole/PortholeCore/Tests/PairingCryptographyTests.swift create mode 100644 Shared/Porthole/PortholeCore/Tests/PairingProtocolTests.swift create mode 100644 Shared/Porthole/PortholeCore/Tests/PortholeCoreTestSupport.swift create mode 100644 Shared/Porthole/PortholeCore/Tests/PortholeCredentialStoreTests.swift create mode 100644 Shared/Porthole/PortholeCore/Tests/PortholeFramerTests.swift create mode 100644 Shared/Porthole/PortholeCore/Tests/PortholeMessagesTests.swift create mode 100644 Shared/Porthole/PortholeCore/Tests/PortholeSchemaTests.swift create mode 100644 Shared/Porthole/PortholeCore/Tests/PortholeSecureChannelTests.swift create mode 100644 Shared/Porthole/PortholeCore/Tests/PortholeValueTests.swift diff --git a/Package.swift b/Package.swift index cc7e603f..f660f2a7 100644 --- a/Package.swift +++ b/Package.swift @@ -23,6 +23,7 @@ let package = Package( .library(name: "WhereIntents", targets: ["WhereIntents"]), .library(name: "BroadwayCore", targets: ["BroadwayCore"]), .library(name: "BroadwayUI", targets: ["BroadwayUI"]), + .library(name: "PortholeCore", targets: ["PortholeCore"]), ], dependencies: [ .package(url: "https://github.com/weichsel/ZIPFoundation", from: "0.9.20"), @@ -139,5 +140,9 @@ let package = Package( ], path: "Shared/Broadway/BroadwayUI/Sources", ), + .target( + name: "PortholeCore", + path: "Shared/Porthole/PortholeCore/Sources", + ), ], ) diff --git a/Project.swift b/Project.swift index 97f39ba1..44b3a8fa 100644 --- a/Project.swift +++ b/Project.swift @@ -391,6 +391,12 @@ let project = Project( "BroadwayCore", ], ), + unitTests( + name: "PortholeCoreTests", + bundleIdSuffix: "porthole.core", + productDependency: "PortholeCore", + sources: ["Shared/Porthole/PortholeCore/Tests/**"], + ), ], // Tuist's autogeneration doesn't emit working standalone test actions for // these unit-test bundles (only the aggregate `Stuff-Workspace` scheme @@ -433,6 +439,7 @@ let project = Project( "BroadwayCoreTests", "BroadwayUITests", "BroadwayCatalogTests", + "PortholeCoreTests", ]), testAction: .targets([ "StuffCoreTests", @@ -450,6 +457,7 @@ let project = Project( "BroadwayCoreTests", "BroadwayUITests", "BroadwayCatalogTests", + "PortholeCoreTests", ]), ), testScheme(name: "StuffCoreTests"), @@ -467,5 +475,6 @@ let project = Project( testScheme(name: "BroadwayCoreTests"), testScheme(name: "BroadwayUITests"), testScheme(name: "BroadwayCatalogTests"), + testScheme(name: "PortholeCoreTests"), ], ) 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/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..73e8b439 --- /dev/null +++ b/Shared/Porthole/PortholeCore/Sources/PortholeSecureChannel.swift @@ -0,0 +1,115 @@ +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 inner: any PortholeTransport + private let key: SymmetricKey + private let sendTag: [UInt8] + private let sendLock = NSLock() + private var sendCounter: UInt64 = 0 + + public init(wrapping inner: some PortholeTransport, key: SymmetricKey, role: Role) { + self.inner = inner + self.key = key + sendTag = role.sendTag + let receiveTag = role.receiveTag + + let (stream, continuation) = AsyncThrowingStream.makeStream() + incoming = stream + + Task { + var counter: UInt64 = 0 + do { + for try await sealed in inner.incoming { + 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 inner.send(payload) + } + + public func close() async { + await inner.close() + } + + // 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/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)])) + } +} From 89617737dcb9a37a4576835b3db93f89b49615c4 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 23 Jul 2026 18:34:35 -0700 Subject: [PATCH 03/18] PortholeKit runtime: composition root, connector protocol, session router The device-side runtime over PortholeCore: the @MainActor/@Observable Porthole composition root, the PortholeConnector protocol (+ PortholeAction / PortholeDataSource), a main-actor ConnectorRegistry resolving to a Sendable dispatch snapshot, the actor-based PortholeSessionRouter (decode, validate against schema, dispatch, bounded drop-oldest subscription streaming), the built-in app connector (app-info + ping), and PortholeState for the UI. Networking is not wired yet; the @_spi(Testing) attach(transport:) seam serves a full session over LoopbackTransport, and PortholeKitTests exercise hello/list/invoke/query/subscribe end-to-end in-process. Closes plan step: porthole-kit-runtime --- Package.swift | 8 + Project.swift | 10 + Shared/Porthole/PortholeKit/AGENTS.md | 48 +++++ Shared/Porthole/PortholeKit/README.md | 49 +++++ .../Sources/ConnectorRegistry.swift | 81 +++++++ .../Sources/Connectors/AppInfoConnector.swift | 91 ++++++++ .../PortholeKit/Sources/DeviceInfo.swift | 49 +++++ .../PortholeKit/Sources/Porthole.swift | 77 +++++++ .../Sources/PortholeConfiguration.swift | 30 +++ .../Sources/PortholeConnector.swift | 59 ++++++ .../PortholeKit/Sources/PortholeLog.swift | 11 + .../Sources/PortholeSessionRouter.swift | 197 ++++++++++++++++++ .../PortholeKit/Sources/PortholeState.swift | 38 ++++ .../Tests/PortholeKitTestSupport.swift | 189 +++++++++++++++++ .../Tests/PortholeSessionRouterTests.swift | 197 ++++++++++++++++++ 15 files changed, 1134 insertions(+) create mode 100644 Shared/Porthole/PortholeKit/AGENTS.md create mode 100644 Shared/Porthole/PortholeKit/README.md create mode 100644 Shared/Porthole/PortholeKit/Sources/ConnectorRegistry.swift create mode 100644 Shared/Porthole/PortholeKit/Sources/Connectors/AppInfoConnector.swift create mode 100644 Shared/Porthole/PortholeKit/Sources/DeviceInfo.swift create mode 100644 Shared/Porthole/PortholeKit/Sources/Porthole.swift create mode 100644 Shared/Porthole/PortholeKit/Sources/PortholeConfiguration.swift create mode 100644 Shared/Porthole/PortholeKit/Sources/PortholeConnector.swift create mode 100644 Shared/Porthole/PortholeKit/Sources/PortholeLog.swift create mode 100644 Shared/Porthole/PortholeKit/Sources/PortholeSessionRouter.swift create mode 100644 Shared/Porthole/PortholeKit/Sources/PortholeState.swift create mode 100644 Shared/Porthole/PortholeKit/Tests/PortholeKitTestSupport.swift create mode 100644 Shared/Porthole/PortholeKit/Tests/PortholeSessionRouterTests.swift diff --git a/Package.swift b/Package.swift index f660f2a7..0a272bd5 100644 --- a/Package.swift +++ b/Package.swift @@ -24,6 +24,7 @@ let package = Package( .library(name: "BroadwayCore", targets: ["BroadwayCore"]), .library(name: "BroadwayUI", targets: ["BroadwayUI"]), .library(name: "PortholeCore", targets: ["PortholeCore"]), + .library(name: "PortholeKit", targets: ["PortholeKit"]), ], dependencies: [ .package(url: "https://github.com/weichsel/ZIPFoundation", from: "0.9.20"), @@ -144,5 +145,12 @@ let package = Package( name: "PortholeCore", path: "Shared/Porthole/PortholeCore/Sources", ), + .target( + name: "PortholeKit", + dependencies: [ + .target(name: "PortholeCore"), + ], + path: "Shared/Porthole/PortholeKit/Sources", + ), ], ) diff --git a/Project.swift b/Project.swift index 44b3a8fa..03a24340 100644 --- a/Project.swift +++ b/Project.swift @@ -397,6 +397,13 @@ let project = Project( productDependency: "PortholeCore", sources: ["Shared/Porthole/PortholeCore/Tests/**"], ), + unitTests( + name: "PortholeKitTests", + bundleIdSuffix: "porthole.kit", + productDependency: "PortholeKit", + sources: ["Shared/Porthole/PortholeKit/Tests/**"], + extraPackageProducts: ["PortholeCore"], + ), ], // Tuist's autogeneration doesn't emit working standalone test actions for // these unit-test bundles (only the aggregate `Stuff-Workspace` scheme @@ -440,6 +447,7 @@ let project = Project( "BroadwayUITests", "BroadwayCatalogTests", "PortholeCoreTests", + "PortholeKitTests", ]), testAction: .targets([ "StuffCoreTests", @@ -458,6 +466,7 @@ let project = Project( "BroadwayUITests", "BroadwayCatalogTests", "PortholeCoreTests", + "PortholeKitTests", ]), ), testScheme(name: "StuffCoreTests"), @@ -476,5 +485,6 @@ let project = Project( testScheme(name: "BroadwayUITests"), testScheme(name: "BroadwayCatalogTests"), testScheme(name: "PortholeCoreTests"), + testScheme(name: "PortholeKitTests"), ], ) diff --git a/Shared/Porthole/PortholeKit/AGENTS.md b/Shared/Porthole/PortholeKit/AGENTS.md new file mode 100644 index 00000000..214aa4e5 --- /dev/null +++ b/Shared/Porthole/PortholeKit/AGENTS.md @@ -0,0 +1,48 @@ +# 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 + a thin adapter so the tested core carries the logic. + +## 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/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/Porthole.swift b/Shared/Porthole/PortholeKit/Sources/Porthole.swift new file mode 100644 index 00000000..84743404 --- /dev/null +++ b/Shared/Porthole/PortholeKit/Sources/Porthole.swift @@ -0,0 +1,77 @@ +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 var sessionTasks: [Task] = [] + + public init(configuration: PortholeConfiguration) { + self.configuration = configuration + register(AppInfoConnector(appName: configuration.appName, bundleID: configuration.bundleID)) + } + + /// 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 and accepting connections. Wired in the network layer. + public func start() throws { + // Implemented in the network layer (PortholeServer). + } + + /// Stops advertising and closes active sessions. + public func stop() { + for task in sessionTasks { + task.cancel() + } + sessionTasks.removeAll() + state.activeSessionCount = 0 + state.isAdvertising = false + } + + 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/PortholeSessionRouter.swift b/Shared/Porthole/PortholeKit/Sources/PortholeSessionRouter.swift new file mode 100644 index 00000000..b7bb3140 --- /dev/null +++ b/Shared/Porthole/PortholeKit/Sources/PortholeSessionRouter.swift @@ -0,0 +1,197 @@ +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 { + 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 { + 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/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) + } +} From 2dad158bcf8a4b5a0203a24894d8ce945d1902ae Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 23 Jul 2026 18:47:46 -0700 Subject: [PATCH 04/18] PortholeKit network layer: Bonjour listener, pairing manager, secure sessions Wires Porthole.start()/stop()/revoke() to a real NWListener that advertises _porthole._tcp with a TXT record and accepts connections. Each connection runs DevicePairingManager (pure of Network types): the X25519/HKDF pairing handshake with a single active 6-digit code (accumulating attempts, 3-strike burn, 120s expiry) and the session re-derivation, then hands the same TransportFrameReader to a device-role PortholeSecureChannel feeding the session router. Adds TransportFrameReader to PortholeCore and a reader-based PortholeSecureChannel init so the plaintext handshake and encrypted session share one connection without a second stream iterator. NWConnectionTransport is a thin framing adapter; the handshake/pairing lifecycle is fully tested over LoopbackTransport (pairing success, wrong code, 3-strike burn, expiry, session key match, unknown pairing, revoke). Closes plan step: porthole-kit-network --- .../Sources/PortholeSecureChannel.swift | 34 ++- .../Sources/TransportFrameReader.swift | 65 +++++ Shared/Porthole/PortholeKit/AGENTS.md | 11 +- .../Sources/DevicePairingManager.swift | 263 ++++++++++++++++++ .../Sources/NWConnectionTransport.swift | 87 ++++++ .../PortholeKit/Sources/Porthole.swift | 83 +++++- .../PortholeKit/Sources/PortholeServer.swift | 115 ++++++++ .../Tests/DevicePairingManagerTests.swift | 203 ++++++++++++++ .../Tests/PairingTestSupport.swift | 165 +++++++++++ 9 files changed, 1016 insertions(+), 10 deletions(-) create mode 100644 Shared/Porthole/PortholeCore/Sources/TransportFrameReader.swift create mode 100644 Shared/Porthole/PortholeKit/Sources/DevicePairingManager.swift create mode 100644 Shared/Porthole/PortholeKit/Sources/NWConnectionTransport.swift create mode 100644 Shared/Porthole/PortholeKit/Sources/PortholeServer.swift create mode 100644 Shared/Porthole/PortholeKit/Tests/DevicePairingManagerTests.swift create mode 100644 Shared/Porthole/PortholeKit/Tests/PairingTestSupport.swift diff --git a/Shared/Porthole/PortholeCore/Sources/PortholeSecureChannel.swift b/Shared/Porthole/PortholeCore/Sources/PortholeSecureChannel.swift index 73e8b439..21fc4368 100644 --- a/Shared/Porthole/PortholeCore/Sources/PortholeSecureChannel.swift +++ b/Shared/Porthole/PortholeCore/Sources/PortholeSecureChannel.swift @@ -34,14 +34,36 @@ public final class PortholeSecureChannel: PortholeTransport, @unchecked Sendable public let incoming: AsyncThrowingStream - private let inner: any PortholeTransport + 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 - public init(wrapping inner: some PortholeTransport, key: SymmetricKey, role: Role) { - self.inner = inner + /// 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 @@ -52,7 +74,7 @@ public final class PortholeSecureChannel: PortholeTransport, @unchecked Sendable Task { var counter: UInt64 = 0 do { - for try await sealed in inner.incoming { + while let sealed = try await reader.next() { let plaintext = try Self.open( sealed, key: key, @@ -81,11 +103,11 @@ public final class PortholeSecureChannel: PortholeTransport, @unchecked Sendable var payload = Data() payload.append(box.ciphertext) payload.append(box.tag) - try await inner.send(payload) + try await sendClosure(payload) } public func close() async { - await inner.close() + await closeClosure() } // MARK: - Record cipher 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/PortholeKit/AGENTS.md b/Shared/Porthole/PortholeKit/AGENTS.md index 214aa4e5..8099068c 100644 --- a/Shared/Porthole/PortholeKit/AGENTS.md +++ b/Shared/Porthole/PortholeKit/AGENTS.md @@ -37,7 +37,16 @@ first. 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 - a thin adapter so the tested core carries the logic. + (`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 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/NWConnectionTransport.swift b/Shared/Porthole/PortholeKit/Sources/NWConnectionTransport.swift new file mode 100644 index 00000000..079194d8 --- /dev/null +++ b/Shared/Porthole/PortholeKit/Sources/NWConnectionTransport.swift @@ -0,0 +1,87 @@ +import Foundation +import Network +import PortholeCore + +/// 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, router) speak only in frames. +/// +/// 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. +final class NWConnectionTransport: PortholeTransport, @unchecked Sendable { + let incoming: AsyncThrowingStream + + private let connection: NWConnection + private let continuation: AsyncThrowingStream.Continuation + private let framerLock = NSLock() + private var framer = PortholeFramer() + + 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() + } + } + + 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() + } + }) + } + } + + func close() async { + connection.cancel() + } +} diff --git a/Shared/Porthole/PortholeKit/Sources/Porthole.swift b/Shared/Porthole/PortholeKit/Sources/Porthole.swift index 84743404..98a3fecd 100644 --- a/Shared/Porthole/PortholeKit/Sources/Porthole.swift +++ b/Shared/Porthole/PortholeKit/Sources/Porthole.swift @@ -17,10 +17,24 @@ public final class Porthole { 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 init(configuration: PortholeConfiguration) { + 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)) } @@ -30,21 +44,84 @@ public final class Porthole { registry.register(connector) } - /// Starts advertising and accepting connections. Wired in the network layer. + /// Starts advertising over Bonjour and accepting connections. public func start() throws { - // Implemented in the network layer (PortholeServer). + 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, 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/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/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 +} From bbc238d2c50f7190b66864f8ad809e45e0657c5f Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 23 Jul 2026 18:59:29 -0700 Subject: [PATCH 05/18] PortholeClientKit: Mac-side discovery, pairing, and sessions The Mac client over PortholeCore: PortholeBrowser (Bonjour discovery), PortholePairingClient (pair handshake + login-keychain credential persistence), PortholeClient (re-discover + open a session), and the PortholeSession actor (id-matched request/response + subscription streams). ClientHandshake mirrors the device handshake and is pure of Network types. Promotes NWConnectionTransport into PortholeCore so both sides share it. PortholeClientKitTests run the real device (PortholeKit) and client stacks in one process over LoopbackTransport: pair, open an encrypted session, and invoke/query/subscribe through the secure channel, plus wrong-code and revoked-pairing rejection. Closes plan step: porthole-client-kit --- Package.swift | 8 + Project.swift | 10 + Shared/Porthole/PortholeClientKit/AGENTS.md | 41 ++++ Shared/Porthole/PortholeClientKit/README.md | 50 +++++ .../Sources/ClientHandshake.swift | 114 +++++++++++ .../Sources/DiscoveredApp.swift | 73 +++++++ .../Sources/PortholeBrowser.swift | 54 +++++ .../Sources/PortholeClient.swift | 104 ++++++++++ .../Sources/PortholeClientSupport.swift | 50 +++++ .../Sources/PortholePairingClient.swift | 68 +++++++ .../Sources/PortholeSession.swift | 156 +++++++++++++++ .../Tests/EndToEndTestSupport.swift | 156 +++++++++++++++ .../Tests/PortholeEndToEndTests.swift | 186 ++++++++++++++++++ .../Sources/NWConnectionTransport.swift | 14 +- 14 files changed, 1077 insertions(+), 7 deletions(-) create mode 100644 Shared/Porthole/PortholeClientKit/AGENTS.md create mode 100644 Shared/Porthole/PortholeClientKit/README.md create mode 100644 Shared/Porthole/PortholeClientKit/Sources/ClientHandshake.swift create mode 100644 Shared/Porthole/PortholeClientKit/Sources/DiscoveredApp.swift create mode 100644 Shared/Porthole/PortholeClientKit/Sources/PortholeBrowser.swift create mode 100644 Shared/Porthole/PortholeClientKit/Sources/PortholeClient.swift create mode 100644 Shared/Porthole/PortholeClientKit/Sources/PortholeClientSupport.swift create mode 100644 Shared/Porthole/PortholeClientKit/Sources/PortholePairingClient.swift create mode 100644 Shared/Porthole/PortholeClientKit/Sources/PortholeSession.swift create mode 100644 Shared/Porthole/PortholeClientKit/Tests/EndToEndTestSupport.swift create mode 100644 Shared/Porthole/PortholeClientKit/Tests/PortholeEndToEndTests.swift rename Shared/Porthole/{PortholeKit => PortholeCore}/Sources/NWConnectionTransport.swift (85%) diff --git a/Package.swift b/Package.swift index 0a272bd5..1131d7c9 100644 --- a/Package.swift +++ b/Package.swift @@ -25,6 +25,7 @@ let package = Package( .library(name: "BroadwayUI", targets: ["BroadwayUI"]), .library(name: "PortholeCore", targets: ["PortholeCore"]), .library(name: "PortholeKit", targets: ["PortholeKit"]), + .library(name: "PortholeClientKit", targets: ["PortholeClientKit"]), ], dependencies: [ .package(url: "https://github.com/weichsel/ZIPFoundation", from: "0.9.20"), @@ -152,5 +153,12 @@ let package = Package( ], path: "Shared/Porthole/PortholeKit/Sources", ), + .target( + name: "PortholeClientKit", + dependencies: [ + .target(name: "PortholeCore"), + ], + path: "Shared/Porthole/PortholeClientKit/Sources", + ), ], ) diff --git a/Project.swift b/Project.swift index 03a24340..92e2d8e4 100644 --- a/Project.swift +++ b/Project.swift @@ -404,6 +404,13 @@ let project = Project( sources: ["Shared/Porthole/PortholeKit/Tests/**"], extraPackageProducts: ["PortholeCore"], ), + unitTests( + name: "PortholeClientKitTests", + bundleIdSuffix: "porthole.client", + productDependency: "PortholeClientKit", + sources: ["Shared/Porthole/PortholeClientKit/Tests/**"], + extraPackageProducts: ["PortholeCore", "PortholeKit"], + ), ], // Tuist's autogeneration doesn't emit working standalone test actions for // these unit-test bundles (only the aggregate `Stuff-Workspace` scheme @@ -448,6 +455,7 @@ let project = Project( "BroadwayCatalogTests", "PortholeCoreTests", "PortholeKitTests", + "PortholeClientKitTests", ]), testAction: .targets([ "StuffCoreTests", @@ -467,6 +475,7 @@ let project = Project( "BroadwayCatalogTests", "PortholeCoreTests", "PortholeKitTests", + "PortholeClientKitTests", ]), ), testScheme(name: "StuffCoreTests"), @@ -486,5 +495,6 @@ let project = Project( testScheme(name: "BroadwayCatalogTests"), testScheme(name: "PortholeCoreTests"), testScheme(name: "PortholeKitTests"), + testScheme(name: "PortholeClientKitTests"), ], ) 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..f6f68676 --- /dev/null +++ b/Shared/Porthole/PortholeClientKit/Sources/PortholeClient.swift @@ -0,0 +1,104 @@ +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, + ) } + } + + /// 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..361f60d4 --- /dev/null +++ b/Shared/Porthole/PortholeClientKit/Tests/PortholeEndToEndTests.swift @@ -0,0 +1,186 @@ +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 psk = try #require(clientStore.key(for: paired.pairingID)) + 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 psk = try #require(clientStore.key(for: paired.pairingID)) + 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 psk = try #require(clientStore.key(for: paired.pairingID)) + 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/PortholeKit/Sources/NWConnectionTransport.swift b/Shared/Porthole/PortholeCore/Sources/NWConnectionTransport.swift similarity index 85% rename from Shared/Porthole/PortholeKit/Sources/NWConnectionTransport.swift rename to Shared/Porthole/PortholeCore/Sources/NWConnectionTransport.swift index 079194d8..ccca2819 100644 --- a/Shared/Porthole/PortholeKit/Sources/NWConnectionTransport.swift +++ b/Shared/Porthole/PortholeCore/Sources/NWConnectionTransport.swift @@ -1,22 +1,22 @@ import Foundation import Network -import PortholeCore /// 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, router) speak only in frames. +/// 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. -final class NWConnectionTransport: PortholeTransport, @unchecked Sendable { - let incoming: AsyncThrowingStream +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() - init(connection: NWConnection, queue: DispatchQueue) { + public init(connection: NWConnection, queue: DispatchQueue) { self.connection = connection (incoming, continuation) = AsyncThrowingStream.makeStream() @@ -65,7 +65,7 @@ final class NWConnectionTransport: PortholeTransport, @unchecked Sendable { } } - func send(_ frame: Data) async throws { + public func send(_ frame: Data) async throws { let framed = try PortholeFraming.encode(frame) try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< Void, @@ -81,7 +81,7 @@ final class NWConnectionTransport: PortholeTransport, @unchecked Sendable { } } - func close() async { + public func close() async { connection.cancel() } } From dc5bd8c3289bb1e7bcf692d472b627ca72386568 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 23 Jul 2026 19:10:22 -0700 Subject: [PATCH 06/18] porthole CLI: PortholeCLICore + command-line tool Adds swift-argument-parser and PortholeCLICore holding all CLI logic over PortholeClientKit: devices, pair, unpair, connectors, actions, sources, call (with destructive confirmation + --out for data), query (with --all paging and table/json output), tail, and an mcp stub. Pure helpers (CLIValueParsing, OutputFormatting, AppResolution) are unit-tested without a device. The PortholeCLI Tuist command-line tool is a thin @main entry that parses and dispatches async commands explicitly (the availability-safe form). Adds PortholeClient.unpair. Closes plan step: porthole-cli --- Package.resolved | 11 +- Package.swift | 10 ++ Project.swift | 28 ++++ .../Sources/PortholeCLIEntry.swift | 22 +++ Shared/Porthole/PortholeCLICore/AGENTS.md | 33 ++++ Shared/Porthole/PortholeCLICore/README.md | 45 +++++ .../Sources/AppResolution.swift | 47 ++++++ .../Sources/CLIValueParsing.swift | 111 ++++++++++++ .../Sources/DataCommands.swift | 158 ++++++++++++++++++ .../Sources/DiscoveryCommands.swift | 133 +++++++++++++++ .../Sources/IntrospectionCommands.swift | 79 +++++++++ .../PortholeCLICore/Sources/MCPCommand.swift | 19 +++ .../Sources/OutputFormatting.swift | 111 ++++++++++++ .../Sources/PortholeCommand.swift | 67 ++++++++ .../Tests/AppResolutionTests.swift | 63 +++++++ .../Tests/CLIValueParsingTests.swift | 65 +++++++ .../Tests/OutputFormattingTests.swift | 50 ++++++ .../Sources/PortholeClient.swift | 5 + 18 files changed, 1056 insertions(+), 1 deletion(-) create mode 100644 Shared/Porthole/PortholeCLI/Sources/PortholeCLIEntry.swift create mode 100644 Shared/Porthole/PortholeCLICore/AGENTS.md create mode 100644 Shared/Porthole/PortholeCLICore/README.md create mode 100644 Shared/Porthole/PortholeCLICore/Sources/AppResolution.swift create mode 100644 Shared/Porthole/PortholeCLICore/Sources/CLIValueParsing.swift create mode 100644 Shared/Porthole/PortholeCLICore/Sources/DataCommands.swift create mode 100644 Shared/Porthole/PortholeCLICore/Sources/DiscoveryCommands.swift create mode 100644 Shared/Porthole/PortholeCLICore/Sources/IntrospectionCommands.swift create mode 100644 Shared/Porthole/PortholeCLICore/Sources/MCPCommand.swift create mode 100644 Shared/Porthole/PortholeCLICore/Sources/OutputFormatting.swift create mode 100644 Shared/Porthole/PortholeCLICore/Sources/PortholeCommand.swift create mode 100644 Shared/Porthole/PortholeCLICore/Tests/AppResolutionTests.swift create mode 100644 Shared/Porthole/PortholeCLICore/Tests/CLIValueParsingTests.swift create mode 100644 Shared/Porthole/PortholeCLICore/Tests/OutputFormattingTests.swift diff --git a/Package.resolved b/Package.resolved index 0755e113..47324f7c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "290f4f4f567766c3af06cda2e9b5ef722f18cb0b6a0ccb699a3fdb0cbbf6d528", + "originHash" : "11816bb24eec2df4d25a25bc682fad16649de43138b5a16865a29a8a6697ae14", "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + }, { "identity" : "zipfoundation", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 1131d7c9..68a02c81 100644 --- a/Package.swift +++ b/Package.swift @@ -26,9 +26,11 @@ let package = Package( .library(name: "PortholeCore", targets: ["PortholeCore"]), .library(name: "PortholeKit", targets: ["PortholeKit"]), .library(name: "PortholeClientKit", targets: ["PortholeClientKit"]), + .library(name: "PortholeCLICore", targets: ["PortholeCLICore"]), ], 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"), ], targets: [ .target( @@ -160,5 +162,13 @@ let package = Package( ], path: "Shared/Porthole/PortholeClientKit/Sources", ), + .target( + name: "PortholeCLICore", + dependencies: [ + .target(name: "PortholeClientKit"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ], + path: "Shared/Porthole/PortholeCLICore/Sources", + ), ], ) diff --git a/Project.swift b/Project.swift index 92e2d8e4..3595f281 100644 --- a/Project.swift +++ b/Project.swift @@ -411,6 +411,25 @@ let project = Project( sources: ["Shared/Porthole/PortholeClientKit/Tests/**"], extraPackageProducts: ["PortholeCore", "PortholeKit"], ), + 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"), + ], + ), ], // Tuist's autogeneration doesn't emit working standalone test actions for // these unit-test bundles (only the aggregate `Stuff-Workspace` scheme @@ -456,6 +475,7 @@ let project = Project( "PortholeCoreTests", "PortholeKitTests", "PortholeClientKitTests", + "PortholeCLICoreTests", ]), testAction: .targets([ "StuffCoreTests", @@ -476,8 +496,15 @@ let project = Project( "PortholeCoreTests", "PortholeKitTests", "PortholeClientKitTests", + "PortholeCLICoreTests", ]), ), + .scheme( + name: "PortholeCLI", + shared: true, + buildAction: .buildAction(targets: ["PortholeCLI"]), + runAction: .runAction(executable: "PortholeCLI"), + ), testScheme(name: "StuffCoreTests"), testScheme(name: "LifecycleKitTests"), testScheme(name: "JournalKitTests"), @@ -496,5 +523,6 @@ let project = Project( testScheme(name: "PortholeCoreTests"), testScheme(name: "PortholeKitTests"), testScheme(name: "PortholeClientKitTests"), + testScheme(name: "PortholeCLICoreTests"), ], ) 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..0d9d27f3 --- /dev/null +++ b/Shared/Porthole/PortholeCLICore/Sources/MCPCommand.swift @@ -0,0 +1,19 @@ +import ArgumentParser +import Foundation + +/// `porthole mcp` — serve the paired app as an MCP server over stdio. +/// +/// The real implementation lands with PortholeMCP; until then this exits with a +/// clear message rather than pretending to serve. +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 { + throw CleanExit.message("`porthole mcp` is added in the next step (PortholeMCP).") + } +} 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/Sources/PortholeClient.swift b/Shared/Porthole/PortholeClientKit/Sources/PortholeClient.swift index f6f68676..07a524f1 100644 --- a/Shared/Porthole/PortholeClientKit/Sources/PortholeClient.swift +++ b/Shared/Porthole/PortholeClientKit/Sources/PortholeClient.swift @@ -37,6 +37,11 @@ public final class PortholeClient: Sendable { ) } } + /// 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, From b47b5a4ba5e23dc677c32c5e2df494a36583c5c0 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 23 Jul 2026 19:23:13 -0700 Subject: [PATCH 07/18] PortholeMCP: expose a session as an MCP stdio server Adds the official MCP Swift SDK and PortholeMCP: MCPToolBuilder plans tools from a device manifest (porthole_overview, act_/query_/tail__ with sanitized names), PortholeMCPDispatcher routes calls to a PortholeSessionProviding (PNG results -> image content, JSON otherwise, bounded tail windows, errors -> isError), and PortholeMCPServer wires both to the SDK's Server + StdioTransport. Implements the real porthole mcp subcommand. Planner and dispatcher are pure of MCP SDK types and unit-tested against a scripted fake session. The SDK is pinned to a main revision because 0.9.0's NetworkTransport fails Swift 6 strict-concurrency under the current toolchain (fixed on main). Closes plan step: porthole-mcp --- Package.resolved | 64 ++++++- Package.swift | 18 ++ Project.swift | 10 ++ .../PortholeCLICore/Sources/MCPCommand.swift | 13 +- Shared/Porthole/PortholeMCP/AGENTS.md | 33 ++++ Shared/Porthole/PortholeMCP/README.md | 40 +++++ .../PortholeMCP/Sources/MCPToolBuilder.swift | 97 +++++++++++ .../PortholeMCP/Sources/MCPValueBridge.swift | 110 ++++++++++++ .../Sources/PortholeMCPDispatcher.swift | 161 ++++++++++++++++++ .../Sources/PortholeMCPServer.swift | 67 ++++++++ .../Sources/PortholeSessionProviding.swift | 15 ++ .../Tests/MCPToolBuilderTests.swift | 51 ++++++ .../Tests/PortholeMCPDispatcherTests.swift | 107 ++++++++++++ .../Tests/PortholeMCPTestSupport.swift | 89 ++++++++++ 14 files changed, 869 insertions(+), 6 deletions(-) create mode 100644 Shared/Porthole/PortholeMCP/AGENTS.md create mode 100644 Shared/Porthole/PortholeMCP/README.md create mode 100644 Shared/Porthole/PortholeMCP/Sources/MCPToolBuilder.swift create mode 100644 Shared/Porthole/PortholeMCP/Sources/MCPValueBridge.swift create mode 100644 Shared/Porthole/PortholeMCP/Sources/PortholeMCPDispatcher.swift create mode 100644 Shared/Porthole/PortholeMCP/Sources/PortholeMCPServer.swift create mode 100644 Shared/Porthole/PortholeMCP/Sources/PortholeSessionProviding.swift create mode 100644 Shared/Porthole/PortholeMCP/Tests/MCPToolBuilderTests.swift create mode 100644 Shared/Porthole/PortholeMCP/Tests/PortholeMCPDispatcherTests.swift create mode 100644 Shared/Porthole/PortholeMCP/Tests/PortholeMCPTestSupport.swift diff --git a/Package.resolved b/Package.resolved index 47324f7c..3e560b43 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "11816bb24eec2df4d25a25bc682fad16649de43138b5a16865a29a8a6697ae14", + "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", @@ -10,6 +19,59 @@ "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 68a02c81..859133cf 100644 --- a/Package.swift +++ b/Package.swift @@ -26,11 +26,20 @@ let package = Package( .library(name: "PortholeCore", targets: ["PortholeCore"]), .library(name: "PortholeKit", targets: ["PortholeKit"]), .library(name: "PortholeClientKit", targets: ["PortholeClientKit"]), + .library(name: "PortholeMCP", targets: ["PortholeMCP"]), .library(name: "PortholeCLICore", targets: ["PortholeCLICore"]), ], 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( @@ -162,10 +171,19 @@ let package = Package( ], 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", diff --git a/Project.swift b/Project.swift index 3595f281..b4c32422 100644 --- a/Project.swift +++ b/Project.swift @@ -411,6 +411,13 @@ let project = Project( 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: "PortholeCLICoreTests", bundleIdSuffix: "porthole.cli", @@ -475,6 +482,7 @@ let project = Project( "PortholeCoreTests", "PortholeKitTests", "PortholeClientKitTests", + "PortholeMCPTests", "PortholeCLICoreTests", ]), testAction: .targets([ @@ -496,6 +504,7 @@ let project = Project( "PortholeCoreTests", "PortholeKitTests", "PortholeClientKitTests", + "PortholeMCPTests", "PortholeCLICoreTests", ]), ), @@ -523,6 +532,7 @@ let project = Project( testScheme(name: "PortholeCoreTests"), testScheme(name: "PortholeKitTests"), testScheme(name: "PortholeClientKitTests"), + testScheme(name: "PortholeMCPTests"), testScheme(name: "PortholeCLICoreTests"), ], ) diff --git a/Shared/Porthole/PortholeCLICore/Sources/MCPCommand.swift b/Shared/Porthole/PortholeCLICore/Sources/MCPCommand.swift index 0d9d27f3..1afd25e0 100644 --- a/Shared/Porthole/PortholeCLICore/Sources/MCPCommand.swift +++ b/Shared/Porthole/PortholeCLICore/Sources/MCPCommand.swift @@ -1,10 +1,10 @@ import ArgumentParser import Foundation +import PortholeMCP -/// `porthole mcp` — serve the paired app as an MCP server over stdio. -/// -/// The real implementation lands with PortholeMCP; until then this exits with a -/// clear message rather than pretending to serve. +/// `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", @@ -14,6 +14,9 @@ struct MCPCommand: AsyncParsableCommand { @OptionGroup var appOption: AppOption func run() async throws { - throw CleanExit.message("`porthole mcp` is added in the next step (PortholeMCP).") + 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/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]) +} From f6d7e318a0a8845a24f7367404bd44b20142538d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 23 Jul 2026 19:30:29 -0700 Subject: [PATCH 08/18] PortholeKitUI: Broadway-styled pairing UI PortholePairingView drives a Porthole's observable state: start/stop advertising, the large pending pairing-code display, active session count, and a paired-Macs list with swipe-to-revoke. Appearance lives in PortholeStylesheet (a Broadway BStylesheet) with portholeBroadwayRoot() seeding, mirroring PeriscopeTools; the pairing-code face widens tracking at accessibility sizes. Tests pin default tokens + environment fallback and host a probe confirming the trait-aware token resolves across the PortholeKitUI<->BroadwayUI boundary, plus a render smoke test. Closes plan step: porthole-kit-ui --- Package.swift | 10 ++ Project.swift | 10 ++ Shared/Porthole/PortholeKitUI/AGENTS.md | 35 ++++ Shared/Porthole/PortholeKitUI/README.md | 37 +++++ .../Sources/PortholePairingView.swift | 151 ++++++++++++++++++ .../Sources/Styling/PortholeStylesheet.swift | 97 +++++++++++ .../Tests/PortholeKitUIHostedTests.swift | 47 ++++++ .../Tests/PortholeStylesheetTests.swift | 20 +++ 8 files changed, 407 insertions(+) create mode 100644 Shared/Porthole/PortholeKitUI/AGENTS.md create mode 100644 Shared/Porthole/PortholeKitUI/README.md create mode 100644 Shared/Porthole/PortholeKitUI/Sources/PortholePairingView.swift create mode 100644 Shared/Porthole/PortholeKitUI/Sources/Styling/PortholeStylesheet.swift create mode 100644 Shared/Porthole/PortholeKitUI/Tests/PortholeKitUIHostedTests.swift create mode 100644 Shared/Porthole/PortholeKitUI/Tests/PortholeStylesheetTests.swift diff --git a/Package.swift b/Package.swift index 859133cf..90e2151c 100644 --- a/Package.swift +++ b/Package.swift @@ -25,6 +25,7 @@ let package = Package( .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"]), @@ -164,6 +165,15 @@ let package = Package( ], 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: [ diff --git a/Project.swift b/Project.swift index b4c32422..d4748d3c 100644 --- a/Project.swift +++ b/Project.swift @@ -404,6 +404,13 @@ let project = Project( 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", @@ -481,6 +488,7 @@ let project = Project( "BroadwayCatalogTests", "PortholeCoreTests", "PortholeKitTests", + "PortholeKitUITests", "PortholeClientKitTests", "PortholeMCPTests", "PortholeCLICoreTests", @@ -503,6 +511,7 @@ let project = Project( "BroadwayCatalogTests", "PortholeCoreTests", "PortholeKitTests", + "PortholeKitUITests", "PortholeClientKitTests", "PortholeMCPTests", "PortholeCLICoreTests", @@ -531,6 +540,7 @@ let project = Project( testScheme(name: "BroadwayCatalogTests"), testScheme(name: "PortholeCoreTests"), testScheme(name: "PortholeKitTests"), + testScheme(name: "PortholeKitUITests"), testScheme(name: "PortholeClientKitTests"), testScheme(name: "PortholeMCPTests"), testScheme(name: "PortholeCLICoreTests"), 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) + } +} From 74a02cbd8df56980f12d1fd251a99edbc69b5a9f Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 23 Jul 2026 19:37:37 -0700 Subject: [PATCH 09/18] Porthole Mac app: Catalyst client + macOS CI build job A SwiftUI Mac Catalyst app (product Porthole) over PortholeClientKit: sidebar of paired/discovered apps with pairing (code-entry sheet), a connector browser, a paged data-source table with a live tail toggle, and a schema-generated action form. Unsandboxed so it shares the login keychain + metadata with the CLI and MCP. Logic lives in @Observable models (AppModel/SourceTableModel/ActionFormModel); views render and route. Adds a macos CI job building PortholeCLI and the Catalyst PortholeApp, and includes PortholeApp in the Stuff-iOS-Tests build action so the iOS variant is built too. Closes plan step: porthole-mac-app --- .github/workflows/ci.yml | 15 +++ Project.swift | 39 ++++++ Shared/Porthole/PortholeApp/AGENTS.md | 34 +++++ Shared/Porthole/PortholeApp/README.md | 34 +++++ .../Sources/Models/ActionFormModel.swift | 86 ++++++++++++ .../PortholeApp/Sources/Models/AppModel.swift | 121 +++++++++++++++++ .../Sources/Models/SourceTableModel.swift | 86 ++++++++++++ .../PortholeApp/Sources/PortholeApp.swift | 127 ++++++++++++++++++ .../PortholeApp/Sources/Rendering.swift | 59 ++++++++ .../Styling/PortholeAppStylesheet.swift | 34 +++++ .../Sources/Views/ActionFormView.swift | 74 ++++++++++ .../Sources/Views/ConnectorBrowserView.swift | 61 +++++++++ .../Sources/Views/SourceTableView.swift | 65 +++++++++ 13 files changed, 835 insertions(+) create mode 100644 Shared/Porthole/PortholeApp/AGENTS.md create mode 100644 Shared/Porthole/PortholeApp/README.md create mode 100644 Shared/Porthole/PortholeApp/Sources/Models/ActionFormModel.swift create mode 100644 Shared/Porthole/PortholeApp/Sources/Models/AppModel.swift create mode 100644 Shared/Porthole/PortholeApp/Sources/Models/SourceTableModel.swift create mode 100644 Shared/Porthole/PortholeApp/Sources/PortholeApp.swift create mode 100644 Shared/Porthole/PortholeApp/Sources/Rendering.swift create mode 100644 Shared/Porthole/PortholeApp/Sources/Styling/PortholeAppStylesheet.swift create mode 100644 Shared/Porthole/PortholeApp/Sources/Views/ActionFormView.swift create mode 100644 Shared/Porthole/PortholeApp/Sources/Views/ConnectorBrowserView.swift create mode 100644 Shared/Porthole/PortholeApp/Sources/Views/SourceTableView.swift 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/Project.swift b/Project.swift index d4748d3c..fcce1a8a 100644 --- a/Project.swift +++ b/Project.swift @@ -444,6 +444,38 @@ let project = Project( .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 @@ -486,6 +518,7 @@ let project = Project( "BroadwayCoreTests", "BroadwayUITests", "BroadwayCatalogTests", + "PortholeApp", "PortholeCoreTests", "PortholeKitTests", "PortholeKitUITests", @@ -523,6 +556,12 @@ let project = Project( 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"), 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() } + } +} From 3dd2ceedcc0f174d6275e2a11c4bce3e666e79b5 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 23 Jul 2026 19:43:30 -0700 Subject: [PATCH 10/18] UI connector: view/accessibility tree, screenshot, open-url Adds the built-in ui connector to PortholeKit (iOS only, auto-registered): windows / view-tree / accessibility-tree data sources and screenshot (PNG) + open-url actions, all captured on the main actor into Sendable values. Hosted tests in StuffTestHost drive them over a loopback session: a tagged view appears in the tree, the screenshot decodes as a PNG, and an invalid URL is rejected. Closes plan step: connector-view-hierarchy --- .../Connectors/ViewTreeConnector.swift | 237 ++++++++++++++++++ .../PortholeKit/Sources/Porthole.swift | 3 + .../Tests/ViewTreeConnectorTests.swift | 142 +++++++++++ 3 files changed, 382 insertions(+) create mode 100644 Shared/Porthole/PortholeKit/Sources/Connectors/ViewTreeConnector.swift create mode 100644 Shared/Porthole/PortholeKit/Tests/ViewTreeConnectorTests.swift 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/Porthole.swift b/Shared/Porthole/PortholeKit/Sources/Porthole.swift index 98a3fecd..dac1758d 100644 --- a/Shared/Porthole/PortholeKit/Sources/Porthole.swift +++ b/Shared/Porthole/PortholeKit/Sources/Porthole.swift @@ -36,6 +36,9 @@ public final class Porthole { self.configuration = configuration self.credentials = credentials register(AppInfoConnector(appName: configuration.appName, bundleID: configuration.bundleID)) + #if canImport(UIKit) + register(ViewTreeConnector()) + #endif } /// Registers a connector. A duplicate id is a programmer error (asserts in 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 From 05271d09f5dd14852b118906269816bed0808864 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 23 Jul 2026 19:47:54 -0700 Subject: [PATCH 11/18] FileBrowserConnector + precise handler errors Adds the built-in files connector to PortholeKit (auto-registered with the configured App Group ids): roots + entries data sources and a read-file action (capped by maxBytes), read-only, with a mandatory path-traversal guard that resolves symlinks and rejects anything escaping the root. Where passes its App Group so the shared container is browsable. Also lets connector handlers throw precise PortholeErrors (e.g. the traversal guard's invalidParameters) instead of collapsing to handlerFailed. Tests cover roots/entries/read-file + truncation and the traversal, symlink-escape, and unknown-root rejections. Closes plan step: connector-files --- .../Connectors/FileBrowserConnector.swift | 219 ++++++++++++++++++ .../PortholeKit/Sources/Porthole.swift | 1 + .../Sources/PortholeSessionRouter.swift | 4 + .../Tests/FileBrowserConnectorTests.swift | 115 +++++++++ 4 files changed, 339 insertions(+) create mode 100644 Shared/Porthole/PortholeKit/Sources/Connectors/FileBrowserConnector.swift create mode 100644 Shared/Porthole/PortholeKit/Tests/FileBrowserConnectorTests.swift 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/Porthole.swift b/Shared/Porthole/PortholeKit/Sources/Porthole.swift index dac1758d..1865c90f 100644 --- a/Shared/Porthole/PortholeKit/Sources/Porthole.swift +++ b/Shared/Porthole/PortholeKit/Sources/Porthole.swift @@ -36,6 +36,7 @@ public final class Porthole { self.configuration = configuration self.credentials = credentials register(AppInfoConnector(appName: configuration.appName, bundleID: configuration.bundleID)) + register(FileBrowserConnector(appGroupIdentifiers: configuration.appGroupIdentifiers)) #if canImport(UIKit) register(ViewTreeConnector()) #endif diff --git a/Shared/Porthole/PortholeKit/Sources/PortholeSessionRouter.swift b/Shared/Porthole/PortholeKit/Sources/PortholeSessionRouter.swift index b7bb3140..da2e45f3 100644 --- a/Shared/Porthole/PortholeKit/Sources/PortholeSessionRouter.swift +++ b/Shared/Porthole/PortholeKit/Sources/PortholeSessionRouter.swift @@ -103,6 +103,8 @@ actor PortholeSessionRouter { } do { return try await .actionResult(action.handler(parameters)) + } catch let error as PortholeError { + return .failure(error) } catch { return .failure(.handlerFailed(String(describing: error))) } @@ -126,6 +128,8 @@ actor PortholeSessionRouter { } do { return try await .queryResult(source.fetch(query)) + } catch let error as PortholeError { + return .failure(error) } catch { return .failure(.handlerFailed(String(describing: error))) } 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"])) + } + } +} From da9cf2bf0435e0832fa558858f760c683e9363f2 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 23 Jul 2026 19:52:10 -0700 Subject: [PATCH 12/18] Notifications + Permissions connectors Adds two built-in PortholeKit connectors, both behind injectable protocol seams (NotificationCenterProviding / PermissionsReading) so tests script them without touching the real frameworks. notifications: settings/pending/ delivered sources + a destructive remove-pending action over UNUserNotificationCenter. permissions: one row per notifications/location/ camera/microphone/photos authorization status, each framework guarded by canImport, and every read a status query that never prompts. Closes plan step: connector-system --- .../Connectors/NotificationsConnector.swift | 287 ++++++++++++++++++ .../Connectors/PermissionsConnector.swift | 161 ++++++++++ .../PortholeKit/Sources/Porthole.swift | 4 + .../Tests/NotificationsConnectorTests.swift | 96 ++++++ .../Tests/PermissionsConnectorTests.swift | 52 ++++ 5 files changed, 600 insertions(+) create mode 100644 Shared/Porthole/PortholeKit/Sources/Connectors/NotificationsConnector.swift create mode 100644 Shared/Porthole/PortholeKit/Sources/Connectors/PermissionsConnector.swift create mode 100644 Shared/Porthole/PortholeKit/Tests/NotificationsConnectorTests.swift create mode 100644 Shared/Porthole/PortholeKit/Tests/PermissionsConnectorTests.swift 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/Porthole.swift b/Shared/Porthole/PortholeKit/Sources/Porthole.swift index 1865c90f..b84856f1 100644 --- a/Shared/Porthole/PortholeKit/Sources/Porthole.swift +++ b/Shared/Porthole/PortholeKit/Sources/Porthole.swift @@ -37,6 +37,10 @@ public final class Porthole { 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 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/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", + ])) + } +} From 72dd6c43166c81c03cb5aa3a288fc5a557c173a1 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 23 Jul 2026 19:57:10 -0700 Subject: [PATCH 13/18] PortholeLifecycle: launch-state connector over LifecycleKit New module with a lifecycle connector exposing a launch-state data source from an injected LifecycleRunner: phase (launching/running/failed/ready), reason, running step, and any launch failure. Reads the runner on the main actor and surfaces exactly what it publishes (no fabricated step list). No actions in v1. Closes plan step: connector-lifecycle --- Package.swift | 9 +++ Project.swift | 10 +++ Shared/Porthole/PortholeLifecycle/AGENTS.md | 25 +++++++ Shared/Porthole/PortholeLifecycle/README.md | 32 +++++++++ .../Sources/LifecycleConnector.swift | 70 +++++++++++++++++++ .../Tests/LifecycleConnectorTests.swift | 44 ++++++++++++ 6 files changed, 190 insertions(+) create mode 100644 Shared/Porthole/PortholeLifecycle/AGENTS.md create mode 100644 Shared/Porthole/PortholeLifecycle/README.md create mode 100644 Shared/Porthole/PortholeLifecycle/Sources/LifecycleConnector.swift create mode 100644 Shared/Porthole/PortholeLifecycle/Tests/LifecycleConnectorTests.swift diff --git a/Package.swift b/Package.swift index 90e2151c..073c96c4 100644 --- a/Package.swift +++ b/Package.swift @@ -29,6 +29,7 @@ let package = Package( .library(name: "PortholeClientKit", targets: ["PortholeClientKit"]), .library(name: "PortholeMCP", targets: ["PortholeMCP"]), .library(name: "PortholeCLICore", targets: ["PortholeCLICore"]), + .library(name: "PortholeLifecycle", targets: ["PortholeLifecycle"]), ], dependencies: [ .package(url: "https://github.com/weichsel/ZIPFoundation", from: "0.9.20"), @@ -198,5 +199,13 @@ let package = Package( ], path: "Shared/Porthole/PortholeCLICore/Sources", ), + .target( + name: "PortholeLifecycle", + dependencies: [ + .target(name: "PortholeKit"), + .target(name: "LifecycleKit"), + ], + path: "Shared/Porthole/PortholeLifecycle/Sources", + ), ], ) diff --git a/Project.swift b/Project.swift index fcce1a8a..9bd213c2 100644 --- a/Project.swift +++ b/Project.swift @@ -425,6 +425,13 @@ let project = Project( 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: "PortholeCLICoreTests", bundleIdSuffix: "porthole.cli", @@ -524,6 +531,7 @@ let project = Project( "PortholeKitUITests", "PortholeClientKitTests", "PortholeMCPTests", + "PortholeLifecycleTests", "PortholeCLICoreTests", ]), testAction: .targets([ @@ -547,6 +555,7 @@ let project = Project( "PortholeKitUITests", "PortholeClientKitTests", "PortholeMCPTests", + "PortholeLifecycleTests", "PortholeCLICoreTests", ]), ), @@ -582,6 +591,7 @@ let project = Project( testScheme(name: "PortholeKitUITests"), testScheme(name: "PortholeClientKitTests"), testScheme(name: "PortholeMCPTests"), + testScheme(name: "PortholeLifecycleTests"), testScheme(name: "PortholeCLICoreTests"), ], ) 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 } From e4658d11b5d43adbd02aad510c8e497d82529881 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 23 Jul 2026 20:07:30 -0700 Subject: [PATCH 14/18] SwiftDataInspector: promote the reader to a public headless API Makes SwiftDataInspectorReader (and its snapshots InspectorEntity/ InspectorRow/InspectorRowSet/InspectorRelatedRows) public + Sendable so a non-UI consumer (the forthcoming Porthole SwiftData connector) can browse a store without SwiftUI. The reflection and views stay internal; the SwiftUI surface is unchanged. Adds SwiftDataInspectorReaderTests over the public API and documents the headless surface. Closes plan step: swiftdata-reader-promotion --- Shared/SwiftDataInspector/AGENTS.md | 13 ++- Shared/SwiftDataInspector/README.md | 11 ++- .../Sources/InspectorEntity.swift | 16 ++-- .../Sources/InspectorRow.swift | 28 +++--- .../Sources/SwiftDataInspectorReader.swift | 13 +-- .../Tests/SwiftDataInspectorReaderTests.swift | 89 +++++++++++++++++++ 6 files changed, 138 insertions(+), 32 deletions(-) create mode 100644 Shared/SwiftDataInspector/Tests/SwiftDataInspectorReaderTests.swift 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) + } +} From 54e614ca0e4ac86cdb82effff4d1e148869e731e Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 23 Jul 2026 20:11:26 -0700 Subject: [PATCH 15/18] PortholeSwiftData: read-only SwiftData store connector New module with SwiftDataConnector over SwiftDataInspector's headless reader: an entities data source (name/count/columns) and a rows data source (filter entity, cursor paging over disjoint rowLimit windows). Register one per ModelContainer with an explicit id. Read-only by construction. Closes plan step: connector-swiftdata --- Package.swift | 9 ++ Project.swift | 10 ++ Shared/Porthole/PortholeSwiftData/AGENTS.md | 30 +++++ Shared/Porthole/PortholeSwiftData/README.md | 38 +++++++ .../Sources/SwiftDataConnector.swift | 104 ++++++++++++++++++ .../Tests/SwiftDataConnectorTests.swift | 94 ++++++++++++++++ 6 files changed, 285 insertions(+) create mode 100644 Shared/Porthole/PortholeSwiftData/AGENTS.md create mode 100644 Shared/Porthole/PortholeSwiftData/README.md create mode 100644 Shared/Porthole/PortholeSwiftData/Sources/SwiftDataConnector.swift create mode 100644 Shared/Porthole/PortholeSwiftData/Tests/SwiftDataConnectorTests.swift diff --git a/Package.swift b/Package.swift index 073c96c4..5235801a 100644 --- a/Package.swift +++ b/Package.swift @@ -30,6 +30,7 @@ let package = Package( .library(name: "PortholeMCP", targets: ["PortholeMCP"]), .library(name: "PortholeCLICore", targets: ["PortholeCLICore"]), .library(name: "PortholeLifecycle", targets: ["PortholeLifecycle"]), + .library(name: "PortholeSwiftData", targets: ["PortholeSwiftData"]), ], dependencies: [ .package(url: "https://github.com/weichsel/ZIPFoundation", from: "0.9.20"), @@ -207,5 +208,13 @@ let package = Package( ], path: "Shared/Porthole/PortholeLifecycle/Sources", ), + .target( + name: "PortholeSwiftData", + dependencies: [ + .target(name: "PortholeKit"), + .target(name: "SwiftDataInspector"), + ], + path: "Shared/Porthole/PortholeSwiftData/Sources", + ), ], ) diff --git a/Project.swift b/Project.swift index 9bd213c2..2d1c6a64 100644 --- a/Project.swift +++ b/Project.swift @@ -432,6 +432,13 @@ let project = Project( 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: "PortholeCLICoreTests", bundleIdSuffix: "porthole.cli", @@ -532,6 +539,7 @@ let project = Project( "PortholeClientKitTests", "PortholeMCPTests", "PortholeLifecycleTests", + "PortholeSwiftDataTests", "PortholeCLICoreTests", ]), testAction: .targets([ @@ -556,6 +564,7 @@ let project = Project( "PortholeClientKitTests", "PortholeMCPTests", "PortholeLifecycleTests", + "PortholeSwiftDataTests", "PortholeCLICoreTests", ]), ), @@ -592,6 +601,7 @@ let project = Project( testScheme(name: "PortholeClientKitTests"), testScheme(name: "PortholeMCPTests"), testScheme(name: "PortholeLifecycleTests"), + testScheme(name: "PortholeSwiftDataTests"), testScheme(name: "PortholeCLICoreTests"), ], ) 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"])) + } + } +} From 7a5e75424ae39611daae8145a38c7f04b6b70fe8 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 23 Jul 2026 20:20:55 -0700 Subject: [PATCH 16/18] PortholePeriscope: logs connector (events, live tail, scopes) New module with a periscope connector over PeriscopeStore + Periscope: an events data source (LogQuery-backed, newest-first, limit+offset cursor, with minimumLevel/eventName/messageContains/date/afterSequence filters and decoded JSON payloads), a subscribable live-events tail off liveRecords(), and a scopes source. Read-only. Closes plan step: connector-periscope --- Package.swift | 9 + Project.swift | 10 ++ Shared/Porthole/PortholePeriscope/AGENTS.md | 28 +++ Shared/Porthole/PortholePeriscope/README.md | 32 ++++ .../Sources/PeriscopeConnector.swift | 169 ++++++++++++++++++ .../Tests/PeriscopeConnectorTests.swift | 152 ++++++++++++++++ 6 files changed, 400 insertions(+) create mode 100644 Shared/Porthole/PortholePeriscope/AGENTS.md create mode 100644 Shared/Porthole/PortholePeriscope/README.md create mode 100644 Shared/Porthole/PortholePeriscope/Sources/PeriscopeConnector.swift create mode 100644 Shared/Porthole/PortholePeriscope/Tests/PeriscopeConnectorTests.swift diff --git a/Package.swift b/Package.swift index 5235801a..f3ded96b 100644 --- a/Package.swift +++ b/Package.swift @@ -31,6 +31,7 @@ let package = Package( .library(name: "PortholeCLICore", targets: ["PortholeCLICore"]), .library(name: "PortholeLifecycle", targets: ["PortholeLifecycle"]), .library(name: "PortholeSwiftData", targets: ["PortholeSwiftData"]), + .library(name: "PortholePeriscope", targets: ["PortholePeriscope"]), ], dependencies: [ .package(url: "https://github.com/weichsel/ZIPFoundation", from: "0.9.20"), @@ -216,5 +217,13 @@ let package = Package( ], path: "Shared/Porthole/PortholeSwiftData/Sources", ), + .target( + name: "PortholePeriscope", + dependencies: [ + .target(name: "PortholeKit"), + .target(name: "PeriscopeCore"), + ], + path: "Shared/Porthole/PortholePeriscope/Sources", + ), ], ) diff --git a/Project.swift b/Project.swift index 2d1c6a64..3c0c6f75 100644 --- a/Project.swift +++ b/Project.swift @@ -439,6 +439,13 @@ let project = Project( 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: "PortholeCLICoreTests", bundleIdSuffix: "porthole.cli", @@ -540,6 +547,7 @@ let project = Project( "PortholeMCPTests", "PortholeLifecycleTests", "PortholeSwiftDataTests", + "PortholePeriscopeTests", "PortholeCLICoreTests", ]), testAction: .targets([ @@ -565,6 +573,7 @@ let project = Project( "PortholeMCPTests", "PortholeLifecycleTests", "PortholeSwiftDataTests", + "PortholePeriscopeTests", "PortholeCLICoreTests", ]), ), @@ -602,6 +611,7 @@ let project = Project( testScheme(name: "PortholeMCPTests"), testScheme(name: "PortholeLifecycleTests"), testScheme(name: "PortholeSwiftDataTests"), + testScheme(name: "PortholePeriscopeTests"), testScheme(name: "PortholeCLICoreTests"), ], ) 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 + } +} From e96076c1d71d8d16338a0422b4827efb2cacd9a5 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 23 Jul 2026 20:37:53 -0700 Subject: [PATCH 17/18] WherePorthole: WhereConnector + wire Porthole into the Where dev menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Where/WherePorthole module: WhereConnector (id where) over WhereServices — year-report, manual-days, evidence (metadata), preferences, and data-issues sources, plus scan-data-issues, capture-location-now, and attribute-coordinate actions. Preferences cross as a Sendable snapshot. Read-mostly, no destructive actions. Wires the whole Porthole suite into the app through WhereUI (the dynamic framework) — PortholeKit/KitUI/Periscope/SwiftData/Lifecycle/WherePorthole are added to WhereUI's deps only, so no duplicate copy can split Broadway's type-keyed metadata (the app target and WhereUITests add nothing). A DEBUG-only PortholeDeveloperView builds the Porthole, registers the Where/Periscope/ SwiftData connectors (built-ins self-register), and shows PortholePairingView from a new developer-menu row. Full Stuff-iOS-Tests scheme green, including the double-link regression guards. Also hardens the end-to-end test's #require(key(for:)) against a Swift 6.2 macro type-check fragility. Closes plan step: connector-where --- Package.swift | 20 ++ Project.swift | 10 + .../Tests/PortholeEndToEndTests.swift | 9 +- Where/WherePorthole/AGENTS.md | 35 +++ Where/WherePorthole/README.md | 42 +++ .../Sources/WhereConnector.swift | 297 ++++++++++++++++++ .../Tests/WhereConnectorTests.swift | 110 +++++++ .../Developer/DeveloperToolsView.swift | 9 + .../Developer/PortholeDeveloperView.swift | 63 ++++ Where/WhereUI/Sources/Shared/Strings.swift | 4 + 10 files changed, 596 insertions(+), 3 deletions(-) create mode 100644 Where/WherePorthole/AGENTS.md create mode 100644 Where/WherePorthole/README.md create mode 100644 Where/WherePorthole/Sources/WhereConnector.swift create mode 100644 Where/WherePorthole/Tests/WhereConnectorTests.swift create mode 100644 Where/WhereUI/Sources/Developer/PortholeDeveloperView.swift diff --git a/Package.swift b/Package.swift index f3ded96b..7d6524cf 100644 --- a/Package.swift +++ b/Package.swift @@ -32,6 +32,7 @@ let package = Package( .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"), @@ -127,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: [ @@ -225,5 +237,13 @@ let package = Package( ], 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 3c0c6f75..54f88047 100644 --- a/Project.swift +++ b/Project.swift @@ -446,6 +446,13 @@ let project = Project( 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", @@ -548,6 +555,7 @@ let project = Project( "PortholeLifecycleTests", "PortholeSwiftDataTests", "PortholePeriscopeTests", + "WherePortholeTests", "PortholeCLICoreTests", ]), testAction: .targets([ @@ -574,6 +582,7 @@ let project = Project( "PortholeLifecycleTests", "PortholeSwiftDataTests", "PortholePeriscopeTests", + "WherePortholeTests", "PortholeCLICoreTests", ]), ), @@ -612,6 +621,7 @@ let project = Project( testScheme(name: "PortholeLifecycleTests"), testScheme(name: "PortholeSwiftDataTests"), testScheme(name: "PortholePeriscopeTests"), + testScheme(name: "WherePortholeTests"), testScheme(name: "PortholeCLICoreTests"), ], ) diff --git a/Shared/Porthole/PortholeClientKit/Tests/PortholeEndToEndTests.swift b/Shared/Porthole/PortholeClientKit/Tests/PortholeEndToEndTests.swift index 361f60d4..beb66e97 100644 --- a/Shared/Porthole/PortholeClientKit/Tests/PortholeEndToEndTests.swift +++ b/Shared/Porthole/PortholeClientKit/Tests/PortholeEndToEndTests.swift @@ -78,7 +78,8 @@ struct PortholeEndToEndTests { let (device, client) = LoopbackTransport.makePair() let deviceTask = harness.serve(transport: device) - let psk = try #require(clientStore.key(for: paired.pairingID)) + 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, @@ -111,7 +112,8 @@ struct PortholeEndToEndTests { let (device, client) = LoopbackTransport.makePair() let deviceTask = harness.serve(transport: device) - let psk = try #require(clientStore.key(for: paired.pairingID)) + 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, @@ -171,7 +173,8 @@ struct PortholeEndToEndTests { let (device, client) = LoopbackTransport.makePair() let deviceTask = harness.serve(transport: device) - let psk = try #require(clientStore.key(for: paired.pairingID)) + 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( 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") From 57c19d7d1cc265ec48db4a278e959ad99280f713 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 23 Jul 2026 20:39:43 -0700 Subject: [PATCH 18/18] Porthole: roadmap TODOs + suite README + final doc pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Shared/Porthole/TODOs.md (daemon/HTTP MCP, SPAKE2, blob transfer, write ops, App Intents bridge, AI connector-builder, iPad) and a top-level Shared/Porthole/README.md tying the modules together. Notes the macOS CI job in the root AGENTS.md. Pure docs — no behavior change. Closes plan step: docs-roadmap --- AGENTS.md | 10 +++++-- Shared/Porthole/README.md | 61 +++++++++++++++++++++++++++++++++++++++ Shared/Porthole/TODOs.md | 50 ++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 Shared/Porthole/README.md create mode 100644 Shared/Porthole/TODOs.md diff --git a/AGENTS.md b/AGENTS.md index b62e41c9..c18ee6cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,9 +81,13 @@ by `./sync-agents`. 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/`) adds macOS-capable libraries plus a -`.commandLineTool` (`porthole`) and a Mac Catalyst app (`PortholeApp`, -productName `Porthole`) in [`Project.swift`](Project.swift). +(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/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).