@reactiive/ennio 0.0.3 → 0.0.4

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.
@@ -0,0 +1,274 @@
1
+ // EnnioControlSocket.cpp
2
+ //
3
+ // Unix domain socket transport that bypasses Hermes Inspector / CDP for
4
+ // pure UIKit operations. Sole reason for existing: CDP `Runtime.evaluate`
5
+ // queues on the JS thread, so any handler — even one that touches no JS
6
+ // state — waits for whatever React work is in flight. Tab swaps on iOS 26
7
+ // expose this: native UIKit cost is ~8 ms but CDP wait is ~2 s after a
8
+ // destination-tab first-render kicks off on the JS thread.
9
+ //
10
+ // The socket lives in the app sandbox tmp dir at a stable filename so
11
+ // the CLI can discover it via `simctl get_app_container ... data`. Wire
12
+ // format = newline-delimited JSON. Server reuses the request/response
13
+ // shapes from `Protocol.hpp` so commands here look identical to CDP
14
+ // dispatch. Only a whitelisted subset of handlers is wired — anything
15
+ // that needs JS thread state (`waitForCommit`, `evalScript`, shadow-tree
16
+ // mutations) stays CDP-only.
17
+ //
18
+ // Pure C++ on purpose — uses POSIX sockets + `getenv("TMPDIR")` so the
19
+ // file compiles outside an ObjC++ TU. The whitelisted handlers call into
20
+ // `EnnioRuntimeHelper` which is the ObjC++ wrapper around UIKit; that
21
+ // indirection keeps the socket server portable and lets it be built into
22
+ // the `cpp/` source group of the pod.
23
+
24
+ #include "EnnioControlSocket.h"
25
+
26
+ #include "EnnioRuntimeHelper.h"
27
+ #include "Protocol.hpp"
28
+
29
+ #include <atomic>
30
+ #include <cerrno>
31
+ #include <cstdio>
32
+ #include <cstdlib>
33
+ #include <cstring>
34
+ #include <pthread.h>
35
+ #include <sstream>
36
+ #include <string>
37
+ #include <sys/socket.h>
38
+ #include <sys/stat.h>
39
+ #include <sys/un.h>
40
+ #include <unistd.h>
41
+
42
+ namespace ennio {
43
+
44
+ std::atomic<bool> EnnioControlSocket::g_started{false};
45
+
46
+ static std::string g_socketPath;
47
+
48
+ static std::string computeSocketPath() {
49
+ // sockaddr_un.sun_path is 104 bytes on macOS. The simulator app's
50
+ // sandbox $TMPDIR is ~140 chars (`.../Containers/Data/Application/
51
+ // <UUID>/tmp/`), too long for bind(). Use the host's `/tmp` — the
52
+ // simulator process is just a macOS process so /tmp is shared with
53
+ // the host, and the CLI runs on the host too.
54
+ //
55
+ // Single fixed name: one test run drives one app at a time. If you
56
+ // ever run multiple Ennio test runs concurrently they'll fight for
57
+ // this socket — same constraint as today's Hermes Inspector port.
58
+ return "/tmp/ennio-control.sock";
59
+ }
60
+
61
+ std::string EnnioControlSocket::socketPath() {
62
+ if (g_socketPath.empty()) g_socketPath = computeSocketPath();
63
+ return g_socketPath;
64
+ }
65
+
66
+ // Handle one request. Single big switch keeps the wire surface explicit
67
+ // and audit-able. Adding a new handler is a deliberate act — no
68
+ // auto-registration.
69
+ static Response dispatchRequest(const Request& req) {
70
+ Response r;
71
+ r.id = req.id;
72
+ r.success = false;
73
+
74
+ if (req.type == "tapTabByName") {
75
+ r.success = EnnioRuntimeHelper::getInstance().tapTabByName(
76
+ json::parseString(req.payload, "name"));
77
+ } else if (req.type == "isAlertPresent") {
78
+ // Pure UIKit query — walks UIWindowScene roots looking for a
79
+ // presented UIAlertController. Safe off the JS thread.
80
+ const bool present = EnnioRuntimeHelper::getInstance().isAlertPresent();
81
+ r.success = true;
82
+ r.data = present ? "true" : "false";
83
+ } else if (req.type == "getViewWindowFrame") {
84
+ // Hot-loop op: `writer.layoutCenter` polls this up to 30 times
85
+ // per id-based tap, checking for a stable frame. Going via CDP
86
+ // queues every poll on the JS thread; routing here drops each
87
+ // poll from ~30-200 ms (JS-queue contention) to ~3 ms (socket
88
+ // round-trip + UIKit query). UIKit `convertRect:toView:nil` is
89
+ // safe off the JS thread.
90
+ auto frame = EnnioRuntimeHelper::getInstance().getViewWindowFrame(
91
+ json::parseString(req.payload, "testID"));
92
+ std::ostringstream oss;
93
+ oss << "{\"x\":" << std::get<0>(frame) << ",\"y\":" << std::get<1>(frame)
94
+ << ",\"width\":" << std::get<2>(frame) << ",\"height\":" << std::get<3>(frame) << "}";
95
+ r.data = oss.str();
96
+ r.success = std::get<2>(frame) > 0 && std::get<3>(frame) > 0;
97
+ } else if (req.type == "scrollTo") {
98
+ // Walks up to the enclosing UIScrollView and sets contentOffset
99
+ // to bring the element into view. Pure UIKit on main thread —
100
+ // no JS state needed.
101
+ r.success = EnnioRuntimeHelper::getInstance().scrollTo(
102
+ json::parseString(req.payload, "scrollViewTestID"),
103
+ json::parseString(req.payload, "elementTestID"));
104
+ } else if (req.type == "setSearchBarText") {
105
+ // RNScreens' UISearchBar (headerSearchBarOptions) is native
106
+ // UIKit, not a React-managed view. testID can't reach its
107
+ // inner UITextField and idb HID keystrokes don't reliably
108
+ // deliver on iOS 26 simulator. Assigns .text and fires
109
+ // textDidChange on the delegate so onChangeText fires.
110
+ r.success = EnnioRuntimeHelper::getInstance().setSearchBarText(
111
+ json::parseString(req.payload, "text"));
112
+ } else if (req.type == "focusSearchBar") {
113
+ // RNScreens UISearchBar isn't in the React view tree —
114
+ // testID lookups + accessibility-text HID taps miss the
115
+ // placeholder. Calls becomeFirstResponder on the bar's text
116
+ // field so later inputText routes via appendSearchBarText.
117
+ r.success = EnnioRuntimeHelper::getInstance().focusSearchBar(
118
+ json::parseString(req.payload, "placeholder"));
119
+ } else if (req.type == "appendSearchBarText") {
120
+ // inputText semantics — append to current search bar text.
121
+ r.success = EnnioRuntimeHelper::getInstance().appendSearchBarText(
122
+ json::parseString(req.payload, "text"));
123
+ } else if (req.type == "eraseSearchBarText") {
124
+ // eraseText / clearText semantics on UISearchBar. Pass a
125
+ // very large count (e.g. INT_MAX from the CLI) to clear.
126
+ std::string countStr = json::parseString(req.payload, "count");
127
+ int count = 0;
128
+ try { count = std::stoi(countStr); } catch (...) { count = 0; }
129
+ r.success = EnnioRuntimeHelper::getInstance().eraseSearchBarText(count);
130
+ } else if (req.type == "selectSegmentByLabel") {
131
+ // UISegmentedControl segment selection by visible label.
132
+ // Sidesteps the slow text-tap retry loop that the shadow-tree
133
+ // walk hits on RNCSegmentedControl labels.
134
+ r.success = EnnioRuntimeHelper::getInstance().selectSegmentByLabel(
135
+ json::parseString(req.payload, "label"));
136
+ } else if (req.type == "selectPickerValueByLabel") {
137
+ // Wheel-style UIPickerView selection. HID swipes against the
138
+ // spinner are flaky (touch begin/end timing inconsistent on
139
+ // iOS 26 simulator); this op walks every visible UIPickerView,
140
+ // matches a row by label, and fires the delegate's
141
+ // didSelectRow so RNCPicker emits onValueChange.
142
+ r.success = EnnioRuntimeHelper::getInstance().selectPickerValueByLabel(
143
+ json::parseString(req.payload, "label"));
144
+ } else if (req.type == "ping") {
145
+ r.success = true;
146
+ r.data = "\"pong\"";
147
+ } else {
148
+ r.error = "unknown_type";
149
+ }
150
+ return r;
151
+ }
152
+
153
+ // Read until newline. Returns false on EOF/error.
154
+ static bool readLine(int fd, std::string& out) {
155
+ out.clear();
156
+ char buf[1];
157
+ while (true) {
158
+ ssize_t n = ::read(fd, buf, 1);
159
+ if (n <= 0) return false;
160
+ if (buf[0] == '\n') return true;
161
+ out.push_back(buf[0]);
162
+ if (out.size() > (1u << 20)) return false; // 1 MiB ceiling
163
+ }
164
+ }
165
+
166
+ static bool writeLine(int fd, const std::string& line) {
167
+ std::string framed = line;
168
+ framed.push_back('\n');
169
+ const char* p = framed.data();
170
+ size_t left = framed.size();
171
+ while (left > 0) {
172
+ ssize_t n = ::write(fd, p, left);
173
+ if (n <= 0) return false;
174
+ p += n;
175
+ left -= static_cast<size_t>(n);
176
+ }
177
+ return true;
178
+ }
179
+
180
+ static void serveClient(int fd) {
181
+ std::string line;
182
+ while (readLine(fd, line)) {
183
+ Request req;
184
+ req.id = json::parseString(line, "id");
185
+ req.type = json::parseString(line, "type");
186
+ // payload is itself a JSON object at the "payload" key. The CLI
187
+ // inlines fields; we keep the whole line for downstream
188
+ // parseString lookups — same loose convention as CDP path.
189
+ req.payload = line;
190
+ Response resp = dispatchRequest(req);
191
+ if (!writeLine(fd, resp.toJSON())) break;
192
+ }
193
+ ::close(fd);
194
+ }
195
+
196
+ static void* acceptLoop(void* arg) {
197
+ int srv = static_cast<int>(reinterpret_cast<intptr_t>(arg));
198
+ ::pthread_setname_np("ennio-control-socket");
199
+ while (true) {
200
+ int cfd = ::accept(srv, nullptr, nullptr);
201
+ if (cfd < 0) {
202
+ if (errno == EINTR) continue;
203
+ break;
204
+ }
205
+ // One client at a time. Sequential serves keep UIKit dispatch
206
+ // ordering predictable and side-step multi-writer races on the
207
+ // dispatch table. CLI uses a single persistent connection so
208
+ // there's no real concurrency pressure.
209
+ serveClient(cfd);
210
+ }
211
+ ::close(srv);
212
+ return nullptr;
213
+ }
214
+
215
+ void EnnioControlSocket::start() {
216
+ bool expected = false;
217
+ if (!g_started.compare_exchange_strong(expected, true)) return;
218
+
219
+ const std::string path = socketPath();
220
+
221
+ // Unlink stale sock from a crashed previous run. Best-effort.
222
+ ::unlink(path.c_str());
223
+
224
+ int srv = ::socket(AF_UNIX, SOCK_STREAM, 0);
225
+ if (srv < 0) {
226
+ std::fprintf(stderr, "[Ennio][socket] socket() failed: %s\n", std::strerror(errno));
227
+ g_started.store(false);
228
+ return;
229
+ }
230
+
231
+ sockaddr_un addr{};
232
+ addr.sun_family = AF_UNIX;
233
+ if (path.size() >= sizeof(addr.sun_path)) {
234
+ std::fprintf(stderr, "[Ennio][socket] path too long (%zu): %s\n", path.size(), path.c_str());
235
+ ::close(srv);
236
+ g_started.store(false);
237
+ return;
238
+ }
239
+ std::strncpy(addr.sun_path, path.c_str(), sizeof(addr.sun_path) - 1);
240
+
241
+ if (::bind(srv, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) {
242
+ std::fprintf(stderr, "[Ennio][socket] bind(%s) failed: %s\n", path.c_str(), std::strerror(errno));
243
+ ::close(srv);
244
+ g_started.store(false);
245
+ return;
246
+ }
247
+
248
+ // 0600 — owner-only. Same user as the simulator process / CLI on a
249
+ // dev box. Other local users on a shared CI box cannot connect.
250
+ ::chmod(path.c_str(), 0600);
251
+
252
+ if (::listen(srv, 4) < 0) {
253
+ std::fprintf(stderr, "[Ennio][socket] listen() failed: %s\n", std::strerror(errno));
254
+ ::close(srv);
255
+ ::unlink(path.c_str());
256
+ g_started.store(false);
257
+ return;
258
+ }
259
+
260
+ std::fprintf(stderr, "[Ennio][socket] listening on %s\n", path.c_str());
261
+
262
+ pthread_t t;
263
+ if (::pthread_create(&t, nullptr, &acceptLoop,
264
+ reinterpret_cast<void*>(static_cast<intptr_t>(srv))) != 0) {
265
+ std::fprintf(stderr, "[Ennio][socket] pthread_create failed\n");
266
+ ::close(srv);
267
+ ::unlink(path.c_str());
268
+ g_started.store(false);
269
+ return;
270
+ }
271
+ ::pthread_detach(t);
272
+ }
273
+
274
+ } // namespace ennio
@@ -0,0 +1,41 @@
1
+ // EnnioControlSocket.h
2
+ //
3
+ // Unix domain socket transport that bypasses Hermes Inspector / CDP for
4
+ // pure UIKit operations. Sole reason for existing: CDP `Runtime.evaluate`
5
+ // queues on the JS thread, so any handler — even one that touches no JS
6
+ // state — waits for whatever React work is in flight. Tab swaps on iOS 26
7
+ // expose this: native UIKit cost is ~8 ms but CDP wait is ~2 s after a
8
+ // destination-tab first-render kicks off on the JS thread.
9
+ //
10
+ // The socket lives in the app sandbox tmp dir at a stable filename so
11
+ // the CLI can discover it via `simctl get_app_container ... data`. Wire
12
+ // format = newline-delimited JSON. Server reuses the request/response
13
+ // shapes from `Protocol.cpp` so commands here look identical to CDP
14
+ // dispatch. Only a whitelisted subset of handlers is wired — anything
15
+ // that needs JS thread state (`waitForCommit`, `evalScript`, shadow-tree
16
+ // mutations) stays CDP-only.
17
+
18
+ #pragma once
19
+
20
+ #include <atomic>
21
+ #include <string>
22
+
23
+ namespace ennio {
24
+
25
+ class EnnioControlSocket {
26
+ public:
27
+ // Returns the absolute filesystem path the server listens on, or
28
+ // empty string if not started.
29
+ static std::string socketPath();
30
+
31
+ // Idempotent. Binds the socket, spawns an accept thread, and starts
32
+ // serving. Safe to call from `+load` or later. Fails quietly if the
33
+ // socket is already in use (e.g. orphaned from a crashed previous
34
+ // run — unlink + retry).
35
+ static void start();
36
+
37
+ private:
38
+ static std::atomic<bool> g_started;
39
+ };
40
+
41
+ } // namespace ennio
package/dist/cli.js CHANGED
@@ -10534,11 +10534,152 @@ function selectorToJson(selector) {
10534
10534
  return JSON.stringify(out);
10535
10535
  }
10536
10536
 
10537
+ // src/cli/socket-client.ts
10538
+ var import_node_net = require("node:net");
10539
+ var REQUEST_TIMEOUT_MS = 6e3;
10540
+ var EnnioSocketClient = class {
10541
+ constructor() {
10542
+ this.socket = null;
10543
+ this.buf = "";
10544
+ this.pending = /* @__PURE__ */ new Map();
10545
+ this.idSeq = 0;
10546
+ this.socketPath = null;
10547
+ this.connecting = null;
10548
+ }
10549
+ /**
10550
+ * Discover + connect. Idempotent. Returns true if connected, false if
10551
+ * the socket isn't reachable (caller should fall back to CDP).
10552
+ *
10553
+ * Path convention matches `EnnioControlSocket::computeSocketPath()`:
10554
+ * `/tmp/ennio-<bundleId>.sock` on the host. The simulator shares
10555
+ * `/tmp` with the host filesystem, so the same path is reachable from
10556
+ * both sides. We use `/tmp` rather than the app sandbox tmp because
10557
+ * sockaddr_un.sun_path is 104 bytes on macOS and the sandbox path
10558
+ * exceeds that limit.
10559
+ */
10560
+ async connect(bundleId, _udid) {
10561
+ if (this.socket && !this.socket.destroyed) return true;
10562
+ if (this.connecting) return this.connecting;
10563
+ this.connecting = this.doConnect(bundleId).finally(() => {
10564
+ this.connecting = null;
10565
+ });
10566
+ return this.connecting;
10567
+ }
10568
+ async doConnect(_bundleId) {
10569
+ this.socketPath = `/tmp/ennio-control.sock`;
10570
+ return new Promise((resolve4) => {
10571
+ const s = (0, import_node_net.createConnection)(this.socketPath);
10572
+ const onError = () => {
10573
+ s.destroy();
10574
+ resolve4(false);
10575
+ };
10576
+ s.once("error", onError);
10577
+ s.once("connect", () => {
10578
+ s.off("error", onError);
10579
+ s.on("data", (chunk) => this.onData(chunk));
10580
+ s.on("error", (e) => this.onSocketError(e));
10581
+ s.on("close", () => this.onClose());
10582
+ this.socket = s;
10583
+ resolve4(true);
10584
+ });
10585
+ });
10586
+ }
10587
+ onData(chunk) {
10588
+ this.buf += chunk.toString("utf8");
10589
+ let nl;
10590
+ while ((nl = this.buf.indexOf("\n")) >= 0) {
10591
+ const line = this.buf.slice(0, nl);
10592
+ this.buf = this.buf.slice(nl + 1);
10593
+ if (!line) continue;
10594
+ try {
10595
+ const resp = JSON.parse(line);
10596
+ const p = this.pending.get(resp.id);
10597
+ if (p) {
10598
+ this.pending.delete(resp.id);
10599
+ p.resolve(resp);
10600
+ }
10601
+ } catch {
10602
+ }
10603
+ }
10604
+ }
10605
+ onSocketError(e) {
10606
+ for (const [, p] of this.pending) p.reject(e);
10607
+ this.pending.clear();
10608
+ }
10609
+ onClose() {
10610
+ this.socket = null;
10611
+ const err = new Error("socket closed");
10612
+ for (const [, p] of this.pending) p.reject(err);
10613
+ this.pending.clear();
10614
+ }
10615
+ /**
10616
+ * Send a request, await response. Throws if not connected.
10617
+ */
10618
+ async send(type2, payload = {}) {
10619
+ if (!this.socket || this.socket.destroyed) {
10620
+ throw new Error("socket not connected");
10621
+ }
10622
+ const id = `s${++this.idSeq}`;
10623
+ const line = JSON.stringify({
10624
+ id,
10625
+ type: type2,
10626
+ ...payload
10627
+ }) + "\n";
10628
+ return new Promise((resolve4, reject) => {
10629
+ const timer = setTimeout(() => {
10630
+ this.pending.delete(id);
10631
+ reject(new Error(`socket request timeout: ${type2}`));
10632
+ }, REQUEST_TIMEOUT_MS);
10633
+ this.pending.set(id, {
10634
+ resolve: (r) => {
10635
+ clearTimeout(timer);
10636
+ resolve4(r);
10637
+ },
10638
+ reject: (e) => {
10639
+ clearTimeout(timer);
10640
+ reject(e);
10641
+ }
10642
+ });
10643
+ this.socket.write(line, (err) => {
10644
+ if (err) {
10645
+ clearTimeout(timer);
10646
+ this.pending.delete(id);
10647
+ reject(err);
10648
+ }
10649
+ });
10650
+ });
10651
+ }
10652
+ isConnected() {
10653
+ return !!this.socket && !this.socket.destroyed;
10654
+ }
10655
+ close() {
10656
+ if (this.socket) this.socket.destroy();
10657
+ this.socket = null;
10658
+ }
10659
+ };
10660
+
10537
10661
  // src/cli/client.ts
10538
10662
  var METRO_BASE = process.env.ENNIO_METRO_URL || "http://localhost:8081";
10539
- var REQUEST_TIMEOUT_MS = 3e4;
10663
+ var REQUEST_TIMEOUT_MS2 = 3e4;
10540
10664
  var RECONNECT_TIMEOUT_MS = 6e3;
10541
10665
  var POLL_INTERVAL_MS = 15;
10666
+ var SOCKET_FAST_OPS = /* @__PURE__ */ new Set([
10667
+ "tapTabByName",
10668
+ "isAlertPresent",
10669
+ // `writer.layoutCenter` polls getViewWindowFrame up to 30 times
10670
+ // per id-tap, plus an occasional scrollTo when the element is
10671
+ // off-screen. Routing both through the socket bypasses the
10672
+ // JS-thread queue that previously paced each iteration.
10673
+ "getViewWindowFrame",
10674
+ "scrollTo",
10675
+ "selectPickerValueByLabel",
10676
+ "selectSegmentByLabel",
10677
+ "setSearchBarText",
10678
+ "appendSearchBarText",
10679
+ "eraseSearchBarText",
10680
+ "focusSearchBar",
10681
+ "ping"
10682
+ ]);
10542
10683
  var EnnioClient = class {
10543
10684
  constructor() {
10544
10685
  this.ws = null;
@@ -10546,6 +10687,7 @@ var EnnioClient = class {
10546
10687
  this.rpcId = 0;
10547
10688
  this.tokenSeq = 0;
10548
10689
  this.debuggerUrl = null;
10690
+ this.socketClient = null;
10549
10691
  }
10550
10692
  /**
10551
10693
  * Discover Metro Inspector pages, pick the JS context. Bridgeless
@@ -10625,6 +10767,7 @@ var EnnioClient = class {
10625
10767
  this.ws.close();
10626
10768
  this.ws = null;
10627
10769
  }
10770
+ this.disconnectSocket();
10628
10771
  }
10629
10772
  async reconnect(maxWaitMs = RECONNECT_TIMEOUT_MS) {
10630
10773
  this.disconnect();
@@ -10657,7 +10800,7 @@ var EnnioClient = class {
10657
10800
  this.pending.delete(id);
10658
10801
  reject(new Error(`CDP timeout: ${method}`));
10659
10802
  }
10660
- }, REQUEST_TIMEOUT_MS);
10803
+ }, REQUEST_TIMEOUT_MS2);
10661
10804
  this.pending.set(id, {
10662
10805
  resolve: (r) => {
10663
10806
  clearTimeout(timer);
@@ -10695,7 +10838,57 @@ var EnnioClient = class {
10695
10838
  * Two-phase: post via `__ennioDispatch`, then poll the result slot
10696
10839
  * until the in-app worker writes a response or the timeout expires.
10697
10840
  */
10841
+ /**
10842
+ * Discover + connect the Unix-domain control socket for this app.
10843
+ * Idempotent. Safe to call after every launchApp / clearState — the
10844
+ * underlying client retries discovery and re-opens the socket against
10845
+ * the new app process.
10846
+ */
10847
+ async ensureSocketConnected(bundleId, udid) {
10848
+ if (!this.socketClient) this.socketClient = new EnnioSocketClient();
10849
+ const ok = await this.socketClient.connect(bundleId, udid);
10850
+ if (process.env.ENNIO_DEBUG_SOCKET) {
10851
+ process.stderr.write(`[socket] ensureSocketConnected(${bundleId}) -> ${ok}
10852
+ `);
10853
+ }
10854
+ return ok;
10855
+ }
10856
+ /**
10857
+ * Tear down the socket alongside the CDP WebSocket. Called from
10858
+ * `disconnect()` so launchApp / clearState restart cleanly.
10859
+ */
10860
+ disconnectSocket() {
10861
+ if (this.socketClient) {
10862
+ this.socketClient.close();
10863
+ this.socketClient = null;
10864
+ }
10865
+ }
10698
10866
  async send(type2, payload = {}) {
10867
+ if (SOCKET_FAST_OPS.has(type2) && this.socketClient?.isConnected()) {
10868
+ if (process.env.ENNIO_DEBUG_SOCKET) {
10869
+ process.stderr.write(`[socket] route ${type2}
10870
+ `);
10871
+ }
10872
+ try {
10873
+ const t0 = Date.now();
10874
+ const r = await this.socketClient.send(type2, payload);
10875
+ if (process.env.ENNIO_DEBUG_SOCKET) {
10876
+ process.stderr.write(`[socket] ${type2} done in ${Date.now() - t0}ms
10877
+ `);
10878
+ }
10879
+ return r;
10880
+ } catch {
10881
+ if (process.env.ENNIO_DEBUG_SOCKET) {
10882
+ process.stderr.write(`[socket] ${type2} threw, falling back to CDP
10883
+ `);
10884
+ }
10885
+ }
10886
+ } else if (SOCKET_FAST_OPS.has(type2) && process.env.ENNIO_DEBUG_SOCKET) {
10887
+ process.stderr.write(
10888
+ `[socket] ${type2} NOT routed (connected=${this.socketClient?.isConnected()})
10889
+ `
10890
+ );
10891
+ }
10699
10892
  if (!this.ws || this.ws.readyState !== wrapper_default.OPEN) {
10700
10893
  const ok = await this.reconnect(RECONNECT_TIMEOUT_MS);
10701
10894
  if (!ok) throw new Error("Not connected to Hermes Inspector");
@@ -10710,7 +10903,7 @@ var EnnioClient = class {
10710
10903
  }
10711
10904
  const start = Date.now();
10712
10905
  const pollExpr = `(()=>{const v=globalThis.__ennioResults&&globalThis.__ennioResults[${tokenLit}];if(v!==undefined){delete globalThis.__ennioResults[${tokenLit}];}return v===undefined?null:v;})()`;
10713
- while (Date.now() - start < REQUEST_TIMEOUT_MS) {
10906
+ while (Date.now() - start < REQUEST_TIMEOUT_MS2) {
10714
10907
  const value = await this.eval(pollExpr);
10715
10908
  if (value != null) {
10716
10909
  return JSON.parse(value);
@@ -13815,9 +14008,15 @@ async function runMaestroTests(client, writer, reader, testFilePath, options = {
13815
14008
  const results = { passed: 0, failed: 0, tests: [], client };
13816
14009
  const flow = parseMaestroFile(testFilePath);
13817
14010
  const flowName = flow.name || (0, import_path3.basename)(testFilePath, ".yaml");
14011
+ if (flow.appId) {
14012
+ await client.ensureSocketConnected(flow.appId, process.env.ENNIO_UDID);
14013
+ }
13818
14014
  const reconnectClient = async () => {
13819
14015
  const newClient = new EnnioClient();
13820
14016
  await newClient.connect();
14017
+ if (flow.appId) {
14018
+ await newClient.ensureSocketConnected(flow.appId, process.env.ENNIO_UDID);
14019
+ }
13821
14020
  return newClient;
13822
14021
  };
13823
14022
  const executor = new MaestroExecutor(client, writer, reader, testFilePath, {
@@ -13867,6 +14066,7 @@ async function runMaestroTests(client, writer, reader, testFilePath, options = {
13867
14066
  }
13868
14067
  }
13869
14068
  }
14069
+ executor.printProfileSummary();
13870
14070
  results.client = executor.getClient();
13871
14071
  return results;
13872
14072
  }
@@ -13880,6 +14080,12 @@ var MaestroExecutor = class _MaestroExecutor {
13880
14080
  // false because every fresh sim boots without an override.
13881
14081
  this.airplaneOn = false;
13882
14082
  this.logStart = Date.now();
14083
+ this.logPrev = Date.now();
14084
+ // Per-step timing breadcrumbs. Each verbose log line gets its delta
14085
+ // recorded here, then `printProfileSummary` buckets them by op-type
14086
+ // at flow end so the operator sees "tab tap: 6× = 21 ms, id tap: 14×
14087
+ // = 3287 ms" without grepping. Always collected when verbose is on.
14088
+ this.profileEvents = [];
13883
14089
  this.client = client;
13884
14090
  this.writer = writer;
13885
14091
  this.reader = reader;
@@ -13900,10 +14106,67 @@ var MaestroExecutor = class _MaestroExecutor {
13900
14106
  }
13901
14107
  log(msg) {
13902
14108
  if (this.verbose) {
13903
- const ms = Date.now() - this.logStart;
13904
- console.log(` [+${ms}ms] ${msg}`);
14109
+ const now = Date.now();
14110
+ const ms = now - this.logStart;
14111
+ const delta = now - this.logPrev;
14112
+ this.logPrev = now;
14113
+ const cum = `+${ms}ms`.padStart(8);
14114
+ const d = `\u0394${delta}ms`.padStart(7);
14115
+ console.log(` [${cum} ${d}] ${msg}`);
14116
+ this.profileEvents.push({ msg, delta });
13905
14117
  }
13906
14118
  }
14119
+ /**
14120
+ * Classify a log message into a coarse op-type bucket. Regex-based
14121
+ * so call sites don't have to change. New categories should mirror
14122
+ * what an operator naturally groups when reading the verbose dump.
14123
+ */
14124
+ bucketFor(msg) {
14125
+ if (msg.startsWith("launchApp")) return "launchApp";
14126
+ if (msg.startsWith("tap: ") && /text/.test(msg) && /Cart|Products|Home|Profile/.test(msg))
14127
+ return "tab tap (text)";
14128
+ if (msg.startsWith("tap: ") && /"id"/.test(msg)) return "id tap";
14129
+ if (msg.startsWith("tap: point")) return "point tap";
14130
+ if (msg.startsWith("tapOn: ") && /"id"/.test(msg)) return "tapOn (auto-scroll + setup)";
14131
+ if (msg.startsWith("tapOn: ") && /text/.test(msg)) return "tapOn (text)";
14132
+ if (msg.startsWith("assertVisible") || msg.startsWith("assertNotVisible"))
14133
+ return "assertVisible";
14134
+ if (msg.startsWith("scrollUntilVisible")) return "scrollUntilVisible";
14135
+ if (msg.startsWith("scroll") || msg.startsWith("swipe")) return "scroll/swipe";
14136
+ if (msg.startsWith("runFlow")) return "runFlow";
14137
+ if (msg.startsWith("(tapping alert button")) return "alert tap";
14138
+ if (msg.startsWith("hideKeyboard")) return "hideKeyboard";
14139
+ if (msg.startsWith("inputText") || msg.startsWith("typeText")) return "typeText";
14140
+ return "other";
14141
+ }
14142
+ printProfileSummary() {
14143
+ if (!this.verbose || this.profileEvents.length === 0) return;
14144
+ const buckets = /* @__PURE__ */ new Map();
14145
+ let total = 0;
14146
+ for (const e of this.profileEvents) {
14147
+ total += e.delta;
14148
+ const b = this.bucketFor(e.msg);
14149
+ const cur = buckets.get(b) ?? { total: 0, count: 0 };
14150
+ cur.total += e.delta;
14151
+ cur.count += 1;
14152
+ buckets.set(b, cur);
14153
+ }
14154
+ const rows = Array.from(buckets.entries()).map(([name, { total: t, count }]) => ({ name, total: t, count })).sort((a, b) => b.total - a.total);
14155
+ console.log("");
14156
+ console.log(" \u2500\u2500 Profile (verbose) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
14157
+ console.log(
14158
+ ` ${"bucket".padEnd(34)} ${"count".padStart(5)} ${"total".padStart(8)} ${"avg".padStart(6)} pct`
14159
+ );
14160
+ for (const r of rows) {
14161
+ const pct = total > 0 ? (r.total / total * 100).toFixed(1) : "0.0";
14162
+ const avg = r.count > 0 ? Math.round(r.total / r.count) : 0;
14163
+ console.log(
14164
+ ` ${r.name.padEnd(34)} ${String(r.count).padStart(5)} ${`${r.total}ms`.padStart(8)} ${`${avg}ms`.padStart(6)} ${pct.padStart(4)}%`
14165
+ );
14166
+ }
14167
+ console.log(` ${"TOTAL".padEnd(34)} ${" ".padStart(5)} ${`${total}ms`.padStart(8)}`);
14168
+ console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
14169
+ }
13907
14170
  /**
13908
14171
  * Get current client (may have been replaced by launchApp/clearState)
13909
14172
  */
@@ -14005,6 +14268,51 @@ var MaestroExecutor = class _MaestroExecutor {
14005
14268
  await this.waitCommit(POINT_TAP_SETTLE_MS);
14006
14269
  return;
14007
14270
  }
14271
+ if (selector.text && !selector.id) {
14272
+ const alertUp = await this.reader.isAlertPresent();
14273
+ if (!alertUp) {
14274
+ const r = await this.client.send("tapTabByName", { name: selector.text });
14275
+ if (r?.success === true) {
14276
+ this.log(`tap: ${JSON.stringify(selector)} via ${this.writer.describe("tap")}`);
14277
+ this.lastTappedSelector = selector;
14278
+ await new Promise((r2) => setTimeout(r2, 100));
14279
+ return;
14280
+ }
14281
+ }
14282
+ }
14283
+ if (selector.text && !selector.id) {
14284
+ const sb = await this.client.send("focusSearchBar", {
14285
+ placeholder: selector.text
14286
+ });
14287
+ if (sb?.success === true) {
14288
+ this.log(`tap: ${JSON.stringify(selector)} via search bar focus`);
14289
+ this.lastTappedSelector = selector;
14290
+ await this.waitCommit(TAP_NAV_SETTLE_MS);
14291
+ return;
14292
+ }
14293
+ }
14294
+ if (selector.text && !selector.id) {
14295
+ const seg = await this.client.send("selectSegmentByLabel", {
14296
+ label: selector.text
14297
+ });
14298
+ if (seg?.success === true) {
14299
+ this.log(`tap: ${JSON.stringify(selector)} via segmented control`);
14300
+ this.lastTappedSelector = selector;
14301
+ await this.waitCommit(TAP_NAV_SETTLE_MS);
14302
+ return;
14303
+ }
14304
+ }
14305
+ if (selector.text && !selector.id) {
14306
+ const r = await this.client.send("selectPickerValueByLabel", {
14307
+ label: selector.text
14308
+ });
14309
+ if (r?.success === true) {
14310
+ this.log(`tap: ${JSON.stringify(selector)} via picker selectRow`);
14311
+ this.lastTappedSelector = selector;
14312
+ await this.waitCommit(TAP_NAV_SETTLE_MS);
14313
+ return;
14314
+ }
14315
+ }
14008
14316
  if (selector.text && !selector.id) {
14009
14317
  const alertPoll = opts.optional ? 200 : 2e3;
14010
14318
  const ok2 = await this.tryTapAlertButton(selector.text, alertPoll);
@@ -14314,6 +14622,12 @@ var MaestroExecutor = class _MaestroExecutor {
14314
14622
  if ("inputText" in cmd) {
14315
14623
  const text = cmd.inputText;
14316
14624
  this.log(`inputText: "${text}"`);
14625
+ const sbar = await this.client.send("appendSearchBarText", { text });
14626
+ if (sbar?.success === true) {
14627
+ this.log(`inputText: via UISearchBar delegate`);
14628
+ await this.waitCommit(TAP_BACK_RECOVER_DELAY_MS);
14629
+ return;
14630
+ }
14317
14631
  await this.typeText(text);
14318
14632
  return;
14319
14633
  }
@@ -14482,6 +14796,12 @@ var MaestroExecutor = class _MaestroExecutor {
14482
14796
  const eraseCmd = cmd.eraseText;
14483
14797
  const chars = typeof eraseCmd === "number" ? eraseCmd : eraseCmd.characters || 50;
14484
14798
  this.log(`eraseText: ${chars} characters`);
14799
+ const sbar = await this.client.send("eraseSearchBarText", { count: String(chars) });
14800
+ if (sbar?.success === true) {
14801
+ this.log(`eraseText: via UISearchBar delegate`);
14802
+ await this.waitCommit(TAP_BACK_RECOVER_DELAY_MS);
14803
+ return;
14804
+ }
14485
14805
  await this.writer.eraseText(this.lastTappedSelector?.id ?? null, chars);
14486
14806
  return;
14487
14807
  }
@@ -14956,6 +15276,8 @@ var MaestroExecutor = class _MaestroExecutor {
14956
15276
  }
14957
15277
  }
14958
15278
  if (!connected) throw new Error("clearState: Failed to reconnect to app after restart");
15279
+ this.writer.setClient(this.client);
15280
+ this.reader.setClient(this.client);
14959
15281
  this.writer.invalidateViewportCache();
14960
15282
  try {
14961
15283
  await this.client.waitForIdle(POST_LAUNCH_IDLE_BUDGET_MS);
@@ -14991,6 +15313,8 @@ var MaestroExecutor = class _MaestroExecutor {
14991
15313
  }
14992
15314
  }
14993
15315
  if (!connected) throw new Error("launchApp: Failed to reconnect to app after restart");
15316
+ this.writer.setClient(this.client);
15317
+ this.reader.setClient(this.client);
14994
15318
  this.writer.invalidateViewportCache();
14995
15319
  await this.sleep(POST_LAUNCH_SHADOW_COMMIT_MS);
14996
15320
  try {
@@ -15263,6 +15587,23 @@ var HidDaemon = class {
15263
15587
  swipe(x1, y1, x2, y2, durationMs = 300) {
15264
15588
  return this.send(`swipe ${x1} ${y1} ${x2} ${y2} ${durationMs}`);
15265
15589
  }
15590
+ key(keyCode) {
15591
+ return this.send(`key ${keyCode}`);
15592
+ }
15593
+ // Bulk key repeat in ONE gRPC call. Saves the ~160 ms per-call
15594
+ // subprocess spawn cost that `eraseText(50)` / `clearText(100)`
15595
+ // used to pay 50–100× — the whole sequence now lands in ~50 ms.
15596
+ keyRepeat(keyCode, count) {
15597
+ if (count <= 0) return Promise.resolve();
15598
+ return this.send(`keyrep ${keyCode} ${count}`);
15599
+ }
15600
+ // Send arbitrary text via idb's `text` (USB HID layer). Encodes the
15601
+ // string as JSON on the wire so it can contain spaces / unicode /
15602
+ // control chars without breaking the line-delimited protocol.
15603
+ text(text) {
15604
+ if (!text) return Promise.resolve();
15605
+ return this.send(`text ${JSON.stringify(text)}`);
15606
+ }
15266
15607
  close() {
15267
15608
  if (this.dead) return;
15268
15609
  try {
@@ -15330,6 +15671,18 @@ async function swipe2(x1, y1, x2, y2, durationMs = 300) {
15330
15671
  const d = await getHidDaemon();
15331
15672
  await d.swipe(x1, y1, x2, y2, durationMs);
15332
15673
  }
15674
+ async function pressKey2(keyCode) {
15675
+ const d = await getHidDaemon();
15676
+ await d.key(keyCode);
15677
+ }
15678
+ async function pressKeyRepeat(keyCode, count) {
15679
+ const d = await getHidDaemon();
15680
+ await d.keyRepeat(keyCode, count);
15681
+ }
15682
+ async function typeText2(text) {
15683
+ const d = await getHidDaemon();
15684
+ await d.text(text);
15685
+ }
15333
15686
 
15334
15687
  // src/cli/hierarchy.ts
15335
15688
  var import_node_child_process = require("node:child_process");
@@ -15489,6 +15842,13 @@ var NitroWriter = class {
15489
15842
  this.screenSize = null;
15490
15843
  this.surfaceOffset = null;
15491
15844
  }
15845
+ // Hot-swap the underlying client after a launchApp/clearState rebind.
15846
+ // The old client's WebSocket + control socket are torn down by the
15847
+ // runner; without this swap, every send() would target the dead
15848
+ // transports and either throw or stall.
15849
+ setClient(client) {
15850
+ this.client = client;
15851
+ }
15492
15852
  async scrollAuto(direction, distance, testID = "") {
15493
15853
  if (direction === "left" || direction === "right") {
15494
15854
  const startX = direction === "left" ? Math.round(SAFE_CENTER_X * 1.75) : Math.round(SAFE_CENTER_X * 0.25);
@@ -15639,8 +15999,12 @@ var NitroWriter = class {
15639
15999
  return true;
15640
16000
  }
15641
16001
  }
15642
- await ensureCompanion();
15643
- await typeText(text);
16002
+ try {
16003
+ await typeText2(text);
16004
+ } catch {
16005
+ await ensureCompanion();
16006
+ await typeText(text);
16007
+ }
15644
16008
  await new Promise((r) => setTimeout(r, 100));
15645
16009
  return true;
15646
16010
  }
@@ -15649,8 +16013,12 @@ var NitroWriter = class {
15649
16013
  if (!c) return false;
15650
16014
  await this.hidTap(c.x, c.y, 50);
15651
16015
  await this.client.waitForCommit(200);
15652
- for (let i = 0; i < 100; i++) {
15653
- await pressKey(42);
16016
+ try {
16017
+ await pressKeyRepeat(42, 100);
16018
+ } catch {
16019
+ for (let i = 0; i < 100; i++) {
16020
+ await pressKey(42);
16021
+ }
15654
16022
  }
15655
16023
  await new Promise((r) => setTimeout(r, 100));
15656
16024
  return true;
@@ -15662,9 +16030,13 @@ var NitroWriter = class {
15662
16030
  await this.hidTap(c.x, c.y, 50);
15663
16031
  await this.client.waitForCommit(200);
15664
16032
  }
15665
- await ensureCompanion();
15666
- for (let i = 0; i < count; i++) {
15667
- await pressKey(42);
16033
+ try {
16034
+ await pressKeyRepeat(42, count);
16035
+ } catch {
16036
+ await ensureCompanion();
16037
+ for (let i = 0; i < count; i++) {
16038
+ await pressKey(42);
16039
+ }
15668
16040
  }
15669
16041
  await new Promise((r) => setTimeout(r, 100));
15670
16042
  return true;
@@ -15679,8 +16051,12 @@ var NitroWriter = class {
15679
16051
  };
15680
16052
  const code = map2[keyName.toLowerCase()];
15681
16053
  if (code === void 0) return false;
15682
- await ensureCompanion();
15683
- await pressKey(code);
16054
+ try {
16055
+ await pressKey2(code);
16056
+ } catch {
16057
+ await ensureCompanion();
16058
+ await pressKey(code);
16059
+ }
15684
16060
  await this.client.waitForCommit(200);
15685
16061
  return true;
15686
16062
  }
@@ -15845,6 +16221,9 @@ var NitroReader = class {
15845
16221
  constructor(client) {
15846
16222
  this.client = client;
15847
16223
  }
16224
+ setClient(client) {
16225
+ this.client = client;
16226
+ }
15848
16227
  existsById(testID) {
15849
16228
  return this.client.exists(testID);
15850
16229
  }
@@ -16,6 +16,7 @@
16
16
  #include <jsi/jsi.h>
17
17
  #include <functional>
18
18
  #include "../cpp/HybridEnnio.hpp"
19
+ #include "../cpp/EnnioControlSocket.h"
19
20
 
20
21
  #if __has_include(<ReactCommon/RCTInstance.h>)
21
22
  #import <ReactCommon/RCTInstance.h>
@@ -113,6 +114,11 @@ static NSString* ennioDistributionName(EnnioDistribution d) {
113
114
  NSString* mark = [NSString stringWithFormat:@"%@/Library/_ennio_load_fired.txt", NSHomeDirectory()];
114
115
  [@"loaded" writeToFile:mark atomically:YES encoding:NSUTF8StringEncoding error:nil];
115
116
 
117
+ // Start the Unix-domain control socket before any JS work runs.
118
+ // Independent of Hermes Inspector — exists to bypass the JS-thread
119
+ // queue for pure UIKit handlers (see EnnioControlSocket.cpp).
120
+ ennio::EnnioControlSocket::start();
121
+
116
122
  // Try to swizzle RCTHost's start method
117
123
  Class hostClass = NSClassFromString(@"RCTHost");
118
124
  if (!hostClass) {
@@ -236,6 +236,64 @@ public:
236
236
  bool copyToClipboard(const std::string& text);
237
237
  bool pasteFromClipboard(const std::string& testID);
238
238
  std::string getClipboardText();
239
+ /**
240
+ * Programmatic UIPickerView wheel selection. HID swipes against
241
+ * the picker's spinner are flaky on the iOS 26 simulator — the
242
+ * touch-begin/end timing doesn't always cross the pan recogniser
243
+ * threshold and the wheel snaps back. Walks every visible
244
+ * UIPickerView, asks its dataSource for row count, uses the
245
+ * delegate's `pickerView:titleForRow:forComponent:` (or a
246
+ * `viewForRow:` UILabel.text leaf walk) to match `label`
247
+ * case-insensitively. Calls `selectRow:inComponent:animated:`
248
+ * then fires `pickerView:didSelectRow:inComponent:` on the
249
+ * delegate so the @react-native-picker/picker bridge emits
250
+ * onValueChange. Caller need not know the picker's testID — at
251
+ * most one picker is normally visible.
252
+ */
253
+ bool selectPickerValueByLabel(const std::string& label);
254
+ /**
255
+ * Programmatic UISearchBar text entry. RNScreens binds a
256
+ * UISearchBar to Stack.Screen.headerSearchBarOptions; the bar is
257
+ * native UIKit, not a React-managed view, so testID lookups
258
+ * can't reach its inner UITextField. idb HID keystrokes don't
259
+ * always deliver to the bar's field on iOS 26 simulator either.
260
+ * Walks every visible UISearchBar, assigns `searchBar.text` and
261
+ * fires `searchBar:textDidChange:` on the delegate so RNScreens
262
+ * emits onChangeText. Pass empty string to clear.
263
+ */
264
+ bool setSearchBarText(const std::string& text);
265
+ /**
266
+ * Append `text` to the currently-focused UISearchBar (if any).
267
+ * Falls back to the first visible UISearchBar when none is the
268
+ * first responder. Used by inputText so successive calls build up
269
+ * the query naturally instead of overwriting.
270
+ */
271
+ bool appendSearchBarText(const std::string& text);
272
+ /**
273
+ * Delete trailing `count` characters from the focused (or first
274
+ * visible) UISearchBar. Used by eraseText / clearText. Pass a
275
+ * very large count (e.g. INT_MAX) to clear the field entirely.
276
+ */
277
+ bool eraseSearchBarText(int count);
278
+ /**
279
+ * Focus the search bar whose placeholder matches `placeholder`
280
+ * (case-insensitive). RNScreens-managed UISearchBar isn't in the
281
+ * React view tree so testID lookups + accessibility-label HID
282
+ * taps miss it. Calls becomeFirstResponder on the embedded text
283
+ * field so subsequent inputText fires textDidChange correctly.
284
+ * When `placeholder` is empty, focuses the first visible search
285
+ * bar.
286
+ */
287
+ bool focusSearchBar(const std::string& placeholder);
288
+ /**
289
+ * Programmatic UISegmentedControl selection. Walks every
290
+ * visible UISegmentedControl, matches a segment by title
291
+ * (case-insensitive), calls setSelectedSegmentIndex and fires
292
+ * UIControlEventValueChanged so the RN bridge (RNCSegmentedControl)
293
+ * emits onChange. Returns false when no matching segment is on
294
+ * screen.
295
+ */
296
+ bool selectSegmentByLabel(const std::string& label);
239
297
 
240
298
  private:
241
299
  EnnioRuntimeHelper() = default;
@@ -2430,6 +2430,283 @@ std::string EnnioRuntimeHelper::getClipboardText() {
2430
2430
  return result;
2431
2431
  }
2432
2432
 
2433
+ // First UILabel.text in the subtree of `root`, depth-first. Used to
2434
+ // extract row labels when a UIPickerViewDelegate only provides
2435
+ // `viewForRow:forComponent:reusingView:` (custom row cells).
2436
+ static NSString* firstLabelTextIn(UIView* root) {
2437
+ if (!root) return nil;
2438
+ if ([root isKindOfClass:[UILabel class]]) {
2439
+ NSString* t = ((UILabel*)root).text;
2440
+ if (t.length) return t;
2441
+ }
2442
+ for (UIView* sub in root.subviews) {
2443
+ NSString* hit = firstLabelTextIn(sub);
2444
+ if (hit) return hit;
2445
+ }
2446
+ return nil;
2447
+ }
2448
+
2449
+ // Collect every visible UIPickerView across connected windows into
2450
+ // `out` (depth-first). UIDatePicker on iOS hosts a UIPickerView as
2451
+ // a private subview in spinner mode, so we recurse past it rather
2452
+ // than treating UIDatePicker itself as terminal.
2453
+ static void collectPickerViewsIn(UIView* root, NSMutableArray<UIPickerView*>* out) {
2454
+ if (!root || root.hidden) return;
2455
+ if ([root isKindOfClass:[UIPickerView class]]) {
2456
+ [out addObject:(UIPickerView*)root];
2457
+ // Don't return — a UIPickerView's subviews don't contain
2458
+ // another nested picker on stock iOS, but recursing is
2459
+ // cheap and future-proofs against subclasses that wrap a
2460
+ // sibling picker.
2461
+ }
2462
+ for (UIView* sub in root.subviews) collectPickerViewsIn(sub, out);
2463
+ }
2464
+
2465
+ bool EnnioRuntimeHelper::selectPickerValueByLabel(const std::string& label) {
2466
+ NSString* needle = [NSString stringWithUTF8String:label.c_str()];
2467
+ __block bool ok = false;
2468
+
2469
+ void (^block)(void) = ^{
2470
+ NSMutableArray<UIPickerView*>* pickers = [NSMutableArray array];
2471
+ for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) {
2472
+ if (![scene isKindOfClass:[UIWindowScene class]]) continue;
2473
+ for (UIWindow* win in [((UIWindowScene*)scene).windows reverseObjectEnumerator]) {
2474
+ collectPickerViewsIn(win, pickers);
2475
+ }
2476
+ }
2477
+ for (UIPickerView* pv in pickers) {
2478
+ id<UIPickerViewDataSource> ds = pv.dataSource;
2479
+ id<UIPickerViewDelegate> dg = pv.delegate;
2480
+ if (!ds) continue;
2481
+
2482
+ // Iterate every component so a multi-wheel picker (e.g.
2483
+ // UIDatePicker's month/day/year) can be targeted by a
2484
+ // unique row label without knowing which component it
2485
+ // belongs to.
2486
+ NSInteger componentCount = [ds numberOfComponentsInPickerView:pv];
2487
+ for (NSInteger component = 0; component < componentCount; component++) {
2488
+ NSInteger rowCount = [ds pickerView:pv numberOfRowsInComponent:component];
2489
+ for (NSInteger row = 0; row < rowCount; row++) {
2490
+ NSString* title = nil;
2491
+ if ([dg respondsToSelector:@selector(pickerView:titleForRow:forComponent:)]) {
2492
+ title = [dg pickerView:pv titleForRow:row forComponent:component];
2493
+ } else if ([dg respondsToSelector:@selector(pickerView:attributedTitleForRow:forComponent:)]) {
2494
+ title = [[dg pickerView:pv attributedTitleForRow:row forComponent:component] string];
2495
+ } else if ([dg respondsToSelector:@selector(pickerView:viewForRow:forComponent:reusingView:)]) {
2496
+ UIView* rowView = [dg pickerView:pv viewForRow:row forComponent:component reusingView:nil];
2497
+ title = firstLabelTextIn(rowView);
2498
+ }
2499
+ if (title && [title compare:needle options:NSCaseInsensitiveSearch] == NSOrderedSame) {
2500
+ [pv selectRow:row inComponent:component animated:NO];
2501
+ if ([dg respondsToSelector:@selector(pickerView:didSelectRow:inComponent:)]) {
2502
+ [dg pickerView:pv didSelectRow:row inComponent:component];
2503
+ }
2504
+ ok = true;
2505
+ return;
2506
+ }
2507
+ }
2508
+ }
2509
+ }
2510
+ };
2511
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
2512
+ return ok;
2513
+ }
2514
+
2515
+ // Collect every text field that powers a search bar UI. iOS 26
2516
+ // replaced UIKit's UISearchBar with a SwiftUI
2517
+ // `InlineSearchBarViewRepresentation` wrapper, so a UISearchBar
2518
+ // class walk returns 0 hits even though the bar is visible on
2519
+ // screen. The inner private class `UISearchBarTextField` (a
2520
+ // UITextField subclass) survives the migration and is what the
2521
+ // SwiftUI host shows. Walking for that class — plus an
2522
+ // in-tree UISearchBar fallback for older iOS / collapsed-mode UI —
2523
+ // covers both worlds. `out` receives UITextField instances; the
2524
+ // caller drives text via .text + UIControlEventEditingChanged.
2525
+ static void collectSearchBarTextFieldsIn(UIView* root, NSMutableArray<UITextField*>* out) {
2526
+ if (!root || root.hidden) return;
2527
+ NSString* cls = NSStringFromClass([root class]);
2528
+ if ([root isKindOfClass:[UITextField class]] &&
2529
+ ([cls isEqualToString:@"UISearchBarTextField"] || [cls containsString:@"SearchBar"])) {
2530
+ [out addObject:(UITextField*)root];
2531
+ return;
2532
+ }
2533
+ if ([root isKindOfClass:[UISearchBar class]]) {
2534
+ UITextField* tf = [(UISearchBar*)root valueForKey:@"searchField"];
2535
+ if (tf) [out addObject:tf];
2536
+ return;
2537
+ }
2538
+ for (UIView* sub in root.subviews) collectSearchBarTextFieldsIn(sub, out);
2539
+ }
2540
+
2541
+ // Walks every visible search-bar text field across connected
2542
+ // windows. Covers iOS 26 SwiftUI-hosted bars (UISearchBarTextField
2543
+ // directly under a SwiftUI host) and legacy UISearchBar bars.
2544
+ static NSArray<UITextField*>* allSearchBarTextFields() {
2545
+ NSMutableArray<UITextField*>* fields = [NSMutableArray array];
2546
+ for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) {
2547
+ if (![scene isKindOfClass:[UIWindowScene class]]) continue;
2548
+ for (UIWindow* win in [((UIWindowScene*)scene).windows reverseObjectEnumerator]) {
2549
+ collectSearchBarTextFieldsIn(win, fields);
2550
+ }
2551
+ }
2552
+ return fields;
2553
+ }
2554
+
2555
+ // Return the search-bar text field that is the current first
2556
+ // responder, or nil if none. Reading isFirstResponder on the
2557
+ // field directly is correct — it IS the focused responder when
2558
+ // the search bar is active.
2559
+ static UITextField* focusedSearchBarTextField() {
2560
+ for (UITextField* tf in allSearchBarTextFields()) {
2561
+ if (tf.isFirstResponder) return tf;
2562
+ }
2563
+ return nil;
2564
+ }
2565
+
2566
+ static UITextField* firstSearchBarTextField() {
2567
+ return allSearchBarTextFields().firstObject;
2568
+ }
2569
+
2570
+ // Fire the events needed for both the SwiftUI search-bar host AND
2571
+ // the legacy UISearchBarDelegate path to observe the new value.
2572
+ // iOS 26 SwiftUI hosts observe UIControlEventEditingChanged on the
2573
+ // underlying text field; UISearchBar bridges through the same
2574
+ // notification to its delegate's searchBar:textDidChange:. So a
2575
+ // single editingChanged dispatch covers both.
2576
+ static void notifySearchTextChanged(UITextField* tf) {
2577
+ if (!tf) return;
2578
+ [tf sendActionsForControlEvents:UIControlEventEditingChanged];
2579
+ [[NSNotificationCenter defaultCenter]
2580
+ postNotificationName:UITextFieldTextDidChangeNotification
2581
+ object:tf];
2582
+ }
2583
+
2584
+ bool EnnioRuntimeHelper::setSearchBarText(const std::string& text) {
2585
+ NSString* str = [NSString stringWithUTF8String:text.c_str()];
2586
+ __block bool ok = false;
2587
+ void (^block)(void) = ^{
2588
+ UITextField* tf = focusedSearchBarTextField() ?: firstSearchBarTextField();
2589
+ if (!tf) return;
2590
+ tf.text = str;
2591
+ notifySearchTextChanged(tf);
2592
+ ok = true;
2593
+ };
2594
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
2595
+ return ok;
2596
+ }
2597
+
2598
+ bool EnnioRuntimeHelper::appendSearchBarText(const std::string& text) {
2599
+ NSString* str = [NSString stringWithUTF8String:text.c_str()];
2600
+ __block bool ok = false;
2601
+ void (^block)(void) = ^{
2602
+ // Prefer the focused search field; fall back to the first
2603
+ // visible one so iOS 26's SwiftUI host (which sometimes
2604
+ // refuses becomeFirstResponder via UIKit) still routes the
2605
+ // input correctly when focusSearchBar was called just
2606
+ // before this.
2607
+ UITextField* tf = focusedSearchBarTextField() ?: firstSearchBarTextField();
2608
+ if (!tf) return;
2609
+ tf.text = [(tf.text ?: @"") stringByAppendingString:str];
2610
+ notifySearchTextChanged(tf);
2611
+ ok = true;
2612
+ };
2613
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
2614
+ return ok;
2615
+ }
2616
+
2617
+ bool EnnioRuntimeHelper::focusSearchBar(const std::string& placeholder) {
2618
+ // `placeholder` is informational only on iOS 26 — the SwiftUI
2619
+ // host's UISearchBarTextField has placeholder="" even when the
2620
+ // visible bar shows "Search fruit". When no placeholder match
2621
+ // is found, fall back to the first visible field (at most one
2622
+ // search bar is normally on screen). Returns success when a
2623
+ // field exists at all, even if becomeFirstResponder is
2624
+ // refused — iOS 26's PlatformViewRepresentable host swallows
2625
+ // some responder calls but the subsequent
2626
+ // appendSearchBarText / eraseSearchBarText paths use
2627
+ // firstSearchBarTextField fallback, so the flow still routes
2628
+ // input correctly.
2629
+ NSString* needle = [NSString stringWithUTF8String:placeholder.c_str()];
2630
+ __block bool ok = false;
2631
+ void (^block)(void) = ^{
2632
+ NSArray<UITextField*>* fields = allSearchBarTextFields();
2633
+ UITextField* target = nil;
2634
+ if (needle.length > 0) {
2635
+ for (UITextField* tf in fields) {
2636
+ if (tf.placeholder &&
2637
+ [tf.placeholder rangeOfString:needle options:NSCaseInsensitiveSearch].location != NSNotFound) {
2638
+ target = tf;
2639
+ break;
2640
+ }
2641
+ }
2642
+ }
2643
+ if (!target) target = fields.firstObject;
2644
+ if (!target) return;
2645
+ [target becomeFirstResponder]; // best-effort; SwiftUI host may refuse
2646
+ ok = true;
2647
+ };
2648
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
2649
+ return ok;
2650
+ }
2651
+
2652
+ bool EnnioRuntimeHelper::eraseSearchBarText(int count) {
2653
+ __block bool ok = false;
2654
+ void (^block)(void) = ^{
2655
+ UITextField* tf = focusedSearchBarTextField() ?: firstSearchBarTextField();
2656
+ if (!tf) return;
2657
+ NSString* cur = tf.text ?: @"";
2658
+ NSInteger keep = (NSInteger)cur.length - (NSInteger)count;
2659
+ if (keep < 0) keep = 0;
2660
+ tf.text = [cur substringToIndex:(NSUInteger)keep];
2661
+ notifySearchTextChanged(tf);
2662
+ ok = true;
2663
+ };
2664
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
2665
+ return ok;
2666
+ }
2667
+
2668
+ // Collect every visible UISegmentedControl across connected windows.
2669
+ static void collectSegmentedIn(UIView* root, NSMutableArray<UISegmentedControl*>* out) {
2670
+ if (!root || root.hidden) return;
2671
+ if ([root isKindOfClass:[UISegmentedControl class]]) {
2672
+ [out addObject:(UISegmentedControl*)root];
2673
+ return;
2674
+ }
2675
+ for (UIView* sub in root.subviews) collectSegmentedIn(sub, out);
2676
+ }
2677
+
2678
+ bool EnnioRuntimeHelper::selectSegmentByLabel(const std::string& label) {
2679
+ NSString* needle = [NSString stringWithUTF8String:label.c_str()];
2680
+ __block bool ok = false;
2681
+ void (^block)(void) = ^{
2682
+ NSMutableArray<UISegmentedControl*>* controls = [NSMutableArray array];
2683
+ for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) {
2684
+ if (![scene isKindOfClass:[UIWindowScene class]]) continue;
2685
+ for (UIWindow* win in [((UIWindowScene*)scene).windows reverseObjectEnumerator]) {
2686
+ collectSegmentedIn(win, controls);
2687
+ }
2688
+ }
2689
+ for (UISegmentedControl* sc in controls) {
2690
+ for (NSUInteger i = 0; i < sc.numberOfSegments; i++) {
2691
+ NSString* title = [sc titleForSegmentAtIndex:i];
2692
+ if (title && [title compare:needle options:NSCaseInsensitiveSearch] == NSOrderedSame) {
2693
+ sc.selectedSegmentIndex = (NSInteger)i;
2694
+ // RNCSegmentedControl bridges onChange via
2695
+ // UIControlEventValueChanged on the underlying
2696
+ // UISegmentedControl; setSelectedSegmentIndex
2697
+ // alone doesn't fire that event so dispatch
2698
+ // explicitly.
2699
+ [sc sendActionsForControlEvents:UIControlEventValueChanged];
2700
+ ok = true;
2701
+ return;
2702
+ }
2703
+ }
2704
+ }
2705
+ };
2706
+ if ([NSThread isMainThread]) block(); else dispatchSyncMainWithTimeout(block);
2707
+ return ok;
2708
+ }
2709
+
2433
2710
  } // namespace ennio
2434
2711
 
2435
2712
  // Objective-C helper for setting the surface presenter
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reactiive/ennio",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/enzomanuelmangano/ennio.git",
@@ -13,10 +13,19 @@ Wire protocol (line-delimited, both directions):
13
13
 
14
14
  IN tap <x> <y> <durationMs>
15
15
  IN swipe <x1> <y1> <x2> <y2> <durationMs>
16
+ IN key <keycode>
17
+ IN keyrep <keycode> <count> — N copies, one gRPC call
18
+ IN text <json-encoded-string> — JSON string handles spaces / unicode
16
19
  IN exit
17
20
  OUT ok — command completed
18
21
  OUT err <message> — command failed; daemon stays alive
19
22
 
23
+ Why batch a `keyrep` op? `eraseText: 50 characters` used to fan out
24
+ 50 separate `idb ui key 42` subprocess invocations (~160 ms each =
25
+ 8 s of pure spawn overhead). `keyrep` sends the whole sequence in
26
+ one `client.key_sequence` gRPC call — ~50 ms total regardless of
27
+ count.
28
+
20
29
  The daemon discovers the companion socket from `IDB_COMPANION` env
21
30
  var or `/tmp/idb/<UDID>_companion.sock` if the parent passes a UDID
22
31
  positional. The CLI launches one daemon per booted target and keeps
@@ -24,6 +33,7 @@ it alive for the whole `ennio test` session.
24
33
  """
25
34
 
26
35
  import asyncio
36
+ import json
27
37
  import logging
28
38
  import os
29
39
  import sys
@@ -116,6 +126,41 @@ async def handle(client: Client, line: str) -> None:
116
126
  duration=dur_ms / 1000.0,
117
127
  )
118
128
  print("ok", flush=True)
129
+ elif op == "key":
130
+ # key <keycode> — single USB HID key down+up.
131
+ if len(parts) < 2:
132
+ print("err key-needs-keycode", flush=True)
133
+ return
134
+ await client.key(keycode=int(parts[1]))
135
+ print("ok", flush=True)
136
+ elif op == "keyrep":
137
+ # keyrep <keycode> <count> — N repeated keys in ONE
138
+ # gRPC call. Replaces the eraseText/clearText fan-out
139
+ # of N separate `idb ui key` subprocess spawns
140
+ # (~160 ms each); the whole sequence now lands in ~50 ms.
141
+ if len(parts) < 3:
142
+ print("err keyrep-needs-code-count", flush=True)
143
+ return
144
+ code = int(parts[1])
145
+ count = max(0, int(parts[2]))
146
+ if count > 0:
147
+ await client.key_sequence(key_sequence=[code] * count)
148
+ print("ok", flush=True)
149
+ elif op == "text":
150
+ # text <json-encoded-string> — JSON string so the
151
+ # payload can carry spaces, unicode, etc. line-safely.
152
+ payload = line[len("text "):].strip() if len(line) > len("text ") else ""
153
+ try:
154
+ text = json.loads(payload)
155
+ except Exception as e:
156
+ print(f"err bad-json-text {e}", flush=True)
157
+ return
158
+ if not isinstance(text, str):
159
+ print("err text-not-string", flush=True)
160
+ return
161
+ if text:
162
+ await client.text(text=text)
163
+ print("ok", flush=True)
119
164
  elif op == "exit":
120
165
  sys.exit(0)
121
166
  else: