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.
- package/README.md +163 -0
- package/dist/assets/create-openspec-template-iOS-Dark-1024@1x.png +0 -0
- package/dist/assets/create-openspec-template-iOS-Default-1024@1x.png +0 -0
- package/dist/bin-BT_kcLuP.mjs +3823 -0
- package/dist/bin-BT_kcLuP.mjs.map +1 -0
- package/dist/bin-CeFW1wxR.d.mts +15 -0
- package/dist/bin-CeFW1wxR.d.mts.map +1 -0
- package/dist/bin.d.mts +2 -0
- package/dist/bin.mjs +3 -0
- package/dist/icon-codec-BQ3cGV1n.mjs +13 -0
- package/dist/icon-codec-BQ3cGV1n.mjs.map +1 -0
- package/dist/index.d.mts +557 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/shell/assets/__vite-browser-external-2447137e.js +1 -0
- package/dist/shell/assets/browse.js +1 -0
- package/dist/shell/assets/ghostty-web.js +13 -0
- package/dist/shell/assets/index.css +1 -0
- package/dist/shell/assets/index.js +9 -0
- package/dist/shell/assets/input.js +1 -0
- package/dist/shell/assets/main.js +43 -0
- package/dist/shell/assets/terminal-pane.js +2 -0
- package/dist/shell/assets/terminal.js +1 -0
- package/dist/shell/browse.html +15 -0
- package/dist/shell/ghostty-vt.wasm +0 -0
- package/dist/shell/index.html +15 -0
- package/dist/shell/terminal.html +15 -0
- package/dist/webui/assets/__vite-browser-external-2447137e.js +1 -0
- package/dist/webui/assets/browse.js +1 -0
- package/dist/webui/assets/ghostty-web.js +13 -0
- package/dist/webui/assets/index.css +1 -0
- package/dist/webui/assets/index.js +9 -0
- package/dist/webui/assets/input.js +1 -0
- package/dist/webui/assets/main.js +43 -0
- package/dist/webui/assets/terminal-pane.js +2 -0
- package/dist/webui/assets/terminal.js +1 -0
- package/dist/webui/browse.html +15 -0
- package/dist/webui/ghostty-vt.wasm +0 -0
- package/dist/webui/index.html +16 -0
- package/dist/webui/terminal.html +15 -0
- package/dist/webui/vendor/ghostty-vt.wasm +0 -0
- package/dist/webui/vendor/ghostty-web.js +2963 -0
- package/package.json +45 -5
- 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(/&/gu, "&").replace(/</gu, "<").replace(/>/gu, ">").replace(/"/gu, "\"").replace(/'/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, "&").replace(/</gu, "<").replace(/>/gu, ">");
|
|
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
|