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/...
This commit is contained in:
Garry Tan
2026-05-19 21:59:02 -07:00
parent b4fe510648
commit f4f8b9f966
9 changed files with 234 additions and 54 deletions
@@ -1,19 +1,20 @@
// 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.
// Bootstraps StateServer on app launch. Lives in DebugBridgeCore (no UIKit
// dependency). The DebugOverlay install is wired separately by the consuming
// app — it lives in DebugBridgeUI which depends on DebugBridgeCore (not the
// other way around). 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) {
public func start(appState: AppState) {
// 1. Register the canonical AppState struct + accessor wiring.
// AppStateAccessor.register(_:) is generated by gen-accessors-tool.
AppStateAccessor.register(appState)
@@ -21,10 +22,12 @@ public final class DebugBridgeManager {
// 2. Boot the StateServer.
StateServer.shared.start()
// 3. Install the DebugOverlay window.
#if canImport(UIKit)
DebugOverlayWindow.shared.install(recording: recording)
#endif
// 3. The consuming app installs DebugOverlayWindow separately. See
// the example in DebugBridgeWiring.swift.template:
//
// #if canImport(UIKit)
// DebugOverlayWindow.shared.install(recording: recording)
// #endif
}
}
+29 -10
View File
@@ -134,15 +134,18 @@ public final class StateServer {
private func startListener(family: AddressFamily) {
do {
// Loopback-only binding: requiredInterfaceType=.loopback restricts the
// listener to lo0. NWListener does NOT honor requiredLocalEndpoint on
// its NWParameters (that's an outbound-connection concept) — so we
// pair the loopback interface gate with an explicit per-connection
// peer-address check below.
// 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
params.requiredInterfaceType = .loopback
params.acceptLocalOnly = true
let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!)
listener.stateUpdateHandler = { [weak self] state in
@@ -180,9 +183,25 @@ public final class StateServer {
switch connection.endpoint {
case .hostPort(let host, _):
switch host {
case .ipv4(let addr): return addr == .loopback
case .ipv6(let addr): return addr.isLoopback
case .name(let name, _): return name == "localhost"
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