mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-17 15:20:11 +02:00
feat(ios): hoist DebugBridgeTouch into canonical templates
Bridges.swift.template imports DebugBridgeTouch but no .m/.h template
shipped — consuming apps installing the canonical drop-in would hit a
linker error. Closes that gap with the fixture's verified working code.
Changes:
- New ios-qa/templates/DebugBridgeTouch.{h,m}.template files (carbon
copies of the fixture sources, including the iOS-18+ SwiftUI hit-test
fix verified on iPhone 17 Pro Max).
- Package.swift.template splits into 3 product targets: DebugBridgeCore
(Swift, cross-platform), DebugBridgeUI (Swift, iOS-only), DebugBridgeTouch
(Obj-C, iOS-only). Consuming app adds one dependency on DebugBridgeUI;
Core + Touch come in transitively.
- DebugBridgeTouch sources wrap their body in #if TARGET_OS_IOS so the
cross-platform `swift build` on macOS host doesn't choke on UIKit. On
iOS the real implementation is active; on macOS sendTapAtPoint: is a
no-op returning NO.
- New parity tests pin template ↔ fixture content so future fixture
fixes propagate or fail loudly.
- Restrict swift-build host tests to DebugBridgeCore (the only target
buildable on macOS) and bring up the previously broken XCTest run via
--filter.
Verified post-change: real iPhone 17 Pro Max, iOS 26.5, three /tap
requests against the rebuilt app — counter went 0 → 3, SwiftUI Button
onTap fires every time. Templates now sufficient to ship to any
consuming iOS app.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+21
-4
@@ -842,10 +842,27 @@ fi
|
||||
|
||||
## Phase 2: Bootstrap the device bridge
|
||||
|
||||
1. Add the `DebugBridge` SPM target (Debug-config-only via
|
||||
`.when(configuration: .debug)`).
|
||||
2. Add `DebugBridgeManager.shared.start()` to the app's `@main` entry, gated
|
||||
on `#if DEBUG`.
|
||||
1. Add the `DebugBridge` SPM dependency to the app's `Package.swift`. The package
|
||||
ships three Debug-config-only library products:
|
||||
- `DebugBridgeCore` (Swift, cross-platform) — StateServer + bridge protocols.
|
||||
- `DebugBridgeTouch` (Objective-C, iOS-only) — KIF-derived in-process touch
|
||||
synthesis with iOS 18+ `_UIHitTestContext` SwiftUI hit-testing.
|
||||
- `DebugBridgeUI` (Swift, iOS-only) — Screenshot / Elements / Mutation
|
||||
bridge implementations.
|
||||
The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)`
|
||||
(transitively pulls in Core + Touch). Release builds refuse to link these
|
||||
targets.
|
||||
2. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
|
||||
```swift
|
||||
#if DEBUG
|
||||
import DebugBridgeCore
|
||||
StateServer.shared.start()
|
||||
#if canImport(UIKit)
|
||||
import DebugBridgeUI
|
||||
DebugBridgeUIWiring.installAll()
|
||||
#endif
|
||||
#endif
|
||||
```
|
||||
3. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
|
||||
-destination 'platform=iOS,id=<UDID>' build install`.
|
||||
4. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
|
||||
|
||||
+21
-4
@@ -107,10 +107,27 @@ fi
|
||||
|
||||
## Phase 2: Bootstrap the device bridge
|
||||
|
||||
1. Add the `DebugBridge` SPM target (Debug-config-only via
|
||||
`.when(configuration: .debug)`).
|
||||
2. Add `DebugBridgeManager.shared.start()` to the app's `@main` entry, gated
|
||||
on `#if DEBUG`.
|
||||
1. Add the `DebugBridge` SPM dependency to the app's `Package.swift`. The package
|
||||
ships three Debug-config-only library products:
|
||||
- `DebugBridgeCore` (Swift, cross-platform) — StateServer + bridge protocols.
|
||||
- `DebugBridgeTouch` (Objective-C, iOS-only) — KIF-derived in-process touch
|
||||
synthesis with iOS 18+ `_UIHitTestContext` SwiftUI hit-testing.
|
||||
- `DebugBridgeUI` (Swift, iOS-only) — Screenshot / Elements / Mutation
|
||||
bridge implementations.
|
||||
The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)`
|
||||
(transitively pulls in Core + Touch). Release builds refuse to link these
|
||||
targets.
|
||||
2. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
|
||||
```swift
|
||||
#if DEBUG
|
||||
import DebugBridgeCore
|
||||
StateServer.shared.start()
|
||||
#if canImport(UIKit)
|
||||
import DebugBridgeUI
|
||||
DebugBridgeUIWiring.installAll()
|
||||
#endif
|
||||
#endif
|
||||
```
|
||||
3. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
|
||||
-destination 'platform=iOS,id=<UDID>' build install`.
|
||||
4. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
//
|
||||
// DebugBridgeTouch.h — public Objective-C interface for in-process touch
|
||||
// synthesis. Implementation derived from KIF (https://github.com/kif-framework/KIF),
|
||||
// MIT-licensed. The minimal subset needed to deliver a real UITouch to a
|
||||
// point on the key window, including SwiftUI Buttons via iOS 18+
|
||||
// _UIHitTestContext. DEBUG-only — never link in Release.
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <CoreGraphics/CoreGraphics.h>
|
||||
#import <TargetConditionals.h>
|
||||
|
||||
#if TARGET_OS_IOS
|
||||
#import <UIKit/UIKit.h>
|
||||
#else
|
||||
// macOS build: forward-declare UIWindow so the module compiles without UIKit.
|
||||
// The host CI runs swift build on macOS to validate the cross-platform Swift
|
||||
// surface; DebugBridgeTouch's implementation is a no-op there. On iOS the
|
||||
// real UIWindow comes from UIKit and the implementation is active.
|
||||
@class UIWindow;
|
||||
#endif
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface DebugBridgeTouch : NSObject
|
||||
|
||||
/// Synthesize a single tap (TouchPhaseBegan + TouchPhaseEnded) at the given
|
||||
/// window-coordinate point. Returns YES if the touch was delivered (a hit
|
||||
/// view was found and the event passed through UIApplication.sendEvent).
|
||||
/// On non-iOS platforms returns NO unconditionally.
|
||||
+ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,301 @@
|
||||
//
|
||||
// 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
|
||||
@@ -1,8 +1,19 @@
|
||||
// AUTO-GENERATED from gstack/ios-qa/templates/Package.swift.template
|
||||
//
|
||||
// Drop-in SPM package definition for the DebugBridge target. The structural
|
||||
// Release-build guard is the `.when(configuration: .debug)` conditional on
|
||||
// every target dependency. SwiftPM refuses to link DebugBridge in Release.
|
||||
// Drop-in SPM package definition for the DebugBridge stack. Three targets:
|
||||
//
|
||||
// - DebugBridgeCore Swift, cross-platform (Foundation + Network).
|
||||
// Hosts the StateServer + bridge protocols.
|
||||
// - DebugBridgeTouch Objective-C, iOS-only. KIF-derived in-process touch
|
||||
// synthesis (UITouch + IOHIDEvent + iOS 18
|
||||
// _UIHitTestContext for SwiftUI Buttons).
|
||||
// - DebugBridgeUI Swift, iOS-only. ScreenshotBridge, ElementsBridge,
|
||||
// MutationBridge implementations. Depends on the other
|
||||
// two.
|
||||
//
|
||||
// The structural Release-build guard is the `.when(configuration: .debug)`
|
||||
// conditional on every consuming target's dependency. SwiftPM refuses to link
|
||||
// DebugBridge* in Release config.
|
||||
//
|
||||
// CI invariant: `swift build -c release` + `nm -j build/Release/<binary>
|
||||
// | grep -q DebugBridge && exit 1`.
|
||||
@@ -14,25 +25,43 @@ let package = Package(
|
||||
name: "DebugBridge",
|
||||
platforms: [.iOS(.v16), .macOS(.v13)],
|
||||
products: [
|
||||
.library(name: "DebugBridge", targets: ["DebugBridge"]),
|
||||
.library(name: "DebugBridgeCore", targets: ["DebugBridgeCore"]),
|
||||
.library(name: "DebugBridgeUI", targets: ["DebugBridgeUI"]),
|
||||
.library(name: "DebugBridgeTouch", targets: ["DebugBridgeTouch"]),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "DebugBridge",
|
||||
name: "DebugBridgeCore",
|
||||
dependencies: [],
|
||||
path: "Sources/DebugBridge",
|
||||
path: "Sources/DebugBridgeCore",
|
||||
swiftSettings: [
|
||||
.define("DEBUG", .when(configuration: .debug)),
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "DebugBridgeTouch",
|
||||
dependencies: [],
|
||||
path: "Sources/DebugBridgeTouch",
|
||||
publicHeadersPath: "include",
|
||||
linkerSettings: [
|
||||
// IOKit is loaded dynamically via dlopen at runtime (it's a
|
||||
// private framework on iOS and can't be linked statically).
|
||||
// UIKit links normally.
|
||||
.linkedFramework("UIKit", .when(platforms: [.iOS])),
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "DebugBridgeUI",
|
||||
dependencies: ["DebugBridgeCore", "DebugBridgeTouch"],
|
||||
path: "Sources/DebugBridgeUI",
|
||||
swiftSettings: [
|
||||
// Belt and suspenders. The host app's `.when(configuration: .debug)`
|
||||
// condition on the dependency means we never link in Release. This
|
||||
// setting additionally fails the build with a clear error if a
|
||||
// user somehow forces the build.
|
||||
.define("DEBUG", .when(configuration: .debug)),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "DebugBridgeTests",
|
||||
dependencies: ["DebugBridge"],
|
||||
path: "Tests/DebugBridgeTests"
|
||||
name: "DebugBridgeCoreTests",
|
||||
dependencies: ["DebugBridgeCore"],
|
||||
path: "Tests/DebugBridgeCoreTests"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
// IOHIDEventSetIntegerValue, IOHIDEventAppendEvent.
|
||||
|
||||
#import "DebugBridgeTouch.h"
|
||||
#import <TargetConditionals.h>
|
||||
|
||||
#if TARGET_OS_IOS
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
@@ -281,3 +285,17 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
+13
@@ -5,7 +5,19 @@
|
||||
// point on the key window, including SwiftUI Buttons via iOS 18+
|
||||
// _UIHitTestContext. DEBUG-only — never link in Release.
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <CoreGraphics/CoreGraphics.h>
|
||||
#import <TargetConditionals.h>
|
||||
|
||||
#if TARGET_OS_IOS
|
||||
#import <UIKit/UIKit.h>
|
||||
#else
|
||||
// macOS build: forward-declare UIWindow so the module compiles without UIKit.
|
||||
// The host CI runs swift build on macOS to validate the cross-platform Swift
|
||||
// surface; DebugBridgeTouch's implementation is a no-op there. On iOS the
|
||||
// real UIWindow comes from UIKit and the implementation is active.
|
||||
@class UIWindow;
|
||||
#endif
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@@ -14,6 +26,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
/// Synthesize a single tap (TouchPhaseBegan + TouchPhaseEnded) at the given
|
||||
/// window-coordinate point. Returns YES if the touch was delivered (a hit
|
||||
/// view was found and the event passed through UIApplication.sendEvent).
|
||||
/// On non-iOS platforms returns NO unconditionally.
|
||||
+ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window;
|
||||
|
||||
@end
|
||||
|
||||
@@ -19,11 +19,47 @@
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const FIXTURE_PATH = join(ROOT, 'test/fixtures/ios-qa/FixtureApp');
|
||||
const TEMPLATES_PATH = join(ROOT, 'ios-qa/templates');
|
||||
|
||||
// Parity: canonical Obj-C touch templates must match the fixture's working
|
||||
// copy. The fixture is the only place the .m / .h are exercised end-to-end
|
||||
// on a real device, so any divergence means consuming apps would ship a
|
||||
// stale, untested version of the SwiftUI hit-test fix.
|
||||
describe('template ↔ fixture parity', () => {
|
||||
test('DebugBridgeTouch.h.template matches fixture include', () => {
|
||||
const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.h.template'), 'utf-8');
|
||||
const fixture = readFileSync(
|
||||
join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(tmpl).toBe(fixture);
|
||||
});
|
||||
|
||||
test('DebugBridgeTouch.m.template matches fixture .m', () => {
|
||||
const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8');
|
||||
const fixture = readFileSync(
|
||||
join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(tmpl).toBe(fixture);
|
||||
});
|
||||
|
||||
test('Package.swift.template declares all 3 DebugBridge targets', () => {
|
||||
const tmpl = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8');
|
||||
// Each target must be present as a library product AND a target definition.
|
||||
for (const name of ['DebugBridgeCore', 'DebugBridgeUI', 'DebugBridgeTouch']) {
|
||||
expect(tmpl).toContain(`name: "${name}"`);
|
||||
}
|
||||
// DebugBridgeUI must depend on the other two; that's how the consuming
|
||||
// app gets the transitive set with one dependency entry.
|
||||
expect(tmpl).toMatch(/name:\s*"DebugBridgeUI"[\s\S]*?dependencies:\s*\["DebugBridgeCore",\s*"DebugBridgeTouch"\]/);
|
||||
});
|
||||
});
|
||||
|
||||
function hasSwift(): boolean {
|
||||
const r = spawnSync('swift', ['--version'], { stdio: 'pipe' });
|
||||
@@ -34,8 +70,13 @@ const swiftAvailable = hasSwift();
|
||||
const describeIfSwift = swiftAvailable ? describe : describe.skip;
|
||||
|
||||
describeIfSwift('swift build invariants', () => {
|
||||
test('Debug-config build succeeds', () => {
|
||||
const r = spawnSync('swift', ['build', '-c', 'debug'], {
|
||||
// DebugBridgeUI + DebugBridgeTouch are iOS-only (they link UIKit). Plain
|
||||
// `swift build` on macOS host can't resolve UIKit, so we scope these
|
||||
// invariants to DebugBridgeCore (Swift, cross-platform) + its XCTest
|
||||
// target. The iOS-only targets are covered by xcodebuild on the device
|
||||
// path (test/skill-e2e-ios-device.test.ts).
|
||||
test('Debug-config build succeeds (DebugBridgeCore)', () => {
|
||||
const r = spawnSync('swift', ['build', '-c', 'debug', '--target', 'DebugBridgeCore'], {
|
||||
cwd: FIXTURE_PATH,
|
||||
stdio: 'pipe',
|
||||
timeout: 120_000,
|
||||
@@ -47,7 +88,7 @@ describeIfSwift('swift build invariants', () => {
|
||||
}, 180_000);
|
||||
|
||||
test('XCTest suite for StateServer passes (validates real Swift impl)', () => {
|
||||
const r = spawnSync('swift', ['test'], {
|
||||
const r = spawnSync('swift', ['test', '--filter', 'DebugBridgeCoreTests'], {
|
||||
cwd: FIXTURE_PATH,
|
||||
stdio: 'pipe',
|
||||
timeout: 180_000,
|
||||
@@ -59,18 +100,23 @@ describeIfSwift('swift build invariants', () => {
|
||||
console.error('swift test failure:', combined.slice(-4000));
|
||||
}
|
||||
expect(r.status).toBe(0);
|
||||
expect(combined).toContain("'All tests' passed");
|
||||
// --filter scopes the run to DebugBridgeCoreTests; the xctest summary
|
||||
// line is "'Selected tests' passed" rather than "'All tests' passed".
|
||||
expect(combined).toMatch(/'(?:All|Selected) tests' passed/);
|
||||
// Guard against an empty pass-by-no-tests (filter typo / target rename):
|
||||
// we expect at least one StateServer smoke test to actually execute.
|
||||
expect(combined).toContain('StateServerSmokeTests');
|
||||
}, 240_000);
|
||||
|
||||
// Codex-flagged: Release-build guard must be STRUCTURAL, not advisory.
|
||||
// The Package.swift's `.when(configuration: .debug)` setting causes Swift
|
||||
// to compile-out the entire DebugBridge target body in Release. Since
|
||||
// to compile-out the entire DebugBridgeCore target body in Release. Since
|
||||
// every public symbol is gated `#if DEBUG`, the release build emits an
|
||||
// empty module — zero symbols.
|
||||
test('Release-config build excludes DebugBridge symbols', () => {
|
||||
// Step 1: clean + release build
|
||||
// Step 1: clean + release build (Core only — UI/Touch can't build on macOS)
|
||||
spawnSync('swift', ['package', 'clean'], { cwd: FIXTURE_PATH, stdio: 'pipe', timeout: 60_000 });
|
||||
const build = spawnSync('swift', ['build', '-c', 'release'], {
|
||||
const build = spawnSync('swift', ['build', '-c', 'release', '--target', 'DebugBridgeCore'], {
|
||||
cwd: FIXTURE_PATH,
|
||||
stdio: 'pipe',
|
||||
timeout: 180_000,
|
||||
|
||||
Reference in New Issue
Block a user