Files
gstack/ios-qa/templates/StateServer.swift.template
T
c7554824cb v1.91.38.0 fix(ios-qa): iPads work, a dropped route no longer relaunches the app, loud boot-token write (#3076)
* fix(ios-qa): allow physical iPad devices in tunnel bootstrap (#2743)

(cherry picked from commit d6d43934ef)

* fix(ios-qa): iPhone + iPad on USB names each UDID instead of picking one

With GSTACK_IOS_TARGET_UDID unset, tied default ranks used to pick the first
device devicectl listed, so a QA session could drive the wrong device. Tied
candidates now fail with multiple_devices, list each device and UDID, and
print a ready export line. Device selection moves into selectDevice() so the
route-drop recovery can re-resolve with the same rules.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ios-qa): a dropped CoreDevice route no longer relaunches the app (#1975)

Rotation deletes the app's one-shot boot-token file, so the daemon's old
503/504 path (invalidate + full bootstrap) could only get a new token by
running devicectl launch --terminate-existing, wiping the in-app QA state at
every route blip.

A route failure now goes through recoverTunnel(): re-select the device with
the bootstrap rules, re-resolve the tunnel address for the pinned UDID, check
the unauthenticated /healthz owner, then probe /state/snapshot with the cached
bearer. Outcomes:
- bearer accepted: keep the session (new address if it moved)
- 401: full bootstrap from the fresh boot token (app was relaunched)
- app confirmed absent: bootstrap, which launches it normally once
- different device or an address devicectl cannot tie to the UDID:
  bootstrap; the old bearer is never sent there
- route still down with the app running: surface the error, keep the session

Concurrent failures on one tunnel share a single refresh. Mutations whose
response was lost are still never replayed. The CLI wiring moves into
deviceTunnelSource() so tests drive it against a simulated device.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ios-qa): a failed boot-token write is loud instead of silent (#1837)

StateServer.start() wrote the boot-token file with try?, so a failed write
left nothing on disk and no trace anywhere, and the daemon then relaunched the
app trying to mint a token that could never be written. The write is now a
do/catch that logs the path and error (never the token), logs a NOT READY
bootstrap line, and reports boot_token_error on /healthz. bootstrapTunnel
reads that field and returns boot_token_unavailable naming the device-side
cause instead of running --terminate-existing.

Also drops the stale comments claiming the token was written to os_log.
Template and fixture stay byte-identical.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(ios-qa): type the iPad acceptance test's mDNS stub

ResolveImpl returns Promise<string[]>; resolving null tripped the test-code
typecheck ratchet. A rejected lookup exercises the same resolve_failed path.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(ios-qa): iPad support, GSTACK_IOS_TARGET_UDID, route-drop recovery, known limits

- iPhone-only description, triggers, and prerequisites now cover iPads, and
  say how to pick one device with GSTACK_IOS_TARGET_UDID.
- Phase 2 no longer tells you to capture a boot token from os_log (it has
  not been logged for many releases); it describes the 0600 tmp/ file and
  the NOT READY signal (#1837, #1735).
- Source-control guidance for the generated DebugBridge/ package (#1735).
- Failure table: route drops keep the session, multiple_devices, unwritable
  token file, and the one relaunch a daemon restart still costs.
- Known limits: SwiftUI DragGesture and /swipe on iOS 26 (#1975), the
  iOS 26.3.1 /elements data from PR #1755, unverified iPad Stage Manager.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: iOS QA how-to and skill index cover iPads and route-drop recovery

The how-to still said the boot token was readable from os_log and that a
route drop re-bootstraps; both are now accurate, with iPad selection,
GSTACK_IOS_TARGET_UDID, and three new failure rows.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.37.0 fix(ios-qa): iPads work, a route drop no longer relaunches the app, loud boot-token write

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(ios-qa): keep issue numbers out of model-read skill text

The archaeology lint rejects issue references in generated SKILL.md text;
the Known limits entry keeps the iOS version and drops the number.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: re-slot to v1.91.38.0 (v1.91.37.0 claimed by #3075)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(ios-qa): device-change recovery test without an IPv4-mapped address

The Windows lane could not reach a fake StateServer through
::ffff:127.0.0.1, so the iPad half of the device-change test timed out. The
fake device now swaps identity on its one ::1 listener, which is also closer
to production: every StateServer answers on the same port.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: loulanyue <260355617@qq.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-08 07:02:02 +00:00

626 lines
27 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, and rotation deletes
// its file, so a copy taken past that window is 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 written to a 0600 file in tmp/ on launch
// (never to os_log). It exists ONLY long enough for the daemon to call
// /auth/rotate. A failed write is kept here and reported on /healthz.
private var bootToken: String
private var bootTokenWriteError: 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] = [:]
// 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 atomicRestores: [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 the boot token to a 0600 file. This is the ONLY way the
// daemon can authenticate (devicectl copies it out of tmp/), so a
// failed write is an error, not a silent skip.
let tokenFile = bootTokenPath
do {
try bootToken.write(toFile: tokenFile, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tokenFile)
} catch {
bootTokenWriteError = error.localizedDescription
logger.error("Boot token file write failed at \(tokenFile, privacy: .public): \(error.localizedDescription, privacy: .public). No daemon can authenticate until the app relaunches with a writable tmp/.")
}
// 2. Announce bootstrap WITHOUT the token. The daemon reads the boot
// token from the 0600 file above (copyFileFromAppContainer); the
// os_log line that used to carry it had no consumer and handed a
// live credential to anything reading the unified log during the
// launch window. Port/build stay for diagnostics.
if let failure = bootTokenWriteError {
logger.error("gstack-ios-qa-bootstrap NOT READY port=\(self.port, privacy: .public): boot token file unwritable (\(failure, privacy: .public))")
} else {
logger.notice("gstack-ios-qa-bootstrap 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.atomicRestores.append(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
// IPv4 has no CoreDevice tunnel path, so it binds strictly to
// loopback at the socket level; IPv6 keeps the wildcard bind and
// relies on the per-connection peer check below for tunnel peers.
let listener: NWListener
switch family {
case .ipv4:
params.requiredLocalEndpoint = NWEndpoint.hostPort(
host: NWEndpoint.Host("127.0.0.1"),
port: NWEndpoint.Port(rawValue: port)!)
listener = try NWListener(using: params)
case .ipv6:
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" {
var health: JSONDict = [
"version": "1.0.0",
"build": appBuildId,
"accessor_hash": accessorHash,
"bundle_id": Bundle.main.bundleIdentifier ?? "unknown",
]
if let failure = bootTokenWriteError { health["boot_token_error"] = failure }
send(connection: connection, status: 200, body: health)
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 !atomicRestores.isEmpty else {
send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"])
return
}
// 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)
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 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 responseStatus {
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 \(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
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