mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-17 23:30:09 +02:00
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:
@@ -4,3 +4,5 @@ DerivedData/
|
||||
*.xcodeproj/
|
||||
*.xcworkspace/
|
||||
Package.resolved
|
||||
*.xcodeproj/xcuserdata/
|
||||
*.xcodeproj/project.xcworkspace/xcuserdata/
|
||||
|
||||
+12
-9
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>ios-qa fixture</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
+49
@@ -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
|
||||
Reference in New Issue
Block a user