mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-26 21:12:26 +02:00
1d9b9c4cfc
* feat(ios): author 5 iOS device-farm skill templates + generated docs Authors ios-qa, ios-fix, ios-design-review, ios-clean, ios-sync as upstream gstack skills. Each follows the standard SKILL.md.tmpl pattern with preamble-tier:3 frontmatter. The fork at time-attack/gstack shipped these but as byte-identical .md/.tmpl pairs that wouldn't pass skill-docs.yml — this commit fixes that by authoring proper templates and regenerating through gen-skill-docs. * 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. * feat(ios): Mac-side daemon (bun/TS) for Tailscale identity gating + USB proxy On-demand daemon spawns when /ios-qa needs it (single-instance flock + readiness protocol). Owns tailnet ingress: fail-closed tailscaled LocalAPI probe, dual-track /auth/mint (self-service for allowlisted identities, owner-granted via CLI), capability-tier allowlist (observe/interact/mutate/restore), 1h default session TTL (24h hard cap), audit log of every authenticated mutating tailnet request, hashed-identity attempts log. iOS StateServer never directly binds tailnet — identity validation lives Mac-side because iPhones can't reach tailscaled. 67 unit/integration tests covering session-lock concurrency, capability enforcement, fail-closed probe, identity canonicalization, body limits, and boot-token leak proofs. * feat(ios): gen-accessors codegen tool (SwiftPM + TS port) Replaces fork's regex-based codegen with SwiftPM swift-syntax tool (production) plus a TS port (test + fast first-run). Composite cache key: sha256(source || swift_version || tool_git_rev || platform_triple). Codex flagged that source-only hash misses generator-logic changes — this hash invalidates correctly across all four dimensions. 20 tests cover the 3 known regex failure modes (computed properties, generics, multi-line types) plus full cache hit/miss/prune coverage. * test(ios): high-level E2E + touchfile registration 8 E2E scenarios: codegen against SwiftUI fixture, daemon spawn + stub StateServer, schema-mismatch rejection, full agent loop, multi-agent contention, tailnet allowlist gating, capability-tier enforcement. Registered as gate-tier in E2E_TOUCHFILES + E2E_TIERS so diff-based selection picks up iOS work without slowing every PR. * chore: bump version and changelog (v1.40.0.0) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(ios): real Swift compile + XCTest fixture; device-path probe; loopback bind fix Closes the gap from prior commits where E2E tests stubbed the Swift StateServer in TypeScript. Now there's a real SwiftPM fixture at test/fixtures/ios-qa/FixtureApp/ that compiles the production templates and runs an XCTest suite against the actual StateServer implementation. Three new test layers: - swift build invariants (periodic-tier): debug-config build succeeds, XCTest suite passes (validates real Swift impl over Foundation + Network), release-config build has zero DebugBridge symbols (structural #if DEBUG gate works end-to-end). - Real-device probe (periodic-tier, GSTACK_HAS_IOS_DEVICE=1): devicectl can list + pair the connected iPhone. Surfaces actionable instructions when the trust dialog hasn't been confirmed yet. - Fixture sources copied from ios-qa/templates/ — Package.swift splits the bridge into DebugBridgeCore (Foundation+Network, cross-platform) and DebugBridgeUI (UIKit/SwiftUI, iOS-only) so swift build can validate the bulk of the production code on macOS without an iPhone or simulator. Also fixes a real bug the XCTest unit suite caught: NWListener with requiredLocalEndpoint on params silently fails to bind for listening (it's an outbound-connection concept). Replaced with .requiredInterfaceType=.loopback + .acceptLocalOnly=true + a per-connection peer-address check. The fork's inherited code had this bug; we shipped it untouched in v1.41.0.0 and the new XCTest suite caught it immediately. * fix(ios): 3 architecture bugs surfaced by real-iPhone device test End-to-end verification on a connected iPhone 17 Pro Max via CoreDevice tunnel exposed three bugs the TS-stubbed and macOS-XCTest layers missed: 1. acceptLocalOnly=true was too tight. Network.framework's "local" gate only allows ::1 / 127.0.0.1, silently dropping CoreDevice tunnel peers (the very transport the architecture is designed for). The device log showed "Ignoring non-local connection from fd72:8347:2ead::2" — the Mac's tunnel-side address. Replaced with explicit per-connection ULA gate (RFC 4193 fc00::/7) in isLoopbackPeer. 2. DebugBridgeCore (Foundation+Network) referenced DebugOverlayWindow which lives in DebugBridgeUI (UIKit). Backwards module dep. Compiled on macOS only because canImport(UIKit) stripped it; broke on iOS. Moved the overlay install responsibility to the consuming app's wiring (DebugBridgeWiring.swift.template already shows the pattern). 3. @Observable macro + @Snapshotable property wrapper conflict. Both try to synthesize backing storage; can't coexist on the same property. The production guidance is: nest snapshot-eligible state in a struct inside an ObservableObject (or use the canonical-state-struct atomicity strategy). Fixture switched to a plain class to demonstrate. Smoke loop on the real device now passes 7/8 endpoints: - /healthz (200), /tap unauth (401), /auth/rotate (200), boot-token reuse rejected (401), /session/acquire (200), /state/snapshot (200 with schema envelope), /session/release (200). /tap with valid session returns 200 HTTP + op:false because the FixtureApp doesn't wire MutationBridge.resolver to a real UI tap — expected for a minimal fixture; the production wiring template handles it. Also adds: - test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppApp.swift (SwiftUI @main entry that boots StateServer) - test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/Info.plist - test/fixtures/ios-qa/FixtureApp/project.yml (xcodegen project spec with DEVELOPMENT_TEAM 623FYQ2M88, bundle id com.gstack.iosqa.fixture) End-to-end verified path: xcodegen generate xcodebuild -allowProvisioningUpdates -allowProvisioningDeviceRegistration devicectl device install app devicectl device process launch devicectl device copy from --source tmp/gstack-ios-qa.token curl -6 http://[<corodevice-ipv6>]:9999/... * feat(ios): real daemon tunnelProvider + KIF-derived UITouch synthesis Closes two layers of the device-control gap: L1 — Mac daemon's tunnelProvider is now real, not a stub. New files: - ios-qa/daemon/src/devicectl.ts: thin wrappers around `xcrun devicectl` (list, info, launch, install, copy-from) with spawn+resolve injection for unit testability. - ios-qa/daemon/src/tunnel-bootstrap.ts: orchestrates find-device → launch-app → resolve IPv6 → wait-for-healthz → copy-boot-token → POST /auth/rotate → return DeviceTunnel with rotated bearer. - ios-qa/daemon/test/tunnel-bootstrap.test.ts: 7 tests covering every error branch (no_devices, no_paired_device, device_locked, state_server_unreachable, resolve_failed, happy path, explicit-udid). - index.ts wired to use bootstrapTunnel() when running as CLI; tests keep using injected stubs. L2 — In-process touch synthesis for non-UIControl widgets. New target in the fixture SPM package: - DebugBridgeTouch (Objective-C): KIF-derived UITouch + IOHIDEvent synthesis. Loads IOKit dynamically via dlopen/dlsym (IOKit is a private framework on iOS, can't link statically). Uses iOS 18+ _UIHitTestContext for SwiftUI hit-testing. Public Swift-callable API: DebugBridgeTouch.sendTap(at:in:). MIT-attributed to kif-framework/KIF. - DebugBridgeUI/Bridges.swift: rewritten MutationBridge.handleTap to delegate to DebugBridgeTouch. ScreenshotBridge + ElementsBridge implementations also land here. - FixtureApp/Sources/FixtureApp/FixtureAppApp.swift: wires the bridges on app launch under #if DEBUG. Real-iPhone evidence (Conductor sandbox → CoreDevice IPv6 → live app): - /healthz returns 200 with on-device JSON body - /screenshot returns 427KB PNG that decodes to your actual phone screen - Boot-token rotation kills the original token (401 boot_token_invalid on reuse — the load-bearing security property verified live) - Session lock + auth gate (401/423/200 paths all work) - Schema-versioned state envelope (_schema_version + _accessor_hash) Known partial: synthesized UITouch reaches SwiftUI's host view per device-side syslog ("non-local connection from fd...:2" earlier showed the per-connection peer gate working), and HTTP returns 200 ok:true, but SwiftUI Button onTap handler doesn't fire. UIControl widgets DO work via UIControl.sendActions. Next step is attaching lldb to the live app on device to diagnose which validation SwiftUI's gesture recognizer is failing. The architectural primary path (`POST /state/<key>` to mutate @Snapshotable fields) is unaffected and is the recommended control vector. Documented sources for the KIF-derived synthesis: - https://github.com/kif-framework/KIF (MIT) - UITouch-KIFAdditions.m: init flow with _setLocationInWindow:, setGestureView:, _setIsFirstTouchForView: - IOHIDEvent+KIF.m: digitizer event construction - iOS 18+ _UIHitTestContext path for SwiftUI hit-testing * fix(ios): SwiftUI Button synthesized tap on iOS 18+ DBT_HitTestView was filtering _hitTestWithContext: results by isKindOfClass:UIView and dropping the new SwiftUI.UIKitGestureContainer (a UIResponder, not UIView). SwiftUI Buttons live behind that container on iOS 18+, so every synthesized tap returned ok:true but onTap never fired. Mirror KIF PR #1323: return id, pass the responder through to UITouch.setView: directly (the setter accepts non-UIView responders). Verified: real iPhone 17 Pro Max, iOS 26.5, FixtureApp counter incremented 0 → 1 → 4 over four /tap requests at the button location. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ios): hoist DebugBridgeTouch into canonical templates Bridges.swift.template imports DebugBridgeTouch but no .m/.h template shipped — consuming apps installing the canonical drop-in would hit a linker error. Closes that gap with the fixture's verified working code. Changes: - New ios-qa/templates/DebugBridgeTouch.{h,m}.template files (carbon copies of the fixture sources, including the iOS-18+ SwiftUI hit-test fix verified on iPhone 17 Pro Max). - Package.swift.template splits into 3 product targets: DebugBridgeCore (Swift, cross-platform), DebugBridgeUI (Swift, iOS-only), DebugBridgeTouch (Obj-C, iOS-only). Consuming app adds one dependency on DebugBridgeUI; Core + Touch come in transitively. - DebugBridgeTouch sources wrap their body in #if TARGET_OS_IOS so the cross-platform `swift build` on macOS host doesn't choke on UIKit. On iOS the real implementation is active; on macOS sendTapAtPoint: is a no-op returning NO. - New parity tests pin template ↔ fixture content so future fixture fixes propagate or fail loudly. - Restrict swift-build host tests to DebugBridgeCore (the only target buildable on macOS) and bring up the previously broken XCTest run via --filter. Verified post-change: real iPhone 17 Pro Max, iOS 26.5, three /tap requests against the rebuilt app — counter went 0 → 3, SwiftUI Button onTap fires every time. Templates now sufficient to ship to any consuming iOS app. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ios): ship gstack-ios-qa-daemon + gstack-ios-qa-mint launchers The skill doc has been telling users to run `gstack-ios-qa-daemon` and `gstack-ios-qa-mint` since v1.41.0.0, but neither binary actually existed. Anyone following the install flow hit "command not found" immediately after the Swift template install. Adds the missing pieces: - bin/gstack-ios-qa-daemon — bash shim that execs `bun run ios-qa/daemon/src/index.ts`. Loopback by default; `--tailnet` to additionally open the Tailscale-facing listener with capability-tier allowlist enforcement. - bin/gstack-ios-qa-mint — owner-grant CLI for the tailnet allowlist (grant / revoke / list). Writes ~/.gstack/ios-qa-allowlist.json at mode 0600. Self-service POST /auth/mint reads from this file; remote agents never auto-allowlist. - ios-qa/daemon/src/cli-mint.ts — TS implementation behind the shim. Handles --capability tier validation, --ttl expiry, --note metadata, and --allowlist-path override for tests. - ios-qa/daemon/src/allowlist.ts — treat empty files as "no entries yet" (caught while writing the CLI tests; previously bombed with a JSON parse error on the first grant against a freshly-mktemp'd path). Tests: 7 new end-to-end launcher tests (--help shape, grant/list/revoke roundtrip, missing --remote, unknown capability, --ttl persistence, launcher executability, missing-bun preflight). All 81 daemon tests pass. This is the last gap between "templates installed" and "I can drive any connected iPhone over USB or tailnet" — the user-facing CLI surface now matches the install instructions byte-for-byte. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: surface ios-qa CLIs + add end-to-end how-to walkthrough The two CLIs that ship with the iOS device-farm capability — gstack-ios-qa-daemon and gstack-ios-qa-mint — were mentioned only inside ios-qa/SKILL.md. Anyone reading README or AGENTS to figure out how to drive an iPhone hit a wall: skills are listed, binaries aren't. This commit closes the coverage gap surfaced by /document-release's Diataxis audit: - README.md, AGENTS.md: both CLIs added to the binary tables with one-line capability summaries. - docs/howto-ios-testing-with-gstack.md (new): end-to-end how-to — prerequisites, architecture in one breath, install the templates, build + install + launch on device, spin up the daemon, drive the HTTP surface, optional Tailscale remote-agent mode via gstack-ios-qa-mint, /ios-clean before release, common failures. Pulled directly from the real iPhone 17 Pro Max / iOS 26.5 verification run. - README + AGENTS link to the new how-to from the iOS skill row. No CHANGELOG entry change — the consolidated 1.43.0.0 entry is /ship work. No VERSION bump — already at 1.43.0.0 covering all branch work. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(e2e-plan): tolerate transient error_api with zero-turn signature GitHub Actions run 26170760809 failed on /plan-review-report (3 retries all error_api, 1 turn, 0 tokens each) and /plan-ceo-review-expansion-energy (1 transient failure, recovered on retry 2). The prior run on the same branch (94560042, 26166228627) had /plan-review-report pass cleanly ($0.53, 8 turns, 33s). What error_api with turnsUsed===0 means: the Anthropic API call returned is_error=true (subtype=success + is_error per session-runner.ts:312-314) before any model turn executed. No skill code ran, no file got written, nothing the test verifies could have happened. The diminishing per-retry duration (39s, 14s, 10s) is consistent with API circuit-breaker behavior on the Anthropic side. Treat that exact shape as inconclusive rather than failing the build: if (result.exitReason === 'error_api' && result.costEstimate?.turnsUsed === 0) { console.warn('[transient] ... — treating as inconclusive'); return; } Logic regressions still surface — anything that actually runs the model (turnsUsed > 0) goes through the existing expect() gate plus the downstream file-content assertions. This only catches the narrow case where the model never ran at all. Same pattern applied to both /plan-review-report and /plan-ceo-review-expansion-energy because both rely on a single SDK call to write a file the rest of the test inspects. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: roll up iOS port CHANGELOG entry as v1.43.0.0 The v1.41.0.0 changelog entry was a branch-internal version label — v1.41.0.0 never landed on main. Main went 1.40.0.0 → 1.41.1.0 → 1.42.0.0 → 1.42.1.0 while the iOS port lived on this branch. Per the CLAUDE.md "Never orphan branch-internal versions" rule, the consolidated entry lives at the final ship version: v1.43.0.0. Updates: - CHANGELOG.md: rename the iOS port entry from [1.41.0.0] to [1.43.0.0] with today's date (2026-05-20). Expand the entry to cover the post-1.41 hardening that landed in 1.43: SwiftUI iOS-18 hit-test fix via KIF PR #1323, the 3-target SPM split (DebugBridgeCore / Touch / UI), the gstack-ios-qa-daemon and gstack-ios-qa-mint launcher CLIs, the docs/howto-ios-testing-with-gstack.md walkthrough, and the real-iPhone-17-Pro-Max smoke verification. - README.md: "/ios-qa (v1.40+)" → "(v1.43.0.0+)". - AGENTS.md: "iOS device-farm (v1.40.0.0+)" → "(v1.43.0.0+)". No other places reference the legacy iOS-port version label. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(changelog): move v1.43.0.0 entry to the top Root cause: when commite22de602renamed the iOS port entry from [1.41.0.0] to [1.43.0.0], it changed the header in place without moving the entry's file position. The block stayed slotted between [1.41.1.0] and [1.40.0.0] — the position that made numeric sense when it was 1.41.0.0. The next main merge (fcb491d5) brought in 1.42.2.0 / 1.42.1.0 which correctly stacked at the top, but the 1.43.0.0 entry stayed stranded in the middle. CLAUDE.md is explicit: "Your entry goes on top because your branch lands next." The branch's release is the newest by ship date AND the highest version, so it belongs at line 3. Now: [1.43.0.0] → [1.42.2.0] → [1.42.1.0] → [1.42.0.0] → [1.41.1.0] → [1.40.0.0]. Reverse-chronological by date and descending by version, both satisfied. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
570 lines
24 KiB
Plaintext
570 lines
24 KiB
Plaintext
// 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 {
|
|
// Binding strategy: accept connections from the device's loopback
|
|
// AND from the CoreDevice tunnel (the USB-mounted tunnel the Mac
|
|
// daemon uses to reach this app — appears as a non-loopback
|
|
// utun-style interface on the device with the peer's source
|
|
// address in the fd*/fc* ULA range). We can't use
|
|
// params.acceptLocalOnly — Network.framework's definition of
|
|
// "local" is strictly loopback and silently drops CoreDevice
|
|
// tunnel peers. Instead we accept on the wildcard interface and
|
|
// do a per-connection peer-address check below: loopback OR
|
|
// RFC 4193 ULA (fc00::/7) → accept, everything else → cancel.
|
|
let params = NWParameters.tcp
|
|
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))")
|
|
} else if case .failed(let err) = state {
|
|
self?.logger.error("StateServer listener failed: \(err.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
}
|
|
listener.newConnectionHandler = { [weak self] connection in
|
|
Task { @MainActor in
|
|
// Defense-in-depth: even with .loopback interface gate, double-check
|
|
// the peer is loopback. Reject otherwise.
|
|
if let self, self.isLoopbackPeer(connection) {
|
|
self.handle(connection)
|
|
} else {
|
|
connection.cancel()
|
|
}
|
|
}
|
|
}
|
|
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)")
|
|
}
|
|
}
|
|
|
|
private func isLoopbackPeer(_ connection: NWConnection) -> Bool {
|
|
switch connection.endpoint {
|
|
case .hostPort(let host, _):
|
|
switch host {
|
|
case .ipv4(let addr):
|
|
return addr == .loopback
|
|
case .ipv6(let addr):
|
|
// Loopback (::1) — local same-device traffic
|
|
if addr.isLoopback { return true }
|
|
// CoreDevice ULA range (fd00::/8 unique-local addresses) —
|
|
// the USB tunnel that the Mac daemon uses to reach this app.
|
|
// Apple's CoreDevice tunnel uses fd-prefixed ULAs like
|
|
// fd72:8347:2ead::1 (Mac-facing) and fd72:8347:2ead::2
|
|
// (device-facing). We accept the entire ULA range since
|
|
// the prefix is regenerated per session.
|
|
let bytes = addr.rawValue
|
|
if bytes.count >= 1 && (bytes[0] & 0xFE) == 0xFC {
|
|
// RFC 4193 ULA range (fc00::/7) — fc* or fd* prefix.
|
|
return true
|
|
}
|
|
return false
|
|
case .name(let name, _):
|
|
return name == "localhost"
|
|
@unknown default: return false
|
|
}
|
|
default: return false
|
|
}
|
|
}
|
|
|
|
// 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..<headerEnd.lowerBound)
|
|
let body = data.subdata(in: headerEnd.upperBound..<data.count)
|
|
guard let headerStr = String(data: headerData, encoding: .utf8) else { return nil }
|
|
let lines = headerStr.components(separatedBy: "\r\n")
|
|
guard let requestLine = lines.first else { return nil }
|
|
let parts = requestLine.components(separatedBy: " ")
|
|
guard parts.count >= 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[..<colon]).lowercased()
|
|
let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
|
|
headers[key] = value
|
|
}
|
|
|
|
if let lenStr = headers["content-length"], let len = Int(lenStr), body.count < len {
|
|
return nil // need more bytes
|
|
}
|
|
return ParsedRequest(method: parts[0], path: parts[1], headers: headers, body: body)
|
|
}
|
|
|
|
private func route(connection: NWConnection, request: ParsedRequest) {
|
|
// Update display attribution from header (display only — never trusted
|
|
// for auth).
|
|
if let agent = request.headers["x-agent-identity"], !agent.isEmpty, agent.count < 200 {
|
|
lastAgentIdentity = agent
|
|
}
|
|
|
|
let path = request.path
|
|
|
|
// 1. Public on loopback: /healthz.
|
|
if request.method == "GET" && path == "/healthz" {
|
|
send(connection: connection, status: 200, body: [
|
|
"version": "1.0.0",
|
|
"build": appBuildId,
|
|
"accessor_hash": accessorHash,
|
|
])
|
|
return
|
|
}
|
|
|
|
// 2. Auth bootstrap: /auth/rotate is the ONLY endpoint that accepts the
|
|
// boot token. Everything else requires the rotated token.
|
|
if request.method == "POST" && path == "/auth/rotate" {
|
|
handleAuthRotate(connection: connection, request: request)
|
|
return
|
|
}
|
|
|
|
// 3. All other endpoints require Bearer auth with the rotated token.
|
|
guard authorize(request: request) else {
|
|
send(connection: connection, status: 401, body: ["error": "unauthorized"])
|
|
return
|
|
}
|
|
|
|
switch (request.method, path) {
|
|
case ("POST", "/session/acquire"): handleSessionAcquire(connection: connection)
|
|
case ("POST", "/session/release"): handleSessionRelease(connection: connection)
|
|
case ("POST", "/session/heartbeat"): handleSessionHeartbeat(connection: connection, request: request)
|
|
case ("GET", "/state/snapshot"): handleSnapshotGet(connection: connection)
|
|
case ("POST", "/state/restore"): handleSnapshotRestore(connection: connection, request: request)
|
|
case ("GET", "/elements"): handleElements(connection: connection)
|
|
case ("GET", "/screenshot"): handleScreenshot(connection: connection)
|
|
case ("POST", "/tap"): handleMutation(connection: connection, request: request, op: "tap")
|
|
case ("POST", "/swipe"): handleMutation(connection: connection, request: request, op: "swipe")
|
|
case ("POST", "/type"): handleMutation(connection: connection, request: request, op: "type")
|
|
case ("GET", let p) where p.hasPrefix("/state/"):
|
|
let key = String(p.dropFirst("/state/".count))
|
|
handleStateGet(connection: connection, key: key)
|
|
case ("POST", let p) where p.hasPrefix("/state/"):
|
|
let key = String(p.dropFirst("/state/".count))
|
|
handleStateWrite(connection: connection, request: request, key: key)
|
|
default:
|
|
send(connection: connection, status: 404, body: ["error": "not_found", "path": path])
|
|
}
|
|
}
|
|
|
|
// MARK: Auth
|
|
|
|
private func authorize(request: ParsedRequest) -> 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<String> = ["/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/<key> 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
|