fix(ios-qa): generate app-owned bridge accessors deterministically

This commit is contained in:
t
2026-07-14 16:35:30 -07:00
parent ea648b7dff
commit 8e25d1583d
13 changed files with 2531 additions and 290 deletions
@@ -14,12 +14,16 @@ import Foundation
public final class DebugBridgeManager {
public static let shared = DebugBridgeManager()
public func start(appState: AppState) {
// 1. Register the canonical AppState struct + accessor wiring.
// AppStateAccessor.register(_:) is generated by gen-accessors-tool.
AppStateAccessor.register(appState)
/// Register app-owned generated accessors, then start the server. The
/// registration closure is passed in from the consuming app because the
/// DebugBridgeCore package cannot import app-target types. On UIKit apps,
/// call DebugBridgeUIWiring.installAll() before this method so a warm
/// daemon cannot reach uninitialized resolvers during listener startup.
public func start<State>(appState: State, register: (State) -> Void) {
register(appState)
// 2. Boot the StateServer.
// Boot only after registration so the first snapshot has a real build
// id, schema hash, and key set.
StateServer.shared.start()
// 3. The consuming app installs DebugOverlayWindow separately. See
@@ -31,19 +35,4 @@ public final class DebugBridgeManager {
}
}
// 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
@@ -59,18 +59,20 @@ public final class StateServer {
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
// Validated-restore hooks. Every generated model registers one two-phase
// handler. The server validates all models before it lets any model mutate,
// so invalid input can never cause a cross-model partial restore.
// Valid restores apply properties on MainActor; observers may receive one
// change notification per property because arbitrary @Observable models do
// not expose a general single-assignment transaction API.
public typealias AtomicRestoreFn = (JSONDict, Bool) -> RestoreResult
public enum RestoreResult {
case ok
case missingKey(String)
case typeMismatch(String)
case schemaMismatch(expected: String, got: String)
}
private var atomicRestore: AtomicRestoreFn?
private var atomicRestores: [AtomicRestoreFn] = []
// Snapshot schema hash written by codegen, stable across builds with
// identical accessor signatures.
@@ -109,7 +111,7 @@ public final class StateServer {
public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) {
self.appBuildId = buildId
self.accessorHash = accessorHash
self.atomicRestore = atomicRestore
self.atomicRestores.append(atomicRestore)
}
public func registerAccessor(key: String, type: String, read: @escaping ReadHandler, write: @escaping WriteHandler) {
@@ -284,6 +286,7 @@ public final class StateServer {
"version": "1.0.0",
"build": appBuildId,
"accessor_hash": accessorHash,
"bundle_id": Bundle.main.bundleIdentifier ?? "unknown",
])
return
}
@@ -476,22 +479,36 @@ public final class StateServer {
send(connection: connection, status: 400, body: ["error": "missing_keys"])
return
}
guard let restore = atomicRestore else {
guard !atomicRestores.isEmpty 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])
// Phase one validates every registered model without assignment.
for restore in atomicRestores {
switch restore(keys, false) {
case .ok:
continue
case .missingKey(let k):
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
return
case .typeMismatch(let k):
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
return
case .schemaMismatch(let expected, let got):
send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
return
}
}
// Phase two applies only after every model accepted the immutable input.
// A valid multi-field restore may notify once per property.
for restore in atomicRestores {
guard case .ok = restore(keys, true) else {
send(connection: connection, status: 500, body: ["error": "restore_apply_failed"])
return
}
}
send(connection: connection, status: 200, body: ["ok": true])
}
// MARK: Stubs (real impls live in DebugBridgeManager + UIKit)
@@ -522,9 +539,19 @@ public final class StateServer {
// MARK: Response
private func send(connection: NWConnection, status: Int, body: JSONDict) {
let json = (try? JSONSerialization.data(withJSONObject: body)) ?? Data("{}".utf8)
let responseStatus: Int
let json: Data
if JSONSerialization.isValidJSONObject(body),
let encoded = try? JSONSerialization.data(withJSONObject: body) {
responseStatus = status
json = encoded
} else {
logger.error("Refusing to send a non-JSON response body")
responseStatus = 500
json = Data("{\"error\":\"response_not_json_serializable\"}".utf8)
}
let statusText: String
switch status {
switch responseStatus {
case 200: statusText = "OK"
case 400: statusText = "Bad Request"
case 401: statusText = "Unauthorized"
@@ -537,7 +564,7 @@ public final class StateServer {
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"
let header = "HTTP/1.1 \(responseStatus) \(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
@@ -1,32 +1,22 @@
// Canonical app state for the fixture. Every snapshot-eligible field is
// marked with the @Snapshotable property wrapper that the codegen tool
// detects via attribute scan.
//
// Note: we DON'T use @Observable here because the macro expansion converts
// stored properties into computed ones, which the @Snapshotable wrapper
// can't apply to. In production apps that need both observability AND
// snapshotting, the right pattern is:
// - Use ObservableObject + @Published (older API), or
// - Hold all @Snapshotable state in a nested struct + replace it
// wholesale on restore so SwiftUI sees a single change notification
// (the canonical-state-struct atomicity strategy from the plan).
// Canonical observable app state for the fixture. Snapshot eligibility is a
// generator-only source marker, not a property wrapper, so it composes with
// Observation's @Observable macro.
import Foundation
import Observation
public final class FixtureAppState {
@Snapshotable public var isLoggedIn: Bool = false
@Snapshotable public var username: String = ""
@Snapshotable public var tapCounter: Int = 0
@Observable
final class FixtureAppState {
// @Snapshotable
var isLoggedIn: Bool = false
// @Snapshotable
var username: String = ""
// @Snapshotable
var tapCounter: Int = 0
// @Snapshotable
var nickname: String? = nil
/// Not snapshotted ephemeral cache that should never leak via /state/snapshot.
public var ephemeralCache: [String: String] = [:]
var ephemeralCache: [String: String] = [:]
public init() {}
}
/// Property wrapper marker for snapshot-eligible state. The actual wrapper
/// is a no-op at runtime; codegen-tool detection happens via attribute scan.
@propertyWrapper
public struct Snapshotable<Value> {
public var wrappedValue: Value
public init(wrappedValue: Value) { self.wrappedValue = wrappedValue }
init() {}
}