create-opentray 0.0.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +163 -0
  2. package/dist/assets/create-openspec-template-iOS-Dark-1024@1x.png +0 -0
  3. package/dist/assets/create-openspec-template-iOS-Default-1024@1x.png +0 -0
  4. package/dist/bin-BT_kcLuP.mjs +3823 -0
  5. package/dist/bin-BT_kcLuP.mjs.map +1 -0
  6. package/dist/bin-CeFW1wxR.d.mts +15 -0
  7. package/dist/bin-CeFW1wxR.d.mts.map +1 -0
  8. package/dist/bin.d.mts +2 -0
  9. package/dist/bin.mjs +3 -0
  10. package/dist/icon-codec-BQ3cGV1n.mjs +13 -0
  11. package/dist/icon-codec-BQ3cGV1n.mjs.map +1 -0
  12. package/dist/index.d.mts +557 -0
  13. package/dist/index.d.mts.map +1 -0
  14. package/dist/index.mjs +2 -0
  15. package/dist/shell/assets/__vite-browser-external-2447137e.js +1 -0
  16. package/dist/shell/assets/browse.js +1 -0
  17. package/dist/shell/assets/ghostty-web.js +13 -0
  18. package/dist/shell/assets/index.css +1 -0
  19. package/dist/shell/assets/index.js +9 -0
  20. package/dist/shell/assets/input.js +1 -0
  21. package/dist/shell/assets/main.js +43 -0
  22. package/dist/shell/assets/terminal-pane.js +2 -0
  23. package/dist/shell/assets/terminal.js +1 -0
  24. package/dist/shell/browse.html +15 -0
  25. package/dist/shell/ghostty-vt.wasm +0 -0
  26. package/dist/shell/index.html +15 -0
  27. package/dist/shell/terminal.html +15 -0
  28. package/dist/webui/assets/__vite-browser-external-2447137e.js +1 -0
  29. package/dist/webui/assets/browse.js +1 -0
  30. package/dist/webui/assets/ghostty-web.js +13 -0
  31. package/dist/webui/assets/index.css +1 -0
  32. package/dist/webui/assets/index.js +9 -0
  33. package/dist/webui/assets/input.js +1 -0
  34. package/dist/webui/assets/main.js +43 -0
  35. package/dist/webui/assets/terminal-pane.js +2 -0
  36. package/dist/webui/assets/terminal.js +1 -0
  37. package/dist/webui/browse.html +15 -0
  38. package/dist/webui/ghostty-vt.wasm +0 -0
  39. package/dist/webui/index.html +16 -0
  40. package/dist/webui/terminal.html +15 -0
  41. package/dist/webui/vendor/ghostty-vt.wasm +0 -0
  42. package/dist/webui/vendor/ghostty-web.js +2963 -0
  43. package/package.json +45 -5
  44. package/index.js +0 -1
