@ycodium-ai/plugin-devices 0.0.0-stage → 0.2.2808

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,286 @@
1
+ // 设备插件的子进程助手:由插件经 `ctx.process` 以 `host:node helper.mjs <模式> …` 起,
2
+ // 跑在服务器自己的 Node 上(桌面版是带 ELECTRON_RUN_AS_NODE 的 Electron)。
3
+ //
4
+ // 宿主不许插件给子进程改 PATH,而 hub 在 Windows 上只靠 PATH 找模拟器、agent-device 的守护进程
5
+ // 也要 adb,npm 的安装脚本要在 PATH 上找 node。所以这几类子进程都经这里:在这个进程里补好 PATH,
6
+ // 再在同一进程里装载目标脚本(`node`、`npm` 模式)或起目标程序(`run` 模式)。插件停用时宿主杀的
7
+ // 是这个进程,装载进来的目标脚本也就跟着没了。
8
+ //
9
+ // 只用 node 内置模块;输出给插件解析的一律是单行 JSON。
10
+ //
11
+ // 它不是插件进程里的代码,而是插件经 `ctx.process` 起的独立程序,跟插件自带的二进制同一类,
12
+ // 所以放在 `bin/` 而不是 `src/`:插件源码边界(不许直接起子进程)管的是 `src/`。`run` 模式自己
13
+ // 起 adb / emulator,并把 SIGTERM / SIGINT 转给它;Windows 上宿主直接结束本进程,收不到信号。
14
+ import * as NodeChildProcess from "node:child_process";
15
+ import * as NodeFS from "node:fs";
16
+ import * as NodePath from "node:path";
17
+ import * as NodeURL from "node:url";
18
+ import * as NodeZlib from "node:zlib";
19
+
20
+ // oxlint-disable-next-line ycodium/no-global-process-runtime -- 独立子进程脚本,没有 Effect 运行时可注入。
21
+ const hostPlatform = process.platform;
22
+ const isWindows = hostPlatform === "win32";
23
+ const exe = (name) => (isWindows ? `${name}.exe` : name);
24
+
25
+ /** `--sdk x --shim y -- rest…`:选项在 `--` 之前,其余原样交给目标。其它环境变量由插件经 spawn 的 env 给。 */
26
+ function parseArgs(argv) {
27
+ const options = { sdk: null, shim: null, rest: [] };
28
+ for (let index = 0; index < argv.length; index += 1) {
29
+ const value = argv[index];
30
+ if (value === "--") {
31
+ options.rest = argv.slice(index + 1);
32
+ break;
33
+ }
34
+ if (value === "--sdk" || value === "--shim") {
35
+ options[value.slice(2)] = argv[index + 1] || null;
36
+ index += 1;
37
+ }
38
+ }
39
+ return options;
40
+ }
41
+
42
+ function prependPath(directories) {
43
+ const key = Object.keys(process.env).find((name) => name.toUpperCase() === "PATH") ?? "PATH";
44
+ process.env[key] = [...directories, process.env[key] ?? ""]
45
+ .filter(Boolean)
46
+ .join(NodePath.delimiter);
47
+ }
48
+
49
+ /**
50
+ * 在 `directory` 里放一个叫 node 的垫片,指向跑这个助手的可执行文件。npm 的安装脚本
51
+ * (比如原生依赖的预编译下载器)按名字在 PATH 上找 node,而这台机器的 PATH 上可能根本没有。
52
+ * ELECTRON_RUN_AS_NODE 已经在本进程环境里(宿主起 host:node 时补的),垫片起的进程照样继承。
53
+ */
54
+ function writeNodeShim(directory) {
55
+ NodeFS.mkdirSync(directory, { recursive: true });
56
+ if (isWindows) {
57
+ NodeFS.writeFileSync(NodePath.join(directory, "node.cmd"), `@"${process.execPath}" %*\r\n`);
58
+ } else {
59
+ const file = NodePath.join(directory, "node");
60
+ NodeFS.writeFileSync(
61
+ file,
62
+ `#!/bin/sh\nexec '${process.execPath.replaceAll("'", `'"'"'`)}' "$@"\n`,
63
+ );
64
+ NodeFS.chmodSync(file, 0o755);
65
+ }
66
+ }
67
+
68
+ /** 读 tar 头里的一个八进制数字段。 */
69
+ function octal(block, start, length) {
70
+ const text = block
71
+ .subarray(start, start + length)
72
+ .toString("utf8")
73
+ .replaceAll("\0", "")
74
+ .trim();
75
+ return text.length === 0 ? 0 : Number.parseInt(text, 8);
76
+ }
77
+
78
+ function text(block, start, length) {
79
+ const raw = block.subarray(start, start + length);
80
+ const end = raw.indexOf(0);
81
+ return raw.subarray(0, end === -1 ? raw.length : end).toString("utf8");
82
+ }
83
+
84
+ /** PAX 扩展头里的 `path=`;其余键这里用不到。 */
85
+ function paxPath(body) {
86
+ let offset = 0;
87
+ while (offset < body.length) {
88
+ const space = body.indexOf(0x20, offset);
89
+ if (space === -1) break;
90
+ const length = Number.parseInt(body.subarray(offset, space).toString("utf8"), 10);
91
+ if (!Number.isFinite(length) || length <= 0) break;
92
+ const record = body.subarray(space + 1, offset + length - 1).toString("utf8");
93
+ if (record.startsWith("path=")) return record.slice("path=".length);
94
+ offset += length;
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * 解一个 npm 包的 .tgz 到 `destination`,去掉包内统一的第一层目录(npm 包是 `package/`)。
101
+ * 只认普通文件和目录;条目路径解析后必须仍在 `destination` 内。
102
+ */
103
+ function extractTarball(file, destination) {
104
+ const archive = NodeZlib.gunzipSync(NodeFS.readFileSync(file));
105
+ const root = NodePath.resolve(destination);
106
+ let offset = 0;
107
+ let pendingName = null;
108
+ let count = 0;
109
+ while (offset + 512 <= archive.length) {
110
+ const block = archive.subarray(offset, offset + 512);
111
+ if (block.every((byte) => byte === 0)) break;
112
+ const size = octal(block, 124, 12);
113
+ const type = String.fromCharCode(block[156] || 0x30);
114
+ const bodyStart = offset + 512;
115
+ const body = archive.subarray(bodyStart, bodyStart + size);
116
+ offset = bodyStart + Math.ceil(size / 512) * 512;
117
+ if (type === "x") {
118
+ pendingName = paxPath(body);
119
+ continue;
120
+ }
121
+ if (type === "L") {
122
+ pendingName = text(body, 0, body.length);
123
+ continue;
124
+ }
125
+ if (type === "g") continue;
126
+ const prefix = text(block, 345, 155);
127
+ const name = pendingName ?? (prefix ? `${prefix}/${text(block, 0, 100)}` : text(block, 0, 100));
128
+ pendingName = null;
129
+ const relative = name.split("/").slice(1).join("/");
130
+ if (relative.length === 0) continue;
131
+ const target = NodePath.resolve(root, relative);
132
+ if (target !== root && !target.startsWith(root + NodePath.sep)) {
133
+ throw new Error(`tar entry escapes the destination: ${name}`);
134
+ }
135
+ if (type === "5") {
136
+ NodeFS.mkdirSync(target, { recursive: true });
137
+ } else if (type === "0" || type === "\0" || type === "7") {
138
+ NodeFS.mkdirSync(NodePath.dirname(target), { recursive: true });
139
+ NodeFS.writeFileSync(target, body, { mode: octal(block, 100, 8) & 0o777 || 0o644 });
140
+ count += 1;
141
+ }
142
+ }
143
+ return count;
144
+ }
145
+
146
+ function exists(file) {
147
+ try {
148
+ NodeFS.accessSync(file);
149
+ return true;
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ /** 与插件的可用性判断共用这份结果;找 SDK 的顺序照 Android Studio 的默认位置。 */
156
+ function probeAndroidSdk(override) {
157
+ const env = process.env;
158
+ const home = env.HOME ?? env.USERPROFILE ?? "";
159
+ const explicit = override || env.ANDROID_HOME?.trim() || env.ANDROID_SDK_ROOT?.trim() || null;
160
+ const candidates = explicit
161
+ ? [explicit]
162
+ : [
163
+ NodePath.join(home, "Library", "Android", "sdk"),
164
+ NodePath.join(home, "Android", "Sdk"),
165
+ NodePath.join(
166
+ env.LOCALAPPDATA ?? NodePath.join(home, "AppData", "Local"),
167
+ "Android",
168
+ "Sdk",
169
+ ),
170
+ ];
171
+ if (!explicit) {
172
+ for (const directory of (env.PATH ?? env.Path ?? "").split(NodePath.delimiter)) {
173
+ if (!directory) continue;
174
+ try {
175
+ const adb = NodeFS.realpathSync(NodePath.join(directory, exe("adb")));
176
+ candidates.push(NodePath.dirname(NodePath.dirname(adb)));
177
+ } catch {
178
+ // PATH 上这一格没有 adb。
179
+ }
180
+ }
181
+ }
182
+ const script = (name) => (isWindows ? `${name}.bat` : name);
183
+ for (const root of candidates) {
184
+ const adb = exists(NodePath.join(root, "platform-tools", exe("adb")));
185
+ const emulator = exists(NodePath.join(root, "emulator", exe("emulator")));
186
+ if (explicit || adb || emulator) {
187
+ const avdmanager = exists(
188
+ NodePath.join(root, "cmdline-tools", "latest", "bin", script("avdmanager")),
189
+ );
190
+ const legacyAvdmanager =
191
+ !avdmanager && exists(NodePath.join(root, "tools", "bin", script("avdmanager")));
192
+ return { root, adb, emulator, avdmanager, legacyAvdmanager };
193
+ }
194
+ }
195
+ return { root: null, adb: false, emulator: false, avdmanager: false, legacyAvdmanager: false };
196
+ }
197
+
198
+ function hasXcrun() {
199
+ if (hostPlatform !== "darwin") return false;
200
+ const result = NodeChildProcess.spawnSync("xcrun", ["--version"], {
201
+ stdio: "ignore",
202
+ timeout: 10_000,
203
+ });
204
+ return result.status === 0;
205
+ }
206
+
207
+ /** 把 SDK 的两个目录排到 PATH 最前,并设好 ANDROID_HOME。 */
208
+ function applySdkEnvironment(sdk) {
209
+ if (!sdk) return;
210
+ prependPath([NodePath.join(sdk, "platform-tools"), NodePath.join(sdk, "emulator")]);
211
+ process.env.ANDROID_HOME = sdk;
212
+ }
213
+
214
+ /** 在本进程里装载一个 Node 入口脚本,就像 `node entry …args` 起的一样。 */
215
+ async function loadEntry(entry, args) {
216
+ process.argv = [process.argv[0], entry, ...args];
217
+ await import(NodeURL.pathToFileURL(entry).href);
218
+ }
219
+
220
+ async function main() {
221
+ const [mode, ...rest] = process.argv.slice(2);
222
+ const options = parseArgs(rest);
223
+ if (mode === "probe") {
224
+ process.stdout.write(
225
+ `${JSON.stringify({
226
+ node: process.version,
227
+ execPath: process.execPath,
228
+ electron: process.versions.electron ?? null,
229
+ platform: hostPlatform,
230
+ android: probeAndroidSdk(options.sdk),
231
+ xcrun: hasXcrun(),
232
+ })}\n`,
233
+ );
234
+ return;
235
+ }
236
+ if (mode === "node") {
237
+ const [entry, ...args] = options.rest;
238
+ if (!entry) throw new Error("helper node: missing entry script");
239
+ applySdkEnvironment(options.sdk);
240
+ await loadEntry(entry, args);
241
+ return;
242
+ }
243
+ if (mode === "npm") {
244
+ const [npmCli, ...args] = options.rest;
245
+ if (!npmCli || !options.shim) throw new Error("helper npm: missing npm-cli.js or --shim");
246
+ writeNodeShim(options.shim);
247
+ prependPath([options.shim]);
248
+ await loadEntry(npmCli, args);
249
+ return;
250
+ }
251
+ if (mode === "extract") {
252
+ const [file, destination] = options.rest;
253
+ if (!file || !destination) throw new Error("helper extract: missing archive or destination");
254
+ process.stdout.write(`${JSON.stringify({ files: extractTarball(file, destination) })}\n`);
255
+ return;
256
+ }
257
+ if (mode === "run") {
258
+ const [command, ...args] = options.rest;
259
+ if (!command) throw new Error("helper run: missing command");
260
+ applySdkEnvironment(options.sdk);
261
+ const resolved =
262
+ command === "emulator" && options.sdk
263
+ ? NodePath.join(options.sdk, "emulator", exe("emulator"))
264
+ : command === "adb" && options.sdk
265
+ ? NodePath.join(options.sdk, "platform-tools", exe("adb"))
266
+ : command;
267
+ const child = NodeChildProcess.spawn(resolved, args, { stdio: "inherit", env: process.env });
268
+ child.on("error", (error) => {
269
+ process.stderr.write(`${error.message}\n`);
270
+ process.exitCode = 127;
271
+ });
272
+ child.on("exit", (code) => {
273
+ process.exitCode = code ?? 1;
274
+ });
275
+ for (const signal of ["SIGTERM", "SIGINT"]) {
276
+ process.on(signal, () => child.kill(signal));
277
+ }
278
+ return;
279
+ }
280
+ throw new Error(`helper: unknown mode ${String(mode)}`);
281
+ }
282
+
283
+ main().catch((error) => {
284
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
285
+ process.exitCode = 1;
286
+ });