@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.
- package/EnnioCore.podspec +61 -0
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/android/CMakeLists.txt +40 -0
- package/android/build.gradle +64 -0
- package/cpp/ElementMatcher.cpp +661 -0
- package/cpp/ElementMatcher.hpp +244 -0
- package/cpp/EnnioLog.hpp +182 -0
- package/cpp/HybridEnnio.cpp +1161 -0
- package/cpp/HybridEnnio.hpp +174 -0
- package/cpp/IdleMonitor.hpp +277 -0
- package/cpp/Protocol.cpp +135 -0
- package/cpp/Protocol.hpp +47 -0
- package/cpp/SelectorCriteria.hpp +281 -0
- package/cpp/SelectorParser.cpp +649 -0
- package/cpp/SelectorParser.hpp +94 -0
- package/cpp/ShadowTreeTraverser.cpp +305 -0
- package/cpp/ShadowTreeTraverser.hpp +142 -0
- package/cpp/TestIDRegistry.cpp +109 -0
- package/cpp/TestIDRegistry.hpp +84 -0
- package/dist/cli.js +16221 -0
- package/ios/EnnioAutoInit.mm +338 -0
- package/ios/EnnioDebugBanner.h +19 -0
- package/ios/EnnioDebugBanner.mm +178 -0
- package/ios/EnnioRuntimeHelper.h +264 -0
- package/ios/EnnioRuntimeHelper.mm +2443 -0
- package/lib/Ennio.nitro.d.ts +263 -0
- package/lib/Ennio.nitro.d.ts.map +1 -0
- package/lib/Ennio.nitro.js +2 -0
- package/lib/Ennio.nitro.js.map +1 -0
- package/lib/index.d.ts +16 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +45 -0
- package/lib/index.js.map +1 -0
- package/nitro.json +24 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/android/EnnioCore+autolinking.cmake +81 -0
- package/nitrogen/generated/android/EnnioCore+autolinking.gradle +27 -0
- package/nitrogen/generated/android/EnnioCoreOnLoad.cpp +49 -0
- package/nitrogen/generated/android/EnnioCoreOnLoad.hpp +34 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/ennio/EnnioCoreOnLoad.kt +35 -0
- package/nitrogen/generated/ios/EnnioCore+autolinking.rb +62 -0
- package/nitrogen/generated/ios/EnnioCore-Swift-Cxx-Bridge.cpp +17 -0
- package/nitrogen/generated/ios/EnnioCore-Swift-Cxx-Bridge.hpp +27 -0
- package/nitrogen/generated/ios/EnnioCore-Swift-Cxx-Umbrella.hpp +38 -0
- package/nitrogen/generated/ios/EnnioCoreAutolinking.mm +35 -0
- package/nitrogen/generated/ios/EnnioCoreAutolinking.swift +16 -0
- package/nitrogen/generated/shared/c++/ExtendedElementInfo.hpp +118 -0
- package/nitrogen/generated/shared/c++/HybridEnnioSpec.cpp +44 -0
- package/nitrogen/generated/shared/c++/HybridEnnioSpec.hpp +93 -0
- package/nitrogen/generated/shared/c++/LayoutMetrics.hpp +103 -0
- package/nitrogen/generated/shared/c++/ScrollDirection.hpp +84 -0
- package/package.json +78 -0
- package/react-native.config.js +14 -0
- package/src/Ennio.nitro.ts +363 -0
- package/src/cli/hid-daemon.py +129 -0
- 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
|
+
}
|