@ycodium-ai/plugin-devices 0.2.3506 → 0.2.3566
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -86
- package/assets/ui.js +16 -4
- package/dist/environment-helper.mjs +239 -0
- package/dist/helper.mjs +77 -128
- package/dist/index.js +1247 -556
- package/locales/en.json +64 -8
- package/locales/zh-CN.json +64 -8
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import * as
|
|
2
|
+
import * as NodePath9 from "node:path";
|
|
3
3
|
import { definePlugin, PLUGIN_PROXY_DIRECT } from "@ycodium-ai/plugin-api";
|
|
4
4
|
|
|
5
5
|
// src/types.ts
|
|
@@ -21,7 +21,7 @@ var errorDetail = (error) => {
|
|
|
21
21
|
|
|
22
22
|
// src/deviceActionInput.ts
|
|
23
23
|
var invalid = (field, rule) => new DeviceError("invalid-input", `Invalid ${field}: ${rule}.`);
|
|
24
|
-
var unsupported = (operation,
|
|
24
|
+
var unsupported = (operation, platform2) => new DeviceError("action-unsupported", `Device ${operation} is not supported on ${platform2}.`);
|
|
25
25
|
var DEVICE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
26
26
|
var IOS_BUNDLE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$/;
|
|
27
27
|
var ANDROID_PACKAGE_PATTERN = /^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)*$/;
|
|
@@ -89,12 +89,12 @@ var assertDeviceId = (id) => {
|
|
|
89
89
|
}
|
|
90
90
|
return id;
|
|
91
91
|
};
|
|
92
|
-
var appId = (
|
|
93
|
-
const ok = typeof input === "string" && (
|
|
92
|
+
var appId = (platform2, input) => {
|
|
93
|
+
const ok = typeof input === "string" && (platform2 === "ios" ? IOS_BUNDLE_ID_PATTERN.test(input) : input.length <= MAX_ANDROID_PACKAGE_LENGTH && ANDROID_PACKAGE_PATTERN.test(input));
|
|
94
94
|
if (!ok) {
|
|
95
95
|
throw invalid(
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
platform2 === "ios" ? "bundle id" : "package name",
|
|
97
|
+
platform2 === "ios" ? "expected letters, digits, dots, hyphens and underscores" : "expected a dotted Java package name"
|
|
98
98
|
);
|
|
99
99
|
}
|
|
100
100
|
return input;
|
|
@@ -120,7 +120,7 @@ var pushPayload = (input) => {
|
|
|
120
120
|
}
|
|
121
121
|
return encoded;
|
|
122
122
|
};
|
|
123
|
-
var parseDeviceAction = (
|
|
123
|
+
var parseDeviceAction = (platform2, input) => {
|
|
124
124
|
if (!isRecord(input)) throw invalid("action", "expected an object");
|
|
125
125
|
const type = oneOf(
|
|
126
126
|
[
|
|
@@ -171,7 +171,7 @@ var parseDeviceAction = (platform, input) => {
|
|
|
171
171
|
case "setPermission":
|
|
172
172
|
return {
|
|
173
173
|
type,
|
|
174
|
-
appId: appId(
|
|
174
|
+
appId: appId(platform2, input["appId"]),
|
|
175
175
|
permission: oneOf(PERMISSIONS, input["permission"], "permission"),
|
|
176
176
|
decision: oneOf(DECISIONS, input["decision"], "decision")
|
|
177
177
|
};
|
|
@@ -179,11 +179,11 @@ var parseDeviceAction = (platform, input) => {
|
|
|
179
179
|
return { type, url: url(input["url"]) };
|
|
180
180
|
case "launchApp":
|
|
181
181
|
case "terminateApp":
|
|
182
|
-
return { type, appId: appId(
|
|
182
|
+
return { type, appId: appId(platform2, input["appId"]) };
|
|
183
183
|
case "sendPush":
|
|
184
184
|
return {
|
|
185
185
|
type,
|
|
186
|
-
appId: appId(
|
|
186
|
+
appId: appId(platform2, input["appId"]),
|
|
187
187
|
payload: input["payload"]
|
|
188
188
|
};
|
|
189
189
|
}
|
|
@@ -225,10 +225,10 @@ var ANDROID_TOGGLES = /* @__PURE__ */ new Set([
|
|
|
225
225
|
"reduceMotion",
|
|
226
226
|
"networkEnabled"
|
|
227
227
|
]);
|
|
228
|
-
var supportsAction = (
|
|
229
|
-
if (!(
|
|
228
|
+
var supportsAction = (platform2, action) => {
|
|
229
|
+
if (!(platform2 === "ios" ? IOS_ACTIONS : ANDROID_ACTIONS).has(action.type)) return false;
|
|
230
230
|
if (action.type === "setToggle") {
|
|
231
|
-
return (
|
|
231
|
+
return (platform2 === "ios" ? IOS_TOGGLES : ANDROID_TOGGLES).has(action.setting);
|
|
232
232
|
}
|
|
233
233
|
return true;
|
|
234
234
|
};
|
|
@@ -239,11 +239,11 @@ var target = {
|
|
|
239
239
|
deviceId: { type: "string", minLength: 1, maxLength: 128 }
|
|
240
240
|
};
|
|
241
241
|
var appId2 = { type: "string", minLength: 1, maxLength: 255 };
|
|
242
|
-
var branch = (type,
|
|
242
|
+
var branch = (type, properties2 = {}, required = []) => ({
|
|
243
243
|
type: "object",
|
|
244
244
|
additionalProperties: false,
|
|
245
245
|
required: ["deviceId", "type", ...required],
|
|
246
|
-
properties: { ...target, type: { const: type }, ...
|
|
246
|
+
properties: { ...target, type: { const: type }, ...properties2 }
|
|
247
247
|
});
|
|
248
248
|
var detailPayload = {
|
|
249
249
|
type: "object",
|
|
@@ -373,9 +373,9 @@ var registerCommands = (ctx, service) => {
|
|
|
373
373
|
if (!signal.aborted) emit(state);
|
|
374
374
|
});
|
|
375
375
|
try {
|
|
376
|
-
await new Promise((
|
|
377
|
-
if (signal.aborted)
|
|
378
|
-
else signal.addEventListener("abort", () =>
|
|
376
|
+
await new Promise((resolve3) => {
|
|
377
|
+
if (signal.aborted) resolve3();
|
|
378
|
+
else signal.addEventListener("abort", () => resolve3(), { once: true });
|
|
379
379
|
});
|
|
380
380
|
} finally {
|
|
381
381
|
unsubscribe();
|
|
@@ -462,7 +462,7 @@ var registerCommands = (ctx, service) => {
|
|
|
462
462
|
});
|
|
463
463
|
ctx.command({
|
|
464
464
|
name: "updateTool",
|
|
465
|
-
description: "
|
|
465
|
+
description: "Verify this plugin's bundled version of one device tool without downloading or starting it.",
|
|
466
466
|
scope: "operate",
|
|
467
467
|
payloadSchema: updatePayload,
|
|
468
468
|
handle: (payload) => service.updateTool(payload["tool"] === "agent" ? "agent" : "hub")
|
|
@@ -494,7 +494,7 @@ var registerCommands = (ctx, service) => {
|
|
|
494
494
|
});
|
|
495
495
|
ctx.stream({
|
|
496
496
|
name: "updateTools",
|
|
497
|
-
description: "Settings card action:
|
|
497
|
+
description: "Settings card action: verify the bundled device tool versions.",
|
|
498
498
|
scope: "operate",
|
|
499
499
|
payloadSchema: emptyPayload,
|
|
500
500
|
handle: async (_payload, emit) => {
|
|
@@ -537,41 +537,1029 @@ var registerCommands = (ctx, service) => {
|
|
|
537
537
|
});
|
|
538
538
|
};
|
|
539
539
|
|
|
540
|
+
// src/environment.ts
|
|
541
|
+
import * as NodeFSP3 from "node:fs/promises";
|
|
542
|
+
import * as NodePath3 from "node:path";
|
|
543
|
+
|
|
544
|
+
// src/androidPackages.ts
|
|
545
|
+
var repo = "https://dl.google.com/android/repository/";
|
|
546
|
+
var archive = (name, file, sha1, bytes, destination = "sdk") => ({ name, url: `${repo}${file}`, sha1, bytes, destination });
|
|
547
|
+
var platformTools = {
|
|
548
|
+
linux: archive(
|
|
549
|
+
"Platform-Tools 37.0.1",
|
|
550
|
+
"platform-tools_r37.0.1-linux.zip",
|
|
551
|
+
"477254aa5f903c15cf51001717bdf347fb6b53e0",
|
|
552
|
+
9054187
|
|
553
|
+
),
|
|
554
|
+
darwin: archive(
|
|
555
|
+
"Platform-Tools 37.0.1",
|
|
556
|
+
"platform-tools_r37.0.1-darwin.zip",
|
|
557
|
+
"6ae73f4de6452dc57e62ec02b68eed92a4c21661",
|
|
558
|
+
16110554
|
|
559
|
+
),
|
|
560
|
+
win32: archive(
|
|
561
|
+
"Platform-Tools 37.0.1",
|
|
562
|
+
"platform-tools_r37.0.1-win.zip",
|
|
563
|
+
"e03e78b1d80b396f1c3358e31251cb31740e1110",
|
|
564
|
+
8044989
|
|
565
|
+
)
|
|
566
|
+
};
|
|
567
|
+
var emulators = {
|
|
568
|
+
"linux-x64": archive(
|
|
569
|
+
"Android Emulator 37.2.12",
|
|
570
|
+
"emulator-linux_x64-16428233.zip",
|
|
571
|
+
"cd7362ea55dfb86a418958138dc396e74165dd01",
|
|
572
|
+
349654171
|
|
573
|
+
),
|
|
574
|
+
"darwin-x64": archive(
|
|
575
|
+
"Android Emulator 37.2.12",
|
|
576
|
+
"emulator-darwin_x64-16428233.zip",
|
|
577
|
+
"0c08fc22f41f2e63976f5503d593f821ff11e30b",
|
|
578
|
+
488731099
|
|
579
|
+
),
|
|
580
|
+
"darwin-arm64": archive(
|
|
581
|
+
"Android Emulator 37.2.12",
|
|
582
|
+
"emulator-darwin_aarch64-16428233.zip",
|
|
583
|
+
"3af4fe44ce82b3d88ae5678a53735f27ad729c15",
|
|
584
|
+
416112708
|
|
585
|
+
),
|
|
586
|
+
"win32-x64": archive(
|
|
587
|
+
"Android Emulator 37.2.12",
|
|
588
|
+
"emulator-windows_x64-16428233.zip",
|
|
589
|
+
"488ed747e82de7e9bb5247becd1ac043c7e5e85d",
|
|
590
|
+
455342868
|
|
591
|
+
)
|
|
592
|
+
};
|
|
593
|
+
var androidArchives = (platform2, arch2) => {
|
|
594
|
+
const emulator = emulators[`${platform2}-${arch2}`];
|
|
595
|
+
const tools = platformTools[platform2];
|
|
596
|
+
if (!emulator || !tools) return null;
|
|
597
|
+
const arm = arch2 === "arm64";
|
|
598
|
+
const image = archive(
|
|
599
|
+
"Android 15 (API 35), AOSP revision 2",
|
|
600
|
+
`sys-img/android/${arm ? "arm64-v8a" : "x86_64"}-35_r02.zip`,
|
|
601
|
+
arm ? "2026a06409db630b56711afdbffb457c1dbaed49" : "2d857d170c0d1b827149565da34b3383e5306f7f",
|
|
602
|
+
arm ? 769099654 : 782404023,
|
|
603
|
+
"image"
|
|
604
|
+
);
|
|
605
|
+
return [tools, emulator, image];
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
// src/environmentArchive.ts
|
|
609
|
+
import * as NodeCrypto from "node:crypto";
|
|
610
|
+
import * as NodeFS from "node:fs";
|
|
611
|
+
import * as NodeFSP from "node:fs/promises";
|
|
612
|
+
import * as NodePath from "node:path";
|
|
613
|
+
import * as NodeStream from "node:stream";
|
|
614
|
+
import * as NodeStreamPromises from "node:stream/promises";
|
|
615
|
+
import * as NodeZlib from "node:zlib";
|
|
616
|
+
var childPath = (root, name) => {
|
|
617
|
+
if (name.includes("\\") || name.includes("\0")) throw new Error("Invalid archive path");
|
|
618
|
+
const result = NodePath.resolve(root, name);
|
|
619
|
+
if (!result.startsWith(`${NodePath.resolve(root)}${NodePath.sep}`))
|
|
620
|
+
throw new Error("Archive path escapes its destination");
|
|
621
|
+
return result;
|
|
622
|
+
};
|
|
623
|
+
var downloadArchive = async (item, directory, fetch) => {
|
|
624
|
+
await NodeFSP.mkdir(directory, { recursive: true });
|
|
625
|
+
const target2 = NodePath.join(directory, item.url.split("/").at(-1));
|
|
626
|
+
if ((await NodeFSP.lstat(target2).catch(() => null))?.isSymbolicLink())
|
|
627
|
+
throw new Error("Download cache entry must not be linked");
|
|
628
|
+
const verify = async () => {
|
|
629
|
+
const hash = NodeCrypto.createHash("sha1");
|
|
630
|
+
let size = 0;
|
|
631
|
+
for await (const bytes of NodeFS.createReadStream(target2)) {
|
|
632
|
+
hash.update(bytes);
|
|
633
|
+
size += bytes.length;
|
|
634
|
+
}
|
|
635
|
+
return size === item.bytes && hash.digest("hex") === item.sha1;
|
|
636
|
+
};
|
|
637
|
+
try {
|
|
638
|
+
if (await verify()) return target2;
|
|
639
|
+
} catch {
|
|
640
|
+
}
|
|
641
|
+
const temporary = `${target2}.${NodeCrypto.randomUUID()}.partial`;
|
|
642
|
+
try {
|
|
643
|
+
const response = await fetch(item.url);
|
|
644
|
+
if (!response.ok || !response.body)
|
|
645
|
+
throw new Error(`Downloading ${item.name} failed: HTTP ${response.status}`);
|
|
646
|
+
let size = 0;
|
|
647
|
+
const hash = NodeCrypto.createHash("sha1");
|
|
648
|
+
const meter = new NodeStream.Transform({
|
|
649
|
+
transform(chunk, _encoding, callback) {
|
|
650
|
+
size += chunk.length;
|
|
651
|
+
if (size > item.bytes) {
|
|
652
|
+
callback(new Error("Archive size exceeds the pinned length"));
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
hash.update(chunk);
|
|
656
|
+
callback(null, chunk);
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
const body = response.body;
|
|
660
|
+
const chunks = async function* () {
|
|
661
|
+
const reader = body.getReader();
|
|
662
|
+
try {
|
|
663
|
+
while (true) {
|
|
664
|
+
const item2 = await reader.read();
|
|
665
|
+
if (item2.done) break;
|
|
666
|
+
yield item2.value;
|
|
667
|
+
}
|
|
668
|
+
} finally {
|
|
669
|
+
await reader.cancel().catch(() => void 0);
|
|
670
|
+
reader.releaseLock();
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
await NodeStreamPromises.pipeline(
|
|
674
|
+
NodeStream.Readable.from(chunks()),
|
|
675
|
+
meter,
|
|
676
|
+
NodeFS.createWriteStream(temporary, { flags: "wx" })
|
|
677
|
+
);
|
|
678
|
+
if (size !== item.bytes || hash.digest("hex") !== item.sha1)
|
|
679
|
+
throw new Error(`Integrity check failed for ${item.name}`);
|
|
680
|
+
await NodeFSP.rename(temporary, target2);
|
|
681
|
+
return target2;
|
|
682
|
+
} finally {
|
|
683
|
+
await NodeFSP.rm(temporary, { force: true });
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
var extractEnvironmentZip = async (file, destination, signal) => {
|
|
687
|
+
const handle = await NodeFSP.open(file, "r");
|
|
688
|
+
try {
|
|
689
|
+
const size = (await handle.stat()).size;
|
|
690
|
+
const tail = Buffer.alloc(Math.min(size, 65557));
|
|
691
|
+
await handle.read(tail, 0, tail.length, size - tail.length);
|
|
692
|
+
let end = tail.length - 22;
|
|
693
|
+
while (end >= 0 && tail.readUInt32LE(end) !== 101010256) end--;
|
|
694
|
+
if (end < 0) throw new Error("ZIP end record missing");
|
|
695
|
+
const count = tail.readUInt16LE(end + 10);
|
|
696
|
+
const centralSize = tail.readUInt32LE(end + 12);
|
|
697
|
+
const centralOffset = tail.readUInt32LE(end + 16);
|
|
698
|
+
if (count === 65535 || centralSize > 32 * 1024 * 1024 || centralOffset + centralSize > size)
|
|
699
|
+
throw new Error("Unsupported ZIP directory");
|
|
700
|
+
const directory = Buffer.alloc(centralSize);
|
|
701
|
+
await handle.read(directory, 0, centralSize, centralOffset);
|
|
702
|
+
let offset = 0;
|
|
703
|
+
for (let index = 0; index < count; index++) {
|
|
704
|
+
signal?.throwIfAborted();
|
|
705
|
+
if (directory.readUInt32LE(offset) !== 33639248)
|
|
706
|
+
throw new Error("Invalid ZIP directory entry");
|
|
707
|
+
const flags = directory.readUInt16LE(offset + 8);
|
|
708
|
+
const method = directory.readUInt16LE(offset + 10);
|
|
709
|
+
const compressed = directory.readUInt32LE(offset + 20);
|
|
710
|
+
const unpacked = directory.readUInt32LE(offset + 24);
|
|
711
|
+
const nameLength = directory.readUInt16LE(offset + 28);
|
|
712
|
+
const extraLength = directory.readUInt16LE(offset + 30);
|
|
713
|
+
const commentLength = directory.readUInt16LE(offset + 32);
|
|
714
|
+
const mode = directory.readUInt32LE(offset + 38) >>> 16;
|
|
715
|
+
const localOffset = directory.readUInt32LE(offset + 42);
|
|
716
|
+
const name = directory.subarray(offset + 46, offset + 46 + nameLength).toString("utf8");
|
|
717
|
+
offset += 46 + nameLength + extraLength + commentLength;
|
|
718
|
+
if (flags & 1 || ![0, 8].includes(method) || localOffset >= centralOffset)
|
|
719
|
+
throw new Error("Unsupported ZIP entry");
|
|
720
|
+
const target2 = childPath(destination, name);
|
|
721
|
+
if (name.endsWith("/")) {
|
|
722
|
+
await NodeFSP.mkdir(target2, { recursive: true });
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
await NodeFSP.mkdir(NodePath.dirname(target2), { recursive: true });
|
|
726
|
+
const header = Buffer.alloc(30);
|
|
727
|
+
await handle.read(header, 0, 30, localOffset);
|
|
728
|
+
if (header.readUInt32LE(0) !== 67324752) throw new Error("Invalid ZIP local header");
|
|
729
|
+
const dataOffset = localOffset + 30 + header.readUInt16LE(26) + header.readUInt16LE(28);
|
|
730
|
+
if (dataOffset + compressed > centralOffset)
|
|
731
|
+
throw new Error("ZIP data exceeds archive bounds");
|
|
732
|
+
const source = compressed ? NodeFS.createReadStream(file, { start: dataOffset, end: dataOffset + compressed - 1 }) : NodeStream.Readable.from([]);
|
|
733
|
+
const transforms = method === 8 ? [NodeZlib.createInflateRaw()] : [];
|
|
734
|
+
if ((mode & 61440) === 40960) {
|
|
735
|
+
if (unpacked > 4096) throw new Error("Invalid ZIP symbolic link");
|
|
736
|
+
const chunks = [];
|
|
737
|
+
let length = 0;
|
|
738
|
+
const collect = new NodeStream.Writable({
|
|
739
|
+
write(chunk, _encoding, callback) {
|
|
740
|
+
length += chunk.length;
|
|
741
|
+
if (length > 4096) {
|
|
742
|
+
callback(new Error("Invalid ZIP symbolic link"));
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
chunks.push(Buffer.from(chunk));
|
|
746
|
+
callback();
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
await NodeStreamPromises.pipeline([source, ...transforms, collect], { signal });
|
|
750
|
+
const link = Buffer.concat(chunks).toString("utf8");
|
|
751
|
+
childPath(
|
|
752
|
+
destination,
|
|
753
|
+
NodePath.relative(destination, NodePath.resolve(NodePath.dirname(target2), link)).split(NodePath.sep).join("/")
|
|
754
|
+
);
|
|
755
|
+
await NodeFSP.symlink(link, target2);
|
|
756
|
+
} else {
|
|
757
|
+
let written = 0;
|
|
758
|
+
const limit = new NodeStream.Transform({
|
|
759
|
+
transform(chunk, _encoding, callback) {
|
|
760
|
+
written += chunk.length;
|
|
761
|
+
callback(
|
|
762
|
+
written > unpacked ? new Error("ZIP expands beyond its declared size") : null,
|
|
763
|
+
chunk
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
await NodeStreamPromises.pipeline(
|
|
768
|
+
[
|
|
769
|
+
source,
|
|
770
|
+
...transforms,
|
|
771
|
+
limit,
|
|
772
|
+
NodeFS.createWriteStream(target2, { flags: "wx", mode: mode & 511 || 420 })
|
|
773
|
+
],
|
|
774
|
+
{ signal }
|
|
775
|
+
);
|
|
776
|
+
if (written !== unpacked) throw new Error("ZIP uncompressed length mismatch");
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
} finally {
|
|
780
|
+
await handle.close();
|
|
781
|
+
}
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
// src/environmentStorage.ts
|
|
785
|
+
import * as NodeFSP2 from "node:fs/promises";
|
|
786
|
+
import * as NodePath2 from "node:path";
|
|
787
|
+
var ENVIRONMENT_MARKER = ".ycodium-devices-environment.json";
|
|
788
|
+
var ANDROID_ENVIRONMENT_VERSION = "Android 15 / API 35 \xB7 Emulator 37.2.12";
|
|
789
|
+
var environmentPaths = (root) => ({
|
|
790
|
+
root,
|
|
791
|
+
sdk: NodePath2.join(root, "sdk"),
|
|
792
|
+
user: NodePath2.join(root, "user"),
|
|
793
|
+
avd: NodePath2.join(root, "avd"),
|
|
794
|
+
temp: NodePath2.join(root, "temp"),
|
|
795
|
+
cache: NodePath2.join(root, "cache")
|
|
796
|
+
});
|
|
797
|
+
var environmentRoot = (dataDir, platform2, directory) => {
|
|
798
|
+
if (directory?.trim()) {
|
|
799
|
+
if (!NodePath2.isAbsolute(directory.trim()))
|
|
800
|
+
throw new Error("Choose an absolute directory on this machine");
|
|
801
|
+
return NodePath2.join(NodePath2.resolve(directory.trim()), `ycodium-devices-${platform2}`);
|
|
802
|
+
}
|
|
803
|
+
return NodePath2.join(dataDir, "environments", platform2);
|
|
804
|
+
};
|
|
805
|
+
var assertOwnedEnvironment = async (root) => {
|
|
806
|
+
const stat = await NodeFSP2.lstat(root);
|
|
807
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
808
|
+
throw new Error("Environment root is not a private directory");
|
|
809
|
+
if ((await NodeFSP2.lstat(NodePath2.join(root, ENVIRONMENT_MARKER))).isSymbolicLink())
|
|
810
|
+
throw new Error("Environment ownership record must not be linked");
|
|
811
|
+
const record = JSON.parse(
|
|
812
|
+
await NodeFSP2.readFile(NodePath2.join(root, ENVIRONMENT_MARKER), "utf8")
|
|
813
|
+
);
|
|
814
|
+
if (typeof record !== "object" || record === null || !("owner" in record) || record.owner !== "ycodium-devices" || !("sdk" in record) || record.sdk !== NodePath2.join(root, "sdk"))
|
|
815
|
+
throw new Error("Directory is not owned by the Devices plugin");
|
|
816
|
+
};
|
|
817
|
+
var assertPrivatePath = async (root, target2) => {
|
|
818
|
+
const relative3 = NodePath2.relative(NodePath2.resolve(root), NodePath2.resolve(target2));
|
|
819
|
+
if (!relative3 || relative3.startsWith(`..${NodePath2.sep}`) || relative3 === ".." || NodePath2.isAbsolute(relative3))
|
|
820
|
+
throw new Error("Private path escapes its environment");
|
|
821
|
+
let current = root;
|
|
822
|
+
for (const part of relative3.split(NodePath2.sep)) {
|
|
823
|
+
current = NodePath2.join(current, part);
|
|
824
|
+
const stat = await NodeFSP2.lstat(current).catch((error) => {
|
|
825
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
|
|
826
|
+
throw error;
|
|
827
|
+
});
|
|
828
|
+
if (!stat) break;
|
|
829
|
+
if (stat.isSymbolicLink()) throw new Error("Refusing a linked private environment path");
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
var assertPrivateEnvironmentPaths = async (root) => {
|
|
833
|
+
await assertOwnedEnvironment(root);
|
|
834
|
+
for (const name of ["sdk", "avd", "cache", "temp", "user", "home", "config", ".prepared"])
|
|
835
|
+
await assertPrivatePath(root, NodePath2.join(root, name));
|
|
836
|
+
};
|
|
837
|
+
var claimEnvironment = async (root) => {
|
|
838
|
+
let owned = false;
|
|
839
|
+
try {
|
|
840
|
+
await assertOwnedEnvironment(root);
|
|
841
|
+
owned = true;
|
|
842
|
+
} catch {
|
|
843
|
+
}
|
|
844
|
+
if (owned) {
|
|
845
|
+
await assertPrivateEnvironmentPaths(root);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
await NodeFSP2.mkdir(root, { recursive: true });
|
|
849
|
+
if ((await NodeFSP2.lstat(root)).isSymbolicLink() || (await NodeFSP2.readdir(root)).length !== 0)
|
|
850
|
+
throw new Error("Choose an empty directory; existing files will not be changed");
|
|
851
|
+
await NodeFSP2.writeFile(
|
|
852
|
+
NodePath2.join(root, ENVIRONMENT_MARKER),
|
|
853
|
+
JSON.stringify({ owner: "ycodium-devices", sdk: NodePath2.join(root, "sdk") }),
|
|
854
|
+
{ flag: "wx" }
|
|
855
|
+
);
|
|
856
|
+
};
|
|
857
|
+
var directoryBytes = async (root) => {
|
|
858
|
+
try {
|
|
859
|
+
const stat = await NodeFSP2.lstat(root);
|
|
860
|
+
if (stat.isSymbolicLink()) return 0;
|
|
861
|
+
if (!stat.isDirectory()) return stat.size;
|
|
862
|
+
const children = await NodeFSP2.readdir(root);
|
|
863
|
+
let total = 0;
|
|
864
|
+
for (const child of children) total += await directoryBytes(NodePath2.join(root, child));
|
|
865
|
+
return total;
|
|
866
|
+
} catch {
|
|
867
|
+
return 0;
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
var freeBytes = async (root) => {
|
|
871
|
+
let probe = root;
|
|
872
|
+
while (true) {
|
|
873
|
+
try {
|
|
874
|
+
const stat = await NodeFSP2.statfs(probe);
|
|
875
|
+
return stat.bavail * stat.bsize;
|
|
876
|
+
} catch {
|
|
877
|
+
}
|
|
878
|
+
const parent = NodePath2.dirname(probe);
|
|
879
|
+
if (parent === probe) return null;
|
|
880
|
+
probe = parent;
|
|
881
|
+
}
|
|
882
|
+
};
|
|
883
|
+
var selectedPrivateRoot = async (dataDir) => {
|
|
884
|
+
try {
|
|
885
|
+
const record = JSON.parse(
|
|
886
|
+
await NodeFSP2.readFile(NodePath2.join(dataDir, "android-environment.json"), "utf8")
|
|
887
|
+
);
|
|
888
|
+
if (typeof record !== "object" || record === null || !("root" in record) || typeof record.root !== "string")
|
|
889
|
+
return null;
|
|
890
|
+
await assertOwnedEnvironment(record.root);
|
|
891
|
+
return record.root;
|
|
892
|
+
} catch {
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
var selectedPrivateSdk = async (dataDir) => {
|
|
897
|
+
const root = await selectedPrivateRoot(dataDir);
|
|
898
|
+
if (!root) return null;
|
|
899
|
+
return await privateEnvironmentReady(root) ? NodePath2.join(root, "sdk") : null;
|
|
900
|
+
};
|
|
901
|
+
var privateEnvironmentReady = async (root) => {
|
|
902
|
+
try {
|
|
903
|
+
await assertPrivateEnvironmentPaths(root);
|
|
904
|
+
const paths = environmentPaths(root);
|
|
905
|
+
if (await NodeFSP2.readFile(NodePath2.join(root, ".prepared"), "utf8") !== ANDROID_ENVIRONMENT_VERSION || await NodeFSP2.readFile(NodePath2.join(paths.sdk, ".ycodium-components"), "utf8") !== ANDROID_ENVIRONMENT_VERSION)
|
|
906
|
+
return false;
|
|
907
|
+
const avd = NodePath2.join(paths.avd, "Ycodium_API_35.avd");
|
|
908
|
+
await assertPrivatePath(root, NodePath2.join(avd, "config.ini"));
|
|
909
|
+
await assertPrivatePath(root, NodePath2.join(paths.avd, "Ycodium_API_35.ini"));
|
|
910
|
+
const config = await NodeFSP2.readFile(NodePath2.join(avd, "config.ini"), "utf8");
|
|
911
|
+
const image = /^image\.sysdir\.1=(system-images\/android-35\/default\/(?:x86_64|arm64-v8a)\/)\r?$/m.exec(
|
|
912
|
+
config
|
|
913
|
+
)?.[1];
|
|
914
|
+
if (!image) return false;
|
|
915
|
+
await assertPrivatePath(root, NodePath2.join(paths.sdk, image, "system.img"));
|
|
916
|
+
await NodeFSP2.access(NodePath2.join(paths.sdk, image, "system.img"));
|
|
917
|
+
if (!(await NodeFSP2.readFile(NodePath2.join(paths.avd, "Ycodium_API_35.ini"), "utf8")).includes(
|
|
918
|
+
`path=${avd}
|
|
919
|
+
`
|
|
920
|
+
))
|
|
921
|
+
return false;
|
|
922
|
+
for (const suffix of ["", ".exe"]) {
|
|
923
|
+
try {
|
|
924
|
+
await NodeFSP2.access(NodePath2.join(paths.sdk, "platform-tools", `adb${suffix}`));
|
|
925
|
+
await NodeFSP2.access(NodePath2.join(paths.sdk, "emulator", `emulator${suffix}`));
|
|
926
|
+
return true;
|
|
927
|
+
} catch {
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
return false;
|
|
931
|
+
} catch {
|
|
932
|
+
return false;
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
var cleanEnvironment = async (root, category) => {
|
|
936
|
+
await assertPrivateEnvironmentPaths(root);
|
|
937
|
+
const target2 = NodePath2.resolve(root, category === "cache" ? "cache" : "avd");
|
|
938
|
+
if (NodePath2.dirname(target2) !== NodePath2.resolve(root))
|
|
939
|
+
throw new Error("Cleanup escaped the private environment");
|
|
940
|
+
const bytes = await directoryBytes(target2);
|
|
941
|
+
if (category === "devices") {
|
|
942
|
+
const devices = await NodeFSP2.readdir(target2).catch(() => []);
|
|
943
|
+
for (const name of devices) {
|
|
944
|
+
if (name.endsWith(".lock"))
|
|
945
|
+
throw new Error("Power off private virtual devices before deleting their data");
|
|
946
|
+
if (!name.endsWith(".avd")) continue;
|
|
947
|
+
if ((await NodeFSP2.lstat(NodePath2.join(target2, name))).isSymbolicLink())
|
|
948
|
+
throw new Error("Cleanup refuses a linked virtual device");
|
|
949
|
+
const entries = await NodeFSP2.readdir(NodePath2.join(target2, name));
|
|
950
|
+
if (entries.some((entry) => entry.endsWith(".lock")))
|
|
951
|
+
throw new Error("Power off private virtual devices before deleting their data");
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
const stat = await NodeFSP2.lstat(target2).catch(() => null);
|
|
955
|
+
if (stat?.isSymbolicLink()) throw new Error("Cleanup refuses a linked directory");
|
|
956
|
+
await NodeFSP2.rm(target2, { recursive: true, force: true });
|
|
957
|
+
if (category === "devices") await NodeFSP2.rm(NodePath2.join(root, ".prepared"), { force: true });
|
|
958
|
+
return bytes;
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
// src/environment.ts
|
|
962
|
+
var GIB = 1024 ** 3;
|
|
963
|
+
var androidGuide = "https://developer.android.com/studio/run/emulator-acceleration";
|
|
964
|
+
var appleGuide = "https://developer.apple.com/documentation/xcode/downloading-and-installing-additional-xcode-components";
|
|
965
|
+
var DeviceEnvironments = class {
|
|
966
|
+
busy = false;
|
|
967
|
+
abort = new AbortController();
|
|
968
|
+
progress = { status: "idle" };
|
|
969
|
+
listeners = /* @__PURE__ */ new Set();
|
|
970
|
+
active = null;
|
|
971
|
+
nextJobId = 1;
|
|
972
|
+
options;
|
|
973
|
+
constructor(options) {
|
|
974
|
+
this.options = options;
|
|
975
|
+
}
|
|
976
|
+
subscribe(listener) {
|
|
977
|
+
this.listeners.add(listener);
|
|
978
|
+
listener(this.progress);
|
|
979
|
+
return () => {
|
|
980
|
+
this.listeners.delete(listener);
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
publish(progress) {
|
|
984
|
+
this.progress = progress;
|
|
985
|
+
for (const listener of this.listeners) listener(progress);
|
|
986
|
+
}
|
|
987
|
+
start(input) {
|
|
988
|
+
if (this.busy)
|
|
989
|
+
throw new DeviceError("invalid-input", "Another simulator preparation is already running");
|
|
990
|
+
const id = this.nextJobId++;
|
|
991
|
+
this.publish({ id, status: "working" });
|
|
992
|
+
this.active = this.prepare(
|
|
993
|
+
input,
|
|
994
|
+
(stage) => this.publish({ id, status: "working", stage })
|
|
995
|
+
).then(
|
|
996
|
+
(plan) => this.publish({ id, status: "done", plan }),
|
|
997
|
+
(error) => {
|
|
998
|
+
this.options.log?.warn("Simulator preparation failed", {
|
|
999
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
1000
|
+
});
|
|
1001
|
+
this.publish({ id, status: "failed" });
|
|
1002
|
+
}
|
|
1003
|
+
);
|
|
1004
|
+
return this.progress;
|
|
1005
|
+
}
|
|
1006
|
+
async dispose() {
|
|
1007
|
+
this.abort.abort();
|
|
1008
|
+
await this.active;
|
|
1009
|
+
this.listeners.clear();
|
|
1010
|
+
}
|
|
1011
|
+
async probe() {
|
|
1012
|
+
const result = await this.options.process.run({
|
|
1013
|
+
program: "host:node",
|
|
1014
|
+
args: [
|
|
1015
|
+
this.options.helperPath,
|
|
1016
|
+
"environment-probe",
|
|
1017
|
+
...this.options.runtimeDir ? ["--tools", this.options.runtimeDir] : []
|
|
1018
|
+
],
|
|
1019
|
+
cwd: this.options.dataDir,
|
|
1020
|
+
timeoutMs: 6e4
|
|
1021
|
+
});
|
|
1022
|
+
if (result.exitCode !== 0)
|
|
1023
|
+
throw new Error("Could not inspect the machine's simulator prerequisites");
|
|
1024
|
+
const probe = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1) ?? "");
|
|
1025
|
+
if (typeof probe !== "object" || probe === null || !("platform" in probe) || !("arch" in probe) || !("virtualization" in probe) || !("xcode" in probe))
|
|
1026
|
+
throw new Error("Invalid simulator prerequisite report");
|
|
1027
|
+
return probe;
|
|
1028
|
+
}
|
|
1029
|
+
async plan(input) {
|
|
1030
|
+
const probe = await this.probe();
|
|
1031
|
+
const selected = input.platform === "android" && !input.directory?.trim() ? await selectedPrivateRoot(this.options.dataDir) : null;
|
|
1032
|
+
const root = selected ?? environmentRoot(this.options.dataDir, input.platform, input.directory);
|
|
1033
|
+
const paths = environmentPaths(root);
|
|
1034
|
+
const [free, cacheBytes, deviceDataBytes] = await Promise.all([
|
|
1035
|
+
freeBytes(root),
|
|
1036
|
+
directoryBytes(paths.cache),
|
|
1037
|
+
directoryBytes(paths.avd)
|
|
1038
|
+
]);
|
|
1039
|
+
if (input.platform === "ios") {
|
|
1040
|
+
const version = input.version?.trim() ?? "";
|
|
1041
|
+
const runtimePresent = probe.xcode.runtimes.some((runtime) => runtime.version === version);
|
|
1042
|
+
return {
|
|
1043
|
+
platform: "ios",
|
|
1044
|
+
supported: probe.platform === "darwin" && probe.iosToolsAvailable !== false && probe.xcode.ready,
|
|
1045
|
+
source: "apple",
|
|
1046
|
+
root,
|
|
1047
|
+
version,
|
|
1048
|
+
downloadBytes: null,
|
|
1049
|
+
requiredFreeBytes: 20 * GIB,
|
|
1050
|
+
freeBytes: free,
|
|
1051
|
+
ready: runtimePresent,
|
|
1052
|
+
...probe.platform === "darwin" && probe.iosToolsAvailable === false ? { reasonCode: "ios-tools-missing" } : !probe.xcode.ready ? { reasonCode: probe.xcode.reasonCode ?? "xcode-incomplete" } : !version ? { reasonCode: "ios-version-required" } : {},
|
|
1053
|
+
licenses: [{ url: "https://www.apple.com/legal/sla/" }],
|
|
1054
|
+
guidanceUrl: appleGuide,
|
|
1055
|
+
cacheBytes,
|
|
1056
|
+
deviceDataBytes
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
const archives = androidArchives(probe.platform, probe.arch);
|
|
1060
|
+
const sdk = await this.options.externalSdk();
|
|
1061
|
+
const prepared = await privateEnvironmentReady(root);
|
|
1062
|
+
return {
|
|
1063
|
+
platform: "android",
|
|
1064
|
+
supported: archives !== null && probe.virtualization.available,
|
|
1065
|
+
source: sdk.available && sdk.root !== paths.sdk ? "external" : "private",
|
|
1066
|
+
...sdk.available && sdk.root ? { existingSdkRoot: sdk.root } : {},
|
|
1067
|
+
root,
|
|
1068
|
+
version: ANDROID_ENVIRONMENT_VERSION,
|
|
1069
|
+
downloadBytes: archives?.reduce((sum, item) => sum + item.bytes, 0) ?? null,
|
|
1070
|
+
requiredFreeBytes: 12 * GIB,
|
|
1071
|
+
freeBytes: free,
|
|
1072
|
+
ready: sdk.available && (sdk.root !== paths.sdk || prepared),
|
|
1073
|
+
...!archives ? { reasonCode: "android-platform-unsupported" } : !probe.virtualization.available ? { reasonCode: probe.virtualization.reasonCode ?? "virtualization-unavailable" } : {},
|
|
1074
|
+
licenses: [
|
|
1075
|
+
{
|
|
1076
|
+
url: "https://developer.android.com/studio/terms"
|
|
1077
|
+
}
|
|
1078
|
+
],
|
|
1079
|
+
guidanceUrl: androidGuide,
|
|
1080
|
+
cacheBytes,
|
|
1081
|
+
deviceDataBytes
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
async exclusively(operation) {
|
|
1085
|
+
if (this.busy)
|
|
1086
|
+
throw new DeviceError(
|
|
1087
|
+
"invalid-input",
|
|
1088
|
+
"Another simulator preparation or cleanup is already running"
|
|
1089
|
+
);
|
|
1090
|
+
this.busy = true;
|
|
1091
|
+
try {
|
|
1092
|
+
return await operation();
|
|
1093
|
+
} finally {
|
|
1094
|
+
this.busy = false;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
async prepare(input, progress = () => void 0) {
|
|
1098
|
+
return this.exclusively(async () => {
|
|
1099
|
+
const plan = await this.plan(input);
|
|
1100
|
+
this.abort.signal.throwIfAborted();
|
|
1101
|
+
if (!input.acceptLicenses)
|
|
1102
|
+
throw new DeviceError(
|
|
1103
|
+
"invalid-input",
|
|
1104
|
+
"Accept the component licenses before preparing a simulator"
|
|
1105
|
+
);
|
|
1106
|
+
if (!plan.supported)
|
|
1107
|
+
throw new DeviceError(
|
|
1108
|
+
"platform-unavailable",
|
|
1109
|
+
`Simulator preparation is unavailable: ${plan.reasonCode ?? "unsupported platform"}. See ${plan.guidanceUrl}`
|
|
1110
|
+
);
|
|
1111
|
+
if (plan.freeBytes === null || plan.freeBytes < plan.requiredFreeBytes)
|
|
1112
|
+
throw new DeviceError(
|
|
1113
|
+
"boot-disk-space",
|
|
1114
|
+
"There is not enough free disk space to prepare this simulator"
|
|
1115
|
+
);
|
|
1116
|
+
if (input.platform === "ios" && (!input.confirmAppleInstall || !/^\d+\.\d+(?:\.\d+)?$/.test(plan.version)))
|
|
1117
|
+
throw new DeviceError(
|
|
1118
|
+
"invalid-input",
|
|
1119
|
+
"Choose an explicit iOS version and confirm installation into Apple's shared simulator environment"
|
|
1120
|
+
);
|
|
1121
|
+
const release = await this.options.beforeMutation();
|
|
1122
|
+
try {
|
|
1123
|
+
await claimEnvironment(plan.root);
|
|
1124
|
+
await assertPrivateEnvironmentPaths(plan.root);
|
|
1125
|
+
const paths = environmentPaths(plan.root);
|
|
1126
|
+
for (const directory of [
|
|
1127
|
+
paths.cache,
|
|
1128
|
+
paths.temp,
|
|
1129
|
+
paths.user,
|
|
1130
|
+
NodePath3.join(plan.root, "home"),
|
|
1131
|
+
NodePath3.join(plan.root, "config")
|
|
1132
|
+
])
|
|
1133
|
+
await NodeFSP3.mkdir(directory, { recursive: true });
|
|
1134
|
+
if (input.platform === "ios") await this.prepareIos(plan, progress);
|
|
1135
|
+
else await this.prepareAndroid(plan, progress);
|
|
1136
|
+
} finally {
|
|
1137
|
+
await release?.();
|
|
1138
|
+
}
|
|
1139
|
+
await this.options.afterPrepared();
|
|
1140
|
+
return this.plan(input);
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
async prepareAndroid(plan, progress) {
|
|
1144
|
+
const probe = await this.probe();
|
|
1145
|
+
const archives = androidArchives(probe.platform, probe.arch);
|
|
1146
|
+
if (!archives) throw new Error("Android host target is unsupported");
|
|
1147
|
+
const paths = environmentPaths(plan.root);
|
|
1148
|
+
const staging = await NodeFSP3.mkdtemp(NodePath3.join(paths.temp, "prepare-"));
|
|
1149
|
+
try {
|
|
1150
|
+
const existing2 = await NodeFSP3.lstat(paths.sdk).catch(() => null);
|
|
1151
|
+
if (existing2) {
|
|
1152
|
+
if (existing2.isSymbolicLink() || !existing2.isDirectory() || await NodeFSP3.readFile(NodePath3.join(paths.sdk, ".ycodium-components"), "utf8").catch(
|
|
1153
|
+
() => ""
|
|
1154
|
+
) !== plan.version)
|
|
1155
|
+
throw new Error(
|
|
1156
|
+
"An unrecognized private SDK already exists; its installation will not be overwritten"
|
|
1157
|
+
);
|
|
1158
|
+
} else {
|
|
1159
|
+
for (const item of archives) {
|
|
1160
|
+
progress(item.name);
|
|
1161
|
+
this.abort.signal.throwIfAborted();
|
|
1162
|
+
const file = await downloadArchive(
|
|
1163
|
+
item,
|
|
1164
|
+
paths.cache,
|
|
1165
|
+
(url2, init) => this.options.fetch(url2, { ...init, signal: this.abort.signal })
|
|
1166
|
+
);
|
|
1167
|
+
await extractEnvironmentZip(
|
|
1168
|
+
file,
|
|
1169
|
+
item.destination === "sdk" ? staging : NodePath3.join(staging, "system-images", "android-35", "default"),
|
|
1170
|
+
this.abort.signal
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
await NodeFSP3.writeFile(NodePath3.join(staging, ".ycodium-components"), plan.version);
|
|
1174
|
+
await NodeFSP3.rename(staging, paths.sdk);
|
|
1175
|
+
}
|
|
1176
|
+
await this.createPrivateAvd(paths.root, probe.arch === "arm64" ? "arm64-v8a" : "x86_64");
|
|
1177
|
+
this.abort.signal.throwIfAborted();
|
|
1178
|
+
progress("validate");
|
|
1179
|
+
const validated = await this.options.process.run({
|
|
1180
|
+
program: "host:node",
|
|
1181
|
+
args: [this.options.helperPath, "validate-android", "--sdk", paths.sdk],
|
|
1182
|
+
cwd: this.options.dataDir,
|
|
1183
|
+
timeoutMs: 6e4
|
|
1184
|
+
});
|
|
1185
|
+
if (validated.exitCode !== 0)
|
|
1186
|
+
throw new Error(`The private SDK could not be validated. ${validated.stderr.trim()}`);
|
|
1187
|
+
await NodeFSP3.writeFile(NodePath3.join(plan.root, ".prepared"), plan.version);
|
|
1188
|
+
await NodeFSP3.writeFile(
|
|
1189
|
+
NodePath3.join(this.options.dataDir, "android-environment.json"),
|
|
1190
|
+
JSON.stringify({ root: plan.root })
|
|
1191
|
+
);
|
|
1192
|
+
} finally {
|
|
1193
|
+
await NodeFSP3.rm(staging, { recursive: true, force: true });
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
async createPrivateAvd(root, abi) {
|
|
1197
|
+
const paths = environmentPaths(root);
|
|
1198
|
+
const name = "Ycodium_API_35";
|
|
1199
|
+
const avd = NodePath3.join(paths.avd, `${name}.avd`);
|
|
1200
|
+
await assertPrivatePath(root, avd);
|
|
1201
|
+
await assertPrivatePath(root, NodePath3.join(paths.avd, `${name}.ini`));
|
|
1202
|
+
const existing2 = await NodeFSP3.lstat(avd).catch(() => null);
|
|
1203
|
+
if (existing2) {
|
|
1204
|
+
if (existing2.isSymbolicLink() || !existing2.isDirectory())
|
|
1205
|
+
throw new Error("Private virtual device is not a directory");
|
|
1206
|
+
await NodeFSP3.access(NodePath3.join(avd, "config.ini"));
|
|
1207
|
+
await NodeFSP3.access(NodePath3.join(paths.avd, `${name}.ini`));
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
await NodeFSP3.mkdir(avd, { recursive: true });
|
|
1211
|
+
const config = {
|
|
1212
|
+
AvdId: name,
|
|
1213
|
+
"avd.ini.displayname": "Ycodium Android 15",
|
|
1214
|
+
"abi.type": abi,
|
|
1215
|
+
"hw.cpu.arch": abi === "arm64-v8a" ? "arm64" : "x86_64",
|
|
1216
|
+
"hw.cpu.ncore": "2",
|
|
1217
|
+
"hw.ramSize": "2048",
|
|
1218
|
+
"hw.lcd.width": "1080",
|
|
1219
|
+
"hw.lcd.height": "1920",
|
|
1220
|
+
"hw.lcd.density": "420",
|
|
1221
|
+
"hw.gpu.enabled": "yes",
|
|
1222
|
+
"hw.gpu.mode": "auto",
|
|
1223
|
+
"hw.keyboard": "yes",
|
|
1224
|
+
"disk.dataPartition.size": "4G",
|
|
1225
|
+
"image.sysdir.1": `system-images/android-35/default/${abi}/`,
|
|
1226
|
+
"tag.id": "default",
|
|
1227
|
+
"tag.display": "Default Android System Image",
|
|
1228
|
+
"PlayStore.enabled": "false"
|
|
1229
|
+
};
|
|
1230
|
+
await NodeFSP3.writeFile(
|
|
1231
|
+
NodePath3.join(avd, "config.ini"),
|
|
1232
|
+
Object.entries(config).map(([key2, value]) => `${key2}=${value}`).join("\n") + "\n",
|
|
1233
|
+
{ flag: "wx" }
|
|
1234
|
+
);
|
|
1235
|
+
await NodeFSP3.writeFile(
|
|
1236
|
+
NodePath3.join(paths.avd, `${name}.ini`),
|
|
1237
|
+
`avd.ini.encoding=UTF-8
|
|
1238
|
+
path=${avd}
|
|
1239
|
+
target=android-35
|
|
1240
|
+
`,
|
|
1241
|
+
{ flag: "wx" }
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
async prepareIos(plan, progress) {
|
|
1245
|
+
if (plan.ready) return;
|
|
1246
|
+
const cache = NodePath3.join(plan.root, "cache", `ios-${plan.version}`);
|
|
1247
|
+
await NodeFSP3.mkdir(cache, { recursive: true });
|
|
1248
|
+
progress(`iOS ${plan.version}`);
|
|
1249
|
+
if (await this.runApple([
|
|
1250
|
+
"-downloadPlatform",
|
|
1251
|
+
"iOS",
|
|
1252
|
+
"-exportPath",
|
|
1253
|
+
cache,
|
|
1254
|
+
"-buildVersion",
|
|
1255
|
+
plan.version
|
|
1256
|
+
]) !== 0)
|
|
1257
|
+
throw new Error(
|
|
1258
|
+
"Apple could not download this iOS runtime; check the selected Xcode version, network and Apple account in Xcode"
|
|
1259
|
+
);
|
|
1260
|
+
const names = (await NodeFSP3.readdir(cache)).filter((name) => name.endsWith(".dmg"));
|
|
1261
|
+
if (names.length !== 1)
|
|
1262
|
+
throw new Error("Apple did not return a unique simulator runtime archive");
|
|
1263
|
+
progress("apple-install");
|
|
1264
|
+
if (await this.runApple(["-importPlatform", NodePath3.join(cache, names[0])]) !== 0)
|
|
1265
|
+
throw new Error(
|
|
1266
|
+
"Apple could not import the runtime. Complete any required permissions in Xcode; Ycodium will not elevate privileges"
|
|
1267
|
+
);
|
|
1268
|
+
if (!(await this.probe()).xcode.runtimes.some((runtime) => runtime.version === plan.version))
|
|
1269
|
+
throw new Error("The requested iOS runtime is still unavailable after Apple's import");
|
|
1270
|
+
}
|
|
1271
|
+
async runApple(args) {
|
|
1272
|
+
this.abort.signal.throwIfAborted();
|
|
1273
|
+
const handle = await this.options.process.spawn({
|
|
1274
|
+
program: "host:node",
|
|
1275
|
+
args: [this.options.helperPath, "run", "--", "xcodebuild", ...args],
|
|
1276
|
+
cwd: this.options.dataDir
|
|
1277
|
+
});
|
|
1278
|
+
const signal = AbortSignal.any([this.abort.signal, AbortSignal.timeout(36e5)]);
|
|
1279
|
+
const terminate = () => {
|
|
1280
|
+
void handle.kill().catch(() => void 0);
|
|
1281
|
+
};
|
|
1282
|
+
signal.addEventListener("abort", terminate, { once: true });
|
|
1283
|
+
if (signal.aborted) terminate();
|
|
1284
|
+
const drain = async (stream) => {
|
|
1285
|
+
for await (const _chunk of stream) {
|
|
1286
|
+
}
|
|
1287
|
+
};
|
|
1288
|
+
try {
|
|
1289
|
+
const [code] = await Promise.all([handle.exited, drain(handle.stdout), drain(handle.stderr)]);
|
|
1290
|
+
signal.throwIfAborted();
|
|
1291
|
+
return code;
|
|
1292
|
+
} finally {
|
|
1293
|
+
signal.removeEventListener("abort", terminate);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
cleanup(input) {
|
|
1297
|
+
return this.exclusively(async () => {
|
|
1298
|
+
if (!input.confirm || input.platform === "ios" && input.category === "devices")
|
|
1299
|
+
throw new DeviceError(
|
|
1300
|
+
"invalid-input",
|
|
1301
|
+
"Confirm cleanup of private download cache or Android virtual device data; Apple runtimes are managed in Xcode"
|
|
1302
|
+
);
|
|
1303
|
+
const release = input.category === "devices" ? await this.options.beforeMutation() : void 0;
|
|
1304
|
+
try {
|
|
1305
|
+
const plan = await this.plan(input);
|
|
1306
|
+
const releasedBytes = await cleanEnvironment(plan.root, input.category);
|
|
1307
|
+
await this.options.afterPrepared();
|
|
1308
|
+
return { releasedBytes };
|
|
1309
|
+
} finally {
|
|
1310
|
+
await release?.();
|
|
1311
|
+
}
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
};
|
|
1315
|
+
|
|
1316
|
+
// src/environmentCommands.ts
|
|
1317
|
+
var properties = {
|
|
1318
|
+
platform: { type: "string", enum: ["android", "ios"] },
|
|
1319
|
+
directory: { type: "string" },
|
|
1320
|
+
version: { type: "string" }
|
|
1321
|
+
};
|
|
1322
|
+
var schema = (extra = {}, required = []) => ({
|
|
1323
|
+
type: "object",
|
|
1324
|
+
additionalProperties: false,
|
|
1325
|
+
properties: { ...properties, ...extra },
|
|
1326
|
+
required: ["platform", ...required]
|
|
1327
|
+
});
|
|
1328
|
+
var request = (payload) => ({
|
|
1329
|
+
platform: payload["platform"] === "ios" ? "ios" : "android",
|
|
1330
|
+
...typeof payload["directory"] === "string" ? { directory: payload["directory"] } : {},
|
|
1331
|
+
...typeof payload["version"] === "string" ? { version: payload["version"] } : {}
|
|
1332
|
+
});
|
|
1333
|
+
var registerEnvironmentCommands = (ctx, environments) => {
|
|
1334
|
+
ctx.command({
|
|
1335
|
+
name: "environmentPlan",
|
|
1336
|
+
description: "Inspect simulator prerequisites, selected storage, download size and licenses without downloading or modifying the machine.",
|
|
1337
|
+
scope: "read",
|
|
1338
|
+
payloadSchema: schema(),
|
|
1339
|
+
handle: (payload) => environments.plan(request(payload))
|
|
1340
|
+
});
|
|
1341
|
+
ctx.command({
|
|
1342
|
+
name: "prepareEnvironment",
|
|
1343
|
+
description: "Start user-triggered simulator preparation. Progress is read from environmentProgress; command completion only acknowledges the request.",
|
|
1344
|
+
scope: "operate",
|
|
1345
|
+
payloadSchema: schema(
|
|
1346
|
+
{ acceptLicenses: { type: "boolean" }, confirmAppleInstall: { type: "boolean" } },
|
|
1347
|
+
["acceptLicenses", "confirmAppleInstall"]
|
|
1348
|
+
),
|
|
1349
|
+
handle: (payload) => environments.start({
|
|
1350
|
+
...request(payload),
|
|
1351
|
+
acceptLicenses: payload["acceptLicenses"] === true,
|
|
1352
|
+
confirmAppleInstall: payload["confirmAppleInstall"] === true
|
|
1353
|
+
})
|
|
1354
|
+
});
|
|
1355
|
+
ctx.stream({
|
|
1356
|
+
name: "environmentProgress",
|
|
1357
|
+
description: "Current user-triggered simulator preparation progress; subscribing does not start preparation.",
|
|
1358
|
+
scope: "read",
|
|
1359
|
+
payloadSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
1360
|
+
handle: async (_payload, emit, signal) => {
|
|
1361
|
+
const unsubscribe = environments.subscribe((progress) => {
|
|
1362
|
+
if (!signal.aborted) emit(progress);
|
|
1363
|
+
});
|
|
1364
|
+
try {
|
|
1365
|
+
await new Promise((resolve3) => {
|
|
1366
|
+
if (signal.aborted) resolve3();
|
|
1367
|
+
else signal.addEventListener("abort", () => resolve3(), { once: true });
|
|
1368
|
+
});
|
|
1369
|
+
} finally {
|
|
1370
|
+
unsubscribe();
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
});
|
|
1374
|
+
ctx.command({
|
|
1375
|
+
name: "cleanupEnvironment",
|
|
1376
|
+
description: "User-confirmed cleanup of private download cache or Android virtual device data. Never deletes external SDKs, external virtual devices or Apple runtimes.",
|
|
1377
|
+
scope: "operate",
|
|
1378
|
+
payloadSchema: schema(
|
|
1379
|
+
{ category: { type: "string", enum: ["cache", "devices"] }, confirm: { type: "boolean" } },
|
|
1380
|
+
["category", "confirm"]
|
|
1381
|
+
),
|
|
1382
|
+
handle: (payload) => environments.cleanup({
|
|
1383
|
+
...request(payload),
|
|
1384
|
+
category: payload["category"] === "devices" ? "devices" : "cache",
|
|
1385
|
+
confirm: payload["confirm"] === true
|
|
1386
|
+
})
|
|
1387
|
+
});
|
|
1388
|
+
};
|
|
1389
|
+
|
|
1390
|
+
// src/probe.ts
|
|
1391
|
+
var MIN_NODE_VERSION = [22, 12, 0];
|
|
1392
|
+
var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1393
|
+
var parseMachineProbe = (stdout) => {
|
|
1394
|
+
const line = stdout.trim().split(/\r?\n/).at(-1) ?? "";
|
|
1395
|
+
let value;
|
|
1396
|
+
try {
|
|
1397
|
+
value = JSON.parse(line);
|
|
1398
|
+
} catch {
|
|
1399
|
+
return null;
|
|
1400
|
+
}
|
|
1401
|
+
if (!isRecord2(value) || !isRecord2(value["android"])) return null;
|
|
1402
|
+
const android = value["android"];
|
|
1403
|
+
if (typeof value["node"] !== "string" || typeof value["execPath"] !== "string" || typeof value["platform"] !== "string") {
|
|
1404
|
+
return null;
|
|
1405
|
+
}
|
|
1406
|
+
return {
|
|
1407
|
+
node: value["node"],
|
|
1408
|
+
execPath: value["execPath"],
|
|
1409
|
+
electron: typeof value["electron"] === "string" ? value["electron"] : null,
|
|
1410
|
+
platform: value["platform"],
|
|
1411
|
+
...typeof value["arch"] === "string" ? { arch: value["arch"] } : {},
|
|
1412
|
+
xcrun: value["xcrun"] === true,
|
|
1413
|
+
...typeof value["androidValidated"] === "boolean" ? { androidValidated: value["androidValidated"] } : {},
|
|
1414
|
+
...typeof value["iosRuntimeAvailable"] === "boolean" ? { iosRuntimeAvailable: value["iosRuntimeAvailable"] } : {},
|
|
1415
|
+
...typeof value["iosToolsAvailable"] === "boolean" ? { iosToolsAvailable: value["iosToolsAvailable"] } : {},
|
|
1416
|
+
android: {
|
|
1417
|
+
root: typeof android["root"] === "string" ? android["root"] : null,
|
|
1418
|
+
adb: android["adb"] === true,
|
|
1419
|
+
emulator: android["emulator"] === true,
|
|
1420
|
+
avdmanager: android["avdmanager"] === true,
|
|
1421
|
+
legacyAvdmanager: android["legacyAvdmanager"] === true
|
|
1422
|
+
}
|
|
1423
|
+
};
|
|
1424
|
+
};
|
|
1425
|
+
var nodeVersionSupported = (version) => {
|
|
1426
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(version);
|
|
1427
|
+
if (!match) return false;
|
|
1428
|
+
const parts = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
1429
|
+
for (let index = 0; index < MIN_NODE_VERSION.length; index += 1) {
|
|
1430
|
+
const actual = parts[index];
|
|
1431
|
+
const required = MIN_NODE_VERSION[index];
|
|
1432
|
+
if (actual !== required) return actual > required;
|
|
1433
|
+
}
|
|
1434
|
+
return true;
|
|
1435
|
+
};
|
|
1436
|
+
var unavailable = (platform2, reasonCode, reason, sdkRoot) => ({
|
|
1437
|
+
platform: platform2,
|
|
1438
|
+
available: false,
|
|
1439
|
+
reasonCode,
|
|
1440
|
+
reason,
|
|
1441
|
+
...sdkRoot === void 0 ? {} : { sdkRoot }
|
|
1442
|
+
});
|
|
1443
|
+
var platformAvailability = (probe, platform2) => {
|
|
1444
|
+
if (platform2 === "ios") {
|
|
1445
|
+
if (probe.platform !== "darwin") {
|
|
1446
|
+
return unavailable("ios", "ios-needs-macos", "iOS Simulators need macOS with Xcode.");
|
|
1447
|
+
}
|
|
1448
|
+
if (probe.iosToolsAvailable === false)
|
|
1449
|
+
return unavailable(
|
|
1450
|
+
"ios",
|
|
1451
|
+
"ios-tools-missing",
|
|
1452
|
+
"The bundled iOS Simulator streaming helpers are missing or could not load. Repair or update Ycodium on this machine."
|
|
1453
|
+
);
|
|
1454
|
+
if (!probe.xcrun) {
|
|
1455
|
+
return unavailable("ios", "xcode-missing", "Xcode command line tools were not found.");
|
|
1456
|
+
}
|
|
1457
|
+
if (probe.iosRuntimeAvailable === false)
|
|
1458
|
+
return unavailable(
|
|
1459
|
+
"ios",
|
|
1460
|
+
"ios-runtime-missing",
|
|
1461
|
+
"An iOS Simulator runtime is missing. Choose an explicit version in simulator preparation or install one in Xcode Settings."
|
|
1462
|
+
);
|
|
1463
|
+
return { platform: platform2, available: true };
|
|
1464
|
+
}
|
|
1465
|
+
const sdk = probe.android;
|
|
1466
|
+
if (!sdk.root) {
|
|
1467
|
+
return unavailable(
|
|
1468
|
+
"android",
|
|
1469
|
+
"android-sdk-missing",
|
|
1470
|
+
"Android SDK was not found. Install it with Android Studio or set its location in the Devices settings."
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
if (!sdk.adb) {
|
|
1474
|
+
return unavailable(
|
|
1475
|
+
"android",
|
|
1476
|
+
"android-platform-tools-missing",
|
|
1477
|
+
`Android SDK Platform-Tools are missing from ${sdk.root}. Install them in Android Studio's SDK Manager.`,
|
|
1478
|
+
sdk.root
|
|
1479
|
+
);
|
|
1480
|
+
}
|
|
1481
|
+
if (!sdk.emulator) {
|
|
1482
|
+
return unavailable(
|
|
1483
|
+
"android",
|
|
1484
|
+
"android-emulator-missing",
|
|
1485
|
+
`Android Emulator is missing from ${sdk.root}. Install it in Android Studio's SDK Manager.`,
|
|
1486
|
+
sdk.root
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
if (probe.androidValidated === false)
|
|
1490
|
+
return unavailable(
|
|
1491
|
+
"android",
|
|
1492
|
+
"android-sdk-invalid",
|
|
1493
|
+
"The Android SDK tools could not run. Check the SDK installation or prepare a private environment.",
|
|
1494
|
+
sdk.root
|
|
1495
|
+
);
|
|
1496
|
+
return { platform: platform2, available: true, sdkRoot: sdk.root };
|
|
1497
|
+
};
|
|
1498
|
+
var probeMachine = async (input) => {
|
|
1499
|
+
const args = [input.helperPath, "probe"];
|
|
1500
|
+
if (input.runtimeDir) args.push("--tools", input.runtimeDir);
|
|
1501
|
+
if (input.sdkOverride.trim().length > 0) args.push("--sdk", input.sdkOverride.trim());
|
|
1502
|
+
const result = await input.process.run({ program: "host:node", args, cwd: input.cwd, timeoutMs: 3e4 }).catch((error) => {
|
|
1503
|
+
throw new DeviceError(
|
|
1504
|
+
"node-unavailable",
|
|
1505
|
+
`Could not run Node.js to check device support: ${errorDetail(error)}`,
|
|
1506
|
+
{ cause: error }
|
|
1507
|
+
);
|
|
1508
|
+
});
|
|
1509
|
+
const probe = result.exitCode === 0 ? parseMachineProbe(result.stdout) : null;
|
|
1510
|
+
if (!probe) {
|
|
1511
|
+
throw new DeviceError(
|
|
1512
|
+
"node-unavailable",
|
|
1513
|
+
`Checking device support failed: ${result.stderr.trim() || "the helper returned no result"}.`
|
|
1514
|
+
);
|
|
1515
|
+
}
|
|
1516
|
+
if (!nodeVersionSupported(probe.node)) {
|
|
1517
|
+
throw new DeviceError(
|
|
1518
|
+
"node-unavailable",
|
|
1519
|
+
`Device support requires Node.js 22.12 or newer; this Ycodium runs on ${probe.node}. Update Ycodium on this machine.`
|
|
1520
|
+
);
|
|
1521
|
+
}
|
|
1522
|
+
return probe;
|
|
1523
|
+
};
|
|
1524
|
+
|
|
1525
|
+
// src/localHost.ts
|
|
1526
|
+
import * as NodePath8 from "node:path";
|
|
1527
|
+
|
|
540
1528
|
// src/agentDevice.ts
|
|
541
|
-
import * as
|
|
542
|
-
import * as
|
|
543
|
-
import * as
|
|
1529
|
+
import * as NodeCrypto2 from "node:crypto";
|
|
1530
|
+
import * as NodeFSP4 from "node:fs/promises";
|
|
1531
|
+
import * as NodePath4 from "node:path";
|
|
544
1532
|
var DAEMON_READY_TIMEOUT_MS = 3e4;
|
|
545
1533
|
var DAEMON_POLL_MS = 100;
|
|
546
|
-
var key = (value) =>
|
|
547
|
-
var agentDeviceConfigPath = (dataDir, hostId) =>
|
|
1534
|
+
var key = (value) => NodeCrypto2.createHash("sha256").update(value).digest("hex").slice(0, 24);
|
|
1535
|
+
var agentDeviceConfigPath = (dataDir, hostId) => NodePath4.join(dataDir, "hosts", `${key(hostId)}.json`);
|
|
548
1536
|
var agentDeviceSession = (threadId, hostId, deviceId) => `ycodium-${key(JSON.stringify([threadId, hostId, deviceId]))}`;
|
|
549
1537
|
var agentDeviceTargetArgs = (device) => device.platform === "ios" ? ["--platform", "ios", "--udid", device.id] : ["--platform", "android", "--serial", device.id];
|
|
550
1538
|
var writeAgentDeviceConfig = async (file, endpoint) => {
|
|
551
|
-
await
|
|
1539
|
+
await NodeFSP4.mkdir(NodePath4.dirname(file), { recursive: true });
|
|
552
1540
|
const content = JSON.stringify({
|
|
553
1541
|
daemonBaseUrl: endpoint.baseUrl,
|
|
554
1542
|
daemonAuthToken: endpoint.token
|
|
555
1543
|
});
|
|
556
|
-
const existing2 = await
|
|
1544
|
+
const existing2 = await NodeFSP4.readFile(file, "utf8").catch(() => "");
|
|
557
1545
|
if (existing2 === content) return;
|
|
558
|
-
const temporary =
|
|
559
|
-
|
|
560
|
-
`.endpoint-${
|
|
1546
|
+
const temporary = NodePath4.join(
|
|
1547
|
+
NodePath4.dirname(file),
|
|
1548
|
+
`.endpoint-${NodeCrypto2.randomUUID()}.json`
|
|
561
1549
|
);
|
|
562
1550
|
try {
|
|
563
|
-
await
|
|
564
|
-
await
|
|
1551
|
+
await NodeFSP4.writeFile(temporary, content, { mode: 384 });
|
|
1552
|
+
await NodeFSP4.rename(temporary, file);
|
|
565
1553
|
} finally {
|
|
566
|
-
await
|
|
1554
|
+
await NodeFSP4.rm(temporary, { force: true }).catch(() => void 0);
|
|
567
1555
|
}
|
|
568
1556
|
};
|
|
569
1557
|
var LAUNCHER_DIR = "bin";
|
|
570
1558
|
var ensureAgentDeviceLauncher = async (input) => {
|
|
571
|
-
const directory =
|
|
572
|
-
await
|
|
573
|
-
const launcherPath =
|
|
574
|
-
await
|
|
1559
|
+
const directory = NodePath4.join(input.dataDir, LAUNCHER_DIR);
|
|
1560
|
+
await NodeFSP4.mkdir(directory, { recursive: true });
|
|
1561
|
+
const launcherPath = NodePath4.join(directory, "agent-device-launcher.mjs");
|
|
1562
|
+
await NodeFSP4.writeFile(
|
|
575
1563
|
launcherPath,
|
|
576
1564
|
`import { spawn } from "node:child_process";
|
|
577
1565
|
const args = process.argv.slice(2);
|
|
@@ -589,15 +1577,16 @@ const env = { ...process.env };
|
|
|
589
1577
|
delete env.AGENT_DEVICE_DAEMON_BASE_URL;
|
|
590
1578
|
delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN;
|
|
591
1579
|
delete env.AGENT_DEVICE_CONFIG;
|
|
592
|
-
const
|
|
1580
|
+
const prefix = ${JSON.stringify(input.helperPath ? [input.helperPath, "node", ...input.sdkRoot ? ["--sdk", input.sdkRoot] : [], "--", input.entryPath] : [input.entryPath])};
|
|
1581
|
+
const child = spawn(${JSON.stringify(input.nodePath)}, [...prefix, ...args], { stdio: "inherit", env, windowsHide: true });
|
|
593
1582
|
child.on("error", error => { console.error(error.message); process.exitCode = 1; });
|
|
594
1583
|
child.on("exit", code => { process.exitCode = code ?? 1; });
|
|
595
1584
|
`
|
|
596
1585
|
);
|
|
597
1586
|
if (input.platform === "win32") {
|
|
598
|
-
const command2 =
|
|
1587
|
+
const command2 = NodePath4.join(directory, "agent-device.cmd");
|
|
599
1588
|
const electron2 = input.electron ? "set ELECTRON_RUN_AS_NODE=1\r\n" : "";
|
|
600
|
-
await
|
|
1589
|
+
await NodeFSP4.writeFile(
|
|
601
1590
|
command2,
|
|
602
1591
|
`@echo off\r
|
|
603
1592
|
${electron2}"${input.nodePath}" "${launcherPath}" %*\r
|
|
@@ -606,15 +1595,15 @@ ${electron2}"${input.nodePath}" "${launcherPath}" %*\r
|
|
|
606
1595
|
return command2;
|
|
607
1596
|
}
|
|
608
1597
|
const quote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
609
|
-
const command =
|
|
1598
|
+
const command = NodePath4.join(directory, "agent-device");
|
|
610
1599
|
const electron = input.electron ? "ELECTRON_RUN_AS_NODE=1 " : "";
|
|
611
|
-
await
|
|
1600
|
+
await NodeFSP4.writeFile(
|
|
612
1601
|
command,
|
|
613
1602
|
`#!/bin/sh
|
|
614
1603
|
${electron}exec ${quote(input.nodePath)} ${quote(launcherPath)} "$@"
|
|
615
1604
|
`
|
|
616
1605
|
);
|
|
617
|
-
await
|
|
1606
|
+
await NodeFSP4.chmod(command, 493);
|
|
618
1607
|
return command;
|
|
619
1608
|
};
|
|
620
1609
|
var parseDaemonFile = (raw) => {
|
|
@@ -634,7 +1623,7 @@ var parseDaemonFile = (raw) => {
|
|
|
634
1623
|
version: typeof record["version"] === "string" ? record["version"] : void 0
|
|
635
1624
|
};
|
|
636
1625
|
};
|
|
637
|
-
var defaultSleep = (ms) => new Promise((
|
|
1626
|
+
var defaultSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
638
1627
|
var daemonEnvironment = (stateDir) => ({
|
|
639
1628
|
AGENT_DEVICE_STATE_DIR: stateDir,
|
|
640
1629
|
AGENT_DEVICE_DAEMON_SERVER_MODE: "http",
|
|
@@ -654,10 +1643,10 @@ var AgentDaemon = class {
|
|
|
654
1643
|
this.sleep = options.sleep ?? defaultSleep;
|
|
655
1644
|
}
|
|
656
1645
|
get daemonFilePath() {
|
|
657
|
-
return
|
|
1646
|
+
return NodePath4.join(this.options.stateDir, "daemon.json");
|
|
658
1647
|
}
|
|
659
1648
|
async readDaemonFile() {
|
|
660
|
-
const raw = await
|
|
1649
|
+
const raw = await NodeFSP4.readFile(this.daemonFilePath, "utf8").catch(() => null);
|
|
661
1650
|
return raw === null ? null : parseDaemonFile(raw);
|
|
662
1651
|
}
|
|
663
1652
|
async healthy(port) {
|
|
@@ -686,11 +1675,11 @@ var AgentDaemon = class {
|
|
|
686
1675
|
*/
|
|
687
1676
|
async ensure(input) {
|
|
688
1677
|
this.entryPath = input.entryPath;
|
|
689
|
-
await
|
|
1678
|
+
await NodeFSP4.mkdir(this.options.stateDir, { recursive: true });
|
|
690
1679
|
const existing2 = await this.readDaemonFile();
|
|
691
1680
|
if (existing2 !== null) {
|
|
692
1681
|
if (await this.healthy(existing2.httpPort)) return this.toEndpoint(existing2, input.entryPath);
|
|
693
|
-
await
|
|
1682
|
+
await NodeFSP4.rm(this.daemonFilePath, { force: true }).catch(() => void 0);
|
|
694
1683
|
}
|
|
695
1684
|
const args = [this.options.helperPath, "node"];
|
|
696
1685
|
if (input.sdkRoot !== null) args.push("--sdk", input.sdkRoot);
|
|
@@ -752,8 +1741,8 @@ var agentDeviceQuickStart = (device, targetArgs, command) => {
|
|
|
752
1741
|
};
|
|
753
1742
|
|
|
754
1743
|
// src/hubProcess.ts
|
|
755
|
-
import * as
|
|
756
|
-
import * as
|
|
1744
|
+
import * as NodeFSP5 from "node:fs/promises";
|
|
1745
|
+
import * as NodePath5 from "node:path";
|
|
757
1746
|
|
|
758
1747
|
// ../../packages/ycodium-fetch-ports/src/fetchPorts.ts
|
|
759
1748
|
import * as NodeNet from "node:net";
|
|
@@ -844,12 +1833,12 @@ var FETCH_BLOCKED_PORTS = /* @__PURE__ */ new Set([
|
|
|
844
1833
|
var isFetchSafePort = (port) => !FETCH_BLOCKED_PORTS.has(port);
|
|
845
1834
|
var MAX_SAFE_PORT_ATTEMPTS = 50;
|
|
846
1835
|
var systemAssigned = () => 0;
|
|
847
|
-
var listenOnce = (server, host, port) => new Promise((
|
|
1836
|
+
var listenOnce = (server, host, port) => new Promise((resolve3, reject) => {
|
|
848
1837
|
server.once("error", reject);
|
|
849
1838
|
server.listen(port, host, () => {
|
|
850
1839
|
server.off("error", reject);
|
|
851
1840
|
const address = server.address();
|
|
852
|
-
|
|
1841
|
+
resolve3(typeof address === "object" && address !== null ? address.port : 0);
|
|
853
1842
|
});
|
|
854
1843
|
});
|
|
855
1844
|
var listenOnSafePort = async (server, options = {}) => {
|
|
@@ -858,7 +1847,7 @@ var listenOnSafePort = async (server, options = {}) => {
|
|
|
858
1847
|
for (let attempt = 0; attempt < MAX_SAFE_PORT_ATTEMPTS; attempt++) {
|
|
859
1848
|
const port = await listenOnce(server, host, portForAttempt(attempt));
|
|
860
1849
|
if (isFetchSafePort(port)) return port;
|
|
861
|
-
await new Promise((
|
|
1850
|
+
await new Promise((resolve3) => server.close(() => resolve3()));
|
|
862
1851
|
}
|
|
863
1852
|
throw new Error(`\u8FDE\u7EED ${String(MAX_SAFE_PORT_ATTEMPTS)} \u6B21\u5206\u5230 fetch \u7684\u574F\u7AEF\u53E3`);
|
|
864
1853
|
};
|
|
@@ -866,7 +1855,7 @@ var reserveFetchSafePort = async (options = {}) => {
|
|
|
866
1855
|
const probe = NodeNet.createServer();
|
|
867
1856
|
try {
|
|
868
1857
|
const port = await listenOnSafePort(probe, options);
|
|
869
|
-
await new Promise((
|
|
1858
|
+
await new Promise((resolve3) => probe.close(() => resolve3()));
|
|
870
1859
|
if (port <= 0) throw new Error("\u65E0\u6CD5\u53D6\u5F97\u76D1\u542C\u7AEF\u53E3");
|
|
871
1860
|
return port;
|
|
872
1861
|
} catch (cause) {
|
|
@@ -900,7 +1889,7 @@ var nodeProcessProbe = {
|
|
|
900
1889
|
}
|
|
901
1890
|
}
|
|
902
1891
|
};
|
|
903
|
-
var defaultSleep2 = (ms) => new Promise((
|
|
1892
|
+
var defaultSleep2 = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
904
1893
|
var parseBreakawayWarning = (line) => {
|
|
905
1894
|
try {
|
|
906
1895
|
const record = JSON.parse(line);
|
|
@@ -953,7 +1942,7 @@ var HubProcess = class {
|
|
|
953
1942
|
this.sleep = options.sleep ?? defaultSleep2;
|
|
954
1943
|
}
|
|
955
1944
|
get statePath() {
|
|
956
|
-
return
|
|
1945
|
+
return NodePath5.join(this.options.stateDir, "hub.json");
|
|
957
1946
|
}
|
|
958
1947
|
get running() {
|
|
959
1948
|
return this.current?.hub ?? null;
|
|
@@ -974,7 +1963,7 @@ var HubProcess = class {
|
|
|
974
1963
|
let previous = null;
|
|
975
1964
|
try {
|
|
976
1965
|
const parsed = JSON.parse(
|
|
977
|
-
await
|
|
1966
|
+
await NodeFSP5.readFile(this.statePath, "utf8")
|
|
978
1967
|
);
|
|
979
1968
|
if (Number.isSafeInteger(parsed.pid) && Number.isSafeInteger(parsed.port) && typeof parsed.entryPath === "string") {
|
|
980
1969
|
previous = parsed;
|
|
@@ -991,18 +1980,18 @@ var HubProcess = class {
|
|
|
991
1980
|
this.probe.terminate(previous.pid);
|
|
992
1981
|
}
|
|
993
1982
|
}
|
|
994
|
-
await
|
|
1983
|
+
await NodeFSP5.rm(this.statePath, { force: true }).catch(() => void 0);
|
|
995
1984
|
}
|
|
996
1985
|
async spawnOnce(launch) {
|
|
997
1986
|
await this.reapStale();
|
|
998
|
-
await
|
|
1987
|
+
await NodeFSP5.mkdir(this.options.stateDir, { recursive: true });
|
|
999
1988
|
const port = await (this.options.reservePort ?? (() => reserveFetchSafePort()))();
|
|
1000
1989
|
const origin = `http://127.0.0.1:${String(port)}`;
|
|
1001
1990
|
const args = [
|
|
1002
1991
|
this.options.helperPath,
|
|
1003
1992
|
"node",
|
|
1004
1993
|
"--shim",
|
|
1005
|
-
|
|
1994
|
+
NodePath5.join(this.options.stateDir, "emulator-shim")
|
|
1006
1995
|
];
|
|
1007
1996
|
if (launch.sdkRoot !== null) args.push("--sdk", launch.sdkRoot);
|
|
1008
1997
|
args.push(
|
|
@@ -1053,7 +2042,7 @@ ${tail}` : ""}`
|
|
|
1053
2042
|
}
|
|
1054
2043
|
const hub = { origin, port, pid: handle.pid, startedAt: this.now() };
|
|
1055
2044
|
const record = { pid: handle.pid, port, entryPath: launch.entryPath };
|
|
1056
|
-
await
|
|
2045
|
+
await NodeFSP5.writeFile(this.statePath, JSON.stringify(record)).catch(() => void 0);
|
|
1057
2046
|
this.current = { hub, handle };
|
|
1058
2047
|
this.options.log.info("Device hub started", { pid: handle.pid, port });
|
|
1059
2048
|
this.options.onStarted?.(hub);
|
|
@@ -1090,115 +2079,21 @@ ${tail}` : ""}`
|
|
|
1090
2079
|
const current = this.current;
|
|
1091
2080
|
this.current = null;
|
|
1092
2081
|
if (current !== null) await current.handle.kill().catch(() => void 0);
|
|
1093
|
-
await
|
|
2082
|
+
await NodeFSP5.rm(this.statePath, { force: true }).catch(() => void 0);
|
|
1094
2083
|
}
|
|
1095
2084
|
};
|
|
1096
2085
|
|
|
1097
|
-
// src/
|
|
1098
|
-
import * as
|
|
1099
|
-
import * as
|
|
1100
|
-
|
|
1101
|
-
// src/npmRuntime.ts
|
|
1102
|
-
import * as NodeCrypto2 from "node:crypto";
|
|
1103
|
-
import * as NodeFSP3 from "node:fs/promises";
|
|
1104
|
-
import * as NodePath3 from "node:path";
|
|
1105
|
-
var MANAGED_NPM_VERSION = "11.21.0";
|
|
1106
|
-
var MANAGED_NPM_INTEGRITY = "sha512-Zov8KhamNneiLdELtj5YALtNmJW4L4fCLTzjfpzXG2w6MSHcf0UxgdlK5uloCuksWT+7mGUU7wi79cO6RqivPg==";
|
|
1107
|
-
var DEFAULT_REGISTRY = "https://registry.npmjs.org";
|
|
1108
|
-
var managedNpmPaths = (dataDir, version = MANAGED_NPM_VERSION) => {
|
|
1109
|
-
const installDir = NodePath3.join(dataDir, "npm", version);
|
|
1110
|
-
return {
|
|
1111
|
-
installDir,
|
|
1112
|
-
cliPath: NodePath3.join(installDir, "bin", "npm-cli.js"),
|
|
1113
|
-
sentinelPath: NodePath3.join(installDir, ".install-complete"),
|
|
1114
|
-
shimDir: NodePath3.join(dataDir, "npm", "node-shim")
|
|
1115
|
-
};
|
|
1116
|
-
};
|
|
1117
|
-
var integrityMatches = (bytes, integrity) => {
|
|
1118
|
-
const [algorithm, expected] = integrity.split("-", 2);
|
|
1119
|
-
if (algorithm !== "sha512" || expected === void 0) return false;
|
|
1120
|
-
return NodeCrypto2.createHash("sha512").update(bytes).digest("base64") === expected;
|
|
1121
|
-
};
|
|
1122
|
-
var fileExists = (file) => NodeFSP3.access(file).then(
|
|
1123
|
-
() => true,
|
|
1124
|
-
() => false
|
|
1125
|
-
);
|
|
1126
|
-
var failed = (step, detail) => new DeviceError(
|
|
1127
|
-
"install-failed",
|
|
1128
|
-
`Preparing npm ${MANAGED_NPM_VERSION} failed while ${step}.${detail ? ` ${detail}` : ""}`
|
|
1129
|
-
);
|
|
1130
|
-
var ensureManagedNpm = async (input) => {
|
|
1131
|
-
const paths = managedNpmPaths(input.dataDir);
|
|
1132
|
-
const sentinel = await NodeFSP3.readFile(paths.sentinelPath, "utf8").catch(() => null);
|
|
1133
|
-
if (sentinel?.trim() === MANAGED_NPM_VERSION && await fileExists(paths.cliPath)) return paths;
|
|
1134
|
-
const registry = (input.registry ?? DEFAULT_REGISTRY).replace(/\/+$/, "");
|
|
1135
|
-
const url2 = `${registry}/npm/-/npm-${MANAGED_NPM_VERSION}.tgz`;
|
|
1136
|
-
let bytes;
|
|
1137
|
-
try {
|
|
1138
|
-
const response = await input.fetch(url2, { signal: AbortSignal.timeout(5 * 6e4) });
|
|
1139
|
-
if (!response.ok)
|
|
1140
|
-
throw failed("downloading", `The registry answered ${String(response.status)}.`);
|
|
1141
|
-
bytes = new Uint8Array(await response.arrayBuffer());
|
|
1142
|
-
} catch (cause) {
|
|
1143
|
-
if (cause instanceof DeviceError) throw cause;
|
|
1144
|
-
throw failed("downloading", errorDetail(cause));
|
|
1145
|
-
}
|
|
1146
|
-
if (!integrityMatches(bytes, MANAGED_NPM_INTEGRITY)) {
|
|
1147
|
-
throw failed("verifying the download", "The archive does not match the pinned checksum.");
|
|
1148
|
-
}
|
|
1149
|
-
const parent = NodePath3.dirname(paths.installDir);
|
|
1150
|
-
await NodeFSP3.mkdir(parent, { recursive: true });
|
|
1151
|
-
const staging = await NodeFSP3.mkdtemp(NodePath3.join(parent, ".staging-"));
|
|
1152
|
-
const archive = NodePath3.join(
|
|
1153
|
-
parent,
|
|
1154
|
-
`.npm-${MANAGED_NPM_VERSION}-${NodeCrypto2.randomUUID()}.tgz`
|
|
1155
|
-
);
|
|
1156
|
-
try {
|
|
1157
|
-
await NodeFSP3.writeFile(archive, bytes);
|
|
1158
|
-
const result = await input.process.run({
|
|
1159
|
-
program: "host:node",
|
|
1160
|
-
args: [input.helperPath, "extract", "--", archive, staging],
|
|
1161
|
-
cwd: input.dataDir,
|
|
1162
|
-
timeoutMs: 12e4
|
|
1163
|
-
});
|
|
1164
|
-
if (result.exitCode !== 0) throw failed("unpacking", result.stderr.trim());
|
|
1165
|
-
if (!await fileExists(NodePath3.join(staging, "bin", "npm-cli.js"))) {
|
|
1166
|
-
throw failed("unpacking", "The archive has no bin/npm-cli.js.");
|
|
1167
|
-
}
|
|
1168
|
-
await NodeFSP3.writeFile(
|
|
1169
|
-
NodePath3.join(staging, ".install-complete"),
|
|
1170
|
-
`${MANAGED_NPM_VERSION}
|
|
1171
|
-
`
|
|
1172
|
-
);
|
|
1173
|
-
await NodeFSP3.rm(paths.installDir, { recursive: true, force: true });
|
|
1174
|
-
await NodeFSP3.rename(staging, paths.installDir);
|
|
1175
|
-
return paths;
|
|
1176
|
-
} finally {
|
|
1177
|
-
await NodeFSP3.rm(staging, { recursive: true, force: true }).catch(() => void 0);
|
|
1178
|
-
await NodeFSP3.rm(archive, { force: true }).catch(() => void 0);
|
|
1179
|
-
}
|
|
1180
|
-
};
|
|
1181
|
-
var runManagedNpm = (input) => input.process.run({
|
|
1182
|
-
program: "host:node",
|
|
1183
|
-
args: [
|
|
1184
|
-
input.helperPath,
|
|
1185
|
-
"npm",
|
|
1186
|
-
"--shim",
|
|
1187
|
-
input.npm.shimDir,
|
|
1188
|
-
"--",
|
|
1189
|
-
input.npm.cliPath,
|
|
1190
|
-
...input.args
|
|
1191
|
-
],
|
|
1192
|
-
cwd: input.cwd,
|
|
1193
|
-
timeoutMs: input.timeoutMs,
|
|
1194
|
-
maxOutputBytes: 256 * 1024
|
|
1195
|
-
});
|
|
2086
|
+
// src/serveSimHelpers.ts
|
|
2087
|
+
import * as NodeFSP7 from "node:fs/promises";
|
|
2088
|
+
import * as NodePath7 from "node:path";
|
|
1196
2089
|
|
|
1197
2090
|
// src/toolchain.ts
|
|
2091
|
+
import * as NodeFSP6 from "node:fs/promises";
|
|
2092
|
+
import * as NodePath6 from "node:path";
|
|
2093
|
+
import * as NodeOS from "node:os";
|
|
1198
2094
|
var DEVICE_HUB_VERSION = "0.12.0";
|
|
1199
2095
|
var AGENT_DEVICE_VERSION = "0.21.12";
|
|
1200
|
-
var
|
|
1201
|
-
var VERSION_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/;
|
|
2096
|
+
var runtimeTarget = `${NodeOS.platform()}-${NodeOS.arch()}`;
|
|
1202
2097
|
var HUB_SPEC = {
|
|
1203
2098
|
tool: "hub",
|
|
1204
2099
|
packageName: "expo-device-hub",
|
|
@@ -1212,358 +2107,57 @@ var AGENT_DEVICE_SPEC = {
|
|
|
1212
2107
|
entry: ["bin", "agent-device.mjs"]
|
|
1213
2108
|
};
|
|
1214
2109
|
var toolSpec = (tool) => tool === "hub" ? HUB_SPEC : AGENT_DEVICE_SPEC;
|
|
1215
|
-
var
|
|
1216
|
-
|
|
1217
|
-
const installDir = NodePath4.join(toolsRoot(dataDir), spec.packageName, version);
|
|
2110
|
+
var bundledToolPaths = (pluginDir, spec, runtimeDir) => {
|
|
2111
|
+
const installDir = runtimeDir ?? NodePath6.join(pluginDir, "runtime", runtimeTarget);
|
|
1218
2112
|
return {
|
|
1219
2113
|
installDir,
|
|
1220
|
-
entryPath:
|
|
1221
|
-
sentinelPath:
|
|
2114
|
+
entryPath: NodePath6.join(installDir, "node_modules", spec.packageName, ...spec.entry),
|
|
2115
|
+
sentinelPath: NodePath6.join(installDir, "node_modules", spec.packageName, "package.json")
|
|
1222
2116
|
};
|
|
1223
2117
|
};
|
|
1224
|
-
var agentDeviceStateDir = (dataDir) =>
|
|
1225
|
-
var
|
|
2118
|
+
var agentDeviceStateDir = (dataDir) => NodePath6.join(dataDir, "agent-device");
|
|
2119
|
+
var isToolInstalled = async (pluginDir, spec, runtimeDir) => {
|
|
2120
|
+
const paths = bundledToolPaths(pluginDir, spec, runtimeDir);
|
|
1226
2121
|
try {
|
|
1227
|
-
|
|
2122
|
+
const runtime = JSON.parse(
|
|
2123
|
+
await NodeFSP6.readFile(NodePath6.join(paths.installDir, "manifest.json"), "utf8")
|
|
2124
|
+
);
|
|
2125
|
+
if (typeof runtime !== "object" || runtime === null || !("schemaVersion" in runtime) || runtime.schemaVersion !== 1 || !("target" in runtime) || runtime.target !== runtimeTarget)
|
|
2126
|
+
return false;
|
|
2127
|
+
await NodeFSP6.access(NodePath6.join(paths.installDir, "inventory.json"));
|
|
2128
|
+
const manifest = JSON.parse(await NodeFSP6.readFile(paths.sentinelPath, "utf8"));
|
|
2129
|
+
if (typeof manifest !== "object" || manifest === null || !("version" in manifest) || manifest.version !== spec.version)
|
|
2130
|
+
return false;
|
|
2131
|
+
await NodeFSP6.access(paths.entryPath);
|
|
1228
2132
|
return true;
|
|
1229
2133
|
} catch {
|
|
1230
2134
|
return false;
|
|
1231
2135
|
}
|
|
1232
2136
|
};
|
|
1233
|
-
var
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
} catch {
|
|
1237
|
-
return null;
|
|
1238
|
-
}
|
|
1239
|
-
};
|
|
1240
|
-
var isInstalled = async (paths, version) => await readSentinel(paths.sentinelPath) === version && await fileExists2(paths.entryPath);
|
|
1241
|
-
var isToolInstalled = (dataDir, spec) => isInstalled(toolPaths(dataDir, spec), spec.version);
|
|
1242
|
-
var installQueue = Promise.resolve();
|
|
1243
|
-
var serialized = (task) => {
|
|
1244
|
-
const next = installQueue.then(task, task);
|
|
1245
|
-
installQueue = next.catch(() => void 0);
|
|
1246
|
-
return next;
|
|
1247
|
-
};
|
|
1248
|
-
var installFailed = (spec, step, detail, cause) => new DeviceError(
|
|
1249
|
-
"install-failed",
|
|
1250
|
-
`Installing ${spec.packageName} ${spec.version} failed while ${step}.${detail ? ` ${detail}` : ""}`,
|
|
1251
|
-
cause === void 0 ? void 0 : { cause }
|
|
1252
|
-
);
|
|
1253
|
-
var ensureTool = (input) => serialized(async () => {
|
|
1254
|
-
const { dataDir, spec } = input;
|
|
1255
|
-
const paths = toolPaths(dataDir, spec);
|
|
1256
|
-
if (await isInstalled(paths, spec.version)) return paths;
|
|
1257
|
-
const parentDir = NodePath4.dirname(paths.installDir);
|
|
1258
|
-
await NodeFSP4.rm(paths.installDir, { recursive: true, force: true });
|
|
1259
|
-
await NodeFSP4.mkdir(parentDir, { recursive: true });
|
|
1260
|
-
const npm = await ensureManagedNpm({
|
|
1261
|
-
dataDir,
|
|
1262
|
-
helperPath: input.helperPath,
|
|
1263
|
-
process: input.process,
|
|
1264
|
-
fetch: input.fetch
|
|
1265
|
-
});
|
|
1266
|
-
const stagingDir = await NodeFSP4.mkdtemp(NodePath4.join(parentDir, ".staging-"));
|
|
1267
|
-
try {
|
|
1268
|
-
const result = await runManagedNpm({
|
|
1269
|
-
helperPath: input.helperPath,
|
|
1270
|
-
process: input.process,
|
|
1271
|
-
npm,
|
|
1272
|
-
args: [
|
|
1273
|
-
"install",
|
|
1274
|
-
"--prefix",
|
|
1275
|
-
stagingDir,
|
|
1276
|
-
"--no-fund",
|
|
1277
|
-
"--no-audit",
|
|
1278
|
-
`${spec.packageName}@${spec.version}`
|
|
1279
|
-
],
|
|
1280
|
-
cwd: dataDir,
|
|
1281
|
-
timeoutMs: INSTALL_TIMEOUT_MS
|
|
1282
|
-
}).catch((error) => {
|
|
1283
|
-
throw installFailed(spec, "running npm install", errorDetail(error), error);
|
|
1284
|
-
});
|
|
1285
|
-
if (result.timedOut) throw installFailed(spec, "running npm install", "npm timed out.");
|
|
1286
|
-
if (result.exitCode !== 0) {
|
|
1287
|
-
throw installFailed(
|
|
1288
|
-
spec,
|
|
1289
|
-
"running npm install",
|
|
1290
|
-
`npm exited with code ${String(result.exitCode)}.`
|
|
1291
|
-
);
|
|
1292
|
-
}
|
|
1293
|
-
const stagedEntry = NodePath4.join(
|
|
1294
|
-
stagingDir,
|
|
1295
|
-
"node_modules",
|
|
1296
|
-
spec.packageName,
|
|
1297
|
-
...spec.entry
|
|
1298
|
-
);
|
|
1299
|
-
if (!await fileExists2(stagedEntry)) {
|
|
1300
|
-
throw installFailed(spec, "verifying the installed entry point");
|
|
1301
|
-
}
|
|
1302
|
-
await NodeFSP4.writeFile(NodePath4.join(stagingDir, ".install-complete"), `${spec.version}
|
|
1303
|
-
`);
|
|
1304
|
-
try {
|
|
1305
|
-
await NodeFSP4.rename(stagingDir, paths.installDir);
|
|
1306
|
-
} catch (error) {
|
|
1307
|
-
if (!await isInstalled(paths, spec.version)) {
|
|
1308
|
-
throw installFailed(spec, "publishing the install", errorDetail(error), error);
|
|
1309
|
-
}
|
|
1310
|
-
}
|
|
1311
|
-
return paths;
|
|
1312
|
-
} finally {
|
|
1313
|
-
await NodeFSP4.rm(stagingDir, { recursive: true, force: true }).catch(() => void 0);
|
|
1314
|
-
}
|
|
1315
|
-
});
|
|
1316
|
-
var inspectTool = async (dataDir, spec, runningVersion) => {
|
|
1317
|
-
const directory = NodePath4.join(toolsRoot(dataDir), spec.packageName);
|
|
1318
|
-
let names = [];
|
|
1319
|
-
try {
|
|
1320
|
-
names = await NodeFSP4.readdir(directory);
|
|
1321
|
-
} catch {
|
|
1322
|
-
names = [];
|
|
1323
|
-
}
|
|
1324
|
-
const completed = [];
|
|
1325
|
-
for (const version of names) {
|
|
1326
|
-
if (!VERSION_PATTERN.test(version)) continue;
|
|
1327
|
-
if (await isInstalled(toolPaths(dataDir, spec, version), version)) completed.push(version);
|
|
1328
|
-
}
|
|
1329
|
-
completed.sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
|
|
1330
|
-
return { requiredVersion: spec.version, installedVersions: completed, runningVersion };
|
|
1331
|
-
};
|
|
1332
|
-
|
|
1333
|
-
// src/maintenance.ts
|
|
1334
|
-
var TOOL_MAINTENANCE_SCRIPT = String.raw`
|
|
1335
|
-
const maintenanceFs = require('node:fs');
|
|
1336
|
-
const maintenancePath = require('node:path');
|
|
1337
|
-
const maintenanceAlive = pid => {
|
|
1338
|
-
try { process.kill(pid, 0); return true; }
|
|
1339
|
-
catch (error) { return error.code !== 'ESRCH'; }
|
|
1340
|
-
};
|
|
1341
|
-
async function withToolMaintenance(root, operation) {
|
|
1342
|
-
maintenanceFs.mkdirSync(root, { recursive: true });
|
|
1343
|
-
const lock = maintenancePath.join(root, '.maintenance-lock');
|
|
1344
|
-
const nonce = require('node:crypto').randomUUID();
|
|
1345
|
-
const ownerFile = process.pid + '.' + nonce + '.json';
|
|
1346
|
-
const candidate = lock + '.' + nonce;
|
|
1347
|
-
const deadline = Date.now() + 30000;
|
|
1348
|
-
const removeEmptyLock = () => {
|
|
1349
|
-
try { maintenanceFs.rmdirSync(lock); }
|
|
1350
|
-
catch (error) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST', 'EPERM'].includes(error.code)) throw error; }
|
|
1351
|
-
};
|
|
1352
|
-
maintenanceFs.mkdirSync(candidate);
|
|
1353
|
-
try {
|
|
1354
|
-
maintenanceFs.writeFileSync(maintenancePath.join(candidate, ownerFile), JSON.stringify({ pid: process.pid }));
|
|
1355
|
-
while (true) {
|
|
1356
|
-
try {
|
|
1357
|
-
maintenanceFs.renameSync(candidate, lock);
|
|
1358
|
-
break;
|
|
1359
|
-
}
|
|
1360
|
-
catch (error) {
|
|
1361
|
-
if (!['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES'].includes(error.code)) throw error;
|
|
1362
|
-
let files = [];
|
|
1363
|
-
try { files = maintenanceFs.readdirSync(lock); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
1364
|
-
if (files.length === 1) {
|
|
1365
|
-
const previousFile = maintenancePath.join(lock, files[0]);
|
|
1366
|
-
let previous;
|
|
1367
|
-
try { previous = JSON.parse(maintenanceFs.readFileSync(previousFile, 'utf8')); } catch {}
|
|
1368
|
-
if (Number.isSafeInteger(previous?.pid) && previous.pid > 0 && !maintenanceAlive(previous.pid)) {
|
|
1369
|
-
try { maintenanceFs.unlinkSync(previousFile); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
1370
|
-
}
|
|
1371
|
-
}
|
|
1372
|
-
removeEmptyLock();
|
|
1373
|
-
if (Date.now() >= deadline) throw Error('Device tool maintenance is locked. Retry when the other operation finishes.');
|
|
1374
|
-
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1375
|
-
}
|
|
1376
|
-
}
|
|
1377
|
-
try { return operation(); }
|
|
1378
|
-
finally {
|
|
1379
|
-
maintenanceFs.unlinkSync(maintenancePath.join(lock, ownerFile));
|
|
1380
|
-
removeEmptyLock();
|
|
1381
|
-
}
|
|
1382
|
-
} finally {
|
|
1383
|
-
maintenanceFs.rmSync(candidate, { recursive: true, force: true });
|
|
1384
|
-
}
|
|
1385
|
-
}
|
|
1386
|
-
function pruneTools(root, specs) {
|
|
1387
|
-
return withToolMaintenance(root, () => {
|
|
1388
|
-
const scan = process.platform === 'win32'
|
|
1389
|
-
? require('node:child_process').spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine'], { encoding: 'utf8', timeout: 10000, windowsHide: true })
|
|
1390
|
-
: require('node:child_process').spawnSync('ps', ['-ax', '-o', 'command='], { encoding: 'utf8', timeout: 10000 });
|
|
1391
|
-
if (scan.status !== 0 || !scan.stdout) return;
|
|
1392
|
-
for (const [name, required] of specs) {
|
|
1393
|
-
const parent = maintenancePath.join(root, name);
|
|
1394
|
-
let names;
|
|
1395
|
-
try { names = maintenanceFs.readdirSync(parent); } catch { continue; }
|
|
1396
|
-
const completed = [];
|
|
1397
|
-
for (const version of names) {
|
|
1398
|
-
if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(version)) continue;
|
|
1399
|
-
const directory = maintenancePath.join(parent, version);
|
|
1400
|
-
try {
|
|
1401
|
-
if (!maintenanceFs.lstatSync(directory).isDirectory()) continue;
|
|
1402
|
-
if (maintenanceFs.readFileSync(maintenancePath.join(directory, '.install-complete'), 'utf8').trim() !== version) continue;
|
|
1403
|
-
completed.push({ version, directory, modified: maintenanceFs.statSync(maintenancePath.join(directory, '.install-complete')).mtimeMs });
|
|
1404
|
-
} catch {}
|
|
1405
|
-
}
|
|
1406
|
-
if (!completed.some(value => value.version === required)) continue;
|
|
1407
|
-
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;
|
|
1408
|
-
for (const { version, directory } of completed) {
|
|
1409
|
-
if (version === required || version === previous || scan.stdout.includes(directory + maintenancePath.sep)) continue;
|
|
1410
|
-
maintenanceFs.rmSync(directory, { recursive: true, force: true });
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1413
|
-
});
|
|
1414
|
-
}
|
|
1415
|
-
`;
|
|
1416
|
-
var pruneTool = async (input) => {
|
|
1417
|
-
const call = `pruneTools(${JSON.stringify(toolsRoot(input.dataDir))}, ${JSON.stringify([
|
|
1418
|
-
[input.spec.packageName, input.spec.version]
|
|
1419
|
-
])})`;
|
|
1420
|
-
const result = await input.process.run({
|
|
1421
|
-
program: "host:node",
|
|
1422
|
-
args: [
|
|
1423
|
-
"-e",
|
|
1424
|
-
`${TOOL_MAINTENANCE_SCRIPT}
|
|
1425
|
-
${call}.catch(error => { console.error(error.message); process.exitCode = 1; });`
|
|
1426
|
-
],
|
|
1427
|
-
cwd: input.dataDir,
|
|
1428
|
-
timeoutMs: 6e4
|
|
1429
|
-
});
|
|
1430
|
-
return { ok: result.exitCode === 0, detail: result.stderr.trim() };
|
|
1431
|
-
};
|
|
1432
|
-
|
|
1433
|
-
// src/probe.ts
|
|
1434
|
-
var MIN_NODE_VERSION = [22, 12, 0];
|
|
1435
|
-
var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1436
|
-
var parseMachineProbe = (stdout) => {
|
|
1437
|
-
const line = stdout.trim().split(/\r?\n/).at(-1) ?? "";
|
|
1438
|
-
let value;
|
|
1439
|
-
try {
|
|
1440
|
-
value = JSON.parse(line);
|
|
1441
|
-
} catch {
|
|
1442
|
-
return null;
|
|
1443
|
-
}
|
|
1444
|
-
if (!isRecord2(value) || !isRecord2(value["android"])) return null;
|
|
1445
|
-
const android = value["android"];
|
|
1446
|
-
if (typeof value["node"] !== "string" || typeof value["execPath"] !== "string" || typeof value["platform"] !== "string") {
|
|
1447
|
-
return null;
|
|
1448
|
-
}
|
|
1449
|
-
return {
|
|
1450
|
-
node: value["node"],
|
|
1451
|
-
execPath: value["execPath"],
|
|
1452
|
-
electron: typeof value["electron"] === "string" ? value["electron"] : null,
|
|
1453
|
-
platform: value["platform"],
|
|
1454
|
-
xcrun: value["xcrun"] === true,
|
|
1455
|
-
android: {
|
|
1456
|
-
root: typeof android["root"] === "string" ? android["root"] : null,
|
|
1457
|
-
adb: android["adb"] === true,
|
|
1458
|
-
emulator: android["emulator"] === true,
|
|
1459
|
-
avdmanager: android["avdmanager"] === true,
|
|
1460
|
-
legacyAvdmanager: android["legacyAvdmanager"] === true
|
|
1461
|
-
}
|
|
1462
|
-
};
|
|
1463
|
-
};
|
|
1464
|
-
var nodeVersionSupported = (version) => {
|
|
1465
|
-
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(version);
|
|
1466
|
-
if (!match) return false;
|
|
1467
|
-
const parts = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
1468
|
-
for (let index = 0; index < MIN_NODE_VERSION.length; index += 1) {
|
|
1469
|
-
const actual = parts[index];
|
|
1470
|
-
const required = MIN_NODE_VERSION[index];
|
|
1471
|
-
if (actual !== required) return actual > required;
|
|
1472
|
-
}
|
|
1473
|
-
return true;
|
|
1474
|
-
};
|
|
1475
|
-
var unavailable = (platform, reasonCode, reason, sdkRoot) => ({
|
|
1476
|
-
platform,
|
|
1477
|
-
available: false,
|
|
1478
|
-
reasonCode,
|
|
1479
|
-
reason,
|
|
1480
|
-
...sdkRoot === void 0 ? {} : { sdkRoot }
|
|
1481
|
-
});
|
|
1482
|
-
var platformAvailability = (probe, platform) => {
|
|
1483
|
-
if (platform === "ios") {
|
|
1484
|
-
if (probe.platform !== "darwin") {
|
|
1485
|
-
return unavailable("ios", "ios-needs-macos", "iOS Simulators need macOS with Xcode.");
|
|
1486
|
-
}
|
|
1487
|
-
if (!probe.xcrun) {
|
|
1488
|
-
return unavailable("ios", "xcode-missing", "Xcode command line tools were not found.");
|
|
1489
|
-
}
|
|
1490
|
-
return { platform, available: true };
|
|
1491
|
-
}
|
|
1492
|
-
const sdk = probe.android;
|
|
1493
|
-
if (!sdk.root) {
|
|
1494
|
-
return unavailable(
|
|
1495
|
-
"android",
|
|
1496
|
-
"android-sdk-missing",
|
|
1497
|
-
"Android SDK was not found. Install it with Android Studio or set its location in the Devices settings."
|
|
1498
|
-
);
|
|
1499
|
-
}
|
|
1500
|
-
if (!sdk.adb) {
|
|
1501
|
-
return unavailable(
|
|
1502
|
-
"android",
|
|
1503
|
-
"android-platform-tools-missing",
|
|
1504
|
-
`Android SDK Platform-Tools are missing from ${sdk.root}. Install them in Android Studio's SDK Manager.`,
|
|
1505
|
-
sdk.root
|
|
1506
|
-
);
|
|
1507
|
-
}
|
|
1508
|
-
if (!sdk.emulator) {
|
|
1509
|
-
return unavailable(
|
|
1510
|
-
"android",
|
|
1511
|
-
"android-emulator-missing",
|
|
1512
|
-
`Android Emulator is missing from ${sdk.root}. Install it in Android Studio's SDK Manager.`,
|
|
1513
|
-
sdk.root
|
|
1514
|
-
);
|
|
1515
|
-
}
|
|
1516
|
-
if (!sdk.avdmanager) {
|
|
1517
|
-
return sdk.legacyAvdmanager ? unavailable(
|
|
1518
|
-
"android",
|
|
1519
|
-
"android-cmdline-tools-legacy",
|
|
1520
|
-
`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.`,
|
|
1521
|
-
sdk.root
|
|
1522
|
-
) : unavailable(
|
|
1523
|
-
"android",
|
|
1524
|
-
"android-cmdline-tools-missing",
|
|
1525
|
-
`Android SDK Command-line Tools (latest) are missing from ${sdk.root}. Install them in Android Studio's SDK Manager.`,
|
|
1526
|
-
sdk.root
|
|
1527
|
-
);
|
|
1528
|
-
}
|
|
1529
|
-
return { platform, available: true };
|
|
1530
|
-
};
|
|
1531
|
-
var probeMachine = async (input) => {
|
|
1532
|
-
const args = [input.helperPath, "probe"];
|
|
1533
|
-
if (input.sdkOverride.trim().length > 0) args.push("--sdk", input.sdkOverride.trim());
|
|
1534
|
-
const result = await input.process.run({ program: "host:node", args, cwd: input.cwd, timeoutMs: 3e4 }).catch((error) => {
|
|
2137
|
+
var ensureTool = async (input) => {
|
|
2138
|
+
const pluginDir = input.pluginDir ?? NodePath6.dirname(NodePath6.dirname(input.helperPath));
|
|
2139
|
+
if (!await isToolInstalled(pluginDir, input.spec, input.runtimeDir)) {
|
|
1535
2140
|
throw new DeviceError(
|
|
1536
|
-
"
|
|
1537
|
-
`
|
|
1538
|
-
{ cause: error }
|
|
1539
|
-
);
|
|
1540
|
-
});
|
|
1541
|
-
const probe = result.exitCode === 0 ? parseMachineProbe(result.stdout) : null;
|
|
1542
|
-
if (!probe) {
|
|
1543
|
-
throw new DeviceError(
|
|
1544
|
-
"node-unavailable",
|
|
1545
|
-
`Checking device support failed: ${result.stderr.trim() || "the helper returned no result"}.`
|
|
1546
|
-
);
|
|
1547
|
-
}
|
|
1548
|
-
if (!nodeVersionSupported(probe.node)) {
|
|
1549
|
-
throw new DeviceError(
|
|
1550
|
-
"node-unavailable",
|
|
1551
|
-
`Device support requires Node.js 22.12 or newer; this Ycodium runs on ${probe.node}. Update Ycodium on this machine.`
|
|
2141
|
+
"install-failed",
|
|
2142
|
+
`Bundled ${input.spec.packageName} ${input.spec.version} is missing or incomplete. Repair or update Ycodium on this machine.`
|
|
1552
2143
|
);
|
|
1553
2144
|
}
|
|
1554
|
-
return
|
|
2145
|
+
return bundledToolPaths(pluginDir, input.spec, input.runtimeDir);
|
|
1555
2146
|
};
|
|
2147
|
+
var inspectTool = async (pluginDir, spec, runningVersion, runtimeDir) => ({
|
|
2148
|
+
requiredVersion: spec.version,
|
|
2149
|
+
installedVersions: await isToolInstalled(pluginDir, spec, runtimeDir) ? [spec.version] : [],
|
|
2150
|
+
runningVersion
|
|
2151
|
+
});
|
|
1556
2152
|
|
|
1557
2153
|
// src/serveSimHelpers.ts
|
|
1558
|
-
|
|
1559
|
-
import * as NodePath5 from "node:path";
|
|
1560
|
-
var existing = (file) => NodeFSP5.access(file).then(
|
|
2154
|
+
var existing = (file) => NodeFSP7.access(file).then(
|
|
1561
2155
|
() => file,
|
|
1562
2156
|
() => null
|
|
1563
2157
|
);
|
|
1564
|
-
var serveSimHelpers = async (dataDir) => {
|
|
1565
|
-
const dist =
|
|
1566
|
-
|
|
2158
|
+
var serveSimHelpers = async (dataDir, runtimeDir) => {
|
|
2159
|
+
const dist = NodePath7.join(
|
|
2160
|
+
bundledToolPaths(dataDir, HUB_SPEC, runtimeDir).installDir,
|
|
1567
2161
|
"node_modules",
|
|
1568
2162
|
HUB_SPEC.packageName,
|
|
1569
2163
|
"vendor",
|
|
@@ -1571,8 +2165,8 @@ var serveSimHelpers = async (dataDir) => {
|
|
|
1571
2165
|
"dist"
|
|
1572
2166
|
);
|
|
1573
2167
|
const [axSettings2, cli] = await Promise.all([
|
|
1574
|
-
existing(
|
|
1575
|
-
existing(
|
|
2168
|
+
existing(NodePath7.join(dist, "simax", "serve-sim-ax-settings")),
|
|
2169
|
+
existing(NodePath7.join(dist, "serve-sim.js"))
|
|
1576
2170
|
]);
|
|
1577
2171
|
return { axSettings: axSettings2, cli };
|
|
1578
2172
|
};
|
|
@@ -1589,6 +2183,7 @@ var LocalDeviceHost = class {
|
|
|
1589
2183
|
agent;
|
|
1590
2184
|
agentEndpoint = null;
|
|
1591
2185
|
lock = Promise.resolve();
|
|
2186
|
+
environmentMutating = false;
|
|
1592
2187
|
options;
|
|
1593
2188
|
constructor(options) {
|
|
1594
2189
|
this.options = options;
|
|
@@ -1612,6 +2207,8 @@ var LocalDeviceHost = class {
|
|
|
1612
2207
|
/** 生命周期操作串行:两个调用者同时要起 hub 时只起一次。 */
|
|
1613
2208
|
installInput(spec) {
|
|
1614
2209
|
return {
|
|
2210
|
+
pluginDir: this.pluginDir,
|
|
2211
|
+
...this.options.runtimeDir ? { runtimeDir: this.options.runtimeDir } : {},
|
|
1615
2212
|
dataDir: this.options.dataDir,
|
|
1616
2213
|
spec,
|
|
1617
2214
|
helperPath: this.options.helperPath,
|
|
@@ -1619,6 +2216,9 @@ var LocalDeviceHost = class {
|
|
|
1619
2216
|
fetch: this.options.downloadFetch
|
|
1620
2217
|
};
|
|
1621
2218
|
}
|
|
2219
|
+
get pluginDir() {
|
|
2220
|
+
return this.options.pluginDir ?? NodePath8.dirname(NodePath8.dirname(this.options.helperPath));
|
|
2221
|
+
}
|
|
1622
2222
|
serialized(task) {
|
|
1623
2223
|
const next = this.lock.then(task, task);
|
|
1624
2224
|
this.lock = next.catch(() => void 0);
|
|
@@ -1626,37 +2226,39 @@ var LocalDeviceHost = class {
|
|
|
1626
2226
|
}
|
|
1627
2227
|
async machine(refresh = false) {
|
|
1628
2228
|
if (this.probeCache !== null && !refresh) return this.probeCache;
|
|
2229
|
+
const privateSdk = this.options.sdkOverride.trim() ? null : await selectedPrivateSdk(this.options.dataDir);
|
|
1629
2230
|
this.probeCache = await probeMachine({
|
|
1630
2231
|
helperPath: this.options.helperPath,
|
|
1631
2232
|
process: this.options.process,
|
|
1632
2233
|
cwd: this.options.dataDir,
|
|
1633
|
-
sdkOverride: this.options.sdkOverride
|
|
2234
|
+
sdkOverride: this.options.sdkOverride.trim() || privateSdk || "",
|
|
2235
|
+
runtimeDir: bundledToolPaths(this.pluginDir, HUB_SPEC, this.options.runtimeDir).installDir
|
|
1634
2236
|
});
|
|
1635
2237
|
return this.probeCache;
|
|
1636
2238
|
}
|
|
1637
|
-
async platformAvailability(
|
|
1638
|
-
return platformAvailability(await this.machine(),
|
|
2239
|
+
async platformAvailability(platform2) {
|
|
2240
|
+
return platformAvailability(await this.machine(), platform2);
|
|
1639
2241
|
}
|
|
1640
2242
|
async platforms(refresh = false) {
|
|
1641
2243
|
const probe = await this.machine(refresh);
|
|
1642
|
-
return DEVICE_PLATFORMS.map((
|
|
2244
|
+
return DEVICE_PLATFORMS.map((platform2) => platformAvailability(probe, platform2));
|
|
1643
2245
|
}
|
|
1644
2246
|
async toolVersion(spec) {
|
|
1645
2247
|
const running = spec.tool === "hub" ? this.hub.running !== null ? HUB_SPEC.version : null : this.agentEndpoint?.version ?? null;
|
|
1646
|
-
return inspectTool(this.
|
|
2248
|
+
return inspectTool(this.pluginDir, spec, running, this.options.runtimeDir);
|
|
1647
2249
|
}
|
|
1648
2250
|
/** 不装不起,只读现状。探测失败(比如没有 Node)时平台一律按不可用报。 */
|
|
1649
2251
|
async summary(refresh = false) {
|
|
1650
2252
|
const [platforms, hubInstalled, agentDeviceInstalled, hub, agent] = await Promise.all([
|
|
1651
2253
|
this.platforms(refresh).catch(
|
|
1652
|
-
(error) => DEVICE_PLATFORMS.map((
|
|
1653
|
-
platform,
|
|
2254
|
+
(error) => DEVICE_PLATFORMS.map((platform2) => ({
|
|
2255
|
+
platform: platform2,
|
|
1654
2256
|
available: false,
|
|
1655
2257
|
reason: errorDetail(error)
|
|
1656
2258
|
}))
|
|
1657
2259
|
),
|
|
1658
|
-
isToolInstalled(this.
|
|
1659
|
-
isToolInstalled(this.
|
|
2260
|
+
isToolInstalled(this.pluginDir, HUB_SPEC, this.options.runtimeDir),
|
|
2261
|
+
isToolInstalled(this.pluginDir, AGENT_DEVICE_SPEC, this.options.runtimeDir),
|
|
1660
2262
|
this.toolVersion(HUB_SPEC),
|
|
1661
2263
|
this.toolVersion(AGENT_DEVICE_SPEC)
|
|
1662
2264
|
]);
|
|
@@ -1670,7 +2272,7 @@ var LocalDeviceHost = class {
|
|
|
1670
2272
|
};
|
|
1671
2273
|
}
|
|
1672
2274
|
async installIfNeeded(spec, onPhase) {
|
|
1673
|
-
if (!await isToolInstalled(this.
|
|
2275
|
+
if (!await isToolInstalled(this.pluginDir, spec, this.options.runtimeDir)) {
|
|
1674
2276
|
onPhase(
|
|
1675
2277
|
"installing",
|
|
1676
2278
|
installPhaseDetail(spec.tool, await this.toolVersion(spec), spec.version)
|
|
@@ -1678,31 +2280,25 @@ var LocalDeviceHost = class {
|
|
|
1678
2280
|
}
|
|
1679
2281
|
return ensureTool(this.installInput(spec));
|
|
1680
2282
|
}
|
|
1681
|
-
async prune(spec) {
|
|
1682
|
-
const result = await pruneTool({
|
|
1683
|
-
dataDir: this.options.dataDir,
|
|
1684
|
-
spec,
|
|
1685
|
-
process: this.options.process
|
|
1686
|
-
}).catch((error) => ({
|
|
1687
|
-
ok: false,
|
|
1688
|
-
detail: error instanceof Error ? error.message : String(error)
|
|
1689
|
-
}));
|
|
1690
|
-
if (!result.ok) {
|
|
1691
|
-
this.options.log.warn("Reclaiming old device tool versions failed", {
|
|
1692
|
-
tool: spec.tool,
|
|
1693
|
-
detail: result.detail
|
|
1694
|
-
});
|
|
1695
|
-
}
|
|
1696
|
-
}
|
|
1697
2283
|
async startHub(onPhase) {
|
|
2284
|
+
if (this.environmentMutating)
|
|
2285
|
+
throw new DeviceError(
|
|
2286
|
+
"invalid-input",
|
|
2287
|
+
"Simulator environment preparation or cleanup is in progress"
|
|
2288
|
+
);
|
|
1698
2289
|
const probe = await this.machine();
|
|
1699
2290
|
const running = this.hub.running;
|
|
1700
|
-
if (running !== null)
|
|
2291
|
+
if (running !== null)
|
|
2292
|
+
return {
|
|
2293
|
+
hostId: this.id,
|
|
2294
|
+
hubOrigin: running.origin,
|
|
2295
|
+
probe,
|
|
2296
|
+
helperPath: this.options.helperPath
|
|
2297
|
+
};
|
|
1701
2298
|
const tool = await this.installIfNeeded(HUB_SPEC, onPhase);
|
|
1702
2299
|
onPhase("starting");
|
|
1703
2300
|
const hub = await this.hub.ensure({ entryPath: tool.entryPath, sdkRoot: probe.android.root });
|
|
1704
|
-
|
|
1705
|
-
return { hostId: this.id, hubOrigin: hub.origin, probe };
|
|
2301
|
+
return { hostId: this.id, hubOrigin: hub.origin, probe, helperPath: this.options.helperPath };
|
|
1706
2302
|
}
|
|
1707
2303
|
/** 首次使用时装 hub 并起来;幂等,hub 已在跑就直接返回。 */
|
|
1708
2304
|
ensureReady(onPhase) {
|
|
@@ -1720,7 +2316,6 @@ var LocalDeviceHost = class {
|
|
|
1720
2316
|
sdkRoot: ready.probe.android.root
|
|
1721
2317
|
});
|
|
1722
2318
|
this.agentEndpoint = endpoint;
|
|
1723
|
-
await this.prune(AGENT_DEVICE_SPEC);
|
|
1724
2319
|
return { ...ready, agentDevice: endpoint };
|
|
1725
2320
|
});
|
|
1726
2321
|
}
|
|
@@ -1739,6 +2334,11 @@ var LocalDeviceHost = class {
|
|
|
1739
2334
|
* Node 起脚本。只传 argv,不过 shell。
|
|
1740
2335
|
*/
|
|
1741
2336
|
async runTool(program, args, options = {}) {
|
|
2337
|
+
if (this.environmentMutating)
|
|
2338
|
+
throw new DeviceError(
|
|
2339
|
+
"invalid-input",
|
|
2340
|
+
"Simulator environment preparation or cleanup is in progress"
|
|
2341
|
+
);
|
|
1742
2342
|
const stdin = options.stdin === void 0 ? {} : { stdin: options.stdin };
|
|
1743
2343
|
const base = {
|
|
1744
2344
|
program: "host:node",
|
|
@@ -1770,7 +2370,7 @@ var LocalDeviceHost = class {
|
|
|
1770
2370
|
async actionTools() {
|
|
1771
2371
|
return {
|
|
1772
2372
|
run: (program, args, options) => this.runTool(program, args, options),
|
|
1773
|
-
helpers: await serveSimHelpers(this.options.
|
|
2373
|
+
helpers: await serveSimHelpers(this.pluginDir, this.options.runtimeDir)
|
|
1774
2374
|
};
|
|
1775
2375
|
}
|
|
1776
2376
|
/** 只停 agent-device;用户自己在面板里看设备不受影响。 */
|
|
@@ -1788,6 +2388,25 @@ var LocalDeviceHost = class {
|
|
|
1788
2388
|
this.agentEndpoint = null;
|
|
1789
2389
|
});
|
|
1790
2390
|
}
|
|
2391
|
+
beginEnvironmentMutation() {
|
|
2392
|
+
return this.serialized(async () => {
|
|
2393
|
+
if (this.environmentMutating)
|
|
2394
|
+
throw new DeviceError("invalid-input", "Another environment mutation is in progress");
|
|
2395
|
+
this.environmentMutating = true;
|
|
2396
|
+
try {
|
|
2397
|
+
await this.hub.stop();
|
|
2398
|
+
await this.agent.stop();
|
|
2399
|
+
this.agentEndpoint = null;
|
|
2400
|
+
} catch (error) {
|
|
2401
|
+
this.environmentMutating = false;
|
|
2402
|
+
throw error;
|
|
2403
|
+
}
|
|
2404
|
+
return () => {
|
|
2405
|
+
this.probeCache = null;
|
|
2406
|
+
this.environmentMutating = false;
|
|
2407
|
+
};
|
|
2408
|
+
});
|
|
2409
|
+
}
|
|
1791
2410
|
};
|
|
1792
2411
|
|
|
1793
2412
|
// src/deviceActions.ts
|
|
@@ -2156,23 +2775,23 @@ var readDeviceDetail = async (tools, device) => {
|
|
|
2156
2775
|
var BOOT_TIMEOUT_MS = 3 * 6e4;
|
|
2157
2776
|
var SCREENSHOT_TIMEOUT_MS = 2e4;
|
|
2158
2777
|
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
2159
|
-
var vendorPrefix = (
|
|
2778
|
+
var vendorPrefix = (platform2) => platform2 === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu";
|
|
2160
2779
|
var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2161
2780
|
var parseHubDevice = (value) => {
|
|
2162
2781
|
if (!isRecord3(value)) return null;
|
|
2163
|
-
const { id, name, version, platform, booted, physical } = value;
|
|
2164
|
-
if (typeof id !== "string" || typeof name !== "string" || typeof version !== "string" ||
|
|
2782
|
+
const { id, name, version, platform: platform2, booted, physical } = value;
|
|
2783
|
+
if (typeof id !== "string" || typeof name !== "string" || typeof version !== "string" || platform2 !== "ios" && platform2 !== "android" || typeof booted !== "boolean" || typeof physical !== "boolean") {
|
|
2165
2784
|
return null;
|
|
2166
2785
|
}
|
|
2167
|
-
return { id, name, version, platform, booted, physical };
|
|
2786
|
+
return { id, name, version, platform: platform2, booted, physical };
|
|
2168
2787
|
};
|
|
2169
2788
|
var parseHubDeviceList = (value) => {
|
|
2170
2789
|
if (!isRecord3(value)) return null;
|
|
2171
2790
|
const simulators = value["simulators"];
|
|
2172
|
-
const
|
|
2173
|
-
if (!Array.isArray(simulators) || !Array.isArray(
|
|
2791
|
+
const emulators2 = value["emulators"];
|
|
2792
|
+
if (!Array.isArray(simulators) || !Array.isArray(emulators2)) return null;
|
|
2174
2793
|
const devices = [];
|
|
2175
|
-
for (const entry of [...simulators, ...
|
|
2794
|
+
for (const entry of [...simulators, ...emulators2]) {
|
|
2176
2795
|
const device = parseHubDevice(entry);
|
|
2177
2796
|
if (device === null) return null;
|
|
2178
2797
|
devices.push(device);
|
|
@@ -2266,14 +2885,14 @@ var HubClient = class {
|
|
|
2266
2885
|
shutdownIos(udid) {
|
|
2267
2886
|
return this.action(`${vendorPrefix("ios")}/grid/api/shutdown`, "shutdown", { udid });
|
|
2268
2887
|
}
|
|
2269
|
-
shutdownDevice(
|
|
2270
|
-
return this.action("/api/devices/shutdown", "shutdown", { platform, id });
|
|
2888
|
+
shutdownDevice(platform2, id) {
|
|
2889
|
+
return this.action("/api/devices/shutdown", "shutdown", { platform: platform2, id });
|
|
2271
2890
|
}
|
|
2272
|
-
async screenshot(
|
|
2891
|
+
async screenshot(platform2, deviceId) {
|
|
2273
2892
|
let response;
|
|
2274
2893
|
try {
|
|
2275
2894
|
response = await this.fetch(
|
|
2276
|
-
`${this.origin}${vendorPrefix(
|
|
2895
|
+
`${this.origin}${vendorPrefix(platform2)}/api/screenshot?device=${encodeURIComponent(deviceId)}`,
|
|
2277
2896
|
{ method: "POST", signal: AbortSignal.timeout(SCREENSHOT_TIMEOUT_MS) }
|
|
2278
2897
|
);
|
|
2279
2898
|
} catch (cause) {
|
|
@@ -2306,6 +2925,9 @@ var classifyBootFailure = (error) => {
|
|
|
2306
2925
|
// src/service.ts
|
|
2307
2926
|
var sameDevice = (a, hostId, deviceId) => a.hostId === hostId && a.id === deviceId;
|
|
2308
2927
|
var DeviceService = class {
|
|
2928
|
+
environmentMutating = false;
|
|
2929
|
+
environmentGeneration = 0;
|
|
2930
|
+
pendingOpens = 0;
|
|
2309
2931
|
state = {
|
|
2310
2932
|
hosts: [],
|
|
2311
2933
|
hostStatuses: {},
|
|
@@ -2345,6 +2967,7 @@ var DeviceService = class {
|
|
|
2345
2967
|
}));
|
|
2346
2968
|
}
|
|
2347
2969
|
onPhase = (phase, detail) => {
|
|
2970
|
+
if (this.environmentMutating) return;
|
|
2348
2971
|
this.setHostStatus({ status: phase, ...detail });
|
|
2349
2972
|
};
|
|
2350
2973
|
resolveHost(hostId) {
|
|
@@ -2353,28 +2976,30 @@ var DeviceService = class {
|
|
|
2353
2976
|
}
|
|
2354
2977
|
}
|
|
2355
2978
|
async withStatus(task) {
|
|
2979
|
+
const generation = this.environmentGeneration;
|
|
2356
2980
|
try {
|
|
2357
2981
|
return await task();
|
|
2358
2982
|
} catch (error) {
|
|
2359
2983
|
const message = error instanceof DeviceError ? error.message : errorDetail(error);
|
|
2360
|
-
this.
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2984
|
+
if (!this.environmentMutating && generation === this.environmentGeneration)
|
|
2985
|
+
this.setHostStatus({
|
|
2986
|
+
status: "failed",
|
|
2987
|
+
code: error instanceof DeviceError ? error.code : "host-unavailable",
|
|
2988
|
+
message
|
|
2989
|
+
});
|
|
2365
2990
|
throw error;
|
|
2366
2991
|
}
|
|
2367
2992
|
}
|
|
2368
2993
|
/** 起 hub 并刷新设备。机器一个平台都跑不了时什么都不装,返回 null。 */
|
|
2369
2994
|
async readinessIfSupported() {
|
|
2370
2995
|
const platforms = await this.host.platforms();
|
|
2371
|
-
if (!platforms.some((
|
|
2996
|
+
if (!platforms.some((platform2) => platform2.available)) return null;
|
|
2372
2997
|
return this.readiness();
|
|
2373
2998
|
}
|
|
2374
2999
|
async readiness() {
|
|
2375
3000
|
return this.withStatus(async () => {
|
|
2376
3001
|
const ready = await this.host.ensureReady(this.onPhase);
|
|
2377
|
-
if (this.state.hostStatuses[this.host.id]?.status !== "ready") {
|
|
3002
|
+
if (!this.environmentMutating && this.state.hostStatuses[this.host.id]?.status !== "ready") {
|
|
2378
3003
|
this.setHostStatus({ status: "ready" });
|
|
2379
3004
|
}
|
|
2380
3005
|
return ready;
|
|
@@ -2414,8 +3039,10 @@ var DeviceService = class {
|
|
|
2414
3039
|
return list.errors.length > 0 ? { devices, detail: list.errors.join("\n") } : { devices };
|
|
2415
3040
|
}
|
|
2416
3041
|
async refresh(ready) {
|
|
3042
|
+
const generation = this.environmentGeneration;
|
|
2417
3043
|
const { devices, detail } = await this.withStatus(() => this.fetchDevices(ready));
|
|
2418
3044
|
const summary = await this.host.summary();
|
|
3045
|
+
if (this.environmentMutating || generation !== this.environmentGeneration) return this.state;
|
|
2419
3046
|
return this.publish((state) => ({
|
|
2420
3047
|
...state,
|
|
2421
3048
|
hosts: [summary],
|
|
@@ -2433,8 +3060,11 @@ var DeviceService = class {
|
|
|
2433
3060
|
}
|
|
2434
3061
|
/** 列设备:能跑的话起 hub 再刷新;跑不了只更新宿主现状。失败记在宿主状态里,不抛。 */
|
|
2435
3062
|
async list() {
|
|
3063
|
+
if (this.environmentMutating) return this.state;
|
|
3064
|
+
const generation = this.environmentGeneration;
|
|
2436
3065
|
try {
|
|
2437
3066
|
const ready = await this.readinessIfSupported();
|
|
3067
|
+
if (this.environmentMutating || generation !== this.environmentGeneration) return this.state;
|
|
2438
3068
|
if (ready === null) return await this.inspect();
|
|
2439
3069
|
return await this.refresh(ready);
|
|
2440
3070
|
} catch {
|
|
@@ -2442,6 +3072,19 @@ var DeviceService = class {
|
|
|
2442
3072
|
}
|
|
2443
3073
|
}
|
|
2444
3074
|
async open(input) {
|
|
3075
|
+
if (this.environmentMutating)
|
|
3076
|
+
throw new DeviceError(
|
|
3077
|
+
"invalid-input",
|
|
3078
|
+
"Simulator environment preparation or cleanup is in progress"
|
|
3079
|
+
);
|
|
3080
|
+
this.pendingOpens++;
|
|
3081
|
+
try {
|
|
3082
|
+
return await this.openDevice(input);
|
|
3083
|
+
} finally {
|
|
3084
|
+
this.pendingOpens--;
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
async openDevice(input) {
|
|
2445
3088
|
this.resolveHost(input.hostId);
|
|
2446
3089
|
const availability = await this.host.platformAvailability(input.platform);
|
|
2447
3090
|
if (!availability.available) {
|
|
@@ -2520,11 +3163,11 @@ var DeviceService = class {
|
|
|
2520
3163
|
}));
|
|
2521
3164
|
return session;
|
|
2522
3165
|
}
|
|
2523
|
-
async shutdownDevice(deviceId,
|
|
3166
|
+
async shutdownDevice(deviceId, platform2) {
|
|
2524
3167
|
const ready = await this.readiness();
|
|
2525
3168
|
const hub = this.hub(ready);
|
|
2526
3169
|
try {
|
|
2527
|
-
const result =
|
|
3170
|
+
const result = platform2 === "ios" ? await hub.shutdownIos(deviceId) : await hub.shutdownDevice(platform2, deviceId);
|
|
2528
3171
|
if (!result.ok) {
|
|
2529
3172
|
throw new DeviceError(
|
|
2530
3173
|
"hub-rejected",
|
|
@@ -2534,7 +3177,7 @@ var DeviceService = class {
|
|
|
2534
3177
|
} catch (error) {
|
|
2535
3178
|
const devices = await this.fetchDevices(ready).catch(() => null);
|
|
2536
3179
|
const known = devices?.devices.find((device) => device.id === deviceId);
|
|
2537
|
-
if (
|
|
3180
|
+
if (platform2 !== "ios" || known?.booted !== false) throw error;
|
|
2538
3181
|
}
|
|
2539
3182
|
this.publish((state) => ({
|
|
2540
3183
|
...state,
|
|
@@ -2640,7 +3283,8 @@ var DeviceService = class {
|
|
|
2640
3283
|
entryPath: ready.agentDevice.entryPath,
|
|
2641
3284
|
nodePath: ready.probe.execPath,
|
|
2642
3285
|
electron: ready.probe.electron !== null,
|
|
2643
|
-
platform: ready.probe.platform
|
|
3286
|
+
platform: ready.probe.platform,
|
|
3287
|
+
...ready.helperPath ? { helperPath: ready.helperPath, sdkRoot: ready.probe.android.root } : {}
|
|
2644
3288
|
});
|
|
2645
3289
|
return {
|
|
2646
3290
|
command,
|
|
@@ -2657,6 +3301,28 @@ var DeviceService = class {
|
|
|
2657
3301
|
await this.host.installTool(tool);
|
|
2658
3302
|
return this.inspect();
|
|
2659
3303
|
}
|
|
3304
|
+
async beginEnvironmentMutation() {
|
|
3305
|
+
if (this.environmentMutating || this.pendingOpens > 0 || this.state.sessions.length > 0 || this.state.bootingDevices.length > 0)
|
|
3306
|
+
throw new DeviceError(
|
|
3307
|
+
"invalid-input",
|
|
3308
|
+
"Close device sessions and wait for device operations to finish before preparing or deleting an environment"
|
|
3309
|
+
);
|
|
3310
|
+
this.environmentMutating = true;
|
|
3311
|
+
this.environmentGeneration++;
|
|
3312
|
+
let release;
|
|
3313
|
+
try {
|
|
3314
|
+
release = await this.host.beginEnvironmentMutation();
|
|
3315
|
+
} catch (error) {
|
|
3316
|
+
this.environmentMutating = false;
|
|
3317
|
+
throw error;
|
|
3318
|
+
}
|
|
3319
|
+
this.setHostStatus({ status: "idle" });
|
|
3320
|
+
return async () => {
|
|
3321
|
+
release();
|
|
3322
|
+
this.environmentMutating = false;
|
|
3323
|
+
await this.inspect(true);
|
|
3324
|
+
};
|
|
3325
|
+
}
|
|
2660
3326
|
/** 插件停用时:停辅助进程,模拟器本身不关。 */
|
|
2661
3327
|
async stop() {
|
|
2662
3328
|
await this.host.stop();
|
|
@@ -2730,7 +3396,7 @@ var deviceToolkit = (service) => ({
|
|
|
2730
3396
|
tools: [
|
|
2731
3397
|
{
|
|
2732
3398
|
name: "device_list",
|
|
2733
|
-
description: "List iOS Simulators and Android Emulators on this machine, which platforms
|
|
3399
|
+
description: "List iOS Simulators and Android Emulators on this machine for mobile app UI and interaction verification, including SwiftUI, UIKit, Jetpack Compose, Flutter and React Native apps. Reports which platforms this machine 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.",
|
|
2734
3400
|
inputSchema: { type: "object", properties: { hostId: HOST_ID_PROPERTY } },
|
|
2735
3401
|
handle: guard(async (input, invocation) => {
|
|
2736
3402
|
const threadId = requireThread(invocation);
|
|
@@ -2746,7 +3412,7 @@ var deviceToolkit = (service) => ({
|
|
|
2746
3412
|
},
|
|
2747
3413
|
{
|
|
2748
3414
|
name: "device_open",
|
|
2749
|
-
description: "Open
|
|
3415
|
+
description: "Open an iOS simulator or Android emulator for this conversation, such as when checking a mobile app's screens, navigation, forms and interactions. 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; use that command to inspect and drive the app afterwards.",
|
|
2750
3416
|
inputSchema: {
|
|
2751
3417
|
type: "object",
|
|
2752
3418
|
properties: {
|
|
@@ -2857,12 +3523,14 @@ var deviceToolkit = (service) => ({
|
|
|
2857
3523
|
var HUB_SERVICE = "hub";
|
|
2858
3524
|
var index_default = definePlugin({
|
|
2859
3525
|
id: "devices",
|
|
2860
|
-
apiVersion:
|
|
3526
|
+
apiVersion: 46,
|
|
2861
3527
|
activate: async (ctx) => {
|
|
2862
3528
|
const direct = (input, init) => ctx.proxies.fetch(PLUGIN_PROXY_DIRECT).then((fetchDirect) => fetchDirect(input, init));
|
|
2863
3529
|
const host = new LocalDeviceHost({
|
|
3530
|
+
pluginDir: ctx.paths.pluginDir,
|
|
3531
|
+
...ctx.paths.runtimeDir ? { runtimeDir: ctx.paths.runtimeDir } : {},
|
|
2864
3532
|
dataDir: ctx.paths.dataDir,
|
|
2865
|
-
helperPath:
|
|
3533
|
+
helperPath: NodePath9.join(ctx.paths.pluginDir, "dist", "helper.mjs"),
|
|
2866
3534
|
process: ctx.process,
|
|
2867
3535
|
fetch: direct,
|
|
2868
3536
|
downloadFetch: ctx.http,
|
|
@@ -2884,8 +3552,31 @@ var index_default = definePlugin({
|
|
|
2884
3552
|
});
|
|
2885
3553
|
ctx.toolkit(deviceToolkit(service));
|
|
2886
3554
|
registerCommands(ctx, service);
|
|
3555
|
+
const environments = new DeviceEnvironments({
|
|
3556
|
+
dataDir: ctx.paths.dataDir,
|
|
3557
|
+
helperPath: NodePath9.join(ctx.paths.pluginDir, "dist", "helper.mjs"),
|
|
3558
|
+
runtimeDir: bundledToolPaths(ctx.paths.pluginDir, HUB_SPEC, ctx.paths.runtimeDir).installDir,
|
|
3559
|
+
process: ctx.process,
|
|
3560
|
+
fetch: ctx.http,
|
|
3561
|
+
log: ctx.log,
|
|
3562
|
+
externalSdk: async () => {
|
|
3563
|
+
const probe = await host.machine(true);
|
|
3564
|
+
return {
|
|
3565
|
+
root: probe.android.root,
|
|
3566
|
+
available: platformAvailability(probe, "android").available
|
|
3567
|
+
};
|
|
3568
|
+
},
|
|
3569
|
+
beforeMutation: () => service.beginEnvironmentMutation(),
|
|
3570
|
+
afterPrepared: async () => {
|
|
3571
|
+
await service.inspect(true);
|
|
3572
|
+
}
|
|
3573
|
+
});
|
|
3574
|
+
registerEnvironmentCommands(ctx, environments);
|
|
2887
3575
|
ctx.settingsCard(SETTINGS_CARD);
|
|
2888
|
-
ctx.onDispose(() =>
|
|
3576
|
+
ctx.onDispose(async () => {
|
|
3577
|
+
await environments.dispose();
|
|
3578
|
+
await service.stop();
|
|
3579
|
+
});
|
|
2889
3580
|
}
|
|
2890
3581
|
});
|
|
2891
3582
|
export {
|