mirror of https://github.com/garrytan/gstack.git
Merge 56c8f523a8 into 7c9df1c568
This commit is contained in:
commit
58fdcfefd4
|
|
@ -64,7 +64,12 @@ export type BootstrapErrorReason =
|
||||||
*/
|
*/
|
||||||
export async function bootstrapTunnel(opts: BootstrapOptions): Promise<BootstrapResult> {
|
export async function bootstrapTunnel(opts: BootstrapOptions): Promise<BootstrapResult> {
|
||||||
const port = opts.port ?? 9999;
|
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 startupTimeoutMs = opts.startupTimeoutMs ?? 5_000;
|
||||||
const spawn = opts.spawnImpl;
|
const spawn = opts.spawnImpl;
|
||||||
const resolve = opts.resolveImpl;
|
const resolve = opts.resolveImpl;
|
||||||
|
|
|
||||||
|
|
@ -210,21 +210,117 @@ enum MutationBridgeImpl {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tap at (x, y) in window coordinates. Delegates to DebugBridgeTouch
|
/// Tap at (x, y) in window coordinates.
|
||||||
/// (KIF-derived in-process touch synthesis). The Obj-C target builds a
|
///
|
||||||
/// real UITouch + IOHIDEvent + UIEvent and dispatches via
|
/// Two-stage dispatch:
|
||||||
/// `UIApplication.sendEvent`, which is what UIKit uses for real touches.
|
///
|
||||||
/// This works for UIControl, SwiftUI Button (via iOS 18+
|
/// 1. **Accessibility activation** — walk the AX tree, find the smallest
|
||||||
/// `_UIHitTestContext`), gesture recognizers, and anything else that
|
/// element whose frame contains the tap point AND carries the
|
||||||
/// listens to the real event-dispatch path.
|
/// `.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 {
|
private static func handleTap(_ payload: JSONDict) -> Bool {
|
||||||
guard let x = payload["x"] as? NSNumber,
|
guard let x = payload["x"] as? NSNumber,
|
||||||
let y = payload["y"] as? NSNumber else { return false }
|
let y = payload["y"] as? NSNumber else { return false }
|
||||||
let point = CGPoint(x: x.doubleValue, y: y.doubleValue)
|
let point = CGPoint(x: x.doubleValue, y: y.doubleValue)
|
||||||
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false }
|
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)
|
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..<count {
|
||||||
|
guard let element = view.accessibilityElement(at: i) as? NSObject else { continue }
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for sub in view.subviews { visit(sub) }
|
||||||
|
}
|
||||||
|
|
||||||
|
visit(window)
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
/// Set text on the first responder if it's a UITextField or UITextView.
|
/// Set text on the first responder if it's a UITextField or UITextView.
|
||||||
private static func handleType(_ payload: JSONDict) -> Bool {
|
private static func handleType(_ payload: JSONDict) -> Bool {
|
||||||
guard let text = payload["text"] as? String else { return false }
|
guard let text = payload["text"] as? String else { return false }
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,18 @@ public final class StateServer {
|
||||||
private init(port: UInt16 = 9999) {
|
private init(port: UInt16 = 9999) {
|
||||||
self.port = port
|
self.port = port
|
||||||
self.bootToken = UUID().uuidString
|
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() {
|
public func start() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue