cicy-desktop 2.1.299 → 2.1.301
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/package.json +1 -1
- package/src/backends/sidecar-ipc.js +20 -0
- package/src/sidecar/wsl-docker.js +11 -0
- package/src/tabbrowser/newtab-protocol.js +1 -3
- package/src/tabbrowser/panel-menu.js +4 -0
- package/src/tabbrowser/panel-page-router.js +9 -4
- package/src/tabbrowser/panel-preload.js +27 -0
- package/src/tabbrowser/panel-presets.js +1 -0
- package/src/tabbrowser/redroid-matrix-core.js +177 -0
- package/src/tabbrowser/redroid-matrix.html +668 -0
- package/src/tabbrowser/redroid-matrix.js +341 -0
- package/src/tabbrowser/redroid-shell.js +117 -0
- package/src/tools/tab-browser-tools.js +5 -2
- package/test/bootstrap-failure-reporting.test.js +7 -0
- package/test/panel-launcher.test.js +3 -2
- package/test/redroid-matrix.test.js +100 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// Copyright 2026 CiCy AI
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// Main-process side of the Redroid matrix panel (cicyui://panel?preset=redroid-matrix).
|
|
5
|
+
//
|
|
6
|
+
// Redroid = containerised Android (no QEMU). The devices live as docker
|
|
7
|
+
// containers inside the cicy WSL distro (Windows) or the local docker (mac /
|
|
8
|
+
// linux), and everything the panel does is `docker …` + `adb …` executed
|
|
9
|
+
// there. The page talks to us over window.redroidAPI (panel-preload.js).
|
|
10
|
+
//
|
|
11
|
+
// Gotchas baked in (see knowledge base "redroid 容器化 Android 在 WSL2 上跑起来"):
|
|
12
|
+
// • host adb port must be ≥ 15555 — 5555-5585 is adb's emulator range and the
|
|
13
|
+
// device stays "offline" forever.
|
|
14
|
+
// • redroid's netd has no policy-routing rule to the main table, so the
|
|
15
|
+
// container can't answer adb / reach the proxy until
|
|
16
|
+
// `ip rule add from all lookup main pref 17000` is added — and netd flushes
|
|
17
|
+
// it again, so a watchdog (redroid-net.sh) re-asserts it every 10 s for
|
|
18
|
+
// EVERY redroid container.
|
|
19
|
+
// • root is AOSP-native: `su <uid> <cmd>` (not `su -c`).
|
|
20
|
+
const { ipcMain, dialog, BrowserWindow } = require("electron");
|
|
21
|
+
const path = require("path");
|
|
22
|
+
const { execFile } = require("child_process");
|
|
23
|
+
const core = require("./redroid-matrix-core");
|
|
24
|
+
|
|
25
|
+
const WATCHDOG = "/usr/local/sbin/redroid-net.sh";
|
|
26
|
+
const FRIDA_SERVER_SRC = process.env.CICY_FRIDA_SERVER || "/mnt/c/wsl/frida-server";
|
|
27
|
+
const FRIDA_PORT = 27042;
|
|
28
|
+
const IP_API = "http://ip-api.com/json/?fields=status,message,query,country,countryCode,regionName,city,isp,org,as,mobile,proxy,hosting";
|
|
29
|
+
|
|
30
|
+
// ── runner ───────────────────────────────────────────────────────────────────
|
|
31
|
+
// Short commands go through a persistent shell session (redroid-shell.js):
|
|
32
|
+
// lane "ui" for screenshots/input, lane "mgmt" for docker/adb status+actions.
|
|
33
|
+
// Long jobs (image pull, apk install, purge) use a one-off process so they
|
|
34
|
+
// never wedge a lane.
|
|
35
|
+
const { lane } = require("./redroid-shell");
|
|
36
|
+
function sh(cmd, { timeout = 30000, lane: laneName = "mgmt" } = {}) {
|
|
37
|
+
return lane(laneName).run(cmd, { timeout });
|
|
38
|
+
}
|
|
39
|
+
function shOnce(cmd, { timeout = 60000 } = {}) {
|
|
40
|
+
if (process.platform === "win32") return require("../sidecar/wsl-docker").wslRun(cmd, { timeout });
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
execFile("bash", ["-lc", cmd], { timeout, maxBuffer: 1 << 26 }, (err, stdout, stderr) => {
|
|
43
|
+
if (err) { err.stdout = String(stdout || ""); err.stderr = String(stderr || ""); return reject(err); }
|
|
44
|
+
resolve({ stdout: String(stdout), stderr: String(stderr) });
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const q = (v) => `'${String(v).replace(/'/g, "'\\''")}'`;
|
|
49
|
+
const errText = (e) => {
|
|
50
|
+
const tail = String((e && (e.stderr || e.stdout)) || "").trim().split("\n").slice(-3).join(" ");
|
|
51
|
+
if (tail) return tail;
|
|
52
|
+
if (e && e.killed) return `命令超时:${e.message}`;
|
|
53
|
+
return (e && e.message) || String(e);
|
|
54
|
+
};
|
|
55
|
+
function toDistroPath(p) {
|
|
56
|
+
if (process.platform !== "win32") return p;
|
|
57
|
+
return String(p).replace(/^([A-Za-z]):[\\/]/, (_m, d) => `/mnt/${d.toLowerCase()}/`).replace(/\\/g, "/");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── adb ──────────────────────────────────────────────────────────────────────
|
|
61
|
+
const serial = (port) => `127.0.0.1:${port}`;
|
|
62
|
+
function adb(port, args, { timeout = 20000, lane: laneName } = {}) {
|
|
63
|
+
return sh(`adb -s ${serial(port)} ${args}`, { timeout, lane: laneName });
|
|
64
|
+
}
|
|
65
|
+
async function adbState(port) {
|
|
66
|
+
try {
|
|
67
|
+
const { stdout } = await sh("adb devices", { timeout: 10000 });
|
|
68
|
+
const m = new RegExp(`^${serial(port).replace(".", "\\.")}\\s+(\\S+)`, "m").exec(stdout);
|
|
69
|
+
return m ? m[1] : "disconnected";
|
|
70
|
+
} catch { return "disconnected"; }
|
|
71
|
+
}
|
|
72
|
+
// adb connect is idempotent; a stale "offline" entry is fixed by disconnect+connect.
|
|
73
|
+
async function ensureConnected(port) {
|
|
74
|
+
let st = await adbState(port);
|
|
75
|
+
if (st === "device") return st;
|
|
76
|
+
if (st === "offline") { try { await sh(`adb disconnect ${serial(port)}`, { timeout: 8000 }); } catch {} }
|
|
77
|
+
try { await sh(`adb connect ${serial(port)}`, { timeout: 10000 }); } catch {}
|
|
78
|
+
return adbState(port);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── docker ───────────────────────────────────────────────────────────────────
|
|
82
|
+
async function dockerList() {
|
|
83
|
+
const { stdout } = await sh("docker ps -a --format '{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}\t{{.Ports}}'", { timeout: 20000 });
|
|
84
|
+
return core.parsePs(stdout);
|
|
85
|
+
}
|
|
86
|
+
async function findDevice(name) {
|
|
87
|
+
const dev = (await dockerList()).find((d) => d.name === name);
|
|
88
|
+
if (!dev) throw new Error(`设备 ${name} 不存在`);
|
|
89
|
+
return dev;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Generalised watchdog: the original one only knew the container named
|
|
93
|
+
// "redroid". This version walks every running redroid/redroid container.
|
|
94
|
+
const WATCHDOG_BODY = `#!/bin/sh
|
|
95
|
+
# cicy redroid network watchdog — re-asserts main-table routing inside every
|
|
96
|
+
# redroid container (netd flushes it). Installed by cicy-desktop Redroid 矩阵.
|
|
97
|
+
while true; do
|
|
98
|
+
for c in $(docker ps --filter ancestor=redroid/redroid:11.0.0-latest --filter ancestor=redroid/redroid:13.0.0-latest --filter ancestor=redroid/redroid:14.0.0-latest --format '{{.Names}}' 2>/dev/null; docker ps --filter label=cicy.redroid=1 --format '{{.Names}}' 2>/dev/null); do
|
|
99
|
+
docker exec "$c" getprop sys.boot_completed 2>/dev/null | grep -q 1 || continue
|
|
100
|
+
IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$c" 2>/dev/null)
|
|
101
|
+
GW=$(echo "$IP" | sed 's/\\.[0-9]*$/.1/')
|
|
102
|
+
docker exec "$c" sh -c "
|
|
103
|
+
ip rule | grep -q 17000 || ip rule add from all lookup main pref 17000
|
|
104
|
+
ip route | grep -q '^default' || ip route add default via $GW dev eth0
|
|
105
|
+
" 2>/dev/null
|
|
106
|
+
done
|
|
107
|
+
date +%s > /var/run/redroid-net.heartbeat
|
|
108
|
+
sleep 10
|
|
109
|
+
done
|
|
110
|
+
`;
|
|
111
|
+
async function ensureWatchdog() {
|
|
112
|
+
const marker = "cicy redroid network watchdog";
|
|
113
|
+
try {
|
|
114
|
+
const { stdout } = await sh(`grep -c ${q(marker)} ${WATCHDOG} 2>/dev/null; pgrep -f redroid-net.sh >/dev/null && echo RUN`, { timeout: 10000 });
|
|
115
|
+
if (/^1/.test(stdout) && /RUN/.test(stdout)) return;
|
|
116
|
+
} catch {}
|
|
117
|
+
const script = `cat > ${WATCHDOG} <<'CICY_EOF'\n${WATCHDOG_BODY}CICY_EOF\nchmod +x ${WATCHDOG}; pkill -f redroid-net.sh; nohup ${WATCHDOG} >/dev/null 2>&1 & disown; ` +
|
|
118
|
+
`grep -q redroid-net.sh /usr/local/sbin/start-dockerd.sh 2>/dev/null || (test -f /usr/local/sbin/start-dockerd.sh && printf '\\nnohup ${WATCHDOG} >/dev/null 2>&1 &\\n' >> /usr/local/sbin/start-dockerd.sh); true`;
|
|
119
|
+
await sh(script, { timeout: 20000 });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── device info / actions ────────────────────────────────────────────────────
|
|
123
|
+
async function deviceInfo(dev) {
|
|
124
|
+
const info = { adb: "disconnected", booted: false, root: null, frida: false, proxy: "", model: "", release: "", fridaPort: dev.port + 10000 };
|
|
125
|
+
if (!dev.running || !dev.port) return info;
|
|
126
|
+
info.adb = await ensureConnected(dev.port);
|
|
127
|
+
if (info.adb !== "device") return info;
|
|
128
|
+
try {
|
|
129
|
+
const { stdout } = await adb(dev.port, `shell "getprop sys.boot_completed; getprop ro.build.version.release; getprop ro.product.model; settings get global http_proxy; (su 0 id 2>/dev/null || echo NOSU); (pgrep -f frida-server >/dev/null 2>&1 && echo FRIDA || ps -A 2>/dev/null | grep -q frida-server && echo FRIDA || echo NOFRIDA); wm size 2>/dev/null | tail -1; cat /proc/uptime 2>/dev/null | cut -d. -f1; getprop ro.build.fingerprint"`);
|
|
130
|
+
const l = stdout.replace(/\r/g, "").split("\n").map((s) => s.trim());
|
|
131
|
+
info.booted = l[0] === "1";
|
|
132
|
+
info.release = l[1] || ""; info.model = l[2] || "";
|
|
133
|
+
info.proxy = l[3] && l[3] !== "null" && l[3] !== ":0" ? l[3] : "";
|
|
134
|
+
info.root = /uid=0/.test(l[4] || "");
|
|
135
|
+
info.frida = /^FRIDA$/m.test(stdout);
|
|
136
|
+
const rest = l.slice(5).filter((x) => !/^(NO)?FRIDA$/.test(x));
|
|
137
|
+
const wm = rest.find((x) => /size:/i.test(x)); info.size = wm ? wm.replace(/.*size:\s*/i, "") : "";
|
|
138
|
+
const up = rest.find((x) => /^\d+$/.test(x)); info.uptime = up ? Number(up) : null;
|
|
139
|
+
info.fingerprint = rest.find((x) => /\//.test(x) && /:/.test(x)) || "";
|
|
140
|
+
} catch (e) { info.error = errText(e); }
|
|
141
|
+
return info;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function screenshot(port) {
|
|
145
|
+
const { stdout } = await adb(port, "exec-out screencap -p | base64 -w0", { timeout: 15000, lane: "ui" });
|
|
146
|
+
const b64 = stdout.replace(/\s+/g, "");
|
|
147
|
+
if (b64.length < 100 || !b64.startsWith("iVBOR") || /[^A-Za-z0-9+/=]/.test(b64)) throw new Error("screencap 返回异常数据");
|
|
148
|
+
return `data:image/png;base64,${b64}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function sendInput(port, ev) {
|
|
152
|
+
await adb(port, `shell ${core.inputArgs(ev).join(" ")}`, { timeout: 10000, lane: "ui" });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function setProxy(port, proxy) {
|
|
156
|
+
const v = core.normalizeDeviceProxy(proxy);
|
|
157
|
+
await adb(port, `shell settings put global http_proxy ${v ? q(v) : "':0'"}`);
|
|
158
|
+
return v;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Egress as the DEVICE sees it: through its configured proxy (probed from the
|
|
162
|
+
// distro, which sits on the same docker bridge). Direct = the distro's own
|
|
163
|
+
// egress. Merges ip-api.com (geo/isp/flags) with ipapi.is (datacenter/vpn/tor/
|
|
164
|
+
// abuser) and grades the result (core.classifyIp).
|
|
165
|
+
async function fetchJsonVia(url, via) {
|
|
166
|
+
const py = `import json,sys,urllib.request as u; h=u.ProxyHandler({'http':'http://${via}','https':'http://${via}'} if ${via ? "True" : "False"} else {}); print(u.build_opener(h).open(sys.argv[1],timeout=12).read().decode())`;
|
|
167
|
+
const r = await sh(`if command -v curl >/dev/null; then curl -s --max-time 12 ${via ? `-x http://${via}` : ""} ${q(url)}; else python3 -c ${q(py)} ${q(url)}; fi`, { timeout: 20000 });
|
|
168
|
+
try { return JSON.parse(r.stdout); } catch { throw new Error(`探测服务无响应:${r.stdout.trim().slice(-120) || "empty"}`); }
|
|
169
|
+
}
|
|
170
|
+
async function probeIp(port) {
|
|
171
|
+
const { stdout } = await adb(port, "shell settings get global http_proxy");
|
|
172
|
+
const proxy = stdout.trim();
|
|
173
|
+
const via = proxy && proxy !== "null" && proxy !== ":0" ? proxy : "";
|
|
174
|
+
const a = await fetchJsonVia(IP_API, via);
|
|
175
|
+
if (!a || a.status !== "success") throw new Error(`出口探测失败${via ? `(经 ${via})` : ""}:${(a && a.message) || "ip-api 无结果"}`);
|
|
176
|
+
let b = null;
|
|
177
|
+
try { b = await fetchJsonVia(`https://api.ipapi.is/?q=${a.query}`, via); } catch {}
|
|
178
|
+
const cls = core.classifyIp({ ipapi: a, ipapis: b });
|
|
179
|
+
return {
|
|
180
|
+
ip: a.query, cc: a.countryCode || (b && b.cc) || "", country: a.country || "", region: a.regionName || "", city: a.city || "",
|
|
181
|
+
area: [a.country, a.regionName, a.city].filter(Boolean).join(" · "),
|
|
182
|
+
isp: a.isp || "", org: a.org || "", as: a.as || (b && b.asn_num ? `AS${b.asn_num} ${b.asn_org || ""}` : ""),
|
|
183
|
+
...cls, sources: { ipapi: !!a, ipapis: !!b },
|
|
184
|
+
via: via || "direct", probedAt: new Date().toISOString(),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function frida(dev, on) {
|
|
189
|
+
const port = dev.port;
|
|
190
|
+
if (!on) {
|
|
191
|
+
await adb(port, `shell "su 0 pkill -f frida-server; pkill -f frida-server" || true`);
|
|
192
|
+
try { await sh(`adb -s ${serial(port)} forward --remove tcp:${port + 10000}`); } catch {}
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
const { stdout } = await adb(port, "shell 'ls /data/local/tmp/frida-server 2>/dev/null || echo MISSING'");
|
|
196
|
+
if (/MISSING/.test(stdout)) {
|
|
197
|
+
try { await sh(`test -f ${q(FRIDA_SERVER_SRC)}`); }
|
|
198
|
+
catch { throw new Error(`frida-server 不存在:${FRIDA_SERVER_SRC}(x86_64 Android 版,可用 CICY_FRIDA_SERVER 指定)`); }
|
|
199
|
+
await shOnce(`adb -s ${serial(port)} push ${q(FRIDA_SERVER_SRC)} /data/local/tmp/frida-server`, { timeout: 60000 });
|
|
200
|
+
}
|
|
201
|
+
await adb(port, `shell "chmod 755 /data/local/tmp/frida-server; su 0 sh -c 'nohup /data/local/tmp/frida-server -D -l 0.0.0.0:${FRIDA_PORT} >/dev/null 2>&1 &'"`);
|
|
202
|
+
await sh(`adb -s ${serial(port)} forward tcp:${port + 10000} tcp:${FRIDA_PORT}`);
|
|
203
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
204
|
+
const chk = await adb(port, "shell 'pgrep -f frida-server >/dev/null && echo OK || (ps -A | grep -q frida-server && echo OK || echo NO)'");
|
|
205
|
+
if (!/OK/.test(chk.stdout)) throw new Error("frida-server 启动失败(需要 root)");
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function listApps(port) {
|
|
210
|
+
const { stdout } = await adb(port, "shell pm list packages -3");
|
|
211
|
+
return stdout.split(/\r?\n/).map((s) => s.replace(/^package:/, "").trim()).filter(Boolean).sort();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function installApk(port, hostPath, emit) {
|
|
215
|
+
const p = toDistroPath(hostPath);
|
|
216
|
+
const ext = path.extname(hostPath).toLowerCase();
|
|
217
|
+
emit && emit({ message: `安装 ${path.basename(hostPath)} …` });
|
|
218
|
+
if (ext === ".apk") {
|
|
219
|
+
const r = await shOnce(`adb -s ${serial(port)} install -r -g ${q(p)}`, { timeout: 300000 });
|
|
220
|
+
if (!/Success/i.test(r.stdout + r.stderr)) throw new Error(errText({ stdout: r.stdout, stderr: r.stderr }));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// xapk / apks / zip bundles → unpack the split apks and install-multiple
|
|
224
|
+
const tmp = `/tmp/cicy-apk-${port}`;
|
|
225
|
+
const r = await shOnce(`rm -rf ${tmp} && mkdir -p ${tmp} && unzip -o -j ${q(p)} '*.apk' -d ${tmp} >/dev/null && adb -s ${serial(port)} install-multiple -r -g ${tmp}/*.apk; rc=$?; rm -rf ${tmp}; exit $rc`, { timeout: 300000 });
|
|
226
|
+
if (!/Success/i.test(r.stdout + r.stderr)) throw new Error(errText({ stdout: r.stdout, stderr: r.stderr }));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function imagePresent(image) {
|
|
230
|
+
try { await sh(`docker image inspect ${image} >/dev/null 2>&1`, { timeout: 10000 }); return true; } catch { return false; }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function createDevice(input, emit) {
|
|
234
|
+
const spec = core.normalizeSpec(input);
|
|
235
|
+
const container = core.containerName(input.name);
|
|
236
|
+
const existing = await dockerList();
|
|
237
|
+
if (existing.some((d) => d.name === container)) throw new Error(`设备 ${container} 已存在`);
|
|
238
|
+
const port = core.allocatePort(existing.map((d) => d.port));
|
|
239
|
+
if (!(await imagePresent(spec.image))) {
|
|
240
|
+
emit && emit({ message: `拉取镜像 ${spec.image}(约 3GB,首次较慢)…` });
|
|
241
|
+
const r = await shOnce(`docker pull ${spec.image} 2>&1 | tail -3`, { timeout: 3600000 });
|
|
242
|
+
if (!(await imagePresent(spec.image))) throw new Error(`镜像拉取失败:${r.stdout.trim().slice(-300)}`);
|
|
243
|
+
}
|
|
244
|
+
emit && emit({ message: `启动容器 ${container}(端口 ${port})…` });
|
|
245
|
+
await sh(core.buildRunCommand({ container, port, spec }), { timeout: 120000 });
|
|
246
|
+
await ensureWatchdog();
|
|
247
|
+
// adb connect in the background — boot takes ~30-60 s; the panel polls status.
|
|
248
|
+
setTimeout(() => { ensureConnected(port).catch(() => {}); }, 20000);
|
|
249
|
+
const proxy = String(input.proxy || "").trim();
|
|
250
|
+
if (proxy) {
|
|
251
|
+
core.normalizeDeviceProxy(proxy);
|
|
252
|
+
waitBootThen(port, () => setProxy(port, proxy)).catch(() => {});
|
|
253
|
+
}
|
|
254
|
+
return { name: container, port, version: spec.version };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function waitBootThen(port, fn, tries = 40) {
|
|
258
|
+
for (let i = 0; i < tries; i++) {
|
|
259
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
260
|
+
try {
|
|
261
|
+
if ((await ensureConnected(port)) !== "device") continue;
|
|
262
|
+
const { stdout } = await adb(port, "shell getprop sys.boot_completed", { timeout: 8000 });
|
|
263
|
+
if (stdout.trim() === "1") return fn();
|
|
264
|
+
} catch {}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function removeDevice(name, purge) {
|
|
269
|
+
await findDevice(name);
|
|
270
|
+
const p = purge ? ` && rm -rf ${q(core.dataDir(name))}` : "";
|
|
271
|
+
await shOnce(`docker rm -f ${q(name)}${p}`, { timeout: 60000 });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── IPC ──────────────────────────────────────────────────────────────────────
|
|
275
|
+
let installed = false;
|
|
276
|
+
function installIpc(findTab) {
|
|
277
|
+
if (installed) return;
|
|
278
|
+
installed = true;
|
|
279
|
+
// Only the panel page (profile 0) may drive this; same gate as panel-cells.
|
|
280
|
+
const guard = (e) => { if (!findTab(e.sender.id)) throw new Error("not a panel page"); };
|
|
281
|
+
const emitter = (e) => (m) => { try { e.sender.send("redroid:progress", m); } catch {} };
|
|
282
|
+
// device lookup cache: the screen stream asks several times a second and a
|
|
283
|
+
// `docker ps` per frame would saturate the mgmt lane.
|
|
284
|
+
let cache = { at: 0, list: [] };
|
|
285
|
+
const cachedList = async () => { if (Date.now() - cache.at > 5000) cache = { at: Date.now(), list: await dockerList() }; return cache.list; };
|
|
286
|
+
const withDev = async (e, name) => {
|
|
287
|
+
guard(e);
|
|
288
|
+
const d = (await cachedList()).find((x) => x.name === name) || await findDevice(name);
|
|
289
|
+
if (!d.running || !d.port) throw new Error(`${name} 未运行`);
|
|
290
|
+
return d;
|
|
291
|
+
};
|
|
292
|
+
const h = (ch, fn) => ipcMain.handle(ch, async (e, a = {}) => {
|
|
293
|
+
try { return await fn(e, a); }
|
|
294
|
+
catch (err) { throw new Error(errText(err)); }
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
h("redroid:list", async (e) => {
|
|
298
|
+
guard(e);
|
|
299
|
+
const devs = await dockerList();
|
|
300
|
+
cache = { at: Date.now(), list: devs };
|
|
301
|
+
const out = [];
|
|
302
|
+
for (const d of devs) out.push({ ...d, info: await deviceInfo(d) }); // serial: one lane, keep it orderly
|
|
303
|
+
return out;
|
|
304
|
+
});
|
|
305
|
+
h("redroid:create", (e, a) => createDevice(a, emitter(e)));
|
|
306
|
+
h("redroid:start", async (e, { name }) => { guard(e); await findDevice(name); await sh(`docker start ${q(name)}`, { timeout: 60000 }); await ensureWatchdog(); });
|
|
307
|
+
h("redroid:stop", async (e, { name }) => { guard(e); await findDevice(name); await sh(`docker stop ${q(name)}`, { timeout: 60000 }); });
|
|
308
|
+
h("redroid:restart", async (e, { name }) => { guard(e); await findDevice(name); await sh(`docker restart ${q(name)}`, { timeout: 90000 }); });
|
|
309
|
+
h("redroid:remove", (e, { name, purge }) => { guard(e); return removeDevice(name, !!purge); });
|
|
310
|
+
h("redroid:screenshot", async (e, { name }) => screenshot((await withDev(e, name)).port));
|
|
311
|
+
h("redroid:input", async (e, { name, event }) => sendInput((await withDev(e, name)).port, event));
|
|
312
|
+
h("redroid:set-proxy", async (e, { name, proxy }) => setProxy((await withDev(e, name)).port, proxy));
|
|
313
|
+
h("redroid:probe-ip", async (e, { name }) => probeIp((await withDev(e, name)).port));
|
|
314
|
+
h("redroid:frida", async (e, { name, on }) => frida(await withDev(e, name), !!on));
|
|
315
|
+
h("redroid:apps", async (e, { name }) => listApps((await withDev(e, name)).port));
|
|
316
|
+
h("redroid:launch", async (e, { name, pkg }) => { const d = await withDev(e, name); await adb(d.port, `shell monkey -p ${q(pkg)} -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || am start -n ${q(pkg)}`); });
|
|
317
|
+
h("redroid:uninstall", async (e, { name, pkg }) => { const d = await withDev(e, name); await adb(d.port, `uninstall ${q(pkg)}`, { timeout: 60000 }); });
|
|
318
|
+
h("redroid:install", async (e, { name }) => {
|
|
319
|
+
const d = await withDev(e, name);
|
|
320
|
+
const win = BrowserWindow.fromWebContents(e.sender) || BrowserWindow.getFocusedWindow();
|
|
321
|
+
const r = await dialog.showOpenDialog(win, { title: "选择 APK / XAPK", properties: ["openFile", "multiSelections"], filters: [{ name: "Android 安装包", extensions: ["apk", "xapk", "apks", "zip"] }] });
|
|
322
|
+
if (r.canceled || !r.filePaths.length) return { canceled: true };
|
|
323
|
+
for (const f of r.filePaths) await installApk(d.port, f, emitter(e));
|
|
324
|
+
return { installed: r.filePaths.map((f) => path.basename(f)) };
|
|
325
|
+
});
|
|
326
|
+
h("redroid:shell", async (e, { name, cmd }) => {
|
|
327
|
+
const d = await withDev(e, name);
|
|
328
|
+
const c = String(cmd || "").trim(); if (!c) return "";
|
|
329
|
+
try { const r = await adb(d.port, `shell ${q(c)}`, { timeout: 30000 }); return r.stdout + (r.stderr ? "\n" + r.stderr : ""); }
|
|
330
|
+
catch (err) { return (err.stdout || "") + (err.stderr || "") || err.message; }
|
|
331
|
+
});
|
|
332
|
+
h("redroid:defaults", async (e) => {
|
|
333
|
+
guard(e);
|
|
334
|
+
// the mihomo (cicy-code) container IP on the docker bridge: the natural proxy host for devices
|
|
335
|
+
let host = "";
|
|
336
|
+
try { host = (await sh("docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' cicy-code-docker-8008", { timeout: 10000 })).stdout.trim(); } catch {}
|
|
337
|
+
return { proxyHint: host ? `${host}:20011` : "", images: core.IMAGES, spec: core.DEFAULT_SPEC, fridaServer: FRIDA_SERVER_SRC };
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
module.exports = { installIpc, deviceInfo, createDevice, removeDevice, screenshot, sendInput, setProxy, probeIp, frida, listApps, installApk, ensureWatchdog, WATCHDOG_BODY, toDistroPath };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Copyright 2026 CiCy AI
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// A persistent bash session (inside the WSL distro on Windows, local bash
|
|
5
|
+
// elsewhere) that runs commands serially over one process.
|
|
6
|
+
//
|
|
7
|
+
// Why not execFile per call: the Redroid panel fires a screenshot every few
|
|
8
|
+
// hundred ms plus status polls and input events. Each `wsl.exe -d …` launch is
|
|
9
|
+
// a fresh WSL session (LxssManager round-trip, ~150-400 ms) and under that
|
|
10
|
+
// churn sessions stall and fail with an empty "Command failed" — observed on
|
|
11
|
+
// the Win11 21H2 built-in WSL. One long-lived shell removes the per-call
|
|
12
|
+
// session cost entirely and makes the screen stream smooth.
|
|
13
|
+
//
|
|
14
|
+
// Protocol: every command is wrapped as
|
|
15
|
+
// ( <cmd> ) </dev/null 2>&1 ; printf '\n<SENTINEL> %s\n' $?
|
|
16
|
+
// (a subshell, so an `exit N` inside the command can't take the session down)
|
|
17
|
+
// and we read stdout until the sentinel line. A command that exceeds its
|
|
18
|
+
// timeout kills the whole session (the only safe way to abort a stuck child);
|
|
19
|
+
// queued commands are rejected and the next call respawns.
|
|
20
|
+
const { spawn } = require("child_process");
|
|
21
|
+
|
|
22
|
+
const SENTINEL = "__CICY_RD_DONE__";
|
|
23
|
+
|
|
24
|
+
class ShellSession {
|
|
25
|
+
constructor({ spawnArgs, label = "shell" }) {
|
|
26
|
+
this.spawnArgs = spawnArgs; // () => [cmd, args]
|
|
27
|
+
this.label = label;
|
|
28
|
+
this.proc = null;
|
|
29
|
+
this.queue = [];
|
|
30
|
+
this.current = null;
|
|
31
|
+
this.buf = "";
|
|
32
|
+
this.seq = 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_spawn() {
|
|
36
|
+
const [cmd, args] = this.spawnArgs();
|
|
37
|
+
const p = spawn(cmd, args, { windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
38
|
+
p.stdout.on("data", (d) => this._onData(d));
|
|
39
|
+
p.stderr.on("data", () => {}); // merged into stdout by the wrapper; keep the pipe drained
|
|
40
|
+
p.on("exit", () => { if (this.proc === p) { this.proc = null; this._failAll(new Error(`${this.label} session exited`)); } });
|
|
41
|
+
p.on("error", (e) => { if (this.proc === p) { this.proc = null; this._failAll(e); } });
|
|
42
|
+
this.proc = p;
|
|
43
|
+
this.buf = "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_failAll(err) {
|
|
47
|
+
const cur = this.current; this.current = null;
|
|
48
|
+
if (cur) { clearTimeout(cur.timer); cur.reject(err); }
|
|
49
|
+
const q = this.queue; this.queue = [];
|
|
50
|
+
for (const j of q) j.reject(err);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_onData(chunk) {
|
|
54
|
+
this.buf += chunk.toString("utf8");
|
|
55
|
+
const cur = this.current;
|
|
56
|
+
if (!cur) return;
|
|
57
|
+
const idx = this.buf.indexOf(cur.mark);
|
|
58
|
+
if (idx < 0) return;
|
|
59
|
+
const after = this.buf.slice(idx + cur.mark.length);
|
|
60
|
+
const nl = after.indexOf("\n");
|
|
61
|
+
if (nl < 0) return; // exit code not fully arrived yet
|
|
62
|
+
const code = Number(after.slice(0, nl).trim());
|
|
63
|
+
let out = this.buf.slice(0, idx);
|
|
64
|
+
if (out.endsWith("\n")) out = out.slice(0, -1);
|
|
65
|
+
this.buf = after.slice(nl + 1);
|
|
66
|
+
this.current = null;
|
|
67
|
+
clearTimeout(cur.timer);
|
|
68
|
+
if (code === 0) cur.resolve({ stdout: out, stderr: "" });
|
|
69
|
+
else { const e = new Error(`exit ${code}`); e.code = code; e.stdout = out; e.stderr = ""; cur.reject(e); }
|
|
70
|
+
this._next();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_next() {
|
|
74
|
+
if (this.current || !this.queue.length) return;
|
|
75
|
+
if (!this.proc) { try { this._spawn(); } catch (e) { this._failAll(e); return; } }
|
|
76
|
+
const job = this.queue.shift();
|
|
77
|
+
this.current = job;
|
|
78
|
+
job.timer = setTimeout(() => {
|
|
79
|
+
const e = new Error(`timeout after ${job.timeout}ms: ${job.cmd.slice(0, 80)}`);
|
|
80
|
+
e.killed = true;
|
|
81
|
+
const p = this.proc; this.proc = null;
|
|
82
|
+
try { p && p.kill("SIGKILL"); } catch {}
|
|
83
|
+
this._failAll(e);
|
|
84
|
+
}, job.timeout);
|
|
85
|
+
const line = `( ${job.cmd}\n) </dev/null 2>&1 ; printf '\\n%s %s\\n' ${job.mark} $?\n`;
|
|
86
|
+
try { this.proc.stdin.write(line); } catch (e) { this._failAll(e); }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
run(cmd, { timeout = 30000 } = {}) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const mark = `${SENTINEL}${++this.seq}`;
|
|
92
|
+
this.queue.push({ cmd, timeout, mark, resolve, reject, timer: null });
|
|
93
|
+
this._next();
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
dispose() { const p = this.proc; this.proc = null; try { p && p.kill(); } catch {} this._failAll(new Error("disposed")); }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function spawnArgsFor() {
|
|
101
|
+
if (process.platform === "win32") {
|
|
102
|
+
const docker = require("../sidecar/docker");
|
|
103
|
+
const distro = process.env.CICY_WSL_DISTRO || "cicy-code-wsl";
|
|
104
|
+
return () => [docker.wslExe(), ["-d", distro, "-u", "root", "--", "bash", "--norc", "--noprofile"]];
|
|
105
|
+
}
|
|
106
|
+
return () => ["bash", ["--norc", "--noprofile"]];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const lanes = new Map();
|
|
110
|
+
// lane "ui": screenshots + input (latency-sensitive); lane "mgmt": docker/adb
|
|
111
|
+
// status + actions. A slow docker call never blocks the screen stream.
|
|
112
|
+
function lane(name) {
|
|
113
|
+
if (!lanes.has(name)) lanes.set(name, new ShellSession({ spawnArgs: spawnArgsFor(), label: `redroid-${name}` }));
|
|
114
|
+
return lanes.get(name);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { ShellSession, lane, SENTINEL };
|
|
@@ -27,6 +27,7 @@ const SHELL_HTML = path.join(__dirname, "..", "tabbrowser", "tab-shell.html");
|
|
|
27
27
|
const SHELL_PRELOAD = path.join(__dirname, "..", "tabbrowser", "tab-shell-preload.js");
|
|
28
28
|
const PANEL_PRELOAD = path.join(__dirname, "..", "tabbrowser", "panel-preload.js");
|
|
29
29
|
const panelCells = require("../tabbrowser/panel-cells");
|
|
30
|
+
const redroidMatrix = require("../tabbrowser/redroid-matrix");
|
|
30
31
|
const { createPanelMenuTemplate } = require("../tabbrowser/panel-menu");
|
|
31
32
|
const { resolvePanelPreset } = require("../tabbrowser/panel-presets");
|
|
32
33
|
const { normalizeCicyTheme, resolveTabChromeTheme } = require("../tabbrowser/tab-theme");
|
|
@@ -751,13 +752,15 @@ function installIpc() {
|
|
|
751
752
|
m.pushState();
|
|
752
753
|
});
|
|
753
754
|
// panel cells IPC: sender = the panel PAGE's webContents → resolve its (manager, tab)
|
|
754
|
-
|
|
755
|
+
const findPanelTab = (senderWcId) => {
|
|
755
756
|
for (const m of managers.values()) {
|
|
756
757
|
const tab = m.tabs.find((t) => t.id === senderWcId);
|
|
757
758
|
if (tab) return { manager: m, tab };
|
|
758
759
|
}
|
|
759
760
|
return null;
|
|
760
|
-
}
|
|
761
|
+
};
|
|
762
|
+
panelCells.installIpc(findPanelTab);
|
|
763
|
+
redroidMatrix.installIpc(findPanelTab);
|
|
761
764
|
ipcMain.on("tabwin:activate", (e, { id }) => { const m = mgr(e); if (m) m.activate(id); });
|
|
762
765
|
ipcMain.on("tabwin:close", (e, { id }) => { const m = mgr(e); if (m) m.close(id); });
|
|
763
766
|
ipcMain.on("tabwin:reorder", (e, { ids }) => { const m = mgr(e); if (m) m.reorder(ids); });
|
|
@@ -100,3 +100,10 @@ test("a broken component store is reported once and backed off for hours, not re
|
|
|
100
100
|
assert.match(d, /reason: storeBroken \? "windows_component_store_broken" : "wsl_enable_failed"/);
|
|
101
101
|
assert.match(read("src/backends/sidecar-ipc.js"), /windows_component_store_broken: 6 \* 60 \* 60 \* 1000/);
|
|
102
102
|
});
|
|
103
|
+
|
|
104
|
+
test("broken WSL localhost relay (healthy inside, unreachable from Windows) is reset once per boot", () => {
|
|
105
|
+
assert.match(read("src/sidecar/wsl-docker.js"), /async function insideHealthy\(/);
|
|
106
|
+
const ipc = read("src/backends/sidecar-ipc.js");
|
|
107
|
+
assert.match(ipc, /_relayMisses >= 2 && !_relayResetDone/);
|
|
108
|
+
assert.match(ipc, /docker:self-heal-localhost-relay/);
|
|
109
|
+
});
|
|
@@ -18,10 +18,11 @@ test("panel menu offers blank panel and Telegram matrix actions", () => {
|
|
|
18
18
|
const opened = [];
|
|
19
19
|
const template = createPanelMenuTemplate((preset) => opened.push(preset));
|
|
20
20
|
|
|
21
|
-
assert.deepEqual(template.map((item) => item.label), ["面板", "Telegram 矩阵"]);
|
|
21
|
+
assert.deepEqual(template.map((item) => item.label), ["面板", "Telegram 矩阵", "Redroid 矩阵"]);
|
|
22
22
|
template[0].click();
|
|
23
23
|
template[1].click();
|
|
24
|
-
|
|
24
|
+
template[2].click();
|
|
25
|
+
assert.deepEqual(opened, ["blank", "telegram-matrix", "redroid-matrix"]);
|
|
25
26
|
});
|
|
26
27
|
|
|
27
28
|
test("the panel plus button opens a native menu that cannot be covered by BrowserViews", () => {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
|
|
6
|
+
const { resolvePanelPreset } = require("../src/tabbrowser/panel-presets");
|
|
7
|
+
const { panelPageForUrl } = require("../src/tabbrowser/panel-page-router");
|
|
8
|
+
const core = require("../src/tabbrowser/redroid-matrix-core");
|
|
9
|
+
|
|
10
|
+
test("Redroid matrix preset routes to its dedicated page", () => {
|
|
11
|
+
assert.deepEqual(resolvePanelPreset("redroid-matrix"), { preset: "redroid-matrix", title: "Redroid 矩阵", query: "preset=redroid-matrix" });
|
|
12
|
+
assert.equal(panelPageForUrl("cicyui://panel/abc?preset=redroid-matrix"), "redroid-matrix.html");
|
|
13
|
+
assert.ok(fs.existsSync(path.join(__dirname, "..", "src", "tabbrowser", "redroid-matrix.html")));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("container names are slugged and host ports skip adb's emulator range", () => {
|
|
17
|
+
assert.equal(core.containerName(" TG 01 "), "redroid-tg-01");
|
|
18
|
+
assert.throws(() => core.containerName("!!!"), /设备名/);
|
|
19
|
+
assert.equal(core.allocatePort([]), 15555);
|
|
20
|
+
assert.equal(core.allocatePort([15555, 15556]), 15557);
|
|
21
|
+
assert.equal(core.allocatePort([15556]), 15555); // holes are reused
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("docker run line carries the redroid boot args, label and data volume", () => {
|
|
25
|
+
const cmd = core.buildRunCommand({ container: "redroid-a", port: 15557, spec: { version: "11", width: "1080", height: "1920", dpi: "420" } });
|
|
26
|
+
assert.match(cmd, /^docker run -d --name redroid-a --label cicy\.redroid=1 /);
|
|
27
|
+
assert.match(cmd, / --privileged /);
|
|
28
|
+
assert.match(cmd, / -v \/root\/redroid-a-data:\/data /);
|
|
29
|
+
assert.match(cmd, / -p 15557:5555 redroid\/redroid:11\.0\.0-latest androidboot\.redroid_gpu_mode=guest androidboot\.redroid_width=1080 androidboot\.redroid_height=1920 androidboot\.redroid_dpi=420$/);
|
|
30
|
+
assert.throws(() => core.normalizeSpec({ version: "9" }), /不支持/);
|
|
31
|
+
assert.equal(core.normalizeSpec({ width: "abc" }).width, 720); // bad numbers fall back to defaults
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("docker ps output is filtered to redroid images with their host adb port", () => {
|
|
35
|
+
const out = [
|
|
36
|
+
"redroid11\tredroid/redroid:11.0.0-latest\trunning\tUp 40 hours\t0.0.0.0:15556->5555/tcp, [::]:15556->5555/tcp",
|
|
37
|
+
"redroid\tredroid/redroid:13.0.0-latest\trunning\tUp 43 hours\t0.0.0.0:15555->5555/tcp, [::]:15555->5555/tcp",
|
|
38
|
+
"redroid-x\tredroid/redroid:13.0.0-latest\texited\tExited (0) 2 hours ago\t",
|
|
39
|
+
"cicy-code-docker-8008\tcicybot/cicy-code:latest\trunning\tUp 35 hours\t127.0.0.1:8008->8008/tcp",
|
|
40
|
+
].join("\n");
|
|
41
|
+
const list = core.parsePs(out);
|
|
42
|
+
assert.deepEqual(list.map((d) => [d.name, d.version, d.port, d.running]), [
|
|
43
|
+
["redroid-x", "13", null, false],
|
|
44
|
+
["redroid", "13", 15555, true],
|
|
45
|
+
["redroid11", "11", 15556, true],
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("device proxy accepts host:port (with or without scheme) and rejects junk", () => {
|
|
50
|
+
assert.equal(core.normalizeDeviceProxy("172.18.0.2:20011"), "172.18.0.2:20011");
|
|
51
|
+
assert.equal(core.normalizeDeviceProxy("http://172.18.0.2:20011/"), "172.18.0.2:20011");
|
|
52
|
+
assert.equal(core.normalizeDeviceProxy(""), "");
|
|
53
|
+
assert.throws(() => core.normalizeDeviceProxy("socks5://x:1"), /host:port/);
|
|
54
|
+
assert.throws(() => core.normalizeDeviceProxy("host"), /host:port/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("adb input args for tap / swipe / keys / text", () => {
|
|
58
|
+
assert.deepEqual(core.inputArgs({ type: "tap", x: 10.4, y: 20.6 }), ["input", "tap", 10, 21]);
|
|
59
|
+
assert.deepEqual(core.inputArgs({ type: "swipe", x1: 0, y1: 0, x2: 100, y2: 200 }), ["input", "swipe", 0, 0, 100, 200, 200]);
|
|
60
|
+
assert.deepEqual(core.inputArgs({ type: "key", key: "home" }), ["input", "keyevent", 3]);
|
|
61
|
+
assert.deepEqual(core.inputArgs({ type: "key", key: "66" }), ["input", "keyevent", 66]);
|
|
62
|
+
assert.deepEqual(core.inputArgs({ type: "text", text: "hi there" }), ["input", "text", "'hi%sthere'"]);
|
|
63
|
+
assert.throws(() => core.inputArgs({ type: "text", text: "你好" }), /ASCII/);
|
|
64
|
+
assert.throws(() => core.inputArgs({ type: "nope" }), /unknown input/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("preload exposes redroidAPI and the IPC channels it uses exist in main", () => {
|
|
68
|
+
const preload = fs.readFileSync(path.join(__dirname, "..", "src", "tabbrowser", "panel-preload.js"), "utf8");
|
|
69
|
+
const main = fs.readFileSync(path.join(__dirname, "..", "src", "tabbrowser", "redroid-matrix.js"), "utf8");
|
|
70
|
+
assert.match(preload, /exposeInMainWorld\("redroidAPI"/);
|
|
71
|
+
for (const ch of ["list", "create", "start", "stop", "restart", "remove", "screenshot", "input", "set-proxy", "probe-ip", "frida", "apps", "launch", "uninstall", "install", "shell", "defaults"]) {
|
|
72
|
+
assert.match(main, new RegExp(`h\\("redroid:${ch}"`), `main handles redroid:${ch}`);
|
|
73
|
+
}
|
|
74
|
+
const wiring = fs.readFileSync(path.join(__dirname, "..", "src", "tools", "tab-browser-tools.js"), "utf8");
|
|
75
|
+
assert.match(wiring, /redroidMatrix\.installIpc\(findPanelTab\)/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("persistent shell session runs commands serially and reports exit codes", async (t) => {
|
|
79
|
+
const { ShellSession } = require("../src/tabbrowser/redroid-shell");
|
|
80
|
+
const s = new ShellSession({ spawnArgs: () => ["bash", ["--norc", "--noprofile"]], label: "t" });
|
|
81
|
+
t.after(() => s.dispose());
|
|
82
|
+
const [a, b] = await Promise.all([s.run("echo one; echo two"), s.run("printf 'no-newline'")]);
|
|
83
|
+
assert.equal(a.stdout, "one\ntwo\n"); // trailing newline kept, same as execFile
|
|
84
|
+
assert.equal(b.stdout, "no-newline");
|
|
85
|
+
await assert.rejects(s.run("echo oops >&2; exit 3"), (e) => e.code === 3 && /oops/.test(e.stdout));
|
|
86
|
+
assert.equal((await s.run("echo still-alive")).stdout.trim(), "still-alive");
|
|
87
|
+
await assert.rejects(s.run("sleep 5", { timeout: 300 }), /timeout/);
|
|
88
|
+
assert.equal((await s.run("echo respawned")).stdout.trim(), "respawned"); // session respawns after a kill
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("egress IP classification grades residential / backbone / datacenter / flagged", () => {
|
|
92
|
+
const g = (ipapi, ipapis) => core.classifyIp({ ipapi, ipapis }).grade;
|
|
93
|
+
assert.equal(g({ isp: "Comcast Cable Communications" }, { is_datacenter: false }), "A");
|
|
94
|
+
assert.equal(g({ mobile: true, hosting: false }, null), "A");
|
|
95
|
+
assert.equal(g({ isp: "Cogent Communications" }, { is_datacenter: false }), "B");
|
|
96
|
+
assert.equal(g({ isp: "Thunderbox Inc", hosting: false }, { is_datacenter: true }), "C");
|
|
97
|
+
assert.equal(g({ isp: "Comcast", proxy: true }, null), "D");
|
|
98
|
+
assert.equal(g({}, { is_vpn: true }), "D");
|
|
99
|
+
assert.deepEqual(core.classifyIp({ ipapi: { proxy: true, hosting: true } }).flags, { proxy: true, vpn: false, tor: false, abuser: false, datacenter: true, mobile: false });
|
|
100
|
+
});
|