@reactiive/ennio 0.0.2 → 0.0.4-beta.0
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/README.md +15 -3
- package/cpp/EnnioControlSocket.cpp +274 -0
- package/cpp/EnnioControlSocket.h +41 -0
- package/dist/cli.js +393 -14
- package/ios/EnnioAutoInit.mm +6 -0
- package/ios/EnnioRuntimeHelper.h +58 -0
- package/ios/EnnioRuntimeHelper.mm +277 -0
- package/package.json +1 -1
- package/src/cli/hid-daemon.py +45 -0
package/README.md
CHANGED
|
@@ -14,11 +14,16 @@ UITouch that half-fires recognizers — the gesture goes through the same
|
|
|
14
14
|
path a finger would.
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
bun add @reactiive/ennio react-native-nitro-modules
|
|
18
|
+
bun add -d @reactiive/ennio-expo-plugin
|
|
19
|
+
|
|
20
|
+
bunx ennio test e2e/01-auth-flow.yaml # one flow
|
|
21
|
+
bunx ennio test e2e/ # every *.yaml in the directory
|
|
20
22
|
```
|
|
21
23
|
|
|
24
|
+
(Or use the equivalent `npm install` / `yarn add` — `ennio-expo-plugin`
|
|
25
|
+
is a build-time config plugin, so it belongs in `devDependencies`.)
|
|
26
|
+
|
|
22
27
|
## Requirements
|
|
23
28
|
|
|
24
29
|
- Expo app on React Native ≥ 0.81 (New Architecture, Fabric)
|
|
@@ -54,3 +59,10 @@ example flows live in the [monorepo README](https://github.com/enzomanuelmangano
|
|
|
54
59
|
## License
|
|
55
60
|
|
|
56
61
|
MIT
|
|
62
|
+
|
|
63
|
+
## Trademarks
|
|
64
|
+
|
|
65
|
+
Maestro is a trademark of mobile.dev. Ennio is an independent
|
|
66
|
+
project, not affiliated with mobile.dev. References to "Maestro"
|
|
67
|
+
describe only the YAML flow format that Ennio consumes; no Maestro
|
|
68
|
+
source code is bundled or redistributed.
|
|
@@ -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
|
|
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
|
-
},
|
|
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 <
|
|
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
|
|
13904
|
-
|
|
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
|
-
|
|
15643
|
-
|
|
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
|
-
|
|
15653
|
-
await
|
|
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
|
-
|
|
15666
|
-
|
|
15667
|
-
|
|
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
|
-
|
|
15683
|
-
|
|
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
|
}
|
package/ios/EnnioAutoInit.mm
CHANGED
|
@@ -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) {
|
package/ios/EnnioRuntimeHelper.h
CHANGED
|
@@ -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
package/src/cli/hid-daemon.py
CHANGED
|
@@ -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:
|