From 9ffde244749459eb05783e392d37f0fdd78b3e9a Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 17 May 2026 19:05:49 -0700 Subject: [PATCH] feat(ios): Swift templates for StateServer + DebugOverlay v2 + structural Release guard StateServer is loopback-only (::1 + 127.0.0.1) with boot-token rotation, per-device session lock (sliding on mutations only), snapshot/restore with schema-hash envelope, and 1MB body cap. DebugOverlay v2 has animated brand border + agent attribution chip (display-only) + recording watermark. Package.swift enforces structural Release-build exclusion via .when(configuration: .debug). Includes Tailscale ACL example doc. --- ios-qa/docs/tailscale-acl-example.md | 157 ++++++ .../DebugBridgeManager.swift.template | 46 ++ .../DebugBridgeWiring.swift.template | 43 ++ ios-qa/templates/DebugOverlay.swift.template | 137 +++++ ios-qa/templates/Package.swift.template | 38 ++ ios-qa/templates/StateAccessor.swift.template | 38 ++ ios-qa/templates/StateServer.swift.template | 521 ++++++++++++++++++ 7 files changed, 980 insertions(+) create mode 100644 ios-qa/docs/tailscale-acl-example.md create mode 100644 ios-qa/templates/DebugBridgeManager.swift.template create mode 100644 ios-qa/templates/DebugBridgeWiring.swift.template create mode 100644 ios-qa/templates/DebugOverlay.swift.template create mode 100644 ios-qa/templates/Package.swift.template create mode 100644 ios-qa/templates/StateAccessor.swift.template create mode 100644 ios-qa/templates/StateServer.swift.template diff --git a/ios-qa/docs/tailscale-acl-example.md b/ios-qa/docs/tailscale-acl-example.md new file mode 100644 index 000000000..f847f9c20 --- /dev/null +++ b/ios-qa/docs/tailscale-acl-example.md @@ -0,0 +1,157 @@ +# Tailscale ACL example for the iOS QA daemon + +The Mac-side daemon binds the Tailscale interface only when you pass +`--tailnet`. By default the daemon is local-USB-only. This doc walks through +the steps to expose your device farm to remote agents safely. + +## Threat model recap + +- **iOS app StateServer:** loopback-only always. Reachable from the Mac via + the CoreDevice IPv6 tunnel. Never directly bound to tailnet. +- **Mac daemon:** owns the tailnet interface. Binds two listeners — loopback + (full surface, never forwarded) and tailnet (locked allowlist with + capability tiers). +- **Auth:** Tailscale identity validation via the local `tailscaled` socket + (`/var/run/tailscale.sock` LocalAPI WhoIs). Allowlist file at + `~/.gstack/ios-qa-allowlist.json` is the single source of truth for who can + do what. + +## Step 1: Install and run Tailscale + +```bash +brew install --cask tailscale +# Login + start tailscaled, then verify: +tailscale status +``` + +Confirm the daemon can read the LocalAPI socket: + +```bash +test -S /var/run/tailscale.sock && echo "socket present" || echo "MISSING" +``` + +If missing, the daemon will refuse to open the tailnet listener (fail-closed). + +## Step 2: Set up the daemon's ACL + +The daemon needs to know which Tailscale identities are allowed to control +which devices at which capability tier. The allowlist file is JSON: + +```json +{ + "version": 1, + "entries": [ + { + "identity": "you@example.com", + "capabilities": ["restore"], + "expires_at": null, + "note": "Owner — full access" + }, + { + "identity": "ci@example.com", + "capabilities": ["mutate"], + "expires_at": "2026-12-31T00:00:00Z", + "note": "CI runner — can write state but not full restore" + }, + { + "identity": "tag:claude-readonly", + "capabilities": ["observe"], + "expires_at": null, + "note": "Agents that should only read" + } + ] +} +``` + +Identities are canonicalized via WhoIs: + +- **User OAuth:** `user@example.com` (no `acct:`, no domain rewriting). +- **Tagged nodes:** `tag:` (lowercased). +- **Node keys:** `node:` (rare; use tags instead). + +Capability tiers are ordered: `observe` < `interact` < `mutate` < `restore`. +Granting `restore` implies all lower tiers. + +## Step 3: Mint a session token for a remote agent + +You can let agents self-mint (if their identity is allowlisted) or you can +mint server-side for them: + +```bash +# Server-side mint (owner-only, runs locally on the Mac with the device): +gstack-ios-qa-mint --remote ci@example.com --capability mutate --ttl 1h + +# Self-service mint (agent over tailnet): +curl -X POST http://:9999/auth/mint \ + -H "Content-Type: application/json" \ + -d '{"capability": "interact"}' +# → {"session_token": "...", "expires_at": "...", "capability": "interact"} +``` + +## Step 4: Tighten the Tailscale ACL (defense in depth) + +The daemon's allowlist is the primary access control. Belt-and-suspenders: +restrict the tailnet ACL to limit who can even *reach* the daemon port. + +```jsonc +// In your tailscale admin console: +{ + "acls": [ + // Allow CI runner to reach the device farm Mac on port 9999 only. + { + "action": "accept", + "src": ["ci@example.com"], + "dst": ["device-farm-mac:9999"] + }, + // Tagged Claude agents — observe tier only (enforced by daemon, not ACL). + { + "action": "accept", + "src": ["tag:claude-readonly"], + "dst": ["device-farm-mac:9999"] + }, + // Default deny. + { + "action": "drop", + "src": ["*"], + "dst": ["device-farm-mac:9999"] + } + ] +} +``` + +## Step 5: Audit trail + +Every authenticated mutating request through the tailnet listener writes a +row to `~/.gstack/security/ios-qa-audit.jsonl`: + +```jsonl +{"ts":"2026-05-18T14:23:00Z","identity":"ci@example.com","device_udid":"00008101-XXXX","endpoint":"/tap","session_id":"abc...","capability":"interact","request_id":"req_001","status":200} +``` + +Rejections (no token, expired token, capability-insufficient, identity not +allowlisted, rate limit hit) write to `~/.gstack/security/attempts.jsonl`. + +## Rate limits + +- `/auth/mint`: 10 mints / 60s per identity. 11th returns 429. +- Per-tailnet-request body: 1MB hard cap (413 above). +- Screenshot response: 10MB hard cap (500 above with sanitized error). + +## Token lifetime + +- Daemon-minted session tokens: default 1h TTL, max 24h via + `--tailnet-session-ttl`. +- Refreshable via `POST /session/heartbeat` (extends by `ttl_seconds`, capped + at the original max). +- Boot token (between iOS app launch and daemon rotation): ~5s lifetime — + daemon rotates immediately on first scrape. + +## Failure modes + +| Symptom | Cause | Action | +|---|---|---| +| Daemon refuses to open tailnet listener | `/var/run/tailscale.sock` missing or permission-denied | Install Tailscale; verify `tailscale status` works as the user running daemon | +| `403 identity_not_allowed` | identity missing from allowlist | Owner mint: `gstack-ios-qa-mint --remote ` | +| `403 capability_insufficient` | token tier below endpoint requirement | Owner mint with higher `--capability` tier | +| `429 rate_limited` | >10 mints/min from one identity | Wait 60s; investigate why the agent is re-minting so often | +| `409 schema_mismatch` on `/state/restore` | snapshot from older app build | Discard the snapshot; re-capture from current app build | diff --git a/ios-qa/templates/DebugBridgeManager.swift.template b/ios-qa/templates/DebugBridgeManager.swift.template new file mode 100644 index 000000000..c1f67e371 --- /dev/null +++ b/ios-qa/templates/DebugBridgeManager.swift.template @@ -0,0 +1,46 @@ +// AUTO-GENERATED from gstack/ios-qa/templates/DebugBridgeManager.swift.template +// +// Bootstraps StateServer + DebugOverlay on app launch. Reads the codegen +// output, registers accessors, and starts the listeners. Everything is +// #if DEBUG-gated; this file does not exist in Release builds. + +#if DEBUG + +import Foundation +import SwiftUI + +@MainActor +public final class DebugBridgeManager { + public static let shared = DebugBridgeManager() + + public func start(appState: AppState, recording: Bool = false) { + // 1. Register the canonical AppState struct + accessor wiring. + // AppStateAccessor.register(_:) is generated by gen-accessors-tool. + AppStateAccessor.register(appState) + + // 2. Boot the StateServer. + StateServer.shared.start() + + // 3. Install the DebugOverlay window. + #if canImport(UIKit) + DebugOverlayWindow.shared.install(recording: recording) + #endif + } +} + +// Placeholder. gen-accessors-tool emits the real `AppStateAccessor` enum next +// to the app's canonical state struct. Apps that haven't run codegen get a +// stub that registers no accessors (snapshot is empty, restore returns +// missing-key for every key). +@MainActor +public enum AppStateAccessor { + public static var register: (Any) -> Void = { _ in } +} + +// Apps declare their canonical state struct; codegen reads it and emits +// AppStateAccessor.register. The app's struct must be `@Observable` and +// must hold all snapshot-eligible state in `@Snapshotable`-marked fields. +@MainActor +public protocol AppState: AnyObject {} + +#endif // DEBUG diff --git a/ios-qa/templates/DebugBridgeWiring.swift.template b/ios-qa/templates/DebugBridgeWiring.swift.template new file mode 100644 index 000000000..009a70861 --- /dev/null +++ b/ios-qa/templates/DebugBridgeWiring.swift.template @@ -0,0 +1,43 @@ +// AUTO-GENERATED from gstack/ios-qa/templates/DebugBridgeWiring.swift.template +// +// Wiring snippet for the app's @main entry. Users paste this into their +// App.swift inside the `init()` of the SwiftUI App struct, gated by +// #if DEBUG. The wiring is intentionally tiny; everything heavy lives in +// the DebugBridge target. + +#if DEBUG +import DebugBridge + +@MainActor +func startGstackDebugBridge(appState: AppState) { + // Read --recording flag from launch arguments + let recording = ProcessInfo.processInfo.arguments.contains("--gstack-recording") + + // Install accessibility + screenshot + mutation bridges before starting + // the server so the first authenticated request can use them. + ElementsBridge.resolver = { AccessibilityScanner.snapshot() } + ScreenshotBridge.resolver = { SnapshotCapture.capturePNG() } + MutationBridge.resolver = { op, payload in + MutationDispatcher.shared.run(op: op, payload: payload) + } + + DebugBridgeManager.shared.start(appState: appState, recording: recording) +} +#endif + +// Example usage in the app's @main entry (paste this into App.swift): +// +// @main +// struct MyApp: App { +// @State private var appState = MyAppState() +// +// init() { +// #if DEBUG +// startGstackDebugBridge(appState: appState) +// #endif +// } +// +// var body: some Scene { +// WindowGroup { ContentView() } +// } +// } diff --git a/ios-qa/templates/DebugOverlay.swift.template b/ios-qa/templates/DebugOverlay.swift.template new file mode 100644 index 000000000..1d888db0c --- /dev/null +++ b/ios-qa/templates/DebugOverlay.swift.template @@ -0,0 +1,137 @@ +// AUTO-GENERATED from gstack/ios-qa/templates/DebugOverlay.swift.template +// +// DebugOverlay — on-device visual presence. Animated brand-colored border + +// agent attribution chip + (optional) recording watermark. Renders above +// sheets, alerts, and modals via a dedicated UIWindow with high windowLevel. +// +// Everything in this file is gated #if DEBUG and gone in Release. + +#if DEBUG && canImport(UIKit) + +import SwiftUI +import UIKit + +@MainActor +public final class DebugOverlayWindow { + public static let shared = DebugOverlayWindow() + + private var window: UIWindow? + + public func install(recording: Bool = false) { + guard window == nil else { return } + guard let scene = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first else { return } + + let w = PassThroughWindow(windowScene: scene) + w.windowLevel = .alert + 1 + w.backgroundColor = .clear + w.isUserInteractionEnabled = false + + let host = UIHostingController(rootView: OverlayRoot(recording: recording)) + host.view.backgroundColor = .clear + w.rootViewController = host + w.isHidden = false + + window = w + } + + public func setAttribution(_ identity: String) { + OverlayAttributionState.shared.identity = identity + } +} + +/// A window that lets touches pass through to underlying windows. +private final class PassThroughWindow: UIWindow { + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let view = super.hitTest(point, with: event) + return view == rootViewController?.view ? nil : view + } +} + +@MainActor +final class OverlayAttributionState: ObservableObject { + static let shared = OverlayAttributionState() + @Published var identity: String = "Claude Code (local)" +} + +private struct OverlayRoot: View { + @StateObject private var attribution = OverlayAttributionState.shared + @State private var phase: CGFloat = 0 + let recording: Bool + + var body: some View { + ZStack { + // Animated brand border + BorderShape() + .stroke( + AngularGradient( + gradient: Gradient(colors: [ + BrandColor.accent.opacity(0.0), + BrandColor.accent.opacity(0.8), + BrandColor.accent.opacity(0.0), + ]), + center: .center, + angle: .degrees(phase * 360) + ), + lineWidth: 4 + ) + .ignoresSafeArea() + .onAppear { + withAnimation(.linear(duration: 2.0).repeatForever(autoreverses: false)) { + phase = 1.0 + } + } + + // Attribution chip (top safe area) + VStack { + HStack { + Spacer() + Text("Driven by \(attribution.identity)") + .font(.caption2.weight(.semibold)) + .foregroundColor(.white) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + Capsule().fill(BrandColor.accent.opacity(0.85)) + ) + .padding(.trailing, 12) + .padding(.top, 8) + Spacer().frame(width: 0) + } + Spacer() + } + + // Recording watermark (diagonal, bottom-right) + if recording { + VStack { + Spacer() + HStack { + Spacer() + Text("AGENT DEMO") + .font(.system(size: 10, weight: .heavy, design: .monospaced)) + .foregroundColor(.red.opacity(0.7)) + .rotationEffect(.degrees(-30)) + .padding(.trailing, 16) + .padding(.bottom, 30) + } + } + } + } + .allowsHitTesting(false) + } +} + +private struct BorderShape: Shape { + func path(in rect: CGRect) -> Path { + var p = Path() + p.addRoundedRect(in: rect.insetBy(dx: 2, dy: 2), cornerSize: CGSize(width: 16, height: 16)) + return p + } +} + +private enum BrandColor { + // gstack brand color — resolved from DESIGN.md when codegen runs. + // Default falls back to a deep blue. + static let accent = Color(red: 0.0, green: 0.46, blue: 1.0) +} + +#endif // DEBUG && canImport(UIKit) diff --git a/ios-qa/templates/Package.swift.template b/ios-qa/templates/Package.swift.template new file mode 100644 index 000000000..feaf3ec72 --- /dev/null +++ b/ios-qa/templates/Package.swift.template @@ -0,0 +1,38 @@ +// AUTO-GENERATED from gstack/ios-qa/templates/Package.swift.template +// +// Drop-in SPM package definition for the DebugBridge target. The structural +// Release-build guard is the `.when(configuration: .debug)` conditional on +// every target dependency. SwiftPM refuses to link DebugBridge in Release. +// +// CI invariant: `swift build -c release` + `nm -j build/Release/ +// | grep -q DebugBridge && exit 1`. + +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "DebugBridge", + platforms: [.iOS(.v16), .macOS(.v13)], + products: [ + .library(name: "DebugBridge", targets: ["DebugBridge"]), + ], + targets: [ + .target( + name: "DebugBridge", + dependencies: [], + path: "Sources/DebugBridge", + swiftSettings: [ + // Belt and suspenders. The host app's `.when(configuration: .debug)` + // condition on the dependency means we never link in Release. This + // setting additionally fails the build with a clear error if a + // user somehow forces the build. + .define("DEBUG", .when(configuration: .debug)), + ] + ), + .testTarget( + name: "DebugBridgeTests", + dependencies: ["DebugBridge"], + path: "Tests/DebugBridgeTests" + ), + ] +) diff --git a/ios-qa/templates/StateAccessor.swift.template b/ios-qa/templates/StateAccessor.swift.template new file mode 100644 index 000000000..07de99e19 --- /dev/null +++ b/ios-qa/templates/StateAccessor.swift.template @@ -0,0 +1,38 @@ +// AUTO-GENERATED from gstack/ios-qa/templates/StateAccessor.swift.template +// Regenerated by `swift run gen-accessors`. DO NOT EDIT. +// +// This file is a TEMPLATE that gen-accessors-tool fills in. The placeholders +// are filled per-class from swift-syntax AST inspection of the app's +// @Observable types. Only properties marked with @Snapshotable are emitted. +// +// {{CLASS_NAME}} — the canonical AppState struct name +// {{APP_BUILD_ID}} — bundle short version + git SHA at codegen time +// {{ACCESSOR_HASH}} — sha256 of accessor signatures (snapshot schema fingerprint) +// {{ACCESSORS}} — generated register/read/write blocks per @Snapshotable field + +#if DEBUG + +import Foundation + +@MainActor +public enum {{CLASS_NAME}}Accessor { + + public static func register(_ state: {{CLASS_NAME}}) { + StateServer.shared.register( + buildId: "{{APP_BUILD_ID}}", + accessorHash: "{{ACCESSOR_HASH}}", + atomicRestore: { keys in + // Validate every key + type FIRST, then apply in one struct + // assignment so SwiftUI observers see exactly one change. + var snapshot = state.snapshotable +{{VALIDATION_BLOCK}} + // Apply atomically. + state.snapshotable = snapshot + return .ok + } + ) +{{REGISTER_BLOCK}} + } +} + +#endif // DEBUG diff --git a/ios-qa/templates/StateServer.swift.template b/ios-qa/templates/StateServer.swift.template new file mode 100644 index 000000000..ac7a3128f --- /dev/null +++ b/ios-qa/templates/StateServer.swift.template @@ -0,0 +1,521 @@ +// AUTO-GENERATED from gstack/ios-qa/templates/StateServer.swift.template +// Regenerate with: /ios-sync +// +// StateServer — HTTP server embedded in the iOS app under test. Loopback-only. +// All tailnet ingress is the responsibility of the Mac-side daemon. +// +// Threat model: this surface is reachable from the local Mac via the CoreDevice +// IPv6 tunnel. It MUST refuse any caller without a current bearer token. The +// boot token is rotated within ~5 seconds of daemon spawn so anything scraping +// os_log past that window sees a dead credential. + +import Foundation +import Network +import os.log + +#if DEBUG + +public typealias JSONDict = [String: Any] + +@MainActor +public final class StateServer { + // MARK: Public surface + + public static let shared = StateServer() + + // MARK: Configuration + + private let logger = Logger(subsystem: "gstack.ios-qa", category: "StateServer") + private let port: UInt16 + private let bootTokenPath: String + + // Two listeners for dual-stack loopback. The fork's single-listener IPv6-only + // binding was caught in eng + outside-voice review as incomplete. + private var ipv6Listener: NWListener? + private var ipv4Listener: NWListener? + + // Auth state. The boot token is what we wrote to os_log on first launch. + // It exists ONLY long enough for the daemon to call /auth/rotate. + private var bootToken: String + private var rotatedToken: String? // set after first /auth/rotate + private var bootTokenValid: Bool = true + + // MARK: Session lock (per-device, sliding window on mutations only) + + private struct Session { + let id: String + var lastMutationAt: Date + } + private var activeSession: Session? + private let sessionTtlSeconds: TimeInterval = 300 // 5 min orphan timeout + + // MARK: Accessor registry (populated by codegen) + + public typealias ReadHandler = () -> Any? + public typealias WriteHandler = (Any) -> Bool + public typealias TypeName = String + + private var readHandlers: [String: ReadHandler] = [:] + private var writeHandlers: [String: WriteHandler] = [:] + private var typeNames: [String: TypeName] = [:] + + // Atomic-restore hook. Codegen wires this to the canonical AppState struct. + // Restore replaces the entire struct in one assignment so SwiftUI's Combine + // pipeline observes exactly one change notification — true observable + // atomicity. @MainActor alone doesn't guarantee that. + public typealias AtomicRestoreFn = (JSONDict) -> RestoreResult + public enum RestoreResult { + case ok + case missingKey(String) + case typeMismatch(String) + case schemaMismatch(expected: String, got: String) + } + private var atomicRestore: AtomicRestoreFn? + + // Snapshot schema hash — written by codegen, stable across builds with + // identical accessor signatures. + private var accessorHash: String = "uninitialized" + private var appBuildId: String = "uninitialized" + + // Agent identity for the DebugOverlay attribution chip. Display-only, + // never used for auth. + public private(set) var lastAgentIdentity: String = "Claude Code (local)" + + // MARK: Lifecycle + + private init(port: UInt16 = 9999) { + self.port = port + self.bootToken = UUID().uuidString + self.bootTokenPath = NSTemporaryDirectory() + "gstack-ios-qa.token" + } + + public func start() { + // 1. Persist boot token to a 0600 file (best-effort fallback for the + // daemon if os_log scrape misses). + try? bootToken.write(toFile: bootTokenPath, atomically: true, encoding: .utf8) + try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: bootTokenPath) + + // 2. Log the boot token EXACTLY ONCE so the daemon can scrape it. + // The daemon will rotate immediately; this log line is dead within + // seconds. + logger.notice("gstack-ios-qa-bootstrap token=\(self.bootToken, privacy: .public) port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)") + + // 3. Bind both IPv6 and IPv4 loopback. CoreDevice tunnel uses IPv6; + // local tooling may use IPv4. Never bind 0.0.0.0 or ::. + startListener(family: .ipv6) + startListener(family: .ipv4) + } + + public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) { + self.appBuildId = buildId + self.accessorHash = accessorHash + self.atomicRestore = atomicRestore + } + + public func registerAccessor(key: String, type: String, read: @escaping ReadHandler, write: @escaping WriteHandler) { + readHandlers[key] = read + writeHandlers[key] = write + typeNames[key] = type + } + + // MARK: Listener setup + + private enum AddressFamily { + case ipv4 + case ipv6 + + var host: NWEndpoint.Host { + switch self { + case .ipv4: return NWEndpoint.Host("127.0.0.1") + case .ipv6: return NWEndpoint.Host("::1") + } + } + } + + private func startListener(family: AddressFamily) { + do { + let params = NWParameters.tcp + params.requiredLocalEndpoint = .hostPort(host: family.host, port: NWEndpoint.Port(rawValue: port)!) + params.allowLocalEndpointReuse = true + + let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!) + listener.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + if case .ready = state { + self?.logger.notice("StateServer listening on \(String(describing: family))") + } + } + } + listener.newConnectionHandler = { [weak self] connection in + Task { @MainActor in self?.handle(connection) } + } + listener.start(queue: .global(qos: .userInitiated)) + + switch family { + case .ipv6: ipv6Listener = listener + case .ipv4: ipv4Listener = listener + } + } catch { + logger.error("Listener bind failed (\(String(describing: family))): \(error.localizedDescription, privacy: .public)") + } + } + + // MARK: Request handling + + private func handle(_ connection: NWConnection) { + connection.start(queue: .global(qos: .userInitiated)) + receive(connection: connection, buffer: Data()) + } + + private static let maxBodyBytes = 1_048_576 // 1MB hard cap + + private func receive(connection: NWConnection, buffer: Data) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] data, _, isComplete, error in + guard let self else { return } + Task { @MainActor in + var current = buffer + if let data = data { current.append(data) } + if current.count > Self.maxBodyBytes { + self.send(connection: connection, status: 413, body: ["error": "body_too_large"]) + return + } + if let req = self.tryParseRequest(current) { + self.route(connection: connection, request: req) + } else if isComplete || error != nil { + self.send(connection: connection, status: 400, body: ["error": "bad_request"]) + } else { + self.receive(connection: connection, buffer: current) + } + } + } + } + + struct ParsedRequest { + let method: String + let path: String + let headers: [String: String] + let body: Data + } + + private func tryParseRequest(_ data: Data) -> ParsedRequest? { + guard let headerEnd = data.range(of: Data("\r\n\r\n".utf8)) else { return nil } + let headerData = data.subdata(in: 0..= 2 else { return nil } + + var headers: [String: String] = [:] + for line in lines.dropFirst() { + guard let colon = line.firstIndex(of: ":") else { continue } + let key = String(line[.. Bool { + guard let auth = request.headers["authorization"], auth.hasPrefix("Bearer ") else { return false } + let token = String(auth.dropFirst("Bearer ".count)) + return token == rotatedToken + } + + private func handleAuthRotate(connection: NWConnection, request: ParsedRequest) { + // Validate boot token (still alive AND used only once). + guard bootTokenValid, + let auth = request.headers["authorization"], + auth.hasPrefix("Bearer "), + String(auth.dropFirst("Bearer ".count)) == bootToken else { + send(connection: connection, status: 401, body: ["error": "boot_token_invalid"]) + return + } + + guard let dict = try? JSONSerialization.jsonObject(with: request.body) as? JSONDict, + let newToken = dict["new_token"] as? String, + newToken.count >= 16 else { + send(connection: connection, status: 400, body: ["error": "invalid_rotate_payload"]) + return + } + + rotatedToken = newToken + bootTokenValid = false + // Best-effort scrub of on-disk boot token file. + try? FileManager.default.removeItem(atPath: bootTokenPath) + + logger.notice("Boot token rotated; original now invalid") + send(connection: connection, status: 200, body: ["ok": true]) + } + + // MARK: Session lock + + private static let mutatingPaths: Set = ["/tap", "/swipe", "/type", "/state/restore"] + + private func mutatingPathRequiresSession(_ path: String, method: String) -> Bool { + if method != "POST" { return false } + if path.hasPrefix("/state/") && path != "/state/restore" { return true } // /state/ writes + return Self.mutatingPaths.contains(path) + } + + private func requireSession(in request: ParsedRequest, connection: NWConnection) -> Bool { + guard let id = request.headers["x-session-id"] else { + send(connection: connection, status: 409, body: ["error": "session_required"]) + return false + } + guard let current = activeSession, current.id == id else { + send(connection: connection, status: 409, body: ["error": "session_invalid_or_expired"]) + return false + } + // Mutation slides the lock; reads do not. + activeSession?.lastMutationAt = Date() + return true + } + + private func handleSessionAcquire(connection: NWConnection) { + // Reap orphaned session. + if let s = activeSession, Date().timeIntervalSince(s.lastMutationAt) > sessionTtlSeconds { + activeSession = nil + } + if activeSession != nil { + send(connection: connection, status: 423, body: ["error": "device_locked"]) + return + } + let id = UUID().uuidString + activeSession = Session(id: id, lastMutationAt: Date()) + send(connection: connection, status: 200, body: [ + "session_id": id, + "ttl_seconds": Int(sessionTtlSeconds), + ]) + } + + private func handleSessionRelease(connection: NWConnection) { + activeSession = nil + send(connection: connection, status: 200, body: ["ok": true]) + } + + private func handleSessionHeartbeat(connection: NWConnection, request: ParsedRequest) { + guard let id = request.headers["x-session-id"], + activeSession?.id == id else { + send(connection: connection, status: 409, body: ["error": "session_invalid_or_expired"]) + return + } + activeSession?.lastMutationAt = Date() + send(connection: connection, status: 200, body: ["ok": true, "ttl_seconds": Int(sessionTtlSeconds)]) + } + + // MARK: State handlers + + private func handleStateGet(connection: NWConnection, key: String) { + guard let handler = readHandlers[key] else { + send(connection: connection, status: 404, body: ["error": "unknown_key", "key": key]) + return + } + let value = handler() ?? NSNull() + send(connection: connection, status: 200, body: ["key": key, "value": value]) + } + + private func handleStateWrite(connection: NWConnection, request: ParsedRequest, key: String) { + guard requireSession(in: request, connection: connection) else { return } + guard let handler = writeHandlers[key] else { + send(connection: connection, status: 404, body: ["error": "unknown_key", "key": key]) + return + } + guard let payload = try? JSONSerialization.jsonObject(with: request.body) as? JSONDict, + let value = payload["value"] else { + send(connection: connection, status: 400, body: ["error": "missing_value"]) + return + } + let ok = handler(value) + if ok { + send(connection: connection, status: 200, body: ["ok": true]) + } else { + send(connection: connection, status: 400, body: ["error": "type_mismatch", "expected": typeNames[key] ?? "?"]) + } + } + + private func handleSnapshotGet(connection: NWConnection) { + var keys: JSONDict = [:] + for (k, read) in readHandlers { + keys[k] = read() ?? NSNull() + } + let envelope: JSONDict = [ + "_schema_version": 1, + "_app_build_id": appBuildId, + "_accessor_hash": accessorHash, + "keys": keys, + ] + send(connection: connection, status: 200, body: envelope) + } + + private func handleSnapshotRestore(connection: NWConnection, request: ParsedRequest) { + guard requireSession(in: request, connection: connection) else { return } + guard let envelope = try? JSONSerialization.jsonObject(with: request.body) as? JSONDict else { + send(connection: connection, status: 400, body: ["error": "invalid_json"]) + return + } + // Schema gate. + if let hash = envelope["_accessor_hash"] as? String, hash != accessorHash { + send(connection: connection, status: 409, body: [ + "error": "schema_mismatch", + "expected_hash": accessorHash, + "got_hash": hash, + ]) + return + } + guard let keys = envelope["keys"] as? JSONDict else { + send(connection: connection, status: 400, body: ["error": "missing_keys"]) + return + } + guard let restore = atomicRestore else { + send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"]) + return + } + // Validate-then-apply via the codegen-supplied closure. The closure does + // a single struct-assignment so SwiftUI sees one change notification. + switch restore(keys) { + case .ok: + send(connection: connection, status: 200, body: ["ok": true]) + case .missingKey(let k): + send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"]) + case .typeMismatch(let k): + send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"]) + case .schemaMismatch(let expected, let got): + send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got]) + } + } + + // MARK: Stubs (real impls live in DebugBridgeManager + UIKit) + + private func handleElements(connection: NWConnection) { + let tree = ElementsBridge.snapshot() + send(connection: connection, status: 200, body: ["elements": tree]) + } + + private func handleScreenshot(connection: NWConnection) { + if let png = ScreenshotBridge.capturePNG() { + send(connection: connection, status: 200, body: ["png_base64": png.base64EncodedString()]) + } else { + send(connection: connection, status: 500, body: ["error": "screenshot_unavailable"]) + } + } + + private func handleMutation(connection: NWConnection, request: ParsedRequest, op: String) { + guard requireSession(in: request, connection: connection) else { return } + guard let payload = try? JSONSerialization.jsonObject(with: request.body) as? JSONDict else { + send(connection: connection, status: 400, body: ["error": "invalid_json"]) + return + } + let ok = MutationBridge.dispatch(op: op, payload: payload) + send(connection: connection, status: ok ? 200 : 400, body: ["op": op, "ok": ok]) + } + + // MARK: Response + + private func send(connection: NWConnection, status: Int, body: JSONDict) { + let json = (try? JSONSerialization.data(withJSONObject: body)) ?? Data("{}".utf8) + let statusText: String + switch status { + case 200: statusText = "OK" + case 400: statusText = "Bad Request" + case 401: statusText = "Unauthorized" + case 404: statusText = "Not Found" + case 409: statusText = "Conflict" + case 413: statusText = "Payload Too Large" + case 423: statusText = "Locked" + case 429: statusText = "Too Many Requests" + case 500: statusText = "Internal Server Error" + case 503: statusText = "Service Unavailable" + default: statusText = "Status" + } + let header = "HTTP/1.1 \(status) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n" + var packet = Data(header.utf8) + packet.append(json) + connection.send(content: packet, completion: .contentProcessed { _ in + connection.cancel() + }) + } +} + +// MARK: - Bridges (implementation provided by DebugBridgeManager) + +@MainActor +public enum ElementsBridge { + public static var resolver: () -> [JSONDict] = { [] } + static func snapshot() -> [JSONDict] { resolver() } +} + +@MainActor +public enum ScreenshotBridge { + public static var resolver: () -> Data? = { nil } + static func capturePNG() -> Data? { resolver() } +} + +@MainActor +public enum MutationBridge { + public static var resolver: (String, JSONDict) -> Bool = { _, _ in false } + static func dispatch(op: String, payload: JSONDict) -> Bool { resolver(op, payload) } +} + +#endif // DEBUG