@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,1161 @@
|
|
|
1
|
+
#include "HybridEnnio.hpp"
|
|
2
|
+
#include "IdleMonitor.hpp"
|
|
3
|
+
#include "EnnioLog.hpp"
|
|
4
|
+
#include "SelectorParser.hpp"
|
|
5
|
+
|
|
6
|
+
#include <thread>
|
|
7
|
+
#include <chrono>
|
|
8
|
+
#include <sstream>
|
|
9
|
+
#include <atomic>
|
|
10
|
+
#include <condition_variable>
|
|
11
|
+
#include <type_traits>
|
|
12
|
+
#include <unordered_map>
|
|
13
|
+
#include <functional>
|
|
14
|
+
#include <cstdio>
|
|
15
|
+
#include <cstdlib>
|
|
16
|
+
#include <cerrno>
|
|
17
|
+
|
|
18
|
+
#include <react/renderer/uimanager/UIManagerBinding.h>
|
|
19
|
+
#include <react/renderer/uimanager/UIManager.h>
|
|
20
|
+
#include <react/renderer/mounting/ShadowTree.h>
|
|
21
|
+
#include <react/renderer/components/view/ViewProps.h>
|
|
22
|
+
|
|
23
|
+
// iOS-specific helper for accessing UIManager
|
|
24
|
+
#if defined(__APPLE__)
|
|
25
|
+
#include "../ios/EnnioRuntimeHelper.h"
|
|
26
|
+
#endif
|
|
27
|
+
|
|
28
|
+
// Logging tag for this module
|
|
29
|
+
static const char* LOG_TAG = "HybridEnnio";
|
|
30
|
+
|
|
31
|
+
// Helper to escape strings for JSON output
|
|
32
|
+
static std::string escapeJsonString(const std::string& str) {
|
|
33
|
+
std::ostringstream oss;
|
|
34
|
+
for (char c : str) {
|
|
35
|
+
switch (c) {
|
|
36
|
+
case '"': oss << "\\\""; break;
|
|
37
|
+
case '\\': oss << "\\\\"; break;
|
|
38
|
+
case '\n': oss << "\\n"; break;
|
|
39
|
+
case '\r': oss << "\\r"; break;
|
|
40
|
+
case '\t': oss << "\\t"; break;
|
|
41
|
+
case '\b': oss << "\\b"; break;
|
|
42
|
+
case '\f': oss << "\\f"; break;
|
|
43
|
+
default:
|
|
44
|
+
if (static_cast<unsigned char>(c) < 0x20) {
|
|
45
|
+
// Control character - escape as unicode
|
|
46
|
+
char buf[8];
|
|
47
|
+
snprintf(buf, sizeof(buf), "\\u%04x", static_cast<unsigned char>(c));
|
|
48
|
+
oss << buf;
|
|
49
|
+
} else {
|
|
50
|
+
oss << c;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return oss.str();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// SFINAE: detect ExtendedElementInfo (has `checked` / `focused` /
|
|
58
|
+
// `selected`). If the type doesn't, those keys are skipped — keeps
|
|
59
|
+
// findByTestID's plain ElementInfo response untouched while
|
|
60
|
+
// findBySelector / findAllBySelector emit the fuller payload.
|
|
61
|
+
template <typename T, typename = void>
|
|
62
|
+
struct hasExtendedFlags : std::false_type {};
|
|
63
|
+
template <typename T>
|
|
64
|
+
struct hasExtendedFlags<T, std::void_t<decltype(std::declval<T>().checked)>> : std::true_type {};
|
|
65
|
+
|
|
66
|
+
// Serialise an ElementInfo (or ExtendedElementInfo) to a JSON object
|
|
67
|
+
// literal. Centralised so findByTestID / findBySelector /
|
|
68
|
+
// findAllBySelector share one truth instead of three near-identical
|
|
69
|
+
// hand-written serialisers (the audit found drift between sites).
|
|
70
|
+
template <typename Info>
|
|
71
|
+
static std::string elementInfoToJson(const Info& info) {
|
|
72
|
+
std::ostringstream oss;
|
|
73
|
+
oss << "{";
|
|
74
|
+
oss << "\"testID\":\"" << escapeJsonString(info.testID) << "\",";
|
|
75
|
+
oss << "\"type\":\"" << escapeJsonString(info.type) << "\",";
|
|
76
|
+
if (info.text.has_value()) {
|
|
77
|
+
oss << "\"text\":\"" << escapeJsonString(info.text.value()) << "\",";
|
|
78
|
+
} else {
|
|
79
|
+
oss << "\"text\":null,";
|
|
80
|
+
}
|
|
81
|
+
oss << "\"accessible\":" << (info.accessible ? "true" : "false") << ",";
|
|
82
|
+
oss << "\"enabled\":" << (info.enabled ? "true" : "false");
|
|
83
|
+
if constexpr (hasExtendedFlags<Info>::value) {
|
|
84
|
+
oss << ",\"checked\":" << (info.checked ? "true" : "false");
|
|
85
|
+
oss << ",\"focused\":" << (info.focused ? "true" : "false");
|
|
86
|
+
oss << ",\"selected\":" << (info.selected ? "true" : "false");
|
|
87
|
+
}
|
|
88
|
+
oss << ",\"layout\":{";
|
|
89
|
+
oss << "\"x\":" << info.layout.x << ",";
|
|
90
|
+
oss << "\"y\":" << info.layout.y << ",";
|
|
91
|
+
oss << "\"width\":" << info.layout.width << ",";
|
|
92
|
+
oss << "\"height\":" << info.layout.height << ",";
|
|
93
|
+
oss << "\"screenX\":" << info.layout.screenX << ",";
|
|
94
|
+
oss << "\"screenY\":" << info.layout.screenY;
|
|
95
|
+
oss << "}}";
|
|
96
|
+
return oss.str();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
namespace margelo::nitro::ennio {
|
|
100
|
+
|
|
101
|
+
// Must call HybridObject(TAG) directly because it's a virtual base class
|
|
102
|
+
HybridEnnio::HybridEnnio() : HybridObject(TAG) {}
|
|
103
|
+
|
|
104
|
+
// ============================================
|
|
105
|
+
// Initialization
|
|
106
|
+
// ============================================
|
|
107
|
+
|
|
108
|
+
void HybridEnnio::initialize(
|
|
109
|
+
std::weak_ptr<facebook::react::UIManager> uiManager,
|
|
110
|
+
facebook::react::SurfaceId surfaceId
|
|
111
|
+
) {
|
|
112
|
+
std::lock_guard<std::mutex> lock(mutex_);
|
|
113
|
+
uiManager_ = uiManager;
|
|
114
|
+
surfaceId_ = surfaceId;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
bool HybridEnnio::isInitialized() const {
|
|
118
|
+
std::lock_guard<std::mutex> lock(mutex_);
|
|
119
|
+
return !uiManager_.expired() && surfaceId_ != 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
ShadowNodePtr HybridEnnio::getShadowTreeRoot() const {
|
|
123
|
+
#if defined(__APPLE__)
|
|
124
|
+
// On iOS, try to get the UIManager through EnnioRuntimeHelper first
|
|
125
|
+
auto& helper = ::ennio::EnnioRuntimeHelper::getInstance();
|
|
126
|
+
|
|
127
|
+
ENNIO_LOG_TRACE(LOG_TAG, ENNIO_LOG_FMT("getShadowTreeRoot: helper initialized=" << (helper.isInitialized() ? "YES" : "NO")));
|
|
128
|
+
|
|
129
|
+
if (helper.isInitialized()) {
|
|
130
|
+
auto uiManager = helper.getUIManager();
|
|
131
|
+
ENNIO_LOG_TRACE(LOG_TAG, ENNIO_LOG_FMT("getShadowTreeRoot: UIManager=" << (uiManager ? "available" : "null")));
|
|
132
|
+
|
|
133
|
+
if (uiManager) {
|
|
134
|
+
auto& shadowTreeRegistry = uiManager->getShadowTreeRegistry();
|
|
135
|
+
ShadowNodePtr rootNode = nullptr;
|
|
136
|
+
|
|
137
|
+
shadowTreeRegistry.enumerate([&](const facebook::react::ShadowTree& shadowTree, bool& stop) {
|
|
138
|
+
rootNode = shadowTree.getCurrentRevision().rootShadowNode;
|
|
139
|
+
stop = true;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (rootNode) {
|
|
143
|
+
return rootNode;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
ENNIO_LOG_DEBUG(LOG_TAG, "getShadowTreeRoot: EnnioRuntimeHelper NOT initialized");
|
|
148
|
+
}
|
|
149
|
+
#endif
|
|
150
|
+
|
|
151
|
+
// Fallback to stored UIManager reference
|
|
152
|
+
auto uiManager = uiManager_.lock();
|
|
153
|
+
if (!uiManager) {
|
|
154
|
+
ENNIO_LOG_WARN(LOG_TAG, "UIManager not available - is EnnioRuntimeHelper initialized?");
|
|
155
|
+
return nullptr;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Get the shadow tree revision provider and get current root
|
|
159
|
+
auto& shadowTreeRegistry = uiManager->getShadowTreeRegistry();
|
|
160
|
+
ShadowNodePtr rootNode = nullptr;
|
|
161
|
+
|
|
162
|
+
shadowTreeRegistry.enumerate([&](const facebook::react::ShadowTree& shadowTree, bool& stop) {
|
|
163
|
+
if (shadowTree.getSurfaceId() == surfaceId_) {
|
|
164
|
+
rootNode = shadowTree.getCurrentRevision().rootShadowNode;
|
|
165
|
+
stop = true;
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
return rootNode;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
ShadowNodePtr HybridEnnio::findNode(const std::string& testID) const {
|
|
173
|
+
ENNIO_LOG_DEBUG(LOG_TAG, ENNIO_LOG_FMT("findNode: testID=" << testID));
|
|
174
|
+
|
|
175
|
+
// Walk the live shadow tree first. The registry caches weak_ptr to nodes
|
|
176
|
+
// captured at update time; after a re-render React produces new nodes for
|
|
177
|
+
// the same testID, but the old shared_ptr can still be alive (held by
|
|
178
|
+
// prior commits). Live walk costs O(n) but matches what the user sees on
|
|
179
|
+
// screen.
|
|
180
|
+
auto root = getShadowTreeRoot();
|
|
181
|
+
if (root) {
|
|
182
|
+
auto found = ::ennio::ShadowTreeTraverser::findByTestID(root, testID);
|
|
183
|
+
if (found) {
|
|
184
|
+
::ennio::TestIDRegistry::getInstance().registerNode(testID, found);
|
|
185
|
+
return found;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Fall back to registry only when we can't reach the live tree.
|
|
190
|
+
return ::ennio::TestIDRegistry::getInstance().findByTestID(testID);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ============================================
|
|
194
|
+
// Element Queries
|
|
195
|
+
// ============================================
|
|
196
|
+
|
|
197
|
+
bool HybridEnnio::exists(const std::string& testID) {
|
|
198
|
+
if (findNode(testID) != nullptr) {
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
bool HybridEnnio::isVisible(const std::string& testID) {
|
|
206
|
+
// UIKit is authoritative. The UIView either has a key-window frame
|
|
207
|
+
// that intersects the visible bounds, or it doesn't. Window-relative
|
|
208
|
+
// frame respects ScrollView offset (shadow-tree screenY does not),
|
|
209
|
+
// so a view at content-Y=2000 in a 900px-tall window correctly
|
|
210
|
+
// reports "not visible" until the user scrolls to it.
|
|
211
|
+
//
|
|
212
|
+
// We deliberately do NOT fall through to the Fabric shadow tree
|
|
213
|
+
// when the UIView is missing or detached. Modal's `visible={false}`
|
|
214
|
+
// dismisses the presented VC but the JSX subtree stays mounted in
|
|
215
|
+
// the fiber tree — falling through would say a closed modal is
|
|
216
|
+
// still visible. For virtualized cells the caller can scroll until
|
|
217
|
+
// the cell mounts; the visibility primitive itself stays UIKit-
|
|
218
|
+
// anchored.
|
|
219
|
+
auto& helper = ::ennio::EnnioRuntimeHelper::getInstance();
|
|
220
|
+
auto frame = helper.getViewWindowFrame(testID);
|
|
221
|
+
bool uiViewMounted = std::get<2>(frame) > 0 && std::get<3>(frame) > 0;
|
|
222
|
+
if (!uiViewMounted) return false;
|
|
223
|
+
return helper.isViewOnscreen(testID);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
std::variant<nitro::NullType, std::string> HybridEnnio::getText(const std::string& testID) {
|
|
227
|
+
auto node = findNode(testID);
|
|
228
|
+
if (!node) {
|
|
229
|
+
return nitro::NullType();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
auto text = ::ennio::ShadowTreeTraverser::getText(node);
|
|
233
|
+
if (!text) {
|
|
234
|
+
return nitro::NullType();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return *text;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ============================================
|
|
241
|
+
// Synchronization
|
|
242
|
+
// ============================================
|
|
243
|
+
|
|
244
|
+
// Idle-detection knobs. Stability is the duration we require the system
|
|
245
|
+
// to stay continuously idle before returning. waitForIdle is the
|
|
246
|
+
// long-budget probe (assertVisible after a launch); synchronize is the
|
|
247
|
+
// short one between commands (just drains pending commits/mounts).
|
|
248
|
+
//
|
|
249
|
+
// Network and animations are excluded by default: tests may have
|
|
250
|
+
// background polling timers, and a Reanimated worklet running a spinner
|
|
251
|
+
// would block forever.
|
|
252
|
+
static constexpr int IDLE_STABILITY_MS = 100;
|
|
253
|
+
static constexpr int SYNCHRONIZE_TIMEOUT_MS = 500;
|
|
254
|
+
static constexpr int SYNCHRONIZE_STABILITY_MS = 50;
|
|
255
|
+
|
|
256
|
+
// Commit signal — incremented from the JS thread via
|
|
257
|
+
// __ennio_native_onCommit (a JSI HostFunction installed in
|
|
258
|
+
// nativeBootstrap). waitForNextCommit blocks on this counter +
|
|
259
|
+
// condition variable until the value advances or maxMs elapses.
|
|
260
|
+
namespace {
|
|
261
|
+
std::mutex g_commitMutex;
|
|
262
|
+
std::condition_variable g_commitCv;
|
|
263
|
+
std::atomic<uint64_t> g_commitCounter{0};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
bool HybridEnnio::waitForIdle(double timeoutMs) {
|
|
267
|
+
return ::ennio::IdleMonitor::getInstance().waitForIdle(
|
|
268
|
+
static_cast<int>(timeoutMs),
|
|
269
|
+
/* includeNetwork */ false,
|
|
270
|
+
/* includeAnimations */ false,
|
|
271
|
+
IDLE_STABILITY_MS
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
void HybridEnnio::synchronize() {
|
|
276
|
+
::ennio::IdleMonitor::getInstance().waitForIdle(
|
|
277
|
+
SYNCHRONIZE_TIMEOUT_MS,
|
|
278
|
+
/* includeNetwork */ false,
|
|
279
|
+
/* includeAnimations */ false,
|
|
280
|
+
SYNCHRONIZE_STABILITY_MS
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
bool HybridEnnio::waitForNextCommit(double maxMs) {
|
|
285
|
+
// Snapshot the current counter; wake when JS bumps it (via the
|
|
286
|
+
// __ennio_native_onCommit HostFunction installed in
|
|
287
|
+
// nativeBootstrap). Cap at maxMs so the worst case is identical
|
|
288
|
+
// to a blind sleep of the same duration — no flake risk.
|
|
289
|
+
uint64_t startId = g_commitCounter.load(std::memory_order_acquire);
|
|
290
|
+
auto deadline = std::chrono::steady_clock::now()
|
|
291
|
+
+ std::chrono::milliseconds(static_cast<long>(maxMs));
|
|
292
|
+
std::unique_lock<std::mutex> lock(g_commitMutex);
|
|
293
|
+
return g_commitCv.wait_until(lock, deadline, [&] {
|
|
294
|
+
return g_commitCounter.load(std::memory_order_acquire) > startId;
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ============================================
|
|
299
|
+
// Command Dispatch
|
|
300
|
+
// ============================================
|
|
301
|
+
//
|
|
302
|
+
// Each handler is `(self, request, response)` — parse args, call the
|
|
303
|
+
// instance method, fill the response. Map lookup is O(1). Reached from
|
|
304
|
+
// the JSI `__ennioDispatch` host function the CLI calls via Hermes
|
|
305
|
+
// Inspector CDP.
|
|
306
|
+
|
|
307
|
+
using HandlerFn = std::function<void(HybridEnnio*, const ::ennio::Request&, ::ennio::Response&)>;
|
|
308
|
+
|
|
309
|
+
static const std::unordered_map<std::string, HandlerFn>& commandHandlers() {
|
|
310
|
+
static const std::unordered_map<std::string, HandlerFn> handlers = {
|
|
311
|
+
// ---- Read queries ----
|
|
312
|
+
{ "exists", [](HybridEnnio* self, const auto& req, auto& r) {
|
|
313
|
+
r.success = true;
|
|
314
|
+
r.data = self->exists(::ennio::json::parseString(req.payload, "testID")) ? "true" : "false";
|
|
315
|
+
}},
|
|
316
|
+
{ "isVisible", [](HybridEnnio* self, const auto& req, auto& r) {
|
|
317
|
+
r.success = true;
|
|
318
|
+
r.data = self->isVisible(::ennio::json::parseString(req.payload, "testID")) ? "true" : "false";
|
|
319
|
+
}},
|
|
320
|
+
{ "isMenuTriggerAncestor", [](HybridEnnio*, const auto& req, auto& r) {
|
|
321
|
+
r.success = true;
|
|
322
|
+
r.data = ::ennio::EnnioRuntimeHelper::getInstance().isMenuTriggerAncestor(
|
|
323
|
+
::ennio::json::parseString(req.payload, "testID")) ? "true" : "false";
|
|
324
|
+
}},
|
|
325
|
+
{ "clearAppData", [](HybridEnnio*, const auto&, auto& r) {
|
|
326
|
+
// In-process sandbox wipe (Library/, Documents/, tmp/). Works
|
|
327
|
+
// identically on Simulator and physical device — no host
|
|
328
|
+
// filesystem access required. Caller restarts the app
|
|
329
|
+
// afterwards to drop in-memory state.
|
|
330
|
+
r.success = ::ennio::EnnioRuntimeHelper::getInstance().clearAppDataDirectories();
|
|
331
|
+
}},
|
|
332
|
+
{ "getText", [](HybridEnnio* self, const auto& req, auto& r) {
|
|
333
|
+
auto result = self->getText(::ennio::json::parseString(req.payload, "testID"));
|
|
334
|
+
r.success = true;
|
|
335
|
+
r.data = std::holds_alternative<nitro::NullType>(result)
|
|
336
|
+
? "null"
|
|
337
|
+
: "\"" + escapeJsonString(std::get<std::string>(result)) + "\"";
|
|
338
|
+
}},
|
|
339
|
+
|
|
340
|
+
// ---- Synchronization ----
|
|
341
|
+
{ "waitForIdle", [](HybridEnnio* self, const auto& req, auto& r) {
|
|
342
|
+
r.success = self->waitForIdle(::ennio::json::parseDouble(req.payload, "timeout"));
|
|
343
|
+
}},
|
|
344
|
+
{ "synchronize", [](HybridEnnio* self, const auto&, auto& r) {
|
|
345
|
+
self->synchronize();
|
|
346
|
+
r.success = true;
|
|
347
|
+
}},
|
|
348
|
+
{ "waitForCommit", [](HybridEnnio* self, const auto& req, auto& r) {
|
|
349
|
+
double maxMs = ::ennio::json::parseDouble(req.payload, "maxMs");
|
|
350
|
+
auto start = std::chrono::steady_clock::now();
|
|
351
|
+
bool gotCommit = self->waitForNextCommit(maxMs);
|
|
352
|
+
auto elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
353
|
+
std::chrono::steady_clock::now() - start).count();
|
|
354
|
+
r.success = true;
|
|
355
|
+
// JSON: { "commit": true|false, "elapsedMs": N }
|
|
356
|
+
std::ostringstream oss;
|
|
357
|
+
oss << "{\"commit\":" << (gotCommit ? "true" : "false")
|
|
358
|
+
<< ",\"elapsedMs\":" << elapsedMs << "}";
|
|
359
|
+
r.data = oss.str();
|
|
360
|
+
}},
|
|
361
|
+
|
|
362
|
+
// ---- Alerts ----
|
|
363
|
+
{ "isAlertPresent", [](HybridEnnio* self, const auto&, auto& r) {
|
|
364
|
+
r.success = true;
|
|
365
|
+
r.data = self->isAlertPresent() ? "true" : "false";
|
|
366
|
+
}},
|
|
367
|
+
{ "getAlertText", [](HybridEnnio* self, const auto&, auto& r) {
|
|
368
|
+
r.success = true;
|
|
369
|
+
r.data = "\"" + escapeJsonString(self->getAlertText()) + "\"";
|
|
370
|
+
}},
|
|
371
|
+
{ "getAlertButtons", [](HybridEnnio* self, const auto&, auto& r) {
|
|
372
|
+
auto buttons = self->getAlertButtons();
|
|
373
|
+
std::ostringstream oss;
|
|
374
|
+
oss << "[";
|
|
375
|
+
for (size_t i = 0; i < buttons.size(); i++) {
|
|
376
|
+
if (i > 0) oss << ",";
|
|
377
|
+
oss << "\"" << escapeJsonString(buttons[i]) << "\"";
|
|
378
|
+
}
|
|
379
|
+
oss << "]";
|
|
380
|
+
r.success = true;
|
|
381
|
+
r.data = oss.str();
|
|
382
|
+
}},
|
|
383
|
+
{ "tapAlertButton", [](HybridEnnio* self, const auto& req, auto& r) {
|
|
384
|
+
r.success = self->tapAlertButton(::ennio::json::parseString(req.payload, "buttonText"));
|
|
385
|
+
}},
|
|
386
|
+
{ "dismissAlert", [](HybridEnnio* self, const auto&, auto& r) {
|
|
387
|
+
r.success = self->dismissAlert();
|
|
388
|
+
}},
|
|
389
|
+
|
|
390
|
+
// ---- Selector queries ----
|
|
391
|
+
{ "findBySelector", [](HybridEnnio* self, const auto& req, auto& r) {
|
|
392
|
+
auto result = self->findBySelector(::ennio::json::parseString(req.payload, "selector"));
|
|
393
|
+
r.success = true;
|
|
394
|
+
r.data = std::holds_alternative<nitro::NullType>(result)
|
|
395
|
+
? "null"
|
|
396
|
+
: elementInfoToJson(std::get<ExtendedElementInfo>(result));
|
|
397
|
+
}},
|
|
398
|
+
{ "findAllBySelector", [](HybridEnnio* self, const auto& req, auto& r) {
|
|
399
|
+
auto results = self->findAllBySelector(::ennio::json::parseString(req.payload, "selector"));
|
|
400
|
+
std::ostringstream oss;
|
|
401
|
+
oss << "[";
|
|
402
|
+
for (size_t i = 0; i < results.size(); i++) {
|
|
403
|
+
if (i > 0) oss << ",";
|
|
404
|
+
oss << elementInfoToJson(results[i]);
|
|
405
|
+
}
|
|
406
|
+
oss << "]";
|
|
407
|
+
r.success = true;
|
|
408
|
+
r.data = oss.str();
|
|
409
|
+
}},
|
|
410
|
+
{ "existsBySelector", [](HybridEnnio* self, const auto& req, auto& r) {
|
|
411
|
+
r.success = true;
|
|
412
|
+
r.data = self->existsBySelector(::ennio::json::parseString(req.payload, "selector")) ? "true" : "false";
|
|
413
|
+
}},
|
|
414
|
+
{ "getTextBySelector", [](HybridEnnio* self, const auto& req, auto& r) {
|
|
415
|
+
auto result = self->getTextBySelector(::ennio::json::parseString(req.payload, "selector"));
|
|
416
|
+
r.success = true;
|
|
417
|
+
r.data = std::holds_alternative<nitro::NullType>(result)
|
|
418
|
+
? "null"
|
|
419
|
+
: "\"" + escapeJsonString(std::get<std::string>(result)) + "\"";
|
|
420
|
+
}},
|
|
421
|
+
{ "isVisibleBySelector", [](HybridEnnio* self, const auto& req, auto& r) {
|
|
422
|
+
r.success = true;
|
|
423
|
+
r.data = self->isVisibleBySelector(::ennio::json::parseString(req.payload, "selector")) ? "true" : "false";
|
|
424
|
+
}},
|
|
425
|
+
|
|
426
|
+
// ---- Direct writes (UIKit/Fabric in-process) ----
|
|
427
|
+
{ "prepareTap", [](HybridEnnio*, const auto& req, auto& r) {
|
|
428
|
+
// Batched: stable-coord poll + auto-scroll + UIMenu check
|
|
429
|
+
// in one JSI call. Saves ~5-10 CDP round trips per tap vs
|
|
430
|
+
// letting the CLI run the poll loop. Empty result string
|
|
431
|
+
// = the testID couldn't be measured on-screen. The actual
|
|
432
|
+
// tap still uses idb HID — UITouch synth misfires on
|
|
433
|
+
// RNGH-wrapped pressables.
|
|
434
|
+
auto out = ::ennio::EnnioRuntimeHelper::getInstance().prepareTap(
|
|
435
|
+
::ennio::json::parseString(req.payload, "testID"),
|
|
436
|
+
::ennio::json::parseDouble(req.payload, "screenW"),
|
|
437
|
+
::ennio::json::parseDouble(req.payload, "screenH"));
|
|
438
|
+
if (out.empty()) {
|
|
439
|
+
r.success = false;
|
|
440
|
+
} else {
|
|
441
|
+
r.success = true;
|
|
442
|
+
r.data = out;
|
|
443
|
+
}
|
|
444
|
+
}},
|
|
445
|
+
{ "tapAtPoint", [](HybridEnnio*, const auto& req, auto& r) {
|
|
446
|
+
// Window-coordinate tap. CLI sends absolute logical points.
|
|
447
|
+
r.success = ::ennio::EnnioRuntimeHelper::getInstance().tapAtScreenPoint(
|
|
448
|
+
::ennio::json::parseDouble(req.payload, "x"),
|
|
449
|
+
::ennio::json::parseDouble(req.payload, "y"));
|
|
450
|
+
}},
|
|
451
|
+
{ "swipeAtPoints", [](HybridEnnio*, const auto& req, auto& r) {
|
|
452
|
+
// Window-coordinate pan: (x1,y1)→(x2,y2) over durationMs.
|
|
453
|
+
// Replaces idb HID swipe — used for cross-screen drags and
|
|
454
|
+
// horizontal carousel panning that doesn't bind to a
|
|
455
|
+
// UIScrollView's scroll axis.
|
|
456
|
+
r.success = ::ennio::EnnioRuntimeHelper::getInstance().swipeAtPoints(
|
|
457
|
+
::ennio::json::parseDouble(req.payload, "x1"),
|
|
458
|
+
::ennio::json::parseDouble(req.payload, "y1"),
|
|
459
|
+
::ennio::json::parseDouble(req.payload, "x2"),
|
|
460
|
+
::ennio::json::parseDouble(req.payload, "y2"),
|
|
461
|
+
static_cast<int>(::ennio::json::parseDouble(req.payload, "durationMs")));
|
|
462
|
+
}},
|
|
463
|
+
{ "pressHardwareKey", [](HybridEnnio*, const auto& req, auto& r) {
|
|
464
|
+
r.success = ::ennio::EnnioRuntimeHelper::getInstance().pressHardwareKey(
|
|
465
|
+
static_cast<int>(::ennio::json::parseDouble(req.payload, "keyCode")));
|
|
466
|
+
}},
|
|
467
|
+
{ "getKeyWindowSize", [](HybridEnnio*, const auto&, auto& r) {
|
|
468
|
+
auto sz = ::ennio::EnnioRuntimeHelper::getInstance().getKeyWindowSize();
|
|
469
|
+
std::ostringstream oss;
|
|
470
|
+
oss << "{\"width\":" << sz.first << ",\"height\":" << sz.second << "}";
|
|
471
|
+
r.data = oss.str();
|
|
472
|
+
r.success = sz.first > 0 && sz.second > 0;
|
|
473
|
+
}},
|
|
474
|
+
{ "getSurfaceOffset", [](HybridEnnio*, const auto&, auto& r) {
|
|
475
|
+
// React-surface origin in the user app's window. Lets the
|
|
476
|
+
// CLI translate Fabric's surface-relative `screenX/screenY`
|
|
477
|
+
// into idb's window-relative coords.
|
|
478
|
+
auto offset = ::ennio::EnnioRuntimeHelper::getInstance().getSurfaceOffset();
|
|
479
|
+
std::ostringstream oss;
|
|
480
|
+
oss << "{\"x\":" << offset.first << ",\"y\":" << offset.second << "}";
|
|
481
|
+
r.data = oss.str();
|
|
482
|
+
r.success = true;
|
|
483
|
+
}},
|
|
484
|
+
{ "getViewWindowFrameByLabel", [](HybridEnnio*, const auto& req, auto& r) {
|
|
485
|
+
auto frame = ::ennio::EnnioRuntimeHelper::getInstance().getViewWindowFrameByLabel(
|
|
486
|
+
::ennio::json::parseString(req.payload, "text"));
|
|
487
|
+
std::ostringstream oss;
|
|
488
|
+
oss << "{\"x\":" << std::get<0>(frame) << ",\"y\":" << std::get<1>(frame)
|
|
489
|
+
<< ",\"width\":" << std::get<2>(frame) << ",\"height\":" << std::get<3>(frame) << "}";
|
|
490
|
+
r.data = oss.str();
|
|
491
|
+
r.success = std::get<2>(frame) > 0 && std::get<3>(frame) > 0;
|
|
492
|
+
}},
|
|
493
|
+
{ "getViewWindowFrame", [](HybridEnnio*, const auto& req, auto& r) {
|
|
494
|
+
// Window-relative UIView frame for a testID. Bypasses Fabric's
|
|
495
|
+
// surface-relative layout — already accounts for ScrollView
|
|
496
|
+
// contentInsetAdjustment, safe-area, modal presentations,
|
|
497
|
+
// and any other runtime offsets UIKit applies.
|
|
498
|
+
auto frame = ::ennio::EnnioRuntimeHelper::getInstance().getViewWindowFrame(
|
|
499
|
+
::ennio::json::parseString(req.payload, "testID"));
|
|
500
|
+
std::ostringstream oss;
|
|
501
|
+
oss << "{\"x\":" << std::get<0>(frame) << ",\"y\":" << std::get<1>(frame)
|
|
502
|
+
<< ",\"width\":" << std::get<2>(frame) << ",\"height\":" << std::get<3>(frame) << "}";
|
|
503
|
+
r.data = oss.str();
|
|
504
|
+
r.success = std::get<2>(frame) > 0 && std::get<3>(frame) > 0;
|
|
505
|
+
}},
|
|
506
|
+
{ "scroll", [](HybridEnnio* self, const auto& req, auto& r) {
|
|
507
|
+
std::string tid = ::ennio::json::parseString(req.payload, "testID");
|
|
508
|
+
std::string dir = ::ennio::json::parseString(req.payload, "direction");
|
|
509
|
+
if (dir.empty()) dir = "down";
|
|
510
|
+
double dist = ::ennio::json::parseDouble(req.payload, "distance");
|
|
511
|
+
if (dist <= 0) dist = 200;
|
|
512
|
+
ScrollDirection sd = ScrollDirection::DOWN;
|
|
513
|
+
if (dir == "up") sd = ScrollDirection::UP;
|
|
514
|
+
else if (dir == "left") sd = ScrollDirection::LEFT;
|
|
515
|
+
else if (dir == "right") sd = ScrollDirection::RIGHT;
|
|
516
|
+
r.success = self->scroll(tid, sd, dist);
|
|
517
|
+
}},
|
|
518
|
+
{ "scrollTo", [](HybridEnnio* self, const auto& req, auto& r) {
|
|
519
|
+
r.success = self->scrollTo(
|
|
520
|
+
::ennio::json::parseString(req.payload, "scrollViewTestID"),
|
|
521
|
+
::ennio::json::parseString(req.payload, "elementTestID"));
|
|
522
|
+
}},
|
|
523
|
+
{ "tapTabByName", [](HybridEnnio*, const auto& req, auto& r) {
|
|
524
|
+
r.success = ::ennio::EnnioRuntimeHelper::getInstance().tapTabByName(
|
|
525
|
+
::ennio::json::parseString(req.payload, "name"));
|
|
526
|
+
}},
|
|
527
|
+
{ "backGesture", [](HybridEnnio* self, const auto&, auto& r) { r.success = self->backGesture(); }},
|
|
528
|
+
{ "hideKeyboard", [](HybridEnnio* self, const auto&, auto& r) { r.success = self->hideKeyboard(); }},
|
|
529
|
+
|
|
530
|
+
// ---- Pasteboard ----
|
|
531
|
+
{ "copyToClipboard", [](HybridEnnio* self, const auto& req, auto& r) {
|
|
532
|
+
r.success = self->copyToClipboard(::ennio::json::parseString(req.payload, "text"));
|
|
533
|
+
}},
|
|
534
|
+
{ "pasteFromClipboard", [](HybridEnnio* self, const auto& req, auto& r) {
|
|
535
|
+
r.success = self->pasteFromClipboard(::ennio::json::parseString(req.payload, "testID"));
|
|
536
|
+
}},
|
|
537
|
+
};
|
|
538
|
+
(void)helper; // suppress unused-capture-style warnings on some compilers.
|
|
539
|
+
return handlers;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
::ennio::Response HybridEnnio::handleCommand(const ::ennio::Request& request) {
|
|
543
|
+
::ennio::Response response;
|
|
544
|
+
response.id = request.id;
|
|
545
|
+
|
|
546
|
+
ENNIO_LOG_DEBUG_F(LOG_TAG, "handleCommand: type=%s", request.type.c_str());
|
|
547
|
+
|
|
548
|
+
const auto& handlers = commandHandlers();
|
|
549
|
+
auto it = handlers.find(request.type);
|
|
550
|
+
if (it == handlers.end()) {
|
|
551
|
+
response.success = false;
|
|
552
|
+
response.error = "Unknown command: " + request.type;
|
|
553
|
+
return response;
|
|
554
|
+
}
|
|
555
|
+
try {
|
|
556
|
+
it->second(this, request, response);
|
|
557
|
+
} catch (const std::exception& e) {
|
|
558
|
+
response.success = false;
|
|
559
|
+
response.error = e.what();
|
|
560
|
+
}
|
|
561
|
+
return response;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ============================================
|
|
565
|
+
// Type Conversions
|
|
566
|
+
// ============================================
|
|
567
|
+
|
|
568
|
+
LayoutMetrics HybridEnnio::convertLayoutMetrics(const ::ennio::LayoutMetrics& metrics) const {
|
|
569
|
+
LayoutMetrics result;
|
|
570
|
+
result.x = metrics.x;
|
|
571
|
+
result.y = metrics.y;
|
|
572
|
+
result.width = metrics.width;
|
|
573
|
+
result.height = metrics.height;
|
|
574
|
+
result.screenX = metrics.screenX;
|
|
575
|
+
result.screenY = metrics.screenY;
|
|
576
|
+
return result;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ============================================
|
|
580
|
+
// Alert/Modal Handling
|
|
581
|
+
// ============================================
|
|
582
|
+
|
|
583
|
+
bool HybridEnnio::isAlertPresent() {
|
|
584
|
+
#if defined(__APPLE__)
|
|
585
|
+
auto& helper = ::ennio::EnnioRuntimeHelper::getInstance();
|
|
586
|
+
return helper.isAlertPresent();
|
|
587
|
+
#else
|
|
588
|
+
// Android: not implemented yet
|
|
589
|
+
return false;
|
|
590
|
+
#endif
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
std::string HybridEnnio::getAlertText() {
|
|
594
|
+
#if defined(__APPLE__)
|
|
595
|
+
auto& helper = ::ennio::EnnioRuntimeHelper::getInstance();
|
|
596
|
+
return helper.getAlertText();
|
|
597
|
+
#else
|
|
598
|
+
return "";
|
|
599
|
+
#endif
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
std::vector<std::string> HybridEnnio::getAlertButtons() {
|
|
603
|
+
#if defined(__APPLE__)
|
|
604
|
+
auto& helper = ::ennio::EnnioRuntimeHelper::getInstance();
|
|
605
|
+
return helper.getAlertButtons();
|
|
606
|
+
#else
|
|
607
|
+
return {};
|
|
608
|
+
#endif
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ============================================
|
|
612
|
+
// Selector-based Methods (Full Maestro Parity)
|
|
613
|
+
// ============================================
|
|
614
|
+
|
|
615
|
+
ShadowNodePtr HybridEnnio::findNodeBySelector(const ::ennio::SelectorCriteria& criteria) const {
|
|
616
|
+
auto root = getShadowTreeRoot();
|
|
617
|
+
if (!root) {
|
|
618
|
+
return nullptr;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return ::ennio::ElementMatcher::findFirst(root, criteria);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
ExtendedElementInfo HybridEnnio::convertExtendedElementInfo(const ::ennio::ExtendedElementInfo& info) const {
|
|
625
|
+
ExtendedElementInfo result;
|
|
626
|
+
result.testID = info.testID;
|
|
627
|
+
result.type = info.type;
|
|
628
|
+
result.text = info.text;
|
|
629
|
+
result.accessible = info.accessible;
|
|
630
|
+
result.enabled = info.enabled;
|
|
631
|
+
result.checked = info.checked;
|
|
632
|
+
result.focused = info.focused;
|
|
633
|
+
result.selected = info.selected;
|
|
634
|
+
|
|
635
|
+
result.layout.x = info.layout.x;
|
|
636
|
+
result.layout.y = info.layout.y;
|
|
637
|
+
result.layout.width = info.layout.width;
|
|
638
|
+
result.layout.height = info.layout.height;
|
|
639
|
+
result.layout.screenX = info.layout.screenX;
|
|
640
|
+
result.layout.screenY = info.layout.screenY;
|
|
641
|
+
|
|
642
|
+
return result;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
std::variant<nitro::NullType, ExtendedElementInfo> HybridEnnio::findBySelector(const std::string& selectorJson) {
|
|
646
|
+
try {
|
|
647
|
+
auto criteria = ::ennio::SelectorParser::parse(selectorJson);
|
|
648
|
+
auto root = getShadowTreeRoot();
|
|
649
|
+
if (!root) {
|
|
650
|
+
return nitro::NullType();
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Walk all matches and prefer the first whose testID resolves to
|
|
654
|
+
// a UIView in the iOS a11y tree. Stops shadow-only matches under
|
|
655
|
+
// inactive tabs / pushed stack frames from being "found" and
|
|
656
|
+
// subsequently tapped against a stale UIView. Matches without
|
|
657
|
+
// testIDs fall through to first-match (text/trait-only selectors).
|
|
658
|
+
auto nodes = ::ennio::ElementMatcher::findAll(root, criteria);
|
|
659
|
+
if (nodes.empty()) {
|
|
660
|
+
return nitro::NullType();
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
auto& helper = ::ennio::EnnioRuntimeHelper::getInstance();
|
|
664
|
+
std::shared_ptr<const facebook::react::ShadowNode> chosen;
|
|
665
|
+
for (const auto& node : nodes) {
|
|
666
|
+
auto testID = ::ennio::ShadowTreeTraverser::getTestID(*node);
|
|
667
|
+
if (!testID) continue;
|
|
668
|
+
if (helper.isInA11yTree(*testID)) { chosen = node; break; }
|
|
669
|
+
}
|
|
670
|
+
if (!chosen) {
|
|
671
|
+
// No a11y-visible testID match. If any node lacks a testID,
|
|
672
|
+
// fall back to the first shadow-tree match (preserves current
|
|
673
|
+
// behavior for text-only / trait-only queries).
|
|
674
|
+
for (const auto& node : nodes) {
|
|
675
|
+
if (!::ennio::ShadowTreeTraverser::getTestID(*node)) { chosen = node; break; }
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (!chosen) {
|
|
679
|
+
return nitro::NullType();
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
auto infoOpt = ::ennio::ElementMatcher::getExtendedElementInfo(root, chosen);
|
|
683
|
+
if (!infoOpt) {
|
|
684
|
+
return nitro::NullType();
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return convertExtendedElementInfo(*infoOpt);
|
|
688
|
+
} catch (const std::exception& e) {
|
|
689
|
+
ENNIO_LOG_ERROR("findBySelector", "Parse error: " << e.what());
|
|
690
|
+
return nitro::NullType();
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
std::vector<ExtendedElementInfo> HybridEnnio::findAllBySelector(const std::string& selectorJson) {
|
|
695
|
+
std::vector<ExtendedElementInfo> results;
|
|
696
|
+
|
|
697
|
+
try {
|
|
698
|
+
auto criteria = ::ennio::SelectorParser::parse(selectorJson);
|
|
699
|
+
auto root = getShadowTreeRoot();
|
|
700
|
+
if (!root) {
|
|
701
|
+
return results;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
auto nodes = ::ennio::ElementMatcher::findAll(root, criteria);
|
|
705
|
+
// a11y filter: drop testID-bearing matches whose UIView isn't in
|
|
706
|
+
// the iOS a11y tree (inactive tab / pushed frame / occluded
|
|
707
|
+
// modal host). Matches without testIDs are kept — caller handles
|
|
708
|
+
// visibility separately for those.
|
|
709
|
+
auto& helper = ::ennio::EnnioRuntimeHelper::getInstance();
|
|
710
|
+
for (const auto& node : nodes) {
|
|
711
|
+
auto testID = ::ennio::ShadowTreeTraverser::getTestID(*node);
|
|
712
|
+
if (testID && !helper.isInA11yTree(*testID)) continue;
|
|
713
|
+
auto infoOpt = ::ennio::ElementMatcher::getExtendedElementInfo(root, node);
|
|
714
|
+
if (infoOpt) {
|
|
715
|
+
results.push_back(convertExtendedElementInfo(*infoOpt));
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
} catch (const std::exception& e) {
|
|
719
|
+
ENNIO_LOG_ERROR("findAllBySelector", "Parse error: " << e.what());
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
return results;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
bool HybridEnnio::existsBySelector(const std::string& selectorJson) {
|
|
726
|
+
try {
|
|
727
|
+
auto criteria = ::ennio::SelectorParser::parse(selectorJson);
|
|
728
|
+
|
|
729
|
+
auto root = getShadowTreeRoot();
|
|
730
|
+
if (!root) return false;
|
|
731
|
+
|
|
732
|
+
// Walk all matches; if any with a testID is in the iOS a11y tree,
|
|
733
|
+
// it exists. Falls back to "any shadow match" when matches lack
|
|
734
|
+
// testIDs (text-only selectors) — the visibility gate is the
|
|
735
|
+
// proper place to enforce a11y for those.
|
|
736
|
+
auto nodes = ::ennio::ElementMatcher::findAll(root, criteria);
|
|
737
|
+
if (nodes.empty()) return false;
|
|
738
|
+
|
|
739
|
+
auto& helper = ::ennio::EnnioRuntimeHelper::getInstance();
|
|
740
|
+
bool hadTestID = false;
|
|
741
|
+
for (const auto& node : nodes) {
|
|
742
|
+
auto testID = ::ennio::ShadowTreeTraverser::getTestID(*node);
|
|
743
|
+
if (!testID) continue;
|
|
744
|
+
hadTestID = true;
|
|
745
|
+
if (helper.isInA11yTree(*testID)) return true;
|
|
746
|
+
}
|
|
747
|
+
// No testID-bearing match: fall back to shadow-tree presence.
|
|
748
|
+
return !hadTestID;
|
|
749
|
+
} catch (const std::exception& e) {
|
|
750
|
+
ENNIO_LOG_ERROR("existsBySelector", "Parse error: " << e.what());
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
std::variant<nitro::NullType, std::string> HybridEnnio::getTextBySelector(const std::string& selectorJson) {
|
|
756
|
+
try {
|
|
757
|
+
auto criteria = ::ennio::SelectorParser::parse(selectorJson);
|
|
758
|
+
auto root = getShadowTreeRoot();
|
|
759
|
+
if (!root) {
|
|
760
|
+
return nitro::NullType();
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Same a11y filter as findBySelector: prefer the first match
|
|
764
|
+
// whose testID is in the iOS a11y tree; fall back to the first
|
|
765
|
+
// testID-less match.
|
|
766
|
+
auto nodes = ::ennio::ElementMatcher::findAll(root, criteria);
|
|
767
|
+
if (nodes.empty()) {
|
|
768
|
+
return nitro::NullType();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
auto& helper = ::ennio::EnnioRuntimeHelper::getInstance();
|
|
772
|
+
std::shared_ptr<const facebook::react::ShadowNode> chosen;
|
|
773
|
+
for (const auto& node : nodes) {
|
|
774
|
+
auto testID = ::ennio::ShadowTreeTraverser::getTestID(*node);
|
|
775
|
+
if (!testID) continue;
|
|
776
|
+
if (helper.isInA11yTree(*testID)) { chosen = node; break; }
|
|
777
|
+
}
|
|
778
|
+
if (!chosen) {
|
|
779
|
+
for (const auto& node : nodes) {
|
|
780
|
+
if (!::ennio::ShadowTreeTraverser::getTestID(*node)) { chosen = node; break; }
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
if (!chosen) {
|
|
784
|
+
return nitro::NullType();
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
auto text = ::ennio::ShadowTreeTraverser::getText(chosen);
|
|
788
|
+
if (!text) {
|
|
789
|
+
return nitro::NullType();
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
return *text;
|
|
793
|
+
} catch (const std::exception& e) {
|
|
794
|
+
ENNIO_LOG_ERROR("getTextBySelector", "Error: " << e.what());
|
|
795
|
+
return nitro::NullType();
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
bool HybridEnnio::isVisibleBySelector(const std::string& selectorJson) {
|
|
800
|
+
ENNIO_LOG_DEBUG_F(LOG_TAG, "isVisibleBySelector: START selector=%s", selectorJson.c_str());
|
|
801
|
+
try {
|
|
802
|
+
auto criteria = ::ennio::SelectorParser::parse(selectorJson);
|
|
803
|
+
|
|
804
|
+
auto root = getShadowTreeRoot();
|
|
805
|
+
if (!root) {
|
|
806
|
+
ENNIO_LOG_DEBUG_F(LOG_TAG, "isVisibleBySelector: no shadow tree root");
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// For multi-match selectors (text, traits, etc.) ANY visible match passes.
|
|
811
|
+
// Avoids returning false when findFirst picks an off-screen sibling but
|
|
812
|
+
// a different match is on-screen.
|
|
813
|
+
auto nodes = ::ennio::ElementMatcher::findAll(root, criteria);
|
|
814
|
+
ENNIO_LOG_DEBUG_F(LOG_TAG, "isVisibleBySelector: findAll returned %zu nodes", nodes.size());
|
|
815
|
+
|
|
816
|
+
if (nodes.empty()) {
|
|
817
|
+
ENNIO_LOG_DEBUG_F(LOG_TAG, "isVisibleBySelector: no matches, returning false");
|
|
818
|
+
return false;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Apply index if specified - only this node counts.
|
|
822
|
+
if (criteria.index.has_value()) {
|
|
823
|
+
int idx = *criteria.index;
|
|
824
|
+
if (idx < 0 || idx >= static_cast<int>(nodes.size())) {
|
|
825
|
+
return false;
|
|
826
|
+
}
|
|
827
|
+
nodes = { nodes[idx] };
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const float width = screenWidth_ > 0 ? screenWidth_ : 430.0f;
|
|
831
|
+
const float height = screenHeight_ > 0 ? screenHeight_ : 932.0f;
|
|
832
|
+
|
|
833
|
+
auto& helper = ::ennio::EnnioRuntimeHelper::getInstance();
|
|
834
|
+
for (const auto& node : nodes) {
|
|
835
|
+
auto testID = ::ennio::ShadowTreeTraverser::getTestID(*node);
|
|
836
|
+
if (testID) {
|
|
837
|
+
// Defer to the UIKit visibility path: it honors the iOS
|
|
838
|
+
// a11y tree (accessibilityElementsHidden), which catches
|
|
839
|
+
// matches under inactive tabs / pushed-under stack frames
|
|
840
|
+
// that the shadow-tree-only check would falsely accept.
|
|
841
|
+
if (helper.isViewOnscreen(*testID)) {
|
|
842
|
+
ENNIO_LOG_DEBUG_F(LOG_TAG, "isVisibleBySelector: visible via testID=%s", testID->c_str());
|
|
843
|
+
return true;
|
|
844
|
+
}
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// No testID - check layout metrics directly
|
|
849
|
+
auto layoutable = dynamic_cast<const facebook::react::LayoutableShadowNode*>(node.get());
|
|
850
|
+
if (!layoutable) continue;
|
|
851
|
+
auto metrics = layoutable->getLayoutMetrics();
|
|
852
|
+
if (metrics.frame.size.width <= 0 || metrics.frame.size.height <= 0) continue;
|
|
853
|
+
if (metrics.frame.origin.x + metrics.frame.size.width < 0) continue;
|
|
854
|
+
if (metrics.frame.origin.y + metrics.frame.size.height < 0) continue;
|
|
855
|
+
if (metrics.frame.origin.x > width) continue;
|
|
856
|
+
if (metrics.frame.origin.y > height) continue;
|
|
857
|
+
ENNIO_LOG_DEBUG_F(LOG_TAG, "isVisibleBySelector: visible via metrics (no testID)");
|
|
858
|
+
return true;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
ENNIO_LOG_DEBUG_F(LOG_TAG, "isVisibleBySelector: no match was visible");
|
|
862
|
+
return false;
|
|
863
|
+
} catch (const std::exception& e) {
|
|
864
|
+
ENNIO_LOG_ERROR("isVisibleBySelector", "Error: " << e.what());
|
|
865
|
+
return false;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// ============================================
|
|
870
|
+
// Fast-mode Writes
|
|
871
|
+
//
|
|
872
|
+
// Each method delegates to EnnioRuntimeHelper, which finds the UIView by
|
|
873
|
+
// accessibilityIdentifier and invokes the matching UIKit / accessibility
|
|
874
|
+
// API. Selector-based variants resolve the testID through the shadow
|
|
875
|
+
// tree first, then dispatch the same write.
|
|
876
|
+
// ============================================
|
|
877
|
+
|
|
878
|
+
#if defined(__APPLE__)
|
|
879
|
+
|
|
880
|
+
namespace {
|
|
881
|
+
|
|
882
|
+
const char* scrollDirectionToString(ScrollDirection direction) {
|
|
883
|
+
switch (direction) {
|
|
884
|
+
case ScrollDirection::UP: return "up";
|
|
885
|
+
case ScrollDirection::DOWN: return "down";
|
|
886
|
+
case ScrollDirection::LEFT: return "left";
|
|
887
|
+
case ScrollDirection::RIGHT: return "right";
|
|
888
|
+
default: return "down";
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
} // namespace
|
|
893
|
+
|
|
894
|
+
bool HybridEnnio::scroll(const std::string& testID, ScrollDirection direction, double distance) {
|
|
895
|
+
return ::ennio::EnnioRuntimeHelper::getInstance().scroll(testID, scrollDirectionToString(direction), distance);
|
|
896
|
+
}
|
|
897
|
+
bool HybridEnnio::scrollTo(const std::string& scrollViewTestID, const std::string& elementTestID) {
|
|
898
|
+
return ::ennio::EnnioRuntimeHelper::getInstance().scrollTo(scrollViewTestID, elementTestID);
|
|
899
|
+
}
|
|
900
|
+
bool HybridEnnio::swipeAtPoints(double x1, double y1, double x2, double y2, double durationMs) {
|
|
901
|
+
return ::ennio::EnnioRuntimeHelper::getInstance().swipeAtPoints(x1, y1, x2, y2, durationMs);
|
|
902
|
+
}
|
|
903
|
+
bool HybridEnnio::pressHardwareKey(double keyCode) {
|
|
904
|
+
return ::ennio::EnnioRuntimeHelper::getInstance().pressHardwareKey(keyCode);
|
|
905
|
+
}
|
|
906
|
+
bool HybridEnnio::backGesture() {
|
|
907
|
+
return ::ennio::EnnioRuntimeHelper::getInstance().backGesture();
|
|
908
|
+
}
|
|
909
|
+
bool HybridEnnio::hideKeyboard() {
|
|
910
|
+
return ::ennio::EnnioRuntimeHelper::getInstance().hideKeyboard();
|
|
911
|
+
}
|
|
912
|
+
bool HybridEnnio::tapAlertButton(const std::string& buttonText) {
|
|
913
|
+
return ::ennio::EnnioRuntimeHelper::getInstance().tapAlertButton(buttonText);
|
|
914
|
+
}
|
|
915
|
+
bool HybridEnnio::dismissAlert() {
|
|
916
|
+
return ::ennio::EnnioRuntimeHelper::getInstance().dismissAlert();
|
|
917
|
+
}
|
|
918
|
+
bool HybridEnnio::copyToClipboard(const std::string& text) {
|
|
919
|
+
return ::ennio::EnnioRuntimeHelper::getInstance().copyToClipboard(text);
|
|
920
|
+
}
|
|
921
|
+
bool HybridEnnio::pasteFromClipboard(const std::string& testID) {
|
|
922
|
+
return ::ennio::EnnioRuntimeHelper::getInstance().pasteFromClipboard(testID);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
#else
|
|
926
|
+
|
|
927
|
+
// Non-Apple stubs so the spec can still build. Android writes are out
|
|
928
|
+
// of scope — they need UIAutomator + a different in-process surface.
|
|
929
|
+
bool HybridEnnio::scroll(const std::string&, ScrollDirection, double) { return false; }
|
|
930
|
+
bool HybridEnnio::scrollTo(const std::string&, const std::string&) { return false; }
|
|
931
|
+
bool HybridEnnio::swipeAtPoints(double, double, double, double, double) { return false; }
|
|
932
|
+
bool HybridEnnio::pressHardwareKey(double) { return false; }
|
|
933
|
+
bool HybridEnnio::backGesture() { return false; }
|
|
934
|
+
bool HybridEnnio::hideKeyboard() { return false; }
|
|
935
|
+
bool HybridEnnio::tapAlertButton(const std::string&) { return false; }
|
|
936
|
+
bool HybridEnnio::dismissAlert() { return false; }
|
|
937
|
+
bool HybridEnnio::copyToClipboard(const std::string&) { return false; }
|
|
938
|
+
bool HybridEnnio::pasteFromClipboard(const std::string&) { return false; }
|
|
939
|
+
|
|
940
|
+
#endif
|
|
941
|
+
|
|
942
|
+
// ============================================
|
|
943
|
+
// JS bridge: runtime capture + commit-signal install.
|
|
944
|
+
// ============================================
|
|
945
|
+
|
|
946
|
+
// React Fiber walker — installed onto globalThis once at boot. Invokes
|
|
947
|
+
// the React onPress closure synchronously, bypassing iOS's gesture
|
|
948
|
+
// pipeline. Living in a C++ string keeps app-side glue minimal: the
|
|
949
|
+
// only JS the app touches is a one-line `__bindRuntime()` call after
|
|
950
|
+
// createHybridObject.
|
|
951
|
+
namespace {
|
|
952
|
+
constexpr const char* kFiberWalkerSource = R"JS(
|
|
953
|
+
(function () {
|
|
954
|
+
// Commit signal — monkey-patch onCommitFiberRoot so the native
|
|
955
|
+
// side learns the moment React finishes a commit. Same pattern
|
|
956
|
+
// React DevTools uses; stable across React versions. The native
|
|
957
|
+
// callback is installed by HybridEnnio::nativeBootstrap right
|
|
958
|
+
// after this snippet is evaluated.
|
|
959
|
+
var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
960
|
+
if (hook && typeof hook.onCommitFiberRoot === 'function') {
|
|
961
|
+
var originalOnCommit = hook.onCommitFiberRoot.bind(hook);
|
|
962
|
+
hook.onCommitFiberRoot = function (rendererID, root, priorityLevel, didError) {
|
|
963
|
+
try { originalOnCommit(rendererID, root, priorityLevel, didError); } catch (e) {}
|
|
964
|
+
if (typeof globalThis.__ennio_native_onCommit === 'function') {
|
|
965
|
+
try { globalThis.__ennio_native_onCommit(); } catch (e) {}
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
})();
|
|
970
|
+
)JS";
|
|
971
|
+
|
|
972
|
+
std::mutex g_jsContextMutex;
|
|
973
|
+
facebook::jsi::Runtime* g_jsRuntime = nullptr;
|
|
974
|
+
HybridEnnio::JSThreadExecutor g_jsExecutor;
|
|
975
|
+
|
|
976
|
+
std::mutex g_instanceMutex;
|
|
977
|
+
std::shared_ptr<HybridEnnio> g_instance;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
void HybridEnnio::setJSThreadExecutor(HybridEnnio::JSThreadExecutor exec) {
|
|
981
|
+
std::lock_guard<std::mutex> lock(g_jsContextMutex);
|
|
982
|
+
g_jsExecutor = std::move(exec);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
void HybridEnnio::nativeBootstrap(facebook::jsi::Runtime& runtime) {
|
|
986
|
+
{
|
|
987
|
+
std::lock_guard<std::mutex> lock(g_jsContextMutex);
|
|
988
|
+
if (g_jsRuntime == nullptr) {
|
|
989
|
+
g_jsRuntime = &runtime;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
try {
|
|
994
|
+
runtime.evaluateJavaScript(
|
|
995
|
+
std::make_shared<facebook::jsi::StringBuffer>(std::string(kFiberWalkerSource)),
|
|
996
|
+
"ennio_fiber_walker.js");
|
|
997
|
+
} catch (const std::exception& e) {
|
|
998
|
+
ENNIO_LOG_TRACE(LOG_TAG, ENNIO_LOG_FMT("nativeBootstrap: walker eval failed: " << e.what()));
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// Install __ennio_native_onCommit as a JSI HostFunction. The
|
|
1003
|
+
// monkey-patched onCommitFiberRoot in the walker calls this on
|
|
1004
|
+
// every React commit; we bump the counter and wake any thread
|
|
1005
|
+
// waiting in waitForNextCommit. Idempotent — overwrites on each
|
|
1006
|
+
// bootstrap.
|
|
1007
|
+
try {
|
|
1008
|
+
auto onCommitFn = facebook::jsi::Function::createFromHostFunction(
|
|
1009
|
+
runtime,
|
|
1010
|
+
facebook::jsi::PropNameID::forAscii(runtime, "__ennio_native_onCommit"),
|
|
1011
|
+
0,
|
|
1012
|
+
[](facebook::jsi::Runtime& rt, const facebook::jsi::Value&,
|
|
1013
|
+
const facebook::jsi::Value*, size_t) -> facebook::jsi::Value {
|
|
1014
|
+
auto next = g_commitCounter.fetch_add(1, std::memory_order_release) + 1;
|
|
1015
|
+
g_commitCv.notify_all();
|
|
1016
|
+
// Mirror to a JS-readable global so the external CLI can
|
|
1017
|
+
// poll commits via `Runtime.evaluate` without going through
|
|
1018
|
+
// the slow async-token path.
|
|
1019
|
+
try {
|
|
1020
|
+
rt.global().setProperty(
|
|
1021
|
+
rt,
|
|
1022
|
+
"__ennioCommitCounter",
|
|
1023
|
+
facebook::jsi::Value(static_cast<double>(next)));
|
|
1024
|
+
} catch (...) {
|
|
1025
|
+
/* runtime might be transitioning; commit signal still wakes the cv */
|
|
1026
|
+
}
|
|
1027
|
+
return facebook::jsi::Value::undefined();
|
|
1028
|
+
});
|
|
1029
|
+
runtime.global().setProperty(runtime, "__ennio_native_onCommit", onCommitFn);
|
|
1030
|
+
// Seed the JS-visible counter so the CLI's first read returns 0
|
|
1031
|
+
// instead of `undefined` before any commit fires.
|
|
1032
|
+
runtime.global().setProperty(runtime, "__ennioCommitCounter", facebook::jsi::Value(0.0));
|
|
1033
|
+
} catch (const std::exception& e) {
|
|
1034
|
+
ENNIO_LOG_TRACE(LOG_TAG, ENNIO_LOG_FMT("nativeBootstrap: onCommit install failed: " << e.what()));
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
std::shared_ptr<HybridEnnio> instance;
|
|
1038
|
+
{
|
|
1039
|
+
std::lock_guard<std::mutex> lock(g_instanceMutex);
|
|
1040
|
+
if (!g_instance) {
|
|
1041
|
+
try {
|
|
1042
|
+
g_instance = std::make_shared<HybridEnnio>();
|
|
1043
|
+
} catch (const std::exception& e) {
|
|
1044
|
+
ENNIO_LOG_TRACE(LOG_TAG, ENNIO_LOG_FMT("nativeBootstrap: ctor threw: " << e.what()));
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
instance = g_instance;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// Seed the JS-side result bucket. External CLI posts work via
|
|
1052
|
+
// `__ennioDispatch(type, payloadJson, token)` and polls
|
|
1053
|
+
// `globalThis.__ennioResults[token]` until the background worker
|
|
1054
|
+
// writes a response.
|
|
1055
|
+
try {
|
|
1056
|
+
auto code = facebook::jsi::String::createFromUtf8(runtime,
|
|
1057
|
+
"globalThis.__ennioResults = globalThis.__ennioResults || {};");
|
|
1058
|
+
runtime.evaluateJavaScript(
|
|
1059
|
+
std::make_shared<facebook::jsi::StringBuffer>(
|
|
1060
|
+
"globalThis.__ennioResults = globalThis.__ennioResults || {};"),
|
|
1061
|
+
"ennio_results_seed.js");
|
|
1062
|
+
(void)code;
|
|
1063
|
+
} catch (const std::exception& e) {
|
|
1064
|
+
ENNIO_LOG_TRACE(LOG_TAG, ENNIO_LOG_FMT("nativeBootstrap: results seed failed: " << e.what()));
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Install `__ennioDispatch(type, payloadJson, token)` JSI host
|
|
1068
|
+
// function. Returns immediately to JS; spawns a background worker
|
|
1069
|
+
// that runs the existing command handlers and schedules a JS-thread
|
|
1070
|
+
// callback to write the response into `globalThis.__ennioResults`.
|
|
1071
|
+
//
|
|
1072
|
+
// Why async + poll vs. synchronous return?
|
|
1073
|
+
// `waitForCommit` blocks until React fires `__ennio_native_onCommit`
|
|
1074
|
+
// (a JS callback). If `__ennioDispatch` blocked the JS thread
|
|
1075
|
+
// waiting for its worker, the React commit could never run and the
|
|
1076
|
+
// waiter would deadlock. Async pattern: worker waits on the cv,
|
|
1077
|
+
// JS thread stays free to advance React, commits fire, cv signals,
|
|
1078
|
+
// worker finishes, result lands on globalThis. CLI polls.
|
|
1079
|
+
try {
|
|
1080
|
+
auto dispatchFn = facebook::jsi::Function::createFromHostFunction(
|
|
1081
|
+
runtime,
|
|
1082
|
+
facebook::jsi::PropNameID::forAscii(runtime, "__ennioDispatch"),
|
|
1083
|
+
3,
|
|
1084
|
+
[](facebook::jsi::Runtime& rt, const facebook::jsi::Value&,
|
|
1085
|
+
const facebook::jsi::Value* args, size_t count) -> facebook::jsi::Value {
|
|
1086
|
+
if (count < 3) return facebook::jsi::Value::undefined();
|
|
1087
|
+
std::string type = args[0].getString(rt).utf8(rt);
|
|
1088
|
+
std::string payloadJson = args[1].getString(rt).utf8(rt);
|
|
1089
|
+
std::string token = args[2].getString(rt).utf8(rt);
|
|
1090
|
+
|
|
1091
|
+
std::shared_ptr<HybridEnnio> inst;
|
|
1092
|
+
JSThreadExecutor exec;
|
|
1093
|
+
{
|
|
1094
|
+
std::lock_guard<std::mutex> ilock(g_instanceMutex);
|
|
1095
|
+
inst = g_instance;
|
|
1096
|
+
}
|
|
1097
|
+
{
|
|
1098
|
+
std::lock_guard<std::mutex> jlock(g_jsContextMutex);
|
|
1099
|
+
exec = g_jsExecutor;
|
|
1100
|
+
}
|
|
1101
|
+
if (!inst || !exec) {
|
|
1102
|
+
return facebook::jsi::Value::undefined();
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
::ennio::Request req;
|
|
1106
|
+
req.id = token;
|
|
1107
|
+
req.type = type;
|
|
1108
|
+
req.payload = payloadJson;
|
|
1109
|
+
|
|
1110
|
+
// Fast path: most handlers run synchronously on the
|
|
1111
|
+
// main thread via `dispatchSyncMainWithTimeout`. Total
|
|
1112
|
+
// time on JS thread is ~1-10 ms — well under the
|
|
1113
|
+
// ~25 ms a CDP-poll round trip would cost. Run inline
|
|
1114
|
+
// and return the JSON directly to the CLI.
|
|
1115
|
+
//
|
|
1116
|
+
// Slow path: `waitForCommit` and `waitForIdle` wait
|
|
1117
|
+
// for the JS thread to run React commits, which can't
|
|
1118
|
+
// happen while THIS host function is blocking the JS
|
|
1119
|
+
// thread. They MUST go through the background worker
|
|
1120
|
+
// + JS-callback to leave the JS thread free for the
|
|
1121
|
+
// commits we're waiting on.
|
|
1122
|
+
const bool needsAsync = (type == "waitForCommit" || type == "waitForIdle");
|
|
1123
|
+
|
|
1124
|
+
if (!needsAsync) {
|
|
1125
|
+
auto resp = inst->handleCommand(req);
|
|
1126
|
+
return facebook::jsi::String::createFromUtf8(rt, resp.toJSON());
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// Async path. Background worker → JS-thread callback
|
|
1130
|
+
// writes into `globalThis.__ennioResults[token]`. CLI
|
|
1131
|
+
// polls.
|
|
1132
|
+
std::thread([inst, req, token, exec]() {
|
|
1133
|
+
auto resp = inst->handleCommand(req);
|
|
1134
|
+
std::string json = resp.toJSON();
|
|
1135
|
+
exec([token, json](facebook::jsi::Runtime& rt2) {
|
|
1136
|
+
try {
|
|
1137
|
+
auto results = rt2.global().getProperty(rt2, "__ennioResults");
|
|
1138
|
+
if (!results.isObject()) {
|
|
1139
|
+
auto fresh = facebook::jsi::Object(rt2);
|
|
1140
|
+
rt2.global().setProperty(rt2, "__ennioResults", fresh);
|
|
1141
|
+
results = rt2.global().getProperty(rt2, "__ennioResults");
|
|
1142
|
+
}
|
|
1143
|
+
results.asObject(rt2).setProperty(
|
|
1144
|
+
rt2,
|
|
1145
|
+
token.c_str(),
|
|
1146
|
+
facebook::jsi::String::createFromUtf8(rt2, json));
|
|
1147
|
+
} catch (...) {
|
|
1148
|
+
/* runtime gone — orphan token, CLI times out. */
|
|
1149
|
+
}
|
|
1150
|
+
});
|
|
1151
|
+
}).detach();
|
|
1152
|
+
|
|
1153
|
+
return facebook::jsi::Value::undefined();
|
|
1154
|
+
});
|
|
1155
|
+
runtime.global().setProperty(runtime, "__ennioDispatch", dispatchFn);
|
|
1156
|
+
} catch (const std::exception& e) {
|
|
1157
|
+
ENNIO_LOG_TRACE(LOG_TAG, ENNIO_LOG_FMT("nativeBootstrap: __ennioDispatch install failed: " << e.what()));
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
} // namespace margelo::nitro::ennio
|