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

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/dist/index.js ADDED
@@ -0,0 +1,2870 @@
1
+ // src/index.ts
2
+ import * as NodePath6 from "node:path";
3
+ import { definePlugin, PLUGIN_PROXY_DIRECT } from "@ycodium-ai/plugin-api";
4
+
5
+ // src/types.ts
6
+ var DEVICE_PLATFORMS = ["ios", "android"];
7
+ var LOCAL_DEVICE_HOST_ID = "local";
8
+ var DeviceError = class extends Error {
9
+ code;
10
+ constructor(code, message, options) {
11
+ super(message, options);
12
+ this.name = "DeviceError";
13
+ this.code = code;
14
+ }
15
+ };
16
+ var errorDetail = (error) => {
17
+ if (!(error instanceof Error)) return String(error);
18
+ const code = error.code;
19
+ return typeof code === "string" && /^E[A-Z0-9]+$/.test(code) ? code : error.message;
20
+ };
21
+
22
+ // src/deviceActionInput.ts
23
+ var invalid = (field, rule) => new DeviceError("invalid-input", `Invalid ${field}: ${rule}.`);
24
+ var unsupported = (operation, platform) => new DeviceError("action-unsupported", `Device ${operation} is not supported on ${platform}.`);
25
+ var DEVICE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
26
+ var IOS_BUNDLE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$/;
27
+ var ANDROID_PACKAGE_PATTERN = /^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)*$/;
28
+ var URL_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:[A-Za-z0-9\-._~:/?#[\]@&=+,;%]*$/;
29
+ var MAX_URL_LENGTH = 2048;
30
+ var MAX_ANDROID_PACKAGE_LENGTH = 255;
31
+ var MAX_PUSH_BYTES = 4096;
32
+ var APPEARANCES = ["light", "dark"];
33
+ var TEXT_SIZES = ["small", "default", "large", "extra-large"];
34
+ var TOGGLES = [
35
+ "reduceMotion",
36
+ "increaseContrast",
37
+ "reduceTransparency",
38
+ "showBorders",
39
+ "voiceOver",
40
+ "networkEnabled"
41
+ ];
42
+ var LIQUID_GLASS = ["clear", "tinted"];
43
+ var COLOR_FILTERS = [
44
+ "none",
45
+ "grayscale",
46
+ "red-green",
47
+ "green-red",
48
+ "blue-yellow"
49
+ ];
50
+ var ORIENTATIONS = [
51
+ "portrait",
52
+ "landscape_left",
53
+ "portrait_upside_down",
54
+ "landscape_right"
55
+ ];
56
+ var PERMISSIONS = [
57
+ "camera",
58
+ "microphone",
59
+ "photos",
60
+ "contacts",
61
+ "calendar",
62
+ "reminders",
63
+ "location",
64
+ "notifications",
65
+ "motion",
66
+ "media-library",
67
+ "faceid"
68
+ ];
69
+ var DECISIONS = ["grant", "revoke", "reset"];
70
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
71
+ var oneOf = (values, input, field) => {
72
+ const found = values.find((value) => value === input);
73
+ if (found === void 0) throw invalid(field, `expected one of ${values.join(", ")}`);
74
+ return found;
75
+ };
76
+ var boolean = (input, field) => {
77
+ if (typeof input !== "boolean") throw invalid(field, "expected true or false");
78
+ return input;
79
+ };
80
+ var coordinate = (input, field, limit) => {
81
+ if (typeof input !== "number" || !Number.isFinite(input) || Math.abs(input) > limit) {
82
+ throw invalid(field, `expected a number between -${String(limit)} and ${String(limit)}`);
83
+ }
84
+ return input;
85
+ };
86
+ var assertDeviceId = (id) => {
87
+ if (typeof id !== "string" || !DEVICE_ID_PATTERN.test(id)) {
88
+ throw invalid("device id", "expected a simulator udid or adb serial");
89
+ }
90
+ return id;
91
+ };
92
+ var appId = (platform, input) => {
93
+ const ok = typeof input === "string" && (platform === "ios" ? IOS_BUNDLE_ID_PATTERN.test(input) : input.length <= MAX_ANDROID_PACKAGE_LENGTH && ANDROID_PACKAGE_PATTERN.test(input));
94
+ if (!ok) {
95
+ throw invalid(
96
+ platform === "ios" ? "bundle id" : "package name",
97
+ platform === "ios" ? "expected letters, digits, dots, hyphens and underscores" : "expected a dotted Java package name"
98
+ );
99
+ }
100
+ return input;
101
+ };
102
+ var url = (input) => {
103
+ if (typeof input !== "string" || input.length > MAX_URL_LENGTH || !URL_PATTERN.test(input)) {
104
+ throw invalid("url", "expected a URL with a scheme and no spaces, quotes or shell characters");
105
+ }
106
+ return input;
107
+ };
108
+ var pushPayload = (input) => {
109
+ const payload = typeof input === "string" ? { aps: { alert: input } } : input;
110
+ if (!isRecord(payload)) throw invalid("push payload", "expected a string or an object");
111
+ let encoded;
112
+ try {
113
+ encoded = JSON.stringify(payload);
114
+ } catch {
115
+ encoded = void 0;
116
+ }
117
+ if (encoded === void 0) throw invalid("push payload", "could not be encoded as JSON");
118
+ if (new TextEncoder().encode(encoded).length > MAX_PUSH_BYTES) {
119
+ throw invalid("push payload", `larger than ${String(MAX_PUSH_BYTES)} bytes`);
120
+ }
121
+ return encoded;
122
+ };
123
+ var parseDeviceAction = (platform, input) => {
124
+ if (!isRecord(input)) throw invalid("action", "expected an object");
125
+ const type = oneOf(
126
+ [
127
+ "setAppearance",
128
+ "setTextSize",
129
+ "setToggle",
130
+ "setLiquidGlass",
131
+ "setColorFilter",
132
+ "setOrientation",
133
+ "setLocation",
134
+ "clearLocation",
135
+ "setPermission",
136
+ "openUrl",
137
+ "launchApp",
138
+ "terminateApp",
139
+ "shake",
140
+ "sendPush"
141
+ ],
142
+ input["type"],
143
+ "action type"
144
+ );
145
+ switch (type) {
146
+ case "setAppearance":
147
+ return { type, value: oneOf(APPEARANCES, input["value"], "appearance") };
148
+ case "setTextSize":
149
+ return { type, value: oneOf(TEXT_SIZES, input["value"], "text size") };
150
+ case "setToggle":
151
+ return {
152
+ type,
153
+ setting: oneOf(TOGGLES, input["setting"], "setting"),
154
+ value: boolean(input["value"], "value")
155
+ };
156
+ case "setLiquidGlass":
157
+ return { type, value: oneOf(LIQUID_GLASS, input["value"], "liquid glass") };
158
+ case "setColorFilter":
159
+ return { type, value: oneOf(COLOR_FILTERS, input["value"], "color filter") };
160
+ case "setOrientation":
161
+ return { type, value: oneOf(ORIENTATIONS, input["value"], "orientation") };
162
+ case "setLocation":
163
+ return {
164
+ type,
165
+ latitude: coordinate(input["latitude"], "latitude", 90),
166
+ longitude: coordinate(input["longitude"], "longitude", 180)
167
+ };
168
+ case "clearLocation":
169
+ case "shake":
170
+ return { type };
171
+ case "setPermission":
172
+ return {
173
+ type,
174
+ appId: appId(platform, input["appId"]),
175
+ permission: oneOf(PERMISSIONS, input["permission"], "permission"),
176
+ decision: oneOf(DECISIONS, input["decision"], "decision")
177
+ };
178
+ case "openUrl":
179
+ return { type, url: url(input["url"]) };
180
+ case "launchApp":
181
+ case "terminateApp":
182
+ return { type, appId: appId(platform, input["appId"]) };
183
+ case "sendPush":
184
+ return {
185
+ type,
186
+ appId: appId(platform, input["appId"]),
187
+ payload: input["payload"]
188
+ };
189
+ }
190
+ };
191
+ var IOS_ACTIONS = /* @__PURE__ */ new Set([
192
+ "setAppearance",
193
+ "setTextSize",
194
+ "setToggle",
195
+ "setLiquidGlass",
196
+ "setColorFilter",
197
+ "setLocation",
198
+ "clearLocation",
199
+ "setPermission",
200
+ "openUrl",
201
+ "launchApp",
202
+ "terminateApp",
203
+ "sendPush"
204
+ ]);
205
+ var ANDROID_ACTIONS = /* @__PURE__ */ new Set([
206
+ "setAppearance",
207
+ "setTextSize",
208
+ "setToggle",
209
+ "setOrientation",
210
+ "setLocation",
211
+ "clearLocation",
212
+ "setPermission",
213
+ "openUrl",
214
+ "launchApp",
215
+ "terminateApp"
216
+ ]);
217
+ var IOS_TOGGLES = /* @__PURE__ */ new Set([
218
+ "reduceMotion",
219
+ "increaseContrast",
220
+ "reduceTransparency",
221
+ "showBorders",
222
+ "voiceOver"
223
+ ]);
224
+ var ANDROID_TOGGLES = /* @__PURE__ */ new Set([
225
+ "reduceMotion",
226
+ "networkEnabled"
227
+ ]);
228
+ var supportsAction = (platform, action) => {
229
+ if (!(platform === "ios" ? IOS_ACTIONS : ANDROID_ACTIONS).has(action.type)) return false;
230
+ if (action.type === "setToggle") {
231
+ return (platform === "ios" ? IOS_TOGGLES : ANDROID_TOGGLES).has(action.setting);
232
+ }
233
+ return true;
234
+ };
235
+
236
+ // src/deviceActionSchema.ts
237
+ var target = {
238
+ hostId: { type: "string", minLength: 1 },
239
+ deviceId: { type: "string", minLength: 1, maxLength: 128 }
240
+ };
241
+ var appId2 = { type: "string", minLength: 1, maxLength: 255 };
242
+ var branch = (type, properties = {}, required = []) => ({
243
+ type: "object",
244
+ additionalProperties: false,
245
+ required: ["deviceId", "type", ...required],
246
+ properties: { ...target, type: { const: type }, ...properties }
247
+ });
248
+ var detailPayload = {
249
+ type: "object",
250
+ additionalProperties: false,
251
+ required: ["deviceId"],
252
+ properties: target
253
+ };
254
+ var actionPayload = {
255
+ type: "object",
256
+ oneOf: [
257
+ branch("setAppearance", { value: { enum: [...APPEARANCES] } }, ["value"]),
258
+ branch("setTextSize", { value: { enum: [...TEXT_SIZES] } }, ["value"]),
259
+ branch("setToggle", { setting: { enum: [...TOGGLES] }, value: { type: "boolean" } }, [
260
+ "setting",
261
+ "value"
262
+ ]),
263
+ branch("setLiquidGlass", { value: { enum: [...LIQUID_GLASS] } }, ["value"]),
264
+ branch("setColorFilter", { value: { enum: [...COLOR_FILTERS] } }, ["value"]),
265
+ branch("setOrientation", { value: { enum: [...ORIENTATIONS] } }, ["value"]),
266
+ branch(
267
+ "setLocation",
268
+ {
269
+ latitude: { type: "number", minimum: -90, maximum: 90 },
270
+ longitude: { type: "number", minimum: -180, maximum: 180 }
271
+ },
272
+ ["latitude", "longitude"]
273
+ ),
274
+ branch("clearLocation"),
275
+ branch(
276
+ "setPermission",
277
+ { appId: appId2, permission: { enum: [...PERMISSIONS] }, decision: { enum: [...DECISIONS] } },
278
+ ["appId", "permission", "decision"]
279
+ ),
280
+ branch("openUrl", { url: { type: "string", minLength: 1, maxLength: 2048 } }, ["url"]),
281
+ branch("launchApp", { appId: appId2 }, ["appId"]),
282
+ branch("terminateApp", { appId: appId2 }, ["appId"]),
283
+ branch("shake"),
284
+ branch("sendPush", { appId: appId2, payload: { type: ["string", "object"] } }, ["appId", "payload"])
285
+ ]
286
+ };
287
+
288
+ // src/commands.ts
289
+ var emptyPayload = {
290
+ type: "object",
291
+ properties: {},
292
+ additionalProperties: false
293
+ };
294
+ var deviceTarget = {
295
+ hostId: { type: "string", minLength: 1 },
296
+ deviceId: { type: "string", minLength: 1 }
297
+ };
298
+ var openPayload = {
299
+ type: "object",
300
+ additionalProperties: false,
301
+ required: ["threadId", "deviceId", "platform"],
302
+ properties: {
303
+ threadId: { type: "string", minLength: 1 },
304
+ ...deviceTarget,
305
+ platform: { type: "string", enum: ["ios", "android"] },
306
+ boot: { type: "boolean" }
307
+ }
308
+ };
309
+ var closePayload = {
310
+ type: "object",
311
+ additionalProperties: false,
312
+ required: ["threadId"],
313
+ properties: {
314
+ threadId: { type: "string", minLength: 1 },
315
+ ...deviceTarget,
316
+ shutdown: { type: "boolean" }
317
+ }
318
+ };
319
+ var shutdownPayload = {
320
+ type: "object",
321
+ additionalProperties: false,
322
+ required: ["deviceId", "platform"],
323
+ properties: { ...deviceTarget, platform: { type: "string", enum: ["ios", "android"] } }
324
+ };
325
+ var updatePayload = {
326
+ type: "object",
327
+ additionalProperties: false,
328
+ required: ["tool"],
329
+ properties: { tool: { type: "string", enum: ["hub", "agent"] } }
330
+ };
331
+ var optional = (value) => typeof value === "string" && value.length > 0 ? value : void 0;
332
+ var versionParams = (prefix, version) => ({
333
+ [`${prefix}Required`]: version?.requiredVersion ?? "\u2014",
334
+ [`${prefix}Installed`]: version === void 0 || version.installedVersions.length === 0 ? "\u2014" : version.installedVersions.join(", "),
335
+ [`${prefix}Running`]: version?.runningVersion ?? "\u2014"
336
+ });
337
+ var SETTINGS_CARD = {
338
+ titleKey: "settings.card.title",
339
+ descriptionKey: "settings.card.description",
340
+ actions: [
341
+ {
342
+ id: "check-tools",
343
+ labelKey: "settings.checkTools.label",
344
+ descriptionKey: "settings.checkTools.description",
345
+ command: "checkTools"
346
+ },
347
+ {
348
+ id: "update-tools",
349
+ labelKey: "settings.updateTools.label",
350
+ descriptionKey: "settings.updateTools.description",
351
+ command: "updateTools"
352
+ }
353
+ ],
354
+ fields: [
355
+ {
356
+ key: "androidSdk",
357
+ kind: "text",
358
+ labelKey: "settings.androidSdk.label",
359
+ descriptionKey: "settings.androidSdk.description",
360
+ placeholderKey: "settings.androidSdk.placeholder"
361
+ }
362
+ ]
363
+ };
364
+ var registerCommands = (ctx, service) => {
365
+ ctx.stream({
366
+ name: "state",
367
+ description: "Device hosts, devices, and which conversation has which device open.",
368
+ scope: "read",
369
+ payloadSchema: emptyPayload,
370
+ buffer: { latest: 8 },
371
+ handle: async (_payload, emit, signal) => {
372
+ const unsubscribe = service.subscribe((state) => {
373
+ if (!signal.aborted) emit(state);
374
+ });
375
+ try {
376
+ await new Promise((resolve) => {
377
+ if (signal.aborted) resolve();
378
+ else signal.addEventListener("abort", () => resolve(), { once: true });
379
+ });
380
+ } finally {
381
+ unsubscribe();
382
+ }
383
+ }
384
+ });
385
+ ctx.command({
386
+ name: "refresh",
387
+ description: "Start the device hub if this machine can run a simulator, then list devices.",
388
+ scope: "operate",
389
+ payloadSchema: emptyPayload,
390
+ handle: () => service.list()
391
+ });
392
+ ctx.command({
393
+ name: "inspect",
394
+ description: "Read the device host's platforms and tool versions without installing or starting anything.",
395
+ scope: "read",
396
+ payloadSchema: emptyPayload,
397
+ handle: () => service.inspect(true)
398
+ });
399
+ ctx.command({
400
+ name: "detail",
401
+ description: "Read a booted device's settings and foreground app.",
402
+ scope: "read",
403
+ payloadSchema: detailPayload,
404
+ handle: (payload) => service.detail({
405
+ hostId: optional(payload["hostId"]),
406
+ deviceId: String(payload["deviceId"])
407
+ })
408
+ });
409
+ ctx.command({
410
+ name: "action",
411
+ description: "Run one settings or app action on a booted device, then return its refreshed detail.",
412
+ scope: "operate",
413
+ payloadSchema: actionPayload,
414
+ // 整份 payload 交给服务,由 parseDeviceAction 重新逐字段校验。
415
+ handle: (payload) => service.action({
416
+ ...payload,
417
+ hostId: optional(payload["hostId"]),
418
+ deviceId: String(payload["deviceId"])
419
+ })
420
+ });
421
+ ctx.command({
422
+ name: "open",
423
+ description: "Open a device in a conversation, booting it if needed.",
424
+ scope: "operate",
425
+ payloadSchema: openPayload,
426
+ handle: (payload) => service.open({
427
+ threadId: String(payload["threadId"]),
428
+ hostId: optional(payload["hostId"]),
429
+ deviceId: String(payload["deviceId"]),
430
+ platform: payload["platform"] === "ios" ? "ios" : "android",
431
+ ...typeof payload["boot"] === "boolean" ? { boot: payload["boot"] } : {}
432
+ })
433
+ });
434
+ ctx.command({
435
+ name: "close",
436
+ description: "Close a device in a conversation; optionally power it off.",
437
+ scope: "operate",
438
+ payloadSchema: closePayload,
439
+ handle: async (payload) => {
440
+ await service.close({
441
+ threadId: String(payload["threadId"]),
442
+ hostId: optional(payload["hostId"]),
443
+ deviceId: optional(payload["deviceId"]),
444
+ shutdown: payload["shutdown"] === true
445
+ });
446
+ return {};
447
+ }
448
+ });
449
+ ctx.command({
450
+ name: "shutdown",
451
+ description: "Power a simulator or emulator off.",
452
+ scope: "operate",
453
+ payloadSchema: shutdownPayload,
454
+ handle: async (payload) => {
455
+ await service.shutdown({
456
+ hostId: optional(payload["hostId"]),
457
+ deviceId: String(payload["deviceId"]),
458
+ platform: payload["platform"] === "ios" ? "ios" : "android"
459
+ });
460
+ return {};
461
+ }
462
+ });
463
+ ctx.command({
464
+ name: "updateTool",
465
+ description: "Install this plugin's pinned version of one device tool without starting it.",
466
+ scope: "operate",
467
+ payloadSchema: updatePayload,
468
+ handle: (payload) => service.updateTool(payload["tool"] === "agent" ? "agent" : "hub")
469
+ });
470
+ ctx.stream({
471
+ name: "checkTools",
472
+ description: "Settings card action: read the installed and running device tool versions.",
473
+ scope: "operate",
474
+ payloadSchema: emptyPayload,
475
+ handle: async (_payload, emit) => {
476
+ const send = (item) => emit(item);
477
+ try {
478
+ const state = await service.inspect(true);
479
+ const tools = state.hosts[0]?.tools;
480
+ send({
481
+ kind: "status",
482
+ statusKey: "settings.checkTools.versions",
483
+ params: { ...versionParams("hub", tools?.hub), ...versionParams("agent", tools?.agent) }
484
+ });
485
+ send({ kind: "done", fills: {}, messageKey: "settings.checkTools.done" });
486
+ } catch (error) {
487
+ send({
488
+ kind: "failed",
489
+ reasonKey: "settings.checkTools.failed",
490
+ detail: error instanceof Error ? error.message : String(error)
491
+ });
492
+ }
493
+ }
494
+ });
495
+ ctx.stream({
496
+ name: "updateTools",
497
+ description: "Settings card action: install the pinned device tool versions.",
498
+ scope: "operate",
499
+ payloadSchema: emptyPayload,
500
+ handle: async (_payload, emit) => {
501
+ const order = ["hub", "agent"];
502
+ const states = new Map(
503
+ order.map((tool) => [tool, "pending"])
504
+ );
505
+ const report = () => emit({
506
+ kind: "steps",
507
+ steps: order.map((tool) => ({
508
+ id: tool,
509
+ labelKey: `settings.updateTools.${tool}`,
510
+ state: states.get(tool) ?? "pending"
511
+ }))
512
+ });
513
+ for (const tool of order) {
514
+ states.set(tool, "running");
515
+ report();
516
+ try {
517
+ await service.updateTool(tool);
518
+ states.set(tool, "done");
519
+ } catch (error) {
520
+ states.set(tool, "failed");
521
+ report();
522
+ emit({
523
+ kind: "failed",
524
+ reasonKey: "settings.updateTools.failed",
525
+ detail: error instanceof Error ? error.message : String(error)
526
+ });
527
+ return;
528
+ }
529
+ }
530
+ report();
531
+ emit({
532
+ kind: "done",
533
+ fills: {},
534
+ messageKey: "settings.updateTools.done"
535
+ });
536
+ }
537
+ });
538
+ };
539
+
540
+ // src/agentDevice.ts
541
+ import * as NodeCrypto from "node:crypto";
542
+ import * as NodeFSP from "node:fs/promises";
543
+ import * as NodePath from "node:path";
544
+ var DAEMON_READY_TIMEOUT_MS = 3e4;
545
+ var DAEMON_POLL_MS = 100;
546
+ var key = (value) => NodeCrypto.createHash("sha256").update(value).digest("hex").slice(0, 24);
547
+ var agentDeviceConfigPath = (dataDir, hostId) => NodePath.join(dataDir, "hosts", `${key(hostId)}.json`);
548
+ var agentDeviceSession = (threadId, hostId, deviceId) => `ycodium-${key(JSON.stringify([threadId, hostId, deviceId]))}`;
549
+ var agentDeviceTargetArgs = (device) => device.platform === "ios" ? ["--platform", "ios", "--udid", device.id] : ["--platform", "android", "--serial", device.id];
550
+ var writeAgentDeviceConfig = async (file, endpoint) => {
551
+ await NodeFSP.mkdir(NodePath.dirname(file), { recursive: true });
552
+ const content = JSON.stringify({
553
+ daemonBaseUrl: endpoint.baseUrl,
554
+ daemonAuthToken: endpoint.token
555
+ });
556
+ const existing2 = await NodeFSP.readFile(file, "utf8").catch(() => "");
557
+ if (existing2 === content) return;
558
+ const temporary = NodePath.join(
559
+ NodePath.dirname(file),
560
+ `.endpoint-${NodeCrypto.randomUUID()}.json`
561
+ );
562
+ try {
563
+ await NodeFSP.writeFile(temporary, content, { mode: 384 });
564
+ await NodeFSP.rename(temporary, file);
565
+ } finally {
566
+ await NodeFSP.rm(temporary, { force: true }).catch(() => void 0);
567
+ }
568
+ };
569
+ var LAUNCHER_DIR = "bin";
570
+ var ensureAgentDeviceLauncher = async (input) => {
571
+ const directory = NodePath.join(input.dataDir, LAUNCHER_DIR);
572
+ await NodeFSP.mkdir(directory, { recursive: true });
573
+ const launcherPath = NodePath.join(directory, "agent-device-launcher.mjs");
574
+ await NodeFSP.writeFile(
575
+ launcherPath,
576
+ `import { spawn } from "node:child_process";
577
+ const args = process.argv.slice(2);
578
+ // \u5E2E\u52A9\u4E0E\u7248\u672C\u4E0D\u78B0\u8BBE\u5907\uFF1A\u5355\u72EC\u7684 help/version\uFF0C\u6216\u4EFB\u4F55\u547D\u4EE4\u540E\u5E26 --help / -h\uFF08\u5FEB\u901F\u4E0A\u624B\u91CC\u6559 agent \u8FD9\u6837\u67E5\u53C2\u6570\uFF09\u3002
579
+ const informational =
580
+ (args.length === 1 && ["help", "--help", "-h", "--version", "version"].includes(args[0])) ||
581
+ args.includes("--help") ||
582
+ args.includes("-h");
583
+ const hasValue = flag => { const index = args.indexOf(flag); return index >= 0 && !!args[index + 1] && !args[index + 1].startsWith("--"); };
584
+ if (!informational && !(hasValue("--config") && hasValue("--session"))) {
585
+ console.error("Call device_open first and include its --config and --session flags.");
586
+ process.exit(1);
587
+ }
588
+ const env = { ...process.env };
589
+ delete env.AGENT_DEVICE_DAEMON_BASE_URL;
590
+ delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN;
591
+ delete env.AGENT_DEVICE_CONFIG;
592
+ const child = spawn(${JSON.stringify(input.nodePath)}, [${JSON.stringify(input.entryPath)}, ...args], { stdio: "inherit", env });
593
+ child.on("error", error => { console.error(error.message); process.exitCode = 1; });
594
+ child.on("exit", code => { process.exitCode = code ?? 1; });
595
+ `
596
+ );
597
+ if (input.platform === "win32") {
598
+ const command2 = NodePath.join(directory, "agent-device.cmd");
599
+ const electron2 = input.electron ? "set ELECTRON_RUN_AS_NODE=1\r\n" : "";
600
+ await NodeFSP.writeFile(
601
+ command2,
602
+ `@echo off\r
603
+ ${electron2}"${input.nodePath}" "${launcherPath}" %*\r
604
+ `
605
+ );
606
+ return command2;
607
+ }
608
+ const quote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
609
+ const command = NodePath.join(directory, "agent-device");
610
+ const electron = input.electron ? "ELECTRON_RUN_AS_NODE=1 " : "";
611
+ await NodeFSP.writeFile(
612
+ command,
613
+ `#!/bin/sh
614
+ ${electron}exec ${quote(input.nodePath)} ${quote(launcherPath)} "$@"
615
+ `
616
+ );
617
+ await NodeFSP.chmod(command, 493);
618
+ return command;
619
+ };
620
+ var parseDaemonFile = (raw) => {
621
+ let value;
622
+ try {
623
+ value = JSON.parse(raw);
624
+ } catch {
625
+ return null;
626
+ }
627
+ if (typeof value !== "object" || value === null) return null;
628
+ const record = value;
629
+ if (!Number.isSafeInteger(record["httpPort"]) || typeof record["token"] !== "string") return null;
630
+ return {
631
+ httpPort: record["httpPort"],
632
+ token: record["token"],
633
+ pid: Number.isSafeInteger(record["pid"]) ? record["pid"] : void 0,
634
+ version: typeof record["version"] === "string" ? record["version"] : void 0
635
+ };
636
+ };
637
+ var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
638
+ var daemonEnvironment = (stateDir) => ({
639
+ AGENT_DEVICE_STATE_DIR: stateDir,
640
+ AGENT_DEVICE_DAEMON_SERVER_MODE: "http",
641
+ AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: "0",
642
+ AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1",
643
+ FORCE_COLOR: "0",
644
+ NO_COLOR: "1"
645
+ });
646
+ var AgentDaemon = class {
647
+ entryPath = null;
648
+ now;
649
+ sleep;
650
+ options;
651
+ constructor(options) {
652
+ this.options = options;
653
+ this.now = options.now ?? Date.now;
654
+ this.sleep = options.sleep ?? defaultSleep;
655
+ }
656
+ get daemonFilePath() {
657
+ return NodePath.join(this.options.stateDir, "daemon.json");
658
+ }
659
+ async readDaemonFile() {
660
+ const raw = await NodeFSP.readFile(this.daemonFilePath, "utf8").catch(() => null);
661
+ return raw === null ? null : parseDaemonFile(raw);
662
+ }
663
+ async healthy(port) {
664
+ try {
665
+ const response = await this.options.fetch(`http://127.0.0.1:${String(port)}/health`, {
666
+ signal: AbortSignal.timeout(2e3)
667
+ });
668
+ await response.arrayBuffer().catch(() => void 0);
669
+ return response.status === 200;
670
+ } catch {
671
+ return false;
672
+ }
673
+ }
674
+ toEndpoint(file, entryPath) {
675
+ return {
676
+ baseUrl: `http://127.0.0.1:${String(file.httpPort)}`,
677
+ token: file.token,
678
+ entryPath,
679
+ version: file.version ?? null,
680
+ pid: file.pid ?? null
681
+ };
682
+ }
683
+ /**
684
+ * agent-device 没有 `daemon start`:状态目录里第一条命令会拉起守护进程并等它应答,
685
+ * `devices` 是最便宜的一条。之后读它写出的 daemon.json。
686
+ */
687
+ async ensure(input) {
688
+ this.entryPath = input.entryPath;
689
+ await NodeFSP.mkdir(this.options.stateDir, { recursive: true });
690
+ const existing2 = await this.readDaemonFile();
691
+ if (existing2 !== null) {
692
+ if (await this.healthy(existing2.httpPort)) return this.toEndpoint(existing2, input.entryPath);
693
+ await NodeFSP.rm(this.daemonFilePath, { force: true }).catch(() => void 0);
694
+ }
695
+ const args = [this.options.helperPath, "node"];
696
+ if (input.sdkRoot !== null) args.push("--sdk", input.sdkRoot);
697
+ args.push("--", input.entryPath, "devices", "--json");
698
+ const timeoutMs = this.options.readyTimeoutMs ?? DAEMON_READY_TIMEOUT_MS;
699
+ await this.options.process.run({
700
+ program: "host:node",
701
+ args,
702
+ cwd: this.options.stateDir,
703
+ env: daemonEnvironment(this.options.stateDir),
704
+ timeoutMs
705
+ }).catch(() => void 0);
706
+ const deadline = this.now() + timeoutMs;
707
+ while (true) {
708
+ const file = await this.readDaemonFile();
709
+ if (file !== null) return this.toEndpoint(file, input.entryPath);
710
+ if (this.now() > deadline) {
711
+ throw new DeviceError(
712
+ "agent-timeout",
713
+ `Agent tools did not start within ${String(timeoutMs)} ms.`
714
+ );
715
+ }
716
+ await this.sleep(DAEMON_POLL_MS);
717
+ }
718
+ }
719
+ /** 守护进程是 agent-device 自己脱离出去的,宿主停插件时杀不到它,所以显式停。 */
720
+ async stop() {
721
+ const entryPath = this.entryPath;
722
+ if (entryPath === null) return;
723
+ await this.options.process.run({
724
+ program: "host:node",
725
+ args: [entryPath, "daemon", "stop", "--state-dir", this.options.stateDir],
726
+ cwd: this.options.stateDir,
727
+ env: { AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1" },
728
+ timeoutMs: 1e4
729
+ }).catch(() => void 0);
730
+ }
731
+ };
732
+ var agentDeviceQuickStart = (device, targetArgs, command) => {
733
+ const quoteArg = (arg) => /^[a-zA-Z0-9_./:\\-]+$/.test(arg) ? arg : `"${arg.replaceAll('"', '\\"')}"`;
734
+ const executable = quoteArg(command);
735
+ const target2 = targetArgs.map(quoteArg).join(" ");
736
+ const platformNote = device.platform === "ios" ? "First use builds an XCTest runner and can take a couple of minutes; later commands are fast." : "The Android snapshot helper installs itself on first use.";
737
+ return [
738
+ `The user can watch ${device.name} (${device.version}) in the Devices panel.`,
739
+ `Drive it with ${executable}. Use this exact executable path; login shells may reset PATH. Always pass ${target2}.`,
740
+ "Typical loop:",
741
+ ` ${executable} open <bundle-or-package-id> ${target2} # or: open <app> <deep-link-url>`,
742
+ ` ${executable} snapshot -i ${target2} # accessibility tree with @eN refs`,
743
+ ` ${executable} click @e3 ${target2}`,
744
+ ` ${executable} fill @e5 "text" ${target2}`,
745
+ ` ${executable} screenshot <file.png> ${target2} # or call device_screenshot`,
746
+ ` ${executable} install <app> <path-to-.app-or-.apk> ${target2}`,
747
+ `Prefer snapshot refs over coordinates. Run ${executable} help for workflow guides and ${executable} <command> --help for flags.`,
748
+ "Prefer agent-device for driving this device. simctl, adb, and xcrun remain available for anything it does not cover.",
749
+ "Keep the returned --config and --session flags on every command.",
750
+ platformNote
751
+ ].join("\n");
752
+ };
753
+
754
+ // src/hubProcess.ts
755
+ import * as NodeFSP2 from "node:fs/promises";
756
+ import * as NodePath2 from "node:path";
757
+
758
+ // ../../packages/ycodium-fetch-ports/src/fetchPorts.ts
759
+ import * as NodeNet from "node:net";
760
+ var FETCH_BLOCKED_PORTS = /* @__PURE__ */ new Set([
761
+ 1,
762
+ 7,
763
+ 9,
764
+ 11,
765
+ 13,
766
+ 15,
767
+ 17,
768
+ 19,
769
+ 20,
770
+ 21,
771
+ 22,
772
+ 23,
773
+ 25,
774
+ 37,
775
+ 42,
776
+ 43,
777
+ 53,
778
+ 69,
779
+ 77,
780
+ 79,
781
+ 87,
782
+ 95,
783
+ 101,
784
+ 102,
785
+ 103,
786
+ 104,
787
+ 109,
788
+ 110,
789
+ 111,
790
+ 113,
791
+ 115,
792
+ 117,
793
+ 119,
794
+ 123,
795
+ 135,
796
+ 137,
797
+ 139,
798
+ 143,
799
+ 161,
800
+ 179,
801
+ 389,
802
+ 427,
803
+ 465,
804
+ 512,
805
+ 513,
806
+ 514,
807
+ 515,
808
+ 526,
809
+ 530,
810
+ 531,
811
+ 532,
812
+ 540,
813
+ 548,
814
+ 554,
815
+ 556,
816
+ 563,
817
+ 587,
818
+ 601,
819
+ 636,
820
+ 989,
821
+ 990,
822
+ 993,
823
+ 995,
824
+ 1719,
825
+ 1720,
826
+ 1723,
827
+ 2049,
828
+ 3659,
829
+ 4045,
830
+ 4190,
831
+ 5060,
832
+ 5061,
833
+ 6e3,
834
+ 6566,
835
+ 6665,
836
+ 6666,
837
+ 6667,
838
+ 6668,
839
+ 6669,
840
+ 6679,
841
+ 6697,
842
+ 10080
843
+ ]);
844
+ var isFetchSafePort = (port) => !FETCH_BLOCKED_PORTS.has(port);
845
+ var MAX_SAFE_PORT_ATTEMPTS = 50;
846
+ var systemAssigned = () => 0;
847
+ var listenOnce = (server, host, port) => new Promise((resolve, reject) => {
848
+ server.once("error", reject);
849
+ server.listen(port, host, () => {
850
+ server.off("error", reject);
851
+ const address = server.address();
852
+ resolve(typeof address === "object" && address !== null ? address.port : 0);
853
+ });
854
+ });
855
+ var listenOnSafePort = async (server, options = {}) => {
856
+ const host = options.host ?? "127.0.0.1";
857
+ const portForAttempt = options.portForAttempt ?? systemAssigned;
858
+ for (let attempt = 0; attempt < MAX_SAFE_PORT_ATTEMPTS; attempt++) {
859
+ const port = await listenOnce(server, host, portForAttempt(attempt));
860
+ if (isFetchSafePort(port)) return port;
861
+ await new Promise((resolve) => server.close(() => resolve()));
862
+ }
863
+ throw new Error(`\u8FDE\u7EED ${String(MAX_SAFE_PORT_ATTEMPTS)} \u6B21\u5206\u5230 fetch \u7684\u574F\u7AEF\u53E3`);
864
+ };
865
+ var reserveFetchSafePort = async (options = {}) => {
866
+ const probe = NodeNet.createServer();
867
+ try {
868
+ const port = await listenOnSafePort(probe, options);
869
+ await new Promise((resolve) => probe.close(() => resolve()));
870
+ if (port <= 0) throw new Error("\u65E0\u6CD5\u53D6\u5F97\u76D1\u542C\u7AEF\u53E3");
871
+ return port;
872
+ } catch (cause) {
873
+ try {
874
+ probe.close();
875
+ } catch {
876
+ }
877
+ throw cause;
878
+ }
879
+ };
880
+
881
+ // src/hubProcess.ts
882
+ var HUB_READY_TIMEOUT_MS = 3e4;
883
+ var HUB_READY_POLL_MS = 200;
884
+ var HUB_RESTART_STABLE_UPTIME_MS = 6e4;
885
+ var HUB_RESTART_MAX_DELAY_MS = 3e4;
886
+ var OUTPUT_TAIL_LINES = 20;
887
+ var nodeProcessProbe = {
888
+ isAlive: (pid) => {
889
+ try {
890
+ process.kill(pid, 0);
891
+ return true;
892
+ } catch (error) {
893
+ return error.code === "EPERM";
894
+ }
895
+ },
896
+ terminate: (pid) => {
897
+ try {
898
+ process.kill(pid, "SIGTERM");
899
+ } catch {
900
+ }
901
+ }
902
+ };
903
+ var defaultSleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
904
+ var drainOutput = (handle) => {
905
+ const tail = [];
906
+ const decoder = new TextDecoder();
907
+ const drain = async (stream) => {
908
+ let pending = "";
909
+ for await (const chunk of stream) {
910
+ pending += decoder.decode(chunk, { stream: true });
911
+ const lines = pending.split(/\r?\n/);
912
+ pending = lines.pop() ?? "";
913
+ for (const line of lines) {
914
+ if (line.trim().length === 0) continue;
915
+ tail.push(line.trim());
916
+ if (tail.length > OUTPUT_TAIL_LINES) tail.shift();
917
+ }
918
+ }
919
+ };
920
+ void drain(handle.stdout).catch(() => void 0);
921
+ void drain(handle.stderr).catch(() => void 0);
922
+ return () => tail.join("\n");
923
+ };
924
+ var HubProcess = class {
925
+ current = null;
926
+ restartDelayMs = 0;
927
+ stopped = false;
928
+ probe;
929
+ now;
930
+ sleep;
931
+ options;
932
+ constructor(options) {
933
+ this.options = options;
934
+ this.probe = options.processProbe ?? nodeProcessProbe;
935
+ this.now = options.now ?? Date.now;
936
+ this.sleep = options.sleep ?? defaultSleep2;
937
+ }
938
+ get statePath() {
939
+ return NodePath2.join(this.options.stateDir, "hub.json");
940
+ }
941
+ get running() {
942
+ return this.current?.hub ?? null;
943
+ }
944
+ async answersReady(origin, timeoutMs = 2e3) {
945
+ try {
946
+ const response = await this.options.fetch(`${origin}/readyz`, {
947
+ signal: AbortSignal.timeout(timeoutMs)
948
+ });
949
+ await response.arrayBuffer().catch(() => void 0);
950
+ return response.ok;
951
+ } catch {
952
+ return false;
953
+ }
954
+ }
955
+ /** 上次的服务器没收尾留下的 hub:pid 活着且那个端口答 /readyz 才结束它。 */
956
+ async reapStale() {
957
+ let previous = null;
958
+ try {
959
+ const parsed = JSON.parse(
960
+ await NodeFSP2.readFile(this.statePath, "utf8")
961
+ );
962
+ if (Number.isSafeInteger(parsed.pid) && Number.isSafeInteger(parsed.port) && typeof parsed.entryPath === "string") {
963
+ previous = parsed;
964
+ }
965
+ } catch {
966
+ previous = null;
967
+ }
968
+ if (previous !== null && previous.pid > 0 && this.probe.isAlive(previous.pid)) {
969
+ if (await this.answersReady(`http://127.0.0.1:${String(previous.port)}`)) {
970
+ this.options.log.warn("Stopping a device hub left behind by a previous server", {
971
+ pid: previous.pid,
972
+ port: previous.port
973
+ });
974
+ this.probe.terminate(previous.pid);
975
+ }
976
+ }
977
+ await NodeFSP2.rm(this.statePath, { force: true }).catch(() => void 0);
978
+ }
979
+ async spawnOnce(launch) {
980
+ await this.reapStale();
981
+ await NodeFSP2.mkdir(this.options.stateDir, { recursive: true });
982
+ const port = await (this.options.reservePort ?? (() => reserveFetchSafePort()))();
983
+ const origin = `http://127.0.0.1:${String(port)}`;
984
+ const args = [this.options.helperPath, "node"];
985
+ if (launch.sdkRoot !== null) args.push("--sdk", launch.sdkRoot);
986
+ args.push(
987
+ "--",
988
+ launch.entryPath,
989
+ "--port",
990
+ String(port),
991
+ "--host",
992
+ "127.0.0.1",
993
+ "--hide-sidebar",
994
+ "--hide-boot-device"
995
+ );
996
+ let handle;
997
+ try {
998
+ handle = await this.options.process.spawn({
999
+ program: "host:node",
1000
+ args,
1001
+ cwd: this.options.stateDir,
1002
+ env: { FORCE_COLOR: "0", NO_COLOR: "1" }
1003
+ });
1004
+ } catch (cause) {
1005
+ throw new DeviceError(
1006
+ "host-unavailable",
1007
+ "Device support failed while starting the device hub.",
1008
+ {
1009
+ cause
1010
+ }
1011
+ );
1012
+ }
1013
+ const output = drainOutput(handle);
1014
+ let exited = false;
1015
+ void handle.exited.then(() => {
1016
+ exited = true;
1017
+ });
1018
+ const deadline = this.now() + (this.options.readyTimeoutMs ?? HUB_READY_TIMEOUT_MS);
1019
+ while (!await this.answersReady(origin)) {
1020
+ if (exited || this.now() > deadline) {
1021
+ await handle.kill().catch(() => void 0);
1022
+ const tail = output();
1023
+ throw new DeviceError(
1024
+ "host-unavailable",
1025
+ `Device support failed while waiting for the device hub to answer.${tail ? `
1026
+ ${tail}` : ""}`
1027
+ );
1028
+ }
1029
+ await this.sleep(HUB_READY_POLL_MS);
1030
+ }
1031
+ const hub = { origin, port, pid: handle.pid, startedAt: this.now() };
1032
+ const record = { pid: handle.pid, port, entryPath: launch.entryPath };
1033
+ await NodeFSP2.writeFile(this.statePath, JSON.stringify(record)).catch(() => void 0);
1034
+ this.current = { hub, handle };
1035
+ this.options.log.info("Device hub started", { pid: handle.pid, port });
1036
+ this.options.onStarted?.(hub);
1037
+ void this.supervise(handle, launch);
1038
+ return hub;
1039
+ }
1040
+ /** hub 意外退出时重启;开机就崩的 hub 按 1 秒起、翻倍到 30 秒的间隔重试,不空转。 */
1041
+ async supervise(handle, launch) {
1042
+ await handle.exited;
1043
+ if (this.stopped || this.current?.handle !== handle) return;
1044
+ const uptime = this.now() - this.current.hub.startedAt;
1045
+ this.current = null;
1046
+ const delay = uptime >= HUB_RESTART_STABLE_UPTIME_MS ? 0 : this.restartDelayMs;
1047
+ this.restartDelayMs = uptime >= HUB_RESTART_STABLE_UPTIME_MS ? 0 : this.restartDelayMs === 0 ? 1e3 : Math.min(this.restartDelayMs * 2, HUB_RESTART_MAX_DELAY_MS);
1048
+ this.options.log.warn("Device hub exited; restarting", { pid: handle.pid, delayMs: delay });
1049
+ await this.sleep(delay);
1050
+ if (this.stopped || this.current !== null) return;
1051
+ await this.spawnOnce(launch).catch((error) => {
1052
+ this.options.log.warn("Device hub restart failed", {
1053
+ detail: error instanceof Error ? error.message : String(error)
1054
+ });
1055
+ });
1056
+ }
1057
+ /** 已在跑就直接返回;否则起一个。 */
1058
+ async ensure(launch) {
1059
+ this.stopped = false;
1060
+ if (this.current !== null) return this.current.hub;
1061
+ this.restartDelayMs = 0;
1062
+ return this.spawnOnce(launch);
1063
+ }
1064
+ /** 结束 hub。设备本身不关,那是用户的。 */
1065
+ async stop() {
1066
+ this.stopped = true;
1067
+ const current = this.current;
1068
+ this.current = null;
1069
+ if (current !== null) await current.handle.kill().catch(() => void 0);
1070
+ await NodeFSP2.rm(this.statePath, { force: true }).catch(() => void 0);
1071
+ }
1072
+ };
1073
+
1074
+ // src/toolchain.ts
1075
+ import * as NodeFSP4 from "node:fs/promises";
1076
+ import * as NodePath4 from "node:path";
1077
+
1078
+ // src/npmRuntime.ts
1079
+ import * as NodeCrypto2 from "node:crypto";
1080
+ import * as NodeFSP3 from "node:fs/promises";
1081
+ import * as NodePath3 from "node:path";
1082
+ var MANAGED_NPM_VERSION = "11.21.0";
1083
+ var MANAGED_NPM_INTEGRITY = "sha512-Zov8KhamNneiLdELtj5YALtNmJW4L4fCLTzjfpzXG2w6MSHcf0UxgdlK5uloCuksWT+7mGUU7wi79cO6RqivPg==";
1084
+ var DEFAULT_REGISTRY = "https://registry.npmjs.org";
1085
+ var managedNpmPaths = (dataDir, version = MANAGED_NPM_VERSION) => {
1086
+ const installDir = NodePath3.join(dataDir, "npm", version);
1087
+ return {
1088
+ installDir,
1089
+ cliPath: NodePath3.join(installDir, "bin", "npm-cli.js"),
1090
+ sentinelPath: NodePath3.join(installDir, ".install-complete"),
1091
+ shimDir: NodePath3.join(dataDir, "npm", "node-shim")
1092
+ };
1093
+ };
1094
+ var integrityMatches = (bytes, integrity) => {
1095
+ const [algorithm, expected] = integrity.split("-", 2);
1096
+ if (algorithm !== "sha512" || expected === void 0) return false;
1097
+ return NodeCrypto2.createHash("sha512").update(bytes).digest("base64") === expected;
1098
+ };
1099
+ var fileExists = (file) => NodeFSP3.access(file).then(
1100
+ () => true,
1101
+ () => false
1102
+ );
1103
+ var failed = (step, detail) => new DeviceError(
1104
+ "install-failed",
1105
+ `Preparing npm ${MANAGED_NPM_VERSION} failed while ${step}.${detail ? ` ${detail}` : ""}`
1106
+ );
1107
+ var ensureManagedNpm = async (input) => {
1108
+ const paths = managedNpmPaths(input.dataDir);
1109
+ const sentinel = await NodeFSP3.readFile(paths.sentinelPath, "utf8").catch(() => null);
1110
+ if (sentinel?.trim() === MANAGED_NPM_VERSION && await fileExists(paths.cliPath)) return paths;
1111
+ const registry = (input.registry ?? DEFAULT_REGISTRY).replace(/\/+$/, "");
1112
+ const url2 = `${registry}/npm/-/npm-${MANAGED_NPM_VERSION}.tgz`;
1113
+ let bytes;
1114
+ try {
1115
+ const response = await input.fetch(url2, { signal: AbortSignal.timeout(5 * 6e4) });
1116
+ if (!response.ok)
1117
+ throw failed("downloading", `The registry answered ${String(response.status)}.`);
1118
+ bytes = new Uint8Array(await response.arrayBuffer());
1119
+ } catch (cause) {
1120
+ if (cause instanceof DeviceError) throw cause;
1121
+ throw failed("downloading", errorDetail(cause));
1122
+ }
1123
+ if (!integrityMatches(bytes, MANAGED_NPM_INTEGRITY)) {
1124
+ throw failed("verifying the download", "The archive does not match the pinned checksum.");
1125
+ }
1126
+ const parent = NodePath3.dirname(paths.installDir);
1127
+ await NodeFSP3.mkdir(parent, { recursive: true });
1128
+ const staging = await NodeFSP3.mkdtemp(NodePath3.join(parent, ".staging-"));
1129
+ const archive = NodePath3.join(
1130
+ parent,
1131
+ `.npm-${MANAGED_NPM_VERSION}-${NodeCrypto2.randomUUID()}.tgz`
1132
+ );
1133
+ try {
1134
+ await NodeFSP3.writeFile(archive, bytes);
1135
+ const result = await input.process.run({
1136
+ program: "host:node",
1137
+ args: [input.helperPath, "extract", "--", archive, staging],
1138
+ cwd: input.dataDir,
1139
+ timeoutMs: 12e4
1140
+ });
1141
+ if (result.exitCode !== 0) throw failed("unpacking", result.stderr.trim());
1142
+ if (!await fileExists(NodePath3.join(staging, "bin", "npm-cli.js"))) {
1143
+ throw failed("unpacking", "The archive has no bin/npm-cli.js.");
1144
+ }
1145
+ await NodeFSP3.writeFile(
1146
+ NodePath3.join(staging, ".install-complete"),
1147
+ `${MANAGED_NPM_VERSION}
1148
+ `
1149
+ );
1150
+ await NodeFSP3.rm(paths.installDir, { recursive: true, force: true });
1151
+ await NodeFSP3.rename(staging, paths.installDir);
1152
+ return paths;
1153
+ } finally {
1154
+ await NodeFSP3.rm(staging, { recursive: true, force: true }).catch(() => void 0);
1155
+ await NodeFSP3.rm(archive, { force: true }).catch(() => void 0);
1156
+ }
1157
+ };
1158
+ var runManagedNpm = (input) => input.process.run({
1159
+ program: "host:node",
1160
+ args: [
1161
+ input.helperPath,
1162
+ "npm",
1163
+ "--shim",
1164
+ input.npm.shimDir,
1165
+ "--",
1166
+ input.npm.cliPath,
1167
+ ...input.args
1168
+ ],
1169
+ cwd: input.cwd,
1170
+ timeoutMs: input.timeoutMs,
1171
+ maxOutputBytes: 256 * 1024
1172
+ });
1173
+
1174
+ // src/toolchain.ts
1175
+ var DEVICE_HUB_VERSION = "0.12.0";
1176
+ var AGENT_DEVICE_VERSION = "0.21.12";
1177
+ var INSTALL_TIMEOUT_MS = 10 * 6e4;
1178
+ var VERSION_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/;
1179
+ var HUB_SPEC = {
1180
+ tool: "hub",
1181
+ packageName: "expo-device-hub",
1182
+ version: DEVICE_HUB_VERSION,
1183
+ entry: ["dist", "server", "cli.mjs"]
1184
+ };
1185
+ var AGENT_DEVICE_SPEC = {
1186
+ tool: "agent",
1187
+ packageName: "agent-device",
1188
+ version: AGENT_DEVICE_VERSION,
1189
+ entry: ["bin", "agent-device.mjs"]
1190
+ };
1191
+ var toolSpec = (tool) => tool === "hub" ? HUB_SPEC : AGENT_DEVICE_SPEC;
1192
+ var toolsRoot = (dataDir) => NodePath4.join(dataDir, "tools");
1193
+ var toolPaths = (dataDir, spec, version = spec.version) => {
1194
+ const installDir = NodePath4.join(toolsRoot(dataDir), spec.packageName, version);
1195
+ return {
1196
+ installDir,
1197
+ entryPath: NodePath4.join(installDir, "node_modules", spec.packageName, ...spec.entry),
1198
+ sentinelPath: NodePath4.join(installDir, ".install-complete")
1199
+ };
1200
+ };
1201
+ var agentDeviceStateDir = (dataDir) => NodePath4.join(dataDir, "agent-device");
1202
+ var fileExists2 = async (file) => {
1203
+ try {
1204
+ await NodeFSP4.access(file);
1205
+ return true;
1206
+ } catch {
1207
+ return false;
1208
+ }
1209
+ };
1210
+ var readSentinel = async (file) => {
1211
+ try {
1212
+ return (await NodeFSP4.readFile(file, "utf8")).trim();
1213
+ } catch {
1214
+ return null;
1215
+ }
1216
+ };
1217
+ var isInstalled = async (paths, version) => await readSentinel(paths.sentinelPath) === version && await fileExists2(paths.entryPath);
1218
+ var isToolInstalled = (dataDir, spec) => isInstalled(toolPaths(dataDir, spec), spec.version);
1219
+ var installQueue = Promise.resolve();
1220
+ var serialized = (task) => {
1221
+ const next = installQueue.then(task, task);
1222
+ installQueue = next.catch(() => void 0);
1223
+ return next;
1224
+ };
1225
+ var installFailed = (spec, step, detail, cause) => new DeviceError(
1226
+ "install-failed",
1227
+ `Installing ${spec.packageName} ${spec.version} failed while ${step}.${detail ? ` ${detail}` : ""}`,
1228
+ cause === void 0 ? void 0 : { cause }
1229
+ );
1230
+ var ensureTool = (input) => serialized(async () => {
1231
+ const { dataDir, spec } = input;
1232
+ const paths = toolPaths(dataDir, spec);
1233
+ if (await isInstalled(paths, spec.version)) return paths;
1234
+ const parentDir = NodePath4.dirname(paths.installDir);
1235
+ await NodeFSP4.rm(paths.installDir, { recursive: true, force: true });
1236
+ await NodeFSP4.mkdir(parentDir, { recursive: true });
1237
+ const npm = await ensureManagedNpm({
1238
+ dataDir,
1239
+ helperPath: input.helperPath,
1240
+ process: input.process,
1241
+ fetch: input.fetch
1242
+ });
1243
+ const stagingDir = await NodeFSP4.mkdtemp(NodePath4.join(parentDir, ".staging-"));
1244
+ try {
1245
+ const result = await runManagedNpm({
1246
+ helperPath: input.helperPath,
1247
+ process: input.process,
1248
+ npm,
1249
+ args: [
1250
+ "install",
1251
+ "--prefix",
1252
+ stagingDir,
1253
+ "--no-fund",
1254
+ "--no-audit",
1255
+ `${spec.packageName}@${spec.version}`
1256
+ ],
1257
+ cwd: dataDir,
1258
+ timeoutMs: INSTALL_TIMEOUT_MS
1259
+ }).catch((error) => {
1260
+ throw installFailed(spec, "running npm install", errorDetail(error), error);
1261
+ });
1262
+ if (result.timedOut) throw installFailed(spec, "running npm install", "npm timed out.");
1263
+ if (result.exitCode !== 0) {
1264
+ throw installFailed(
1265
+ spec,
1266
+ "running npm install",
1267
+ `npm exited with code ${String(result.exitCode)}.`
1268
+ );
1269
+ }
1270
+ const stagedEntry = NodePath4.join(
1271
+ stagingDir,
1272
+ "node_modules",
1273
+ spec.packageName,
1274
+ ...spec.entry
1275
+ );
1276
+ if (!await fileExists2(stagedEntry)) {
1277
+ throw installFailed(spec, "verifying the installed entry point");
1278
+ }
1279
+ await NodeFSP4.writeFile(NodePath4.join(stagingDir, ".install-complete"), `${spec.version}
1280
+ `);
1281
+ try {
1282
+ await NodeFSP4.rename(stagingDir, paths.installDir);
1283
+ } catch (error) {
1284
+ if (!await isInstalled(paths, spec.version)) {
1285
+ throw installFailed(spec, "publishing the install", errorDetail(error), error);
1286
+ }
1287
+ }
1288
+ return paths;
1289
+ } finally {
1290
+ await NodeFSP4.rm(stagingDir, { recursive: true, force: true }).catch(() => void 0);
1291
+ }
1292
+ });
1293
+ var inspectTool = async (dataDir, spec, runningVersion) => {
1294
+ const directory = NodePath4.join(toolsRoot(dataDir), spec.packageName);
1295
+ let names = [];
1296
+ try {
1297
+ names = await NodeFSP4.readdir(directory);
1298
+ } catch {
1299
+ names = [];
1300
+ }
1301
+ const completed = [];
1302
+ for (const version of names) {
1303
+ if (!VERSION_PATTERN.test(version)) continue;
1304
+ if (await isInstalled(toolPaths(dataDir, spec, version), version)) completed.push(version);
1305
+ }
1306
+ completed.sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
1307
+ return { requiredVersion: spec.version, installedVersions: completed, runningVersion };
1308
+ };
1309
+
1310
+ // src/maintenance.ts
1311
+ var TOOL_MAINTENANCE_SCRIPT = String.raw`
1312
+ const maintenanceFs = require('node:fs');
1313
+ const maintenancePath = require('node:path');
1314
+ const maintenanceAlive = pid => {
1315
+ try { process.kill(pid, 0); return true; }
1316
+ catch (error) { return error.code !== 'ESRCH'; }
1317
+ };
1318
+ async function withToolMaintenance(root, operation) {
1319
+ maintenanceFs.mkdirSync(root, { recursive: true });
1320
+ const lock = maintenancePath.join(root, '.maintenance-lock');
1321
+ const nonce = require('node:crypto').randomUUID();
1322
+ const ownerFile = process.pid + '.' + nonce + '.json';
1323
+ const candidate = lock + '.' + nonce;
1324
+ const deadline = Date.now() + 30000;
1325
+ const removeEmptyLock = () => {
1326
+ try { maintenanceFs.rmdirSync(lock); }
1327
+ catch (error) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST', 'EPERM'].includes(error.code)) throw error; }
1328
+ };
1329
+ maintenanceFs.mkdirSync(candidate);
1330
+ try {
1331
+ maintenanceFs.writeFileSync(maintenancePath.join(candidate, ownerFile), JSON.stringify({ pid: process.pid }));
1332
+ while (true) {
1333
+ try {
1334
+ maintenanceFs.renameSync(candidate, lock);
1335
+ break;
1336
+ }
1337
+ catch (error) {
1338
+ if (!['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES'].includes(error.code)) throw error;
1339
+ let files = [];
1340
+ try { files = maintenanceFs.readdirSync(lock); } catch (error) { if (error.code !== 'ENOENT') throw error; }
1341
+ if (files.length === 1) {
1342
+ const previousFile = maintenancePath.join(lock, files[0]);
1343
+ let previous;
1344
+ try { previous = JSON.parse(maintenanceFs.readFileSync(previousFile, 'utf8')); } catch {}
1345
+ if (Number.isSafeInteger(previous?.pid) && previous.pid > 0 && !maintenanceAlive(previous.pid)) {
1346
+ try { maintenanceFs.unlinkSync(previousFile); } catch (error) { if (error.code !== 'ENOENT') throw error; }
1347
+ }
1348
+ }
1349
+ removeEmptyLock();
1350
+ if (Date.now() >= deadline) throw Error('Device tool maintenance is locked. Retry when the other operation finishes.');
1351
+ await new Promise(resolve => setTimeout(resolve, 50));
1352
+ }
1353
+ }
1354
+ try { return operation(); }
1355
+ finally {
1356
+ maintenanceFs.unlinkSync(maintenancePath.join(lock, ownerFile));
1357
+ removeEmptyLock();
1358
+ }
1359
+ } finally {
1360
+ maintenanceFs.rmSync(candidate, { recursive: true, force: true });
1361
+ }
1362
+ }
1363
+ function pruneTools(root, specs) {
1364
+ return withToolMaintenance(root, () => {
1365
+ const scan = process.platform === 'win32'
1366
+ ? require('node:child_process').spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine'], { encoding: 'utf8', timeout: 10000 })
1367
+ : require('node:child_process').spawnSync('ps', ['-ax', '-o', 'command='], { encoding: 'utf8', timeout: 10000 });
1368
+ if (scan.status !== 0 || !scan.stdout) return;
1369
+ for (const [name, required] of specs) {
1370
+ const parent = maintenancePath.join(root, name);
1371
+ let names;
1372
+ try { names = maintenanceFs.readdirSync(parent); } catch { continue; }
1373
+ const completed = [];
1374
+ for (const version of names) {
1375
+ if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version)) continue;
1376
+ const directory = maintenancePath.join(parent, version);
1377
+ try {
1378
+ if (!maintenanceFs.lstatSync(directory).isDirectory()) continue;
1379
+ if (maintenanceFs.readFileSync(maintenancePath.join(directory, '.install-complete'), 'utf8').trim() !== version) continue;
1380
+ completed.push({ version, directory, modified: maintenanceFs.statSync(maintenancePath.join(directory, '.install-complete')).mtimeMs });
1381
+ } catch {}
1382
+ }
1383
+ if (!completed.some(value => value.version === required)) continue;
1384
+ const previous = completed.filter(value => value.version !== required).sort((a, b) => b.modified - a.modified || b.version.localeCompare(a.version, 'en', { numeric: true }))[0]?.version;
1385
+ for (const { version, directory } of completed) {
1386
+ if (version === required || version === previous || scan.stdout.includes(directory + maintenancePath.sep)) continue;
1387
+ maintenanceFs.rmSync(directory, { recursive: true, force: true });
1388
+ }
1389
+ }
1390
+ });
1391
+ }
1392
+ `;
1393
+ var pruneTool = async (input) => {
1394
+ const call = `pruneTools(${JSON.stringify(toolsRoot(input.dataDir))}, ${JSON.stringify([
1395
+ [input.spec.packageName, input.spec.version]
1396
+ ])})`;
1397
+ const result = await input.process.run({
1398
+ program: "host:node",
1399
+ args: [
1400
+ "-e",
1401
+ `${TOOL_MAINTENANCE_SCRIPT}
1402
+ ${call}.catch(error => { console.error(error.message); process.exitCode = 1; });`
1403
+ ],
1404
+ cwd: input.dataDir,
1405
+ timeoutMs: 6e4
1406
+ });
1407
+ return { ok: result.exitCode === 0, detail: result.stderr.trim() };
1408
+ };
1409
+
1410
+ // src/probe.ts
1411
+ var MIN_NODE_VERSION = [22, 12, 0];
1412
+ var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1413
+ var parseMachineProbe = (stdout) => {
1414
+ const line = stdout.trim().split(/\r?\n/).at(-1) ?? "";
1415
+ let value;
1416
+ try {
1417
+ value = JSON.parse(line);
1418
+ } catch {
1419
+ return null;
1420
+ }
1421
+ if (!isRecord2(value) || !isRecord2(value["android"])) return null;
1422
+ const android = value["android"];
1423
+ if (typeof value["node"] !== "string" || typeof value["execPath"] !== "string" || typeof value["platform"] !== "string") {
1424
+ return null;
1425
+ }
1426
+ return {
1427
+ node: value["node"],
1428
+ execPath: value["execPath"],
1429
+ electron: typeof value["electron"] === "string" ? value["electron"] : null,
1430
+ platform: value["platform"],
1431
+ xcrun: value["xcrun"] === true,
1432
+ android: {
1433
+ root: typeof android["root"] === "string" ? android["root"] : null,
1434
+ adb: android["adb"] === true,
1435
+ emulator: android["emulator"] === true,
1436
+ avdmanager: android["avdmanager"] === true,
1437
+ legacyAvdmanager: android["legacyAvdmanager"] === true
1438
+ }
1439
+ };
1440
+ };
1441
+ var nodeVersionSupported = (version) => {
1442
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(version);
1443
+ if (!match) return false;
1444
+ const parts = [Number(match[1]), Number(match[2]), Number(match[3])];
1445
+ for (let index = 0; index < MIN_NODE_VERSION.length; index += 1) {
1446
+ const actual = parts[index];
1447
+ const required = MIN_NODE_VERSION[index];
1448
+ if (actual !== required) return actual > required;
1449
+ }
1450
+ return true;
1451
+ };
1452
+ var unavailable = (platform, reasonCode, reason, sdkRoot) => ({
1453
+ platform,
1454
+ available: false,
1455
+ reasonCode,
1456
+ reason,
1457
+ ...sdkRoot === void 0 ? {} : { sdkRoot }
1458
+ });
1459
+ var platformAvailability = (probe, platform) => {
1460
+ if (platform === "ios") {
1461
+ if (probe.platform !== "darwin") {
1462
+ return unavailable("ios", "ios-needs-macos", "iOS Simulators need macOS with Xcode.");
1463
+ }
1464
+ if (!probe.xcrun) {
1465
+ return unavailable("ios", "xcode-missing", "Xcode command line tools were not found.");
1466
+ }
1467
+ return { platform, available: true };
1468
+ }
1469
+ const sdk = probe.android;
1470
+ if (!sdk.root) {
1471
+ return unavailable(
1472
+ "android",
1473
+ "android-sdk-missing",
1474
+ "Android SDK was not found. Install it with Android Studio or set its location in the Devices settings."
1475
+ );
1476
+ }
1477
+ if (!sdk.adb) {
1478
+ return unavailable(
1479
+ "android",
1480
+ "android-platform-tools-missing",
1481
+ `Android SDK Platform-Tools are missing from ${sdk.root}. Install them in Android Studio's SDK Manager.`,
1482
+ sdk.root
1483
+ );
1484
+ }
1485
+ if (!sdk.emulator) {
1486
+ return unavailable(
1487
+ "android",
1488
+ "android-emulator-missing",
1489
+ `Android Emulator is missing from ${sdk.root}. Install it in Android Studio's SDK Manager.`,
1490
+ sdk.root
1491
+ );
1492
+ }
1493
+ if (!sdk.avdmanager) {
1494
+ return sdk.legacyAvdmanager ? unavailable(
1495
+ "android",
1496
+ "android-cmdline-tools-legacy",
1497
+ `The Android SDK command-line tools in ${sdk.root} appear to be an older, unsupported version. Install Android SDK Command-line Tools (latest) in Android Studio's SDK Manager under SDK Tools.`,
1498
+ sdk.root
1499
+ ) : unavailable(
1500
+ "android",
1501
+ "android-cmdline-tools-missing",
1502
+ `Android SDK Command-line Tools (latest) are missing from ${sdk.root}. Install them in Android Studio's SDK Manager.`,
1503
+ sdk.root
1504
+ );
1505
+ }
1506
+ return { platform, available: true };
1507
+ };
1508
+ var probeMachine = async (input) => {
1509
+ const args = [input.helperPath, "probe"];
1510
+ if (input.sdkOverride.trim().length > 0) args.push("--sdk", input.sdkOverride.trim());
1511
+ const result = await input.process.run({ program: "host:node", args, cwd: input.cwd, timeoutMs: 3e4 }).catch((error) => {
1512
+ throw new DeviceError(
1513
+ "node-unavailable",
1514
+ `Could not run Node.js to check device support: ${errorDetail(error)}`,
1515
+ { cause: error }
1516
+ );
1517
+ });
1518
+ const probe = result.exitCode === 0 ? parseMachineProbe(result.stdout) : null;
1519
+ if (!probe) {
1520
+ throw new DeviceError(
1521
+ "node-unavailable",
1522
+ `Checking device support failed: ${result.stderr.trim() || "the helper returned no result"}.`
1523
+ );
1524
+ }
1525
+ if (!nodeVersionSupported(probe.node)) {
1526
+ throw new DeviceError(
1527
+ "node-unavailable",
1528
+ `Device support requires Node.js 22.12 or newer; this Ycodium runs on ${probe.node}. Update Ycodium on this machine.`
1529
+ );
1530
+ }
1531
+ return probe;
1532
+ };
1533
+
1534
+ // src/serveSimHelpers.ts
1535
+ import * as NodeFSP5 from "node:fs/promises";
1536
+ import * as NodePath5 from "node:path";
1537
+ var existing = (file) => NodeFSP5.access(file).then(
1538
+ () => file,
1539
+ () => null
1540
+ );
1541
+ var serveSimHelpers = async (dataDir) => {
1542
+ const dist = NodePath5.join(
1543
+ toolPaths(dataDir, HUB_SPEC).installDir,
1544
+ "node_modules",
1545
+ HUB_SPEC.packageName,
1546
+ "vendor",
1547
+ "serve-sim",
1548
+ "dist"
1549
+ );
1550
+ const [axSettings2, cli] = await Promise.all([
1551
+ existing(NodePath5.join(dist, "simax", "serve-sim-ax-settings")),
1552
+ existing(NodePath5.join(dist, "serve-sim.js"))
1553
+ ]);
1554
+ return { axSettings: axSettings2, cli };
1555
+ };
1556
+
1557
+ // src/localHost.ts
1558
+ var installPhaseDetail = (tool, version, required) => {
1559
+ const previous = version?.runningVersion ?? [...version?.installedVersions ?? []].sort((a, b) => a.localeCompare(b, "en", { numeric: true })).at(-1);
1560
+ return previous !== void 0 && !(version?.installedVersions ?? []).includes(required) ? { tool, fromVersion: previous, toVersion: required } : { tool, toVersion: required };
1561
+ };
1562
+ var LocalDeviceHost = class {
1563
+ id = LOCAL_DEVICE_HOST_ID;
1564
+ probeCache = null;
1565
+ hub;
1566
+ agent;
1567
+ agentEndpoint = null;
1568
+ lock = Promise.resolve();
1569
+ options;
1570
+ constructor(options) {
1571
+ this.options = options;
1572
+ const stateDir = agentDeviceStateDir(options.dataDir);
1573
+ this.hub = new HubProcess({
1574
+ stateDir,
1575
+ helperPath: options.helperPath,
1576
+ process: options.process,
1577
+ fetch: options.fetch,
1578
+ log: options.log,
1579
+ ...options.hub,
1580
+ ...options.onHubStarted === void 0 ? {} : { onStarted: (hub) => options.onHubStarted?.(hub.port) }
1581
+ });
1582
+ this.agent = new AgentDaemon({
1583
+ stateDir,
1584
+ helperPath: options.helperPath,
1585
+ process: options.process,
1586
+ fetch: options.fetch
1587
+ });
1588
+ }
1589
+ /** 生命周期操作串行:两个调用者同时要起 hub 时只起一次。 */
1590
+ installInput(spec) {
1591
+ return {
1592
+ dataDir: this.options.dataDir,
1593
+ spec,
1594
+ helperPath: this.options.helperPath,
1595
+ process: this.options.process,
1596
+ fetch: this.options.downloadFetch
1597
+ };
1598
+ }
1599
+ serialized(task) {
1600
+ const next = this.lock.then(task, task);
1601
+ this.lock = next.catch(() => void 0);
1602
+ return next;
1603
+ }
1604
+ async machine(refresh = false) {
1605
+ if (this.probeCache !== null && !refresh) return this.probeCache;
1606
+ this.probeCache = await probeMachine({
1607
+ helperPath: this.options.helperPath,
1608
+ process: this.options.process,
1609
+ cwd: this.options.dataDir,
1610
+ sdkOverride: this.options.sdkOverride
1611
+ });
1612
+ return this.probeCache;
1613
+ }
1614
+ async platformAvailability(platform) {
1615
+ return platformAvailability(await this.machine(), platform);
1616
+ }
1617
+ async platforms(refresh = false) {
1618
+ const probe = await this.machine(refresh);
1619
+ return DEVICE_PLATFORMS.map((platform) => platformAvailability(probe, platform));
1620
+ }
1621
+ async toolVersion(spec) {
1622
+ const running = spec.tool === "hub" ? this.hub.running !== null ? HUB_SPEC.version : null : this.agentEndpoint?.version ?? null;
1623
+ return inspectTool(this.options.dataDir, spec, running);
1624
+ }
1625
+ /** 不装不起,只读现状。探测失败(比如没有 Node)时平台一律按不可用报。 */
1626
+ async summary(refresh = false) {
1627
+ const [platforms, hubInstalled, agentDeviceInstalled, hub, agent] = await Promise.all([
1628
+ this.platforms(refresh).catch(
1629
+ (error) => DEVICE_PLATFORMS.map((platform) => ({
1630
+ platform,
1631
+ available: false,
1632
+ reason: errorDetail(error)
1633
+ }))
1634
+ ),
1635
+ isToolInstalled(this.options.dataDir, HUB_SPEC),
1636
+ isToolInstalled(this.options.dataDir, AGENT_DEVICE_SPEC),
1637
+ this.toolVersion(HUB_SPEC),
1638
+ this.toolVersion(AGENT_DEVICE_SPEC)
1639
+ ]);
1640
+ return {
1641
+ id: this.id,
1642
+ kind: "local",
1643
+ platforms,
1644
+ tools: { hub, agent },
1645
+ hubInstalled,
1646
+ agentDeviceInstalled
1647
+ };
1648
+ }
1649
+ async installIfNeeded(spec, onPhase) {
1650
+ if (!await isToolInstalled(this.options.dataDir, spec)) {
1651
+ onPhase(
1652
+ "installing",
1653
+ installPhaseDetail(spec.tool, await this.toolVersion(spec), spec.version)
1654
+ );
1655
+ }
1656
+ return ensureTool(this.installInput(spec));
1657
+ }
1658
+ async prune(spec) {
1659
+ const result = await pruneTool({
1660
+ dataDir: this.options.dataDir,
1661
+ spec,
1662
+ process: this.options.process
1663
+ }).catch((error) => ({
1664
+ ok: false,
1665
+ detail: error instanceof Error ? error.message : String(error)
1666
+ }));
1667
+ if (!result.ok) {
1668
+ this.options.log.warn("Reclaiming old device tool versions failed", {
1669
+ tool: spec.tool,
1670
+ detail: result.detail
1671
+ });
1672
+ }
1673
+ }
1674
+ async startHub(onPhase) {
1675
+ const probe = await this.machine();
1676
+ const running = this.hub.running;
1677
+ if (running !== null) return { hostId: this.id, hubOrigin: running.origin, probe };
1678
+ const tool = await this.installIfNeeded(HUB_SPEC, onPhase);
1679
+ onPhase("starting");
1680
+ const hub = await this.hub.ensure({ entryPath: tool.entryPath, sdkRoot: probe.android.root });
1681
+ await this.prune(HUB_SPEC);
1682
+ return { hostId: this.id, hubOrigin: hub.origin, probe };
1683
+ }
1684
+ /** 首次使用时装 hub 并起来;幂等,hub 已在跑就直接返回。 */
1685
+ ensureReady(onPhase) {
1686
+ return this.serialized(() => this.startHub(onPhase));
1687
+ }
1688
+ /** hub 之外再装、起 agent-device 守护进程。 */
1689
+ ensureAgentReady(onPhase) {
1690
+ return this.serialized(async () => {
1691
+ const ready = await this.startHub(onPhase);
1692
+ if (this.agentEndpoint !== null) return { ...ready, agentDevice: this.agentEndpoint };
1693
+ const tool = await this.installIfNeeded(AGENT_DEVICE_SPEC, onPhase);
1694
+ onPhase("starting");
1695
+ const endpoint = await this.agent.ensure({
1696
+ entryPath: tool.entryPath,
1697
+ sdkRoot: ready.probe.android.root
1698
+ });
1699
+ this.agentEndpoint = endpoint;
1700
+ await this.prune(AGENT_DEVICE_SPEC);
1701
+ return { ...ready, agentDevice: endpoint };
1702
+ });
1703
+ }
1704
+ /** 已在跑时的 hub 地址,不启动任何东西。 */
1705
+ current() {
1706
+ return this.hub.running?.origin ?? null;
1707
+ }
1708
+ /** 只装不起:设置里的「更新工具」用。 */
1709
+ installTool(tool) {
1710
+ return this.serialized(async () => {
1711
+ await ensureTool(this.installInput(toolSpec(tool)));
1712
+ });
1713
+ }
1714
+ /**
1715
+ * 在宿主上跑一条工具命令。adb、emulator、xcrun 经助手的 run 模式补好 PATH;node 直接用宿主的
1716
+ * Node 起脚本。只传 argv,不过 shell。
1717
+ */
1718
+ async runTool(program, args, options = {}) {
1719
+ const stdin = options.stdin === void 0 ? {} : { stdin: options.stdin };
1720
+ const base = {
1721
+ program: "host:node",
1722
+ cwd: this.options.dataDir,
1723
+ timeoutMs: options.timeoutMs ?? 2e4
1724
+ };
1725
+ let input;
1726
+ if (program === "node") {
1727
+ input = { ...base, args: [...args], ...stdin };
1728
+ } else {
1729
+ const probe = await this.machine();
1730
+ const helperArgs = [this.options.helperPath, "run"];
1731
+ if (probe.android.root !== null) helperArgs.push("--sdk", probe.android.root);
1732
+ helperArgs.push("--", program, ...args);
1733
+ input = { ...base, args: helperArgs, ...stdin };
1734
+ }
1735
+ const result = await this.options.process.run(input).catch((error) => ({
1736
+ exitCode: 127,
1737
+ stdout: "",
1738
+ stderr: error instanceof Error ? error.message : String(error)
1739
+ }));
1740
+ return { stdout: result.stdout, stderr: result.stderr, code: result.exitCode ?? 1 };
1741
+ }
1742
+ /** 在宿主上跑一条 SDK 命令(adb、emulator),经助手补好 PATH。 */
1743
+ runSdkCommand(command, args, timeoutMs = 2e4) {
1744
+ return this.runTool(command, args, { timeoutMs });
1745
+ }
1746
+ /** 设备操作与读详情要的工具:命令执行器加 iOS 的 serve-sim 助手路径。 */
1747
+ async actionTools() {
1748
+ return {
1749
+ run: (program, args, options) => this.runTool(program, args, options),
1750
+ helpers: await serveSimHelpers(this.options.dataDir)
1751
+ };
1752
+ }
1753
+ /** 只停 agent-device;用户自己在面板里看设备不受影响。 */
1754
+ stopAgent() {
1755
+ return this.serialized(async () => {
1756
+ await this.agent.stop();
1757
+ this.agentEndpoint = null;
1758
+ });
1759
+ }
1760
+ /** 停辅助进程。模拟器本身不关,那是用户的。 */
1761
+ stop() {
1762
+ return this.serialized(async () => {
1763
+ await this.hub.stop();
1764
+ await this.agent.stop();
1765
+ this.agentEndpoint = null;
1766
+ });
1767
+ }
1768
+ };
1769
+
1770
+ // src/deviceActions.ts
1771
+ var IOS_TEXT_SIZES = {
1772
+ small: "small",
1773
+ default: "large",
1774
+ large: "extra-extra-large",
1775
+ "extra-large": "accessibility-large"
1776
+ };
1777
+ var ANDROID_TEXT_SIZES = {
1778
+ small: "0.85",
1779
+ default: "1.0",
1780
+ large: "1.15",
1781
+ "extra-large": "1.3"
1782
+ };
1783
+ var textSizeFromIos = (category) => {
1784
+ const entry = Object.entries(IOS_TEXT_SIZES).find(
1785
+ ([, value]) => value === category
1786
+ );
1787
+ if (entry !== void 0) return entry[0];
1788
+ if (category.startsWith("accessibility")) return "extra-large";
1789
+ if (category.includes("extra")) return "large";
1790
+ return category === "extra-small" || category === "small" || category === "medium" ? "small" : "default";
1791
+ };
1792
+ var textSizeFromAndroid = (scale) => {
1793
+ if (scale <= 0.9) return "small";
1794
+ if (scale >= 1.25) return "extra-large";
1795
+ if (scale >= 1.1) return "large";
1796
+ return "default";
1797
+ };
1798
+ var IOS_TOGGLE_OPTIONS = {
1799
+ reduceMotion: "reduce-motion",
1800
+ reduceTransparency: "reduce-transparency",
1801
+ showBorders: "show-borders",
1802
+ voiceOver: "voiceover"
1803
+ };
1804
+ var IOS_PRIVACY_SERVICES = {
1805
+ camera: "camera",
1806
+ microphone: "microphone",
1807
+ photos: "photos",
1808
+ contacts: "contacts",
1809
+ calendar: "calendar",
1810
+ reminders: "reminders",
1811
+ motion: "motion",
1812
+ "media-library": "media-library",
1813
+ faceid: "faceid",
1814
+ location: "location"
1815
+ };
1816
+ var ANDROID_PERMISSIONS = {
1817
+ camera: ["android.permission.CAMERA"],
1818
+ microphone: ["android.permission.RECORD_AUDIO"],
1819
+ photos: ["android.permission.READ_MEDIA_IMAGES", "android.permission.READ_EXTERNAL_STORAGE"],
1820
+ contacts: ["android.permission.READ_CONTACTS", "android.permission.WRITE_CONTACTS"],
1821
+ calendar: ["android.permission.READ_CALENDAR", "android.permission.WRITE_CALENDAR"],
1822
+ location: [
1823
+ "android.permission.ACCESS_FINE_LOCATION",
1824
+ "android.permission.ACCESS_COARSE_LOCATION"
1825
+ ],
1826
+ notifications: ["android.permission.POST_NOTIFICATIONS"],
1827
+ motion: ["android.permission.ACTIVITY_RECOGNITION"]
1828
+ };
1829
+ var ANDROID_GRAVITY = {
1830
+ portrait: "0:9.81:0",
1831
+ landscape_left: "9.81:0:0",
1832
+ portrait_upside_down: "0:-9.81:0",
1833
+ landscape_right: "-9.81:0:0"
1834
+ };
1835
+ var ANDROID_ROTATION = {
1836
+ portrait: "0",
1837
+ landscape_left: "1",
1838
+ portrait_upside_down: "2",
1839
+ landscape_right: "3"
1840
+ };
1841
+ var decimal = (value) => /e/i.test(String(value)) ? value.toFixed(10).replace(/\.?0+$/, "") || "0" : String(value);
1842
+ var checked = async (result, operation) => {
1843
+ const done = await result;
1844
+ if (done.code !== 0) {
1845
+ throw new DeviceError(
1846
+ "command-failed",
1847
+ `Device ${operation} failed (exit code ${String(done.code)}).`,
1848
+ {
1849
+ cause: done
1850
+ }
1851
+ );
1852
+ }
1853
+ return done.stdout;
1854
+ };
1855
+ var simctl = (tools, udid, args, operation, options) => checked(
1856
+ tools.run("xcrun", ["simctl", ...args.slice(0, 1), udid, ...args.slice(1)], options),
1857
+ operation
1858
+ );
1859
+ var axSettings = (tools, udid, args) => {
1860
+ const helper = tools.helpers.axSettings;
1861
+ if (helper === null) {
1862
+ throw new DeviceError(
1863
+ "helper-missing",
1864
+ "Device accessibility requires a helper missing from this install. Set up device support again."
1865
+ );
1866
+ }
1867
+ return checked(tools.run("xcrun", ["simctl", "spawn", udid, helper, ...args]), "accessibility");
1868
+ };
1869
+ var runIos = async (tools, udid, action) => {
1870
+ switch (action.type) {
1871
+ case "setAppearance":
1872
+ await simctl(tools, udid, ["ui", "appearance", action.value], "appearance");
1873
+ return;
1874
+ case "setTextSize":
1875
+ await simctl(tools, udid, ["ui", "content_size", IOS_TEXT_SIZES[action.value]], "text size");
1876
+ return;
1877
+ case "setToggle": {
1878
+ if (action.setting === "increaseContrast") {
1879
+ await simctl(
1880
+ tools,
1881
+ udid,
1882
+ ["ui", "increase_contrast", action.value ? "enabled" : "disabled"],
1883
+ "increase contrast"
1884
+ );
1885
+ return;
1886
+ }
1887
+ const option = IOS_TOGGLE_OPTIONS[action.setting];
1888
+ if (option === void 0) throw unsupported(action.type, "ios");
1889
+ await axSettings(tools, udid, ["set", option, action.value ? "on" : "off"]);
1890
+ return;
1891
+ }
1892
+ case "setLiquidGlass":
1893
+ await axSettings(tools, udid, ["set", "liquid-glass", action.value]);
1894
+ return;
1895
+ case "setColorFilter":
1896
+ await axSettings(tools, udid, ["set", "color-filter", action.value]);
1897
+ return;
1898
+ case "setLocation":
1899
+ await simctl(
1900
+ tools,
1901
+ udid,
1902
+ ["location", "set", `${decimal(action.latitude)},${decimal(action.longitude)}`],
1903
+ "location"
1904
+ );
1905
+ return;
1906
+ case "clearLocation":
1907
+ await simctl(tools, udid, ["location", "clear"], "location");
1908
+ return;
1909
+ case "setPermission": {
1910
+ if (action.permission === "notifications") {
1911
+ const cli = tools.helpers.cli;
1912
+ if (cli === null) {
1913
+ throw new DeviceError(
1914
+ "helper-missing",
1915
+ "Device permission requires a helper missing from this install. Set up device support again."
1916
+ );
1917
+ }
1918
+ await checked(
1919
+ tools.run("node", [
1920
+ cli,
1921
+ "permissions",
1922
+ action.decision,
1923
+ action.permission,
1924
+ action.appId,
1925
+ "-d",
1926
+ udid
1927
+ ]),
1928
+ "permission"
1929
+ );
1930
+ return;
1931
+ }
1932
+ const service = IOS_PRIVACY_SERVICES[action.permission];
1933
+ if (service === void 0) throw unsupported("permission", "ios");
1934
+ await simctl(tools, udid, ["privacy", action.decision, service, action.appId], "permission");
1935
+ return;
1936
+ }
1937
+ case "openUrl":
1938
+ await simctl(tools, udid, ["openurl", action.url], "open url");
1939
+ return;
1940
+ case "launchApp":
1941
+ await simctl(tools, udid, ["launch", action.appId], "launch");
1942
+ return;
1943
+ case "terminateApp":
1944
+ await simctl(tools, udid, ["terminate", action.appId], "terminate");
1945
+ return;
1946
+ case "sendPush":
1947
+ await simctl(tools, udid, ["push", action.appId, "-"], "push", {
1948
+ stdin: pushPayload(action.payload)
1949
+ });
1950
+ return;
1951
+ case "shake":
1952
+ case "setOrientation":
1953
+ throw unsupported(action.type, "ios");
1954
+ }
1955
+ };
1956
+ var adb = (tools, serial, args, operation) => checked(tools.run("adb", ["-s", serial, ...args]), operation);
1957
+ var runAndroid = async (tools, serial, action) => {
1958
+ const shell = (args, operation) => adb(tools, serial, ["shell", ...args], operation);
1959
+ switch (action.type) {
1960
+ case "setAppearance":
1961
+ await shell(["cmd", "uimode", "night", action.value === "dark" ? "yes" : "no"], "appearance");
1962
+ return;
1963
+ case "setTextSize":
1964
+ await shell(
1965
+ ["settings", "put", "system", "font_scale", ANDROID_TEXT_SIZES[action.value]],
1966
+ "text size"
1967
+ );
1968
+ return;
1969
+ case "setToggle": {
1970
+ if (action.setting === "networkEnabled") {
1971
+ const state = action.value ? "enable" : "disable";
1972
+ await shell(["svc", "wifi", state], "network");
1973
+ await shell(["svc", "data", state], "network");
1974
+ return;
1975
+ }
1976
+ if (action.setting === "reduceMotion") {
1977
+ const scale = action.value ? "0" : "1";
1978
+ for (const key2 of [
1979
+ "animator_duration_scale",
1980
+ "transition_animation_scale",
1981
+ "window_animation_scale"
1982
+ ]) {
1983
+ await shell(["settings", "put", "global", key2, scale], "reduce motion");
1984
+ }
1985
+ return;
1986
+ }
1987
+ throw unsupported(action.type, "android");
1988
+ }
1989
+ case "setOrientation": {
1990
+ if (serial.startsWith("emulator-")) {
1991
+ await shell(["settings", "put", "system", "accelerometer_rotation", "1"], "orientation");
1992
+ await shell(["cmd", "window", "user-rotation", "free"], "orientation");
1993
+ await adb(
1994
+ tools,
1995
+ serial,
1996
+ ["emu", "sensor", "set", "acceleration", ANDROID_GRAVITY[action.value]],
1997
+ "orientation"
1998
+ );
1999
+ return;
2000
+ }
2001
+ await shell(
2002
+ ["cmd", "window", "user-rotation", "lock", ANDROID_ROTATION[action.value]],
2003
+ "orientation"
2004
+ );
2005
+ return;
2006
+ }
2007
+ case "setLocation":
2008
+ await adb(
2009
+ tools,
2010
+ serial,
2011
+ ["emu", "geo", "fix", decimal(action.longitude), decimal(action.latitude)],
2012
+ "location"
2013
+ );
2014
+ return;
2015
+ case "clearLocation":
2016
+ return;
2017
+ case "setPermission": {
2018
+ const permissions = ANDROID_PERMISSIONS[action.permission];
2019
+ if (permissions === void 0) throw unsupported("permission", "android");
2020
+ const verb = action.decision === "grant" ? "grant" : "revoke";
2021
+ for (const permission of permissions) {
2022
+ await shell(["pm", verb, action.appId, permission], "permission").catch(() => void 0);
2023
+ }
2024
+ return;
2025
+ }
2026
+ case "openUrl":
2027
+ await shell(
2028
+ ["am", "start", "-a", "android.intent.action.VIEW", "-d", `'${action.url}'`],
2029
+ "open url"
2030
+ );
2031
+ return;
2032
+ case "launchApp":
2033
+ await shell(
2034
+ ["monkey", "-p", action.appId, "-c", "android.intent.category.LAUNCHER", "1"],
2035
+ "launch"
2036
+ );
2037
+ return;
2038
+ case "terminateApp":
2039
+ await shell(["am", "force-stop", action.appId], "terminate");
2040
+ return;
2041
+ case "setLiquidGlass":
2042
+ case "setColorFilter":
2043
+ case "shake":
2044
+ case "sendPush":
2045
+ throw unsupported(action.type, "android");
2046
+ }
2047
+ };
2048
+ var runDeviceAction = async (tools, device, input) => {
2049
+ const id = assertDeviceId(device.id);
2050
+ const action = parseDeviceAction(device.platform, input);
2051
+ if (!supportsAction(device.platform, action)) throw unsupported(action.type, device.platform);
2052
+ if (device.platform === "ios") await runIos(tools, id, action);
2053
+ else await runAndroid(tools, id, action);
2054
+ };
2055
+ var quiet = async (task) => {
2056
+ try {
2057
+ return await task();
2058
+ } catch {
2059
+ return void 0;
2060
+ }
2061
+ };
2062
+ var isColorFilter = (value) => COLOR_FILTERS.some((filter) => filter === value);
2063
+ var onOff = (value) => value === "on" ? true : value === "off" ? false : void 0;
2064
+ var parseAxStatus = (stdout) => {
2065
+ if (stdout === void 0) return void 0;
2066
+ try {
2067
+ const parsed = JSON.parse(stdout);
2068
+ if (!isRecord(parsed)) return void 0;
2069
+ return Object.fromEntries(
2070
+ Object.entries(parsed).filter(
2071
+ (entry) => typeof entry[1] === "string"
2072
+ )
2073
+ );
2074
+ } catch {
2075
+ return void 0;
2076
+ }
2077
+ };
2078
+ var readIos = async (tools, udid) => {
2079
+ const uiValue = (option) => quiet(async () => (await simctl(tools, udid, ["ui", option], option)).trim().toLowerCase());
2080
+ const [appearance, contentSize, contrast, axStdout] = await Promise.all([
2081
+ uiValue("appearance"),
2082
+ uiValue("content_size"),
2083
+ uiValue("increase_contrast"),
2084
+ tools.helpers.axSettings === null ? Promise.resolve(void 0) : quiet(() => axSettings(tools, udid, ["status"]))
2085
+ ]);
2086
+ const ax = parseAxStatus(axStdout);
2087
+ const flag = (key2) => ax === void 0 ? void 0 : onOff(ax[key2]);
2088
+ const glass = ax?.["liquid-glass"];
2089
+ const filter = ax?.["color-filter"];
2090
+ const booleans = {
2091
+ reduceMotion: flag("reduce-motion"),
2092
+ reduceTransparency: flag("reduce-transparency"),
2093
+ showBorders: flag("show-borders"),
2094
+ voiceOver: flag("voiceover")
2095
+ };
2096
+ const settings = {
2097
+ ...appearance === "light" || appearance === "dark" ? { appearance } : {},
2098
+ ...contentSize ? { textSize: textSizeFromIos(contentSize) } : {},
2099
+ ...contrast ? { increaseContrast: contrast === "enabled" } : {},
2100
+ ...Object.fromEntries(Object.entries(booleans).filter(([, value]) => value !== void 0)),
2101
+ ...glass === "clear" || glass === "tinted" ? { liquidGlass: glass } : {},
2102
+ ...isColorFilter(filter) ? { colorFilter: filter } : {}
2103
+ };
2104
+ return { settings, foregroundApp: null };
2105
+ };
2106
+ var readAndroid = async (tools, serial) => {
2107
+ const shell = (args) => quiet(async () => (await adb(tools, serial, ["shell", ...args], args[0] ?? "shell")).trim());
2108
+ const [night, fontScale, animator, wifi, focus] = await Promise.all([
2109
+ shell(["cmd", "uimode", "night"]),
2110
+ shell(["settings", "get", "system", "font_scale"]),
2111
+ shell(["settings", "get", "global", "animator_duration_scale"]),
2112
+ shell(["settings", "get", "global", "wifi_on"]),
2113
+ // `dumpsys window windows` 在 API 36 起不再打印焦点;不带过滤的整份 dump 还打。
2114
+ shell(["dumpsys", "window"])
2115
+ ]);
2116
+ const scale = fontScale && fontScale !== "null" ? Number(fontScale) : Number.NaN;
2117
+ const focused = focus?.match(/m(?:CurrentFocus|FocusedApp)=\w+\{[^ ]+ u\d+ ([^/ ]+)\//);
2118
+ const settings = {
2119
+ ...night?.includes("yes") ? { appearance: "dark" } : night?.includes("no") ? { appearance: "light" } : {},
2120
+ ...Number.isFinite(scale) ? { textSize: textSizeFromAndroid(scale) } : {},
2121
+ ...animator !== void 0 && animator !== "null" ? { reduceMotion: Number(animator) === 0 } : {},
2122
+ ...wifi === "1" || wifi === "0" ? { networkEnabled: wifi === "1" } : {}
2123
+ };
2124
+ const foregroundApp = focused?.[1] === void 0 ? null : { id: focused[1] };
2125
+ return { settings, foregroundApp };
2126
+ };
2127
+ var readDeviceDetail = async (tools, device) => {
2128
+ const id = assertDeviceId(device.id);
2129
+ return device.platform === "ios" ? readIos(tools, id) : readAndroid(tools, id);
2130
+ };
2131
+
2132
+ // src/hubClient.ts
2133
+ var BOOT_TIMEOUT_MS = 3 * 6e4;
2134
+ var SCREENSHOT_TIMEOUT_MS = 2e4;
2135
+ var DEFAULT_TIMEOUT_MS = 15e3;
2136
+ var vendorPrefix = (platform) => platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu";
2137
+ var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2138
+ var parseHubDevice = (value) => {
2139
+ if (!isRecord3(value)) return null;
2140
+ const { id, name, version, platform, booted, physical } = value;
2141
+ if (typeof id !== "string" || typeof name !== "string" || typeof version !== "string" || platform !== "ios" && platform !== "android" || typeof booted !== "boolean" || typeof physical !== "boolean") {
2142
+ return null;
2143
+ }
2144
+ return { id, name, version, platform, booted, physical };
2145
+ };
2146
+ var parseHubDeviceList = (value) => {
2147
+ if (!isRecord3(value)) return null;
2148
+ const simulators = value["simulators"];
2149
+ const emulators = value["emulators"];
2150
+ if (!Array.isArray(simulators) || !Array.isArray(emulators)) return null;
2151
+ const devices = [];
2152
+ for (const entry of [...simulators, ...emulators]) {
2153
+ const device = parseHubDevice(entry);
2154
+ if (device === null) return null;
2155
+ devices.push(device);
2156
+ }
2157
+ const errors = Array.isArray(value["errors"]) ? value["errors"].flatMap(
2158
+ (error) => isRecord3(error) && typeof error["message"] === "string" ? [error["message"]] : []
2159
+ ) : [];
2160
+ return { devices, errors };
2161
+ };
2162
+ var parseHubActionResult = (value) => {
2163
+ if (!isRecord3(value) || typeof value["ok"] !== "boolean") return null;
2164
+ const text2 = (key2) => typeof value[key2] === "string" ? value[key2] : void 0;
2165
+ return { ok: value["ok"], id: text2("id"), serial: text2("serial"), error: text2("error") };
2166
+ };
2167
+ var HubClient = class {
2168
+ origin;
2169
+ fetch;
2170
+ constructor(origin, fetch) {
2171
+ this.origin = origin;
2172
+ this.fetch = fetch;
2173
+ }
2174
+ async json(path, operation, init = {}) {
2175
+ let response;
2176
+ try {
2177
+ response = await this.fetch(`${this.origin}${path}`, {
2178
+ method: init.method ?? "GET",
2179
+ ...init.body === void 0 ? {} : { body: JSON.stringify(init.body), headers: { "content-type": "application/json" } },
2180
+ signal: AbortSignal.timeout(init.timeoutMs ?? DEFAULT_TIMEOUT_MS)
2181
+ });
2182
+ } catch (cause) {
2183
+ throw new DeviceError(
2184
+ "hub-request-failed",
2185
+ `Device ${operation} failed: could not reach the device hub.`,
2186
+ { cause }
2187
+ );
2188
+ }
2189
+ if (!response.ok) {
2190
+ throw new DeviceError(
2191
+ "hub-request-failed",
2192
+ `Device ${operation} failed: the device hub answered ${String(response.status)}.`
2193
+ );
2194
+ }
2195
+ try {
2196
+ return await response.json();
2197
+ } catch (cause) {
2198
+ throw new DeviceError("hub-request-failed", `Device ${operation} failed: unreadable reply.`, {
2199
+ cause
2200
+ });
2201
+ }
2202
+ }
2203
+ async listDevices() {
2204
+ const list = parseHubDeviceList(await this.json("/api/devices", "list"));
2205
+ if (list === null) {
2206
+ throw new DeviceError("hub-request-failed", "Device list failed: unexpected reply shape.");
2207
+ }
2208
+ return list;
2209
+ }
2210
+ async action(path, operation, body, timeoutMs) {
2211
+ const result = parseHubActionResult(
2212
+ await this.json(path, operation, {
2213
+ method: "POST",
2214
+ body,
2215
+ ...timeoutMs === void 0 ? {} : { timeoutMs }
2216
+ })
2217
+ );
2218
+ if (result === null) {
2219
+ throw new DeviceError(
2220
+ "hub-request-failed",
2221
+ `Device ${operation} failed: unexpected reply shape.`
2222
+ );
2223
+ }
2224
+ return result;
2225
+ }
2226
+ boot(device) {
2227
+ return this.action(
2228
+ "/api/devices/boot",
2229
+ "boot",
2230
+ { platform: device.platform, id: device.id, name: device.name },
2231
+ BOOT_TIMEOUT_MS
2232
+ );
2233
+ }
2234
+ /** iOS:开机不会挂上出画面的助手;grid start 两样都做,对已开机的模拟器幂等。 */
2235
+ attachIosStream(udid) {
2236
+ return this.action(
2237
+ `${vendorPrefix("ios")}/grid/api/start`,
2238
+ "attach stream",
2239
+ { udid },
2240
+ BOOT_TIMEOUT_MS
2241
+ );
2242
+ }
2243
+ shutdownIos(udid) {
2244
+ return this.action(`${vendorPrefix("ios")}/grid/api/shutdown`, "shutdown", { udid });
2245
+ }
2246
+ shutdownDevice(platform, id) {
2247
+ return this.action("/api/devices/shutdown", "shutdown", { platform, id });
2248
+ }
2249
+ async screenshot(platform, deviceId) {
2250
+ let response;
2251
+ try {
2252
+ response = await this.fetch(
2253
+ `${this.origin}${vendorPrefix(platform)}/api/screenshot?device=${encodeURIComponent(deviceId)}`,
2254
+ { method: "POST", signal: AbortSignal.timeout(SCREENSHOT_TIMEOUT_MS) }
2255
+ );
2256
+ } catch (cause) {
2257
+ throw new DeviceError(
2258
+ "hub-request-failed",
2259
+ "Device screenshot failed: could not reach the device hub.",
2260
+ {
2261
+ cause
2262
+ }
2263
+ );
2264
+ }
2265
+ if (!response.ok) {
2266
+ throw new DeviceError(
2267
+ "hub-request-failed",
2268
+ `Device screenshot failed: the device hub answered ${String(response.status)}.`
2269
+ );
2270
+ }
2271
+ return new Uint8Array(await response.arrayBuffer());
2272
+ }
2273
+ };
2274
+ var classifyBootFailure = (error) => {
2275
+ const text2 = error ?? "";
2276
+ if (/insufficient.*(?:disk|space)|not enough.*(?:disk|space)|no space left/i.test(text2)) {
2277
+ return "boot-disk-space";
2278
+ }
2279
+ if (/timed? out|timeout/i.test(text2)) return "boot-timeout";
2280
+ return "boot-failed";
2281
+ };
2282
+
2283
+ // src/service.ts
2284
+ var sameDevice = (a, hostId, deviceId) => a.hostId === hostId && a.id === deviceId;
2285
+ var DeviceService = class {
2286
+ state = {
2287
+ hosts: [],
2288
+ hostStatuses: {},
2289
+ devices: [],
2290
+ sessions: [],
2291
+ bootingDevices: [],
2292
+ revision: 0
2293
+ };
2294
+ listeners = /* @__PURE__ */ new Set();
2295
+ host;
2296
+ options;
2297
+ now;
2298
+ constructor(options) {
2299
+ this.options = options;
2300
+ this.host = options.host;
2301
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
2302
+ }
2303
+ get current() {
2304
+ return this.state;
2305
+ }
2306
+ /** 立刻收到当前状态,之后每次变化收一份。 */
2307
+ subscribe(listener) {
2308
+ this.listeners.add(listener);
2309
+ listener(this.state);
2310
+ return () => this.listeners.delete(listener);
2311
+ }
2312
+ publish(update) {
2313
+ const next = { ...update(this.state), revision: this.state.revision + 1 };
2314
+ this.state = next;
2315
+ for (const listener of this.listeners) listener(next);
2316
+ return next;
2317
+ }
2318
+ setHostStatus(detail) {
2319
+ this.publish((state) => ({
2320
+ ...state,
2321
+ hostStatuses: { ...state.hostStatuses, [this.host.id]: detail }
2322
+ }));
2323
+ }
2324
+ onPhase = (phase, detail) => {
2325
+ this.setHostStatus({ status: phase, ...detail });
2326
+ };
2327
+ resolveHost(hostId) {
2328
+ if ((hostId ?? LOCAL_DEVICE_HOST_ID) !== this.host.id) {
2329
+ throw new DeviceError("host-unavailable", `Unknown device host ${String(hostId)}.`);
2330
+ }
2331
+ }
2332
+ async withStatus(task) {
2333
+ try {
2334
+ return await task();
2335
+ } catch (error) {
2336
+ const message = error instanceof DeviceError ? error.message : errorDetail(error);
2337
+ this.setHostStatus({
2338
+ status: "failed",
2339
+ code: error instanceof DeviceError ? error.code : "host-unavailable",
2340
+ message
2341
+ });
2342
+ throw error;
2343
+ }
2344
+ }
2345
+ /** 起 hub 并刷新设备。机器一个平台都跑不了时什么都不装,返回 null。 */
2346
+ async readinessIfSupported() {
2347
+ const platforms = await this.host.platforms();
2348
+ if (!platforms.some((platform) => platform.available)) return null;
2349
+ return this.readiness();
2350
+ }
2351
+ async readiness() {
2352
+ return this.withStatus(async () => {
2353
+ const ready = await this.host.ensureReady(this.onPhase);
2354
+ if (this.state.hostStatuses[this.host.id]?.status !== "ready") {
2355
+ this.setHostStatus({ status: "ready" });
2356
+ }
2357
+ return ready;
2358
+ });
2359
+ }
2360
+ hub(ready) {
2361
+ return new HubClient(ready.hubOrigin, this.options.fetch);
2362
+ }
2363
+ async fetchDevices(ready) {
2364
+ const list = await this.hub(ready).listDevices();
2365
+ const devices = list.devices.map((device) => ({
2366
+ ...device,
2367
+ hostId: ready.hostId
2368
+ }));
2369
+ if ((await this.host.platformAvailability("android")).available) {
2370
+ const avds = await this.host.runSdkCommand("emulator", ["-list-avds"]);
2371
+ if (avds.code !== 0) {
2372
+ throw new DeviceError(
2373
+ "command-failed",
2374
+ `Listing Android virtual devices failed (exit code ${String(avds.code)}).`
2375
+ );
2376
+ }
2377
+ for (const name of avds.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)) {
2378
+ if (!devices.some((device) => device.platform === "android" && device.name === name)) {
2379
+ devices.push({
2380
+ hostId: ready.hostId,
2381
+ id: name,
2382
+ name,
2383
+ platform: "android",
2384
+ version: "Android",
2385
+ booted: false,
2386
+ physical: false
2387
+ });
2388
+ }
2389
+ }
2390
+ }
2391
+ return list.errors.length > 0 ? { devices, detail: list.errors.join("\n") } : { devices };
2392
+ }
2393
+ async refresh(ready) {
2394
+ const { devices, detail } = await this.withStatus(() => this.fetchDevices(ready));
2395
+ const summary = await this.host.summary();
2396
+ return this.publish((state) => ({
2397
+ ...state,
2398
+ hosts: [summary],
2399
+ devices: [...state.devices.filter((device) => device.hostId !== ready.hostId), ...devices],
2400
+ hostStatuses: {
2401
+ ...state.hostStatuses,
2402
+ [ready.hostId]: { status: "ready", ...detail === void 0 ? {} : { message: detail } }
2403
+ }
2404
+ }));
2405
+ }
2406
+ /** 宿主现状,不装不起。 */
2407
+ async inspect(refreshProbe = false) {
2408
+ const summary = await this.host.summary(refreshProbe);
2409
+ return this.publish((state) => ({ ...state, hosts: [summary] }));
2410
+ }
2411
+ /** 列设备:能跑的话起 hub 再刷新;跑不了只更新宿主现状。失败记在宿主状态里,不抛。 */
2412
+ async list() {
2413
+ try {
2414
+ const ready = await this.readinessIfSupported();
2415
+ if (ready === null) return await this.inspect();
2416
+ return await this.refresh(ready);
2417
+ } catch {
2418
+ return this.state;
2419
+ }
2420
+ }
2421
+ async open(input) {
2422
+ this.resolveHost(input.hostId);
2423
+ const availability = await this.host.platformAvailability(input.platform);
2424
+ if (!availability.available) {
2425
+ throw new DeviceError(
2426
+ "platform-unavailable",
2427
+ `${input.platform} devices are unavailable on this machine: ${availability.reason ?? "toolchain missing"}`
2428
+ );
2429
+ }
2430
+ const ready = await this.readiness();
2431
+ let device = (await this.refresh(ready)).devices.find(
2432
+ (candidate) => sameDevice(candidate, ready.hostId, input.deviceId)
2433
+ );
2434
+ if (device === void 0) {
2435
+ throw new DeviceError(
2436
+ "device-not-found",
2437
+ `Device ${input.deviceId} was not found on this machine.`
2438
+ );
2439
+ }
2440
+ const hub = this.hub(ready);
2441
+ if (!device.booted && input.boot !== false) {
2442
+ const booting = { ...device, threadId: input.threadId };
2443
+ this.publish((state) => ({
2444
+ ...state,
2445
+ bootingDevices: [
2446
+ ...state.bootingDevices.filter((entry) => !sameDevice(entry, booting.hostId, booting.id)),
2447
+ booting
2448
+ ]
2449
+ }));
2450
+ let bootedId;
2451
+ try {
2452
+ const result = await hub.boot(device);
2453
+ if (!result.ok) {
2454
+ const code = classifyBootFailure(result.error);
2455
+ throw new DeviceError(
2456
+ code,
2457
+ `Device ${device.id} failed to boot: ${result.error ?? code}.`
2458
+ );
2459
+ }
2460
+ if (device.platform === "ios") await hub.attachIosStream(device.id);
2461
+ bootedId = result.serial ?? result.id ?? device.id;
2462
+ } finally {
2463
+ this.publish((state) => ({
2464
+ ...state,
2465
+ bootingDevices: state.bootingDevices.filter(
2466
+ (entry) => !sameDevice(entry, booting.hostId, booting.id)
2467
+ )
2468
+ }));
2469
+ }
2470
+ const after = await this.refresh(ready);
2471
+ const original = device;
2472
+ device = after.devices.find((candidate) => sameDevice(candidate, ready.hostId, bootedId)) ?? after.devices.find((candidate) => sameDevice(candidate, ready.hostId, original.id));
2473
+ if (device === void 0) {
2474
+ throw new DeviceError(
2475
+ "device-not-found",
2476
+ `Device ${bootedId} was not found after booting.`
2477
+ );
2478
+ }
2479
+ } else if (device.platform === "ios" && device.booted) {
2480
+ await hub.attachIosStream(device.id);
2481
+ }
2482
+ const session = {
2483
+ threadId: input.threadId,
2484
+ hostId: ready.hostId,
2485
+ deviceId: device.id,
2486
+ platform: device.platform,
2487
+ openedAt: this.now().toISOString()
2488
+ };
2489
+ this.publish((state) => ({
2490
+ ...state,
2491
+ sessions: [
2492
+ ...state.sessions.filter(
2493
+ (existing2) => !(existing2.threadId === session.threadId && existing2.hostId === session.hostId && existing2.deviceId === session.deviceId)
2494
+ ),
2495
+ session
2496
+ ]
2497
+ }));
2498
+ return session;
2499
+ }
2500
+ async shutdownDevice(deviceId, platform) {
2501
+ const ready = await this.readiness();
2502
+ const hub = this.hub(ready);
2503
+ try {
2504
+ const result = platform === "ios" ? await hub.shutdownIos(deviceId) : await hub.shutdownDevice(platform, deviceId);
2505
+ if (!result.ok) {
2506
+ throw new DeviceError(
2507
+ "hub-rejected",
2508
+ `Device shutdown failed: ${result.error ?? "the hub refused"}.`
2509
+ );
2510
+ }
2511
+ } catch (error) {
2512
+ const devices = await this.fetchDevices(ready).catch(() => null);
2513
+ const known = devices?.devices.find((device) => device.id === deviceId);
2514
+ if (platform !== "ios" || known?.booted !== false) throw error;
2515
+ }
2516
+ this.publish((state) => ({
2517
+ ...state,
2518
+ devices: state.devices.map(
2519
+ (device) => sameDevice(device, ready.hostId, deviceId) ? { ...device, booted: false } : device
2520
+ ),
2521
+ sessions: state.sessions.filter(
2522
+ (session) => !(session.hostId === ready.hostId && session.deviceId === deviceId)
2523
+ )
2524
+ }));
2525
+ await this.refresh(ready).catch((error) => {
2526
+ this.options.log.warn("Device discovery unavailable after shutdown", {
2527
+ detail: error instanceof Error ? error.message : String(error)
2528
+ });
2529
+ });
2530
+ }
2531
+ async close(input) {
2532
+ const closing = this.state.sessions.filter(
2533
+ (session) => session.threadId === input.threadId && (input.hostId === void 0 || session.hostId === input.hostId) && (input.deviceId === void 0 || session.deviceId === input.deviceId)
2534
+ );
2535
+ if (closing.length === 0) return;
2536
+ this.publish((state) => ({
2537
+ ...state,
2538
+ sessions: state.sessions.filter((session) => !closing.includes(session))
2539
+ }));
2540
+ if (input.shutdown === true) {
2541
+ for (const session of closing) await this.shutdownDevice(session.deviceId, session.platform);
2542
+ }
2543
+ }
2544
+ async shutdown(input) {
2545
+ this.resolveHost(input.hostId);
2546
+ await this.shutdownDevice(input.deviceId, input.platform);
2547
+ }
2548
+ async screenshot(input) {
2549
+ this.resolveHost(input.hostId);
2550
+ const ready = await this.readiness();
2551
+ const device = this.state.devices.find((candidate) => sameDevice(candidate, ready.hostId, input.deviceId)) ?? (await this.refresh(ready)).devices.find(
2552
+ (candidate) => sameDevice(candidate, ready.hostId, input.deviceId)
2553
+ );
2554
+ if (device === void 0) {
2555
+ throw new DeviceError(
2556
+ "device-not-found",
2557
+ `Device ${input.deviceId} was not found on this machine.`
2558
+ );
2559
+ }
2560
+ const png = await this.hub(ready).screenshot(device.platform, device.id);
2561
+ return { device, png };
2562
+ }
2563
+ /** 找目标设备:缓存里没有、或缓存说没开机时先刷新一次,免得被外面刚开机的设备骗过。 */
2564
+ async resolveTarget(input) {
2565
+ this.resolveHost(input.hostId);
2566
+ const ready = await this.readiness();
2567
+ const find = (state) => state.devices.find((candidate) => sameDevice(candidate, ready.hostId, input.deviceId));
2568
+ let device = find(this.state);
2569
+ if (device === void 0 || !device.booted) device = find(await this.refresh(ready));
2570
+ if (device === void 0) {
2571
+ throw new DeviceError(
2572
+ "device-not-found",
2573
+ `Device ${input.deviceId} was not found on this machine.`
2574
+ );
2575
+ }
2576
+ return { ready, device };
2577
+ }
2578
+ async readDetail(hostId, device) {
2579
+ const read = device.booted ? await readDeviceDetail(await this.host.actionTools(), device) : { settings: {}, foregroundApp: null };
2580
+ return {
2581
+ hostId,
2582
+ deviceId: device.id,
2583
+ settings: read.settings,
2584
+ foregroundApp: read.foregroundApp,
2585
+ readAt: this.now().toISOString()
2586
+ };
2587
+ }
2588
+ /** 设备当前的设置与前台应用。没开机时设置为空。 */
2589
+ async detail(input) {
2590
+ const { ready, device } = await this.resolveTarget(input);
2591
+ return this.readDetail(ready.hostId, device);
2592
+ }
2593
+ /** 在已开机的设备上跑一次操作,返回操作后的详情。入参整份交给校验,目标之外的键会被丢掉。 */
2594
+ async action(input) {
2595
+ const { ready, device } = await this.resolveTarget(input);
2596
+ if (!device.booted) {
2597
+ throw new DeviceError("device-not-booted", `Device ${device.id} is not booted.`);
2598
+ }
2599
+ await runDeviceAction(await this.host.actionTools(), device, input);
2600
+ return this.readDetail(ready.hostId, device);
2601
+ }
2602
+ sessionsForThread(threadId) {
2603
+ return this.state.sessions.filter((session) => session.threadId === threadId);
2604
+ }
2605
+ /**
2606
+ * agent 用这台设备前的准备:装、起 agent-device 守护进程,写这台宿主的连接配置与启动器,
2607
+ * 返回启动器路径和把命令钉到这条会话、这台设备的参数。
2608
+ */
2609
+ async agentTarget(input) {
2610
+ this.resolveHost(input.hostId);
2611
+ const ready = await this.withStatus(() => this.host.ensureAgentReady(this.onPhase));
2612
+ this.setHostStatus({ status: "ready" });
2613
+ const configPath = agentDeviceConfigPath(this.options.dataDir, input.hostId);
2614
+ await writeAgentDeviceConfig(configPath, ready.agentDevice);
2615
+ const command = await ensureAgentDeviceLauncher({
2616
+ dataDir: this.options.dataDir,
2617
+ entryPath: ready.agentDevice.entryPath,
2618
+ nodePath: ready.probe.execPath,
2619
+ electron: ready.probe.electron !== null,
2620
+ platform: ready.probe.platform
2621
+ });
2622
+ return {
2623
+ command,
2624
+ args: [
2625
+ "--config",
2626
+ configPath,
2627
+ "--session",
2628
+ agentDeviceSession(input.threadId, input.hostId, input.deviceId)
2629
+ ]
2630
+ };
2631
+ }
2632
+ /** 设置里的「更新工具」:只装不起,装完重读版本。 */
2633
+ async updateTool(tool) {
2634
+ await this.host.installTool(tool);
2635
+ return this.inspect();
2636
+ }
2637
+ /** 插件停用时:停辅助进程,模拟器本身不关。 */
2638
+ async stop() {
2639
+ await this.host.stop();
2640
+ }
2641
+ };
2642
+
2643
+ // src/tools.ts
2644
+ var text = (value) => ({ text: JSON.stringify(value, null, 2) });
2645
+ var failure = (message, errorCode) => ({
2646
+ text: message,
2647
+ isError: true,
2648
+ ...errorCode === void 0 ? {} : { errorCode }
2649
+ });
2650
+ var asString = (value) => typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
2651
+ var asPlatform = (value) => value === "ios" || value === "android" ? value : void 0;
2652
+ var pngDimensions = (png) => {
2653
+ if (png.length < 24) return { width: 0, height: 0 };
2654
+ const view = new DataView(png.buffer, png.byteOffset, png.byteLength);
2655
+ const isPng = view.getUint32(0) === 2303741511 && view.getUint32(4) === 218765834 && view.getUint32(12) === 1229472850;
2656
+ return isPng ? { width: view.getUint32(16), height: view.getUint32(20) } : { width: 0, height: 0 };
2657
+ };
2658
+ var pickDevice = (devices, input) => {
2659
+ const hostId = input.hostId ?? LOCAL_DEVICE_HOST_ID;
2660
+ if (input.deviceId !== void 0) {
2661
+ const match = devices.find(
2662
+ (device) => device.hostId === hostId && device.id === input.deviceId
2663
+ );
2664
+ if (match) return match;
2665
+ throw new DeviceError(
2666
+ "device-not-found",
2667
+ `No device ${input.deviceId} on host ${hostId}. Call devices_device_list for current ids.`
2668
+ );
2669
+ }
2670
+ const candidates = devices.filter(
2671
+ (device) => device.hostId === hostId && (input.platform === void 0 || device.platform === input.platform)
2672
+ );
2673
+ if (candidates.length === 0) {
2674
+ throw new DeviceError(
2675
+ "device-not-found",
2676
+ input.platform === void 0 ? "No simulators or emulators were found. Call devices_device_list to see why." : `No ${input.platform} devices were found on host ${hostId}. Call devices_device_list to see why.`
2677
+ );
2678
+ }
2679
+ if (input.platform === void 0 && new Set(candidates.map((device) => device.platform)).size > 1) {
2680
+ throw new DeviceError(
2681
+ "ambiguous-platform",
2682
+ "Both iOS and Android devices are available; pass platform or deviceId."
2683
+ );
2684
+ }
2685
+ return candidates.find((device) => device.booted) ?? candidates[0];
2686
+ };
2687
+ var requireThread = (invocation) => {
2688
+ if (invocation.threadId === null) {
2689
+ throw new DeviceError("no-open-device", "Device tools can only be used from a conversation.");
2690
+ }
2691
+ return invocation.threadId;
2692
+ };
2693
+ var guard = (handle) => async (input, invocation) => {
2694
+ try {
2695
+ return await handle(input, invocation);
2696
+ } catch (error) {
2697
+ if (error instanceof DeviceError) return failure(error.message, error.code);
2698
+ throw error;
2699
+ }
2700
+ };
2701
+ var HOST_ID_PROPERTY = {
2702
+ type: "string",
2703
+ description: "Device host from devices_device_list. Defaults to this machine."
2704
+ };
2705
+ var deviceToolkit = (service) => ({
2706
+ name: "devices",
2707
+ tools: [
2708
+ {
2709
+ name: "device_list",
2710
+ description: "List iOS Simulators and Android Emulators on this machine, which platforms it can run, and which devices are already open in this conversation's Devices panel. Call this before device_open when you do not know a device id.",
2711
+ inputSchema: { type: "object", properties: { hostId: HOST_ID_PROPERTY } },
2712
+ handle: guard(async (input, invocation) => {
2713
+ const threadId = requireThread(invocation);
2714
+ const state = await service.list();
2715
+ const hostId = asString(input["hostId"]);
2716
+ return text({
2717
+ hostStatuses: state.hostStatuses,
2718
+ hosts: hostId ? state.hosts.filter((host) => host.id === hostId) : state.hosts,
2719
+ devices: hostId ? state.devices.filter((device) => device.hostId === hostId) : state.devices,
2720
+ open: service.sessionsForThread(threadId).map((session) => ({ hostId: session.hostId, deviceId: session.deviceId }))
2721
+ });
2722
+ })
2723
+ },
2724
+ {
2725
+ name: "device_open",
2726
+ description: "Open a simulator or emulator for this conversation: boots it if needed, starts its live stream, and shows it in the user's Devices panel. Returns the agent-device command line pinned to the device; drive the device with that command afterwards.",
2727
+ inputSchema: {
2728
+ type: "object",
2729
+ properties: {
2730
+ deviceId: {
2731
+ type: "string",
2732
+ description: "Simulator udid or emulator serial from devices_device_list. Omit to use the booted device for the platform, or the first one."
2733
+ },
2734
+ platform: {
2735
+ type: "string",
2736
+ enum: ["ios", "android"],
2737
+ description: "Required when deviceId is omitted and both platforms are available."
2738
+ },
2739
+ hostId: HOST_ID_PROPERTY
2740
+ }
2741
+ },
2742
+ handle: guard(async (input, invocation) => {
2743
+ const threadId = requireThread(invocation);
2744
+ const state = await service.list();
2745
+ const target2 = pickDevice(state.devices, {
2746
+ deviceId: asString(input["deviceId"]),
2747
+ platform: asPlatform(input["platform"]),
2748
+ hostId: asString(input["hostId"])
2749
+ });
2750
+ const agent = await service.agentTarget({
2751
+ threadId,
2752
+ hostId: target2.hostId,
2753
+ deviceId: target2.id
2754
+ });
2755
+ const session = await service.open({
2756
+ threadId,
2757
+ hostId: target2.hostId,
2758
+ deviceId: target2.id,
2759
+ platform: target2.platform
2760
+ });
2761
+ const device = service.current.devices.find(
2762
+ (candidate) => candidate.hostId === session.hostId && candidate.id === session.deviceId
2763
+ ) ?? target2;
2764
+ const targetArgs = [...agentDeviceTargetArgs(device), ...agent.args];
2765
+ return text({
2766
+ device,
2767
+ agentDevice: { command: agent.command, targetArgs },
2768
+ quickStart: agentDeviceQuickStart(device, targetArgs, agent.command)
2769
+ });
2770
+ })
2771
+ },
2772
+ {
2773
+ name: "device_screenshot",
2774
+ description: "Capture the current screen of an open device as a PNG image. Use it to see what the user sees; for taps and text use the agent-device command line.",
2775
+ inputSchema: {
2776
+ type: "object",
2777
+ properties: {
2778
+ deviceId: {
2779
+ type: "string",
2780
+ description: "Device from devices_device_list. Omit to use the device most recently opened in this conversation."
2781
+ },
2782
+ hostId: HOST_ID_PROPERTY
2783
+ }
2784
+ },
2785
+ handle: guard(async (input, invocation) => {
2786
+ const threadId = requireThread(invocation);
2787
+ const hostId = asString(input["hostId"]);
2788
+ const deviceId = asString(input["deviceId"]);
2789
+ const target2 = deviceId !== void 0 ? { hostId: hostId ?? LOCAL_DEVICE_HOST_ID, deviceId } : service.sessionsForThread(threadId).findLast((session) => hostId === void 0 || session.hostId === hostId);
2790
+ if (target2 === void 0) {
2791
+ return failure(
2792
+ "No device is open in this conversation. Call devices_device_open first.",
2793
+ "no-open-device"
2794
+ );
2795
+ }
2796
+ const shot = await service.screenshot(target2);
2797
+ return {
2798
+ text: JSON.stringify({ device: shot.device, ...pngDimensions(shot.png) }),
2799
+ images: [{ data: shot.png, mimeType: "image/png" }]
2800
+ };
2801
+ })
2802
+ },
2803
+ {
2804
+ name: "device_close",
2805
+ description: "Remove a device from this conversation's Devices panel. Pass shutdown=true to also power the simulator or emulator off.",
2806
+ inputSchema: {
2807
+ type: "object",
2808
+ properties: {
2809
+ deviceId: {
2810
+ type: "string",
2811
+ description: "Device to close. Omit to close every device in this conversation."
2812
+ },
2813
+ hostId: HOST_ID_PROPERTY,
2814
+ shutdown: {
2815
+ type: "boolean",
2816
+ description: "Also power the device off. Defaults to false."
2817
+ }
2818
+ }
2819
+ },
2820
+ handle: guard(async (input, invocation) => {
2821
+ await service.close({
2822
+ threadId: requireThread(invocation),
2823
+ hostId: asString(input["hostId"]),
2824
+ deviceId: asString(input["deviceId"]),
2825
+ shutdown: input["shutdown"] === true
2826
+ });
2827
+ return text({});
2828
+ })
2829
+ }
2830
+ ]
2831
+ });
2832
+
2833
+ // src/index.ts
2834
+ var HUB_SERVICE = "hub";
2835
+ var index_default = definePlugin({
2836
+ id: "devices",
2837
+ apiVersion: 3,
2838
+ activate: async (ctx) => {
2839
+ const direct = (input, init) => ctx.proxies.fetch(PLUGIN_PROXY_DIRECT).then((fetchDirect) => fetchDirect(input, init));
2840
+ const host = new LocalDeviceHost({
2841
+ dataDir: ctx.paths.dataDir,
2842
+ helperPath: NodePath6.join(ctx.paths.pluginDir, "dist", "helper.mjs"),
2843
+ process: ctx.process,
2844
+ fetch: direct,
2845
+ downloadFetch: ctx.http,
2846
+ log: ctx.log,
2847
+ sdkOverride: typeof ctx.config.androidSdk === "string" ? ctx.config.androidSdk : "",
2848
+ onHubStarted: (port) => {
2849
+ ctx.network.serve(HUB_SERVICE, { port, instance: LOCAL_DEVICE_HOST_ID }).catch((error) => {
2850
+ ctx.log.warn("Could not hand the device hub to the plugin network proxy", {
2851
+ detail: error instanceof Error ? error.message : String(error)
2852
+ });
2853
+ });
2854
+ }
2855
+ });
2856
+ const service = new DeviceService({
2857
+ host,
2858
+ dataDir: ctx.paths.dataDir,
2859
+ fetch: direct,
2860
+ log: ctx.log
2861
+ });
2862
+ ctx.toolkit(deviceToolkit(service));
2863
+ registerCommands(ctx, service);
2864
+ ctx.settingsCard(SETTINGS_CARD);
2865
+ ctx.onDispose(() => service.stop());
2866
+ }
2867
+ });
2868
+ export {
2869
+ index_default as default
2870
+ };