mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-29 21:15:37 +02:00
945600428e
Closes two layers of the device-control gap:
L1 — Mac daemon's tunnelProvider is now real, not a stub. New files:
- ios-qa/daemon/src/devicectl.ts: thin wrappers around `xcrun devicectl`
(list, info, launch, install, copy-from) with spawn+resolve injection
for unit testability.
- ios-qa/daemon/src/tunnel-bootstrap.ts: orchestrates find-device →
launch-app → resolve IPv6 → wait-for-healthz → copy-boot-token →
POST /auth/rotate → return DeviceTunnel with rotated bearer.
- ios-qa/daemon/test/tunnel-bootstrap.test.ts: 7 tests covering every
error branch (no_devices, no_paired_device, device_locked,
state_server_unreachable, resolve_failed, happy path, explicit-udid).
- index.ts wired to use bootstrapTunnel() when running as CLI; tests
keep using injected stubs.
L2 — In-process touch synthesis for non-UIControl widgets. New target
in the fixture SPM package:
- DebugBridgeTouch (Objective-C): KIF-derived UITouch + IOHIDEvent
synthesis. Loads IOKit dynamically via dlopen/dlsym (IOKit is a
private framework on iOS, can't link statically). Uses iOS 18+
_UIHitTestContext for SwiftUI hit-testing. Public Swift-callable
API: DebugBridgeTouch.sendTap(at:in:). MIT-attributed to
kif-framework/KIF.
- DebugBridgeUI/Bridges.swift: rewritten MutationBridge.handleTap to
delegate to DebugBridgeTouch. ScreenshotBridge + ElementsBridge
implementations also land here.
- FixtureApp/Sources/FixtureApp/FixtureAppApp.swift: wires the bridges
on app launch under #if DEBUG.
Real-iPhone evidence (Conductor sandbox → CoreDevice IPv6 → live app):
- /healthz returns 200 with on-device JSON body
- /screenshot returns 427KB PNG that decodes to your actual phone screen
- Boot-token rotation kills the original token (401 boot_token_invalid
on reuse — the load-bearing security property verified live)
- Session lock + auth gate (401/423/200 paths all work)
- Schema-versioned state envelope (_schema_version + _accessor_hash)
Known partial: synthesized UITouch reaches SwiftUI's host view per
device-side syslog ("non-local connection from fd...:2" earlier showed
the per-connection peer gate working), and HTTP returns 200 ok:true,
but SwiftUI Button onTap handler doesn't fire. UIControl widgets DO
work via UIControl.sendActions. Next step is attaching lldb to the
live app on device to diagnose which validation SwiftUI's gesture
recognizer is failing. The architectural primary path
(`POST /state/<key>` to mutate @Snapshotable fields) is unaffected
and is the recommended control vector.
Documented sources for the KIF-derived synthesis:
- https://github.com/kif-framework/KIF (MIT)
- UITouch-KIFAdditions.m: init flow with _setLocationInWindow:,
setGestureView:, _setIsFirstTouchForView:
- IOHIDEvent+KIF.m: digitizer event construction
- iOS 18+ _UIHitTestContext path for SwiftUI hit-testing
278 lines
10 KiB
Objective-C
278 lines
10 KiB
Objective-C
//
|
|
// DebugBridgeTouch.m — minimal port of KIF's in-process touch synthesis.
|
|
// Original code: https://github.com/kif-framework/KIF — MIT-licensed
|
|
// (Square, Inc. + KIF contributors). Adapted to a single-file, tap-only,
|
|
// iOS 18+ aware subset for the gstack/ios-qa DebugBridge.
|
|
//
|
|
// Uses these private UIKit selectors (DEBUG-only; never shipped to App Store):
|
|
// UITouch: _setLocationInWindow:resetPrevious:, _setIsFirstTouchForView:,
|
|
// setPhase:, setTimestamp:, setView:, setWindow:, setTapCount:,
|
|
// _setHidEvent:
|
|
// UIEvent: _clearTouches, _addTouch:forDelayedDelivery:, _setHIDEvent:
|
|
// UIApplication: _touchesEvent
|
|
// UIView: _hitTestWithContext: (iOS 18+ for SwiftUI hit-testing)
|
|
// NSObject: _UIHitTestContext contextWithPoint:radius: (iOS 18+)
|
|
//
|
|
// IOKit private symbols (linked dynamically via the IOKit framework on iOS):
|
|
// IOHIDEventCreateDigitizerEvent, IOHIDEventCreateDigitizerFingerEventWithQuality,
|
|
// IOHIDEventSetIntegerValue, IOHIDEventAppendEvent.
|
|
|
|
#import "DebugBridgeTouch.h"
|
|
#import <UIKit/UIKit.h>
|
|
#import <objc/runtime.h>
|
|
#import <objc/message.h>
|
|
#import <mach/mach_time.h>
|
|
|
|
#pragma mark - IOHIDEvent (private symbols from IOKit)
|
|
|
|
typedef struct __IOHIDEvent * IOHIDEventRef;
|
|
|
|
#define IOHIDEventFieldBase(type) (type << 16)
|
|
#ifdef __LP64__
|
|
typedef double IOHIDFloat;
|
|
#else
|
|
typedef float IOHIDFloat;
|
|
#endif
|
|
typedef UInt32 IOOptionBits;
|
|
typedef uint32_t IOHIDDigitizerTransducerType;
|
|
typedef uint32_t IOHIDEventField;
|
|
|
|
enum {
|
|
kIOHIDDigitizerTransducerTypeStylus = 0,
|
|
kIOHIDDigitizerTransducerTypePuck,
|
|
kIOHIDDigitizerTransducerTypeFinger,
|
|
kIOHIDDigitizerTransducerTypeHand
|
|
};
|
|
|
|
enum {
|
|
kIOHIDEventTypeDigitizer = 11,
|
|
};
|
|
|
|
enum {
|
|
kIOHIDDigitizerEventRange = 0x00000001,
|
|
kIOHIDDigitizerEventTouch = 0x00000002,
|
|
kIOHIDDigitizerEventPosition = 0x00000004,
|
|
};
|
|
|
|
enum {
|
|
kIOHIDEventFieldDigitizerX = IOHIDEventFieldBase(kIOHIDEventTypeDigitizer),
|
|
kIOHIDEventFieldDigitizerY,
|
|
kIOHIDEventFieldDigitizerZ,
|
|
kIOHIDEventFieldDigitizerButtonMask,
|
|
kIOHIDEventFieldDigitizerType,
|
|
kIOHIDEventFieldDigitizerIndex,
|
|
kIOHIDEventFieldDigitizerIdentity,
|
|
kIOHIDEventFieldDigitizerEventMask,
|
|
kIOHIDEventFieldDigitizerRange,
|
|
kIOHIDEventFieldDigitizerTouch,
|
|
kIOHIDEventFieldDigitizerPressure,
|
|
kIOHIDEventFieldDigitizerAuxiliaryPressure,
|
|
kIOHIDEventFieldDigitizerTwist,
|
|
kIOHIDEventFieldDigitizerTiltX,
|
|
kIOHIDEventFieldDigitizerTiltY,
|
|
kIOHIDEventFieldDigitizerAltitude,
|
|
kIOHIDEventFieldDigitizerAzimuth,
|
|
kIOHIDEventFieldDigitizerQuality,
|
|
kIOHIDEventFieldDigitizerDensity,
|
|
kIOHIDEventFieldDigitizerIrregularity,
|
|
kIOHIDEventFieldDigitizerMajorRadius,
|
|
kIOHIDEventFieldDigitizerMinorRadius,
|
|
kIOHIDEventFieldDigitizerCollection,
|
|
kIOHIDEventFieldDigitizerCollectionChord,
|
|
kIOHIDEventFieldDigitizerChildEventMask,
|
|
kIOHIDEventFieldDigitizerIsDisplayIntegrated,
|
|
};
|
|
|
|
// IOKit is a PRIVATE framework on iOS — we can't link it via -framework. Load
|
|
// at runtime via dlopen/dlsym. This is the standard approach for KIF-style
|
|
// touch synthesis on iOS, including in DEBUG-only test harnesses.
|
|
#import <dlfcn.h>
|
|
|
|
typedef IOHIDEventRef (*IOHIDEventCreateDigitizerEventFn)(CFAllocatorRef, AbsoluteTime,
|
|
IOHIDDigitizerTransducerType, uint32_t, uint32_t, uint32_t, uint32_t,
|
|
IOHIDFloat, IOHIDFloat, IOHIDFloat, IOHIDFloat, IOHIDFloat, Boolean, Boolean, IOOptionBits);
|
|
|
|
typedef IOHIDEventRef (*IOHIDEventCreateDigitizerFingerEventWithQualityFn)(CFAllocatorRef,
|
|
AbsoluteTime, uint32_t, uint32_t, uint32_t, IOHIDFloat, IOHIDFloat, IOHIDFloat,
|
|
IOHIDFloat, IOHIDFloat, IOHIDFloat, IOHIDFloat, IOHIDFloat, IOHIDFloat,
|
|
IOHIDFloat, Boolean, Boolean, IOOptionBits);
|
|
|
|
typedef void (*IOHIDEventSetIntegerValueFn)(IOHIDEventRef, IOHIDEventField, int);
|
|
typedef void (*IOHIDEventAppendEventFn)(IOHIDEventRef, IOHIDEventRef);
|
|
|
|
static IOHIDEventCreateDigitizerEventFn _IOHIDEventCreateDigitizerEvent;
|
|
static IOHIDEventCreateDigitizerFingerEventWithQualityFn _IOHIDEventCreateDigitizerFingerEventWithQuality;
|
|
static IOHIDEventSetIntegerValueFn _IOHIDEventSetIntegerValue;
|
|
static IOHIDEventAppendEventFn _IOHIDEventAppendEvent;
|
|
|
|
static BOOL _IOKitLoaded = NO;
|
|
static BOOL DBT_LoadIOKit(void) {
|
|
if (_IOKitLoaded) return YES;
|
|
void *handle = dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", RTLD_NOW);
|
|
if (!handle) {
|
|
handle = dlopen("/System/Library/PrivateFrameworks/IOKit.framework/IOKit", RTLD_NOW);
|
|
}
|
|
if (!handle) return NO;
|
|
_IOHIDEventCreateDigitizerEvent = (IOHIDEventCreateDigitizerEventFn)dlsym(handle, "IOHIDEventCreateDigitizerEvent");
|
|
_IOHIDEventCreateDigitizerFingerEventWithQuality = (IOHIDEventCreateDigitizerFingerEventWithQualityFn)dlsym(handle, "IOHIDEventCreateDigitizerFingerEventWithQuality");
|
|
_IOHIDEventSetIntegerValue = (IOHIDEventSetIntegerValueFn)dlsym(handle, "IOHIDEventSetIntegerValue");
|
|
_IOHIDEventAppendEvent = (IOHIDEventAppendEventFn)dlsym(handle, "IOHIDEventAppendEvent");
|
|
_IOKitLoaded = (_IOHIDEventCreateDigitizerEvent && _IOHIDEventCreateDigitizerFingerEventWithQuality &&
|
|
_IOHIDEventSetIntegerValue && _IOHIDEventAppendEvent);
|
|
return _IOKitLoaded;
|
|
}
|
|
|
|
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) CF_RETURNS_RETAINED;
|
|
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
|
|
if (!DBT_LoadIOKit()) return NULL;
|
|
uint64_t abTime = mach_absolute_time();
|
|
AbsoluteTime timeStamp;
|
|
timeStamp.hi = (UInt32)(abTime >> 32);
|
|
timeStamp.lo = (UInt32)(abTime);
|
|
|
|
IOHIDEventRef handEvent = _IOHIDEventCreateDigitizerEvent(kCFAllocatorDefault,
|
|
timeStamp, kIOHIDDigitizerTransducerTypeHand,
|
|
0, 0, kIOHIDDigitizerEventTouch, 0,
|
|
0, 0, 0, 0, 0,
|
|
0, true, 0);
|
|
_IOHIDEventSetIntegerValue(handEvent, kIOHIDEventFieldDigitizerIsDisplayIntegrated, 1);
|
|
|
|
uint32_t eventMask = (touch.phase == UITouchPhaseMoved)
|
|
? kIOHIDDigitizerEventPosition
|
|
: (kIOHIDDigitizerEventRange | kIOHIDDigitizerEventTouch);
|
|
uint32_t isTouching = (touch.phase == UITouchPhaseEnded) ? 0 : 1;
|
|
|
|
CGPoint loc = [touch locationInView:touch.window];
|
|
|
|
IOHIDEventRef fingerEvent = _IOHIDEventCreateDigitizerFingerEventWithQuality(kCFAllocatorDefault,
|
|
timeStamp, 1, 2, eventMask,
|
|
(IOHIDFloat)loc.x, (IOHIDFloat)loc.y, 0.0,
|
|
0, 0, 5.0, 5.0, 1.0, 1.0, 1.0,
|
|
(IOHIDFloat)isTouching, (IOHIDFloat)isTouching, 0);
|
|
_IOHIDEventSetIntegerValue(fingerEvent, kIOHIDEventFieldDigitizerIsDisplayIntegrated, 1);
|
|
|
|
_IOHIDEventAppendEvent(handEvent, fingerEvent);
|
|
CFRelease(fingerEvent);
|
|
|
|
return handEvent;
|
|
}
|
|
|
|
#pragma mark - Private selectors
|
|
|
|
@interface UITouch ()
|
|
- (void)setWindow:(UIWindow *)window;
|
|
- (void)setView:(UIView *)view;
|
|
- (void)setTapCount:(NSUInteger)tapCount;
|
|
- (void)setTimestamp:(NSTimeInterval)timestamp;
|
|
- (void)setPhase:(UITouchPhase)touchPhase;
|
|
- (void)setGestureView:(UIView *)view;
|
|
- (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)resetPrevious;
|
|
- (void)_setIsFirstTouchForView:(BOOL)firstTouchForView;
|
|
- (void)_setHidEvent:(IOHIDEventRef)event;
|
|
@end
|
|
|
|
@interface UIEvent (DBTPrivate)
|
|
- (void)_clearTouches;
|
|
- (void)_addTouch:(UITouch *)touch forDelayedDelivery:(BOOL)delayed;
|
|
- (void)_setHIDEvent:(IOHIDEventRef)event;
|
|
- (void)_setTimestamp:(NSTimeInterval)timestamp;
|
|
@end
|
|
|
|
@interface UIApplication (DBTPrivate)
|
|
- (UIEvent *)_touchesEvent;
|
|
@end
|
|
|
|
@interface UIView (DBTPrivate)
|
|
- (id)_hitTestWithContext:(id)context;
|
|
@end
|
|
|
|
#pragma mark - SwiftUI-aware hit test (iOS 18+)
|
|
|
|
static UIView *DBT_HitTestView(UIWindow *window, CGPoint point) {
|
|
UIView *fallback = [window hitTest:point withEvent:nil];
|
|
|
|
if (@available(iOS 18.0, *)) {
|
|
Class ctxClass = NSClassFromString(@"_UIHitTestContext");
|
|
SEL ctxSel = NSSelectorFromString(@"contextWithPoint:radius:");
|
|
if (ctxClass && [ctxClass respondsToSelector:ctxSel] &&
|
|
[UIView instancesRespondToSelector:@selector(_hitTestWithContext:)]) {
|
|
id (*sendCtx)(id, SEL, CGPoint, CGFloat) =
|
|
(id (*)(id, SEL, CGPoint, CGFloat))objc_msgSend;
|
|
id ctx = sendCtx(ctxClass, ctxSel, point, 0);
|
|
if (ctx) {
|
|
id found = nil;
|
|
UIView *current = fallback;
|
|
while (found == nil && current != nil) {
|
|
found = [current _hitTestWithContext:ctx];
|
|
current = current.superview;
|
|
}
|
|
if (found && [found isKindOfClass:[UIView class]]) {
|
|
return (UIView *)found;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
#pragma mark - Public API
|
|
|
|
@implementation DebugBridgeTouch
|
|
|
|
+ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window {
|
|
if (!window) return NO;
|
|
|
|
UIView *hit = DBT_HitTestView(window, point);
|
|
if (!hit) return NO;
|
|
|
|
// Build a single synthetic UITouch via private setters. Order matters —
|
|
// setWindow: clears internal state and must come first.
|
|
UITouch *touch = [[UITouch alloc] init];
|
|
[touch setWindow:window];
|
|
[touch setTapCount:1];
|
|
[touch _setLocationInWindow:point resetPrevious:YES];
|
|
[touch setView:hit];
|
|
[touch setPhase:UITouchPhaseBegan];
|
|
if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) {
|
|
[touch _setIsFirstTouchForView:YES];
|
|
}
|
|
[touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]];
|
|
// KIF sets the gestureView too — required for SwiftUI Button gesture
|
|
// recognition. Without this the gesture system sees the touch as
|
|
// unattached and drops it.
|
|
if ([touch respondsToSelector:@selector(setGestureView:)]) {
|
|
[touch setGestureView:hit];
|
|
}
|
|
|
|
// Attach a real IOHIDEvent (required iOS 9+).
|
|
IOHIDEventRef hidEventBegan = DBT_IOHIDEventWithTouch(touch);
|
|
[touch _setHidEvent:hidEventBegan];
|
|
|
|
UIEvent *event = [[UIApplication sharedApplication] _touchesEvent];
|
|
if (!event) {
|
|
CFRelease(hidEventBegan);
|
|
return NO;
|
|
}
|
|
[event _clearTouches];
|
|
[event _setHIDEvent:hidEventBegan];
|
|
[event _addTouch:touch forDelayedDelivery:NO];
|
|
|
|
[[UIApplication sharedApplication] sendEvent:event];
|
|
CFRelease(hidEventBegan);
|
|
|
|
// Ended phase
|
|
[touch setPhase:UITouchPhaseEnded];
|
|
[touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]];
|
|
IOHIDEventRef hidEventEnded = DBT_IOHIDEventWithTouch(touch);
|
|
[touch _setHidEvent:hidEventEnded];
|
|
[event _clearTouches];
|
|
[event _setHIDEvent:hidEventEnded];
|
|
[event _addTouch:touch forDelayedDelivery:NO];
|
|
[[UIApplication sharedApplication] sendEvent:event];
|
|
CFRelease(hidEventEnded);
|
|
|
|
return YES;
|
|
}
|
|
|
|
@end
|