diff --git a/ios-qa/daemon/src/tunnel-bootstrap.ts b/ios-qa/daemon/src/tunnel-bootstrap.ts index aa6636938..01860b015 100644 --- a/ios-qa/daemon/src/tunnel-bootstrap.ts +++ b/ios-qa/daemon/src/tunnel-bootstrap.ts @@ -64,7 +64,12 @@ export type BootstrapErrorReason = */ export async function bootstrapTunnel(opts: BootstrapOptions): Promise { const port = opts.port ?? 9999; - const tokenPath = opts.bootTokenPath ?? 'tmp/gstack-ios-qa.token'; + // Default Documents/ instead of tmp/. iOS clears tmp/ on memory + // pressure / between launches → `devicectl copy from tmp/...` returns + // ENOENT → bootstrap fails with `boot_token_unavailable` even though + // StateServer is alive. Documents/ persists across backgrounding. + // Pair with StateServer.swift.template `bootTokenPath`. + const tokenPath = opts.bootTokenPath ?? 'Documents/gstack-ios-qa.token'; const startupTimeoutMs = opts.startupTimeoutMs ?? 5_000; const spawn = opts.spawnImpl; const resolve = opts.resolveImpl; diff --git a/ios-qa/templates/Bridges.swift.template b/ios-qa/templates/Bridges.swift.template index bf7af6e3f..8a1e2ee9a 100644 --- a/ios-qa/templates/Bridges.swift.template +++ b/ios-qa/templates/Bridges.swift.template @@ -210,21 +210,117 @@ enum MutationBridgeImpl { } } - /// Tap at (x, y) in window coordinates. Delegates to DebugBridgeTouch - /// (KIF-derived in-process touch synthesis). The Obj-C target builds a - /// real UITouch + IOHIDEvent + UIEvent and dispatches via - /// `UIApplication.sendEvent`, which is what UIKit uses for real touches. - /// This works for UIControl, SwiftUI Button (via iOS 18+ - /// `_UIHitTestContext`), gesture recognizers, and anything else that - /// listens to the real event-dispatch path. + /// Tap at (x, y) in window coordinates. + /// + /// Two-stage dispatch: + /// + /// 1. **Accessibility activation** — walk the AX tree, find the smallest + /// element whose frame contains the tap point AND carries the + /// `.button` trait, then call `accessibilityActivate()`. This routes + /// through SwiftUI / UIKit's standard VoiceOver activation path, + /// which fires `Button.action` regardless of iOS version. Required + /// for SwiftUI Button hit-testing on iOS 16/17 where + /// `_UIHitTestContext` is unavailable (DebugBridgeTouch's + /// `sendTapAtPoint:` synthesizes a real UITouch but iOS 16/17 won't + /// route it into the SwiftUI hosting controller's gesture system). + /// + /// 2. **UITouch synthesis fallback** — if the AX path doesn't match + /// (gesture recognizers, UIScrollView pan, custom hit-test-only + /// views, drag gestures, anything without an explicit button trait), + /// delegate to `DebugBridgeTouch.sendTap`. The Obj-C target builds + /// a real UITouch + IOHIDEvent + UIEvent and dispatches via + /// `UIApplication.sendEvent` — what UIKit uses for real touches. + /// Covers UIControl, SwiftUI Button on iOS 18+ (via + /// `_UIHitTestContext`), gesture recognizers, and anything else + /// that listens to the real event-dispatch path. private static func handleTap(_ payload: JSONDict) -> Bool { guard let x = payload["x"] as? NSNumber, let y = payload["y"] as? NSNumber else { return false } let point = CGPoint(x: x.doubleValue, y: y.doubleValue) guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false } + + // Stage 1: AX activation (SwiftUI-friendly, iOS 16+ universal). + if let element = findActivatableAXElement(at: point, in: window), + element.accessibilityActivate() { + return true + } + + // Stage 2: UITouch synthesis fallback. return DebugBridgeTouch.sendTap(at: point, in: window) } + /// Walk the accessibility hierarchy from `window` and return the smallest + /// element whose accessibility frame contains `point` AND carries the + /// `.button` trait. "Smallest containing" mirrors how UIKit hit-testing + /// resolves nested taps — the deepest interactive element wins. Returns + /// nil if no button-trait element matches (caller falls through to + /// UITouch synthesis). + /// + /// Both UIView-backed AX elements (`view.isAccessibilityElement`) and + /// synthetic NSObject AX elements (vended by SwiftUI hosting controllers + /// via `accessibilityElements` / `accessibilityElementCount`) are + /// considered. SwiftUI Buttons typically surface as the latter on iOS + /// 16/17. + private static func findActivatableAXElement(at point: CGPoint, in window: UIWindow) -> NSObject? { + var best: NSObject? = nil + var bestArea: CGFloat = .infinity + + func consider(frame: CGRect, traits: UInt, element: NSObject) { + guard frame.contains(point) else { return } + guard (traits & UIAccessibilityTraits.button.rawValue) != 0 else { return } + let area = frame.width * frame.height + if area < bestArea { + best = element + bestArea = area + } + } + + func visit(_ view: UIView) { + // Early-out: subtree can't contain the point. + let frame = view.convert(view.bounds, to: nil) + guard frame.contains(point) else { return } + guard !view.isHidden, view.alpha >= 0.01 else { return } + + if view.isAccessibilityElement { + consider(frame: frame, traits: view.accessibilityTraits.rawValue, element: view) + } + + // SwiftUI hosting controllers vend their elements lazily; force + // population by reading the count first, then prefer + // `accessibilityElements` (eager array) over the indexed + // protocol when it returns non-nil. + _ = view.accessibilityElementCount() + if let elements = view.accessibilityElements { + for case let element as NSObject in elements { + if let v = element as? UIView { + visit(v) + } else { + let f = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero + let t = (element.value(forKey: "accessibilityTraits") as? NSNumber)?.uintValue ?? 0 + consider(frame: f, traits: t, element: element) + } + } + } else { + let count = view.accessibilityElementCount() + for i in 0.. Bool { guard let text = payload["text"] as? String else { return false } diff --git a/ios-qa/templates/StateServer.swift.template b/ios-qa/templates/StateServer.swift.template index 803bedf31..d680d75e4 100644 --- a/ios-qa/templates/StateServer.swift.template +++ b/ios-qa/templates/StateServer.swift.template @@ -86,7 +86,18 @@ public final class StateServer { private init(port: UInt16 = 9999) { self.port = port self.bootToken = UUID().uuidString - self.bootTokenPath = NSTemporaryDirectory() + "gstack-ios-qa.token" + // Persist in Documents/ rather than NSTemporaryDirectory(). + // iOS clears tmp/ aggressively (memory pressure, between launches, + // jetsam pressure when app is backgrounded for any meaningful time). + // The daemon's `devicectl copy from tmp/gstack-ios-qa.token` then + // returns ENOENT and bootstrap fails with `boot_token_unavailable` + // — even though StateServer is alive and responding to /healthz. + // Documents/ survives backgrounding + memory pressure and is + // reachable via the same appDataContainer domain identifier. + // Pair with `tunnel-bootstrap.ts` default tokenPath. + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + self.bootTokenPath = (docs?.appendingPathComponent("gstack-ios-qa.token").path) + ?? (NSTemporaryDirectory() + "gstack-ios-qa.token") } public func start() {