mirror of https://github.com/garrytan/gstack.git
302 lines
11 KiB
Plaintext
302 lines
11 KiB
Plaintext
//
|
|
// 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 <TargetConditionals.h>
|
|
|
|
#if TARGET_OS_IOS
|
|
|
|
#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+)
|
|
|
|
// Returns `id` because iOS 18's _hitTestWithContext: can return either a UIView
|
|
// OR a SwiftUI.UIKitGestureContainer (a plain UIResponder, NOT a UIView).
|
|
// The latter is the case for SwiftUI Buttons. KIF's observation: the returned
|
|
// responder is still compatible with UITouch.setView: even when it isn't a
|
|
// UIView — so we pass it through as-is. Filtering by isKindOfClass:UIView
|
|
// here would drop every SwiftUI Button tap silently. Mirrors KIF PR #1323.
|
|
static id 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) {
|
|
return found;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
#pragma mark - Public API
|
|
|
|
@implementation DebugBridgeTouch
|
|
|
|
+ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window {
|
|
if (!window) return NO;
|
|
|
|
id 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];
|
|
// setView: typed UIView * but accepts SwiftUI.UIKitGestureContainer
|
|
// (UIResponder) too — that's how SwiftUI Buttons get routed on iOS 18+.
|
|
[touch setView:(UIView *)hit];
|
|
[touch setPhase:UITouchPhaseBegan];
|
|
if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) {
|
|
[touch _setIsFirstTouchForView:YES];
|
|
}
|
|
[touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]];
|
|
if ([touch respondsToSelector:@selector(setGestureView:)] &&
|
|
[hit isKindOfClass:[UIView class]]) {
|
|
[touch setGestureView:(UIView *)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
|
|
|
|
#else // !TARGET_OS_IOS
|
|
|
|
// macOS / Catalyst / other non-iOS host build: no-op stub so the module
|
|
// resolves cleanly without UIKit or IOKit. The Swift cross-platform tests
|
|
// don't exercise touch synthesis; that's iOS-only by definition.
|
|
@implementation DebugBridgeTouch
|
|
+ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window {
|
|
(void)point; (void)window;
|
|
return NO;
|
|
}
|
|
@end
|
|
|
|
#endif // TARGET_OS_IOS
|