@reactiive/ennio 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/EnnioCore.podspec +61 -0
  2. package/LICENSE +21 -0
  3. package/README.md +50 -0
  4. package/android/CMakeLists.txt +40 -0
  5. package/android/build.gradle +64 -0
  6. package/cpp/ElementMatcher.cpp +661 -0
  7. package/cpp/ElementMatcher.hpp +244 -0
  8. package/cpp/EnnioLog.hpp +182 -0
  9. package/cpp/HybridEnnio.cpp +1161 -0
  10. package/cpp/HybridEnnio.hpp +174 -0
  11. package/cpp/IdleMonitor.hpp +277 -0
  12. package/cpp/Protocol.cpp +135 -0
  13. package/cpp/Protocol.hpp +47 -0
  14. package/cpp/SelectorCriteria.hpp +281 -0
  15. package/cpp/SelectorParser.cpp +649 -0
  16. package/cpp/SelectorParser.hpp +94 -0
  17. package/cpp/ShadowTreeTraverser.cpp +305 -0
  18. package/cpp/ShadowTreeTraverser.hpp +142 -0
  19. package/cpp/TestIDRegistry.cpp +109 -0
  20. package/cpp/TestIDRegistry.hpp +84 -0
  21. package/dist/cli.js +16221 -0
  22. package/ios/EnnioAutoInit.mm +338 -0
  23. package/ios/EnnioDebugBanner.h +19 -0
  24. package/ios/EnnioDebugBanner.mm +178 -0
  25. package/ios/EnnioRuntimeHelper.h +264 -0
  26. package/ios/EnnioRuntimeHelper.mm +2443 -0
  27. package/lib/Ennio.nitro.d.ts +263 -0
  28. package/lib/Ennio.nitro.d.ts.map +1 -0
  29. package/lib/Ennio.nitro.js +2 -0
  30. package/lib/Ennio.nitro.js.map +1 -0
  31. package/lib/index.d.ts +16 -0
  32. package/lib/index.d.ts.map +1 -0
  33. package/lib/index.js +45 -0
  34. package/lib/index.js.map +1 -0
  35. package/nitro.json +24 -0
  36. package/nitrogen/generated/.gitattributes +1 -0
  37. package/nitrogen/generated/android/EnnioCore+autolinking.cmake +81 -0
  38. package/nitrogen/generated/android/EnnioCore+autolinking.gradle +27 -0
  39. package/nitrogen/generated/android/EnnioCoreOnLoad.cpp +49 -0
  40. package/nitrogen/generated/android/EnnioCoreOnLoad.hpp +34 -0
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/ennio/EnnioCoreOnLoad.kt +35 -0
  42. package/nitrogen/generated/ios/EnnioCore+autolinking.rb +62 -0
  43. package/nitrogen/generated/ios/EnnioCore-Swift-Cxx-Bridge.cpp +17 -0
  44. package/nitrogen/generated/ios/EnnioCore-Swift-Cxx-Bridge.hpp +27 -0
  45. package/nitrogen/generated/ios/EnnioCore-Swift-Cxx-Umbrella.hpp +38 -0
  46. package/nitrogen/generated/ios/EnnioCoreAutolinking.mm +35 -0
  47. package/nitrogen/generated/ios/EnnioCoreAutolinking.swift +16 -0
  48. package/nitrogen/generated/shared/c++/ExtendedElementInfo.hpp +118 -0
  49. package/nitrogen/generated/shared/c++/HybridEnnioSpec.cpp +44 -0
  50. package/nitrogen/generated/shared/c++/HybridEnnioSpec.hpp +93 -0
  51. package/nitrogen/generated/shared/c++/LayoutMetrics.hpp +103 -0
  52. package/nitrogen/generated/shared/c++/ScrollDirection.hpp +84 -0
  53. package/package.json +78 -0
  54. package/react-native.config.js +14 -0
  55. package/src/Ennio.nitro.ts +363 -0
  56. package/src/cli/hid-daemon.py +129 -0
  57. package/src/index.ts +72 -0
