From f4f8b9f966e31bc7f4ed034330c2fea30c973c00 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 19 May 2026 21:59:02 -0700 Subject: [PATCH] fix(ios): 3 architecture bugs surfaced by real-iPhone device test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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://[]:9999/... --- .../DebugBridgeManager.swift.template | 21 ++++--- ios-qa/templates/StateServer.swift.template | 39 +++++++++---- test/fixtures/ios-qa/FixtureApp/.gitignore | 2 + .../DebugBridgeCore/DebugBridgeManager.swift | 21 ++++--- .../Sources/DebugBridgeCore/StateServer.swift | 39 +++++++++---- .../Sources/FixtureApp/FixtureAppApp.swift | 55 +++++++++++++++++++ .../Sources/FixtureApp/FixtureAppState.swift | 28 ++++------ .../FixtureApp/Sources/FixtureApp/Info.plist | 34 ++++++++++++ test/fixtures/ios-qa/FixtureApp/project.yml | 49 +++++++++++++++++ 9 files changed, 234 insertions(+), 54 deletions(-) create mode 100644 test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppApp.swift create mode 100644 test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/Info.plist create mode 100644 test/fixtures/ios-qa/FixtureApp/project.yml diff --git a/ios-qa/templates/DebugBridgeManager.swift.template b/ios-qa/templates/DebugBridgeManager.swift.template index c1f67e371..e18e2fb05 100644 --- a/ios-qa/templates/DebugBridgeManager.swift.template +++ b/ios-qa/templates/DebugBridgeManager.swift.template @@ -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 } } diff --git a/ios-qa/templates/StateServer.swift.template b/ios-qa/templates/StateServer.swift.template index 8cafac6bb..803bedf31 100644 --- a/ios-qa/templates/StateServer.swift.template +++ b/ios-qa/templates/StateServer.swift.template @@ -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 diff --git a/test/fixtures/ios-qa/FixtureApp/.gitignore b/test/fixtures/ios-qa/FixtureApp/.gitignore index 678914f8c..85fa8eebd 100644 --- a/test/fixtures/ios-qa/FixtureApp/.gitignore +++ b/test/fixtures/ios-qa/FixtureApp/.gitignore @@ -4,3 +4,5 @@ DerivedData/ *.xcodeproj/ *.xcworkspace/ Package.resolved +*.xcodeproj/xcuserdata/ +*.xcodeproj/project.xcworkspace/xcuserdata/ diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/DebugBridgeManager.swift b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/DebugBridgeManager.swift index c1f67e371..e18e2fb05 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/DebugBridgeManager.swift +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/DebugBridgeManager.swift @@ -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 } } diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift index 8cafac6bb..803bedf31 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift +++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift @@ -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 diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppApp.swift b/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppApp.swift new file mode 100644 index 000000000..e3d8906db --- /dev/null +++ b/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppApp.swift @@ -0,0 +1,55 @@ +// FixtureApp — minimal SwiftUI app used by the ios-qa device-path E2E test. +// +// On launch: +// 1. Boot StateServer (loopback :::1/127.0.0.1 + 9999) +// 2. Log boot token to os_log so devicectl + the Mac daemon can scrape it +// 3. Render a single ContentView so the app stays foreground +// +// Everything ios-qa-related is gated #if DEBUG. Release builds compile this +// to a no-op app (no StateServer, no DebugBridge import, no overlay). + +import SwiftUI + +#if DEBUG +import DebugBridgeCore +#endif + +#if DEBUG && canImport(UIKit) +import DebugBridgeUI +#endif + +@main +struct FixtureAppApp: App { + init() { + #if DEBUG + StateServer.shared.start() + #endif + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} + +struct ContentView: View { + @State private var counter: Int = 0 + + var body: some View { + VStack(spacing: 24) { + Text("ios-qa fixture") + .font(.largeTitle.bold()) + Text("StateServer should be on :9999") + .font(.subheadline) + .foregroundColor(.secondary) + Button("Tap (\(counter))") { + counter += 1 + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("tap-button") + } + .padding() + .accessibilityIdentifier("fixture-content") + } +} diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppState.swift b/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppState.swift index 464c5dcfe..2980cd9e7 100644 --- a/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppState.swift +++ b/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppState.swift @@ -1,31 +1,27 @@ // Canonical app state for the fixture. Every snapshot-eligible field is -// marked with the @Snapshotable wrapper-style sentinel comment that the -// codegen tool detects. Two @Observable classes (one annotated, one not) -// confirm the codegen scopes correctly. +// 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). import Foundation -#if canImport(Observation) -import Observation -#endif - -#if canImport(Observation) -@available(iOS 17.0, macOS 14.0, *) -@Observable public final class FixtureAppState { @Snapshotable public var isLoggedIn: Bool = false @Snapshotable public var username: String = "" @Snapshotable public var tapCounter: Int = 0 /// Not snapshotted — ephemeral cache that should never leak via /state/snapshot. public var ephemeralCache: [String: String] = [:] -} -@available(iOS 17.0, macOS 14.0, *) -@Observable -public final class FixtureUtility { - public var lastEvent: String = "" + public init() {} } -#endif /// Property wrapper marker for snapshot-eligible state. The actual wrapper /// is a no-op at runtime; codegen-tool detection happens via attribute scan. diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/Info.plist b/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/Info.plist new file mode 100644 index 000000000..8791db340 --- /dev/null +++ b/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + ios-qa fixture + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + UILaunchScreen + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/test/fixtures/ios-qa/FixtureApp/project.yml b/test/fixtures/ios-qa/FixtureApp/project.yml new file mode 100644 index 000000000..35906f7b2 --- /dev/null +++ b/test/fixtures/ios-qa/FixtureApp/project.yml @@ -0,0 +1,49 @@ +name: FixtureApp +options: + deploymentTarget: + iOS: "16.0" + bundleIdPrefix: com.gstack.iosqa + developmentLanguage: en + createIntermediateGroups: true + +settings: + DEVELOPMENT_TEAM: 623FYQ2M88 + CODE_SIGN_STYLE: Automatic + ENABLE_USER_SCRIPT_SANDBOXING: NO + # Personal-team bundle IDs are scoped per-team; this prefix is unique. + PRODUCT_BUNDLE_IDENTIFIER: com.gstack.iosqa.fixture + +# Local SPM package providing DebugBridgeCore + DebugBridgeUI as dependencies. +# packages keyword (with `path:`) means a sibling local package next to project.yml. +packages: + DebugBridge: + path: . + +targets: + FixtureApp: + type: application + platform: iOS + deploymentTarget: "16.0" + sources: + - path: Sources/FixtureApp + dependencies: + - package: DebugBridge + product: DebugBridgeCore + - package: DebugBridge + product: DebugBridgeUI + info: + path: Sources/FixtureApp/Info.plist + properties: + CFBundleDisplayName: ios-qa fixture + UILaunchScreen: {} + UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait] + UIRequiredDeviceCapabilities: [arm64] + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.gstack.iosqa.fixture + DEVELOPMENT_TEAM: 623FYQ2M88 + CODE_SIGN_STYLE: Automatic + TARGETED_DEVICE_FAMILY: "1" + SWIFT_VERSION: "5.9" + IPHONEOS_DEPLOYMENT_TARGET: "16.0" + ENABLE_PREVIEWS: YES