mirror of https://github.com/garrytan/gstack.git
405 lines
18 KiB
Plaintext
405 lines
18 KiB
Plaintext
// AUTO-GENERATED from gstack/ios-qa/templates/Bridges.swift.template
|
|
//
|
|
// Real UIKit-backed implementations of the three bridges StateServer
|
|
// declares: ScreenshotBridge (PNG capture), ElementsBridge (accessibility
|
|
// tree), MutationBridge (tap/swipe/type via accessibility actions + hit
|
|
// testing). Everything #if DEBUG && canImport(UIKit) so Release builds
|
|
// don't link UIKit or carry any of this code.
|
|
//
|
|
// Wire from the consuming app:
|
|
//
|
|
// #if DEBUG && canImport(UIKit)
|
|
// import DebugBridgeUI
|
|
// DebugBridgeUIWiring.installAll()
|
|
// #endif
|
|
|
|
#if DEBUG && canImport(UIKit)
|
|
|
|
import DebugBridgeCore
|
|
import DebugBridgeTouch
|
|
import Foundation
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
@MainActor
|
|
public enum DebugBridgeUIWiring {
|
|
/// Install all three bridge resolvers. Idempotent — calling multiple
|
|
/// times reinstalls the same closures. Must be called on @MainActor
|
|
/// because every UIKit access requires the main actor.
|
|
public static func installAll() {
|
|
ScreenshotBridge.resolver = { ScreenshotBridgeImpl.capturePNG() }
|
|
ElementsBridge.resolver = { ElementsBridgeImpl.snapshot() }
|
|
MutationBridge.resolver = { op, payload in MutationBridgeImpl.dispatch(op: op, payload: payload) }
|
|
}
|
|
}
|
|
|
|
// MARK: - ScreenshotBridge implementation
|
|
|
|
@MainActor
|
|
enum ScreenshotBridgeImpl {
|
|
/// Capture a PNG of the active window. Uses UIGraphicsImageRenderer
|
|
/// (modern API, replaces UIGraphicsBeginImageContext). Returns nil if
|
|
/// no key window is available (e.g., app backgrounded).
|
|
static func capturePNG() -> Data? {
|
|
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return nil }
|
|
let bounds = window.bounds
|
|
let renderer = UIGraphicsImageRenderer(bounds: bounds)
|
|
let image = renderer.image { _ in
|
|
// drawHierarchy is the documented way to snapshot real UIKit
|
|
// layers including layer-backed views. afterScreenUpdates: false
|
|
// because we want the CURRENT visible state, not a forced layout.
|
|
window.drawHierarchy(in: bounds, afterScreenUpdates: false)
|
|
}
|
|
return image.pngData()
|
|
}
|
|
|
|
private static func activeScene() -> UIWindowScene? {
|
|
UIApplication.shared.connectedScenes
|
|
.compactMap { $0 as? UIWindowScene }
|
|
.first { $0.activationState == .foregroundActive }
|
|
?? (UIApplication.shared.connectedScenes.first as? UIWindowScene)
|
|
}
|
|
|
|
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
|
|
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
|
|
}
|
|
}
|
|
|
|
// MARK: - ElementsBridge implementation
|
|
|
|
@MainActor
|
|
enum ElementsBridgeImpl {
|
|
/// Walk the accessibility hierarchy + emit a flat list of elements.
|
|
/// Each entry has frame (in window coords), accessibility label,
|
|
/// identifier, traits as a bitmask, and a parent path. Skips
|
|
/// non-accessible / hidden views.
|
|
static func snapshot() -> [JSONDict] {
|
|
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return [] }
|
|
var elements: [JSONDict] = []
|
|
collect(view: window, parentPath: "", windowBounds: window.bounds, into: &elements)
|
|
return elements
|
|
}
|
|
|
|
private static func collect(view: UIView, parentPath: String, windowBounds: CGRect, into elements: inout [JSONDict]) {
|
|
// Skip hidden / zero-size / off-screen subtrees early.
|
|
if view.isHidden || view.alpha < 0.01 { return }
|
|
|
|
let frameInWindow = view.convert(view.bounds, to: nil)
|
|
if !windowBounds.intersects(frameInWindow) { return }
|
|
|
|
let isAccessible = view.isAccessibilityElement
|
|
let label = view.accessibilityLabel ?? ""
|
|
let identifier = view.accessibilityIdentifier ?? ""
|
|
let traits = Int(view.accessibilityTraits.rawValue)
|
|
let value = (view.accessibilityValue ?? "") as String
|
|
let className = String(describing: type(of: view))
|
|
let path = parentPath.isEmpty ? className : "\(parentPath) > \(className)"
|
|
|
|
// Emit if any of:
|
|
// - Marked accessible (covers UIKit-native widgets)
|
|
// - Has explicit AX label / identifier
|
|
// - Is a known interactive type (UIControl, UITextField, UIScrollView)
|
|
// - Hosts a SwiftUI view (UIHostingController's view class)
|
|
let isInteractive = view is UIControl || view is UIScrollView || view is UITextInput
|
|
let isHosting = className.contains("Hosting") || className.contains("SwiftUI")
|
|
if isAccessible || !label.isEmpty || !identifier.isEmpty || isInteractive || isHosting {
|
|
elements.append([
|
|
"path": path,
|
|
"class": className,
|
|
"label": label,
|
|
"identifier": identifier,
|
|
"value": value,
|
|
"traits": traits,
|
|
"frame": [
|
|
"x": Int(frameInWindow.origin.x),
|
|
"y": Int(frameInWindow.origin.y),
|
|
"w": Int(frameInWindow.size.width),
|
|
"h": Int(frameInWindow.size.height),
|
|
],
|
|
"is_user_interaction_enabled": view.isUserInteractionEnabled,
|
|
])
|
|
}
|
|
|
|
// Recurse into accessibility-elements first (some custom views vend
|
|
// synthetic children), then UIView subviews. SwiftUI's host views
|
|
// populate accessibilityElements lazily — many return nil before
|
|
// VoiceOver triggers them. Force population by reading accessibilityElementCount.
|
|
_ = view.accessibilityElementCount()
|
|
if let axElements = view.accessibilityElements {
|
|
for case let element as NSObject in axElements {
|
|
if let v = element as? UIView {
|
|
collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements)
|
|
} else {
|
|
// Synthetic accessibility element (no UIView). Capture frame in screen coords.
|
|
let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
|
|
elements.append([
|
|
"path": "\(path) > <synthetic>",
|
|
"class": "AccessibilityElement",
|
|
"label": (element.value(forKey: "accessibilityLabel") as? String) ?? "",
|
|
"identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "",
|
|
"value": (element.value(forKey: "accessibilityValue") as? String) ?? "",
|
|
"traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0,
|
|
"frame": [
|
|
"x": Int(af.origin.x),
|
|
"y": Int(af.origin.y),
|
|
"w": Int(af.size.width),
|
|
"h": Int(af.size.height),
|
|
],
|
|
"is_user_interaction_enabled": true,
|
|
])
|
|
}
|
|
}
|
|
} else {
|
|
// accessibilityElements is nil — iterate by index. SwiftUI uses
|
|
// this dynamic protocol pattern; many AX elements only respond
|
|
// to accessibilityElementCount + accessibilityElement(at:).
|
|
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 {
|
|
collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements)
|
|
} else {
|
|
let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
|
|
elements.append([
|
|
"path": "\(path) > <ax\(i)>",
|
|
"class": String(describing: type(of: element)),
|
|
"label": (element.value(forKey: "accessibilityLabel") as? String) ?? "",
|
|
"identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "",
|
|
"value": (element.value(forKey: "accessibilityValue") as? String) ?? "",
|
|
"traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0,
|
|
"frame": [
|
|
"x": Int(af.origin.x),
|
|
"y": Int(af.origin.y),
|
|
"w": Int(af.size.width),
|
|
"h": Int(af.size.height),
|
|
],
|
|
"is_user_interaction_enabled": true,
|
|
])
|
|
}
|
|
}
|
|
}
|
|
for sub in view.subviews {
|
|
collect(view: sub, parentPath: path, windowBounds: windowBounds, into: &elements)
|
|
}
|
|
}
|
|
|
|
private static func activeScene() -> UIWindowScene? {
|
|
UIApplication.shared.connectedScenes
|
|
.compactMap { $0 as? UIWindowScene }
|
|
.first { $0.activationState == .foregroundActive }
|
|
?? (UIApplication.shared.connectedScenes.first as? UIWindowScene)
|
|
}
|
|
|
|
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
|
|
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
|
|
}
|
|
}
|
|
|
|
// MARK: - MutationBridge implementation
|
|
|
|
@MainActor
|
|
enum MutationBridgeImpl {
|
|
/// Route a mutation op to the right handler. Returns true on success,
|
|
/// false on failure (which the StateServer surfaces as 400 to the agent).
|
|
static func dispatch(op: String, payload: JSONDict) -> Bool {
|
|
switch op {
|
|
case "tap": return handleTap(payload)
|
|
case "type": return handleType(payload)
|
|
case "swipe": return handleSwipe(payload)
|
|
default: return false
|
|
}
|
|
}
|
|
|
|
/// 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..<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.
|
|
private static func handleType(_ payload: JSONDict) -> Bool {
|
|
guard let text = payload["text"] as? String else { return false }
|
|
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false }
|
|
guard let responder = findFirstResponder(in: window) else { return false }
|
|
if let field = responder as? UITextField {
|
|
field.text = text
|
|
field.sendActions(for: .editingChanged)
|
|
return true
|
|
}
|
|
if let view = responder as? UITextView {
|
|
view.text = text
|
|
view.delegate?.textViewDidChange?(view)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// Swipe via UIScrollView programmatic scroll OR via setContentOffset on
|
|
/// the deepest UIScrollView in the hit-tested ancestor chain. Less
|
|
/// faithful than synthesized touches but covers common scroll scenarios.
|
|
private static func handleSwipe(_ payload: JSONDict) -> Bool {
|
|
guard let fx = payload["from_x"] as? NSNumber,
|
|
let fy = payload["from_y"] as? NSNumber,
|
|
let tx = payload["to_x"] as? NSNumber,
|
|
let ty = payload["to_y"] as? NSNumber else { return false }
|
|
let from = CGPoint(x: fx.doubleValue, y: fy.doubleValue)
|
|
let to = CGPoint(x: tx.doubleValue, y: ty.doubleValue)
|
|
|
|
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false }
|
|
guard let hit = window.hitTest(from, with: nil) else { return false }
|
|
|
|
// Find the nearest enclosing UIScrollView.
|
|
var node: UIView? = hit
|
|
while let cur = node {
|
|
if let scroll = cur as? UIScrollView {
|
|
let dx = from.x - to.x
|
|
let dy = from.y - to.y
|
|
var off = scroll.contentOffset
|
|
off.x = max(0, min(scroll.contentSize.width - scroll.bounds.width, off.x + dx))
|
|
off.y = max(0, min(scroll.contentSize.height - scroll.bounds.height, off.y + dy))
|
|
scroll.setContentOffset(off, animated: true)
|
|
return true
|
|
}
|
|
node = cur.superview
|
|
}
|
|
return false
|
|
}
|
|
|
|
// MARK: helpers
|
|
|
|
private static func walkUp(_ view: UIView) -> UIView? {
|
|
var node: UIView? = view
|
|
while let cur = node {
|
|
if cur is UIControl { return cur }
|
|
node = cur.superview
|
|
}
|
|
return view
|
|
}
|
|
|
|
private static func findFirstResponder(in view: UIView) -> UIResponder? {
|
|
if view.isFirstResponder { return view }
|
|
for sub in view.subviews {
|
|
if let found = findFirstResponder(in: sub) { return found }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func activeScene() -> UIWindowScene? {
|
|
UIApplication.shared.connectedScenes
|
|
.compactMap { $0 as? UIWindowScene }
|
|
.first { $0.activationState == .foregroundActive }
|
|
?? (UIApplication.shared.connectedScenes.first as? UIWindowScene)
|
|
}
|
|
|
|
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
|
|
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
|
|
}
|
|
}
|
|
|
|
#endif // DEBUG && canImport(UIKit)
|