@@ -0,0 +1,3823 @@
1
+ import { createRequire } from "node:module";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { access, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
4
+ import { homedir, tmpdir } from "node:os";
5
+ import { delimiter, dirname, extname, isAbsolute, join, resolve, sep } from "node:path";
6
+ import { execFile, spawn } from "node:child_process";
7
+ import net from "node:net";
8
+ import { fileURLToPath } from "node:url";
9
+ import sharp from "sharp";
10
+ import { resolveDefaultDarwinAppBundlePath, sanitizeAppBundleName } from "@opentray/packaging";
11
+ import { generateOpenTrayAppIcon } from "@opentray/vite-plugin";
12
+ import { constants } from "node:fs";
13
+ import { createServer } from "node:http";
14
+ //#region src/app-id.ts
15
+ /** A token that looks like a command option, e.g. `--xx`, `-p`, or `--port=8080`. */
16
+ const isOptionToken = (token) => token.startsWith("-") && token.length > 1;
17
+ /**
18
+ * Default appId derivation. `npx somecommand start --xx` keeps the pre-option
19
+ * tokens `["npx", "somecommand", "start"]`, reverses them, and dot-joins:
20
+ * `start.somecommand.npx`.
21
+ */
22
+ const deriveDefaultAppId = (tokens) => {
23
+ const preOption = [];
24
+ for (const token of tokens) {
25
+ if (isOptionToken(token)) break;
26
+ preOption.push(token);
27
+ }
28
+ const segments = preOption.map((token) => token.split(/[/\\]/).pop() ?? token).filter((segment) => segment.length > 0).reverse();
29
+ if (segments.length === 0) return "app.opentray";
30
+ return segments.join(".");
31
+ };
32
+ /** Human display name from the appId derivation: `Somecommand Start`. */
33
+ const deriveDefaultAppName = (tokens) => {
34
+ return deriveDefaultAppId(tokens).split(".").filter((segment) => segment.length > 0).map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)).join(" ");
35
+ };
36
+ /** Directory-safe project name from an appId (mirrors packaging normalizeAppId semantics). */
37
+ const toProjectDirectoryName = (appId) => {
38
+ const normalized = appId.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
39
+ return normalized.length > 0 ? normalized : "opentray-app";
40
+ };
41
+ /** True when the appId has the shape consumers expect for stable identity. */
42
+ const isValidAppId = (appId) => {
43
+ const trimmed = appId.trim();
44
+ return trimmed.length > 0 && /^[a-z0-9]+(\.[a-z0-9-]+)+$/iu.test(trimmed);
45
+ };
46
+ //#endregion
47
+ //#region src/command-run.ts
48
+ const SHELL_METACHARS = /[<>&|;$`"'%]/u;
49
+ /** True when the command line needs a shell (only Windows uses cmd /c). */
50
+ const needsShell = (tokens) => tokens.some((token) => SHELL_METACHARS.test(token) && token.length > 1);
51
+ let ptyProbe;
52
+ /**
53
+ * Feature-detect the optional native PTY dependency. A failed or missing
54
+ * install must never break the wizard: callers fall back to pipe mode.
55
+ * The probe result is cached so repeated commands do not re-require it.
56
+ */
57
+ const loadPtyModule = (probe = defaultPtyProbe) => {
58
+ ptyProbe ??= probe().catch(() => void 0);
59
+ return ptyProbe;
60
+ };
61
+ const defaultPtyProbe = async () => {
62
+ if (process.versions.bun !== void 0) return;
63
+ const require = createRequire(import.meta.url);
64
+ for (const request of ["@lydell/node-pty", "node-pty"]) try {
65
+ const raw = require(request);
66
+ if (typeof raw.spawn === "function") return raw;
67
+ } catch {}
68
+ };
69
+ const DEFAULT_TERMINAL_SIZE = {
70
+ cols: 100,
71
+ rows: 30
72
+ };
73
+ /** The Bun global when running under Bun with the native Terminal API. */
74
+ const bunTerminalRuntime = () => {
75
+ const runtime = globalThis.Bun;
76
+ if (typeof runtime !== "object" || runtime === null) return;
77
+ const candidate = runtime;
78
+ if (typeof candidate.Terminal !== "function" || typeof candidate.spawn !== "function") return;
79
+ return candidate;
80
+ };
81
+ /**
82
+ * Native Bun PTY backend: `Bun.Terminal` + `Bun.spawn({ terminal })`. Under
83
+ * Bun this replaces @lydell/node-pty entirely — the optional native module
84
+ * loads but never delivers output under Bun, while the built-in Terminal is
85
+ * first-class (verified Bun 1.3.14: output, stdin echo, resize, exit codes).
86
+ */
87
+ const startBunTerminalRun = (options, bun) => {
88
+ const [command, ...args] = options.tokens;
89
+ if (command === void 0) return emptyRun(options, "command is empty");
90
+ const size = options.terminalSize ?? DEFAULT_TERMINAL_SIZE;
91
+ const ring = [];
92
+ const ringLimit = options.ringLimit ?? 200;
93
+ const decoder = new TextDecoder();
94
+ const append = (chunk) => {
95
+ ring.push(chunk);
96
+ if (ring.length > ringLimit) ring.splice(0, ring.length - ringLimit);
97
+ };
98
+ const terminal = new bun.Terminal({
99
+ cols: size.cols,
100
+ rows: size.rows,
101
+ name: "xterm-256color",
102
+ data: (_terminal, data) => {
103
+ const text = decoder.decode(data);
104
+ if (text.length === 0) return;
105
+ append(text);
106
+ options.onEvent({
107
+ type: "stdout",
108
+ chunk: text
109
+ });
110
+ }
111
+ });
112
+ const proc = bun.spawn([command, ...args], {
113
+ terminal,
114
+ cwd: options.cwd ?? globalThis.process.cwd(),
115
+ env: {
116
+ ...globalThis.process.env,
117
+ ...options.env,
118
+ TERM: "xterm-256color"
119
+ }
120
+ });
121
+ options.onEvent({ type: "pty-ready" });
122
+ const exited = proc.exited.then((code) => {
123
+ options.onEvent({
124
+ type: "exit",
125
+ code
126
+ });
127
+ return { code };
128
+ });
129
+ let killPromise;
130
+ return {
131
+ pid: proc.pid,
132
+ pty: true,
133
+ exited,
134
+ output: ring,
135
+ write(data) {
136
+ terminal.write(data);
137
+ },
138
+ resize({ cols, rows }) {
139
+ terminal.resize(cols, rows);
140
+ },
141
+ kill() {
142
+ killPromise ??= (async () => {
143
+ try {
144
+ proc.kill();
145
+ } catch {}
146
+ try {
147
+ terminal.close();
148
+ } catch {}
149
+ await exited.catch(() => void 0);
150
+ })();
151
+ return killPromise;
152
+ }
153
+ };
154
+ };
155
+ const startCommandRun = async (options) => {
156
+ if (options.pty !== false) {
157
+ const bun = bunTerminalRuntime();
158
+ if (bun !== void 0) try {
159
+ return startBunTerminalRun(options, bun);
160
+ } catch (error) {
161
+ options.onEvent({
162
+ type: "spawn-error",
163
+ message: `bun terminal spawn failed: ${error instanceof Error ? error.message : String(error)}`
164
+ });
165
+ }
166
+ else if (process.versions.bun !== void 0) options.onEvent({
167
+ type: "pty-unavailable",
168
+ message: "Bun 版本缺少 Bun.Terminal(需要 Bun ≥ 1.2.19),预览以非交互模式运行。"
169
+ });
170
+ else {
171
+ const ptyModule = await loadPtyModule();
172
+ if (ptyModule !== void 0) try {
173
+ return startPtyRun(options, ptyModule);
174
+ } catch (error) {
175
+ options.onEvent({
176
+ type: "spawn-error",
177
+ message: `pty spawn failed: ${error instanceof Error ? error.message : String(error)}`
178
+ });
179
+ }
180
+ else options.onEvent({
181
+ type: "pty-unavailable",
182
+ message: "node-pty 不可用,预览以非交互模式运行(无法向命令输入内容)。可安装 @lydell/node-pty 启用交互。"
183
+ });
184
+ }
185
+ }
186
+ return startPipeRun(options);
187
+ };
188
+ const startPtyRun = (options, ptyModule) => {
189
+ const [command, ...args] = options.tokens;
190
+ if (command === void 0) return emptyRun(options, "command is empty");
191
+ const size = options.terminalSize ?? DEFAULT_TERMINAL_SIZE;
192
+ const ring = [];
193
+ const ringLimit = options.ringLimit ?? 200;
194
+ const onEvent = options.onEvent;
195
+ const append = (chunk) => {
196
+ ring.push(chunk);
197
+ if (ring.length > ringLimit) ring.splice(0, ring.length - ringLimit);
198
+ };
199
+ const ptyProcess = ptyModule.spawn(command, args, {
200
+ name: "xterm-256color",
201
+ cols: size.cols,
202
+ rows: size.rows,
203
+ cwd: options.cwd ?? globalThis.process.cwd(),
204
+ env: {
205
+ ...globalThis.process.env,
206
+ ...options.env,
207
+ TERM: "xterm-256color"
208
+ }
209
+ });
210
+ onEvent({ type: "pty-ready" });
211
+ const exited = new Promise((resolve) => {
212
+ ptyProcess.onExit(({ exitCode }) => {
213
+ onEvent({
214
+ type: "exit",
215
+ code: exitCode
216
+ });
217
+ resolve({ code: exitCode });
218
+ });
219
+ });
220
+ ptyProcess.onData((data) => {
221
+ if (data.length === 0) return;
222
+ append(data);
223
+ onEvent({
224
+ type: "stdout",
225
+ chunk: data
226
+ });
227
+ });
228
+ let killPromise;
229
+ return {
230
+ pid: ptyProcess.pid,
231
+ pty: true,
232
+ exited,
233
+ output: ring,
234
+ write(data) {
235
+ ptyProcess.write(data);
236
+ },
237
+ resize({ cols, rows }) {
238
+ try {
239
+ ptyProcess.resize(cols, rows);
240
+ } catch {}
241
+ },
242
+ kill: () => {
243
+ killPromise ??= (async () => {
244
+ try {
245
+ ptyProcess.kill();
246
+ } catch {}
247
+ })();
248
+ return killPromise;
249
+ }
250
+ };
251
+ };
252
+ const startPipeRun = (options) => {
253
+ const [command, ...args] = options.tokens;
254
+ if (command === void 0) return emptyRun(options, "command is empty");
255
+ const ring = [];
256
+ const ringLimit = options.ringLimit ?? 200;
257
+ const onEvent = options.onEvent;
258
+ const append = (chunk) => {
259
+ ring.push(chunk);
260
+ if (ring.length > ringLimit) ring.splice(0, ring.length - ringLimit);
261
+ };
262
+ const useShell = process.platform === "win32" && needsShell(options.tokens);
263
+ let child;
264
+ try {
265
+ child = spawn(command, args, {
266
+ cwd: options.cwd,
267
+ env: options.env ?? process.env,
268
+ stdio: [
269
+ "ignore",
270
+ "pipe",
271
+ "pipe"
272
+ ],
273
+ shell: useShell,
274
+ windowsHide: true,
275
+ detached: process.platform !== "win32"
276
+ });
277
+ } catch (error) {
278
+ return emptyRun(options, error instanceof Error ? error.message : String(error), {
279
+ code: null,
280
+ spawnError: error instanceof Error ? error.message : String(error)
281
+ });
282
+ }
283
+ const exited = new Promise((resolve) => {
284
+ child.once("error", (error) => {
285
+ onEvent({
286
+ type: "spawn-error",
287
+ message: error.message
288
+ });
289
+ resolve({
290
+ code: null,
291
+ spawnError: error.message
292
+ });
293
+ });
294
+ child.once("exit", (code) => {
295
+ onEvent({
296
+ type: "exit",
297
+ code
298
+ });
299
+ resolve({ code });
300
+ });
301
+ });
302
+ const pipe = (stream, type) => {
303
+ if (stream === null) return;
304
+ stream.on("data", (chunk) => {
305
+ const text = Buffer.from(chunk).toString("utf8");
306
+ append(text);
307
+ onEvent({
308
+ type,
309
+ chunk: text
310
+ });
311
+ });
312
+ };
313
+ pipe(child.stdout, "stdout");
314
+ pipe(child.stderr, "stderr");
315
+ let killPromise;
316
+ return {
317
+ pid: child.pid,
318
+ pty: false,
319
+ exited,
320
+ output: ring,
321
+ write() {},
322
+ resize() {},
323
+ kill: () => {
324
+ killPromise ??= killProcessTree(child);
325
+ return killPromise;
326
+ }
327
+ };
328
+ };
329
+ const emptyRun = (options, message, resolved) => {
330
+ options.onEvent({
331
+ type: "spawn-error",
332
+ message
333
+ });
334
+ return {
335
+ pid: void 0,
336
+ pty: false,
337
+ exited: Promise.resolve(resolved ?? {
338
+ code: null,
339
+ spawnError: message
340
+ }),
341
+ output: [],
342
+ write() {},
343
+ resize() {},
344
+ kill: async () => {}
345
+ };
346
+ };
347
+ /** Kill a command run and every descendant it spawned. */
348
+ const killProcessTree = async (child) => {
349
+ const pid = child.pid;
350
+ if (pid === void 0) return;
351
+ try {
352
+ if (process.platform === "win32") await runExecFile("taskkill", [
353
+ "/PID",
354
+ String(pid),
355
+ "/T",
356
+ "/F"
357
+ ]);
358
+ else {
359
+ try {
360
+ process.kill(-pid, "SIGTERM");
361
+ } catch {
362
+ child.kill("SIGTERM");
363
+ }
364
+ await waitForExit(child, 3e3).then((exited) => {
365
+ if (!exited) try {
366
+ process.kill(-pid, "SIGKILL");
367
+ } catch {
368
+ child.kill("SIGKILL");
369
+ }
370
+ });
371
+ }
372
+ } catch {}
373
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
374
+ };
375
+ const waitForExit = (child, timeoutMs) => new Promise((resolve) => {
376
+ if (child.exitCode !== null || child.signalCode !== null) {
377
+ resolve(true);
378
+ return;
379
+ }
380
+ const timer = setTimeout(() => resolve(false), timeoutMs);
381
+ child.once("exit", () => {
382
+ clearTimeout(timer);
383
+ resolve(true);
384
+ });
385
+ });
386
+ const runExecFile = (command, args) => new Promise((resolve, reject) => {
387
+ execFile(command, [...args], {
388
+ timeout: 5e3,
389
+ windowsHide: true
390
+ }, (error) => {
391
+ if (error !== null) {
392
+ reject(error);
393
+ return;
394
+ }
395
+ resolve();
396
+ });
397
+ });
398
+ //#endregion
399
+ //#region src/port-scan.ts
400
+ /** Loopback service URL for a discovered port. */
401
+ const serviceUrl = (port) => `http://127.0.0.1:${port}`;
402
+ const LOOPBACK_NO_PROXY = "localhost,127.0.0.1,::1";
403
+ /** Ensure loopback fetches bypass system proxies (same guard as source examples). */
404
+ const ensureLoopbackNoProxy = (env = process.env) => {
405
+ const merged = [env.NO_PROXY ?? env.no_proxy ?? "", LOOPBACK_NO_PROXY].join(",").split(",").map((value) => value.trim()).filter(Boolean);
406
+ env.NO_PROXY = Array.from(new Set(merged)).join(",");
407
+ env.no_proxy = env.NO_PROXY;
408
+ };
409
+ const listListeningPorts = async (platform) => {
410
+ if (platform === "win32") return listWindowsListeningPorts();
411
+ return listLsofListeningPorts();
412
+ };
413
+ /** Listeners with ownership; used to attribute ports to the preview process tree. */
414
+ const listListeningPortOwners = async (platform = process.platform) => {
415
+ if (platform === "win32") {
416
+ const stdout = await runCapture("netstat", [
417
+ "-ano",
418
+ "-p",
419
+ "tcp"
420
+ ]).catch(() => "");
421
+ return parseNetstatPortOwners(stdout);
422
+ }
423
+ const stdout = await runCapture("lsof", [
424
+ "-nP",
425
+ "-iTCP",
426
+ "-sTCP:LISTEN",
427
+ "-F",
428
+ "pPn"
429
+ ]).catch(() => "");
430
+ return parseLsofPortOwners(stdout);
431
+ };
432
+ const listLsofListeningPorts = async () => {
433
+ const stdout = await runCapture("lsof", [
434
+ "-nP",
435
+ "-iTCP",
436
+ "-sTCP:LISTEN",
437
+ "-F",
438
+ "Pn"
439
+ ]).catch(() => "");
440
+ return parseLsofPorts(stdout);
441
+ };
442
+ /**
443
+ * Parse `lsof -F pPn` output into port -> owning PIDs. The field stream is
444
+ * `p<pid>`, `P<proto>`, `n<host:port>` per socket, so each address inherits
445
+ * the most recent pid field.
446
+ */
447
+ const parseLsofPortOwners = (stdout) => {
448
+ const owners = /* @__PURE__ */ new Map();
449
+ let currentPid;
450
+ for (const line of stdout.split("\n")) {
451
+ if (line.startsWith("p")) {
452
+ const pid = Number.parseInt(line.slice(1), 10);
453
+ currentPid = Number.isInteger(pid) ? pid : void 0;
454
+ continue;
455
+ }
456
+ if (!line.startsWith("n")) continue;
457
+ const hostPort = line.slice(1);
458
+ const index = hostPort.lastIndexOf(":");
459
+ if (index < 0) continue;
460
+ const port = Number.parseInt(hostPort.slice(index + 1), 10);
461
+ if (!Number.isInteger(port) || port <= 0 || currentPid === void 0) continue;
462
+ const pids = owners.get(port) ?? /* @__PURE__ */ new Set();
463
+ pids.add(currentPid);
464
+ owners.set(port, pids);
465
+ }
466
+ return owners;
467
+ };
468
+ /** Parse `netstat -ano -p tcp` into port -> owning PIDs (PID is the last column). */
469
+ const parseNetstatPortOwners = (stdout) => {
470
+ const owners = /* @__PURE__ */ new Map();
471
+ for (const rawLine of stdout.split("\n")) {
472
+ const line = rawLine.trim();
473
+ if (!line.toLowerCase().includes("listening")) continue;
474
+ const columns = line.split(/\s+/);
475
+ const local = columns.find((column) => column.includes(":"));
476
+ const pid = Number.parseInt(columns[columns.length - 1] ?? "", 10);
477
+ if (local === void 0 || !Number.isInteger(pid)) continue;
478
+ const index = local.lastIndexOf(":");
479
+ const port = Number.parseInt(local.slice(index + 1), 10);
480
+ if (!Number.isInteger(port) || port <= 0) continue;
481
+ const pids = owners.get(port) ?? /* @__PURE__ */ new Set();
482
+ pids.add(pid);
483
+ owners.set(port, pids);
484
+ }
485
+ return owners;
486
+ };
487
+ const parseLsofPorts = (stdout) => {
488
+ const ports = /* @__PURE__ */ new Set();
489
+ for (const line of stdout.split("\n")) {
490
+ if (!line.startsWith("n")) continue;
491
+ const hostPort = line.slice(1);
492
+ const index = hostPort.lastIndexOf(":");
493
+ if (index < 0) continue;
494
+ const port = Number.parseInt(hostPort.slice(index + 1), 10);
495
+ if (Number.isInteger(port) && port > 0) ports.add(port);
496
+ }
497
+ return ports;
498
+ };
499
+ const listWindowsListeningPorts = async () => {
500
+ try {
501
+ const stdout = await runCapture("netstat", [
502
+ "-ano",
503
+ "-p",
504
+ "tcp"
505
+ ]);
506
+ return parseNetstatPorts(stdout);
507
+ } catch {
508
+ const stdout = await runPowerShellTcpConnections();
509
+ return parsePowerShellPorts(stdout);
510
+ }
511
+ };
512
+ /** Parses `netstat -ano -p tcp` output; keeps LISTENING rows. */
513
+ const parseNetstatPorts = (stdout) => {
514
+ const ports = /* @__PURE__ */ new Set();
515
+ for (const rawLine of stdout.split("\n")) {
516
+ const line = rawLine.trim();
517
+ if (!line.toLowerCase().includes("listening")) continue;
518
+ const local = line.split(/\s+/).find((column) => column.includes(":"));
519
+ if (local === void 0) continue;
520
+ const index = local.lastIndexOf(":");
521
+ const port = Number.parseInt(local.slice(index + 1), 10);
522
+ if (Number.isInteger(port) && port > 0) ports.add(port);
523
+ }
524
+ return ports;
525
+ };
526
+ const runPowerShellTcpConnections = async () => {
527
+ return await runCapture("powershell.exe", [
528
+ "-NoProfile",
529
+ "-NonInteractive",
530
+ "-ExecutionPolicy",
531
+ "Bypass",
532
+ "-Command",
533
+ "[Net.NetworkInformation.NetworkInformation]::GetActiveTcpConnections() | ForEach-Object { $_.LocalEndPoint.Port }"
534
+ ]);
535
+ };
536
+ const parsePowerShellPorts = (stdout) => {
537
+ const ports = /* @__PURE__ */ new Set();
538
+ for (const line of stdout.split("\n")) {
539
+ const port = Number.parseInt(line.trim(), 10);
540
+ if (Number.isInteger(port) && port > 0) ports.add(port);
541
+ }
542
+ return ports;
543
+ };
544
+ /** TCP-connect probe used by the generated app and discovery verification. */
545
+ const waitForTcpPort = async (port, timeoutMs, intervalMs = 150, host = "127.0.0.1") => {
546
+ const deadline = Date.now() + timeoutMs;
547
+ while (Date.now() < deadline) {
548
+ if (await tcpProbe(host, port)) return true;
549
+ await sleep(intervalMs);
550
+ }
551
+ return false;
552
+ };
553
+ const tcpProbe = (host, port, timeoutMs = 500) => new Promise((resolve) => {
554
+ const socket = new net.Socket();
555
+ const finish = (result) => {
556
+ socket.destroy();
557
+ resolve(result);
558
+ };
559
+ socket.setTimeout(timeoutMs);
560
+ socket.once("connect", () => finish(true));
561
+ socket.once("timeout", () => finish(false));
562
+ socket.once("error", () => finish(false));
563
+ socket.connect(port, host);
564
+ });
565
+ /** Verify a port answers with an HTTP response (any status counts). */
566
+ const verifyHttpService = async (port, timeoutMs = 2e3) => {
567
+ ensureLoopbackNoProxy();
568
+ const controller = new AbortController();
569
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
570
+ try {
571
+ return (await fetch(serviceUrl(port), {
572
+ signal: controller.signal,
573
+ redirect: "manual"
574
+ })).status > 0;
575
+ } catch {
576
+ return false;
577
+ } finally {
578
+ clearTimeout(timer);
579
+ }
580
+ };
581
+ /** Collect a PID and every descendant (BFS over `pgrep -P` on POSIX). */
582
+ const collectProcessTreePids = async (rootPid, platform = process.platform, options = {}) => {
583
+ const capture = options.runCapture ?? runCapture;
584
+ const tree = /* @__PURE__ */ new Set([rootPid]);
585
+ if (platform === "win32") return tree;
586
+ const frontier = [rootPid];
587
+ while (frontier.length > 0) {
588
+ const pid = frontier.shift();
589
+ if (pid === void 0) break;
590
+ const stdout = await capture("pgrep", ["-P", String(pid)]).catch(() => "");
591
+ for (const line of stdout.split("\n")) {
592
+ const child = Number.parseInt(line.trim(), 10);
593
+ if (Number.isInteger(child) && child > 0 && !tree.has(child)) {
594
+ tree.add(child);
595
+ frontier.push(child);
596
+ }
597
+ }
598
+ }
599
+ return tree;
600
+ };
601
+ /**
602
+ * Diff-based port discovery. Each poll re-enumerates listeners, keeps ports
603
+ * absent from the baseline, and adds HTTP-verified ones in first-seen order.
604
+ */
605
+ const createPortDiscovery = (options) => {
606
+ const platform = options.platform ?? process.platform;
607
+ const listListeners = options.listListeners ?? listListeningPorts;
608
+ const verifyHttp = options.verifyHttp ?? verifyHttpService;
609
+ const listOwners = options.listOwners ?? (() => listListeningPortOwners(platform));
610
+ const services = /* @__PURE__ */ new Map();
611
+ const verifying = /* @__PURE__ */ new Set();
612
+ const rejected = /* @__PURE__ */ new Set();
613
+ let stopped = false;
614
+ const poll = async () => {
615
+ if (stopped) return [];
616
+ let listeners;
617
+ try {
618
+ listeners = await listListeners(platform);
619
+ } catch {
620
+ return [];
621
+ }
622
+ let owners;
623
+ let ownerPids;
624
+ if (options.resolveOwnerPids !== void 0) {
625
+ const [ownerMap, pids] = await Promise.all([listOwners().catch(() => void 0), options.resolveOwnerPids().catch(() => void 0)]);
626
+ owners = ownerMap;
627
+ ownerPids = pids;
628
+ }
629
+ const added = [];
630
+ const pending = [];
631
+ for (const port of listeners) {
632
+ if (options.baseline.has(port) || services.has(port) || rejected.has(port)) continue;
633
+ if (verifying.has(port)) continue;
634
+ if (owners !== void 0 && ownerPids !== void 0) {
635
+ const portOwners = owners.get(port);
636
+ if (!(portOwners !== void 0 && [...portOwners].some((pid) => ownerPids.has(pid)))) continue;
637
+ }
638
+ verifying.add(port);
639
+ pending.push(verifyHttp(port).then((ok) => {
640
+ if (!ok) {
641
+ rejected.add(port);
642
+ return;
643
+ }
644
+ const service = {
645
+ port,
646
+ url: serviceUrl(port),
647
+ firstSeenAt: Date.now()
648
+ };
649
+ services.set(port, service);
650
+ added.push(service);
651
+ }).catch(() => {
652
+ rejected.add(port);
653
+ }).finally(() => {
654
+ verifying.delete(port);
655
+ }));
656
+ }
657
+ await Promise.all(pending);
658
+ return [...services.values()].sort((a, b) => a.firstSeenAt - b.firstSeenAt);
659
+ };
660
+ return {
661
+ services: () => [...services.values()].sort((a, b) => a.firstSeenAt - b.firstSeenAt),
662
+ poll,
663
+ stop() {
664
+ stopped = true;
665
+ }
666
+ };
667
+ };
668
+ const runCapture = async (command, args) => new Promise((resolve, reject) => {
669
+ execFile(command, [...args], {
670
+ encoding: "utf8",
671
+ timeout: 1e4,
672
+ windowsHide: true
673
+ }, (error, stdout) => {
674
+ if (error !== null) {
675
+ reject(error);
676
+ return;
677
+ }
678
+ resolve(stdout);
679
+ });
680
+ });
681
+ const sleep = (ms) => new Promise((resolve) => {
682
+ setTimeout(resolve, ms);
683
+ });
684
+ //#endregion
685
+ //#region src/icon-compose.ts
686
+ /**
687
+ * App-icon composition (owner round-12): the user's foreground icon is
688
+ * composited onto one of three BACKGROUNDS — black, white, or transparent —
689
+ * and the background is auto-selected for contrast with the artwork's own
690
+ * luminance. The foreground's ORIGINAL PIXELS are always preserved (never
691
+ * recolored); macOS receives an 824px-content variant inside the 1024 canvas
692
+ * (platform best practice), Windows/Linux take the full 1024.
693
+ *
694
+ * The bundled background PNGs carry the squircle alpha mask. That mask is the
695
+ * owner's clipping law (invert → polarize → mask): it is applied to EVERY
696
+ * composition — including the transparent background, whose square source
697
+ * would otherwise render un-rounded on macOS.
698
+ */
699
+ const moduleDirectory$1 = dirname(fileURLToPath(import.meta.url));
700
+ const APP_ICON_CANVAS = 1024;
701
+ const BACKGROUND_FILES = {
702
+ black: "create-openspec-template-iOS-Dark-1024@1x.png",
703
+ white: "create-openspec-template-iOS-Default-1024@1x.png"
704
+ };
705
+ const TRANSPARENT = {
706
+ r: 0,
707
+ g: 0,
708
+ b: 0,
709
+ alpha: 0
710
+ };
711
+ const backgroundCache = /* @__PURE__ */ new Map();
712
+ const assetsDirectory = () => moduleDirectory$1.endsWith(`${sep}src`) ? join(moduleDirectory$1, "..", "assets") : join(moduleDirectory$1, "assets");
713
+ const loadBackground = async (background) => {
714
+ const cached = backgroundCache.get(background);
715
+ if (cached !== void 0) return cached;
716
+ const bytes = await readFile(join(assetsDirectory(), BACKGROUND_FILES[background]));
717
+ backgroundCache.set(background, bytes);
718
+ return bytes;
719
+ };
720
+ /**
721
+ * Full-resolution RGBA pixels of a source (no geometry-altering resize).
722
+ * `.rotate()` applies EXIF orientation so phone-photo icons compose upright.
723
+ * SVG density is raised so large-viewBox art rasterizes instead of hitting
724
+ * the pixel limit wholesale.
725
+ */
726
+ const foregroundRaw = async (sourcePath) => {
727
+ const { data, info } = await sharp(sourcePath, {
728
+ failOn: "none",
729
+ density: 72,
730
+ limitInputPixels: false
731
+ }).rotate().resize(512, 512, {
732
+ fit: "inside",
733
+ withoutEnlargement: false
734
+ }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
735
+ return {
736
+ data,
737
+ width: info.width,
738
+ height: info.height
739
+ };
740
+ };
741
+ /** Both analysis metrics from ONE decode (huge uploads must not double). */
742
+ const foregroundStats = async (sourcePath) => {
743
+ const { data, width, height } = await foregroundRaw(sourcePath);
744
+ let weight = 0;
745
+ let sum = 0;
746
+ let opaque = 0;
747
+ for (let i = 0; i < data.length; i += 4) {
748
+ const a = (data[i + 3] ?? 0) / 255;
749
+ if (a > 0) {
750
+ const lum = (.299 * (data[i] ?? 0) + .587 * (data[i + 1] ?? 0) + .114 * (data[i + 2] ?? 0)) / 255;
751
+ weight += a;
752
+ sum += lum * a;
753
+ }
754
+ if ((data[i + 3] ?? 0) > 16) opaque += 1;
755
+ }
756
+ return {
757
+ luminance: weight < width * height * .02 ? void 0 : sum / weight,
758
+ coverage: opaque / (width * height)
759
+ };
760
+ };
761
+ /** Owner rule: pick the background for a foreground automatically. */
762
+ const autoBackground = (options) => {
763
+ if (options.coverage >= .985) return "transparent";
764
+ return options.luminance !== void 0 && options.luminance > .5 ? "black" : "white";
765
+ };
766
+ let squircleMaskPromise;
767
+ /**
768
+ * The squircle clip mask (1024², single channel) extracted from the bundled
769
+ * background's alpha: 255 inside the rounded tile, 0 outside.
770
+ */
771
+ const squircleMask = () => {
772
+ squircleMaskPromise ??= (async () => {
773
+ const { data, info } = await sharp(await loadBackground("white"), { failOn: "none" }).ensureAlpha().extractChannel("alpha").raw().toBuffer({ resolveWithObject: true });
774
+ if (info.width !== 1024 || info.height !== 1024) throw new Error("squircle mask must be 1024×1024");
775
+ return data;
776
+ })();
777
+ return squircleMaskPromise;
778
+ };
779
+ /** Clip an RGBA buffer to the squircle via a dest-in alpha mask. */
780
+ const clipToSquircle = async (bytes) => {
781
+ const mask = await squircleMask();
782
+ const overlay = Buffer.alloc(APP_ICON_CANVAS * APP_ICON_CANVAS * 4);
783
+ for (let i = 0; i < APP_ICON_CANVAS * APP_ICON_CANVAS; i += 1) {
784
+ const o = i * 4;
785
+ overlay[o] = 255;
786
+ overlay[o + 1] = 255;
787
+ overlay[o + 2] = 255;
788
+ overlay[o + 3] = mask[i] ?? 0;
789
+ }
790
+ return sharp(bytes).composite([{
791
+ input: overlay,
792
+ blend: "dest-in",
793
+ left: 0,
794
+ top: 0,
795
+ raw: {
796
+ width: APP_ICON_CANVAS,
797
+ height: APP_ICON_CANVAS,
798
+ channels: 4
799
+ }
800
+ }]).png({ compressionLevel: 9 }).toBuffer();
801
+ };
802
+ /**
803
+ * Composite the app icon. The foreground's ORIGINAL pixels are preserved on
804
+ * every background — the background choice provides the contrast, not a
805
+ * recolor of the artwork (the round-12 defect that painted a white icon
806
+ * black).
807
+ *
808
+ * Output: `app-composited.png` at CANVAS size (Windows/Linux form — the tile
809
+ * fills the canvas), plus `app-composited-macos.png` where the ENTIRE tile
810
+ * (background + art) is scaled to 824 and centered on the transparent 1024
811
+ * canvas. Dock icons since Big Sur carry those margins instead of running
812
+ * edge-to-edge; scaling only the art (the earlier defect) left the tile
813
+ * filling all available space.
814
+ */
815
+ const composeAppIcon = async (options) => {
816
+ const scale = options.scale ?? .8;
817
+ const outputDir = options.outputDir;
818
+ await mkdir(outputDir, { recursive: true });
819
+ const compositionDir = join(outputDir, compositionCacheKey({
820
+ foregroundPath: options.foregroundPath,
821
+ background: options.background,
822
+ scale
823
+ }));
824
+ await mkdir(compositionDir, { recursive: true });
825
+ const buildComposite = async () => {
826
+ const fgSize = Math.round(APP_ICON_CANVAS * scale);
827
+ const offset = Math.round((APP_ICON_CANVAS - fgSize) / 2);
828
+ const foreground = await sharp(options.foregroundPath, {
829
+ failOn: "none",
830
+ density: 72,
831
+ limitInputPixels: false
832
+ }).rotate().resize(fgSize, fgSize, {
833
+ fit: "contain",
834
+ background: TRANSPARENT
835
+ }).png().toBuffer();
836
+ const composed = await (options.background === "transparent" ? sharp({ create: {
837
+ width: APP_ICON_CANVAS,
838
+ height: APP_ICON_CANVAS,
839
+ channels: 4,
840
+ background: TRANSPARENT
841
+ } }) : sharp(await loadBackground(options.background))).composite([{
842
+ input: foreground,
843
+ top: offset,
844
+ left: offset
845
+ }]).png({ compressionLevel: 9 }).toBuffer();
846
+ return clipToSquircle(composed);
847
+ };
848
+ const writeVariant = async (bytes, suffix) => {
849
+ const path = join(compositionDir, `app-composited${suffix}.png`);
850
+ await writeFile(path, bytes);
851
+ return path;
852
+ };
853
+ const full = await buildComposite();
854
+ const macOSMargin = Math.round((APP_ICON_CANVAS - 824) / 2);
855
+ const macOSBytes = await sharp(full).resize(824, 824, { kernel: sharp.kernel.lanczos3 }).extend({
856
+ top: macOSMargin,
857
+ bottom: macOSMargin,
858
+ left: macOSMargin,
859
+ right: macOSMargin,
860
+ background: TRANSPARENT
861
+ }).png({ compressionLevel: 9 }).toBuffer();
862
+ return {
863
+ compositePath: await writeVariant(full, ""),
864
+ macOSPath: await writeVariant(macOSBytes, "-macos"),
865
+ background: options.background
866
+ };
867
+ };
868
+ /** Stable cache key for a composed icon (wizard-side preview reuse). */
869
+ const compositionCacheKey = (options) => createHash("sha256").update(`${options.foregroundPath}|${options.background}|${options.scale}`).digest("hex").slice(0, 16);
870
+ //#endregion
871
+ //#region src/scrape.ts
872
+ /** Extract `<title>` text from HTML. */
873
+ const extractTitle = (html) => {
874
+ const match = /<title[^>]*>([\s\S]*?)<\/title>/iu.exec(html);
875
+ if (match === null || match[1] === void 0) return;
876
+ const trimmed = match[1].replace(/&amp;/gu, "&").replace(/&lt;/gu, "<").replace(/&gt;/gu, ">").replace(/&quot;/gu, "\"").replace(/&#39;/gu, "'").replace(/\s+/gu, " ").trim();
877
+ return trimmed.length > 0 ? trimmed : void 0;
878
+ };
879
+ /** Extract `<link rel=... href=...>` favicon candidates from HTML head. */
880
+ const extractFaviconCandidates = (html) => {
881
+ const candidates = [];
882
+ const pattern = /<link\b[^>]*>/giu;
883
+ let match;
884
+ while ((match = pattern.exec(html)) !== null) {
885
+ const tag = match[0];
886
+ const rel = /rel\s*=\s*("([^"]*)"|'([^']*)')/iu.exec(tag);
887
+ const href = /href\s*=\s*("([^"]*)"|'([^']*)')/iu.exec(tag);
888
+ if (rel === null || href === null) continue;
889
+ const relValue = (rel[2] ?? rel[3] ?? "").trim().toLowerCase();
890
+ if (!relValue.includes("icon") || relValue.includes("mask")) continue;
891
+ const hrefValue = (href[2] ?? href[3] ?? "").trim();
892
+ if (hrefValue.length === 0) continue;
893
+ const sizesMatch = /sizes\s*=\s*("([^"]*)"|'([^']*)')/iu.exec(tag);
894
+ const sizesValue = sizesMatch?.[2] ?? sizesMatch?.[3];
895
+ candidates.push({
896
+ href: hrefValue,
897
+ rel: relValue,
898
+ ...sizesValue === void 0 ? {} : { sizes: sizesValue }
899
+ });
900
+ }
901
+ return candidates;
902
+ };
903
+ /** Largest dimension of a `sizes` attribute value such as `32x32` or `any`. */
904
+ const faviconCandidateSize = (candidate) => {
905
+ if (candidate.sizes === void 0) return 0;
906
+ const match = /(\d+)\s*x\s*(\d+)/iu.exec(candidate.sizes);
907
+ if (match === null || match[1] === void 0 || match[2] === void 0) return 0;
908
+ return Math.max(Number.parseInt(match[1], 10), Number.parseInt(match[2], 10));
909
+ };
910
+ /** Resolve a favicon href against the service origin. */
911
+ const resolveFaviconUrl = (href, origin) => {
912
+ try {
913
+ return new URL(href, origin).href;
914
+ } catch {
915
+ return;
916
+ }
917
+ };
918
+ /** Order candidates: declared-size icons descending, then apple-touch-icon, then others. */
919
+ const rankFaviconCandidates = (candidates) => {
920
+ const score = (candidate) => {
921
+ const declared = faviconCandidateSize(candidate);
922
+ if (declared > 0) return declared;
923
+ if (candidate.rel.includes("apple-touch-icon")) return 128;
924
+ return 1;
925
+ };
926
+ return [...candidates].sort((a, b) => score(b) - score(a));
927
+ };
928
+ const fetchWithTimeout = async (url, timeoutMs, accept) => {
929
+ ensureLoopbackNoProxy();
930
+ const controller = new AbortController();
931
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
932
+ try {
933
+ return await fetch(url, {
934
+ signal: controller.signal,
935
+ redirect: "follow",
936
+ headers: { accept }
937
+ });
938
+ } catch {
939
+ return;
940
+ } finally {
941
+ clearTimeout(timer);
942
+ }
943
+ };
944
+ const defaultFetch = {
945
+ async page(url, timeoutMs = 5e3) {
946
+ const response = await fetchWithTimeout(url, timeoutMs, "text/html,application/xhtml+xml");
947
+ if (response === void 0) return {
948
+ ok: false,
949
+ status: 0,
950
+ body: "",
951
+ headers: {}
952
+ };
953
+ const body = await response.text();
954
+ const headers = {};
955
+ response.headers.forEach((value, key) => {
956
+ headers[key.toLowerCase()] = value;
957
+ });
958
+ return {
959
+ ok: response.ok,
960
+ status: response.status,
961
+ body,
962
+ headers
963
+ };
964
+ },
965
+ async bytes(url, timeoutMs = 5e3) {
966
+ const response = await fetchWithTimeout(url, timeoutMs, "image/*,*/*;q=0.8");
967
+ if (response === void 0) return {
968
+ ok: false,
969
+ status: 0,
970
+ bytes: Buffer.alloc(0),
971
+ contentType: ""
972
+ };
973
+ const buffer = Buffer.from(await response.arrayBuffer());
974
+ return {
975
+ ok: response.ok,
976
+ status: response.status,
977
+ bytes: buffer,
978
+ contentType: (response.headers.get("content-type") ?? "").toLowerCase()
979
+ };
980
+ }
981
+ };
982
+ /** Cap on downloaded candidates per scrape. */
983
+ const MAX_ICON_DOWNLOADS = 8;
984
+ /**
985
+ * Scrape title and ALL icon candidates from a service port. Never throws:
986
+ * failures return `ok: false` with whatever partial identity was found.
987
+ */
988
+ const scrapeService = async (port, options = {}) => {
989
+ const fetchImpl = options.fetch ?? defaultFetch;
990
+ const origin = serviceUrl(port);
991
+ const page = await fetchImpl.page(origin);
992
+ if (!page.ok) return {
993
+ ok: false,
994
+ title: void 0,
995
+ iconPath: void 0,
996
+ icons: []
997
+ };
998
+ const title = extractTitle(page.body);
999
+ const orderedUrls = [...rankFaviconCandidates(extractFaviconCandidates(page.body)).map((candidate) => resolveFaviconUrl(candidate.href, origin)), `${origin}/favicon.ico`].filter((url) => url !== void 0).filter((url, index, all) => all.indexOf(url) === index).slice(0, MAX_ICON_DOWNLOADS);
1000
+ const dir = await ensureTempIconDir(options.tempDir);
1001
+ const collected = [];
1002
+ for (const url of orderedUrls) {
1003
+ if (collected.length >= MAX_ICON_DOWNLOADS) break;
1004
+ const icon = await fetchImpl.bytes(url);
1005
+ if (!icon.ok || icon.bytes.length < 64) continue;
1006
+ const prepared = await prepareIconBytes(icon.bytes, icon.contentType);
1007
+ if (prepared === void 0) continue;
1008
+ const { bytes, format } = prepared;
1009
+ const meta = await iconDimensions(bytes, format);
1010
+ if (meta === void 0) continue;
1011
+ const hash = await iconPerceptualHash(bytes);
1012
+ if (hash !== void 0 && collected.some((c) => c.hash !== void 0 && hamming(hash, c.hash) <= 8)) continue;
1013
+ const path = await writeIconTemp(bytes, dir);
1014
+ collected.push({
1015
+ url,
1016
+ path,
1017
+ ...meta,
1018
+ format,
1019
+ hash
1020
+ });
1021
+ }
1022
+ collected.sort((a, b) => b.width * a.height === a.width * b.height ? 0 : b.width * b.height - a.width * a.height);
1023
+ const originals = collected.map((c, order) => ({
1024
+ ...c,
1025
+ order
1026
+ }));
1027
+ const solids = [];
1028
+ for (const original of originals) for (const variant of ["solid-black", "solid-white"]) {
1029
+ const solid = await renderSolidSilhouette(original.path, variant === "solid-black" ? black : white);
1030
+ if (solid === void 0) continue;
1031
+ const hash = await iconPerceptualHash(solid);
1032
+ if (hash !== void 0 && solids.some((s) => s.hash !== void 0 && hamming(hash, s.hash) <= 6)) continue;
1033
+ const path = await writeIconTemp(solid, dir);
1034
+ solids.push({
1035
+ url: original.url,
1036
+ path,
1037
+ variant,
1038
+ variantOf: original.order,
1039
+ hash
1040
+ });
1041
+ }
1042
+ const icons = [...originals.map((c) => ({
1043
+ index: c.order,
1044
+ url: c.url,
1045
+ path: c.path,
1046
+ width: c.width,
1047
+ height: c.height,
1048
+ format: c.format,
1049
+ variant: "original"
1050
+ })), ...solids.map((s, i) => ({
1051
+ index: originals.length + i,
1052
+ url: s.url,
1053
+ path: s.path,
1054
+ width: SOLID_SIZE,
1055
+ height: SOLID_SIZE,
1056
+ format: "png",
1057
+ variant: s.variant,
1058
+ variantOf: s.variantOf
1059
+ }))];
1060
+ return {
1061
+ ok: true,
1062
+ title,
1063
+ iconPath: originals[0]?.path,
1064
+ ...originals[0] === void 0 ? {} : { iconUrl: originals[0].url },
1065
+ icons
1066
+ };
1067
+ };
1068
+ const black = {
1069
+ r: 0,
1070
+ g: 0,
1071
+ b: 0
1072
+ };
1073
+ const white = {
1074
+ r: 255,
1075
+ g: 255,
1076
+ b: 255
1077
+ };
1078
+ const SOLID_SIZE = 128;
1079
+ /**
1080
+ * Render a solid-color silhouette from an icon's alpha mask (RGB discarded,
1081
+ * alpha kept) — the shape language macOS tray templates want. Non-decodable
1082
+ * sources (e.g. corrupt bytes) return undefined instead of failing the scrape.
1083
+ */
1084
+ const renderSolidSilhouette = async (sourcePath, color) => {
1085
+ try {
1086
+ const sharp = (await import("sharp")).default;
1087
+ const { data, info } = await sharp(sourcePath, { failOn: "none" }).resize(SOLID_SIZE, SOLID_SIZE, {
1088
+ fit: "contain",
1089
+ background: {
1090
+ r: 0,
1091
+ g: 0,
1092
+ b: 0,
1093
+ alpha: 0
1094
+ }
1095
+ }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1096
+ if (info.channels !== 4) return;
1097
+ const out = Buffer.alloc(data.length);
1098
+ for (let i = 0; i < data.length; i += 4) {
1099
+ out[i] = color.r;
1100
+ out[i + 1] = color.g;
1101
+ out[i + 2] = color.b;
1102
+ out[i + 3] = data[i + 3] ?? 0;
1103
+ }
1104
+ return sharp(out, { raw: {
1105
+ width: info.width,
1106
+ height: info.height,
1107
+ channels: 4
1108
+ } }).png().toBuffer();
1109
+ } catch {
1110
+ return;
1111
+ }
1112
+ };
1113
+ /** Recognizable raster image signatures (PNG/JPEG/GIF/ICO/BMP/WebP). */
1114
+ const hasRasterImageSignature = (bytes) => {
1115
+ if (bytes.length < 16) return false;
1116
+ if (bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71 && bytes[4] === 13 && bytes[5] === 10 && bytes[6] === 26 && bytes[7] === 10 && bytes.subarray(12, 16).toString("latin1") === "IHDR") return true;
1117
+ if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return true;
1118
+ if (bytes.subarray(0, 4).toString("latin1") === "RIFF" && bytes.subarray(8, 12).toString("latin1") === "WEBP") return true;
1119
+ if (bytes[0] === 0 && bytes[1] === 0 && bytes[2] === 1 && bytes[3] === 0) return true;
1120
+ if (bytes.subarray(0, 6).toString("latin1").startsWith("GIF8")) return true;
1121
+ if (bytes[0] === 66 && bytes[1] === 77) return true;
1122
+ return false;
1123
+ };
1124
+ const ensureTempIconDir = async (tempDir) => {
1125
+ if (tempDir !== void 0) return tempDir;
1126
+ return mkdtemp(join(tmpdir(), "create-opentray-icon-"));
1127
+ };
1128
+ /**
1129
+ * Validate and normalize candidate bytes. Returns decodable image bytes plus a
1130
+ * format tag: raster signatures pass through, SVG text passes through, and ICO
1131
+ * containers are cracked open to their largest frame (PNG payload extracted
1132
+ * verbatim, BMP DIB rows converted to PNG) because sharp cannot read ICO.
1133
+ */
1134
+ const prepareIconBytes = async (bytes, contentType) => {
1135
+ if (looksLikeSvg(bytes, contentType)) return {
1136
+ bytes: await densifySvg(bytes),
1137
+ format: "svg"
1138
+ };
1139
+ if (isIcoContainer(bytes)) {
1140
+ const extracted = await extractLargestIcoFrame(bytes);
1141
+ if (extracted !== void 0) return {
1142
+ bytes: extracted,
1143
+ format: "png"
1144
+ };
1145
+ return;
1146
+ }
1147
+ if (hasRasterImageSignature(bytes)) {
1148
+ const format = rasterFormatOf(bytes);
1149
+ return format === void 0 ? void 0 : {
1150
+ bytes,
1151
+ format
1152
+ };
1153
+ }
1154
+ };
1155
+ /**
1156
+ * Rewrite an SVG so it rasterizes at high resolution. sharp (librsvg) pays
1157
+ * no attention to a density attribute; it renders at the declared
1158
+ * width/height. Scraped favicons declare small intrinsic sizes (often just
1159
+ * 16–50px), so the rasterized base bitmap is tiny and every later upscale
1160
+ * (icon catalog, tray, candidates) is blurry. Rewriting the root <svg>
1161
+ * width/height to a large target — viewBox untouched, so vector geometry
1162
+ * scales cleanly — gives every downstream consumer a crisp base.
1163
+ */
1164
+ const SVG_RASTER_TARGET = 1024;
1165
+ const densifySvg = async (bytes) => {
1166
+ const text = bytes.toString("utf8");
1167
+ const svgOpen = text.indexOf("<svg");
1168
+ if (svgOpen === -1) return bytes;
1169
+ const tagEnd = text.indexOf(">", svgOpen);
1170
+ if (tagEnd === -1) return bytes;
1171
+ const openTag = text.slice(svgOpen, tagEnd + 1);
1172
+ let next = openTag;
1173
+ if (/\swidth=/u.test(next)) next = next.replace(/\swidth="[^"]*"/u, ` width="${SVG_RASTER_TARGET}"`);
1174
+ else next = next.replace("<svg", `<svg width="${SVG_RASTER_TARGET}"`);
1175
+ if (/\sheight=/u.test(next)) next = next.replace(/\sheight="[^"]*"/u, ` height="${SVG_RASTER_TARGET}"`);
1176
+ else next = next.replace("<svg", `<svg height="${SVG_RASTER_TARGET}"`);
1177
+ if (next === openTag) return bytes;
1178
+ return Buffer.from(text.slice(0, svgOpen) + next + text.slice(tagEnd + 1), "utf8");
1179
+ };
1180
+ const looksLikeSvg = (bytes, contentType) => {
1181
+ if (contentType.includes("image/svg")) return true;
1182
+ const head = bytes.subarray(0, 512).toString("utf8").trimStart();
1183
+ return head.startsWith("<?xml") || head.startsWith("<svg") || head.includes("<svg");
1184
+ };
1185
+ const rasterFormatOf = (bytes) => {
1186
+ if (bytes[0] === 137 && bytes[1] === 80) return "png";
1187
+ if (bytes[0] === 255 && bytes[1] === 216) return "jpeg";
1188
+ if (bytes[0] === 71 && bytes[1] === 73) return "gif";
1189
+ if (bytes.subarray(0, 4).toString("latin1") === "RIFF") return "webp";
1190
+ };
1191
+ const isIcoContainer = (bytes) => bytes.length >= 8 && bytes[0] === 0 && bytes[1] === 0 && bytes[2] === 1 && bytes[3] === 0;
1192
+ /** Crack an ICO open: pick the largest frame; PNG payloads return verbatim, DIB rows become PNG. */
1193
+ const extractLargestIcoFrame = async (ico) => {
1194
+ const count = ico.readUInt16LE(4);
1195
+ if (count === 0 || count > 64) return;
1196
+ let best;
1197
+ for (let i = 0; i < count; i += 1) {
1198
+ const base = 6 + i * 16;
1199
+ if (base + 16 > ico.length) break;
1200
+ const rawWidth = ico[base] ?? 0;
1201
+ const rawHeight = ico[base + 1] ?? 0;
1202
+ const width = rawWidth === 0 ? 256 : rawWidth;
1203
+ const height = rawHeight === 0 ? 256 : rawHeight;
1204
+ const size = ico.readUInt32LE(base + 8);
1205
+ const offset = ico.readUInt32LE(base + 12);
1206
+ if (offset + size > ico.length) continue;
1207
+ if (best === void 0 || width * height > best.width * best.height) best = {
1208
+ offset,
1209
+ size,
1210
+ width,
1211
+ height
1212
+ };
1213
+ }
1214
+ if (best === void 0) return;
1215
+ const frame = ico.subarray(best.offset, best.offset + best.size);
1216
+ const b0 = frame[0];
1217
+ const b1 = frame[1];
1218
+ if (b0 === 137 && b1 === 80) return frame;
1219
+ return dibToPng(frame, best.width, best.height);
1220
+ };
1221
+ /** Convert a bottom-up BGRA/BGR DIB (BITMAPINFOHEADER) frame to PNG bytes. */
1222
+ const dibToPng = async (dib, width, height) => {
1223
+ if (dib.length < 40) return;
1224
+ const declaredHeight = dib.readInt32LE(8);
1225
+ const bitCount = dib.readUInt16LE(14);
1226
+ const pixels = dib.subarray(40);
1227
+ const rowBytes = Math.ceil(width * bitCount / 8);
1228
+ const rows = Math.abs(declaredHeight) / 2;
1229
+ if (rows === 0 || pixels.length < rowBytes * rows) return;
1230
+ const channels = bitCount === 32 ? 4 : 3;
1231
+ const rgba = Buffer.alloc(width * rows * channels);
1232
+ for (let y = 0; y < rows; y += 1) {
1233
+ const src = pixels.subarray(y * rowBytes, (y + 1) * rowBytes);
1234
+ const flipped = rows - 1 - y;
1235
+ for (let x = 0; x < width; x += 1) {
1236
+ const srcIdx = x * channels;
1237
+ const dstIdx = (flipped * width + x) * channels;
1238
+ const b = src[srcIdx];
1239
+ const g = src[srcIdx + 1];
1240
+ const r = src[srcIdx + 2];
1241
+ if (b === void 0 || g === void 0 || r === void 0) continue;
1242
+ rgba[dstIdx] = r;
1243
+ rgba[dstIdx + 1] = g;
1244
+ rgba[dstIdx + 2] = b;
1245
+ if (channels === 4) rgba[dstIdx + 3] = src[srcIdx + 3] ?? 255;
1246
+ }
1247
+ }
1248
+ const { toPngBuffer } = await import("./icon-codec-BQ3cGV1n.mjs");
1249
+ return toPngBuffer(rgba, width, rows, channels);
1250
+ };
1251
+ /** True pixel dimensions; SVG uses intrinsic attrs, else viewBox, else 512. */
1252
+ const iconDimensions = async (bytes, format) => {
1253
+ if (format === "svg") return svgDimensions(bytes) ?? {
1254
+ width: 512,
1255
+ height: 512
1256
+ };
1257
+ try {
1258
+ const sharp = (await import("sharp")).default;
1259
+ const meta = await sharp(bytes, { failOn: "none" }).metadata();
1260
+ if (meta.width !== void 0 && meta.height !== void 0 && meta.width > 0) return {
1261
+ width: meta.width,
1262
+ height: meta.height
1263
+ };
1264
+ } catch {}
1265
+ if (format === "png" && bytes.length >= 24) {
1266
+ const width = bytes.readUInt32BE(16);
1267
+ const height = bytes.readUInt32BE(20);
1268
+ if (width > 0) return {
1269
+ width,
1270
+ height
1271
+ };
1272
+ }
1273
+ };
1274
+ const svgDimensions = (bytes) => {
1275
+ const head = bytes.subarray(0, 2048).toString("utf8");
1276
+ const num = (value) => {
1277
+ if (value === void 0) return void 0;
1278
+ const parsed = Number.parseFloat(value);
1279
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
1280
+ };
1281
+ const width = num(/<svg[^>]*\bwidth\s*=\s*["']([\d.]+)/iu.exec(head)?.[1]);
1282
+ const height = num(/<svg[^>]*\bheight\s*=\s*["']([\d.]+)/iu.exec(head)?.[1]);
1283
+ if (width !== void 0 && height !== void 0) return {
1284
+ width,
1285
+ height
1286
+ };
1287
+ const viewBox = /viewBox\s*=\s*["']\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/iu.exec(head);
1288
+ const vbWidthRaw = viewBox?.[3];
1289
+ const vbHeightRaw = viewBox?.[4];
1290
+ if (vbWidthRaw !== void 0 && vbHeightRaw !== void 0) {
1291
+ const vbWidth = Number.parseFloat(vbWidthRaw);
1292
+ const vbHeight = Number.parseFloat(vbHeightRaw);
1293
+ if (Number.isFinite(vbWidth) && vbWidth > 0) return {
1294
+ width: vbWidth,
1295
+ height: Number.isFinite(vbHeight) ? vbHeight : vbWidth
1296
+ };
1297
+ }
1298
+ };
1299
+ /** 64-bit average hash over an 8x8 grayscale normalization (perceptual dedupe). */
1300
+ const iconPerceptualHash = async (bytes) => {
1301
+ try {
1302
+ const sharp = (await import("sharp")).default;
1303
+ const { data } = await sharp(bytes, { failOn: "none" }).removeAlpha().flatten({ background: "#ffffff" }).resize(8, 8, { fit: "fill" }).grayscale().raw().toBuffer({ resolveWithObject: true });
1304
+ if (data.length < 64) return;
1305
+ let sum = 0;
1306
+ for (const value of data.subarray(0, 64)) sum += value;
1307
+ const mean = sum / 64;
1308
+ let hash = "";
1309
+ for (let i = 0; i < 64; i += 1) {
1310
+ const value = data[i];
1311
+ hash += value === void 0 ? "0" : value >= mean ? "1" : "0";
1312
+ }
1313
+ return hash;
1314
+ } catch {
1315
+ return;
1316
+ }
1317
+ };
1318
+ const hamming = (a, b) => {
1319
+ let distance = 0;
1320
+ for (let i = 0; i < Math.min(a.length, b.length); i += 1) if (a[i] !== b[i]) distance += 1;
1321
+ return distance;
1322
+ };
1323
+ const writeIconTemp = async (bytes, dir) => {
1324
+ const path = join(dir ?? await mkdtemp(join(tmpdir(), "create-opentray-icon-")), `icon-${createHash("sha256").update(bytes).digest("hex").slice(0, 16)}.bin`);
1325
+ await writeFile(path, bytes);
1326
+ return path;
1327
+ };
1328
+ /**
1329
+ * First-letter glyph fallback source: a self-contained SVG that the icon
1330
+ * generator can rasterize when no favicon was usable.
1331
+ */
1332
+ const createGlyphIconSvg = (appName, accent = "#0A84FF") => {
1333
+ const escaped = (appName.trim().charAt(0) || "A").toUpperCase().replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
1334
+ return [
1335
+ `<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">`,
1336
+ `<rect width="512" height="512" rx="96" fill="${accent}"/>`,
1337
+ `<text x="256" y="256" font-family="-apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="280" font-weight="600" fill="#FFFFFF" text-anchor="middle" dominant-baseline="central">${escaped}</text>`,
1338
+ `</svg>`
1339
+ ].join("");
1340
+ };
1341
+ /** Persist the glyph fallback SVG as a temp icon source. */
1342
+ const writeGlyphIconTemp = async (appName, tempDir) => {
1343
+ const path = join(tempDir, "glyph.svg");
1344
+ await writeFile(path, createGlyphIconSvg(appName), "utf8");
1345
+ return path;
1346
+ };
1347
+ //#endregion
1348
+ //#region src/tokenize.ts
1349
+ /**
1350
+ * Shell-style tokenizer for the wizard command input. Supports single quotes,
1351
+ * double quotes, and backslash escapes outside quotes. It never executes
1352
+ * anything; it only produces the argv that will be spawned.
1353
+ */
1354
+ const tokenizeCommandLine = (input) => {
1355
+ const tokens = [];
1356
+ let current = "";
1357
+ let hasCurrent = false;
1358
+ let index = 0;
1359
+ while (index < input.length) {
1360
+ const char = input[index];
1361
+ if (char === void 0) break;
1362
+ if (isWhitespace(char)) {
1363
+ if (hasCurrent) {
1364
+ tokens.push(current);
1365
+ current = "";
1366
+ hasCurrent = false;
1367
+ }
1368
+ index += 1;
1369
+ continue;
1370
+ }
1371
+ if (char === "\"") {
1372
+ const quoted = readQuoted(input, index, "\"");
1373
+ if (quoted === void 0) return {
1374
+ ok: false,
1375
+ tokens: [],
1376
+ error: "unbalanced double quote in command"
1377
+ };
1378
+ current += quoted.value;
1379
+ hasCurrent = true;
1380
+ index = quoted.nextIndex;
1381
+ continue;
1382
+ }
1383
+ if (char === "'") {
1384
+ const quoted = readQuoted(input, index, "'");
1385
+ if (quoted === void 0) return {
1386
+ ok: false,
1387
+ tokens: [],
1388
+ error: "unbalanced single quote in command"
1389
+ };
1390
+ current += quoted.value;
1391
+ hasCurrent = true;
1392
+ index = quoted.nextIndex;
1393
+ continue;
1394
+ }
1395
+ if (char === "\\" && index + 1 < input.length) {
1396
+ const next = input[index + 1];
1397
+ if (next !== void 0) {
1398
+ current += next;
1399
+ hasCurrent = true;
1400
+ index += 2;
1401
+ continue;
1402
+ }
1403
+ }
1404
+ current += char;
1405
+ hasCurrent = true;
1406
+ index += 1;
1407
+ }
1408
+ if (hasCurrent) tokens.push(current);
1409
+ if (tokens.length === 0) return {
1410
+ ok: false,
1411
+ tokens: [],
1412
+ error: "command is empty"
1413
+ };
1414
+ return {
1415
+ ok: true,
1416
+ tokens,
1417
+ error: void 0
1418
+ };
1419
+ };
1420
+ const readQuoted = (input, start, quote) => {
1421
+ let value = "";
1422
+ let index = start + 1;
1423
+ while (index < input.length) {
1424
+ const char = input[index];
1425
+ if (char === void 0) break;
1426
+ if (char === quote) return {
1427
+ value,
1428
+ nextIndex: index + 1
1429
+ };
1430
+ if (quote === "\"" && char === "\\" && index + 1 < input.length) {
1431
+ const next = input[index + 1];
1432
+ if (next !== void 0 && (next === "\"" || next === "\\")) {
1433
+ value += next;
1434
+ index += 2;
1435
+ continue;
1436
+ }
1437
+ }
1438
+ value += char;
1439
+ index += 1;
1440
+ }
1441
+ };
1442
+ const isWhitespace = (char) => char === " " || char === " " || char === "\n" || char === "\r";
1443
+ //#endregion
1444
+ //#region src/entry-template.ts
1445
+ /** The generated app entry: supervises the command, owns tray + window. */
1446
+ const createEntrySource = (config) => `#!/usr/bin/env node
1447
+ // Generated by create-opentray. Supervises the recorded command and hosts it
1448
+ // in an OpenTray tray + appMode window. Quit lives in the tray menu.
1449
+ import { spawn, execFile, spawnSync } from "node:child_process";
1450
+ import { appendFile, mkdir, readFile } from "node:fs/promises";
1451
+ import http from "node:http";
1452
+ import { dirname, resolve } from "node:path";
1453
+ import { fileURLToPath } from "node:url";
1454
+
1455
+ import { createTray } from "opentray";
1456
+ import { WebviewExt } from "@opentray/ext-webview";
1457
+
1458
+ const PROJECT_DIR = dirname(fileURLToPath(import.meta.url));
1459
+
1460
+ // App Launch Law: persist the ABSOLUTE JS runtime. Under a Bun-hosted
1461
+ // wizard, execPath is bun — resolve node's real path once at startup
1462
+ // instead of persisting a bare PATH lookup (launchd's PATH is minimal).
1463
+ const nodeRuntime = () => {
1464
+ if (process.versions.bun === undefined) return process.execPath;
1465
+ try {
1466
+ const found = spawnSync("which", ["node"], { encoding: "utf8" }).stdout?.trim() ?? "";
1467
+ if (found.length > 0) return found;
1468
+ } catch { /* fall through */ }
1469
+ return "node";
1470
+ };
1471
+ const READY_MARK_PREFIX = "opentray: ready";
1472
+
1473
+ const config = ${JSON.stringify(config, null, 2)};
1474
+
1475
+ const appLogPath = resolve(PROJECT_DIR, "app.log");
1476
+ await mkdir(dirname(appLogPath), { recursive: true });
1477
+ const logSink = appendFile.bind(undefined, appLogPath);
1478
+
1479
+ const shellOptions = ${JSON.stringify(config.shell ?? null)};
1480
+ const hasShell = shellOptions !== null && (shellOptions.showTerminal || shellOptions.showAddressBar);
1481
+ const showTerminal = shellOptions !== null && shellOptions.showTerminal === true;
1482
+ const shellApi = hasShell ? await import("./app-shell-server.mjs") : null;
1483
+
1484
+ // Configured env overlay (advanced command options); empty by default.
1485
+ const commandEnv = ${JSON.stringify(config.command.env ?? {})};
1486
+
1487
+ let command;
1488
+ let commandExited = false;
1489
+ if (showTerminal) {
1490
+ // Startup terminal (advanced option): run the command through a PTY and
1491
+ // stream its bytes to the shell UI — the same tab experience the wizard has.
1492
+ let ptyModule;
1493
+ try {
1494
+ ptyModule = await import("@lydell/node-pty");
1495
+ } catch {
1496
+ ptyModule = null;
1497
+ }
1498
+ const cwd = resolve(PROJECT_DIR, config.command.cwd);
1499
+ if (ptyModule !== null) {
1500
+ const pty = ptyModule.spawn(config.command.command, [...config.command.args], {
1501
+ name: "xterm-256color",
1502
+ cols: 100,
1503
+ rows: 30,
1504
+ cwd,
1505
+ env: { ...process.env, ...commandEnv, TERM: "xterm-256color" },
1506
+ });
1507
+ shellApi?.registerPty(pty);
1508
+ pty.onData((chunk) => {
1509
+ void logSink(chunk, "utf8");
1510
+ shellApi?.pushOutput(chunk);
1511
+ });
1512
+ const exited = new Promise((resolvePromise) => {
1513
+ pty.onExit(({ exitCode }) => {
1514
+ commandExited = true;
1515
+ void logSink(\`[command exited \${exitCode}]\n\`, "utf8");
1516
+ resolvePromise(exitCode);
1517
+ });
1518
+ });
1519
+ command = { pid: pty.pid, exited, kill: async () => { try { pty.kill(); } catch {} await exited; } };
1520
+ } else {
1521
+ await logSink("[create-opentray] @lydell/node-pty unavailable; startup terminal degraded to pipes\\n", "utf8");
1522
+ }
1523
+ }
1524
+ if (command === undefined) {
1525
+ const child = spawn(config.command.command, [...config.command.args], {
1526
+ cwd: resolve(PROJECT_DIR, config.command.cwd),
1527
+ stdio: ["ignore", "pipe", "pipe"],
1528
+ env: { ...process.env, ...commandEnv },
1529
+ windowsHide: true,
1530
+ });
1531
+ child.stdout.setEncoding("utf8");
1532
+ child.stderr.setEncoding("utf8");
1533
+ child.stdout.on("data", (chunk) => { void logSink(chunk, "utf8"); shellApi?.pushOutput(chunk); });
1534
+ child.stderr.on("data", (chunk) => { void logSink(chunk, "utf8"); shellApi?.pushOutput(chunk); });
1535
+ child.once("exit", (code) => {
1536
+ commandExited = true;
1537
+ void logSink(\`[command exited \${code ?? "signal"}]\n\`, "utf8");
1538
+ });
1539
+ command = child;
1540
+ }
1541
+
1542
+ const killCommand = async () => {
1543
+ if (commandExited) return;
1544
+ // command.kill is always a function here: the PTY wrapper (shell mode) or
1545
+ // the ChildProcess (plain mode). The PTY wrapper tears down the session
1546
+ // tree; plain spawns SIGTERM the direct child.
1547
+ await command.kill();
1548
+ };
1549
+
1550
+ let appIcon;
1551
+ try {
1552
+ const manifest = JSON.parse(await readFile(resolve(PROJECT_DIR, "app-icon", "app-icon.json"), "utf8"));
1553
+ // The manifest stores project-relative source paths; OpenTray validates file
1554
+ // sources against the caller's cwd, so canonicalize them to absolute paths
1555
+ // before dispatch (a reused broker cwd must never reinterpret App identity).
1556
+ appIcon = manifest.appIcon.map((asset) => (
1557
+ asset.source?.type === "file" && !asset.source.path.startsWith("/")
1558
+ ? { ...asset, source: { ...asset.source, path: resolve(PROJECT_DIR, "app-icon", asset.source.path) } }
1559
+ : asset
1560
+ ));
1561
+ } catch {
1562
+ appIcon = undefined;
1563
+ }
1564
+
1565
+ const httpAnswers = (port) => new Promise((resolvePromise) => {
1566
+ const request = http.get({ host: "127.0.0.1", port, path: "/", timeout: 1500 }, (response) => {
1567
+ response.resume();
1568
+ resolvePromise(response.statusCode !== undefined && response.statusCode > 0);
1569
+ });
1570
+ request.once("timeout", () => { request.destroy(); resolvePromise(false); });
1571
+ request.once("error", () => resolvePromise(false));
1572
+ });
1573
+
1574
+ const listProcessTreePids = async (rootPid) => {
1575
+ if (process.platform === "win32") return [rootPid];
1576
+ const pids = new Set([rootPid]);
1577
+ const frontier = [rootPid];
1578
+ while (frontier.length > 0) {
1579
+ const pid = frontier.pop();
1580
+ const children = await new Promise((resolvePromise) => {
1581
+ execFile("pgrep", ["-P", String(pid)], (error, stdout) => {
1582
+ resolvePromise(error ? [] : stdout.split("\\n").map((line) => Number(line)).filter((n) => Number.isInteger(n) && n > 0));
1583
+ });
1584
+ });
1585
+ for (const child of children) {
1586
+ if (!pids.has(child)) { pids.add(child); frontier.push(child); }
1587
+ }
1588
+ }
1589
+ return [...pids];
1590
+ };
1591
+
1592
+ const listOwnedListeningPorts = async (rootPid) => {
1593
+ if (process.platform === "win32") return [];
1594
+ const mine = new Set((await listProcessTreePids(rootPid)).map((pid) => String(pid)));
1595
+ const output = await new Promise((resolvePromise) => {
1596
+ execFile("lsof", ["-F", "pPn", "-nP", "-i", "TCP", "-sTCP:LISTEN"], (error, stdout) => {
1597
+ resolvePromise(error ? "" : stdout);
1598
+ });
1599
+ });
1600
+ const ports = new Set();
1601
+ let currentPid = "";
1602
+ for (const line of output.split("\\n")) {
1603
+ if (line.length === 0) continue;
1604
+ const tag = line[0];
1605
+ const value = line.slice(1);
1606
+ if (tag === "p") currentPid = value;
1607
+ else if (tag === "n" && currentPid !== "" && mine.has(currentPid)) {
1608
+ const port = Number(value.split(":").pop());
1609
+ if (Number.isInteger(port) && port > 0) ports.add(port);
1610
+ }
1611
+ }
1612
+ return [...ports];
1613
+ };
1614
+
1615
+ // PORTS COME EXCLUSIVELY FROM SNIFFING (owner law): scan the command's
1616
+ // process tree for listening ports and HTTP-verify each candidate. The
1617
+ // recorded preview port is informational only and is never addressed
1618
+ // without verification; dynamic-port commands (listen(0)) therefore behave
1619
+ // identically to fixed-port ones.
1620
+ const sniffServicePort = async (timeoutMs) => {
1621
+ const deadline = Date.now() + timeoutMs;
1622
+ for (;;) {
1623
+ const owned = await listOwnedListeningPorts(command.pid).catch(() => []);
1624
+ for (const port of owned.filter((p) => p > 0)) {
1625
+ if (await httpAnswers(port)) return port;
1626
+ }
1627
+ if (Date.now() > deadline) {
1628
+ throw new Error("no HTTP service found among the command's listening ports within " + timeoutMs + "ms");
1629
+ }
1630
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 500));
1631
+ }
1632
+ };
1633
+
1634
+ // Shell mode: start the local shell server first so windows can open
1635
+ // immediately; the monitor below sniffs services continuously.
1636
+ const shellPort = hasShell ? await shellApi.listenShell() : null;
1637
+
1638
+ // Plain mode needs one verified address before opening its single window;
1639
+ // shell mode defers entirely to the continuous monitor.
1640
+ const servicePort = hasShell
1641
+ ? null
1642
+ : await sniffServicePort(30_000).catch(async (error) => {
1643
+ await logSink(\`[create-opentray] \${error.message}\\n\`, "utf8");
1644
+ await killCommand();
1645
+ throw error;
1646
+ });
1647
+
1648
+ const trayIcon = ${JSON.stringify(config.trayIcon ?? null)};
1649
+ const trayIconCandidates = trayIcon === null
1650
+ ? {}
1651
+ : trayIcon.template && process.platform === "darwin"
1652
+ ? { "darwin-icon-only": { type: "file", path: resolve(PROJECT_DIR, trayIcon.path), isTemplate: true } }
1653
+ : { "icon-only": { type: "file", path: resolve(PROJECT_DIR, trayIcon.path) } };
1654
+
1655
+ const tray = await createTray({
1656
+ id: config.appId,
1657
+ tooltip: { title: config.appName, description: \`\${config.appName} (OpenTray)\` },
1658
+ icon: Object.keys(trayIconCandidates).length > 0
1659
+ ? trayIconCandidates
1660
+ : { "text-only": "${config.appName.replace(/['"\\]/gu, "").slice(0, 2) || "A"}" },
1661
+ menu: { items: [
1662
+ { type: "item", id: 1, title: \`Show \${config.appName}\`, primaryEvent: true },
1663
+ { type: "separator" },
1664
+ { type: "item", id: 2, title: "Quit" },
1665
+ ] },
1666
+ }, {
1667
+ appId: config.appId,
1668
+ appName: config.appName,
1669
+ ...(appIcon === undefined ? {} : { appIcon }),
1670
+ appLaunch: {
1671
+ // The generated entry may embed a native PTY (@lydell/node-pty), which
1672
+ // requires a Node host — cold launches must not inherit a Bun execPath.
1673
+ command: nodeRuntime(),
1674
+ args: [resolve(PROJECT_DIR, "main.mjs")],
1675
+ cwd: PROJECT_DIR,
1676
+ },
1677
+ });
1678
+
1679
+ const baseTitle = config.appName;
1680
+ const showAddressBar = shellOptions !== null && shellOptions.showAddressBar === true;
1681
+
1682
+ // Dedicated windows (round 9b): one terminal window when enabled, one window
1683
+ // per listened port — an address-bar wrapper page when enabled, the direct
1684
+ // service URL otherwise. No embedded tabs panel.
1685
+ // Each window gets its OWN extension mount (tray.extend per window): one
1686
+ // mount owns exactly one native webview slot, so dedicated windows never
1687
+ // collide on content.
1688
+ const serviceWindows = new Map();
1689
+ let terminalWindow = null;
1690
+
1691
+ const ensureServiceWindow = async (port) => {
1692
+ if (serviceWindows.has(port)) return;
1693
+ const direct = \`http://127.0.0.1:\${port}\`;
1694
+ const url = showAddressBar && shellPort !== null
1695
+ ? \`http://127.0.0.1:\${shellPort}/browse.html?url=\${encodeURIComponent(direct)}\`
1696
+ : direct;
1697
+ const win = tray.extend(WebviewExt).createWebviewWindow({
1698
+ url,
1699
+ width: config.window.width,
1700
+ height: config.window.height,
1701
+ title: baseTitle,
1702
+ style: { appMode: true, autoHide: false, keepOnTop: false },
1703
+ ...(showAddressBar ? {} : { titleSync: { documentToWindow: true, windowToDocument: true } }),
1704
+ ...(showAddressBar ? {} : { iconSync: { faviconToWindow: true, windowToFavicon: true } }),
1705
+ });
1706
+ serviceWindows.set(port, { win, detached: false });
1707
+ await win.show().catch(() => {});
1708
+ };
1709
+
1710
+ if (showTerminal && shellPort !== null) {
1711
+ terminalWindow = tray.extend(WebviewExt).createWebviewWindow({
1712
+ url: \`http://127.0.0.1:\${shellPort}/terminal.html\`,
1713
+ width: 900,
1714
+ height: 560,
1715
+ title: \`\${baseTitle} — Terminal\`,
1716
+ style: { appMode: true, autoHide: false, keepOnTop: false },
1717
+ });
1718
+ await terminalWindow.show().catch(() => {});
1719
+ }
1720
+
1721
+ // Plain mode: the first verified port opens immediately through the SAME
1722
+ // per-port window machinery the monitor uses (no duplicate window object);
1723
+ // additional ports get their own windows as the monitor verifies them.
1724
+ if (!hasShell && servicePort !== null) {
1725
+ await ensureServiceWindow(servicePort);
1726
+ }
1727
+
1728
+ {
1729
+ // Continuous owned-port monitor (BOTH modes): every HTTP-verified listening
1730
+ // port gets its own window, and a port that stops listening marks THAT
1731
+ // window's title (detached). Spec: one window per listened port, never
1732
+ // gated on shell mode.
1733
+ const seenPorts = new Set();
1734
+ const verifiedPorts = new Set();
1735
+ const monitor = setInterval(async () => {
1736
+ try {
1737
+ const owned = await listOwnedListeningPorts(command.pid).catch(() => []);
1738
+ const listening = owned.filter((port) => port > 0);
1739
+ // Spec: owned-listener scan PLUS HTTP verification before a port is
1740
+ // ever addressed — the monitor never adopts a non-HTTP listener.
1741
+ for (const port of listening) {
1742
+ if (!verifiedPorts.has(port) && (await httpAnswers(port))) {
1743
+ verifiedPorts.add(port);
1744
+ }
1745
+ }
1746
+ for (const port of verifiedPorts) seenPorts.add(port);
1747
+ const services = [...seenPorts].map((port) => ({
1748
+ port,
1749
+ detached: !listening.includes(port),
1750
+ }));
1751
+ shellApi?.setServices(services);
1752
+ for (const service of services) {
1753
+ if (!service.detached) await ensureServiceWindow(service.port);
1754
+ const entry = serviceWindows.get(service.port);
1755
+ if (entry !== undefined && Boolean(entry.detached) !== Boolean(service.detached)) {
1756
+ entry.detached = Boolean(service.detached);
1757
+ const title = service.detached ? \`\${baseTitle} (detached)\` : baseTitle;
1758
+ await entry.win.setTitle(title).catch(() => {});
1759
+ }
1760
+ }
1761
+ } catch {
1762
+ /* monitor tick */
1763
+ }
1764
+ }, 1500);
1765
+ const stopMonitor = () => clearInterval(monitor);
1766
+ process.once("exit", stopMonitor);
1767
+ }
1768
+
1769
+ const quit = async () => {
1770
+ for (const { win } of serviceWindows.values()) {
1771
+ try { await win.destroy(); } catch {}
1772
+ }
1773
+ serviceWindows.clear();
1774
+ if (terminalWindow !== null) {
1775
+ try { await terminalWindow.destroy(); } catch {}
1776
+ }
1777
+ await tray.destroy();
1778
+ await killCommand();
1779
+ process.exit(0);
1780
+ };
1781
+
1782
+ tray.onMenuClick(({ itemId }) => {
1783
+ if (itemId === 1) {
1784
+ const target = terminalWindow !== null
1785
+ ? terminalWindow
1786
+ : serviceWindows.size > 0
1787
+ ? [...serviceWindows.values()][0].win
1788
+ : null;
1789
+ if (target === null) return;
1790
+ void target.isVisible().then(async (visible) => {
1791
+ if (visible) { await target.close(); } else { await target.toVisible(); }
1792
+ }).catch(() => {});
1793
+ return;
1794
+ }
1795
+ if (itemId === 2) void quit();
1796
+ });
1797
+
1798
+ process.on("SIGINT", () => void quit());
1799
+ process.on("SIGTERM", () => void quit());
1800
+
1801
+ console.log(\`\${READY_MARK_PREFIX} \${JSON.stringify({ appId: config.appId, ...(servicePort === null ? {} : { port: servicePort }) })}\`);
1802
+
1803
+ `;
1804
+ //#endregion
1805
+ //#region src/shell-server-template.ts
1806
+ /** The generated app's local shell host: static UI + PTY stream + port state. */
1807
+ const shellServerSource = (options) => `#!/usr/bin/env node
1808
+ // Generated by create-opentray. Local shell host: static UI + PTY stream + port state.
1809
+ import { createServer } from "node:http";
1810
+ import { readFile } from "node:fs/promises";
1811
+ import { extname, join, normalize, resolve, sep } from "node:path";
1812
+ import { fileURLToPath } from "node:url";
1813
+ import { dirname } from "node:path";
1814
+
1815
+ const PROJECT_DIR = dirname(fileURLToPath(import.meta.url));
1816
+ const SHELL_DIR = resolve(PROJECT_DIR, "app-shell");
1817
+
1818
+ // --- state shared with the shell UI ---
1819
+ const state = {
1820
+ command: ${JSON.stringify(options.commandDisplay)},
1821
+ interactive: false, // main.mjs flips this when a PTY is attached
1822
+ output: [], // ring of PTY chunks
1823
+ services: [], // [{ port, detached }]
1824
+ listeners: new Set(), // SSE response streams
1825
+ };
1826
+ const RING_LIMIT = 2000;
1827
+
1828
+ const emit = (event) => {
1829
+ const frame = \`data: \${JSON.stringify(event)}\n\n\`;
1830
+ for (const res of state.listeners) {
1831
+ try {
1832
+ res.write(frame);
1833
+ } catch {
1834
+ /* dropped client */
1835
+ }
1836
+ }
1837
+ };
1838
+
1839
+ const snapshot = () => ({
1840
+ type: "state",
1841
+ command: state.command,
1842
+ interactive: state.interactive,
1843
+ services: state.services,
1844
+ output: state.output.slice(-400),
1845
+ });
1846
+
1847
+ /** main.mjs registers the live PTY handle here. */
1848
+ export const registerPty = (pty) => {
1849
+ state.pty = pty;
1850
+ state.interactive = pty !== null && pty !== undefined;
1851
+ };
1852
+
1853
+ /** main.mjs forwards PTY output chunks here. */
1854
+ export const pushOutput = (chunk) => {
1855
+ state.output.push(chunk);
1856
+ if (state.output.length > RING_LIMIT) {
1857
+ state.output.splice(0, state.output.length - RING_LIMIT);
1858
+ }
1859
+ emit({ type: "log", chunk });
1860
+ };
1861
+
1862
+ /** main.mjs reports the owned-port snapshot; detached flags drive title marks. */
1863
+ export const setServices = (services) => {
1864
+ const changed =
1865
+ services.length !== state.services.length ||
1866
+ services.some(
1867
+ (s, i) =>
1868
+ state.services[i] === undefined ||
1869
+ s.port !== state.services[i].port ||
1870
+ Boolean(s.detached) !== Boolean(state.services[i].detached),
1871
+ );
1872
+ state.services = services;
1873
+ if (changed) {
1874
+ emit({ type: "services", services });
1875
+ }
1876
+ };
1877
+
1878
+ const CT = {
1879
+ ".html": "text/html; charset=utf-8",
1880
+ ".js": "text/javascript; charset=utf-8",
1881
+ ".css": "text/css; charset=utf-8",
1882
+ ".svg": "image/svg+xml",
1883
+ ".png": "image/png",
1884
+ ".wasm": "application/wasm",
1885
+ ".json": "application/json",
1886
+ };
1887
+
1888
+ const server = createServer(async (req, res) => {
1889
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
1890
+ if (url.pathname === "/api/events") {
1891
+ res.writeHead(200, {
1892
+ "content-type": "text/event-stream",
1893
+ "cache-control": "no-store",
1894
+ connection: "keep-alive",
1895
+ });
1896
+ res.write(\`data: \${JSON.stringify(snapshot())}\n\n\`);
1897
+ state.listeners.add(res);
1898
+ req.on("close", () => {
1899
+ state.listeners.delete(res);
1900
+ });
1901
+ return;
1902
+ }
1903
+ if (url.pathname === "/api/terminal-input" && req.method === "POST") {
1904
+ let body = "";
1905
+ for await (const chunk of req) {
1906
+ body += chunk;
1907
+ }
1908
+ try {
1909
+ const { data } = JSON.parse(body);
1910
+ if (state.pty !== null && state.pty !== undefined && typeof data === "string") {
1911
+ state.pty.write(data);
1912
+ }
1913
+ } catch {
1914
+ /* malformed body */
1915
+ }
1916
+ res.writeHead(200, { "content-type": "application/json" }).end('{"ok":true}');
1917
+ return;
1918
+ }
1919
+ // Static shell files with traversal containment; SPA fallback for routes.
1920
+ const relative = normalize(url.pathname).replace(/\\/(\\.\\.\\/?)*/, "");
1921
+ const target = resolve(
1922
+ SHELL_DIR,
1923
+ relative === "" || relative === "/" ? "index.html" : relative,
1924
+ );
1925
+ if (target !== SHELL_DIR && !target.startsWith(SHELL_DIR + sep)) {
1926
+ res.writeHead(404).end();
1927
+ return;
1928
+ }
1929
+ const bytes = await readFile(target).catch(() => undefined);
1930
+ if (bytes === undefined) {
1931
+ const index = await readFile(join(SHELL_DIR, "index.html")).catch(() => undefined);
1932
+ if (index === undefined) {
1933
+ res.writeHead(404).end();
1934
+ return;
1935
+ }
1936
+ res
1937
+ .writeHead(200, { "content-type": CT[".html"], "cache-control": "no-store" })
1938
+ .end(index);
1939
+ return;
1940
+ }
1941
+ res
1942
+ .writeHead(200, {
1943
+ "content-type": CT[extname(target).toLowerCase()] ?? "application/octet-stream",
1944
+ "cache-control": "no-store",
1945
+ })
1946
+ .end(bytes);
1947
+ });
1948
+
1949
+ /** Start listening on an ephemeral loopback port; resolves the port. */
1950
+ export const listenShell = () =>
1951
+ new Promise((resolvePort) => {
1952
+ server.listen(0, "127.0.0.1", () => {
1953
+ resolvePort(server.address().port);
1954
+ });
1955
+ });
1956
+ `;
1957
+ //#endregion
1958
+ //#region src/scaffold.ts
1959
+ const writeScaffold = async (options) => {
1960
+ const projectDir = resolve(options.targetDir);
1961
+ await mkdir(join(projectDir, "app-icon"), { recursive: true });
1962
+ const writtenFiles = [];
1963
+ const write = async (relative, content) => {
1964
+ const path = join(projectDir, relative);
1965
+ await mkdir(join(path, ".."), { recursive: true });
1966
+ await writeFile(path, content, "utf8");
1967
+ writtenFiles.push(relative);
1968
+ };
1969
+ await write("package.json", createPackageJson(options));
1970
+ await write("opentray.app.json", `${JSON.stringify(options.config, null, 2)}\n`);
1971
+ await write("main.mjs", createEntrySource(options.config));
1972
+ const shell = options.config.shell;
1973
+ if (shell !== void 0 && (shell.showTerminal || shell.showAddressBar)) {
1974
+ await write("app-shell-server.mjs", createShellServerSource(options.config));
1975
+ if (options.shellAssetsDir !== void 0) {
1976
+ const { cp } = await import("node:fs/promises");
1977
+ await cp(options.shellAssetsDir, join(projectDir, "app-shell"), { recursive: true });
1978
+ writtenFiles.push("app-shell/");
1979
+ }
1980
+ }
1981
+ await write("README.md", createReadme(options));
1982
+ await write(".gitignore", [
1983
+ "node_modules/\n",
1984
+ "app.log\n",
1985
+ "dist/\n"
1986
+ ].join(""));
1987
+ return {
1988
+ projectDir,
1989
+ entryPath: join(projectDir, "main.mjs"),
1990
+ configPath: join(projectDir, "opentray.app.json"),
1991
+ appIconDir: join(projectDir, "app-icon"),
1992
+ writtenFiles
1993
+ };
1994
+ };
1995
+ const createPackageJson = (options) => {
1996
+ const shell = options.config.shell;
1997
+ const dependencies = {
1998
+ opentray: options.dependencyRange,
1999
+ "@opentray/ext-webview": options.dependencyRange
2000
+ };
2001
+ if (shell !== void 0 && shell.showTerminal) dependencies["@lydell/node-pty"] = "^1.1.0";
2002
+ return `${JSON.stringify({
2003
+ name: toProjectDirectoryName(options.config.appId),
2004
+ version: "0.1.0",
2005
+ private: true,
2006
+ type: "module",
2007
+ description: `${options.config.appName} — OpenTray-hosted app generated by create-opentray`,
2008
+ scripts: { start: "node main.mjs" },
2009
+ dependencies
2010
+ }, null, 2)}\n`;
2011
+ };
2012
+ const createReadme = (options) => `# ${options.config.appName}
2013
+
2014
+ Generated by \`create-opentray\`. This app supervises the recorded start
2015
+ command, hosts it in an OpenTray tray + application window, and can be pinned
2016
+ to the taskbar (Windows) or Dock (macOS).
2017
+
2018
+ ## Run
2019
+
2020
+ ${options.skipInstall === true ? "Install dependencies first, then:" : ""}
2021
+ \`\`\`bash
2022
+ npm run start
2023
+ \`\`\`
2024
+
2025
+ - Service: ${options.config.service.port > 0 ? `http://127.0.0.1:${options.config.service.port} (preview hint; re-sniffed at runtime)` : "sniffed at runtime from the command's owned listening ports"}
2026
+ - Command: \`${options.config.command.command} ${options.config.command.args.join(" ")}\`
2027
+ - Logs: \`app.log\`
2028
+
2029
+ ## Files
2030
+
2031
+ - \`opentray.app.json\` — frozen identity and launch vector
2032
+ - \`app-icon/\` — generated platform icon catalog (ICNS/ICO/PNG)
2033
+ - \`main.mjs\` — app entry: supervises the command and owns the tray session
2034
+ `;
2035
+ /** Shell server entry source: static UI + PTY stream + port state (round 9). */
2036
+ const createShellServerSource = (config) => shellServerSource({ commandDisplay: `${config.command.command} ${config.command.args.join(" ")}`.trim() });
2037
+ //#endregion
2038
+ //#region src/materialize.ts
2039
+ const moduleDirectory = dirname(fileURLToPath(import.meta.url));
2040
+ /** Prebuilt shell UI: prefer the packaged copy (dist/shell), else the
2041
+ * workspace build next to this package. */
2042
+ const resolveShellAssetsDir = async () => {
2043
+ const candidates = [
2044
+ join(moduleDirectory, "shell"),
2045
+ join(moduleDirectory, "..", "dist", "shell"),
2046
+ join(moduleDirectory, "..", "create-webui", "dist")
2047
+ ];
2048
+ for (const candidate of candidates) if (await access(join(candidate, "index.html")).then(() => true, () => false)) return candidate;
2049
+ };
2050
+ const shellAssetsDir = await resolveShellAssetsDir();
2051
+ const errorMessage = (error) => error instanceof Error ? error.message : String(error);
2052
+ const READY_MARKER_PREFIX = "opentray: ready";
2053
+ /** True when the directory exists and contains anything beyond ignorable files. */
2054
+ const isDirectoryOccupied = async (dir) => {
2055
+ let entries;
2056
+ try {
2057
+ entries = await readdir(dir);
2058
+ } catch {
2059
+ return false;
2060
+ }
2061
+ const ignorable = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db"]);
2062
+ return entries.some((entry) => !ignorable.has(entry));
2063
+ };
2064
+ /** Detect package manager from lockfiles then npm_config_user_agent. */
2065
+ const detectPackageManager = (files, userAgent) => {
2066
+ if (files.includes("pnpm-lock.yaml")) return "pnpm";
2067
+ if (files.includes("bun.lockb") || files.includes("bun.lock")) return "bun";
2068
+ if (files.includes("package-lock.json")) return "npm";
2069
+ const agent = (userAgent ?? "").toLowerCase();
2070
+ if (agent.includes("pnpm")) return "pnpm";
2071
+ if (agent.includes("bun")) return "bun";
2072
+ return "npm";
2073
+ };
2074
+ /** Expected stable Darwin bundle path for the generated project's identity. */
2075
+ const expectedDarwinBundlePath = (config) => resolveDefaultDarwinAppBundlePath({
2076
+ homeDir: homedir(),
2077
+ packageName: toProjectDirectoryName(config.appId),
2078
+ appName: sanitizeAppBundleName(config.appName)
2079
+ });
2080
+ const materialize = async (input, context) => {
2081
+ const step = (name, message) => context.log({
2082
+ type: "step",
2083
+ step: name,
2084
+ message
2085
+ });
2086
+ const waitMs = context.waitMs ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
2087
+ const targetDir = resolve(input.targetDir);
2088
+ step("scaffold", `checking target directory ${targetDir}`);
2089
+ if (await isDirectoryOccupied(targetDir)) {
2090
+ if (input.force !== true) throw new Error(`target directory is not empty: ${targetDir} (pass --force or choose another directory)`);
2091
+ step("scaffold", "force: clearing existing target directory");
2092
+ await rm(targetDir, {
2093
+ recursive: true,
2094
+ force: true
2095
+ });
2096
+ }
2097
+ const appIconDir = join(targetDir, "app-icon");
2098
+ await mkdir(appIconDir, { recursive: true });
2099
+ const traySource = input.trayIconSourcePath ?? input.iconSourcePath;
2100
+ let trayIconConfig;
2101
+ if (traySource !== void 0) {
2102
+ const trayPath = join(appIconDir, "tray-icon.png");
2103
+ try {
2104
+ await (await import("sharp")).default(traySource, { failOn: "none" }).resize(128, 128, {
2105
+ fit: "contain",
2106
+ background: {
2107
+ r: 0,
2108
+ g: 0,
2109
+ b: 0,
2110
+ alpha: 0
2111
+ }
2112
+ }).png().toFile(trayPath);
2113
+ const template = input.trayIconIsSolid === true;
2114
+ trayIconConfig = {
2115
+ path: "app-icon/tray-icon.png",
2116
+ template
2117
+ };
2118
+ context.log({
2119
+ type: "log",
2120
+ message: `tray icon: app-icon/tray-icon.png${template ? " (template)" : ""}`
2121
+ });
2122
+ } catch (error) {
2123
+ context.log({
2124
+ type: "log",
2125
+ message: `tray icon unavailable (${errorMessage(error)}); falling back to text tray`
2126
+ });
2127
+ }
2128
+ }
2129
+ let composedIcon;
2130
+ if (input.iconSourcePath !== void 0) try {
2131
+ composedIcon = await composeAppIcon({
2132
+ foregroundPath: input.iconSourcePath,
2133
+ background: input.iconBackground ?? "transparent",
2134
+ scale: input.iconScale ?? .8,
2135
+ outputDir: join(targetDir, "app-icon")
2136
+ });
2137
+ context.log({
2138
+ type: "log",
2139
+ message: `composed app icon (${composedIcon.background} background, macOS 824 / windows 1024)`
2140
+ });
2141
+ } catch (error) {
2142
+ context.log({
2143
+ type: "log",
2144
+ message: `icon composition unavailable (${errorMessage(error)}); using source directly`
2145
+ });
2146
+ }
2147
+ step("scaffold", "writing project files");
2148
+ const shell = input.shell;
2149
+ const scaffold = await writeScaffold({
2150
+ config: {
2151
+ ...input.config,
2152
+ ...shell === void 0 ? {} : { shell },
2153
+ ...trayIconConfig === void 0 ? {} : { trayIcon: trayIconConfig }
2154
+ },
2155
+ targetDir,
2156
+ dependencyRange: input.dependencyRange,
2157
+ skipInstall: input.skipInstall,
2158
+ ...shellAssetsDir === void 0 ? {} : { shellAssetsDir }
2159
+ });
2160
+ context.log({
2161
+ type: "log",
2162
+ message: `wrote ${scaffold.writtenFiles.join(", ")}`
2163
+ });
2164
+ step("icon", "generating platform icon catalog");
2165
+ const iconSource = input.iconSourcePath ?? await writeGlyphIconTemp(input.config.appName, scaffold.appIconDir);
2166
+ const generate = context.generateIcon ?? generateOpenTrayAppIcon;
2167
+ const generateIntoScaffold = async (sourcePath) => generate({
2168
+ sourcePath,
2169
+ ...composedIcon === void 0 ? {} : {
2170
+ composed: true,
2171
+ macosSourcePath: composedIcon.macOSPath
2172
+ },
2173
+ icnsOutputPath: join(scaffold.appIconDir, "app-icon.icns"),
2174
+ icoOutputPath: join(scaffold.appIconDir, "app-icon.ico"),
2175
+ linuxOutputDirectory: join(scaffold.appIconDir, "linux"),
2176
+ manifestOutputPath: join(scaffold.appIconDir, "app-icon.json"),
2177
+ outputPath: join(scaffold.appIconDir, "app-icon.png"),
2178
+ cachePath: join(scaffold.appIconDir, ".cache.json")
2179
+ });
2180
+ const catalogSource = composedIcon !== void 0 && composedIcon.compositePath !== void 0 ? composedIcon.compositePath : input.iconSourcePath;
2181
+ let iconMetadata;
2182
+ try {
2183
+ iconMetadata = await generateIntoScaffold(catalogSource !== void 0 ? catalogSource : iconSource);
2184
+ } catch (error) {
2185
+ context.log({
2186
+ type: "log",
2187
+ message: `icon source unusable (${errorMessage(error)}); falling back to glyph icon`
2188
+ });
2189
+ iconMetadata = await generateIntoScaffold(await writeGlyphIconTemp(input.config.appName, scaffold.appIconDir));
2190
+ }
2191
+ context.log({
2192
+ type: "log",
2193
+ message: `icon assets: icns + ico + ${iconMetadata.linuxPngOutputPaths.length} linux pngs`
2194
+ });
2195
+ if (!input.skipInstall) {
2196
+ step("install", `installing dependencies with ${input.packageManager}`);
2197
+ await (context.runInstall ?? runPackageManagerInstall)({
2198
+ projectDir: scaffold.projectDir,
2199
+ packageManager: input.packageManager,
2200
+ log: (message) => context.log({
2201
+ type: "log",
2202
+ message
2203
+ })
2204
+ });
2205
+ } else step("install", "skipping dependency install (--skip-install)");
2206
+ if (input.skipInstall) {
2207
+ step("launch", "skipping first launch (--skip-install)");
2208
+ context.log({
2209
+ type: "log",
2210
+ message: "install dependencies and run `node main.mjs` to launch"
2211
+ });
2212
+ return {
2213
+ scaffold,
2214
+ projectDir: scaffold.projectDir,
2215
+ bundlePath: void 0
2216
+ };
2217
+ }
2218
+ step("launch", "first launch of the generated app");
2219
+ const launched = await (context.firstLaunchEntry ?? firstLaunchEntry)(scaffold.projectDir);
2220
+ context.log({
2221
+ type: "log",
2222
+ message: `app entry spawned (pid ${launched.pid})`
2223
+ });
2224
+ step("launch", "waiting for app ready marker");
2225
+ await launched.ready;
2226
+ context.log({
2227
+ type: "log",
2228
+ message: `${READY_MARKER_PREFIX} received`
2229
+ });
2230
+ const platform = context.platform ?? process.platform;
2231
+ let bundlePath;
2232
+ if (platform === "darwin") {
2233
+ step("bundle", "verifying stable Darwin app bundle");
2234
+ const expected = expectedDarwinBundlePath(input.config);
2235
+ bundlePath = await waitForDirectory(expected, context.bundleTimeoutMs ?? 6e4, waitMs);
2236
+ context.log({
2237
+ type: "log",
2238
+ message: `stable bundle: ${bundlePath}`
2239
+ });
2240
+ }
2241
+ return {
2242
+ scaffold,
2243
+ projectDir: scaffold.projectDir,
2244
+ bundlePath,
2245
+ firstLaunch: launched
2246
+ };
2247
+ };
2248
+ const runPackageManagerInstall = async (options) => {
2249
+ const { cmd, args } = {
2250
+ npm: {
2251
+ cmd: "npm",
2252
+ args: [
2253
+ "install",
2254
+ "--no-fund",
2255
+ "--no-audit"
2256
+ ]
2257
+ },
2258
+ pnpm: {
2259
+ cmd: "pnpm",
2260
+ args: ["install"]
2261
+ },
2262
+ bun: {
2263
+ cmd: "bun",
2264
+ args: ["install"]
2265
+ }
2266
+ }[options.packageManager];
2267
+ await new Promise((resolvePromise, rejectPromise) => {
2268
+ const child = spawn(cmd, [...args], {
2269
+ cwd: options.projectDir,
2270
+ stdio: [
2271
+ "ignore",
2272
+ "pipe",
2273
+ "pipe"
2274
+ ],
2275
+ windowsHide: true
2276
+ });
2277
+ child.stdout?.setEncoding("utf8");
2278
+ child.stderr?.setEncoding("utf8");
2279
+ child.stdout?.on("data", (chunk) => {
2280
+ for (const line of chunk.split("\n").filter(Boolean)) options.log(line);
2281
+ });
2282
+ child.stderr?.on("data", (chunk) => {
2283
+ for (const line of chunk.split("\n").filter(Boolean)) options.log(line);
2284
+ });
2285
+ child.once("error", rejectPromise);
2286
+ child.once("exit", (code) => {
2287
+ if (code === 0) {
2288
+ resolvePromise();
2289
+ return;
2290
+ }
2291
+ rejectPromise(/* @__PURE__ */ new Error(`${cmd} ${args.join(" ")} exited with ${code ?? "signal"}`));
2292
+ });
2293
+ });
2294
+ };
2295
+ /**
2296
+ * Spawn the generated entry detached with piped stdout, resolving when the
2297
+ * entry prints its ready marker. The child is unref'd so the wizard can exit
2298
+ * without taking the generated app down.
2299
+ */
2300
+ /**
2301
+ * The generated app may embed a native PTY (@lydell/node-pty), which requires
2302
+ * a Node host (Bun loads it but never delivers output). Always launch the
2303
+ * generated entry with Node: prefer the Node currently executing the wizard,
2304
+ * else resolve `node` from PATH.
2305
+ */
2306
+ const nodeExecutable = () => process.versions.bun === void 0 && process.execPath.includes("node") ? process.execPath : "node";
2307
+ const firstLaunchEntry = async (projectDir) => {
2308
+ const child = spawn(nodeExecutable(), [join(projectDir, "main.mjs")], {
2309
+ cwd: projectDir,
2310
+ stdio: [
2311
+ "ignore",
2312
+ "pipe",
2313
+ "inherit"
2314
+ ],
2315
+ detached: true,
2316
+ windowsHide: true
2317
+ });
2318
+ const pid = child.pid;
2319
+ if (pid === void 0) throw new Error(`failed to spawn generated app entry in ${projectDir}`);
2320
+ child.unref();
2321
+ let buffer = "";
2322
+ return {
2323
+ pid,
2324
+ ready: new Promise((resolvePromise, rejectPromise) => {
2325
+ const finish = (error) => {
2326
+ child.stdout?.removeListener("data", onData);
2327
+ if (error !== void 0) {
2328
+ rejectPromise(error);
2329
+ return;
2330
+ }
2331
+ resolvePromise();
2332
+ };
2333
+ const onData = (chunk) => {
2334
+ buffer += chunk;
2335
+ const newline = buffer.indexOf("\n");
2336
+ if (newline < 0) return;
2337
+ const line = buffer.slice(0, newline);
2338
+ buffer = buffer.slice(newline + 1);
2339
+ if (line.startsWith("opentray: ready")) finish(void 0);
2340
+ };
2341
+ child.stdout?.setEncoding("utf8");
2342
+ child.stdout?.on("data", onData);
2343
+ child.once("error", (error) => finish(error));
2344
+ child.once("exit", (code) => {
2345
+ finish(/* @__PURE__ */ new Error(`generated app entry exited early with ${code ?? "signal"}`));
2346
+ });
2347
+ })
2348
+ };
2349
+ };
2350
+ const waitForDirectory = async (path, timeoutMs, waitMs) => {
2351
+ const deadline = Date.now() + timeoutMs;
2352
+ while (Date.now() < deadline) {
2353
+ try {
2354
+ if ((await stat(path)).isDirectory()) return path;
2355
+ } catch {}
2356
+ await waitMs(500);
2357
+ }
2358
+ throw new Error(`stable Darwin app bundle did not appear within ${timeoutMs}ms: ${path}`);
2359
+ };
2360
+ //#endregion
2361
+ //#region src/launch-vector.ts
2362
+ const isExecutable = async (path) => {
2363
+ try {
2364
+ await access(path, constants.X_OK);
2365
+ return true;
2366
+ } catch {
2367
+ return false;
2368
+ }
2369
+ };
2370
+ const readFirstLine = async (path) => {
2371
+ try {
2372
+ return (await readFile(path, "utf8")).split("\n", 1)[0];
2373
+ } catch {
2374
+ return;
2375
+ }
2376
+ };
2377
+ /** Resolve a bare command name against PATH; returns undefined when absent. */
2378
+ const resolveOnPath = async (command, options) => {
2379
+ if (isAbsolute(command) || command.includes("/") || command.includes("\\")) return;
2380
+ const platform = options.platform ?? process.platform;
2381
+ const pathEnv = options.pathEnv ?? (platform === "win32" ? `${process.env.PATH ?? ""}${delimiter}${process.cwd()}` : process.env.PATH ?? "");
2382
+ const extensions = platform === "win32" ? [
2383
+ "",
2384
+ ".cmd",
2385
+ ".exe",
2386
+ ".bat"
2387
+ ] : [""];
2388
+ for (const dir of pathEnv.split(delimiter).filter(Boolean)) for (const ext of extensions) {
2389
+ const candidate = resolve(dir, `${command}${ext}`);
2390
+ if (options.accessFile ? await options.accessFile(candidate).then(() => true, () => false) : await isExecutable(candidate)) return candidate;
2391
+ }
2392
+ };
2393
+ /** Read a `#!/usr/bin/env <interpreter>` shebang; undefined for other files. */
2394
+ const parseShebangInterpreter = (firstLine) => {
2395
+ if (firstLine === void 0 || !firstLine.startsWith("#!")) return;
2396
+ const [interpreter, ...interpreterArgs] = firstLine.slice(2).trim().split(/\s+/u).filter(Boolean);
2397
+ if (interpreter === void 0) return;
2398
+ return {
2399
+ interpreter,
2400
+ args: interpreterArgs
2401
+ };
2402
+ };
2403
+ /**
2404
+ * Resolve the user's command tokens to a PATH-independent vector:
2405
+ * - absolute-ize bare executables through PATH lookup;
2406
+ * - resolve relative script paths against cwd;
2407
+ * - when the executable is an `env <interpreter>` shebang script, run the
2408
+ * interpreter directly with the script as its first argument.
2409
+ * The persisted descriptor never includes an environment map.
2410
+ */
2411
+ const resolveLaunchVector = async (options) => {
2412
+ const [rawCommand, ...restArgs] = options.tokens;
2413
+ if (rawCommand === void 0 || rawCommand.trim().length === 0) throw new Error("launch vector requires a command");
2414
+ options.accessFile;
2415
+ const firstLine = options.firstLine ?? readFirstLine;
2416
+ let command = rawCommand;
2417
+ if (!isAbsolute(command)) {
2418
+ const onPath = await resolveOnPath(command, options);
2419
+ if (onPath !== void 0) command = onPath;
2420
+ else if (command.includes("/") || command.includes("\\")) command = resolve(options.cwd, command);
2421
+ }
2422
+ if ((options.platform ?? process.platform) === "win32" && /\.cmd$/iu.test(rawCommand)) return {
2423
+ command: resolveSystemPath("cmd.exe"),
2424
+ args: [
2425
+ "/d",
2426
+ "/s",
2427
+ "/c",
2428
+ rawCommand,
2429
+ ...restArgs
2430
+ ],
2431
+ cwd: options.cwd
2432
+ };
2433
+ const shebang = parseShebangInterpreter(await firstLine(command).catch(() => void 0));
2434
+ if (shebang !== void 0 && !command.endsWith(".exe")) {
2435
+ let interpreter = shebang.interpreter;
2436
+ if (interpreter === "/usr/bin/env" || interpreter === "env") {
2437
+ const [envTarget, ...envArgs] = shebang.args;
2438
+ if (envTarget !== void 0) return {
2439
+ command: await resolveOnPath(envTarget, options) ?? envTarget,
2440
+ args: [
2441
+ ...envArgs,
2442
+ command,
2443
+ ...restArgs
2444
+ ],
2445
+ cwd: options.cwd
2446
+ };
2447
+ interpreter = "/usr/bin/env";
2448
+ }
2449
+ return {
2450
+ command: interpreter,
2451
+ args: [
2452
+ ...shebang.args,
2453
+ command,
2454
+ ...restArgs
2455
+ ],
2456
+ cwd: options.cwd
2457
+ };
2458
+ }
2459
+ return {
2460
+ command,
2461
+ args: restArgs,
2462
+ cwd: options.cwd
2463
+ };
2464
+ };
2465
+ const resolveSystemPath = (name) => {
2466
+ if (isAbsolute(name)) return name;
2467
+ return `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\${name}`;
2468
+ };
2469
+ //#endregion
2470
+ //#region src/open-app.ts
2471
+ const openMaterializedApp = async (input) => {
2472
+ if ((input.platform ?? process.platform) === "darwin") {
2473
+ if (input.bundlePath === void 0) return {
2474
+ ok: false,
2475
+ detail: "stable Darwin app bundle path is unknown"
2476
+ };
2477
+ const child = spawn("open", [input.bundlePath], {
2478
+ stdio: "ignore",
2479
+ windowsHide: true
2480
+ });
2481
+ const status = await new Promise((resolve) => {
2482
+ child.once("error", () => resolve(null));
2483
+ child.once("exit", (code) => resolve(code));
2484
+ });
2485
+ if (status === 0) return {
2486
+ ok: true,
2487
+ detail: `opened ${input.bundlePath}`
2488
+ };
2489
+ return {
2490
+ ok: false,
2491
+ detail: `open ${input.bundlePath} failed with ${status ?? "spawn error"}`
2492
+ };
2493
+ }
2494
+ const child = spawn(process.execPath, [join(input.projectDir, "main.mjs")], {
2495
+ cwd: input.projectDir,
2496
+ stdio: "ignore",
2497
+ detached: true,
2498
+ windowsHide: true
2499
+ });
2500
+ child.once("error", () => {});
2501
+ child.unref();
2502
+ if (child.pid === void 0) return {
2503
+ ok: false,
2504
+ detail: `failed to spawn ${input.projectDir}/main.mjs`
2505
+ };
2506
+ return {
2507
+ ok: true,
2508
+ detail: `launched app entry (pid ${child.pid})`
2509
+ };
2510
+ };
2511
+ /** Platform-truthful pinning hint; makes no Windows persistence claim. */
2512
+ const pinningHint = (platform = process.platform) => {
2513
+ if (platform === "darwin") return "右键点击 Dock 中的应用图标,选择“选项 → 在程序坞中保留”,即可固定到 Dock。";
2514
+ if (platform === "win32") return "右键点击任务栏中的应用图标,选择“固定到任务栏”即可固定。(OpenTray 尚未生成开始菜单快捷方式)";
2515
+ return "可将应用窗口固定到任务栏/收藏夹;Linux 桌面快捷方式生成尚未提供。";
2516
+ };
2517
+ //#endregion
2518
+ //#region src/wizard.ts
2519
+ const DEFAULT_ICON_SCALE = .8;
2520
+ const DEFAULT_COMMAND_OPTIONS = {
2521
+ cwd: "",
2522
+ env: [],
2523
+ argsMode: "string"
2524
+ };
2525
+ const createWizardSession = (options) => {
2526
+ let state = "idle";
2527
+ let services = [];
2528
+ let selectedPort;
2529
+ let run;
2530
+ let discovery = createPortDiscovery({
2531
+ baseline: /* @__PURE__ */ new Set(),
2532
+ ...options.listListeners === void 0 ? {} : { listListeners: () => options.listListeners() },
2533
+ ...options.verifyHttp === void 0 ? {} : { verifyHttp: options.verifyHttp }
2534
+ });
2535
+ let discoveryTimer;
2536
+ let scrapeTimer;
2537
+ let scraping = false;
2538
+ let tempIconDir;
2539
+ let currentIconPath;
2540
+ let currentTrayIconPath;
2541
+ let trayIconIsSolid = false;
2542
+ let iconCandidates = [];
2543
+ let iconPort;
2544
+ let currentTokens = [];
2545
+ let currentCommand = "";
2546
+ let scrapedTitle;
2547
+ let resolvedVector;
2548
+ let frozenForm;
2549
+ let resolvedServicePort;
2550
+ let resolvedTargetDir;
2551
+ let frozenIconPath;
2552
+ let submitting = false;
2553
+ let runAlive = false;
2554
+ let commandOptions = { ...DEFAULT_COMMAND_OPTIONS };
2555
+ let composeIconDir;
2556
+ const iconCompositions = /* @__PURE__ */ new Map();
2557
+ let iconBackground;
2558
+ let iconScale;
2559
+ const touched = {
2560
+ appId: false,
2561
+ appName: false,
2562
+ iconPath: false,
2563
+ iconBackground: false,
2564
+ iconScale: false,
2565
+ trayIconPath: false,
2566
+ pm: false,
2567
+ force: false,
2568
+ showStartupTerminal: false,
2569
+ showAddressBar: false
2570
+ };
2571
+ let form = {
2572
+ appId: "",
2573
+ appName: "",
2574
+ iconPath: "",
2575
+ iconBackground: "transparent",
2576
+ iconScale: DEFAULT_ICON_SCALE,
2577
+ trayIconPath: "",
2578
+ force: options.force === true,
2579
+ showStartupTerminal: false,
2580
+ showAddressBar: false,
2581
+ pm: options.packageManager ?? detectPackageManager([], process.env.npm_config_user_agent)
2582
+ };
2583
+ let result;
2584
+ const emit = options.emit;
2585
+ const setState = (next, reason) => {
2586
+ state = next;
2587
+ emit({
2588
+ type: "state",
2589
+ state: next,
2590
+ ...reason === void 0 ? {} : { reason }
2591
+ });
2592
+ };
2593
+ const stopTimers = () => {
2594
+ if (discoveryTimer !== void 0) {
2595
+ clearInterval(discoveryTimer);
2596
+ discoveryTimer = void 0;
2597
+ }
2598
+ if (scrapeTimer !== void 0) {
2599
+ clearInterval(scrapeTimer);
2600
+ scrapeTimer = void 0;
2601
+ }
2602
+ };
2603
+ const publishServices = () => {
2604
+ emit({
2605
+ type: "services",
2606
+ services: [...services],
2607
+ selectedPort
2608
+ });
2609
+ };
2610
+ /**
2611
+ * Placeholder defaults: scrapes and derivations update the *suggestions*,
2612
+ * never the user's values. Empty values mean "use the default".
2613
+ */
2614
+ const currentDefaults = () => {
2615
+ const effectiveAppId = form.appId.trim().length > 0 ? form.appId : deriveDefaultAppId(currentTokens);
2616
+ return {
2617
+ appId: deriveDefaultAppId(currentTokens),
2618
+ appName: scrapedTitle ?? deriveDefaultAppName(currentTokens),
2619
+ iconPath: currentIconPath ?? "",
2620
+ targetDir: options.targetDir ?? join(homeDir, ".opentray", "create", toProjectDirectoryName(effectiveAppId))
2621
+ };
2622
+ };
2623
+ const publishForm = () => {
2624
+ emit({
2625
+ type: "form",
2626
+ values: form,
2627
+ defaults: currentDefaults(),
2628
+ targetDirExists
2629
+ });
2630
+ };
2631
+ /** Track whether the resolved target directory is already occupied so the
2632
+ * UI can warn and offer the force toggle. */
2633
+ let targetDirExists = false;
2634
+ let targetDirProbe = 0;
2635
+ const refreshTargetDirExists = () => {
2636
+ const probe = ++targetDirProbe;
2637
+ const target = currentDefaults().targetDir;
2638
+ (async () => {
2639
+ let occupied = false;
2640
+ try {
2641
+ occupied = (await stat(target)).isDirectory();
2642
+ } catch {
2643
+ occupied = false;
2644
+ }
2645
+ if (probe !== targetDirProbe) return;
2646
+ if (occupied !== targetDirExists) {
2647
+ targetDirExists = occupied;
2648
+ publishForm();
2649
+ }
2650
+ })();
2651
+ };
2652
+ const scrapeOnce = async () => {
2653
+ if (scraping || selectedPort === void 0 || state === "frozen" || state === "materializing") return;
2654
+ scraping = true;
2655
+ try {
2656
+ const port = selectedPort;
2657
+ const scraped = await (options.scrape ?? scrapeService)(port, tempIconDir === void 0 ? {} : { tempDir: tempIconDir });
2658
+ if (selectedPort !== port) return;
2659
+ const stateNow = state;
2660
+ if (stateNow === "frozen" || stateNow === "materializing" || stateNow === "success") return;
2661
+ iconCandidates = scraped.icons;
2662
+ iconPort = port;
2663
+ currentIconPath = scraped.icons[0]?.path;
2664
+ if (scraped.title !== void 0) scrapedTitle = scraped.title;
2665
+ emit({
2666
+ type: "icons",
2667
+ port,
2668
+ icons: scraped.icons
2669
+ });
2670
+ emit({
2671
+ type: "scrape",
2672
+ port,
2673
+ ...scraped.title === void 0 ? {} : { title: scraped.title },
2674
+ hasIcon: scraped.icons.length > 0
2675
+ });
2676
+ publishForm();
2677
+ } finally {
2678
+ scraping = false;
2679
+ }
2680
+ };
2681
+ const startScrapePolling = () => {
2682
+ if (scrapeTimer !== void 0) clearInterval(scrapeTimer);
2683
+ scrapeOnce();
2684
+ scrapeTimer = setInterval(() => {
2685
+ scrapeOnce();
2686
+ }, options.scrapeIntervalMs ?? 1500);
2687
+ };
2688
+ const selectDefaultService = () => {
2689
+ if (selectedPort === void 0 && services.length > 0) selectedPort = services[0]?.port;
2690
+ if (selectedPort !== void 0 && state === "running") {
2691
+ setState("discovered");
2692
+ startScrapePolling();
2693
+ }
2694
+ };
2695
+ const startDiscoveryPolling = () => {
2696
+ if (discoveryTimer !== void 0) clearInterval(discoveryTimer);
2697
+ (async () => {
2698
+ const found = await discovery.poll();
2699
+ if (found.length > 0) {
2700
+ const known = new Set(services.map((service) => service.port));
2701
+ services = [...services, ...found.filter((s) => !known.has(s.port))];
2702
+ selectDefaultService();
2703
+ publishServices();
2704
+ }
2705
+ })();
2706
+ discoveryTimer = setInterval(() => {
2707
+ (async () => {
2708
+ const found = await discovery.poll();
2709
+ if (found.length > 0) {
2710
+ const known = new Set(services.map((service) => service.port));
2711
+ services = [...services, ...found.filter((s) => !known.has(s.port))];
2712
+ selectDefaultService();
2713
+ publishServices();
2714
+ }
2715
+ })();
2716
+ }, options.pollIntervalMs ?? 1e3);
2717
+ };
2718
+ /** Default command cwd is the USER_HOME directory (owner round-10 law):
2719
+ * empty input means home, and relative paths resolve against home. */
2720
+ const homeDir = options.homeDir ?? homedir();
2721
+ const effectiveCwd = () => {
2722
+ const custom = commandOptions.cwd.trim();
2723
+ return custom.length > 0 ? resolve(homeDir, custom) : homeDir;
2724
+ };
2725
+ /** Build the env overlay from configured entries (empty keys skipped). */
2726
+ const commandEnv = () => {
2727
+ const env = {};
2728
+ for (const entry of commandOptions.env) if (entry.key.trim().length > 0) env[entry.key.trim()] = entry.value;
2729
+ return env;
2730
+ };
2731
+ const publishCommandOptions = () => {
2732
+ emit({
2733
+ type: "command-options",
2734
+ options: commandOptions,
2735
+ defaultCwd: homeDir
2736
+ });
2737
+ };
2738
+ /** Stable dir for composed preview assets, created on demand. */
2739
+ const ensureIconComposeDir = async () => {
2740
+ if (composeIconDir === void 0) composeIconDir = await mkdtemp(join(tmpdir(), "create-opentray-compose-"));
2741
+ return composeIconDir;
2742
+ };
2743
+ const session = {
2744
+ get state() {
2745
+ return state;
2746
+ },
2747
+ get services() {
2748
+ return services;
2749
+ },
2750
+ get selectedPort() {
2751
+ return selectedPort;
2752
+ },
2753
+ get runAlive() {
2754
+ return runAlive;
2755
+ },
2756
+ get iconCandidates() {
2757
+ return iconCandidates;
2758
+ },
2759
+ iconCandidate(port, index) {
2760
+ if (iconPort !== port) return;
2761
+ return iconCandidates.find((icon) => icon.index === index);
2762
+ },
2763
+ replaceIconCandidates(port, icons) {
2764
+ iconPort = port;
2765
+ iconCandidates = icons;
2766
+ },
2767
+ selectTrayIconCandidate(port, index) {
2768
+ if (state === "frozen" || state === "materializing" || state === "success") return false;
2769
+ const candidate = session.iconCandidate(port, index);
2770
+ if (candidate === void 0) return false;
2771
+ touched.trayIconPath = true;
2772
+ currentTrayIconPath = candidate.path;
2773
+ trayIconIsSolid = candidate.variant !== "original";
2774
+ form = {
2775
+ ...form,
2776
+ trayIconPath: candidate.path
2777
+ };
2778
+ publishForm();
2779
+ return true;
2780
+ },
2781
+ selectIconCandidate(port, index) {
2782
+ if (state === "frozen" || state === "materializing" || state === "success") return false;
2783
+ const candidate = session.iconCandidate(port, index);
2784
+ if (candidate === void 0) return false;
2785
+ touched.iconPath = true;
2786
+ currentIconPath = candidate.path;
2787
+ if (!touched.trayIconPath) {
2788
+ currentTrayIconPath = candidate.path;
2789
+ trayIconIsSolid = candidate.variant !== "original";
2790
+ form = {
2791
+ ...form,
2792
+ iconPath: candidate.path,
2793
+ trayIconPath: candidate.path
2794
+ };
2795
+ } else form = {
2796
+ ...form,
2797
+ iconPath: candidate.path
2798
+ };
2799
+ publishForm();
2800
+ return true;
2801
+ },
2802
+ get form() {
2803
+ return form;
2804
+ },
2805
+ get result() {
2806
+ return result;
2807
+ },
2808
+ async submitCommand(command) {
2809
+ if (state !== "idle" && state !== "failed" && state !== "running" && state !== "discovered") throw new Error(`cannot submit a command while ${state}`);
2810
+ if (submitting) throw new Error("a command submission is already in flight");
2811
+ submitting = true;
2812
+ const tokens = typeof command === "string" ? void 0 : command;
2813
+ let tokenized;
2814
+ if (tokens === void 0) {
2815
+ tokenized = tokenizeCommandLine(command);
2816
+ if (!tokenized.ok) {
2817
+ submitting = false;
2818
+ setState("failed", tokenized.error);
2819
+ return;
2820
+ }
2821
+ } else if (tokens.length === 0 || tokens[0].trim().length === 0) {
2822
+ submitting = false;
2823
+ setState("failed", "数组模式至少需要程序元素(第一个参数)");
2824
+ return;
2825
+ }
2826
+ await session.stop();
2827
+ runAlive = false;
2828
+ services = [];
2829
+ selectedPort = void 0;
2830
+ currentTokens = tokens ?? tokenized.tokens;
2831
+ refreshTargetDirExists();
2832
+ currentIconPath = void 0;
2833
+ currentTrayIconPath = void 0;
2834
+ trayIconIsSolid = false;
2835
+ iconCandidates = [];
2836
+ touched.appId = false;
2837
+ touched.appName = false;
2838
+ touched.pm = false;
2839
+ touched.trayIconPath = false;
2840
+ form = {
2841
+ appId: "",
2842
+ appName: "",
2843
+ iconPath: "",
2844
+ iconBackground: iconBackground ?? "transparent",
2845
+ iconScale: iconScale ?? .8,
2846
+ ...touched.force ? { force: form.force } : { force: options.force === true },
2847
+ ...touched.trayIconPath ? { trayIconPath: form.trayIconPath } : { trayIconPath: "" },
2848
+ ...touched.showStartupTerminal ? { showStartupTerminal: form.showStartupTerminal } : { showStartupTerminal: false },
2849
+ ...touched.showAddressBar ? { showAddressBar: form.showAddressBar } : { showAddressBar: false },
2850
+ pm: options.packageManager ?? detectPackageManager([], process.env.npm_config_user_agent)
2851
+ };
2852
+ currentCommand = typeof command === "string" ? command : command.join(" ");
2853
+ scrapedTitle = void 0;
2854
+ emit({
2855
+ type: "command-display",
2856
+ command: currentCommand
2857
+ });
2858
+ tempIconDir = await mkdtemp(join(tmpdir(), "create-opentray-"));
2859
+ const baseline = await (options.listListeners ?? (() => listListeningPorts(process.platform)))().catch(() => /* @__PURE__ */ new Set());
2860
+ setState("running");
2861
+ publishForm();
2862
+ const spawnRun = options.spawnRun ?? startCommandRun;
2863
+ const envOverlay = commandEnv();
2864
+ run = await spawnRun({
2865
+ tokens: currentTokens,
2866
+ cwd: effectiveCwd(),
2867
+ ...Object.keys(envOverlay).length === 0 ? {} : { env: envOverlay },
2868
+ onEvent: (event) => {
2869
+ if (event.type === "stdout" || event.type === "stderr") {
2870
+ emit({
2871
+ type: "log",
2872
+ stream: event.type,
2873
+ chunk: event.chunk ?? ""
2874
+ });
2875
+ return;
2876
+ }
2877
+ if (event.type === "pty-ready") {
2878
+ emit({
2879
+ type: "term-mode",
2880
+ interactive: true
2881
+ });
2882
+ return;
2883
+ }
2884
+ if (event.type === "pty-unavailable") {
2885
+ emit({
2886
+ type: "term-mode",
2887
+ interactive: false,
2888
+ ...event.message === void 0 ? {} : { message: event.message }
2889
+ });
2890
+ return;
2891
+ }
2892
+ if (event.type === "spawn-error") {
2893
+ stopTimers();
2894
+ setState("failed", event.message ?? "spawn failed");
2895
+ return;
2896
+ }
2897
+ if (event.type === "exit") {
2898
+ runAlive = false;
2899
+ stopTimers();
2900
+ emit({
2901
+ type: "run-status",
2902
+ running: false,
2903
+ ...event.code === void 0 ? {} : { code: event.code }
2904
+ });
2905
+ if (state === "running" && services.length === 0) setState("failed", `command exited with ${event.code ?? "signal"} before any service appeared`);
2906
+ }
2907
+ }
2908
+ });
2909
+ submitting = false;
2910
+ run.exited.then(async ({ code, spawnError }) => {
2911
+ if (state === "running" && services.length === 0 && spawnError !== void 0) {
2912
+ stopTimers();
2913
+ setState("failed", spawnError);
2914
+ }
2915
+ });
2916
+ discovery = createPortDiscovery({
2917
+ baseline,
2918
+ ...options.listListeners === void 0 ? {} : { listListeners: () => options.listListeners() },
2919
+ ...options.verifyHttp === void 0 ? {} : { verifyHttp: options.verifyHttp },
2920
+ ...options.listPortOwners === void 0 ? {} : { listOwners: options.listPortOwners },
2921
+ ...run.pid === void 0 ? {} : { resolveOwnerPids: () => collectProcessTreePids(run?.pid ?? 0, options.platform ?? process.platform) }
2922
+ });
2923
+ runAlive = true;
2924
+ emit({
2925
+ type: "run-status",
2926
+ running: true
2927
+ });
2928
+ startDiscoveryPolling();
2929
+ },
2930
+ prime(command) {
2931
+ if (state === "frozen" || state === "materializing" || state === "success") return;
2932
+ if (typeof command !== "string") {
2933
+ if (command.length === 0 || command[0].trim().length === 0) return;
2934
+ currentTokens = command;
2935
+ currentCommand = command.join(" ");
2936
+ emit({
2937
+ type: "command-display",
2938
+ command: currentCommand
2939
+ });
2940
+ refreshTargetDirExists();
2941
+ publishForm();
2942
+ return;
2943
+ }
2944
+ const tokenized = tokenizeCommandLine(command);
2945
+ if (!tokenized.ok) return;
2946
+ currentTokens = tokenized.tokens;
2947
+ currentCommand = command;
2948
+ emit({
2949
+ type: "command-display",
2950
+ command
2951
+ });
2952
+ refreshTargetDirExists();
2953
+ publishForm();
2954
+ },
2955
+ get commandOptions() {
2956
+ return commandOptions;
2957
+ },
2958
+ updateCommandOptions(patch) {
2959
+ if (state === "frozen" || state === "materializing" || state === "success") return;
2960
+ commandOptions = {
2961
+ ...commandOptions,
2962
+ ...patch
2963
+ };
2964
+ publishCommandOptions();
2965
+ },
2966
+ async analyzeIconForeground(foregroundPath) {
2967
+ const stats = await foregroundStats(foregroundPath).catch(() => ({
2968
+ luminance: void 0,
2969
+ coverage: 0
2970
+ }));
2971
+ return {
2972
+ ...stats,
2973
+ suggested: autoBackground(stats)
2974
+ };
2975
+ },
2976
+ async composeIcon(options) {
2977
+ if (state === "frozen" || state === "materializing" || state === "success") throw new Error("cannot compose while frozen");
2978
+ const background = options.background ?? iconBackground ?? "transparent";
2979
+ const scale = options.scale ?? iconScale ?? .8;
2980
+ const composed = await composeAppIcon({
2981
+ foregroundPath: options.foregroundPath,
2982
+ background,
2983
+ scale,
2984
+ outputDir: await ensureIconComposeDir()
2985
+ });
2986
+ const composition = {
2987
+ key: compositionCacheKey({
2988
+ foregroundPath: options.foregroundPath,
2989
+ background,
2990
+ scale
2991
+ }),
2992
+ ...composed
2993
+ };
2994
+ iconCompositions.set(composition.key, composition);
2995
+ return composition;
2996
+ },
2997
+ trackIconComposition(composition) {
2998
+ iconCompositions.set(composition.key, composition);
2999
+ },
3000
+ iconSourceRoots() {
3001
+ return [...tempIconDir !== void 0 ? [tempIconDir] : [], ...composeIconDir !== void 0 ? [composeIconDir] : []];
3002
+ },
3003
+ iconComposition(key) {
3004
+ return iconCompositions.get(key);
3005
+ },
3006
+ async saveIconUpload(bytes) {
3007
+ const path = join(tempIconDir ?? (tempIconDir = await mkdtemp(join(tmpdir(), "create-opentray-"))), `upload-${createHash("sha256").update(bytes).digest("hex").slice(0, 16)}.bin`);
3008
+ await writeFile(path, bytes);
3009
+ return path;
3010
+ },
3011
+ selectService(port) {
3012
+ if (state !== "discovered" && state !== "running") return;
3013
+ if (!services.some((service) => service.port === port)) return;
3014
+ selectedPort = port;
3015
+ currentIconPath = void 0;
3016
+ publishServices();
3017
+ if (state === "discovered") startScrapePolling();
3018
+ },
3019
+ updateForm(patch) {
3020
+ if (state !== "idle" && state !== "running" && state !== "discovered" && state !== "failed") return;
3021
+ if (patch.iconBackground !== void 0) iconBackground = patch.iconBackground;
3022
+ if (patch.iconScale !== void 0) iconScale = patch.iconScale;
3023
+ for (const key of Object.keys(patch)) if (patch[key] !== void 0 && patch[key] !== form[key]) {
3024
+ touched[key] = true;
3025
+ if (key === "trayIconPath") trayIconIsSolid = false;
3026
+ }
3027
+ form = {
3028
+ ...form,
3029
+ ...patch
3030
+ };
3031
+ publishForm();
3032
+ },
3033
+ /** Forward base64-encoded terminal keystroke bytes to the preview command. */
3034
+ terminalInput(data) {
3035
+ if (state !== "running" && state !== "discovered") return;
3036
+ run?.write(data);
3037
+ },
3038
+ /** Forward terminal dimensions to the pseudo-terminal. */
3039
+ terminalResize(size) {
3040
+ if (state !== "running" && state !== "discovered") return;
3041
+ run?.resize(size);
3042
+ },
3043
+ confirm() {
3044
+ if (state !== "idle" && state !== "running" && state !== "discovered" && state !== "failed") throw new Error(`cannot confirm while ${state}`);
3045
+ resolvedServicePort = selectedPort ?? 0;
3046
+ resolvedTargetDir = currentDefaults().targetDir;
3047
+ const defaults = currentDefaults();
3048
+ let resolvedForm = {
3049
+ ...form,
3050
+ appId: form.appId.trim().length > 0 ? form.appId : defaults.appId,
3051
+ appName: form.appName.trim().length > 0 ? form.appName : defaults.appName
3052
+ };
3053
+ if (resolvedForm.iconPath.trim().length > 0) currentIconPath = resolvedForm.iconPath.trim();
3054
+ frozenIconPath = currentIconPath;
3055
+ const resolvedTrayIconPath = resolvedForm.trayIconPath.trim().length > 0 ? resolvedForm.trayIconPath.trim() : resolvedForm.iconPath.trim().length > 0 ? resolvedForm.iconPath.trim() : currentIconPath ?? "";
3056
+ resolvedForm = {
3057
+ ...resolvedForm,
3058
+ trayIconPath: resolvedTrayIconPath
3059
+ };
3060
+ currentTrayIconPath = resolvedTrayIconPath;
3061
+ form = resolvedForm;
3062
+ stopTimers();
3063
+ frozenForm = { ...form };
3064
+ setState("frozen");
3065
+ publishForm();
3066
+ },
3067
+ async create() {
3068
+ if (state !== "frozen") throw new Error(`cannot create while ${state}`);
3069
+ const frozen = frozenForm ?? form;
3070
+ if (currentTokens.length === 0) throw new Error("no command recorded");
3071
+ setState("materializing");
3072
+ if (run !== void 0) {
3073
+ await run.kill();
3074
+ run = void 0;
3075
+ }
3076
+ try {
3077
+ resolvedVector = await (options.resolveVector ?? resolveLaunchVector)({
3078
+ tokens: currentTokens,
3079
+ cwd: effectiveCwd()
3080
+ });
3081
+ const envOverlay = commandEnv();
3082
+ if (Object.keys(envOverlay).length > 0) resolvedVector = {
3083
+ ...resolvedVector,
3084
+ env: envOverlay
3085
+ };
3086
+ } catch (error) {
3087
+ setState("failed", error instanceof Error ? error.message : String(error));
3088
+ return;
3089
+ }
3090
+ try {
3091
+ result = await materialize({
3092
+ config: {
3093
+ schemaVersion: 1,
3094
+ appId: frozen.appId,
3095
+ appName: frozen.appName,
3096
+ command: resolvedVector,
3097
+ service: { port: resolvedServicePort ?? 0 },
3098
+ window: {
3099
+ width: 1200,
3100
+ height: 800
3101
+ }
3102
+ },
3103
+ targetDir: resolvedTargetDir ?? currentDefaults().targetDir,
3104
+ dependencyRange: options.dependencyRange,
3105
+ iconSourcePath: frozenIconPath ?? currentIconPath,
3106
+ ...frozen.iconBackground === void 0 ? {} : { iconBackground: frozen.iconBackground },
3107
+ ...frozen.iconScale === void 0 ? {} : { iconScale: frozen.iconScale },
3108
+ ...currentTrayIconPath === void 0 ? {} : { trayIconSourcePath: currentTrayIconPath },
3109
+ ...trayIconIsSolid ? { trayIconIsSolid: true } : {},
3110
+ shell: {
3111
+ showTerminal: frozen.showStartupTerminal,
3112
+ showAddressBar: frozen.showAddressBar
3113
+ },
3114
+ packageManager: frozen.pm,
3115
+ skipInstall: options.skipInstall,
3116
+ force: frozen.force
3117
+ }, {
3118
+ log: (event) => {
3119
+ if (event.type === "step") {
3120
+ emit({
3121
+ type: "materialize-step",
3122
+ step: event.step,
3123
+ message: event.message
3124
+ });
3125
+ return;
3126
+ }
3127
+ emit({
3128
+ type: "materialize-log",
3129
+ message: event.message
3130
+ });
3131
+ },
3132
+ ...options.platform === void 0 ? {} : { platform: options.platform },
3133
+ ...options.materializeContext ?? {}
3134
+ });
3135
+ setState("success");
3136
+ emit({
3137
+ type: "success",
3138
+ projectDir: result.projectDir,
3139
+ ...result.bundlePath === void 0 ? {} : { bundlePath: result.bundlePath },
3140
+ pinHint: pinningHint()
3141
+ });
3142
+ } catch (error) {
3143
+ const message = error instanceof Error ? error.message : String(error);
3144
+ const occupied = message.includes("target directory is not empty");
3145
+ setState("failed", occupied ? `${message};可在「高级选项」中开启 强制覆盖 后重试` : message);
3146
+ }
3147
+ },
3148
+ async stop() {
3149
+ stopTimers();
3150
+ discovery.stop();
3151
+ if (run !== void 0) {
3152
+ await run.kill();
3153
+ run = void 0;
3154
+ }
3155
+ }
3156
+ };
3157
+ publishCommandOptions();
3158
+ return session;
3159
+ };
3160
+ //#endregion
3161
+ //#region src/server.ts
3162
+ const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
3163
+ "127.0.0.1",
3164
+ "localhost",
3165
+ "[::1]",
3166
+ "::1"
3167
+ ]);
3168
+ const createWizardServer = async (createSession, options = {}) => {
3169
+ const token = randomBytes(16).toString("hex");
3170
+ const clients = /* @__PURE__ */ new Set();
3171
+ const eventLog = [];
3172
+ const emit = (event) => {
3173
+ eventLog.push(event);
3174
+ const frame = `data: ${JSON.stringify(event)}\n\n`;
3175
+ for (const client of clients) client.write(frame);
3176
+ };
3177
+ const session = createSession(emit);
3178
+ const indexHtml = await readWebUiIndex();
3179
+ const server = createServer((request, response) => {
3180
+ handle(request, response).catch((error) => {
3181
+ respond(response, 500, "application/json", `${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n`);
3182
+ });
3183
+ });
3184
+ const handle = async (request, response) => {
3185
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
3186
+ if (url.pathname === "/" || url.pathname === "/index.html") {
3187
+ if (!isAuthorized(request, url, token)) {
3188
+ respond(response, 401, "text/plain", "invalid wizard token\n");
3189
+ return;
3190
+ }
3191
+ if (indexHtml === void 0) {
3192
+ respond(response, 500, "text/plain", "wizard page is missing from this installation\n");
3193
+ return;
3194
+ }
3195
+ respond(response, 200, "text/html; charset=utf-8", indexHtml);
3196
+ return;
3197
+ }
3198
+ if (url.pathname === "/api/events") {
3199
+ if (!isAuthorized(request, url, token)) {
3200
+ respond(response, 401, "text/plain", "unauthorized\n");
3201
+ return;
3202
+ }
3203
+ response.writeHead(200, {
3204
+ "content-type": "text/event-stream",
3205
+ "cache-control": "no-cache",
3206
+ connection: "keep-alive"
3207
+ });
3208
+ response.write(": connected\n\n");
3209
+ for (const event of eventLog) response.write(`data: ${JSON.stringify(event)}\n\n`);
3210
+ clients.add(response);
3211
+ request.once("close", () => {
3212
+ clients.delete(response);
3213
+ });
3214
+ return;
3215
+ }
3216
+ if (url.pathname.startsWith("/assets/")) {
3217
+ await handleAssetFile(url.pathname, response);
3218
+ return;
3219
+ }
3220
+ if (url.pathname === "/ghostty-vt.wasm") {
3221
+ await handleAssetFile("/ghostty-vt.wasm", response);
3222
+ return;
3223
+ }
3224
+ if (url.pathname.startsWith("/vendor/")) {
3225
+ await handleVendorAsset(url.pathname, response);
3226
+ return;
3227
+ }
3228
+ const composedMatch = /^\/api\/icon-composed\/([a-f0-9]+)$/.exec(url.pathname);
3229
+ if (composedMatch !== null) {
3230
+ if (!isAuthorized(request, url, token)) {
3231
+ respond(response, 401, "text/plain", "unauthorized\n");
3232
+ return;
3233
+ }
3234
+ const key = composedMatch[1];
3235
+ const composed = session.iconComposition(key);
3236
+ if (composed === void 0) {
3237
+ respond(response, 404, "text/plain", "not found\n");
3238
+ return;
3239
+ }
3240
+ try {
3241
+ const bytes = await readFile(composed.compositePath);
3242
+ response.writeHead(200, {
3243
+ "content-type": "image/png",
3244
+ "cache-control": "no-store",
3245
+ "content-length": bytes.byteLength
3246
+ });
3247
+ response.end(bytes);
3248
+ return;
3249
+ } catch {
3250
+ respond(response, 404, "text/plain", "not found\n");
3251
+ return;
3252
+ }
3253
+ }
3254
+ const iconDataMatch = /^\/api\/icon-data\/(\d+)\/(\d+)$/.exec(url.pathname);
3255
+ if (iconDataMatch !== null) {
3256
+ if (!isAuthorized(request, url, token)) {
3257
+ respond(response, 401, "text/plain", "unauthorized\n");
3258
+ return;
3259
+ }
3260
+ const port = Number.parseInt(iconDataMatch[1], 10);
3261
+ const index = Number.parseInt(iconDataMatch[2], 10);
3262
+ const candidate = session.iconCandidate(port, index);
3263
+ if (candidate === void 0) {
3264
+ respond(response, 404, "text/plain", "not found\n");
3265
+ return;
3266
+ }
3267
+ const bytes = await readFile(candidate.path).catch(() => void 0);
3268
+ if (bytes === void 0) {
3269
+ respond(response, 404, "text/plain", "not found\n");
3270
+ return;
3271
+ }
3272
+ response.writeHead(200, {
3273
+ "content-type": ICON_CONTENT_TYPES[candidate.format] ?? "application/octet-stream",
3274
+ "content-length": bytes.length,
3275
+ "cache-control": "no-store"
3276
+ });
3277
+ response.end(bytes);
3278
+ return;
3279
+ }
3280
+ if (url.pathname.startsWith("/api/")) {
3281
+ if (!isAuthorized(request, url, token)) {
3282
+ respond(response, 401, "application/json", "{\"error\":\"unauthorized\"}\n");
3283
+ return;
3284
+ }
3285
+ if (!isLoopbackHost(request)) {
3286
+ respond(response, 403, "application/json", "{\"error\":\"forbidden host\"}\n");
3287
+ return;
3288
+ }
3289
+ await handleApi(url.pathname, request, response, session);
3290
+ return;
3291
+ }
3292
+ respond(response, 404, "text/plain", "not found\n");
3293
+ };
3294
+ await listen(server, options.port);
3295
+ const address = server.address();
3296
+ if (address === null || typeof address === "string") {
3297
+ server.close();
3298
+ throw new Error("wizard server did not bind a loopback port");
3299
+ }
3300
+ return {
3301
+ url: `http://127.0.0.1:${address.port}/?token=${token}`,
3302
+ port: address.port,
3303
+ token,
3304
+ session,
3305
+ close: () => new Promise((resolve) => {
3306
+ for (const client of clients) client.end();
3307
+ clients.clear();
3308
+ server.close(() => resolve());
3309
+ })
3310
+ };
3311
+ };
3312
+ /** Whitelisted static terminal-renderer assets; exact names only, no traversal. */
3313
+ const VENDOR_ASSETS = {
3314
+ "/vendor/ghostty-web.js": {
3315
+ contentType: "text/javascript; charset=utf-8",
3316
+ candidates: () => [join(moduleDir(), "webui", "vendor", "ghostty-web.js"), ghosttyPackageFile("dist/ghostty-web.js")]
3317
+ },
3318
+ "/vendor/ghostty-vt.wasm": {
3319
+ contentType: "application/wasm",
3320
+ candidates: () => [
3321
+ join(moduleDir(), "webui", "vendor", "ghostty-vt.wasm"),
3322
+ ghosttyPackageFile("ghostty-vt.wasm"),
3323
+ ghosttyPackageFile("dist/ghostty-vt.wasm")
3324
+ ]
3325
+ }
3326
+ };
3327
+ const moduleDir = () => dirname(fileURLToPath(import.meta.url));
3328
+ /**
3329
+ * Resolve a file inside the installed ghostty-web package. Deep paths are
3330
+ * blocked by the package `exports` map, so resolve the exported main module
3331
+ * and walk from the package root instead.
3332
+ */
3333
+ const ghosttyPackageFile = (relative) => {
3334
+ try {
3335
+ return join(dirname(dirname(createRequire(import.meta.url).resolve("ghostty-web"))), relative);
3336
+ } catch {
3337
+ return "";
3338
+ }
3339
+ };
3340
+ const ICON_CONTENT_TYPES = {
3341
+ png: "image/png",
3342
+ svg: "image/svg+xml",
3343
+ jpeg: "image/jpeg",
3344
+ webp: "image/webp",
3345
+ gif: "image/gif",
3346
+ ico: "image/x-icon"
3347
+ };
3348
+ const ASSET_CONTENT_TYPES = {
3349
+ ".js": "text/javascript; charset=utf-8",
3350
+ ".css": "text/css; charset=utf-8",
3351
+ ".wasm": "application/wasm",
3352
+ ".html": "text/html; charset=utf-8",
3353
+ ".svg": "image/svg+xml",
3354
+ ".png": "image/png",
3355
+ ".json": "application/json"
3356
+ };
3357
+ /**
3358
+ * Serve one file from the built webui directory (dist/webui). Only simple
3359
+ * relative names are accepted: resolve first, then require the resolved path
3360
+ * to stay inside the webui root, so traversal cannot escape.
3361
+ */
3362
+ const handleAssetFile = async (pathname, response) => {
3363
+ const relative = pathname.replace(/^\/+/, "");
3364
+ if (relative.length === 0 || relative.includes("\0")) {
3365
+ respond(response, 404, "text/plain", "not found\n");
3366
+ return;
3367
+ }
3368
+ const roots = [join(moduleDir(), "webui"), join(moduleDir(), "..", "..", "create-webui", "dist")];
3369
+ for (const root of roots) {
3370
+ const resolved = resolve(root, relative);
3371
+ if (!(resolved === root || resolved.startsWith(`${root}${sep}`))) continue;
3372
+ const bytes = await readFile(resolved).catch(() => void 0);
3373
+ if (bytes === void 0) continue;
3374
+ const extension = extname(resolved).toLowerCase();
3375
+ response.writeHead(200, {
3376
+ "content-type": ASSET_CONTENT_TYPES[extension] ?? "application/octet-stream",
3377
+ "content-length": bytes.length,
3378
+ "cache-control": "no-store"
3379
+ });
3380
+ response.end(bytes);
3381
+ return;
3382
+ }
3383
+ respond(response, 404, "text/plain", "not found\n");
3384
+ };
3385
+ const handleVendorAsset = async (pathname, response) => {
3386
+ const asset = VENDOR_ASSETS[pathname];
3387
+ if (asset === void 0) {
3388
+ respond(response, 404, "text/plain", "not found\n");
3389
+ return;
3390
+ }
3391
+ for (const candidate of asset.candidates()) {
3392
+ if (candidate.length === 0) continue;
3393
+ const bytes = await readFile(candidate).catch(() => void 0);
3394
+ if (bytes !== void 0) {
3395
+ response.writeHead(200, {
3396
+ "content-type": asset.contentType,
3397
+ "content-length": bytes.length,
3398
+ "cache-control": "no-store"
3399
+ });
3400
+ response.end(bytes);
3401
+ return;
3402
+ }
3403
+ }
3404
+ respond(response, 404, "text/plain", "terminal renderer asset is missing\n");
3405
+ };
3406
+ /** Containment: icon routes must only read sources the wizard itself
3407
+ * produced (its temp dirs / saved uploads), never arbitrary paths. */
3408
+ const isWizardOwnedIconPath = (session, target) => {
3409
+ for (const root of session.iconSourceRoots()) if (root.length > 0 && target.startsWith(root + sep)) return true;
3410
+ return false;
3411
+ };
3412
+ const handleApi = async (pathname, request, response, session) => {
3413
+ if (request.method !== "POST") {
3414
+ respond(response, 405, "application/json", "{\"error\":\"method not allowed\"}\n");
3415
+ return;
3416
+ }
3417
+ if (pathname === "/api/icon-upload") {
3418
+ const chunks = [];
3419
+ for await (const chunk of request) {
3420
+ const piece = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
3421
+ chunks.push(piece);
3422
+ }
3423
+ const bytes = Buffer.concat(chunks);
3424
+ if (bytes.length < 64) {
3425
+ respond(response, 400, "application/json", "{\"error\":\"image bytes are required\"}\n");
3426
+ return;
3427
+ }
3428
+ const path = await session.saveIconUpload(bytes);
3429
+ respond(response, 200, "application/json", JSON.stringify({ path }) + "\n");
3430
+ return;
3431
+ }
3432
+ const body = await readJsonBody(request);
3433
+ switch (pathname) {
3434
+ case "/api/command": {
3435
+ if (Array.isArray(body.argv)) {
3436
+ const argv = body.argv.filter((element) => typeof element === "string");
3437
+ if (argv.length === 0 || (argv[0] ?? "").trim().length === 0) {
3438
+ respond(response, 400, "application/json", "{\"error\":\"argv requires the program element\"}\n");
3439
+ return;
3440
+ }
3441
+ await session.submitCommand(argv);
3442
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
3443
+ return;
3444
+ }
3445
+ const command = typeof body.command === "string" ? body.command : "";
3446
+ if (command.trim().length === 0) {
3447
+ respond(response, 400, "application/json", "{\"error\":\"command is required\"}\n");
3448
+ return;
3449
+ }
3450
+ await session.submitCommand(command);
3451
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
3452
+ return;
3453
+ }
3454
+ case "/api/command-options": {
3455
+ const patch = {};
3456
+ if (typeof body.cwd === "string") patch.cwd = body.cwd;
3457
+ if (body.argsMode === "string" || body.argsMode === "array") patch.argsMode = body.argsMode;
3458
+ if (Array.isArray(body.env)) {
3459
+ const entries = [];
3460
+ for (const entry of body.env) if (typeof entry === "object" && entry !== null && typeof entry.key === "string" && typeof entry.value === "string") entries.push({
3461
+ key: entry.key,
3462
+ value: entry.value
3463
+ });
3464
+ patch.env = entries;
3465
+ }
3466
+ session.updateCommandOptions(patch);
3467
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
3468
+ return;
3469
+ }
3470
+ case "/api/prime": {
3471
+ const command = typeof body.command === "string" ? body.command : "";
3472
+ if (command.trim().length === 0) {
3473
+ respond(response, 400, "application/json", "{\"error\":\"command is required\"}\n");
3474
+ return;
3475
+ }
3476
+ session.prime(command);
3477
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
3478
+ return;
3479
+ }
3480
+ case "/api/select-service": {
3481
+ const port = Number(body.port);
3482
+ if (!Number.isInteger(port) || port <= 0) {
3483
+ respond(response, 400, "application/json", "{\"error\":\"port is required\"}\n");
3484
+ return;
3485
+ }
3486
+ session.selectService(port);
3487
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
3488
+ return;
3489
+ }
3490
+ case "/api/form": {
3491
+ const patch = {};
3492
+ for (const key of [
3493
+ "appId",
3494
+ "appName",
3495
+ "iconPath",
3496
+ "trayIconPath"
3497
+ ]) {
3498
+ const value = body[key];
3499
+ if (typeof value === "string") patch[key] = value;
3500
+ }
3501
+ if (body.pm === "npm" || body.pm === "pnpm" || body.pm === "bun") patch.pm = body.pm;
3502
+ if (body.iconBackground === "black" || body.iconBackground === "white" || body.iconBackground === "transparent") patch.iconBackground = body.iconBackground;
3503
+ if (typeof body.iconScale === "number" && body.iconScale >= .5 && body.iconScale <= .95) patch.iconScale = body.iconScale;
3504
+ for (const key of [
3505
+ "showStartupTerminal",
3506
+ "showAddressBar",
3507
+ "force"
3508
+ ]) {
3509
+ const value = body[key];
3510
+ if (typeof value === "boolean") patch[key] = value;
3511
+ }
3512
+ session.updateForm(patch);
3513
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
3514
+ return;
3515
+ }
3516
+ case "/api/confirm":
3517
+ try {
3518
+ session.confirm();
3519
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
3520
+ } catch (error) {
3521
+ respond(response, 409, "application/json", `${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n`);
3522
+ }
3523
+ return;
3524
+ case "/api/create":
3525
+ try {
3526
+ await session.create();
3527
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
3528
+ } catch (error) {
3529
+ respond(response, 409, "application/json", `${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n`);
3530
+ }
3531
+ return;
3532
+ case "/api/terminal-input": {
3533
+ const data = typeof body.data === "string" ? body.data : void 0;
3534
+ if (data === void 0) {
3535
+ respond(response, 400, "application/json", "{\"error\":\"data is required\"}\n");
3536
+ return;
3537
+ }
3538
+ session.terminalInput(data);
3539
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
3540
+ return;
3541
+ }
3542
+ case "/api/icon-analyze": {
3543
+ const path = typeof body.path === "string" ? body.path.trim() : "";
3544
+ if (path.length === 0) {
3545
+ respond(response, 400, "application/json", "{\"error\":\"path is required\"}\n");
3546
+ return;
3547
+ }
3548
+ if (!isWizardOwnedIconPath(session, path)) {
3549
+ respond(response, 403, "application/json", "{\"error\":\"path is not a wizard icon source\"}\n");
3550
+ return;
3551
+ }
3552
+ try {
3553
+ const analysis = await session.analyzeIconForeground(path);
3554
+ respond(response, 200, "application/json", JSON.stringify(analysis) + "\n");
3555
+ } catch (error) {
3556
+ respond(response, 500, "application/json", JSON.stringify({ error: String(error) }) + "\n");
3557
+ }
3558
+ return;
3559
+ }
3560
+ case "/api/icon-compose": {
3561
+ const foregroundPath = typeof body.foregroundPath === "string" ? body.foregroundPath.trim() : "";
3562
+ if (foregroundPath.length === 0) {
3563
+ respond(response, 400, "application/json", "{\"error\":\"foregroundPath is required\"}\n");
3564
+ return;
3565
+ }
3566
+ if (!isWizardOwnedIconPath(session, foregroundPath)) {
3567
+ respond(response, 403, "application/json", "{\"error\":\"foregroundPath is not a wizard icon source\"}\n");
3568
+ return;
3569
+ }
3570
+ const background = body.background === "black" || body.background === "white" || body.background === "transparent" ? body.background : void 0;
3571
+ const scale = typeof body.scale === "number" && body.scale >= .5 && body.scale <= .95 ? body.scale : void 0;
3572
+ try {
3573
+ const composed = await session.composeIcon({
3574
+ foregroundPath,
3575
+ ...background === void 0 ? {} : { background },
3576
+ ...scale === void 0 ? {} : { scale }
3577
+ });
3578
+ respond(response, 200, "application/json", JSON.stringify(composed) + "\n");
3579
+ } catch (error) {
3580
+ respond(response, 500, "application/json", JSON.stringify({ error: String(error) }) + "\n");
3581
+ }
3582
+ return;
3583
+ }
3584
+ case "/api/tray-icon-select": {
3585
+ const port = typeof body.port === "number" ? body.port : NaN;
3586
+ const index = typeof body.index === "number" ? body.index : NaN;
3587
+ if (!Number.isInteger(port) || !Number.isInteger(index)) {
3588
+ respond(response, 400, "application/json", "{\"error\":\"port and index are required\"}\n");
3589
+ return;
3590
+ }
3591
+ const ok = session.selectTrayIconCandidate(port, index);
3592
+ respond(response, 200, "application/json", JSON.stringify({ ok }) + "\n");
3593
+ return;
3594
+ }
3595
+ case "/api/icon-select": {
3596
+ const port = typeof body.port === "number" ? body.port : NaN;
3597
+ const index = typeof body.index === "number" ? body.index : NaN;
3598
+ if (!Number.isInteger(port) || !Number.isInteger(index)) {
3599
+ respond(response, 400, "application/json", "{\"error\":\"port and index are required\"}\n");
3600
+ return;
3601
+ }
3602
+ const ok = session.selectIconCandidate(port, index);
3603
+ respond(response, 200, "application/json", JSON.stringify({ ok }) + "\n");
3604
+ return;
3605
+ }
3606
+ case "/api/icon-source": {
3607
+ const path = typeof body.path === "string" ? body.path.trim() : "";
3608
+ if (path.length === 0) {
3609
+ respond(response, 400, "application/json", "{\"error\":\"path is required\"}\n");
3610
+ return;
3611
+ }
3612
+ session.updateForm({ iconPath: path });
3613
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
3614
+ return;
3615
+ }
3616
+ case "/api/terminal-resize": {
3617
+ const cols = Number(body.cols);
3618
+ const rows = Number(body.rows);
3619
+ if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols <= 0 || rows <= 0) {
3620
+ respond(response, 400, "application/json", "{\"error\":\"cols and rows are required\"}\n");
3621
+ return;
3622
+ }
3623
+ session.terminalResize({
3624
+ cols,
3625
+ rows
3626
+ });
3627
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
3628
+ return;
3629
+ }
3630
+ case "/api/stop":
3631
+ await session.stop();
3632
+ respond(response, 200, "application/json", "{\"ok\":true}\n");
3633
+ return;
3634
+ case "/api/open-app": {
3635
+ const result = session.result;
3636
+ if (result === void 0) {
3637
+ respond(response, 409, "application/json", "{\"error\":\"no materialized app\"}\n");
3638
+ return;
3639
+ }
3640
+ const opened = await openMaterializedApp({
3641
+ projectDir: result.projectDir,
3642
+ bundlePath: result.bundlePath
3643
+ });
3644
+ respond(response, opened.ok ? 200 : 500, "application/json", `${JSON.stringify({
3645
+ ok: opened.ok,
3646
+ detail: opened.detail
3647
+ })}\n`);
3648
+ return;
3649
+ }
3650
+ default: respond(response, 404, "application/json", "{\"error\":\"unknown endpoint\"}\n");
3651
+ }
3652
+ };
3653
+ const listen = (server, port) => new Promise((resolve, reject) => {
3654
+ server.once("error", reject);
3655
+ server.listen(port ?? 0, "127.0.0.1", () => {
3656
+ server.off("error", reject);
3657
+ resolve();
3658
+ });
3659
+ });
3660
+ const isAuthorized = (request, url, token) => {
3661
+ if (request.headers.authorization === `Bearer ${token}`) return true;
3662
+ return url.searchParams.get("token") === token;
3663
+ };
3664
+ const isLoopbackHost = (request) => {
3665
+ const hostname = (request.headers.host ?? "").replace(/:\d+$/u, "").toLowerCase();
3666
+ return LOOPBACK_HOSTS.has(hostname);
3667
+ };
3668
+ const readJsonBody = async (request) => {
3669
+ const chunks = [];
3670
+ let size = 0;
3671
+ for await (const chunk of request) {
3672
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
3673
+ size += buffer.length;
3674
+ if (size > 1 << 20) throw new Error("request body too large");
3675
+ chunks.push(buffer);
3676
+ }
3677
+ if (chunks.length === 0) return {};
3678
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
3679
+ if (typeof parsed !== "object" || parsed === null) return {};
3680
+ return parsed;
3681
+ };
3682
+ const respond = (response, status, contentType, body) => {
3683
+ response.writeHead(status, {
3684
+ "content-type": contentType,
3685
+ "content-length": Buffer.byteLength(body),
3686
+ "cache-control": "no-store"
3687
+ });
3688
+ response.end(body);
3689
+ };
3690
+ /** Built package: dist/webui/index.html; source checkout falls back to the create-webui vite output. */
3691
+ const webUiIndexPaths = () => [join(moduleDir(), "webui", "index.html"), join(moduleDir(), "..", "..", "create-webui", "dist", "index.html")];
3692
+ const readWebUiIndex = async () => {
3693
+ for (const candidate of webUiIndexPaths()) {
3694
+ const html = await readFile(candidate, "utf8").catch(() => void 0);
3695
+ if (html !== void 0) return html;
3696
+ }
3697
+ };
3698
+ //#endregion
3699
+ //#region src/bin.ts
3700
+ const parseWizardCli = (argv) => {
3701
+ const options = {
3702
+ open: true,
3703
+ skipInstall: false,
3704
+ force: false
3705
+ };
3706
+ const positional = [];
3707
+ for (let index = 0; index < argv.length; index += 1) {
3708
+ const arg = argv[index];
3709
+ if (arg === void 0) continue;
3710
+ if (arg === "--no-open") options.open = false;
3711
+ else if (arg === "--skip-install") options.skipInstall = true;
3712
+ else if (arg === "--force") options.force = true;
3713
+ else if (arg === "--port") {
3714
+ const value = argv[index + 1];
3715
+ const port = Number.parseInt(value ?? "", 10);
3716
+ if (Number.isInteger(port) && port > 0 && port < 65536) {
3717
+ options.port = port;
3718
+ index += 1;
3719
+ }
3720
+ } else if (arg === "--pm") {
3721
+ const value = argv[index + 1];
3722
+ if (value === "npm" || value === "pnpm" || value === "bun") {
3723
+ options.pm = value;
3724
+ index += 1;
3725
+ }
3726
+ } else if (arg === "--help" || arg === "-h") {
3727
+ options.open = false;
3728
+ positional.length = 0;
3729
+ break;
3730
+ } else if (!arg.startsWith("--")) positional.push(arg);
3731
+ }
3732
+ const [target] = positional;
3733
+ return {
3734
+ open: options.open,
3735
+ port: options.port,
3736
+ pm: options.pm,
3737
+ skipInstall: options.skipInstall,
3738
+ force: options.force,
3739
+ targetDir: target
3740
+ };
3741
+ };
3742
+ const WIZARD_HELP = [
3743
+ "create-opentray — turn a start command into an OpenTray-hosted desktop app",
3744
+ "",
3745
+ "Usage: create-opentray [targetDir] [options]",
3746
+ "",
3747
+ "Options:",
3748
+ " --no-open do not open the default browser",
3749
+ " --port <n> bind the wizard server to a specific loopback port",
3750
+ " --pm <name> package manager for the generated app (npm | pnpm | bun)",
3751
+ " --skip-install scaffold without installing dependencies",
3752
+ " --force allow materializing into a non-empty directory",
3753
+ " -h, --help show this help"
3754
+ ].join("\n");
3755
+ const readDependencyRange = async () => {
3756
+ const packageJsonUrl = new URL("../package.json", import.meta.url);
3757
+ try {
3758
+ const parsed = JSON.parse(await readFile(packageJsonUrl, "utf8"));
3759
+ if (typeof parsed.version === "string" && /^\d/u.test(parsed.version)) return `^${parsed.version}`;
3760
+ } catch {}
3761
+ return "latest";
3762
+ };
3763
+ const openBrowser = async (url) => {
3764
+ const platform = process.platform;
3765
+ spawn(platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open", platform === "win32" ? [
3766
+ "/c",
3767
+ "start",
3768
+ "",
3769
+ url
3770
+ ] : platform === "darwin" ? [url] : [url], {
3771
+ stdio: "ignore",
3772
+ detached: true,
3773
+ windowsHide: true
3774
+ }).unref();
3775
+ };
3776
+ const main = async (argv) => {
3777
+ const options = parseWizardCli(argv);
3778
+ if (argv.includes("--help") || argv.includes("-h")) {
3779
+ console.log(WIZARD_HELP);
3780
+ return 0;
3781
+ }
3782
+ ensureLoopbackNoProxy();
3783
+ const invocationDir = process.env.INIT_CWD || process.cwd();
3784
+ const cwd = invocationDir;
3785
+ const dependencyRange = await readDependencyRange();
3786
+ const server = await createWizardServer((emit) => createWizardSession({
3787
+ cwd,
3788
+ skipInstall: options.skipInstall,
3789
+ force: options.force,
3790
+ ...options.targetDir === void 0 ? {} : { targetDir: resolve(invocationDir, options.targetDir) },
3791
+ ...options.pm === void 0 ? {} : { packageManager: options.pm },
3792
+ dependencyRange,
3793
+ emit
3794
+ }), options.port === void 0 ? {} : { port: options.port });
3795
+ console.log(`create-opentray wizard: ${server.url}`);
3796
+ console.log(`working directory: ${cwd}`);
3797
+ if (options.open) await openBrowser(server.url);
3798
+ const shutdown = () => {
3799
+ (async () => {
3800
+ await server.session.stop();
3801
+ await server.close();
3802
+ process.exit(0);
3803
+ })();
3804
+ };
3805
+ process.once("SIGINT", shutdown);
3806
+ process.once("SIGTERM", shutdown);
3807
+ await new Promise(() => {});
3808
+ return 0;
3809
+ };
3810
+ const isMainModule = () => {
3811
+ const entryPath = process.argv[1];
3812
+ if (entryPath === void 0) return false;
3813
+ const modulePath = fileURLToPath(import.meta.url);
3814
+ if (resolve(entryPath) === resolve(modulePath)) return true;
3815
+ return resolve(entryPath) === resolve(modulePath.replace(/bin-[^/]*\.mjs$/u, "bin.mjs"));
3816
+ };
3817
+ if (isMainModule()) main(process.argv.slice(2)).then((code) => {
3818
+ process.exitCode = code;
3819
+ });
3820
+ //#endregion
3821
+ export { verifyHttpService as A, resolveFaviconUrl as C, parseNetstatPorts as D, parseLsofPorts as E, toProjectDirectoryName as F, deriveDefaultAppId as M, deriveDefaultAppName as N, parsePowerShellPorts as O, isValidAppId as P, rankFaviconCandidates as S, createPortDiscovery as T, writeScaffold as _, isAuthorized as a, extractTitle as b, openMaterializedApp as c, resolveLaunchVector as d, resolveOnPath as f, materialize as g, isDirectoryOccupied as h, createWizardServer as i, waitForTcpPort as j, serviceUrl as k, pinningHint as l, expectedDarwinBundlePath as m, openBrowser as n, isLoopbackHost as o, detectPackageManager as p, parseWizardCli as r, createWizardSession as s, main as t, parseShebangInterpreter as u, tokenizeCommandLine as v, scrapeService as w, faviconCandidateSize as x, extractFaviconCandidates as y };
3822
+
3823
+ //# sourceMappingURL=bin-BT_kcLuP.mjs.map