@@ -0,0 +1,2443 @@
1
+ //
2
+ // EnnioRuntimeHelper.mm
3
+ // Objective-C++ implementation for accessing React Native runtime
4
+ //
5
+
6
+ #import "EnnioRuntimeHelper.h"
7
+ #import <React/RCTSurfacePresenter.h>
8
+ #import <React/RCTScheduler.h>
9
+ #import <react/renderer/core/ShadowNode.h>
10
+ #import <UIKit/UIKit.h>
11
+ #import <objc/runtime.h>
12
+ #import <objc/message.h>
13
+ #include <chrono>
14
+ #include <cmath>
15
+ #include <sstream>
16
+ #include <thread>
17
+
18
+ // Timeout for main thread dispatch (5 seconds)
19
+ static const int64_t MAIN_THREAD_TIMEOUT_NS = 5 * NSEC_PER_SEC;
20
+
21
+ // Dispatch `block` to the main thread, wait up to MAIN_THREAD_TIMEOUT_NS.
22
+ // Inline-runs on the main thread to avoid deadlock if the caller is
23
+ // already there.
24
+ //
25
+ // Memory: the semaphore is dispatch_object_t under ARC — the async block
26
+ // captures it strongly, so it stays alive until the block signals (even
27
+ // if we time out and return). No explicit dispatch_release needed.
28
+ static BOOL dispatchSyncMainWithTimeout(void (^block)(void)) {
29
+ if ([NSThread isMainThread]) {
30
+ block();
31
+ return YES;
32
+ }
33
+ dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
34
+ dispatch_async(dispatch_get_main_queue(), ^{
35
+ block();
36
+ dispatch_semaphore_signal(semaphore);
37
+ });
38
+ long result = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, MAIN_THREAD_TIMEOUT_NS));
39
+ if (result != 0) {
40
+ NSLog(@"[Ennio] WARNING: Main thread dispatch timed out after 5 seconds");
41
+ return NO;
42
+ }
43
+ return YES;
44
+ }
45
+
46
+ namespace ennio {
47
+
48
+ EnnioRuntimeHelper& EnnioRuntimeHelper::getInstance() {
49
+ static EnnioRuntimeHelper instance;
50
+ return instance;
51
+ }
52
+
53
+ void EnnioRuntimeHelper::setSurfacePresenter(void* surfacePresenter) {
54
+ surfacePresenter_ = surfacePresenter;
55
+ NSLog(@"[Ennio] EnnioRuntimeHelper::setSurfacePresenter called with %p", surfacePresenter);
56
+ }
57
+
58
+ // Helper to find surface presenter by looking through runtime objects
59
+ // Read-only `[obj performSelector:NSSelectorFromString(name)]` with the
60
+ // ARC leak warning quieted. All call sites here are getters; ARC's
61
+ // retain-leak heuristic is overcautious for these.
62
+ static id performGetter(id obj, NSString* name) {
63
+ SEL sel = NSSelectorFromString(name);
64
+ if (![obj respondsToSelector:sel]) return nil;
65
+ #pragma clang diagnostic push
66
+ #pragma clang diagnostic ignored "-Warc-performSelector-leaks"
67
+ id result = [obj performSelector:sel];
68
+ #pragma clang diagnostic pop
69
+ return result;
70
+ }
71
+
72
+ // Newer Expo / RN: AppDelegate exposes reactNativeFactory whose reactHost
73
+ // (or the factory itself in older Expo) carries the surfacePresenter.
74
+ static RCTSurfacePresenter* presenterViaAppDelegate() {
75
+ id appDelegate = [UIApplication sharedApplication].delegate;
76
+ NSLog(@"[Ennio] AppDelegate class: %@", NSStringFromClass([appDelegate class]));
77
+ id factory = performGetter(appDelegate, @"reactNativeFactory");
78
+ if (!factory) return nil;
79
+ NSLog(@"[Ennio] Found reactNativeFactory: %@", NSStringFromClass([factory class]));
80
+
81
+ id host = performGetter(factory, @"reactHost");
82
+ if (host) {
83
+ NSLog(@"[Ennio] Found reactHost: %@", NSStringFromClass([host class]));
84
+ id presenter = performGetter(host, @"surfacePresenter");
85
+ if (presenter) {
86
+ NSLog(@"[Ennio] Found surfacePresenter via host: %@", presenter);
87
+ return (__bridge RCTSurfacePresenter *)(__bridge void *)presenter;
88
+ }
89
+ }
90
+ id presenter = performGetter(factory, @"surfacePresenter");
91
+ if (presenter) {
92
+ NSLog(@"[Ennio] Found surfacePresenter on factory: %@", presenter);
93
+ return (__bridge RCTSurfacePresenter *)(__bridge void *)presenter;
94
+ }
95
+
96
+ // Diagnostic: print factory's method list so a future RN/Expo bump
97
+ // doesn't leave us guessing which selector was renamed.
98
+ NSLog(@"[Ennio] Factory methods:");
99
+ unsigned int count;
100
+ Method* methods = class_copyMethodList([factory class], &count);
101
+ for (unsigned int i = 0; i < count && i < 20; i++) {
102
+ NSLog(@"[Ennio] - %@", NSStringFromSelector(method_getName(methods[i])));
103
+ }
104
+ free(methods);
105
+ return nil;
106
+ }
107
+
108
+ // Fallback: scan every window for an RCTSurface*View and pull the
109
+ // presenter via its surface. Catches setups where the AppDelegate path
110
+ // is unavailable (third-party RN host, older Expo, naked RN).
111
+ static RCTSurfacePresenter* presenterViaWindowScan() {
112
+ NSLog(@"[Ennio] Searching windows for RCTSurfaceHostingView...");
113
+ for (UIScene* scene in [[UIApplication sharedApplication] connectedScenes]) {
114
+ if (![scene isKindOfClass:[UIWindowScene class]]) continue;
115
+ for (UIWindow* window in ((UIWindowScene*)scene).windows) {
116
+ UIViewController* rootVC = window.rootViewController;
117
+ if (!rootVC || !rootVC.view) continue;
118
+ for (UIView* subview in rootVC.view.subviews) {
119
+ NSString* className = NSStringFromClass([subview class]);
120
+ NSLog(@"[Ennio] Found view: %@", className);
121
+ if (![className containsString:@"RCTSurface"] && ![className containsString:@"RCTRoot"]) continue;
122
+ id surface = performGetter(subview, @"surface");
123
+ if (!surface) continue;
124
+ id presenter = performGetter(surface, @"surfacePresenter");
125
+ if (!presenter) continue;
126
+ NSLog(@"[Ennio] Found surfacePresenter via view: %@", presenter);
127
+ return (__bridge RCTSurfacePresenter *)(__bridge void *)presenter;
128
+ }
129
+ }
130
+ }
131
+ return nil;
132
+ }
133
+
134
+ static RCTSurfacePresenter* findSurfacePresenterInRuntime() {
135
+ NSLog(@"[Ennio] Searching for surface presenter...");
136
+ if (RCTSurfacePresenter* p = presenterViaAppDelegate()) return p;
137
+ if (RCTSurfacePresenter* p = presenterViaWindowScan()) return p;
138
+ NSLog(@"[Ennio] Could not find surface presenter");
139
+ return nil;
140
+ }
141
+
142
+ std::shared_ptr<facebook::react::UIManager> EnnioRuntimeHelper::getUIManager() {
143
+ NSLog(@"[Ennio] EnnioRuntimeHelper::getUIManager called, cached=%p", surfacePresenter_);
144
+
145
+ __block std::shared_ptr<facebook::react::UIManager> result = nullptr;
146
+
147
+ void (^getUIManagerBlock)(void) = ^{
148
+ @try {
149
+ RCTSurfacePresenter* presenter = nil;
150
+
151
+ // Probe cached presenter, but verify it's still alive. After
152
+ // launchApp:clearState the app process restarts but our singleton
153
+ // can still hold a void* to the previous presenter; touching that
154
+ // dangling pointer is a SIGSEGV in objc_retain. Re-find from
155
+ // runtime if the cached one looks dead.
156
+ if (surfacePresenter_) {
157
+ @try {
158
+ presenter = (__bridge RCTSurfacePresenter*)surfacePresenter_;
159
+ // Force a method dispatch to surface deallocation as a
160
+ // catchable exception rather than a SIGSEGV.
161
+ if (![presenter respondsToSelector:@selector(scheduler)]) {
162
+ presenter = nil;
163
+ surfacePresenter_ = nullptr;
164
+ }
165
+ } @catch (...) {
166
+ presenter = nil;
167
+ surfacePresenter_ = nullptr;
168
+ }
169
+ if (presenter) {
170
+ NSLog(@"[Ennio] Using cached surfacePresenter: %@", presenter);
171
+ }
172
+ }
173
+
174
+ // If no cached (or cached looked stale), search runtime fresh.
175
+ if (!presenter) {
176
+ presenter = findSurfacePresenterInRuntime();
177
+ if (presenter) {
178
+ surfacePresenter_ = (__bridge void*)presenter;
179
+ }
180
+ }
181
+
182
+ if (!presenter) {
183
+ NSLog(@"[Ennio] EnnioRuntimeHelper::getUIManager: Could not find surface presenter");
184
+ return;
185
+ }
186
+
187
+ RCTScheduler* scheduler = [presenter scheduler];
188
+ if (!scheduler) {
189
+ NSLog(@"[Ennio] EnnioRuntimeHelper::getUIManager: scheduler is null");
190
+ return;
191
+ }
192
+
193
+ NSLog(@"[Ennio] EnnioRuntimeHelper::getUIManager: scheduler=%@", scheduler);
194
+
195
+ result = [scheduler uiManager];
196
+ NSLog(@"[Ennio] EnnioRuntimeHelper::getUIManager: uiManager=%s", result ? "valid" : "null");
197
+ } @catch (NSException *exception) {
198
+ NSLog(@"[Ennio] EnnioRuntimeHelper::getUIManager: Exception: %@", exception);
199
+ }
200
+ };
201
+
202
+ // Must access surface presenter and scheduler from main thread
203
+ if ([NSThread isMainThread]) {
204
+ getUIManagerBlock();
205
+ } else {
206
+ dispatchSyncMainWithTimeout(getUIManagerBlock);
207
+ }
208
+
209
+ return result;
210
+ }
211
+
212
+ std::shared_ptr<const facebook::react::ShadowNode> EnnioRuntimeHelper::getShadowTreeRoot() {
213
+ NSLog(@"[Ennio] EnnioRuntimeHelper::getShadowTreeRoot called, surfacePresenter_=%p", surfacePresenter_);
214
+
215
+ auto uiManager = getUIManager();
216
+ if (!uiManager) {
217
+ NSLog(@"[Ennio] EnnioRuntimeHelper::getShadowTreeRoot: UIManager is null");
218
+ return nullptr;
219
+ }
220
+
221
+ NSLog(@"[Ennio] EnnioRuntimeHelper::getShadowTreeRoot: UIManager available");
222
+
223
+ // Use a pointer wrapper to capture in lambda (since __block doesn't work with lambdas)
224
+ auto rootNodePtr = std::make_shared<std::shared_ptr<const facebook::react::ShadowNode>>(nullptr);
225
+
226
+ void (^getRootBlock)(void) = ^{
227
+ // Get the shadow tree registry and enumerate to find the first surface
228
+ auto& shadowTreeRegistry = uiManager->getShadowTreeRegistry();
229
+ int surfaceCount = 0;
230
+
231
+ shadowTreeRegistry.enumerate([&surfaceCount, rootNodePtr](const facebook::react::ShadowTree& shadowTree, bool& stop) {
232
+ surfaceCount++;
233
+ // Get the root from the first surface we find
234
+ *rootNodePtr = shadowTree.getCurrentRevision().rootShadowNode;
235
+ NSLog(@"[Ennio] EnnioRuntimeHelper::getShadowTreeRoot: Found surface %d", surfaceCount);
236
+ stop = true;
237
+ });
238
+
239
+ NSLog(@"[Ennio] EnnioRuntimeHelper::getShadowTreeRoot: Total surfaces=%d, rootNode=%s",
240
+ surfaceCount, *rootNodePtr ? "valid" : "null");
241
+ };
242
+
243
+ // Shadow tree access should happen on main thread for safety
244
+ if ([NSThread isMainThread]) {
245
+ getRootBlock();
246
+ } else {
247
+ dispatchSyncMainWithTimeout(getRootBlock);
248
+ }
249
+
250
+ return *rootNodePtr;
251
+ }
252
+
253
+ bool EnnioRuntimeHelper::isInitialized() const {
254
+ return surfacePresenter_ != nullptr;
255
+ }
256
+
257
+ // ============================================
258
+ // Alert/Modal Handling
259
+ // ============================================
260
+
261
+ static UIAlertController* findPresentedAlertController() {
262
+ // Walk every connected window. UIAlertController on iOS 13+ is hosted
263
+ // on its own UIWindowLevelAlert window — not on the app's key window —
264
+ // so a key-window-only lookup misses it.
265
+ for (UIScene* scene in [[UIApplication sharedApplication] connectedScenes]) {
266
+ if (![scene isKindOfClass:[UIWindowScene class]]) continue;
267
+ for (UIWindow* window in [((UIWindowScene*)scene).windows reverseObjectEnumerator]) {
268
+ UIViewController* currentVC = window.rootViewController;
269
+ while (currentVC) {
270
+ if ([currentVC isKindOfClass:[UIAlertController class]]) {
271
+ return (UIAlertController*)currentVC;
272
+ }
273
+ currentVC = currentVC.presentedViewController;
274
+ }
275
+ }
276
+ }
277
+ return nil;
278
+ }
279
+
280
+ bool EnnioRuntimeHelper::isAlertPresent() {
281
+ __block bool result = false;
282
+
283
+ void (^block)(void) = ^{
284
+ result = (findPresentedAlertController() != nil);
285
+ NSLog(@"[Ennio] isAlertPresent: %@", result ? @"YES" : @"NO");
286
+ };
287
+
288
+ if ([NSThread isMainThread]) {
289
+ block();
290
+ } else {
291
+ dispatchSyncMainWithTimeout(block);
292
+ }
293
+
294
+ return result;
295
+ }
296
+
297
+ std::string EnnioRuntimeHelper::getAlertText() {
298
+ __block std::string result;
299
+
300
+ void (^block)(void) = ^{
301
+ UIAlertController* alert = findPresentedAlertController();
302
+ if (alert) {
303
+ NSMutableString* text = [NSMutableString string];
304
+ if (alert.title) {
305
+ [text appendString:alert.title];
306
+ }
307
+ if (alert.message) {
308
+ if (text.length > 0) {
309
+ [text appendString:@"\n"];
310
+ }
311
+ [text appendString:alert.message];
312
+ }
313
+ result = [text UTF8String];
314
+ NSLog(@"[Ennio] getAlertText: %@", text);
315
+ } else {
316
+ NSLog(@"[Ennio] getAlertText: No alert found");
317
+ }
318
+ };
319
+
320
+ if ([NSThread isMainThread]) {
321
+ block();
322
+ } else {
323
+ dispatchSyncMainWithTimeout(block);
324
+ }
325
+
326
+ return result;
327
+ }
328
+
329
+ std::vector<std::string> EnnioRuntimeHelper::getAlertButtons() {
330
+ __block std::vector<std::string> result;
331
+
332
+ void (^block)(void) = ^{
333
+ UIAlertController* alert = findPresentedAlertController();
334
+ if (alert) {
335
+ for (UIAlertAction* action in alert.actions) {
336
+ if (action.title) {
337
+ result.push_back([action.title UTF8String]);
338
+ NSLog(@"[Ennio] getAlertButtons: Found button '%@'", action.title);
339
+ }
340
+ }
341
+ } else {
342
+ NSLog(@"[Ennio] getAlertButtons: No alert found");
343
+ }
344
+ };
345
+
346
+ if ([NSThread isMainThread]) {
347
+ block();
348
+ } else {
349
+ dispatchSyncMainWithTimeout(block);
350
+ }
351
+
352
+ return result;
353
+ }
354
+
355
+ } // namespace ennio
356
+
357
+ // ============================================
358
+ // Fast-mode write helpers (file-local)
359
+ // ============================================
360
+
361
+ // Private UIKit shims so we can build a fake UITouch and feed it through
362
+ // UIApplication's standard event pipeline. RN Fabric's
363
+ // RCTSurfaceTouchHandler watches sendEvent: and routes the touch through
364
+ // its responder system, which is the JS-side path Pressable hangs off.
365
+ @interface UITouch (EnnioPrivate)
366
+ - (void)_setLocationInWindow:(CGPoint)point resetPrevious:(BOOL)reset;
367
+ - (void)_setIsFirstTouchForView:(BOOL)isFirst;
368
+ @end
369
+
370
+ @interface UIEvent (EnnioPrivate)
371
+ - (void)_addTouch:(UITouch*)touch forDelayedDelivery:(BOOL)delayed;
372
+ - (void)_clearTouches;
373
+ @end
374
+
375
+ @interface UIApplication (EnnioPrivate)
376
+ - (UIEvent*)_touchesEvent;
377
+ @end
378
+
379
+ // Synthesize a Began -> Ended touch sequence at a view's centre. Returns
380
+ // YES if the simulated event was actually delivered. RN Pressable's
381
+ // gesture-progress timer expects a small gap between PressIn and
382
+ // PressOut; sending both within the same runloop tick can leave the
383
+ // touch in an indeterminate state, so we wait one runloop iteration
384
+ // and reset the timestamp on the End phase.
385
+ /**
386
+ * Synthesise a UITouch (Began + Ended) at an absolute window-coordinate
387
+ * point. UIKit's hit-test routes the touch through the responder chain,
388
+ * which fires Pressable / UIControl / accessibilityActivate handlers
389
+ * regardless of which view owns the gesture. ~5 ms in-process, no HID,
390
+ * no out-of-process driver.
391
+ *
392
+ * Strategy is layered: UIControl chain → tap GR walk-up → RN Gesture
393
+ * Handler direct dispatch → bare sendEvent fallback. Each layer is its
394
+ * own static helper below; `synthesizeTouchAtPoint` is just the wiring.
395
+ */
396
+ static BOOL invokeTapGestureRecognizers(UIView* view);
397
+
398
+ static UIWindow* findKeyWindow(void) {
399
+ UIApplication* app = [UIApplication sharedApplication];
400
+ for (UIScene* scene in app.connectedScenes) {
401
+ if (![scene isKindOfClass:[UIWindowScene class]]) continue;
402
+ for (UIWindow* w in ((UIWindowScene*)scene).windows) {
403
+ if (w.isKeyWindow) return w;
404
+ }
405
+ }
406
+ for (UIScene* scene in app.connectedScenes) {
407
+ if (![scene isKindOfClass:[UIWindowScene class]]) continue;
408
+ UIWindowScene* ws = (UIWindowScene*)scene;
409
+ if (ws.windows.count > 0) return ws.windows.firstObject;
410
+ }
411
+ return nil;
412
+ }
413
+
414
+ // RN Pressable's touch processor inspects touch.view to decide which
415
+ // shadow node owns the gesture. Hit-test can return an inner Text /
416
+ // Image leaf with no React responder; walk to the nearest RCT* ancestor
417
+ // so the touch is attributed to the wrapper React rendered.
418
+ static UIView* walkToReactView(UIView* hit) {
419
+ UIView* cursor = hit;
420
+ while (cursor && ![NSStringFromClass([cursor class]) hasPrefix:@"RCT"]) {
421
+ cursor = cursor.superview;
422
+ }
423
+ return cursor ?: hit;
424
+ }
425
+
426
+ // UIControl subclasses (UIButton, RNGestureHandlerButton bound to
427
+ // RNNativeViewGestureHandler) wire `onPress` to a UIControlEvent action
428
+ // chain. RNGH's BaseButton.onPress only fires when JS sees
429
+ // `oldState===Active && state===End`, so plain TouchUpInside is dropped
430
+ // as End-without-Active — fire TouchDown first. UIButton ignores the
431
+ // extra TouchDown so this is a no-op cost there.
432
+ static BOOL tryUIControlChain(UIView* hit) {
433
+ for (UIView* cursor = hit; cursor != nil; cursor = cursor.superview) {
434
+ if (![cursor isKindOfClass:[UIControl class]]) continue;
435
+ UIControl* ctrl = (UIControl*)cursor;
436
+ if (!ctrl.enabled) continue;
437
+ BOOL hasAction = NO;
438
+ for (id t in ctrl.allTargets) {
439
+ if ([ctrl actionsForTarget:t forControlEvent:UIControlEventTouchUpInside].count > 0) {
440
+ hasAction = YES;
441
+ break;
442
+ }
443
+ }
444
+ if (!hasAction) continue;
445
+ [ctrl sendActionsForControlEvents:UIControlEventTouchDown];
446
+ [ctrl sendActionsForControlEvents:UIControlEventTouchUpInside];
447
+ return YES;
448
+ }
449
+ return NO;
450
+ }
451
+
452
+ // Pressable / TouchableOpacity / RNGH attach a UITapGestureRecognizer to
453
+ // a wrapper view higher up. Synthesised UITouches reach sendEvent, but
454
+ // the GR system doesn't always engage on private-API touches the way it
455
+ // does on real HID — walk up invoking any tap-class GR's target directly.
456
+ static BOOL tryAncestorTapGestures(UIView* hit) {
457
+ for (UIView* cursor = hit; cursor != nil; cursor = cursor.superview) {
458
+ if (cursor.gestureRecognizers.count > 0) {
459
+ NSMutableString* dump = [NSMutableString string];
460
+ for (UIGestureRecognizer* gr in cursor.gestureRecognizers) {
461
+ [dump appendFormat:@"%@(%ld) ", NSStringFromClass([gr class]), (long)gr.state];
462
+ }
463
+ NSLog(@"[Ennio] tryAncestorTapGestures view=%@ recognizers=[%@]",
464
+ NSStringFromClass([cursor class]), dump);
465
+ }
466
+ if (invokeTapGestureRecognizers(cursor)) return YES;
467
+ }
468
+ return NO;
469
+ }
470
+
471
+ static UITouch* makeSynthTouchAtPoint(CGPoint locationInWindow, UIWindow* window, UIView* targetView) {
472
+ UITouch* touch = [[UITouch alloc] init];
473
+ // Each KVC group is wrapped: a future iOS could rename or remove any
474
+ // of these private keys, and a bare setValue:forKey: throws an
475
+ // NSException that escapes silently from the only outer @try (the
476
+ // sendEvent path much further down). One log per missed key beats a
477
+ // silent broken tap.
478
+ @try {
479
+ if ([touch respondsToSelector:@selector(_setLocationInWindow:resetPrevious:)]) {
480
+ [touch _setLocationInWindow:locationInWindow resetPrevious:NO];
481
+ } else {
482
+ [touch setValue:[NSValue valueWithCGPoint:locationInWindow] forKey:@"locationInWindow"];
483
+ }
484
+ } @catch (NSException* e) {
485
+ NSLog(@"[Ennio] makeSynthTouch: failed to set location: %@", e.reason);
486
+ }
487
+ @try {
488
+ [touch setValue:@(UITouchPhaseBegan) forKey:@"phase"];
489
+ [touch setValue:window forKey:@"window"];
490
+ [touch setValue:targetView forKey:@"view"];
491
+ [touch setValue:@(1) forKey:@"tapCount"];
492
+ [touch setValue:@([[NSProcessInfo processInfo] systemUptime]) forKey:@"timestamp"];
493
+ } @catch (NSException* e) {
494
+ NSLog(@"[Ennio] makeSynthTouch: failed to set core fields: %@", e.reason);
495
+ }
496
+ return touch;
497
+ }
498
+
499
+ // RNDummyGestureRecognizer (NativeViewGestureHandler — pressto's
500
+ // PressableScale, RNGH RawButton, BaseButton on non-UIControl views)
501
+ // only fires `onPress` when its touchesBegan:/touchesEnded: overrides
502
+ // run with a real touch. UIKit's GR pipeline doesn't always deliver
503
+ // synthesised touches to the overrides — forward via the public
504
+ // UIGestureRecognizerSubclass entry points. Class-name prefix detection
505
+ // avoids coupling to RNGH headers from inside Ennio's pod target.
506
+ static BOOL tryRNGestureHandlerDirect(UIView* hit, UIWindow* window, UIEvent* event, CGPoint locationInWindow) {
507
+ UITouch* touch = makeSynthTouchAtPoint(locationInWindow, window, hit);
508
+ NSSet<UITouch*>* touchSet = [NSSet setWithObject:touch];
509
+ NSTimeInterval beganAt = [[touch valueForKey:@"timestamp"] doubleValue];
510
+ for (UIView* cursor = hit; cursor != nil; cursor = cursor.superview) {
511
+ for (UIGestureRecognizer* gr in cursor.gestureRecognizers) {
512
+ if (!gr.enabled) continue;
513
+ NSString* clsName = NSStringFromClass([gr class]);
514
+ // RNGH 2.x recognizer prefixes:
515
+ // RNDummyGestureRecognizer / RNNativeViewGestureRecognizer — wraps a base RN view.
516
+ // RN<Type>GestureHandler — direct Gesture.Tap() / Gesture.Pan() etc.
517
+ // RNGestureHandlerButton — RNGH's Button wrapper.
518
+ // Widened from {RNDummy, RNNative} so Gesture.Tap() on a bare
519
+ // View (no Pressable wrapper) routes through this path.
520
+ if (![clsName hasPrefix:@"RN"]) continue;
521
+ @try {
522
+ if ([gr respondsToSelector:@selector(touchesBegan:withEvent:)]) {
523
+ [gr touchesBegan:touchSet withEvent:event];
524
+ }
525
+ [touch setValue:@(UITouchPhaseEnded) forKey:@"phase"];
526
+ [touch setValue:@(beganAt + 0.030) forKey:@"timestamp"];
527
+ if ([gr respondsToSelector:@selector(touchesEnded:withEvent:)]) {
528
+ [gr touchesEnded:touchSet withEvent:event];
529
+ }
530
+ return YES;
531
+ } @catch (NSException* e) {
532
+ NSLog(@"[Ennio] forward to %@: %@", clsName, e.reason);
533
+ }
534
+ }
535
+ }
536
+ return NO;
537
+ }
538
+
539
+ // Bare Began → 30 ms runloop tick → Ended via UIApplication sendEvent.
540
+ // 30 ms is the minimum gap RN's touch handler needs to register Began
541
+ // before Ended arrives — shorter gets flagged as touchCancelled, longer
542
+ // runs unrelated timers. End uses a fresh UIEvent because reusing
543
+ // Began's event is hash-deduped by RN's iOS 26 touch handler.
544
+ static BOOL sendSynthUITouchSequence(UIView* targetView, UIWindow* window, CGPoint locationInWindow) {
545
+ UIApplication* app = [UIApplication sharedApplication];
546
+ if (![app respondsToSelector:@selector(_touchesEvent)]) return NO;
547
+ UIEvent* event = [app _touchesEvent];
548
+ if (!event) return NO;
549
+
550
+ UITouch* touch = makeSynthTouchAtPoint(locationInWindow, window, targetView);
551
+ NSTimeInterval beganAt = [[touch valueForKey:@"timestamp"] doubleValue];
552
+ if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) {
553
+ [touch _setIsFirstTouchForView:YES];
554
+ }
555
+ @try {
556
+ if ([event respondsToSelector:@selector(_clearTouches)]) [event _clearTouches];
557
+ if ([event respondsToSelector:@selector(_addTouch:forDelayedDelivery:)]) {
558
+ [event _addTouch:touch forDelayedDelivery:NO];
559
+ }
560
+ [app sendEvent:event];
561
+ [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.030]];
562
+
563
+ UIEvent* endEvent = [app respondsToSelector:@selector(_touchesEvent)] ? [app _touchesEvent] : event;
564
+ [touch setValue:@(UITouchPhaseEnded) forKey:@"phase"];
565
+ [touch setValue:@(beganAt + 0.030) forKey:@"timestamp"];
566
+ if (endEvent != event) {
567
+ if ([endEvent respondsToSelector:@selector(_clearTouches)]) [endEvent _clearTouches];
568
+ if ([endEvent respondsToSelector:@selector(_addTouch:forDelayedDelivery:)]) {
569
+ [endEvent _addTouch:touch forDelayedDelivery:NO];
570
+ }
571
+ }
572
+ [app sendEvent:endEvent];
573
+ return YES;
574
+ } @catch (NSException* e) {
575
+ NSLog(@"[Ennio] sendSynthUITouchSequence: %@", e.reason);
576
+ return NO;
577
+ }
578
+ }
579
+
580
+ static BOOL synthesizeTouchAtPoint(CGPoint locationInWindow) {
581
+ UIWindow* window = findKeyWindow();
582
+ if (!window) return NO;
583
+ UIView* hit = [window hitTest:locationInWindow withEvent:nil] ?: window;
584
+ hit = walkToReactView(hit);
585
+ NSLog(@"[Ennio] tapAtPoint window=(%.1f,%.1f) hit=%@",
586
+ locationInWindow.x, locationInWindow.y, NSStringFromClass([hit class]));
587
+
588
+ if (tryUIControlChain(hit)) return YES;
589
+ if (tryAncestorTapGestures(hit)) return YES;
590
+
591
+ UIApplication* app = [UIApplication sharedApplication];
592
+ if (![app respondsToSelector:@selector(_touchesEvent)]) return NO;
593
+ UIEvent* event = [app _touchesEvent];
594
+ if (!event) return NO;
595
+
596
+ if (tryRNGestureHandlerDirect(hit, window, event, locationInWindow)) return YES;
597
+ return sendSynthUITouchSequence(hit, window, locationInWindow);
598
+ }
599
+
600
+ static BOOL synthesizeTouchAtViewCenter(UIView* view) {
601
+ if (!view || !view.window) return NO;
602
+ CGPoint center = CGPointMake(view.bounds.size.width / 2, view.bounds.size.height / 2);
603
+ CGPoint locationInWindow = [view convertPoint:center toView:view.window];
604
+ return sendSynthUITouchSequence(view, view.window, locationInWindow);
605
+ }
606
+
607
+ // Try every reasonable activation path on a view. Returns YES on the first
608
+ // one that succeeds.
609
+ //
610
+ // Order matters. The synthesized UITouch -> sendEvent: path fires RN
611
+ // Fabric's RCTSurfaceTouchHandler, which is the only path that
612
+ // reliably runs Pressable's onPress on iOS 26 (RN intercepts touches
613
+ // inside its own touch processor, not through the standard responder
614
+ // chain). Try it first.
615
+ //
616
+ // Falling back: UIControl.sendActionsForControlEvents covers UIKit
617
+ // controls (UITabBarButton, UIButton). accessibilityActivate covers
618
+ // VoiceOver-wired widgets. Direct gesture-recognizer invocation
619
+ // catches a handful of RN cases where the recognizer is attached but
620
+ // the touch processor isn't (e.g. paper architecture, some 3rd-party
621
+ // libs).
622
+ // Walks the gesture-recognizer list and invokes the target/action of any
623
+ // tap gesture recognizer attached to the view. Returns YES if at least
624
+ // one action fired. This is the most reliable trigger for RN Pressable
625
+ // because Pressable installs a gesture recognizer whose action is the
626
+ // onPress handler — synthesised UITouch events go through the touch
627
+ // processor and may be filtered (e.g. on third-party RN setups where
628
+ // UITouch private API doesn't reach the Pressability state machine).
629
+ // Drive a tap-style GR's state machine to fire onPress. Only safe on
630
+ // known recogniser classes — system / accessibility GRs (e.g.
631
+ // _UIAccessibilityHUDGateGestureRecognizer attached to RCTUITextField)
632
+ // SIGSEGV on iOS 26 when state is set outside a real touch interaction.
633
+ // The whitelist below covers every tap-firing class encountered in
634
+ // practice across the Fabric / RNGH / pressto / TouchableOpacity stacks.
635
+ static BOOL canStateDrive(UIGestureRecognizer* gr) {
636
+ NSString* name = NSStringFromClass([gr class]);
637
+ // RN-Gesture-Handler's tap recogniser (used by RNGH BaseButton +
638
+ // pressto's PressableScale + every Gesture.Tap() in user code).
639
+ if ([name isEqualToString:@"RNBetterTapGestureRecognizer"]) return YES;
640
+ // Plain UIKit tap GR — RN's TouchableOpacity / TouchableHighlight on
641
+ // the old bridge add this directly to their wrapper view.
642
+ if ([gr isKindOfClass:[UITapGestureRecognizer class]]) return YES;
643
+ return NO;
644
+ }
645
+
646
+ static BOOL invokeTapGestureRecognizers(UIView* view) {
647
+ if (!view) return NO;
648
+ BOOL fired = NO;
649
+ for (UIGestureRecognizer* gr in view.gestureRecognizers) {
650
+ if (!gr.enabled) continue;
651
+ // Preferred path: drive the GR through Began → Ended via the
652
+ // public `state` setter (UIGestureRecognizerSubclass). UIKit's
653
+ // action-dispatch fires registered targets automatically when
654
+ // state transitions to Ended, with the recogniser's `state`
655
+ // already at Ended — so RNGH's `recognizerState` maps to
656
+ // `RNGestureHandlerStateEnd` and the JS-side onPress fires.
657
+ if (canStateDrive(gr)) {
658
+ @try {
659
+ gr.state = UIGestureRecognizerStateBegan;
660
+ gr.state = UIGestureRecognizerStateEnded;
661
+ fired = YES;
662
+ continue;
663
+ } @catch (NSException* e) {
664
+ NSLog(@"[Ennio] state-drive %@: %@", NSStringFromClass([gr class]), e.reason);
665
+ }
666
+ }
667
+ // RNGH 2.x exposes a public `triggerAction` on every RN* tap
668
+ // recogniser that bypasses the UIKit state machine and fires
669
+ // `handleGesture:fromReset:` on the wrapping RNGestureHandler.
670
+ // That's the only path that delivers a tap event into the JS
671
+ // event chain for Gesture.Tap() on a bare View (no Pressable
672
+ // wrapper, no UIControl). State-drive alone leaves the gesture
673
+ // handler in Possible because RN's pointer tracker never saw
674
+ // a touchesBegan, so JS `onEnd` is never resolved.
675
+ NSString* grClassName = NSStringFromClass([gr class]);
676
+ if ([grClassName hasPrefix:@"RN"] &&
677
+ [gr respondsToSelector:NSSelectorFromString(@"triggerAction")]) {
678
+ @try {
679
+ #pragma clang diagnostic push
680
+ #pragma clang diagnostic ignored "-Warc-performSelector-leaks"
681
+ [gr performSelector:NSSelectorFromString(@"triggerAction")];
682
+ #pragma clang diagnostic pop
683
+ fired = YES;
684
+ continue;
685
+ } @catch (NSException* e) {
686
+ NSLog(@"[Ennio] triggerAction %@: %@", grClassName, e.reason);
687
+ }
688
+ }
689
+ // Fallback for unrecognised tap-class GRs: walk `_targets` and
690
+ // invoke the action selector. Modern iOS UIGestureRecognizerTarget
691
+ // has been observed to hide the `_action` key on KVC under some
692
+ // configurations — wrap in @try so the warning doesn't propagate.
693
+ @try {
694
+ NSArray* targets = [gr valueForKey:@"_targets"];
695
+ for (id target in targets) {
696
+ id realTarget = [target valueForKey:@"_target"];
697
+ NSString* actionName = nil;
698
+ @try { actionName = [target valueForKey:@"_action"]; } @catch (...) {}
699
+ if (!realTarget || !actionName) continue;
700
+ SEL action = NSSelectorFromString(actionName);
701
+ if (![realTarget respondsToSelector:action]) continue;
702
+ #pragma clang diagnostic push
703
+ #pragma clang diagnostic ignored "-Warc-performSelector-leaks"
704
+ [realTarget performSelector:action withObject:gr];
705
+ #pragma clang diagnostic pop
706
+ fired = YES;
707
+ }
708
+ } @catch (NSException* e) {
709
+ NSLog(@"[Ennio] invokeTapGestureRecognizers: %@", e.reason);
710
+ }
711
+ }
712
+ return fired;
713
+ }
714
+
715
+ /**
716
+ * Try the activation paths that have a verifiable signal of success:
717
+ * UIControl.sendActions (RNGH BaseButton), tap gesture-recognizer KVC
718
+ * (RN Pressable on the legacy bridge), accessibilityActivate. Returns NO
719
+ * if none apply — caller falls back to synthesizeTouch which always
720
+ * "succeeds" but may not actually fire onPress.
721
+ */
722
+ static BOOL tryDefiniteActivation(UIView* view) {
723
+ if (!view) return NO;
724
+ if ([view isKindOfClass:[UIControl class]]) {
725
+ UIControl* ctrl = (UIControl*)view;
726
+ if (ctrl.enabled) {
727
+ [ctrl sendActionsForControlEvents:UIControlEventTouchUpInside];
728
+ return YES;
729
+ }
730
+ }
731
+ if (invokeTapGestureRecognizers(view)) return YES;
732
+ if ([view accessibilityActivate]) return YES;
733
+ return NO;
734
+ }
735
+
736
+ static BOOL fireActivation(UIView* view) {
737
+ if (!view) return NO;
738
+ if (tryDefiniteActivation(view)) return YES;
739
+ // Fallback: synthesised UITouch via UIApplication.sendEvent. Reaches
740
+ // RCTSurfaceTouchHandler / RN Fabric's touchesBegan-Ended overrides.
741
+ // Always returns YES (event was dispatched), so caller has no way to
742
+ // tell if onPress actually fired — kept as a last resort.
743
+ if (synthesizeTouchAtViewCenter(view)) return YES;
744
+ return NO;
745
+ }
746
+
747
+ // Mirror UIKit touch-routing rules. Two paths block a tap:
748
+ // 1. UIKit's own gate: hidden / alpha~0 / userInteractionEnabled=NO
749
+ // anywhere up the superview chain.
750
+ // 2. RN Fabric's pointerEvents="none": implemented as a hitTest:
751
+ // override on the host view, leaving userInteractionEnabled alone.
752
+ // Only a real hit-test from the window catches this.
753
+ // Activation paths (sendActionsForControlEvents, GR target invoke,
754
+ // accessibilityActivate) bypass both, so we gate explicitly to avoid
755
+ // firing a tap that a finger never could.
756
+ static BOOL viewIsTappable(UIView* view) {
757
+ if (!view || !view.window) return NO;
758
+ for (UIView* v = view; v; v = v.superview) {
759
+ if (v.hidden || v.alpha < 0.01) return NO;
760
+ if (!v.userInteractionEnabled) return NO;
761
+ // RN Fabric implements pointerEvents="none" on RCTViewComponentView
762
+ // by overriding hitTest: rather than touching userInteractionEnabled.
763
+ // Read the prop via KVC so we reject the tap explicitly.
764
+ @try {
765
+ id pe = [v valueForKey:@"pointerEvents"];
766
+ if ([pe isKindOfClass:[NSString class]] &&
767
+ [(NSString*)pe isEqualToString:@"none"]) return NO;
768
+ if ([pe isKindOfClass:[NSNumber class]] &&
769
+ [(NSNumber*)pe integerValue] == 1) return NO;
770
+ } @catch (__unused NSException* e) {}
771
+ }
772
+ return YES;
773
+ }
774
+
775
+ // Walk up the UIView's responder chain to find the immediate owning
776
+ // UIViewController. UIView -> nextResponder is the VC iff the view is
777
+ // that VC's view directly; for normal subviews nextResponder is the
778
+ // superview. So we walk view -> superview -> ... and check each step's
779
+ // nextResponder, returning the first VC encountered.
780
+ static UIViewController* owningViewController(UIView* view) {
781
+ for (UIView* v = view; v != nil; v = v.superview) {
782
+ UIResponder* r = v.nextResponder;
783
+ if ([r isKindOfClass:[UIViewController class]]) return (UIViewController*)r;
784
+ }
785
+ return nil;
786
+ }
787
+
788
+ // True iff every UIViewController in the view's owning chain is the
789
+ // "active" child of its parent: for UITabBarController the selected
790
+ // tab, for UINavigationController the visible top of stack. A modal
791
+ // presentation flips the underlying chain to inactive (the presented
792
+ // VC overlays it), so a presented-then-dismissed VC stops being active.
793
+ //
794
+ // react-native-screens with native-stack on iOS uses UINavigationController;
795
+ // pushed-then-popped frames stay mounted but their VC is no longer
796
+ // `visibleViewController`. expo-router bottom-tabs on iOS uses
797
+ // UITabBarController; inactive tabs' VCs aren't `selectedViewController`.
798
+ // This predicate is what catches the cases the a11y-elementsHidden flag
799
+ // alone misses (RNS doesn't always flip a11y on inactive frames).
800
+ static BOOL isViewInActiveVCChain(UIView* view) {
801
+ UIViewController* vc = owningViewController(view);
802
+ while (vc) {
803
+ UIViewController* parent = vc.parentViewController;
804
+ if (parent) {
805
+ if (parent.presentedViewController && parent.presentedViewController != vc) {
806
+ return NO;
807
+ }
808
+ if ([parent isKindOfClass:[UITabBarController class]]) {
809
+ UITabBarController* tab = (UITabBarController*)parent;
810
+ if (tab.selectedViewController != vc) return NO;
811
+ } else if ([parent isKindOfClass:[UINavigationController class]]) {
812
+ UINavigationController* nav = (UINavigationController*)parent;
813
+ if (nav.visibleViewController != vc) return NO;
814
+ }
815
+ vc = parent;
816
+ } else {
817
+ if (vc.presentedViewController) return NO;
818
+ return YES;
819
+ }
820
+ }
821
+ return YES;
822
+ }
823
+
824
+ // Recursively search the view tree for a UIView whose accessibilityIdentifier
825
+ // matches the testID, restricted to subtrees that iOS considers part of the
826
+ // accessibility tree. accessibilityElementsHidden is the same flag UIKit honors
827
+ // when XCUI / VoiceOver enumerate elements: react-native-screens flips it on
828
+ // inactive stack frames, bottom-tabs flips it on inactive tabs, and UIKit
829
+ // flips it on the underlying VC during modal presentation. Skipping those
830
+ // subtrees here is what stops "found a stale UIView mounted under an inactive
831
+ // tab" false positives.
832
+ static UIView* findViewByTestID(UIView* root, NSString* testID) {
833
+ if (!root || root.hidden) return nil;
834
+ if (root.accessibilityElementsHidden) return nil;
835
+ if ([root.accessibilityIdentifier isEqualToString:testID]) {
836
+ if (!isViewInActiveVCChain(root)) {
837
+ return nil;
838
+ }
839
+ return root;
840
+ }
841
+ for (UIView* sub in root.subviews) {
842
+ UIView* hit = findViewByTestID(sub, testID);
843
+ if (hit) return hit;
844
+ }
845
+ return nil;
846
+ }
847
+
848
+ // Is this view (and every ancestor up to the window) part of the iOS
849
+ // accessibility tree? Combines accessibilityElementsHidden walk with the
850
+ // active-VC-chain check.
851
+ static BOOL viewIsInA11yTree(UIView* view) {
852
+ if (!view || !view.window) return NO;
853
+ for (UIView* v = view; v != nil; v = v.superview) {
854
+ if (v.accessibilityElementsHidden) return NO;
855
+ }
856
+ if (!isViewInActiveVCChain(view)) return NO;
857
+ return YES;
858
+ }
859
+
860
+ // Walk every connected scene window so views inside presented modals /
861
+ // child windows (alerts, action sheets, sheet routers) are findable.
862
+ static UIView* findViewByTestIDInAllWindows(NSString* testID) {
863
+ if (testID.length == 0) return nil;
864
+ for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) {
865
+ if (![scene isKindOfClass:[UIWindowScene class]]) continue;
866
+ UIWindowScene* ws = (UIWindowScene*)scene;
867
+ // Iterate in reverse so the most-recently-presented window wins.
868
+ for (UIWindow* win in [ws.windows reverseObjectEnumerator]) {
869
+ UIView* hit = findViewByTestID(win, testID);
870
+ if (hit) return hit;
871
+ }
872
+ }
873
+ return nil;
874
+ }
875
+
876
+ // Locate the first responder hosting the keyboard, if any.
877
+ static UIView* findFirstResponderUnder(UIView* root) {
878
+ if (!root) return nil;
879
+ if (root.isFirstResponder) return root;
880
+ for (UIView* sub in root.subviews) {
881
+ UIView* hit = findFirstResponderUnder(sub);
882
+ if (hit) return hit;
883
+ }
884
+ return nil;
885
+ }
886
+ static UIView* findFirstResponder() {
887
+ for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) {
888
+ if (![scene isKindOfClass:[UIWindowScene class]]) continue;
889
+ UIWindowScene* ws = (UIWindowScene*)scene;
890
+ for (UIWindow* win in [ws.windows reverseObjectEnumerator]) {
891
+ UIView* hit = findFirstResponderUnder(win);
892
+ if (hit) return hit;
893
+ }
894
+ }
895
+ return nil;
896
+ }
897
+
898
+ // Resolve the closest UIScrollView ancestor for a testID. Used by
899
+ // scroll / swipe so the caller can just point at any descendant.
900
+ static UIScrollView* findEnclosingScrollView(UIView* view) {
901
+ UIView* node = view;
902
+ while (node) {
903
+ if ([node isKindOfClass:[UIScrollView class]]) return (UIScrollView*)node;
904
+ node = node.superview;
905
+ }
906
+ return nil;
907
+ }
908
+
909
+ // Top-most view controller, walking modal / nav stacks.
910
+ static UIViewController* topMostViewController(UIViewController* root) {
911
+ if (root.presentedViewController) {
912
+ return topMostViewController(root.presentedViewController);
913
+ }
914
+ if ([root isKindOfClass:[UINavigationController class]]) {
915
+ UINavigationController* nav = (UINavigationController*)root;
916
+ if (nav.visibleViewController) return topMostViewController(nav.visibleViewController);
917
+ }
918
+ if ([root isKindOfClass:[UITabBarController class]]) {
919
+ UITabBarController* tab = (UITabBarController*)root;
920
+ if (tab.selectedViewController) return topMostViewController(tab.selectedViewController);
921
+ }
922
+ return root;
923
+ }
924
+ static UIViewController* topMostViewControllerForKeyWindow() {
925
+ for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) {
926
+ if (![scene isKindOfClass:[UIWindowScene class]]) continue;
927
+ UIWindowScene* ws = (UIWindowScene*)scene;
928
+ for (UIWindow* win in [ws.windows reverseObjectEnumerator]) {
929
+ if (!win.rootViewController) continue;
930
+ return topMostViewController(win.rootViewController);
931
+ }
932
+ }
933
+ return nil;
934
+ }
935
+
936
+ // Pull a UIAlertAction's stored handler block via KVC. Apple keeps the
937
+ // handler private but the ivar name is stable across iOS versions.
938
+ // After invoking the handler, dismiss via the presenting controller —
939
+ // `[alert dismiss...]` only dismisses anything alert presented (which
940
+ // is nothing for a leaf alert), it doesn't dismiss alert itself.
941
+ static void invokeAlertAction(UIAlertController* alert, UIAlertAction* action) {
942
+ @try {
943
+ id handler = [action valueForKey:@"handler"];
944
+ if (handler) {
945
+ void (^block)(UIAlertAction*) = (void (^)(UIAlertAction*))handler;
946
+ block(action);
947
+ }
948
+ } @catch (NSException* e) {
949
+ NSLog(@"[Ennio] invokeAlertAction: KVC handler lookup failed: %@", e.reason);
950
+ }
951
+ // Try presenter, then alert's own dismiss as a fallback (some iOS
952
+ // versions wire the alert's window so [alert dismiss] does the
953
+ // right thing).
954
+ UIViewController* presenter = alert.presentingViewController;
955
+ if (presenter) {
956
+ [presenter dismissViewControllerAnimated:NO completion:nil];
957
+ } else {
958
+ [alert dismissViewControllerAnimated:NO completion:nil];
959
+ }
960
+ }
961
+
962
+ namespace ennio {
963
+
964
+ // BFS the descendant subtree looking for a definite activation target.
965
+ // Handles RNGH BaseButton: testID lives on the wrapper, but the actual
966
+ // UIControl that fires onPress is a child a couple levels in.
967
+ static BOOL activateInSubtree(UIView* root) {
968
+ NSMutableArray* queue = [NSMutableArray arrayWithArray:root.subviews];
969
+ while (queue.count > 0) {
970
+ UIView* next = queue.firstObject;
971
+ [queue removeObjectAtIndex:0];
972
+ if (tryDefiniteActivation(next)) return YES;
973
+ [queue addObjectsFromArray:next.subviews];
974
+ }
975
+ return NO;
976
+ }
977
+
978
+ // Climb superview chain. Catches the case where testID is on an inner
979
+ // Text leaf and the actual handler is a Pressable wrapper a few levels up.
980
+ static BOOL activateInAncestors(UIView* leaf) {
981
+ for (UIView* cursor = leaf.superview; cursor; cursor = cursor.superview) {
982
+ if (tryDefiniteActivation(cursor)) return YES;
983
+ }
984
+ return NO;
985
+ }
986
+
987
+ bool EnnioRuntimeHelper::tap(const std::string& testID) {
988
+ NSString* tid = [NSString stringWithUTF8String:testID.c_str()];
989
+ __block bool ok = false;
990
+ void (^block)(void) = ^{
991
+ UIView* view = findViewByTestIDInAllWindows(tid);
992
+ if (!view) {
993
+ NSLog(@"[Ennio] tap: testID '%@' not found in view tree", tid);
994
+ return;
995
+ }
996
+ if (!viewIsTappable(view)) {
997
+ NSLog(@"[Ennio] tap: '%@' blocked (pointerEvents=none / hidden / userInteractionEnabled=NO on view or ancestor)", tid);
998
+ return;
999
+ }
1000
+ if (tryDefiniteActivation(view)) { ok = true; return; }
1001
+ if (activateInSubtree(view)) { ok = true; return; }
1002
+ if (activateInAncestors(view)) { ok = true; return; }
1003
+ // Last resort: synthesise a UITouch on the view itself. Reaches
1004
+ // vanilla Pressable via RCTSurfaceTouchHandler. Always reports YES
1005
+ // even if no responder claims the touch — keep this last so a
1006
+ // false-positive doesn't pre-empt a real activation path.
1007
+ if (synthesizeTouchAtViewCenter(view)) { ok = true; return; }
1008
+ NSLog(@"[Ennio] tap: '%@' has no activation path (class=%@, traits=0x%llx, isAccessibilityElement=%d)",
1009
+ tid, NSStringFromClass([view class]), (unsigned long long)view.accessibilityTraits, view.isAccessibilityElement);
1010
+ };
1011
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
1012
+ return ok;
1013
+ }
1014
+
1015
+ std::tuple<double, double, double, double>
1016
+ EnnioRuntimeHelper::getViewWindowFrame(const std::string& testID) {
1017
+ NSString* tid = [NSString stringWithUTF8String:testID.c_str()];
1018
+ __block double rx = 0, ry = 0, rw = 0, rh = 0;
1019
+ void (^block)(void) = ^{
1020
+ UIView* view = findViewByTestIDInAllWindows(tid);
1021
+ if (!view || !view.window) return;
1022
+ CGRect inWindow = [view convertRect:view.bounds toView:view.window];
1023
+ rx = inWindow.origin.x;
1024
+ ry = inWindow.origin.y;
1025
+ rw = inWindow.size.width;
1026
+ rh = inWindow.size.height;
1027
+ };
1028
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
1029
+ return {rx, ry, rw, rh};
1030
+ }
1031
+
1032
+ std::pair<double, double> EnnioRuntimeHelper::getSurfaceOffset() {
1033
+ __block double ox = 0, oy = 0;
1034
+ void (^block)(void) = ^{
1035
+ UIWindow* window = findKeyWindow();
1036
+ if (!window) return;
1037
+ UIView* root = window.rootViewController.view;
1038
+ if (!root) return;
1039
+ CGRect inWindow = [root convertRect:root.bounds toView:window];
1040
+ ox = inWindow.origin.x;
1041
+ oy = inWindow.origin.y;
1042
+ };
1043
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
1044
+ return {ox, oy};
1045
+ }
1046
+
1047
+ std::pair<double, double> EnnioRuntimeHelper::getKeyWindowSize() {
1048
+ __block double w = 0, h = 0;
1049
+ void (^block)(void) = ^{
1050
+ UIWindow* window = findKeyWindow();
1051
+ if (!window) return;
1052
+ w = window.bounds.size.width;
1053
+ h = window.bounds.size.height;
1054
+ };
1055
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
1056
+ return {w, h};
1057
+ }
1058
+
1059
+ bool EnnioRuntimeHelper::clearAppDataDirectories() {
1060
+ NSFileManager* fm = [NSFileManager defaultManager];
1061
+ NSString* home = NSHomeDirectory();
1062
+ NSArray<NSString*>* targets = @[
1063
+ [home stringByAppendingPathComponent:@"Library"],
1064
+ [home stringByAppendingPathComponent:@"Documents"],
1065
+ [home stringByAppendingPathComponent:@"tmp"],
1066
+ ];
1067
+ bool ok = true;
1068
+ for (NSString* dir in targets) {
1069
+ NSError* err = nil;
1070
+ NSArray<NSString*>* entries = [fm contentsOfDirectoryAtPath:dir error:&err];
1071
+ if (!entries) continue;
1072
+ for (NSString* name in entries) {
1073
+ // Library/Caches and Library/Preferences are recreated by
1074
+ // iOS on next launch — wiping their contents drops AsyncStorage,
1075
+ // RN HermesRuntime caches, NSUserDefaults, etc.
1076
+ NSString* path = [dir stringByAppendingPathComponent:name];
1077
+ NSError* rmErr = nil;
1078
+ if (![fm removeItemAtPath:path error:&rmErr]) {
1079
+ NSLog(@"[Ennio] clearAppDataDirectories: failed to remove %@: %@",
1080
+ path, rmErr.localizedDescription);
1081
+ ok = false;
1082
+ }
1083
+ }
1084
+ }
1085
+ return ok;
1086
+ }
1087
+
1088
+ bool EnnioRuntimeHelper::isMenuTriggerAncestor(const std::string& testID) {
1089
+ NSString* tid = [NSString stringWithUTF8String:testID.c_str()];
1090
+ __block bool isMenuTrigger = false;
1091
+ void (^block)(void) = ^{
1092
+ UIView* view = findViewByTestIDInAllWindows(tid);
1093
+ if (!view) return;
1094
+ if (@available(iOS 14.0, *)) {
1095
+ // Walk up: testID may sit on the asChild child of zeego's
1096
+ // DropdownMenu.Trigger; the UIButton.menu host is its superview.
1097
+ for (UIView* cursor = view; cursor; cursor = cursor.superview) {
1098
+ if (![cursor isKindOfClass:[UIButton class]]) continue;
1099
+ UIButton* b = (UIButton*)cursor;
1100
+ if (b.menu && b.showsMenuAsPrimaryAction) {
1101
+ isMenuTrigger = true;
1102
+ return;
1103
+ }
1104
+ }
1105
+ // Walk down: testID may sit on an outer wrapper View hoisted
1106
+ // above DropdownMenu.Root so Maestro can see it in the iOS
1107
+ // accessibility tree. Find the UIButton.menu in the subtree.
1108
+ NSMutableArray<UIView*>* stack = [NSMutableArray arrayWithObject:view];
1109
+ while (stack.count > 0) {
1110
+ UIView* cur = stack.lastObject;
1111
+ [stack removeLastObject];
1112
+ if ([cur isKindOfClass:[UIButton class]]) {
1113
+ UIButton* b = (UIButton*)cur;
1114
+ if (b.menu && b.showsMenuAsPrimaryAction) {
1115
+ isMenuTrigger = true;
1116
+ return;
1117
+ }
1118
+ }
1119
+ for (UIView* sub in cur.subviews) [stack addObject:sub];
1120
+ }
1121
+ }
1122
+ };
1123
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
1124
+ return isMenuTrigger;
1125
+ }
1126
+
1127
+ bool EnnioRuntimeHelper::isInA11yTree(const std::string& testID) {
1128
+ NSString* tid = [NSString stringWithUTF8String:testID.c_str()];
1129
+ __block bool ok = false;
1130
+ void (^block)(void) = ^{
1131
+ UIView* view = findViewByTestIDInAllWindows(tid);
1132
+ ok = viewIsInA11yTree(view) ? true : false;
1133
+ };
1134
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
1135
+ return ok;
1136
+ }
1137
+
1138
+ bool EnnioRuntimeHelper::isViewOnscreen(const std::string& testID) {
1139
+ NSString* tid = [NSString stringWithUTF8String:testID.c_str()];
1140
+ __block bool onscreen = false;
1141
+ void (^block)(void) = ^{
1142
+ UIView* view = findViewByTestIDInAllWindows(tid);
1143
+ if (!view || !view.window) return;
1144
+ CGRect viewRect = [view convertRect:view.bounds toView:view.window];
1145
+ if (viewRect.size.width <= 0 || viewRect.size.height <= 0) return;
1146
+ // "Visible" requires the element's centre point to lie inside
1147
+ // the safe content rect — window bounds minus the system safe-
1148
+ // area insets (status bar / nav header on top, home-indicator +
1149
+ // UITabBar on bottom). A FlashList cell rendered just below the
1150
+ // scroll's visible bounds is technically in window space but
1151
+ // its centre falls under the tab bar; tapping there is a no-op
1152
+ // for the user. The centre-point predicate handles full-screen
1153
+ // container views (centre is mid-screen → inside) and rejects
1154
+ // virtualized cells parked offscreen (centre is below tab bar
1155
+ // → outside). Matches Maestro's `visibilityPercentage 100`
1156
+ // semantics for tap-targeting purposes.
1157
+ UIWindow* w = view.window;
1158
+ UIEdgeInsets insets = w.safeAreaInsets;
1159
+ CGRect safeRect = UIEdgeInsetsInsetRect(w.bounds, insets);
1160
+ CGPoint viewCentre = CGPointMake(CGRectGetMidX(viewRect), CGRectGetMidY(viewRect));
1161
+ if (!CGRectContainsPoint(safeRect, viewCentre)) return;
1162
+ // Also require some intersection with the safe rect — a centre-
1163
+ // outside, edge-just-inside element would still slip through if
1164
+ // we only checked the centre. (Belt-and-suspenders.)
1165
+ if (!CGRectIntersectsRect(viewRect, safeRect)) return;
1166
+
1167
+ // Walk ancestors: hidden / alpha~0 / accessibilityElementsHidden
1168
+ // all hide this view. The a11y check is what catches inactive
1169
+ // tabs (bottom-tabs sets it on the inactive UITabBar children)
1170
+ // and the underlying VC during modal presentation.
1171
+ for (UIView* v = view; v != nil; v = v.superview) {
1172
+ if (v.hidden || v.alpha < 0.01) return;
1173
+ if (v.accessibilityElementsHidden) return;
1174
+ }
1175
+ // Active-VC-chain: catches react-native-screens stack frames
1176
+ // that stay mounted but inactive (push then dismissAll), where
1177
+ // the a11y-elementsHidden flag isn't always flipped. Required
1178
+ // for visibility correctness on multi-tab + native-stack apps.
1179
+ if (!isViewInActiveVCChain(view)) return;
1180
+
1181
+ // Z-order / occlusion. A modal/sheet/alert presented above the
1182
+ // view's window blocks any finger from reaching it. Reject if a
1183
+ // higher-level window covers the view's centre.
1184
+ CGPoint centre = CGPointMake(CGRectGetMidX(viewRect), CGRectGetMidY(viewRect));
1185
+ UIWindow* targetWindow = view.window;
1186
+ for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) {
1187
+ if (![scene isKindOfClass:[UIWindowScene class]]) continue;
1188
+ for (UIWindow* w in ((UIWindowScene*)scene).windows) {
1189
+ if (w.hidden || w == targetWindow) continue;
1190
+ if (w.windowLevel <= targetWindow.windowLevel) continue;
1191
+ CGPoint pt = [w convertPoint:centre fromWindow:targetWindow];
1192
+ if ([w hitTest:pt withEvent:nil]) return;
1193
+ }
1194
+ }
1195
+ onscreen = true;
1196
+ };
1197
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
1198
+ return onscreen;
1199
+ }
1200
+
1201
+ bool EnnioRuntimeHelper::tapAtScreenPoint(double x, double y) {
1202
+ __block bool ok = false;
1203
+ void (^block)(void) = ^{
1204
+ // Fabric layout is React-surface-relative — origin (0,0) sits
1205
+ // below the system status bar / notch. UIWindow.sendEvent
1206
+ // expects window coords, so add the surface's offset within
1207
+ // its window before synthesising the touch.
1208
+ UIWindow* keyWindow = findKeyWindow();
1209
+ CGPoint point = CGPointMake((CGFloat)x, (CGFloat)y);
1210
+ if (keyWindow) {
1211
+ // The React surface is the first non-trivial child of the
1212
+ // window's root. Find it and convert.
1213
+ UIView* surface = keyWindow.rootViewController.view;
1214
+ if (surface) {
1215
+ CGRect inWindow = [surface convertRect:surface.bounds toView:keyWindow];
1216
+ point.x += inWindow.origin.x;
1217
+ point.y += inWindow.origin.y;
1218
+ }
1219
+ }
1220
+ ok = synthesizeTouchAtPoint(point);
1221
+ };
1222
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
1223
+ return ok;
1224
+ }
1225
+
1226
+ std::string EnnioRuntimeHelper::prepareTap(const std::string& testID, double screenW, double screenH) {
1227
+ // Stable-coord poll + auto-scroll fallback + UIMenu check, all in
1228
+ // one JSI call. CLI-side `layoutCenter` previously did this via
1229
+ // ~5-10 separate CDP round trips; batching cuts the CDP overhead
1230
+ // for the hottest yaml verb. The actual tap stays on the CLI side
1231
+ // through idb HID — UITouch synth doesn't reliably fire RNGH-wrapped
1232
+ // gesture recognizers (PressableScale, RNBetterTapGestureRecognizer).
1233
+ const int maxIters = 100;
1234
+ const int probeSleepMs = 20;
1235
+ double lastCx = 0, lastCy = 0;
1236
+ bool haveLast = false;
1237
+ bool didScroll = false;
1238
+ double finalCx = 0, finalCy = 0;
1239
+ bool foundStable = false;
1240
+ for (int i = 0; i < maxIters; i++) {
1241
+ auto frame = getViewWindowFrame(testID);
1242
+ double fx = std::get<0>(frame), fy = std::get<1>(frame);
1243
+ double fw = std::get<2>(frame), fh = std::get<3>(frame);
1244
+ if (fw > 0 && fh > 0) {
1245
+ double cx = fx + fw / 2.0;
1246
+ double cy = fy + fh / 2.0;
1247
+ bool onScreen = (cx >= 0 && cx <= screenW && cy >= 0 && cy <= screenH);
1248
+ if (!onScreen) {
1249
+ if (!didScroll) {
1250
+ didScroll = true;
1251
+ scrollTo(std::string(), testID);
1252
+ std::this_thread::sleep_for(std::chrono::milliseconds(200));
1253
+ continue;
1254
+ }
1255
+ return ""; // Off-screen even after scroll attempt.
1256
+ }
1257
+ if (haveLast && std::abs(lastCx - cx) < 2.0 && std::abs(lastCy - cy) < 2.0) {
1258
+ // Coord-stable. Now verify a hit-test at the centre actually
1259
+ // resolves to the testID's view (or a descendant). During a
1260
+ // UIKit stack-push transition the destination Pressable's
1261
+ // frame is reported stable in window coords, but the
1262
+ // responder chain isn't bound yet — the topmost view at
1263
+ // (cx, cy) is the still-fading source screen. Tapping there
1264
+ // misses. Hit-test reflects the real interaction graph.
1265
+ __block bool hitOk = false;
1266
+ void (^hitBlock)(void) = ^{
1267
+ UIView* target = findViewByTestIDInAllWindows(
1268
+ [NSString stringWithUTF8String:testID.c_str()]);
1269
+ if (!target || !target.window) return;
1270
+ UIWindow* win = target.window;
1271
+ CGPoint p = CGPointMake((CGFloat)cx, (CGFloat)cy);
1272
+ UIView* top = [win hitTest:p withEvent:nil];
1273
+ if (!top) return;
1274
+ // Strict: top must be target itself or a descendant.
1275
+ // The deepest hit-tested view is whichever React leaf
1276
+ // (icon glyph, text, container) sits at the point —
1277
+ // walk up to the target Pressable. Reject if the
1278
+ // walk hits a UIKit wrapper or a sibling first; that
1279
+ // means a real touch would be delivered somewhere
1280
+ // else, not to the testID we resolved.
1281
+ for (UIView* cursor = top; cursor; cursor = cursor.superview) {
1282
+ if (cursor == target) { hitOk = true; return; }
1283
+ }
1284
+ };
1285
+ if ([NSThread isMainThread]) hitBlock(); else dispatchSyncMainWithTimeout(hitBlock);
1286
+ if (hitOk) {
1287
+ finalCx = cx;
1288
+ finalCy = cy;
1289
+ foundStable = true;
1290
+ break;
1291
+ }
1292
+ }
1293
+ lastCx = cx;
1294
+ lastCy = cy;
1295
+ haveLast = true;
1296
+ }
1297
+ std::this_thread::sleep_for(std::chrono::milliseconds(probeSleepMs));
1298
+ }
1299
+ if (!foundStable) {
1300
+ // Hit-test never confirmed the testID's view as topmost.
1301
+ // Could be:
1302
+ // (a) a pointerEvents="none" wrapper — the tap is expected
1303
+ // to fall through to whatever sits beneath (test
1304
+ // authors rely on this for "blocked" cases).
1305
+ // (b) the view is obscured by a tab bar / large title —
1306
+ // the tap would land on the obscuring view and fire
1307
+ // the wrong action.
1308
+ // We can't distinguish (a) from (b) here, so fall through to
1309
+ // the best-effort last-stable coord and let the CLI-side
1310
+ // scrollUntilVisible safe-tap-zone buffer prevent (b) before
1311
+ // a user-driven `tapOn` ever reaches this branch.
1312
+ if (!haveLast) return "";
1313
+ finalCx = lastCx;
1314
+ finalCy = lastCy;
1315
+ }
1316
+ bool isMenu = isMenuTriggerAncestor(testID);
1317
+ std::ostringstream oss;
1318
+ oss << "{\"x\":" << finalCx << ",\"y\":" << finalCy
1319
+ << ",\"isMenu\":" << (isMenu ? "true" : "false") << "}";
1320
+ return oss.str();
1321
+ }
1322
+
1323
+ bool EnnioRuntimeHelper::swipeAtPoints(double x1, double y1, double x2, double y2, double durationMs) {
1324
+ if (durationMs <= 0) {
1325
+ NSLog(@"[Ennio] swipeAtPoints: invalid durationMs=%.1f, must be > 0", durationMs);
1326
+ return false;
1327
+ }
1328
+ __block bool ok = false;
1329
+ void (^block)(void) = ^{
1330
+ // Resolve the React surface offset the same way tapAtScreenPoint
1331
+ // does — both endpoints are React-surface-relative when the caller
1332
+ // is forwarding maestro yaml `swipe: start: ...` coords.
1333
+ UIApplication* app = [UIApplication sharedApplication];
1334
+ UIWindow* keyWindow = findKeyWindow();
1335
+ CGFloat offX = 0, offY = 0;
1336
+ if (keyWindow && keyWindow.rootViewController.view) {
1337
+ UIView* surface = keyWindow.rootViewController.view;
1338
+ CGRect inWindow = [surface convertRect:surface.bounds toView:keyWindow];
1339
+ offX = inWindow.origin.x;
1340
+ offY = inWindow.origin.y;
1341
+ }
1342
+ CGPoint start = CGPointMake((CGFloat)x1 + offX, (CGFloat)y1 + offY);
1343
+ CGPoint end = CGPointMake((CGFloat)x2 + offX, (CGFloat)y2 + offY);
1344
+
1345
+ if (!keyWindow) keyWindow = app.keyWindow;
1346
+ if (!keyWindow) { ok = NO; return; }
1347
+
1348
+ // Fast path: the start point lands inside a UIScrollView. Any
1349
+ // RN ScrollView / FlatList / FlashList ends up as one. Compute
1350
+ // the new offset from the swipe delta (negated — content moves
1351
+ // opposite to the finger) and clamp to the scrollable bounds.
1352
+ UIView* hit = [keyWindow hitTest:start withEvent:nil];
1353
+ UIScrollView* scrollView = nil;
1354
+ for (UIView* cursor = hit; cursor != nil; cursor = cursor.superview) {
1355
+ if ([cursor isKindOfClass:[UIScrollView class]]) {
1356
+ scrollView = (UIScrollView*)cursor;
1357
+ break;
1358
+ }
1359
+ }
1360
+ if (scrollView) {
1361
+ CGFloat dx = end.x - start.x;
1362
+ CGFloat dy = end.y - start.y;
1363
+ CGPoint newOffset = scrollView.contentOffset;
1364
+ newOffset.x -= dx;
1365
+ newOffset.y -= dy;
1366
+ CGFloat maxX = MAX(0, scrollView.contentSize.width - scrollView.bounds.size.width);
1367
+ CGFloat maxY = MAX(0, scrollView.contentSize.height - scrollView.bounds.size.height);
1368
+ newOffset.x = MAX(0, MIN(newOffset.x, maxX));
1369
+ newOffset.y = MAX(0, MIN(newOffset.y, maxY));
1370
+ // Pagination snapping is driven by the pan recogniser
1371
+ // deciding "this gesture crossed a page boundary". A direct
1372
+ // `setContentOffset:animated:NO` skips the recogniser
1373
+ // entirely; RN's RCTScrollView (Fabric) sees the offset
1374
+ // change before any RCTScrollEvent fires and re-syncs from
1375
+ // the React-side state — page snaps back to 0. Use the
1376
+ // animated setter so the UIScrollView fires the proper
1377
+ // begin/end-decelerating events; momentumScrollEnd then
1378
+ // updates React state and the page advances cleanly.
1379
+ [scrollView setContentOffset:newOffset animated:scrollView.pagingEnabled];
1380
+ ok = YES;
1381
+ return;
1382
+ }
1383
+
1384
+ // Slow path: drive a synthesised UITouch sequence with phase=Moved
1385
+ // updates between Began and Ended. Lets us pan a sheet, drive a
1386
+ // UIPanGestureRecognizer attached to a non-scroll view, etc.
1387
+ // Pick step count so each Move is ~30 ms — that's the cadence
1388
+ // RNGH and UIKit gesture recognisers expect for a "real" swipe.
1389
+ UIView* startView = hit ?: keyWindow;
1390
+ UIView* reactView = startView;
1391
+ while (reactView && ![NSStringFromClass([reactView class]) hasPrefix:@"RCT"]) {
1392
+ reactView = reactView.superview;
1393
+ }
1394
+ if (reactView) startView = reactView;
1395
+
1396
+ const int totalMs = durationMs > 0 ? durationMs : 200;
1397
+ const int stepMs = 30;
1398
+ const int steps = MAX(4, totalMs / stepMs);
1399
+ UIEvent* event = [app respondsToSelector:@selector(_touchesEvent)] ? [app _touchesEvent] : nil;
1400
+ if (!event) { ok = NO; return; }
1401
+
1402
+ UITouch* touch = [[UITouch alloc] init];
1403
+ if ([touch respondsToSelector:@selector(_setLocationInWindow:resetPrevious:)]) {
1404
+ [touch _setLocationInWindow:start resetPrevious:NO];
1405
+ } else {
1406
+ [touch setValue:[NSValue valueWithCGPoint:start] forKey:@"locationInWindow"];
1407
+ }
1408
+ [touch setValue:@(UITouchPhaseBegan) forKey:@"phase"];
1409
+ [touch setValue:keyWindow forKey:@"window"];
1410
+ [touch setValue:startView forKey:@"view"];
1411
+ [touch setValue:@(1) forKey:@"tapCount"];
1412
+ NSTimeInterval beganAt = [[NSProcessInfo processInfo] systemUptime];
1413
+ [touch setValue:@(beganAt) forKey:@"timestamp"];
1414
+ if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) {
1415
+ [touch _setIsFirstTouchForView:YES];
1416
+ }
1417
+
1418
+ @try {
1419
+ if ([event respondsToSelector:@selector(_clearTouches)]) [event _clearTouches];
1420
+ if ([event respondsToSelector:@selector(_addTouch:forDelayedDelivery:)]) {
1421
+ [event _addTouch:touch forDelayedDelivery:NO];
1422
+ }
1423
+ [app sendEvent:event];
1424
+
1425
+ for (int i = 1; i < steps; i++) {
1426
+ CGFloat t = (CGFloat)i / (CGFloat)steps;
1427
+ CGPoint mid = CGPointMake(start.x + (end.x - start.x) * t,
1428
+ start.y + (end.y - start.y) * t);
1429
+ if ([touch respondsToSelector:@selector(_setLocationInWindow:resetPrevious:)]) {
1430
+ [touch _setLocationInWindow:mid resetPrevious:NO];
1431
+ }
1432
+ [touch setValue:@(UITouchPhaseMoved) forKey:@"phase"];
1433
+ [touch setValue:@(beganAt + (stepMs * i) / 1000.0) forKey:@"timestamp"];
1434
+ UIEvent* moveEvent = [app respondsToSelector:@selector(_touchesEvent)] ? [app _touchesEvent] : event;
1435
+ if (moveEvent != event) {
1436
+ if ([moveEvent respondsToSelector:@selector(_clearTouches)]) [moveEvent _clearTouches];
1437
+ if ([moveEvent respondsToSelector:@selector(_addTouch:forDelayedDelivery:)]) {
1438
+ [moveEvent _addTouch:touch forDelayedDelivery:NO];
1439
+ }
1440
+ }
1441
+ [app sendEvent:moveEvent];
1442
+ [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:stepMs / 1000.0]];
1443
+ }
1444
+
1445
+ if ([touch respondsToSelector:@selector(_setLocationInWindow:resetPrevious:)]) {
1446
+ [touch _setLocationInWindow:end resetPrevious:NO];
1447
+ }
1448
+ [touch setValue:@(UITouchPhaseEnded) forKey:@"phase"];
1449
+ [touch setValue:@(beganAt + totalMs / 1000.0) forKey:@"timestamp"];
1450
+ UIEvent* endEvent = [app respondsToSelector:@selector(_touchesEvent)] ? [app _touchesEvent] : event;
1451
+ if (endEvent != event) {
1452
+ if ([endEvent respondsToSelector:@selector(_clearTouches)]) [endEvent _clearTouches];
1453
+ if ([endEvent respondsToSelector:@selector(_addTouch:forDelayedDelivery:)]) {
1454
+ [endEvent _addTouch:touch forDelayedDelivery:NO];
1455
+ }
1456
+ }
1457
+ [app sendEvent:endEvent];
1458
+ ok = YES;
1459
+ } @catch (NSException* e) {
1460
+ NSLog(@"[Ennio] swipeAtPoints: %@", e.reason);
1461
+ ok = NO;
1462
+ }
1463
+ };
1464
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
1465
+ return ok;
1466
+ }
1467
+
1468
+ bool EnnioRuntimeHelper::pressHardwareKey(double keyCode) {
1469
+ __block bool ok = false;
1470
+ void (^block)(void) = ^{
1471
+ // Walk the key window's responder chain to the current first
1472
+ // responder. UIKeyInput is the protocol UITextInput descends
1473
+ // from; insertText: / deleteBackward are the standard hooks.
1474
+ UIWindow* keyWindow = findKeyWindow();
1475
+ if (!keyWindow) { ok = NO; return; }
1476
+
1477
+ UIResponder* fr = nil;
1478
+ // Crawl the view tree for whatever has isFirstResponder set —
1479
+ // findFirstResponder lives elsewhere in this file but as a
1480
+ // file-local helper, so duplicate the trivial walk here to
1481
+ // avoid forward-declaration churn.
1482
+ NSMutableArray<UIView*>* stack = [NSMutableArray arrayWithObject:keyWindow];
1483
+ while (stack.count) {
1484
+ UIView* v = stack.lastObject;
1485
+ [stack removeLastObject];
1486
+ if (v.isFirstResponder) { fr = v; break; }
1487
+ for (UIView* sub in v.subviews) [stack addObject:sub];
1488
+ }
1489
+ if (!fr || ![fr conformsToProtocol:@protocol(UIKeyInput)]) { ok = NO; return; }
1490
+ id<UIKeyInput> input = (id<UIKeyInput>)fr;
1491
+
1492
+ // Nitro hands us the keycode as a double (TS `number`); narrow
1493
+ // it for the switch.
1494
+ const int code = (int)keyCode;
1495
+ switch (code) {
1496
+ case 42: // backspace
1497
+ [input deleteBackward];
1498
+ ok = YES;
1499
+ break;
1500
+ case 40: // return
1501
+ [input insertText:@"\n"];
1502
+ ok = YES;
1503
+ break;
1504
+ case 44: // space
1505
+ [input insertText:@" "];
1506
+ ok = YES;
1507
+ break;
1508
+ default:
1509
+ ok = NO;
1510
+ break;
1511
+ }
1512
+ };
1513
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
1514
+ return ok;
1515
+ }
1516
+
1517
+ bool EnnioRuntimeHelper::doubleTap(const std::string& testID) {
1518
+ if (!tap(testID)) return false;
1519
+ [NSThread sleepForTimeInterval:0.12];
1520
+ return tap(testID);
1521
+ }
1522
+
1523
+ // Recursive label search across UIKit. Picks the smallest matching frame
1524
+ // (most specific element) — important because RN's tab bar has both the
1525
+ // outer container (full bar) and the per-item button carrying the label.
1526
+ //
1527
+ // VoiceOver / UITabBar augment accessibilityLabel with extra context
1528
+ // ("Home, Tab, 1 of 4"), so we try exact match first then CONTAINS.
1529
+ // Match `label` against `needle`: exact, then whole-word case-insensitive
1530
+ // CONTAINS so "Home" matches "Home, Tab" but not "Welcome Home".
1531
+ static BOOL labelMatchesText(NSString* label, NSString* needle) {
1532
+ if (!label) return NO;
1533
+ if ([label isEqualToString:needle]) return YES;
1534
+ NSRange r = [label rangeOfString:needle options:NSCaseInsensitiveSearch];
1535
+ if (r.location == NSNotFound) return NO;
1536
+ NSCharacterSet* letters = [NSCharacterSet letterCharacterSet];
1537
+ BOOL leftOk = r.location == 0 || ![letters characterIsMember:[label characterAtIndex:r.location - 1]];
1538
+ BOOL rightOk = r.location + r.length == label.length
1539
+ || ![letters characterIsMember:[label characterAtIndex:r.location + r.length]];
1540
+ return leftOk && rightOk;
1541
+ }
1542
+
1543
+ // Hit-test at the candidate's centre and confirm it (or a descendant) is
1544
+ // the topmost view. A Stack-pushed screen leaves the predecessor's
1545
+ // UIViews in the tree but covers them — without this guard the label
1546
+ // finder taps a hidden tab bar.
1547
+ static BOOL viewIsHittableAtCenter(UIView* view) {
1548
+ UIWindow* win = view.window;
1549
+ if (!win) return YES;
1550
+ CGRect inWindow = [view convertRect:view.bounds toView:win];
1551
+ CGPoint centre = CGPointMake(CGRectGetMidX(inWindow), CGRectGetMidY(inWindow));
1552
+ UIView* topMost = [win hitTest:centre withEvent:nil];
1553
+ for (UIView* cursor = topMost; cursor; cursor = cursor.superview) {
1554
+ if (cursor == view) return YES;
1555
+ }
1556
+ return NO;
1557
+ }
1558
+
1559
+ // Smallest hittable view whose accessibility label matches `text`.
1560
+ // Caller iterates root windows; this recurses into one tree.
1561
+ // accessibilityElementsHidden filter: same a11y-tree predicate as
1562
+ // findViewByTestID — keeps text-based finders from latching onto labels
1563
+ // inside inactive stack frames / inactive tabs.
1564
+ static UIView* findLabelMatch(UIView* root, NSString* text, UIView* best) {
1565
+ if (!root || root.hidden || root.alpha < 0.01) return best;
1566
+ if (root.accessibilityElementsHidden) return best;
1567
+ if (labelMatchesText(root.accessibilityLabel, text) && viewIsHittableAtCenter(root) &&
1568
+ isViewInActiveVCChain(root)) {
1569
+ CGFloat rootArea = root.bounds.size.width * root.bounds.size.height;
1570
+ CGFloat bestArea = best ? best.bounds.size.width * best.bounds.size.height : CGFLOAT_MAX;
1571
+ if (rootArea < bestArea) best = root;
1572
+ }
1573
+ for (UIView* sub in root.subviews) {
1574
+ best = findLabelMatch(sub, text, best);
1575
+ }
1576
+ return best;
1577
+ }
1578
+
1579
+ std::tuple<double, double, double, double>
1580
+ EnnioRuntimeHelper::getViewWindowFrameByLabel(const std::string& text) {
1581
+ NSString* label = [NSString stringWithUTF8String:text.c_str()];
1582
+ __block double rx = 0, ry = 0, rw = 0, rh = 0;
1583
+ void (^block)(void) = ^{
1584
+ UIView* hit = nil;
1585
+ for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) {
1586
+ if (![scene isKindOfClass:[UIWindowScene class]]) continue;
1587
+ for (UIWindow* win in [((UIWindowScene*)scene).windows reverseObjectEnumerator]) {
1588
+ hit = findLabelMatch(win, label, hit);
1589
+ }
1590
+ }
1591
+ if (!hit || !hit.window) return;
1592
+ CGRect inWindow = [hit convertRect:hit.bounds toView:hit.window];
1593
+ rx = inWindow.origin.x;
1594
+ ry = inWindow.origin.y;
1595
+ rw = inWindow.size.width;
1596
+ rh = inWindow.size.height;
1597
+ };
1598
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
1599
+ return {rx, ry, rw, rh};
1600
+ }
1601
+
1602
+ bool EnnioRuntimeHelper::tapByLabel(const std::string& text) {
1603
+ NSString* label = [NSString stringWithUTF8String:text.c_str()];
1604
+ __block bool ok = false;
1605
+ void (^block)(void) = ^{
1606
+ UIView* hit = nil;
1607
+ for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) {
1608
+ if (![scene isKindOfClass:[UIWindowScene class]]) continue;
1609
+ for (UIWindow* win in [((UIWindowScene*)scene).windows reverseObjectEnumerator]) {
1610
+ hit = findLabelMatch(win, label, hit);
1611
+ }
1612
+ }
1613
+ if (!hit) {
1614
+ NSLog(@"[Ennio] tapByLabel: no UIView matched label '%@'", label);
1615
+ return;
1616
+ }
1617
+ // Try the matched view + every ancestor up to the window. RN often
1618
+ // attaches the gesture recognizer on a wrapper, not on the leaf
1619
+ // text label that carries accessibilityLabel.
1620
+ UIView* cursor = hit;
1621
+ while (cursor) {
1622
+ if (fireActivation(cursor)) { ok = true; return; }
1623
+ cursor = cursor.superview;
1624
+ }
1625
+ // Last-resort: synthesized UITouch on the matched view's centre.
1626
+ if (synthesizeTouchAtViewCenter(hit)) { ok = true; return; }
1627
+ NSLog(@"[Ennio] tapByLabel: no activation path on '%@' or any ancestor", label);
1628
+ };
1629
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
1630
+ return ok;
1631
+ }
1632
+
1633
+ bool EnnioRuntimeHelper::longPress(const std::string& testID, int durationMs) {
1634
+ // RN's longPress is driven by a touch-progress timer that we can't
1635
+ // synthesize without real UITouch events. As a best effort, fire
1636
+ // accessibilityActivate (matches what VoiceOver users get) and let
1637
+ // the duration argument be advisory.
1638
+ (void)durationMs;
1639
+ return tap(testID);
1640
+ }
1641
+
1642
+ // Recursively look for a UITextInput descendant. RN often nests the
1643
+ // actual UITextField several levels under the testID-bearing wrapper.
1644
+ static UIView* findTextInputDescendant(UIView* root) {
1645
+ if (!root) return nil;
1646
+ if ([root conformsToProtocol:@protocol(UITextInput)]) return root;
1647
+ for (UIView* sub in root.subviews) {
1648
+ UIView* hit = findTextInputDescendant(sub);
1649
+ if (hit) return hit;
1650
+ }
1651
+ return nil;
1652
+ }
1653
+
1654
+ bool EnnioRuntimeHelper::typeText(const std::string& testID, const std::string& text) {
1655
+ NSString* tid = [NSString stringWithUTF8String:testID.c_str()];
1656
+ NSString* str = [NSString stringWithUTF8String:text.c_str()];
1657
+ __block bool ok = false;
1658
+ void (^block)(void) = ^{
1659
+ UIView* view = findViewByTestIDInAllWindows(tid);
1660
+ if (!view) {
1661
+ NSLog(@"[Ennio] typeText: testID '%@' not found", tid);
1662
+ return;
1663
+ }
1664
+ UIView* input = findTextInputDescendant(view);
1665
+ if (!input) {
1666
+ NSLog(@"[Ennio] typeText: '%@' has no UITextInput descendant (class=%@)",
1667
+ tid, NSStringFromClass([view class]));
1668
+ return;
1669
+ }
1670
+ if (![input isFirstResponder]) {
1671
+ [input becomeFirstResponder];
1672
+ // Run the runloop briefly so the responder chain settles
1673
+ // before we drive input. RN's RCTTextInputComponentView wires
1674
+ // `_eventEmitter` during a runloop tick after the component
1675
+ // view becomes first responder; firing insertText: on the
1676
+ // exact same tick can win the race and lose the JS event.
1677
+ CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.05, false);
1678
+ }
1679
+ // Prefer the UITextField delegate's shouldChangeCharactersInRange:
1680
+ // path when available — that's what UIKit calls during a real
1681
+ // keystroke and is the entry point RN's adapter listens on. Falls
1682
+ // back to insertText: + manual notify for UITextView and
1683
+ // non-RN-managed fields.
1684
+ BOOL handled = NO;
1685
+ if ([input isKindOfClass:[UITextField class]]) {
1686
+ UITextField* tf = (UITextField*)input;
1687
+ id<UITextFieldDelegate> d = tf.delegate;
1688
+ if ([d respondsToSelector:@selector(textField:shouldChangeCharactersInRange:replacementString:)]) {
1689
+ NSRange range = NSMakeRange((tf.text ?: @"").length, 0);
1690
+ BOOL ok = [d textField:tf shouldChangeCharactersInRange:range replacementString:str];
1691
+ if (ok) {
1692
+ tf.text = [(tf.text ?: @"") stringByAppendingString:str];
1693
+ [tf sendActionsForControlEvents:UIControlEventEditingChanged];
1694
+ }
1695
+ handled = YES;
1696
+ }
1697
+ }
1698
+ if (!handled) {
1699
+ [(id<UITextInput>)input insertText:str];
1700
+ }
1701
+ // RN's RCTBaseTextInputView / RCTTextInputComponentView (the
1702
+ // wrapper) propagates the change to JS via -textInputDidChange.
1703
+ // insertText: on the inner RCTUITextField doesn't trigger that,
1704
+ // so controlled inputs in RN keep stale state.
1705
+ //
1706
+ // We try three sources for the wrapper that responds to
1707
+ // textInputDidChange: (1) the testID-bearing view itself
1708
+ // (legacy wraps the UITextField), (2) the input view's
1709
+ // textInputDelegate property (set during init), (3) the input
1710
+ // view's superview chain.
1711
+ SEL didChange = NSSelectorFromString(@"textInputDidChange");
1712
+ // Race: RCTTextInputComponentView's `_eventEmitter` is wired
1713
+ // during a runloop tick after the component view becomes the
1714
+ // backed text input's host. A single textInputDidChange call on
1715
+ // the same tick can hit a nil emitter and drop the JS event,
1716
+ // leaving controlled inputs with stale state. Retry across 3
1717
+ // runloop ticks (50ms each) so the wired emitter catches a
1718
+ // later call. Walk every ancestor: Fabric uses
1719
+ // RCTTextInputComponentView as the wrapper, legacy uses
1720
+ // RCTBaseTextInputView; both respond to -textInputDidChange.
1721
+ for (int retry = 0; retry < 3; retry++) {
1722
+ for (UIView* anc = view; anc; anc = anc.superview) {
1723
+ if ([anc respondsToSelector:didChange]) {
1724
+ #pragma clang diagnostic push
1725
+ #pragma clang diagnostic ignored "-Warc-performSelector-leaks"
1726
+ [anc performSelector:didChange];
1727
+ #pragma clang diagnostic pop
1728
+ }
1729
+ }
1730
+ if ([input isKindOfClass:[UIControl class]]) {
1731
+ [(UIControl*)input sendActionsForControlEvents:UIControlEventEditingChanged];
1732
+ }
1733
+ CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.05, false);
1734
+ }
1735
+ // UITextView path: deliver the change via the delegate. UITextView
1736
+ // doesn't fire UIControlEventEditingChanged (not a UIControl), so
1737
+ // the retry-loop sendActions above is a no-op for it.
1738
+ if ([input isKindOfClass:[UITextView class]]) {
1739
+ id<UITextViewDelegate> d = ((UITextView*)input).delegate;
1740
+ if ([d respondsToSelector:@selector(textViewDidChange:)]) {
1741
+ [d textViewDidChange:(UITextView*)input];
1742
+ }
1743
+ }
1744
+ ok = true;
1745
+ };
1746
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
1747
+ return ok;
1748
+ }
1749
+
1750
+ bool EnnioRuntimeHelper::clearText(const std::string& testID) {
1751
+ NSString* tid = [NSString stringWithUTF8String:testID.c_str()];
1752
+ __block bool ok = false;
1753
+ void (^block)(void) = ^{
1754
+ UIView* view = findViewByTestIDInAllWindows(tid);
1755
+ if (!view) return;
1756
+ UIView* target = view;
1757
+ if (![target conformsToProtocol:@protocol(UITextInput)]) {
1758
+ for (UIView* sub in view.subviews) {
1759
+ if ([sub conformsToProtocol:@protocol(UITextInput)]) { target = sub; break; }
1760
+ }
1761
+ }
1762
+ if (![target conformsToProtocol:@protocol(UITextInput)]) return;
1763
+ if (![target isFirstResponder]) [target becomeFirstResponder];
1764
+ // Select-all then delete fires a single UITextInput change so
1765
+ // React sees one onChangeText with empty string.
1766
+ if ([target respondsToSelector:@selector(selectAll:)]) {
1767
+ [target performSelector:@selector(selectAll:) withObject:nil];
1768
+ }
1769
+ if ([target respondsToSelector:@selector(deleteBackward)]) {
1770
+ [target performSelector:@selector(deleteBackward)];
1771
+ }
1772
+ if ([target isKindOfClass:[UIControl class]]) {
1773
+ [(UIControl*)target sendActionsForControlEvents:UIControlEventEditingChanged];
1774
+ }
1775
+ if ([target isKindOfClass:[UITextView class]]) {
1776
+ id<UITextViewDelegate> d = ((UITextView*)target).delegate;
1777
+ if ([d respondsToSelector:@selector(textViewDidChange:)]) {
1778
+ [d textViewDidChange:(UITextView*)target];
1779
+ }
1780
+ }
1781
+ ok = true;
1782
+ };
1783
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
1784
+ return ok;
1785
+ }
1786
+
1787
+ bool EnnioRuntimeHelper::eraseText(const std::string& testID, int count) {
1788
+ NSString* tid = [NSString stringWithUTF8String:testID.c_str()];
1789
+ __block bool ok = false;
1790
+ void (^block)(void) = ^{
1791
+ UIView* view = findViewByTestIDInAllWindows(tid);
1792
+ if (!view) return;
1793
+ UIView* target = view;
1794
+ if (![target conformsToProtocol:@protocol(UITextInput)]) {
1795
+ for (UIView* sub in view.subviews) {
1796
+ if ([sub conformsToProtocol:@protocol(UITextInput)]) { target = sub; break; }
1797
+ }
1798
+ }
1799
+ if (![target conformsToProtocol:@protocol(UITextInput)]) return;
1800
+ if (![target isFirstResponder]) [target becomeFirstResponder];
1801
+ for (int i = 0; i < count; i++) {
1802
+ if ([target respondsToSelector:@selector(deleteBackward)]) {
1803
+ [target performSelector:@selector(deleteBackward)];
1804
+ }
1805
+ }
1806
+ if ([target isKindOfClass:[UIControl class]]) {
1807
+ [(UIControl*)target sendActionsForControlEvents:UIControlEventEditingChanged];
1808
+ }
1809
+ if ([target isKindOfClass:[UITextView class]]) {
1810
+ id<UITextViewDelegate> d = ((UITextView*)target).delegate;
1811
+ if ([d respondsToSelector:@selector(textViewDidChange:)]) {
1812
+ [d textViewDidChange:(UITextView*)target];
1813
+ }
1814
+ }
1815
+ ok = true;
1816
+ };
1817
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
1818
+ return ok;
1819
+ }
1820
+
1821
+ bool EnnioRuntimeHelper::pressKey(const std::string& testID, const std::string& keyName) {
1822
+ NSString* key = [[NSString stringWithUTF8String:keyName.c_str()] lowercaseString];
1823
+ NSString* tid = [NSString stringWithUTF8String:testID.c_str()];
1824
+ __block bool ok = false;
1825
+ void (^block)(void) = ^{
1826
+ UIView* view = tid.length > 0 ? findViewByTestIDInAllWindows(tid) : findFirstResponder();
1827
+ if (!view) return;
1828
+ UIView* target = view;
1829
+ if (![target conformsToProtocol:@protocol(UITextInput)]) {
1830
+ for (UIView* sub in view.subviews) {
1831
+ if ([sub conformsToProtocol:@protocol(UITextInput)]) { target = sub; break; }
1832
+ }
1833
+ }
1834
+ if (![target conformsToProtocol:@protocol(UITextInput)]) return;
1835
+ if (![target isFirstResponder]) [target becomeFirstResponder];
1836
+
1837
+ if ([key isEqualToString:@"backspace"] || [key isEqualToString:@"delete"]) {
1838
+ if ([target respondsToSelector:@selector(deleteBackward)]) {
1839
+ [target performSelector:@selector(deleteBackward)];
1840
+ ok = true;
1841
+ }
1842
+ } else if ([key isEqualToString:@"return"] || [key isEqualToString:@"enter"]) {
1843
+ [(id<UITextInput>)target insertText:@"\n"];
1844
+ ok = true;
1845
+ } else if ([key isEqualToString:@"tab"]) {
1846
+ [(id<UITextInput>)target insertText:@"\t"];
1847
+ ok = true;
1848
+ } else if ([key isEqualToString:@"space"]) {
1849
+ [(id<UITextInput>)target insertText:@" "];
1850
+ ok = true;
1851
+ } else if (key.length == 1) {
1852
+ [(id<UITextInput>)target insertText:key];
1853
+ ok = true;
1854
+ }
1855
+ };
1856
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
1857
+ return ok;
1858
+ }
1859
+
1860
+ // Find the topmost user-visible UIScrollView in a window tree. Used as a
1861
+ // "scroll something on this screen" fallback when the runner doesn't
1862
+ // hand us a testID — Maestro's `scroll: direction: DOWN` semantics.
1863
+ // `axis` 0 = either, 1 = horizontal-only, 2 = vertical-only.
1864
+ // Direction-aware filtering lets `swipe LEFT/RIGHT` find a horizontal
1865
+ // carousel nested under a vertical outer ScrollView (Home: featured
1866
+ // products carousel inside the page scroller). Without this filter the
1867
+ // outer vertical scroller wins and a LEFT swipe clamps to offset.x=0.
1868
+ static UIScrollView* findTopmostScrollView(UIView* root, int axis) {
1869
+ if (!root || root.hidden || root.alpha < 0.01) return nil;
1870
+ if ([root isKindOfClass:[UIScrollView class]]) {
1871
+ UIScrollView* sv = (UIScrollView*)root;
1872
+ BOOL hScroll = sv.contentSize.width > sv.bounds.size.width;
1873
+ BOOL vScroll = sv.contentSize.height > sv.bounds.size.height;
1874
+ BOOL accept =
1875
+ axis == 0 ? (hScroll || vScroll) :
1876
+ axis == 1 ? hScroll :
1877
+ vScroll;
1878
+ if (accept) return sv;
1879
+ }
1880
+ for (UIView* sub in [root.subviews reverseObjectEnumerator]) {
1881
+ UIScrollView* hit = findTopmostScrollView(sub, axis);
1882
+ if (hit) return hit;
1883
+ }
1884
+ return nil;
1885
+ }
1886
+
1887
+ // testID-less scroll: pick the deepest scrollable on screen, mirroring
1888
+ // what a user would touch. Iterates windows in reverse so the most-
1889
+ // recently-presented (modal/sheet) scroll view wins over the underlying.
1890
+ static UIScrollView* findFirstVisibleScrollView(int axis) {
1891
+ for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) {
1892
+ if (![scene isKindOfClass:[UIWindowScene class]]) continue;
1893
+ for (UIWindow* win in [((UIWindowScene*)scene).windows reverseObjectEnumerator]) {
1894
+ UIScrollView* sv = findTopmostScrollView(win, axis);
1895
+ if (sv) return sv;
1896
+ }
1897
+ }
1898
+ return nil;
1899
+ }
1900
+
1901
+ static UIScrollView* resolveScrollTarget(NSString* tid, int axis) {
1902
+ if (tid.length > 0) {
1903
+ UIView* view = findViewByTestIDInAllWindows(tid);
1904
+ if (view) {
1905
+ return [view isKindOfClass:[UIScrollView class]] ? (UIScrollView*)view : findEnclosingScrollView(view);
1906
+ }
1907
+ }
1908
+ return findFirstVisibleScrollView(axis);
1909
+ }
1910
+
1911
+ static bool scrollImpl(NSString* tid, NSString* direction, double distance) {
1912
+ __block bool ok = false;
1913
+ void (^block)(void) = ^{
1914
+ NSString* d = [direction lowercaseString];
1915
+ int axis = ([d isEqualToString:@"left"] || [d isEqualToString:@"right"]) ? 1
1916
+ : ([d isEqualToString:@"up"] || [d isEqualToString:@"down"]) ? 2
1917
+ : 0;
1918
+ UIScrollView* sv = resolveScrollTarget(tid, axis);
1919
+ if (!sv) return;
1920
+ CGPoint offset = sv.contentOffset;
1921
+ CGFloat dx = 0, dy = 0;
1922
+ if ([d isEqualToString:@"up"]) dy = -distance;
1923
+ else if ([d isEqualToString:@"down"]) dy = distance;
1924
+ else if ([d isEqualToString:@"left"]) dx = -distance;
1925
+ else if ([d isEqualToString:@"right"]) dx = distance;
1926
+ offset.x = MAX(-sv.contentInset.left, MIN(offset.x + dx, sv.contentSize.width - sv.bounds.size.width + sv.contentInset.right));
1927
+ offset.y = MAX(-sv.contentInset.top, MIN(offset.y + dy, sv.contentSize.height - sv.bounds.size.height + sv.contentInset.bottom));
1928
+ [sv setContentOffset:offset animated:NO];
1929
+ ok = true;
1930
+ };
1931
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
1932
+ return ok;
1933
+ }
1934
+
1935
+ bool EnnioRuntimeHelper::scroll(const std::string& testID, const std::string& direction, double distance) {
1936
+ return scrollImpl([NSString stringWithUTF8String:testID.c_str()],
1937
+ [NSString stringWithUTF8String:direction.c_str()],
1938
+ distance);
1939
+ }
1940
+
1941
+ bool EnnioRuntimeHelper::swipe(const std::string& testID, const std::string& direction, double distance) {
1942
+ return scrollImpl([NSString stringWithUTF8String:testID.c_str()],
1943
+ [NSString stringWithUTF8String:direction.c_str()],
1944
+ distance);
1945
+ }
1946
+
1947
+ bool EnnioRuntimeHelper::scrollTo(const std::string& scrollViewTestID, const std::string& elementTestID) {
1948
+ NSString* svId = [NSString stringWithUTF8String:scrollViewTestID.c_str()];
1949
+ NSString* elId = [NSString stringWithUTF8String:elementTestID.c_str()];
1950
+ __block bool ok = false;
1951
+ void (^block)(void) = ^{
1952
+ UIView* elView = findViewByTestIDInAllWindows(elId);
1953
+ if (!elView) return;
1954
+ UIScrollView* sv = nil;
1955
+ if (svId.length > 0) {
1956
+ UIView* svView = findViewByTestIDInAllWindows(svId);
1957
+ if (!svView) return;
1958
+ sv = [svView isKindOfClass:[UIScrollView class]] ? (UIScrollView*)svView : findEnclosingScrollView(svView);
1959
+ } else {
1960
+ // Empty scrollViewTestID — walk up from the element to find
1961
+ // the closest enclosing UIScrollView. Mirrors Maestro/XCUI
1962
+ // scrollToVisible which only needs the target.
1963
+ sv = findEnclosingScrollView(elView);
1964
+ }
1965
+ if (!sv) return;
1966
+ CGRect frame = [elView convertRect:elView.bounds toView:sv];
1967
+ [sv scrollRectToVisible:frame animated:NO];
1968
+ ok = true;
1969
+ };
1970
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
1971
+ return ok;
1972
+ }
1973
+
1974
+ // Plain explicit recursion. The previous implementation used a
1975
+ // self-referential block (`__block find = …; findWeak = find;`) where
1976
+ // the weak capture happened before assignment — if the block ran via a
1977
+ // dispatched continuation before the assignment was observable on the
1978
+ // executing thread, `findWeak` was nil and the recursion no-op'd.
1979
+ static UITabBarController* findTabBarController(UIViewController* vc) {
1980
+ if (!vc) return nil;
1981
+ if ([vc isKindOfClass:[UITabBarController class]]) return (UITabBarController*)vc;
1982
+ for (UIViewController* child in vc.childViewControllers) {
1983
+ UITabBarController* found = findTabBarController(child);
1984
+ if (found) return found;
1985
+ }
1986
+ if (vc.presentedViewController) {
1987
+ return findTabBarController(vc.presentedViewController);
1988
+ }
1989
+ return nil;
1990
+ }
1991
+
1992
+ bool EnnioRuntimeHelper::tapTabByName(const std::string& name) {
1993
+ NSString* needle = [NSString stringWithUTF8String:name.c_str()];
1994
+ __block bool ok = false;
1995
+ void (^block)(void) = ^{
1996
+ for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) {
1997
+ if (![scene isKindOfClass:[UIWindowScene class]]) continue;
1998
+ for (UIWindow* win in ((UIWindowScene*)scene).windows) {
1999
+ UITabBarController* tab = findTabBarController(win.rootViewController);
2000
+ if (!tab) continue;
2001
+ NSUInteger idx = 0;
2002
+ for (UIViewController* vc in tab.viewControllers) {
2003
+ NSString* title = vc.tabBarItem.title.length > 0 ? vc.tabBarItem.title : vc.title;
2004
+ if (title && [title compare:needle options:NSCaseInsensitiveSearch] == NSOrderedSame) {
2005
+ // expo-router top-level routes (e.g. /product/[id],
2006
+ // /orders, /checkout) push over the tab bar via the
2007
+ // root Stack. A tab tap while one of those is on
2008
+ // top would only swap tabs *behind* the pushed VC —
2009
+ // visually nothing changes. Walk up the parent
2010
+ // chain and pop any nav stack that has more than
2011
+ // its root so the tab controller becomes visible.
2012
+ UIViewController* ancestor = tab.parentViewController;
2013
+ while (ancestor) {
2014
+ if ([ancestor isKindOfClass:[UINavigationController class]]) {
2015
+ UINavigationController* nav = (UINavigationController*)ancestor;
2016
+ if (nav.viewControllers.count > 1) {
2017
+ [nav popToRootViewControllerAnimated:NO];
2018
+ }
2019
+ }
2020
+ ancestor = ancestor.parentViewController;
2021
+ }
2022
+ // Programmatic setSelectedIndex: never fires the
2023
+ // delegates. RNScreens emits onNativeFocusChange
2024
+ // from shouldSelect — so call shouldSelect first to
2025
+ // give expo-router NativeTabs a chance to update
2026
+ // its React state. Then set selectedIndex so the
2027
+ // native tabbar visually switches even in
2028
+ // controlled mode (React state will catch up via
2029
+ // the emitted event). Finally call didSelect so
2030
+ // RNScreens' stack-push child of the destination
2031
+ // tab is shown rather than the previous tab's
2032
+ // stale content.
2033
+ if ([tab.delegate respondsToSelector:@selector(tabBarController:shouldSelectViewController:)]) {
2034
+ [tab.delegate tabBarController:tab shouldSelectViewController:vc];
2035
+ }
2036
+ tab.selectedIndex = idx;
2037
+ if ([tab.delegate respondsToSelector:@selector(tabBarController:didSelectViewController:)]) {
2038
+ [tab.delegate tabBarController:tab didSelectViewController:vc];
2039
+ }
2040
+ ok = true;
2041
+ return;
2042
+ }
2043
+ idx++;
2044
+ }
2045
+ }
2046
+ }
2047
+ };
2048
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
2049
+ return ok;
2050
+ }
2051
+
2052
+ bool EnnioRuntimeHelper::tapTab(int index) {
2053
+ __block bool ok = false;
2054
+ void (^block)(void) = ^{
2055
+ for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) {
2056
+ if (![scene isKindOfClass:[UIWindowScene class]]) continue;
2057
+ for (UIWindow* win in ((UIWindowScene*)scene).windows) {
2058
+ UITabBarController* tab = findTabBarController(win.rootViewController);
2059
+ if (tab && index >= 0 && index < (int)tab.viewControllers.count) {
2060
+ UIViewController* vc = tab.viewControllers[index];
2061
+ if ([tab.delegate respondsToSelector:@selector(tabBarController:shouldSelectViewController:)]) {
2062
+ [tab.delegate tabBarController:tab shouldSelectViewController:vc];
2063
+ }
2064
+ tab.selectedIndex = (NSUInteger)index;
2065
+ if ([tab.delegate respondsToSelector:@selector(tabBarController:didSelectViewController:)]) {
2066
+ [tab.delegate tabBarController:tab didSelectViewController:vc];
2067
+ }
2068
+ ok = true;
2069
+ return;
2070
+ }
2071
+ }
2072
+ }
2073
+ };
2074
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
2075
+ return ok;
2076
+ }
2077
+
2078
+ // DFS the VC hierarchy looking for the deepest UINavigationController
2079
+ // whose stack has > 1 controllers. react-native-screens nests its
2080
+ // RNSScreenStackHostController several levels deep, so we can't just walk
2081
+ // up from the topmost VC — we have to scan everything.
2082
+ static UINavigationController* findPoppableNavController(UIViewController* root) {
2083
+ if (!root) return nil;
2084
+ NSMutableArray<UIViewController*>* queue = [NSMutableArray arrayWithObject:root];
2085
+ UINavigationController* deepest = nil;
2086
+ while (queue.count > 0) {
2087
+ UIViewController* vc = queue.firstObject;
2088
+ [queue removeObjectAtIndex:0];
2089
+ if ([vc isKindOfClass:[UINavigationController class]]) {
2090
+ UINavigationController* nav = (UINavigationController*)vc;
2091
+ if (nav.viewControllers.count > 1) {
2092
+ deepest = nav; // Keep the last (deepest) match.
2093
+ }
2094
+ }
2095
+ for (UIViewController* child in vc.childViewControllers) [queue addObject:child];
2096
+ if (vc.presentedViewController) [queue addObject:vc.presentedViewController];
2097
+ }
2098
+ return deepest;
2099
+ }
2100
+
2101
+ std::tuple<double, double, double, double>
2102
+ EnnioRuntimeHelper::getReadyCoord(const std::string& testID, int maxWaitMs) {
2103
+ NSString* tid = [NSString stringWithUTF8String:testID.c_str()];
2104
+ NSTimeInterval start = CACurrentMediaTime();
2105
+ NSTimeInterval deadline = start + maxWaitMs / 1000.0;
2106
+
2107
+ while (true) {
2108
+ __block double rx = 0, ry = 0, rw = 0, rh = 0;
2109
+ __block BOOL ready = NO;
2110
+
2111
+ void (^block)(void) = ^{
2112
+ UIView* view = findViewByTestIDInAllWindows(tid);
2113
+ if (!view || !view.window) return;
2114
+ UIWindow* win = view.window;
2115
+
2116
+ // Chain: every ancestor must accept hits. iOS sets
2117
+ // userInteractionEnabled = NO on UIPresentationController's
2118
+ // containerView only DURING a present/dismiss transition,
2119
+ // so checking the chain catches mid-transition blocking
2120
+ // without falsely rejecting idle screens. Hidden view ⇒
2121
+ // hit-test always misses. Alpha is intentionally NOT
2122
+ // checked: Modal fade-in starts at alpha 0 yet iOS still
2123
+ // delivers the touch to the layer.
2124
+ for (UIView* v = view; v != nil; v = v.superview) {
2125
+ if (!v.userInteractionEnabled) return;
2126
+ if (v.hidden) return;
2127
+ }
2128
+
2129
+ // No alert-level window above us. UIAlertController takes
2130
+ // a window at UIWindowLevelAlert; HID lands on the alert,
2131
+ // not on our target. Wait for it to clear.
2132
+ for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) {
2133
+ if (![scene isKindOfClass:[UIWindowScene class]]) continue;
2134
+ for (UIWindow* w in ((UIWindowScene*)scene).windows) {
2135
+ if (w == win) continue;
2136
+ if (w.hidden) continue;
2137
+ if (w.windowLevel >= UIWindowLevelAlert) return;
2138
+ }
2139
+ }
2140
+
2141
+ CGRect inWindow = [view convertRect:view.bounds toView:win];
2142
+ rx = inWindow.origin.x;
2143
+ ry = inWindow.origin.y;
2144
+ rw = inWindow.size.width;
2145
+ rh = inWindow.size.height;
2146
+ if (rw > 0 && rh > 0) ready = YES;
2147
+ };
2148
+
2149
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
2150
+ if (ready) return {rx, ry, rw, rh};
2151
+ if (CACurrentMediaTime() >= deadline) return {0, 0, 0, 0};
2152
+ [NSThread sleepForTimeInterval:0.020];
2153
+ }
2154
+ }
2155
+
2156
+ // Build a synthesized UITouch the recognizer / responder chain accepts.
2157
+ // Phase is settable on the touch directly via private KVC; window/view
2158
+ // fields are required so RN's touch handlers route the event correctly.
2159
+ static UITouch* makeSynthTouch(UIView* view, UIWindow* window, CGPoint location, UITouchPhase phase) {
2160
+ UITouch* touch = [[UITouch alloc] init];
2161
+ if ([touch respondsToSelector:@selector(_setLocationInWindow:resetPrevious:)]) {
2162
+ [touch _setLocationInWindow:location resetPrevious:NO];
2163
+ } else {
2164
+ [touch setValue:[NSValue valueWithCGPoint:location] forKey:@"locationInWindow"];
2165
+ }
2166
+ [touch setValue:@(phase) forKey:@"phase"];
2167
+ [touch setValue:window forKey:@"window"];
2168
+ [touch setValue:view forKey:@"view"];
2169
+ [touch setValue:@(1) forKey:@"tapCount"];
2170
+ [touch setValue:@([[NSProcessInfo processInfo] systemUptime]) forKey:@"timestamp"];
2171
+ if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) {
2172
+ [touch _setIsFirstTouchForView:YES];
2173
+ }
2174
+ return touch;
2175
+ }
2176
+
2177
+ // Recurse through a view's subtree collecting recognizers. RNGH
2178
+ // attaches its handler's recognizer (RNNativeViewGestureRecognizer /
2179
+ // RNDummyGestureRecognizer) to the host view itself or a hidden child,
2180
+ // so a superview-only walk misses it.
2181
+ static void appendRecognizersFromSubtree(UIView* view, NSMutableArray* out, NSHashTable* seen) {
2182
+ // NSHashTable.weakObjectsHashTable holds zeroing weak refs. If a
2183
+ // descendant view dealloc's mid-walk the entry self-clears, so
2184
+ // there's no dangling-pointer hazard the prior NSValue-based set had.
2185
+ if (!view || [seen containsObject:view]) return;
2186
+ [seen addObject:view];
2187
+ for (UIGestureRecognizer* r in view.gestureRecognizers) {
2188
+ if (r.enabled) [out addObject:r];
2189
+ }
2190
+ for (UIView* sub in view.subviews) {
2191
+ appendRecognizersFromSubtree(sub, out, seen);
2192
+ }
2193
+ }
2194
+
2195
+ // Walk view's own + ancestors' + descendants' recognizers. Deepest-first
2196
+ // (descendants then self then ancestors) so the inner gesture-handler's
2197
+ // recogniser wins gesture-coordinator ties.
2198
+ static NSArray<UIGestureRecognizer*>* collectRecognizersDeepestFirst(UIView* view) {
2199
+ NSMutableArray* out = [NSMutableArray array];
2200
+ NSHashTable* seen = [NSHashTable weakObjectsHashTable];
2201
+ appendRecognizersFromSubtree(view, out, seen);
2202
+ // Then ancestors.
2203
+ for (UIView* v = view.superview; v != nil; v = v.superview) {
2204
+ for (UIGestureRecognizer* r in v.gestureRecognizers) {
2205
+ if (r.enabled) [out addObject:r];
2206
+ }
2207
+ }
2208
+ return out;
2209
+ }
2210
+
2211
+ bool EnnioRuntimeHelper::fireTapByTestID(const std::string& testID) {
2212
+ NSString* tid = [NSString stringWithUTF8String:testID.c_str()];
2213
+ __block bool ok = false;
2214
+
2215
+ void (^block)(void) = ^{
2216
+ UIView* view = findViewByTestIDInAllWindows(tid);
2217
+ if (!view || !view.window) return;
2218
+ UIWindow* window = view.window;
2219
+ CGPoint center = CGPointMake(CGRectGetMidX(view.bounds), CGRectGetMidY(view.bounds));
2220
+ CGPoint inWindow = [view convertPoint:center toView:window];
2221
+ NSLog(@"[Ennio][fireTap] testID=%@ class=%@ window=YES", tid, NSStringFromClass([view class]));
2222
+
2223
+ // Path 1: nearest enabled UIControl ancestor → fire
2224
+ // touchUpInside actions. Covers UIButton, UISwitch, UISlider,
2225
+ // and RNGestureHandlerButton (RNGH BaseButton / pressto) —
2226
+ // RNGH does register UIControl actions even though its public
2227
+ // path is the gesture handler module. If allTargets is empty
2228
+ // for this UIControl, fall through (some custom UIControls
2229
+ // don't use action targets).
2230
+ for (UIView* cursor = view; cursor != nil; cursor = cursor.superview) {
2231
+ if (![cursor isKindOfClass:[UIControl class]]) continue;
2232
+ UIControl* ctrl = (UIControl*)cursor;
2233
+ if (!ctrl.enabled) continue;
2234
+ NSSet* targets = [ctrl allTargets];
2235
+ if (targets.count == 0) continue;
2236
+ NSLog(@"[Ennio][fireTap] sendActions on %@ targets=%lu",
2237
+ NSStringFromClass([ctrl class]), (unsigned long)targets.count);
2238
+ [ctrl sendActionsForControlEvents:UIControlEventTouchUpInside];
2239
+ ok = true;
2240
+ return;
2241
+ }
2242
+
2243
+ // Path 2: drive every gesture recogniser in the view +
2244
+ // ancestor chain by hand. RCTSurfaceTouchHandler (the
2245
+ // RCTRootView-level recogniser RN uses to feed its responder
2246
+ // system) recognises a tap → JS sees onResponderRelease →
2247
+ // Pressability fires onPress. RNNativeViewGestureRecognizer
2248
+ // (RNGH BaseButton/pressto) recognises a tap → JS sees
2249
+ // onActivated → RNGH BaseButton fires onPress. Bypasses
2250
+ // UIWindow.sendEvent so UIPresentationController's
2251
+ // mid-transition gating can't drop the touch. Calling
2252
+ // touchesBegan:/touchesEnded: directly on the recogniser is
2253
+ // the supported entry point — UIKit dispatches there itself
2254
+ // during normal events.
2255
+ NSArray<UIGestureRecognizer*>* recognizers = collectRecognizersDeepestFirst(view);
2256
+ NSLog(@"[Ennio][fireTap] recognizers count=%lu", (unsigned long)recognizers.count);
2257
+ for (UIGestureRecognizer* r in recognizers) {
2258
+ NSLog(@"[Ennio][fireTap] - %@ enabled=%d", NSStringFromClass([r class]), r.enabled);
2259
+ }
2260
+ if (recognizers.count > 0) {
2261
+ UIApplication* app = [UIApplication sharedApplication];
2262
+ UIEvent* event = [app respondsToSelector:@selector(_touchesEvent)] ? [app _touchesEvent] : nil;
2263
+
2264
+ UITouch* touchBegan = makeSynthTouch(view, window, inWindow, UITouchPhaseBegan);
2265
+ NSSet* setBegan = [NSSet setWithObject:touchBegan];
2266
+ for (UIGestureRecognizer* r in recognizers) {
2267
+ @try { [r touchesBegan:setBegan withEvent:event]; }
2268
+ @catch (NSException* e) { NSLog(@"[Ennio] touchesBegan throw: %@", e.reason); }
2269
+ }
2270
+ // Spin the runloop one tick so recognisers can transition
2271
+ // from Possible → Began (some need a frame to commit
2272
+ // intermediate state before they accept Ended).
2273
+ [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.020]];
2274
+
2275
+ UITouch* touchEnded = makeSynthTouch(view, window, inWindow, UITouchPhaseEnded);
2276
+ NSSet* setEnded = [NSSet setWithObject:touchEnded];
2277
+ for (UIGestureRecognizer* r in recognizers) {
2278
+ @try { [r touchesEnded:setEnded withEvent:event]; }
2279
+ @catch (NSException* e) { NSLog(@"[Ennio] touchesEnded throw: %@", e.reason); }
2280
+ }
2281
+ ok = true;
2282
+ return;
2283
+ }
2284
+
2285
+ // Path 3: accessibilityActivate. Last resort because it only
2286
+ // fires onPress for views that explicitly opt in (Pressable
2287
+ // with accessibilityRole="button"). For the rest it returns
2288
+ // YES but does nothing visible. Returning ok=true here is
2289
+ // best-effort; the caller's assertVisible/assertNotVisible will
2290
+ // catch a no-op.
2291
+ if ([view accessibilityActivate]) {
2292
+ ok = true;
2293
+ return;
2294
+ }
2295
+ };
2296
+
2297
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
2298
+ return ok;
2299
+ }
2300
+
2301
+ bool EnnioRuntimeHelper::backGesture() {
2302
+ __block bool ok = false;
2303
+ void (^block)(void) = ^{
2304
+ // Modal dismiss first: RNScreens native-stack with
2305
+ // presentation: 'modal' presents the screen modally, but iOS
2306
+ // also exposes that VC inside a poppable parent nav (RNScreens
2307
+ // wraps the modal screen so it appears in the stack).
2308
+ // popViewControllerAnimated on that nav does NOT dismiss the
2309
+ // modal — RNScreens drives presentation via viewWillAppear
2310
+ // hooks, not the nav stack. So check for presentedVC first.
2311
+ // Find ANY presented modal across all scenes' windows. Skip
2312
+ // system windows (rootVC of class UIViewController plain) — only
2313
+ // the app window's rootVC has the actual containment hierarchy.
2314
+ UIViewController* presented = nil;
2315
+ for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) {
2316
+ if (![scene isKindOfClass:[UIWindowScene class]]) continue;
2317
+ for (UIWindow* win in [((UIWindowScene*)scene).windows reverseObjectEnumerator]) {
2318
+ UIViewController* candidate = topMostViewController(win.rootViewController);
2319
+ if (!candidate) continue;
2320
+ UIViewController* walker = candidate;
2321
+ while (walker && !walker.presentingViewController) {
2322
+ walker = walker.parentViewController;
2323
+ }
2324
+ if (walker && walker.presentingViewController) {
2325
+ presented = walker;
2326
+ break;
2327
+ }
2328
+ }
2329
+ if (presented) break;
2330
+ }
2331
+ if (presented) {
2332
+ [presented dismissViewControllerAnimated:NO completion:nil];
2333
+ ok = true;
2334
+ return;
2335
+ }
2336
+ // No modal — fall back to popping the topmost poppable nav
2337
+ // controller. Walk every connected window: the poppable nav
2338
+ // may live in a window other than the keyWindow (e.g. modal
2339
+ // sheet hosted in its own UIWindow on newer iOS).
2340
+ UINavigationController* nav = nil;
2341
+ for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) {
2342
+ if (![scene isKindOfClass:[UIWindowScene class]]) continue;
2343
+ for (UIWindow* win in [((UIWindowScene*)scene).windows reverseObjectEnumerator]) {
2344
+ UINavigationController* candidate = findPoppableNavController(win.rootViewController);
2345
+ if (candidate) { nav = candidate; break; }
2346
+ }
2347
+ if (nav) break;
2348
+ }
2349
+ if (nav) {
2350
+ [nav popViewControllerAnimated:NO];
2351
+ ok = true;
2352
+ return;
2353
+ }
2354
+ NSLog(@"[Ennio] backGesture: no navigation stack to pop and no presented VC to dismiss");
2355
+ };
2356
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
2357
+ return ok;
2358
+ }
2359
+
2360
+ bool EnnioRuntimeHelper::hideKeyboard() {
2361
+ __block bool ok = false;
2362
+ void (^block)(void) = ^{
2363
+ UIView* fr = findFirstResponder();
2364
+ if (fr) { [fr resignFirstResponder]; ok = true; }
2365
+ };
2366
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
2367
+ return ok;
2368
+ }
2369
+
2370
+ bool EnnioRuntimeHelper::tapAlertButton(const std::string& buttonText) {
2371
+ NSString* title = [NSString stringWithUTF8String:buttonText.c_str()];
2372
+ __block bool ok = false;
2373
+ void (^block)(void) = ^{
2374
+ UIAlertController* alert = findPresentedAlertController();
2375
+ if (!alert) return;
2376
+ for (UIAlertAction* action in alert.actions) {
2377
+ if ([action.title isEqualToString:title]) {
2378
+ invokeAlertAction(alert, action);
2379
+ ok = true;
2380
+ return;
2381
+ }
2382
+ }
2383
+ };
2384
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
2385
+ return ok;
2386
+ }
2387
+
2388
+ bool EnnioRuntimeHelper::dismissAlert() {
2389
+ __block bool ok = false;
2390
+ void (^block)(void) = ^{
2391
+ UIAlertController* alert = findPresentedAlertController();
2392
+ if (!alert) return;
2393
+ UIAlertAction* pick = nil;
2394
+ for (NSString* preferred in @[@"Cancel", @"OK", @"Dismiss"]) {
2395
+ for (UIAlertAction* action in alert.actions) {
2396
+ if ([action.title isEqualToString:preferred]) { pick = action; break; }
2397
+ }
2398
+ if (pick) break;
2399
+ }
2400
+ if (!pick && alert.actions.count > 0) pick = alert.actions.firstObject;
2401
+ if (pick) { invokeAlertAction(alert, pick); ok = true; }
2402
+ };
2403
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
2404
+ return ok;
2405
+ }
2406
+
2407
+ bool EnnioRuntimeHelper::copyToClipboard(const std::string& text) {
2408
+ NSString* str = [NSString stringWithUTF8String:text.c_str()];
2409
+ __block bool ok = false;
2410
+ void (^block)(void) = ^{
2411
+ [UIPasteboard generalPasteboard].string = str;
2412
+ ok = true;
2413
+ };
2414
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
2415
+ return ok;
2416
+ }
2417
+
2418
+ bool EnnioRuntimeHelper::pasteFromClipboard(const std::string& testID) {
2419
+ NSString* clip = [UIPasteboard generalPasteboard].string ?: @"";
2420
+ return typeText(testID, [clip UTF8String]);
2421
+ }
2422
+
2423
+ std::string EnnioRuntimeHelper::getClipboardText() {
2424
+ __block std::string result;
2425
+ void (^block)(void) = ^{
2426
+ NSString* s = [UIPasteboard generalPasteboard].string;
2427
+ if (s) result = [s UTF8String];
2428
+ };
2429
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
2430
+ return result;
2431
+ }
2432
+
2433
+ } // namespace ennio
2434
+
2435
+ // Objective-C helper for setting the surface presenter
2436
+ extern "C" void EnnioSetSurfacePresenter(RCTSurfacePresenter* presenter) {
2437
+ ennio::EnnioRuntimeHelper::getInstance().setSurfacePresenter((__bridge void*)presenter);
2438
+ }
2439
+
2440
+ // Logging helper for C++ code
2441
+ extern "C" void EnnioLogMessage(const char* message) {
2442
+ NSLog(@"%s", message);
2443
+ }