mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-18 21:47:20 +02:00
fix(ios-qa): generate app-owned bridge accessors deterministically
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -6,22 +6,35 @@
|
||||
// the DebugBridge target.
|
||||
|
||||
#if DEBUG
|
||||
import DebugBridge
|
||||
import Foundation
|
||||
import DebugBridgeCore
|
||||
#if canImport(UIKit)
|
||||
import DebugBridgeUI
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
func startGstackDebugBridge(appState: AppState) {
|
||||
func startGstackDebugBridge<State>(
|
||||
appState: State,
|
||||
register: (State) -> Void
|
||||
) {
|
||||
// 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)
|
||||
}
|
||||
// Install the UI resolvers before opening the listener. A warm daemon can
|
||||
// issue its first request as soon as StateServer starts; it must never see
|
||||
// the default empty screenshot/elements/mutation handlers.
|
||||
#if canImport(UIKit)
|
||||
DebugBridgeUIWiring.installAll()
|
||||
#endif
|
||||
|
||||
DebugBridgeManager.shared.start(appState: appState, recording: recording)
|
||||
// Generated typed accessors live in the app target, so pass their register
|
||||
// function into the package instead of asking DebugBridgeCore to import
|
||||
// app-owned types.
|
||||
DebugBridgeManager.shared.start(appState: appState, register: register)
|
||||
|
||||
#if canImport(UIKit)
|
||||
DebugOverlayWindow.shared.install(recording: recording)
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -33,7 +46,10 @@ func startGstackDebugBridge(appState: AppState) {
|
||||
//
|
||||
// init() {
|
||||
// #if DEBUG
|
||||
// startGstackDebugBridge(appState: appState)
|
||||
// startGstackDebugBridge(
|
||||
// appState: appState,
|
||||
// register: MyAppStateAccessor.register
|
||||
// )
|
||||
// #endif
|
||||
// }
|
||||
//
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
//
|
||||
// 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.
|
||||
// @Observable types. Only properties preceded by `// @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
|
||||
// {{ACCESSORS}} — generated register/read/write blocks per marked field
|
||||
|
||||
#if DEBUG
|
||||
|
||||
@@ -21,13 +21,16 @@ public enum {{CLASS_NAME}}Accessor {
|
||||
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.
|
||||
atomicRestore: { keys, apply in
|
||||
// Validate every key + type before mutating any state. Invalid
|
||||
// input therefore cannot leave a partially restored model.
|
||||
var snapshot = state.snapshotable
|
||||
{{VALIDATION_BLOCK}}
|
||||
// Apply atomically.
|
||||
state.snapshotable = snapshot
|
||||
// StateServer validates every model first, then invokes the
|
||||
// same handlers with apply=true for a cross-model commit.
|
||||
if apply {
|
||||
state.snapshotable = snapshot
|
||||
}
|
||||
return .ok
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user