create-opentray 0.19.0 → 0.20.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 +136 -133
- package/assets/create-opentray-logo.png +0 -0
- package/dist/bin-sZB5nWq0.mjs +2142 -0
- package/dist/bin-sZB5nWq0.mjs.map +1 -0
- package/dist/bin.d.mts +15 -2
- package/dist/bin.d.mts.map +1 -0
- package/dist/bin.mjs +1 -1
- package/dist/index.d.mts +62 -351
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -1
- package/dist/shell/assets/browse.js +1 -1
- package/dist/shell/assets/index.css +1 -1
- package/dist/shell/assets/index.js +7 -7
- package/dist/shell/assets/input.js +1 -1
- package/dist/shell/assets/main.js +21 -43
- package/dist/shell/assets/terminal-pane.js +1 -1
- package/dist/shell/browse.html +3 -0
- package/dist/shell/favicon-16.png +0 -0
- package/dist/shell/favicon-32.png +0 -0
- package/dist/shell/favicon-64.png +0 -0
- package/dist/shell/index.html +3 -0
- package/dist/shell/logo.png +0 -0
- package/dist/shell/terminal.html +3 -0
- package/dist/skill/SKILL.md +156 -0
- package/dist/skill/references/cli-reference.md +77 -0
- package/dist/skill/references/how-it-works.md +49 -0
- package/dist/webui/assets/browse.js +1 -1
- package/dist/webui/assets/index.css +1 -1
- package/dist/webui/assets/index.js +7 -7
- package/dist/webui/assets/input.js +1 -1
- package/dist/webui/assets/main.js +21 -43
- package/dist/webui/assets/terminal-pane.js +1 -1
- package/dist/webui/browse.html +3 -0
- package/dist/webui/favicon-16.png +0 -0
- package/dist/webui/favicon-32.png +0 -0
- package/dist/webui/favicon-64.png +0 -0
- package/dist/webui/index.html +3 -0
- package/dist/webui/logo.png +0 -0
- package/dist/webui/terminal.html +3 -0
- package/package.json +12 -6
- package/skill/SKILL.md +156 -0
- package/skill/references/cli-reference.md +77 -0
- package/skill/references/how-it-works.md +49 -0
- package/dist/bin-BT_kcLuP.mjs +0 -3823
- package/dist/bin-BT_kcLuP.mjs.map +0 -1
- package/dist/bin-CeFW1wxR.d.mts +0 -15
- package/dist/bin-CeFW1wxR.d.mts.map +0 -1
- package/dist/icon-codec-BQ3cGV1n.mjs +0 -13
- package/dist/icon-codec-BQ3cGV1n.mjs.map +0 -1
|
@@ -0,0 +1,2142 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { access, mkdtemp, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir, tmpdir } from "node:os";
|
|
5
|
+
import { basename, dirname, extname, join, resolve, sep } from "node:path";
|
|
6
|
+
import { autoBackground, buildExportPlan, buildScriptExport, collectProcessTreePids, composeAppIcon, compositionCacheKey, createPortDiscovery, deriveDefaultAppId, deriveDefaultAppName, detectImageFormat, detectPackageManager, ensureLoopbackNoProxy, findCreateEntry, foregroundStats, formatPosixCommandLine, listCreateEntries, listListeningPorts, materialize, openMaterializedApp, pinningHint, readResourceBytes, readWizardProjectIcon, resolveLaunchVector, scrapeService, startCommandRun, stopRunningApp, toProjectDirectoryName, tokenizeCommandLine, uninstallApp, uninstallWizardProject } from "@create-opentray/core";
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { realpathSync } from "node:fs";
|
|
11
|
+
import { consoleStreams, dispatchCli } from "@create-opentray/cli";
|
|
12
|
+
//#region src/wizard.ts
|
|
13
|
+
const DEFAULT_ICON_SCALE = .8;
|
|
14
|
+
const DEFAULT_COMMAND_OPTIONS = {
|
|
15
|
+
cwd: "",
|
|
16
|
+
env: [],
|
|
17
|
+
argsMode: "string"
|
|
18
|
+
};
|
|
19
|
+
const createWizardSession = (options) => {
|
|
20
|
+
let state = "idle";
|
|
21
|
+
let services = [];
|
|
22
|
+
let selectedPort;
|
|
23
|
+
let run;
|
|
24
|
+
let discovery = createPortDiscovery({
|
|
25
|
+
baseline: /* @__PURE__ */ new Set(),
|
|
26
|
+
...options.listListeners === void 0 ? {} : { listListeners: () => options.listListeners() },
|
|
27
|
+
...options.verifyHttp === void 0 ? {} : { verifyHttp: options.verifyHttp }
|
|
28
|
+
});
|
|
29
|
+
let discoveryTimer;
|
|
30
|
+
let scrapeTimer;
|
|
31
|
+
let scraping = false;
|
|
32
|
+
let tempIconDir;
|
|
33
|
+
let currentIconPath;
|
|
34
|
+
let currentIconUrl;
|
|
35
|
+
let currentTrayIconPath;
|
|
36
|
+
let currentTrayIconUrl;
|
|
37
|
+
let trayIconIsSolid = false;
|
|
38
|
+
let iconCandidates = [];
|
|
39
|
+
let iconPort;
|
|
40
|
+
let currentTokens = [];
|
|
41
|
+
let currentCommand = "";
|
|
42
|
+
let scrapedTitle;
|
|
43
|
+
let interactive = true;
|
|
44
|
+
let resolvedVector;
|
|
45
|
+
let frozenForm;
|
|
46
|
+
let resolvedServicePort;
|
|
47
|
+
let resolvedTargetDir;
|
|
48
|
+
let frozenIconPath;
|
|
49
|
+
let frozenIconUrl;
|
|
50
|
+
let submitting = false;
|
|
51
|
+
let runAlive = false;
|
|
52
|
+
let commandOptions = { ...DEFAULT_COMMAND_OPTIONS };
|
|
53
|
+
let composeIconDir;
|
|
54
|
+
const iconCompositions = /* @__PURE__ */ new Map();
|
|
55
|
+
let iconBackground;
|
|
56
|
+
let iconScale;
|
|
57
|
+
const touched = {
|
|
58
|
+
appId: false,
|
|
59
|
+
appName: false,
|
|
60
|
+
iconPath: false,
|
|
61
|
+
iconBackground: false,
|
|
62
|
+
iconScale: false,
|
|
63
|
+
trayIconPath: false,
|
|
64
|
+
pm: false,
|
|
65
|
+
force: false,
|
|
66
|
+
showStartupTerminal: false,
|
|
67
|
+
showAddressBar: false,
|
|
68
|
+
imageSmoothingEnabled: false,
|
|
69
|
+
developerMode: false
|
|
70
|
+
};
|
|
71
|
+
let form = {
|
|
72
|
+
appId: "",
|
|
73
|
+
appName: "",
|
|
74
|
+
iconPath: "",
|
|
75
|
+
iconBackground: "transparent",
|
|
76
|
+
iconScale: DEFAULT_ICON_SCALE,
|
|
77
|
+
trayIconPath: "",
|
|
78
|
+
force: options.force === true,
|
|
79
|
+
showStartupTerminal: false,
|
|
80
|
+
showAddressBar: false,
|
|
81
|
+
imageSmoothingEnabled: true,
|
|
82
|
+
developerMode: false,
|
|
83
|
+
pm: options.packageManager ?? detectPackageManager([], process.env.npm_config_user_agent)
|
|
84
|
+
};
|
|
85
|
+
let result;
|
|
86
|
+
const emit = options.emit;
|
|
87
|
+
const setState = (next, reason) => {
|
|
88
|
+
state = next;
|
|
89
|
+
emit({
|
|
90
|
+
type: "state",
|
|
91
|
+
state: next,
|
|
92
|
+
...reason === void 0 ? {} : { reason }
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
const stopTimers = () => {
|
|
96
|
+
if (discoveryTimer !== void 0) {
|
|
97
|
+
clearInterval(discoveryTimer);
|
|
98
|
+
discoveryTimer = void 0;
|
|
99
|
+
}
|
|
100
|
+
if (scrapeTimer !== void 0) {
|
|
101
|
+
clearInterval(scrapeTimer);
|
|
102
|
+
scrapeTimer = void 0;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const publishServices = () => {
|
|
106
|
+
emit({
|
|
107
|
+
type: "services",
|
|
108
|
+
services: [...services],
|
|
109
|
+
selectedPort
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Placeholder defaults: scrapes and derivations update the *suggestions*,
|
|
114
|
+
* never the user's values. Empty values mean "use the default".
|
|
115
|
+
*/
|
|
116
|
+
const currentDefaults = () => {
|
|
117
|
+
const effectiveAppId = form.appId.trim().length > 0 ? form.appId : deriveDefaultAppId(currentTokens);
|
|
118
|
+
return {
|
|
119
|
+
appId: deriveDefaultAppId(currentTokens),
|
|
120
|
+
appName: scrapedTitle ?? deriveDefaultAppName(currentTokens),
|
|
121
|
+
iconPath: currentIconPath ?? "",
|
|
122
|
+
targetDir: options.targetDir ?? join(homeDir, ".opentray", "create", toProjectDirectoryName(effectiveAppId))
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
const publishForm = () => {
|
|
126
|
+
emit({
|
|
127
|
+
type: "form",
|
|
128
|
+
values: form,
|
|
129
|
+
defaults: currentDefaults(),
|
|
130
|
+
targetDirExists
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
/** Track whether the resolved target directory is already occupied so the
|
|
134
|
+
* UI can warn and offer the force toggle. */
|
|
135
|
+
let targetDirExists = false;
|
|
136
|
+
let targetDirProbe = 0;
|
|
137
|
+
const refreshTargetDirExists = () => {
|
|
138
|
+
const probe = ++targetDirProbe;
|
|
139
|
+
const target = currentDefaults().targetDir;
|
|
140
|
+
(async () => {
|
|
141
|
+
let occupied = false;
|
|
142
|
+
try {
|
|
143
|
+
occupied = (await stat(target)).isDirectory();
|
|
144
|
+
} catch {
|
|
145
|
+
occupied = false;
|
|
146
|
+
}
|
|
147
|
+
if (probe !== targetDirProbe) return;
|
|
148
|
+
if (occupied !== targetDirExists) {
|
|
149
|
+
targetDirExists = occupied;
|
|
150
|
+
publishForm();
|
|
151
|
+
}
|
|
152
|
+
})();
|
|
153
|
+
};
|
|
154
|
+
const scrapeOnce = async () => {
|
|
155
|
+
if (scraping || selectedPort === void 0 || state === "frozen" || state === "materializing") return;
|
|
156
|
+
scraping = true;
|
|
157
|
+
try {
|
|
158
|
+
const port = selectedPort;
|
|
159
|
+
const scraped = await (options.scrape ?? scrapeService)(port, tempIconDir === void 0 ? {} : { tempDir: tempIconDir });
|
|
160
|
+
if (selectedPort !== port) return;
|
|
161
|
+
const stateNow = state;
|
|
162
|
+
if (stateNow === "frozen" || stateNow === "materializing" || stateNow === "success") return;
|
|
163
|
+
iconCandidates = scraped.icons;
|
|
164
|
+
iconPort = port;
|
|
165
|
+
currentIconPath = scraped.icons[0]?.path;
|
|
166
|
+
currentIconUrl = scraped.icons[0]?.url;
|
|
167
|
+
if (scraped.title !== void 0) scrapedTitle = scraped.title;
|
|
168
|
+
emit({
|
|
169
|
+
type: "icons",
|
|
170
|
+
port,
|
|
171
|
+
icons: scraped.icons
|
|
172
|
+
});
|
|
173
|
+
emit({
|
|
174
|
+
type: "scrape",
|
|
175
|
+
port,
|
|
176
|
+
...scraped.title === void 0 ? {} : { title: scraped.title },
|
|
177
|
+
hasIcon: scraped.icons.length > 0
|
|
178
|
+
});
|
|
179
|
+
publishForm();
|
|
180
|
+
} finally {
|
|
181
|
+
scraping = false;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
const startScrapePolling = () => {
|
|
185
|
+
if (scrapeTimer !== void 0) clearInterval(scrapeTimer);
|
|
186
|
+
scrapeOnce();
|
|
187
|
+
scrapeTimer = setInterval(() => {
|
|
188
|
+
scrapeOnce();
|
|
189
|
+
}, options.scrapeIntervalMs ?? 1500);
|
|
190
|
+
};
|
|
191
|
+
const selectDefaultService = () => {
|
|
192
|
+
if (selectedPort === void 0 && services.length > 0) selectedPort = services[0]?.port;
|
|
193
|
+
if (selectedPort !== void 0 && state === "running") {
|
|
194
|
+
setState("discovered");
|
|
195
|
+
startScrapePolling();
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
const startDiscoveryPolling = () => {
|
|
199
|
+
if (discoveryTimer !== void 0) clearInterval(discoveryTimer);
|
|
200
|
+
(async () => {
|
|
201
|
+
const found = await discovery.poll();
|
|
202
|
+
if (found.length > 0) {
|
|
203
|
+
const known = new Set(services.map((service) => service.port));
|
|
204
|
+
services = [...services, ...found.filter((s) => !known.has(s.port))];
|
|
205
|
+
selectDefaultService();
|
|
206
|
+
publishServices();
|
|
207
|
+
}
|
|
208
|
+
})();
|
|
209
|
+
discoveryTimer = setInterval(() => {
|
|
210
|
+
(async () => {
|
|
211
|
+
const found = await discovery.poll();
|
|
212
|
+
if (found.length > 0) {
|
|
213
|
+
const known = new Set(services.map((service) => service.port));
|
|
214
|
+
services = [...services, ...found.filter((s) => !known.has(s.port))];
|
|
215
|
+
selectDefaultService();
|
|
216
|
+
publishServices();
|
|
217
|
+
}
|
|
218
|
+
})();
|
|
219
|
+
}, options.pollIntervalMs ?? 1e3);
|
|
220
|
+
};
|
|
221
|
+
/** Default command cwd is the USER_HOME directory (owner round-10 law):
|
|
222
|
+
* empty input means home, and relative paths resolve against home. */
|
|
223
|
+
const homeDir = options.homeDir ?? homedir();
|
|
224
|
+
const effectiveCwd = () => {
|
|
225
|
+
const custom = commandOptions.cwd.trim();
|
|
226
|
+
return custom.length > 0 ? resolve(homeDir, custom) : homeDir;
|
|
227
|
+
};
|
|
228
|
+
/** Build the env overlay from configured entries (empty keys skipped). */
|
|
229
|
+
const commandEnv = () => {
|
|
230
|
+
const env = {};
|
|
231
|
+
for (const entry of commandOptions.env) if (entry.key.trim().length > 0) env[entry.key.trim()] = entry.value;
|
|
232
|
+
return env;
|
|
233
|
+
};
|
|
234
|
+
const publishCommandOptions = () => {
|
|
235
|
+
emit({
|
|
236
|
+
type: "command-options",
|
|
237
|
+
options: commandOptions,
|
|
238
|
+
defaultCwd: homeDir
|
|
239
|
+
});
|
|
240
|
+
};
|
|
241
|
+
/** Stable dir for composed preview assets, created on demand. */
|
|
242
|
+
const ensureIconComposeDir = async () => {
|
|
243
|
+
if (composeIconDir === void 0) composeIconDir = await mkdtemp(join(tmpdir(), "create-opentray-compose-"));
|
|
244
|
+
return composeIconDir;
|
|
245
|
+
};
|
|
246
|
+
const session = {
|
|
247
|
+
get state() {
|
|
248
|
+
return state;
|
|
249
|
+
},
|
|
250
|
+
snapshot() {
|
|
251
|
+
return {
|
|
252
|
+
type: "snapshot",
|
|
253
|
+
state,
|
|
254
|
+
runAlive,
|
|
255
|
+
command: currentCommand,
|
|
256
|
+
commandOptions: {
|
|
257
|
+
...commandOptions,
|
|
258
|
+
env: [...commandOptions.env]
|
|
259
|
+
},
|
|
260
|
+
form: { ...form },
|
|
261
|
+
defaults: currentDefaults(),
|
|
262
|
+
targetDirExists,
|
|
263
|
+
services: [...services],
|
|
264
|
+
...selectedPort === void 0 ? {} : { selectedPort },
|
|
265
|
+
iconCandidates: [...iconCandidates],
|
|
266
|
+
...iconPort === void 0 ? {} : { iconsPort: iconPort },
|
|
267
|
+
interactive
|
|
268
|
+
};
|
|
269
|
+
},
|
|
270
|
+
get services() {
|
|
271
|
+
return services;
|
|
272
|
+
},
|
|
273
|
+
get selectedPort() {
|
|
274
|
+
return selectedPort;
|
|
275
|
+
},
|
|
276
|
+
get runAlive() {
|
|
277
|
+
return runAlive;
|
|
278
|
+
},
|
|
279
|
+
get iconCandidates() {
|
|
280
|
+
return iconCandidates;
|
|
281
|
+
},
|
|
282
|
+
iconCandidate(port, index) {
|
|
283
|
+
if (iconPort !== port) return;
|
|
284
|
+
return iconCandidates.find((icon) => icon.index === index);
|
|
285
|
+
},
|
|
286
|
+
replaceIconCandidates(port, icons) {
|
|
287
|
+
iconPort = port;
|
|
288
|
+
iconCandidates = icons;
|
|
289
|
+
},
|
|
290
|
+
selectTrayIconCandidate(port, index) {
|
|
291
|
+
if (state === "frozen" || state === "materializing" || state === "success") return false;
|
|
292
|
+
const candidate = session.iconCandidate(port, index);
|
|
293
|
+
if (candidate === void 0) return false;
|
|
294
|
+
touched.trayIconPath = true;
|
|
295
|
+
currentTrayIconPath = candidate.path;
|
|
296
|
+
currentTrayIconUrl = candidate.url;
|
|
297
|
+
trayIconIsSolid = candidate.variant !== "original";
|
|
298
|
+
form = {
|
|
299
|
+
...form,
|
|
300
|
+
trayIconPath: candidate.path
|
|
301
|
+
};
|
|
302
|
+
publishForm();
|
|
303
|
+
return true;
|
|
304
|
+
},
|
|
305
|
+
selectIconCandidate(port, index) {
|
|
306
|
+
if (state === "frozen" || state === "materializing" || state === "success") return false;
|
|
307
|
+
const candidate = session.iconCandidate(port, index);
|
|
308
|
+
if (candidate === void 0) return false;
|
|
309
|
+
touched.iconPath = true;
|
|
310
|
+
currentIconPath = candidate.path;
|
|
311
|
+
currentIconUrl = candidate.url;
|
|
312
|
+
if (!touched.trayIconPath) {
|
|
313
|
+
currentTrayIconPath = candidate.path;
|
|
314
|
+
currentTrayIconUrl = candidate.url;
|
|
315
|
+
trayIconIsSolid = candidate.variant !== "original";
|
|
316
|
+
form = {
|
|
317
|
+
...form,
|
|
318
|
+
iconPath: candidate.path,
|
|
319
|
+
trayIconPath: candidate.path
|
|
320
|
+
};
|
|
321
|
+
} else form = {
|
|
322
|
+
...form,
|
|
323
|
+
iconPath: candidate.path
|
|
324
|
+
};
|
|
325
|
+
publishForm();
|
|
326
|
+
return true;
|
|
327
|
+
},
|
|
328
|
+
get form() {
|
|
329
|
+
return form;
|
|
330
|
+
},
|
|
331
|
+
get result() {
|
|
332
|
+
return result;
|
|
333
|
+
},
|
|
334
|
+
async submitCommand(command) {
|
|
335
|
+
if (state !== "idle" && state !== "failed" && state !== "running" && state !== "discovered") throw new Error(`cannot submit a command while ${state}`);
|
|
336
|
+
if (submitting) throw new Error("a command submission is already in flight");
|
|
337
|
+
submitting = true;
|
|
338
|
+
const tokens = typeof command === "string" ? void 0 : command;
|
|
339
|
+
let tokenized;
|
|
340
|
+
if (tokens === void 0) {
|
|
341
|
+
tokenized = tokenizeCommandLine(command);
|
|
342
|
+
if (!tokenized.ok) {
|
|
343
|
+
submitting = false;
|
|
344
|
+
setState("failed", tokenized.error);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
} else if (tokens.length === 0 || tokens[0].trim().length === 0) {
|
|
348
|
+
submitting = false;
|
|
349
|
+
setState("failed", "数组模式至少需要程序元素(第一个参数)");
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
await session.stop();
|
|
353
|
+
runAlive = false;
|
|
354
|
+
services = [];
|
|
355
|
+
selectedPort = void 0;
|
|
356
|
+
currentTokens = tokens ?? tokenized.tokens;
|
|
357
|
+
refreshTargetDirExists();
|
|
358
|
+
currentIconPath = void 0;
|
|
359
|
+
currentIconUrl = void 0;
|
|
360
|
+
currentTrayIconPath = void 0;
|
|
361
|
+
currentTrayIconUrl = void 0;
|
|
362
|
+
trayIconIsSolid = false;
|
|
363
|
+
iconCandidates = [];
|
|
364
|
+
touched.appId = false;
|
|
365
|
+
touched.appName = false;
|
|
366
|
+
touched.pm = false;
|
|
367
|
+
touched.trayIconPath = false;
|
|
368
|
+
form = {
|
|
369
|
+
appId: "",
|
|
370
|
+
appName: "",
|
|
371
|
+
iconPath: "",
|
|
372
|
+
iconBackground: iconBackground ?? "transparent",
|
|
373
|
+
iconScale: iconScale ?? .8,
|
|
374
|
+
...touched.force ? { force: form.force } : { force: options.force === true },
|
|
375
|
+
...touched.trayIconPath ? { trayIconPath: form.trayIconPath } : { trayIconPath: "" },
|
|
376
|
+
...touched.showStartupTerminal ? { showStartupTerminal: form.showStartupTerminal } : { showStartupTerminal: false },
|
|
377
|
+
...touched.showAddressBar ? { showAddressBar: form.showAddressBar } : { showAddressBar: false },
|
|
378
|
+
...touched.imageSmoothingEnabled ? { imageSmoothingEnabled: form.imageSmoothingEnabled } : { imageSmoothingEnabled: true },
|
|
379
|
+
...touched.developerMode ? { developerMode: form.developerMode } : { developerMode: false },
|
|
380
|
+
pm: options.packageManager ?? detectPackageManager([], process.env.npm_config_user_agent)
|
|
381
|
+
};
|
|
382
|
+
currentCommand = typeof command === "string" ? command : command.join(" ");
|
|
383
|
+
scrapedTitle = void 0;
|
|
384
|
+
emit({
|
|
385
|
+
type: "command-display",
|
|
386
|
+
command: currentCommand
|
|
387
|
+
});
|
|
388
|
+
tempIconDir = await mkdtemp(join(tmpdir(), "create-opentray-"));
|
|
389
|
+
const baseline = await (options.listListeners ?? (() => listListeningPorts(process.platform)))().catch(() => /* @__PURE__ */ new Set());
|
|
390
|
+
setState("running");
|
|
391
|
+
publishForm();
|
|
392
|
+
const spawnRun = options.spawnRun ?? startCommandRun;
|
|
393
|
+
const envOverlay = commandEnv();
|
|
394
|
+
run = await spawnRun({
|
|
395
|
+
tokens: currentTokens,
|
|
396
|
+
cwd: effectiveCwd(),
|
|
397
|
+
...Object.keys(envOverlay).length === 0 ? {} : { env: envOverlay },
|
|
398
|
+
onEvent: (event) => {
|
|
399
|
+
if (event.type === "stdout" || event.type === "stderr") {
|
|
400
|
+
emit({
|
|
401
|
+
type: "log",
|
|
402
|
+
stream: event.type,
|
|
403
|
+
chunk: event.chunk ?? ""
|
|
404
|
+
});
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (event.type === "pty-ready") {
|
|
408
|
+
interactive = true;
|
|
409
|
+
emit({
|
|
410
|
+
type: "term-mode",
|
|
411
|
+
interactive: true
|
|
412
|
+
});
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (event.type === "pty-unavailable") {
|
|
416
|
+
interactive = false;
|
|
417
|
+
emit({
|
|
418
|
+
type: "term-mode",
|
|
419
|
+
interactive: false,
|
|
420
|
+
...event.message === void 0 ? {} : { message: event.message }
|
|
421
|
+
});
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (event.type === "spawn-error") {
|
|
425
|
+
stopTimers();
|
|
426
|
+
setState("failed", event.message ?? "spawn failed");
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (event.type === "exit") {
|
|
430
|
+
runAlive = false;
|
|
431
|
+
stopTimers();
|
|
432
|
+
emit({
|
|
433
|
+
type: "run-status",
|
|
434
|
+
running: false,
|
|
435
|
+
...event.code === void 0 ? {} : { code: event.code }
|
|
436
|
+
});
|
|
437
|
+
if (state === "running" && services.length === 0) setState("failed", `command exited with ${event.code ?? "signal"} before any service appeared`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
submitting = false;
|
|
442
|
+
run.exited.then(async ({ code, spawnError }) => {
|
|
443
|
+
if (state === "running" && services.length === 0 && spawnError !== void 0) {
|
|
444
|
+
stopTimers();
|
|
445
|
+
setState("failed", spawnError);
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
discovery = createPortDiscovery({
|
|
449
|
+
baseline,
|
|
450
|
+
...options.listListeners === void 0 ? {} : { listListeners: () => options.listListeners() },
|
|
451
|
+
...options.verifyHttp === void 0 ? {} : { verifyHttp: options.verifyHttp },
|
|
452
|
+
...options.listPortOwners === void 0 ? {} : { listOwners: options.listPortOwners },
|
|
453
|
+
...run.pid === void 0 ? {} : { resolveOwnerPids: () => collectProcessTreePids(run?.pid ?? 0, options.platform ?? process.platform) }
|
|
454
|
+
});
|
|
455
|
+
runAlive = true;
|
|
456
|
+
emit({
|
|
457
|
+
type: "run-status",
|
|
458
|
+
running: true
|
|
459
|
+
});
|
|
460
|
+
startDiscoveryPolling();
|
|
461
|
+
},
|
|
462
|
+
prime(command) {
|
|
463
|
+
if (state === "frozen" || state === "materializing" || state === "success") return;
|
|
464
|
+
if (typeof command !== "string") {
|
|
465
|
+
if (command.length === 0 || command[0].trim().length === 0) return;
|
|
466
|
+
currentTokens = command;
|
|
467
|
+
currentCommand = command.join(" ");
|
|
468
|
+
emit({
|
|
469
|
+
type: "command-display",
|
|
470
|
+
command: currentCommand
|
|
471
|
+
});
|
|
472
|
+
refreshTargetDirExists();
|
|
473
|
+
publishForm();
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const tokenized = tokenizeCommandLine(command);
|
|
477
|
+
if (!tokenized.ok) return;
|
|
478
|
+
currentTokens = tokenized.tokens;
|
|
479
|
+
currentCommand = command;
|
|
480
|
+
emit({
|
|
481
|
+
type: "command-display",
|
|
482
|
+
command
|
|
483
|
+
});
|
|
484
|
+
refreshTargetDirExists();
|
|
485
|
+
publishForm();
|
|
486
|
+
},
|
|
487
|
+
get commandOptions() {
|
|
488
|
+
return commandOptions;
|
|
489
|
+
},
|
|
490
|
+
updateCommandOptions(patch) {
|
|
491
|
+
if (state === "frozen" || state === "materializing" || state === "success") return;
|
|
492
|
+
commandOptions = {
|
|
493
|
+
...commandOptions,
|
|
494
|
+
...patch
|
|
495
|
+
};
|
|
496
|
+
publishCommandOptions();
|
|
497
|
+
},
|
|
498
|
+
async analyzeIconForeground(foregroundPath) {
|
|
499
|
+
const stats = await foregroundStats(foregroundPath).catch(() => ({
|
|
500
|
+
luminance: void 0,
|
|
501
|
+
coverage: 0
|
|
502
|
+
}));
|
|
503
|
+
return {
|
|
504
|
+
...stats,
|
|
505
|
+
suggested: autoBackground(stats)
|
|
506
|
+
};
|
|
507
|
+
},
|
|
508
|
+
async composeIcon(options) {
|
|
509
|
+
if (state === "frozen" || state === "materializing" || state === "success") throw new Error("cannot compose while frozen");
|
|
510
|
+
const background = options.background ?? iconBackground ?? "transparent";
|
|
511
|
+
const scale = options.scale ?? iconScale ?? .8;
|
|
512
|
+
const composed = await composeAppIcon({
|
|
513
|
+
foregroundPath: options.foregroundPath,
|
|
514
|
+
background,
|
|
515
|
+
scale,
|
|
516
|
+
outputDir: await ensureIconComposeDir()
|
|
517
|
+
});
|
|
518
|
+
const composition = {
|
|
519
|
+
key: compositionCacheKey({
|
|
520
|
+
foregroundPath: options.foregroundPath,
|
|
521
|
+
background,
|
|
522
|
+
scale
|
|
523
|
+
}),
|
|
524
|
+
...composed
|
|
525
|
+
};
|
|
526
|
+
iconCompositions.set(composition.key, composition);
|
|
527
|
+
return composition;
|
|
528
|
+
},
|
|
529
|
+
trackIconComposition(composition) {
|
|
530
|
+
iconCompositions.set(composition.key, composition);
|
|
531
|
+
},
|
|
532
|
+
iconSourceRoots() {
|
|
533
|
+
return [...tempIconDir !== void 0 ? [tempIconDir] : [], ...composeIconDir !== void 0 ? [composeIconDir] : []];
|
|
534
|
+
},
|
|
535
|
+
iconComposition(key) {
|
|
536
|
+
return iconCompositions.get(key);
|
|
537
|
+
},
|
|
538
|
+
async saveIconUpload(bytes) {
|
|
539
|
+
const path = join(tempIconDir ?? (tempIconDir = await mkdtemp(join(tmpdir(), "create-opentray-"))), `upload-${createHash("sha256").update(bytes).digest("hex").slice(0, 16)}.bin`);
|
|
540
|
+
await writeFile(path, bytes);
|
|
541
|
+
return path;
|
|
542
|
+
},
|
|
543
|
+
selectService(port) {
|
|
544
|
+
if (state !== "discovered" && state !== "running") return;
|
|
545
|
+
if (!services.some((service) => service.port === port)) return;
|
|
546
|
+
selectedPort = port;
|
|
547
|
+
currentIconPath = void 0;
|
|
548
|
+
currentIconUrl = void 0;
|
|
549
|
+
publishServices();
|
|
550
|
+
if (state === "discovered") startScrapePolling();
|
|
551
|
+
},
|
|
552
|
+
updateForm(patch) {
|
|
553
|
+
if (state !== "idle" && state !== "running" && state !== "discovered" && state !== "failed") return;
|
|
554
|
+
if (patch.iconBackground !== void 0) iconBackground = patch.iconBackground;
|
|
555
|
+
if (patch.iconScale !== void 0) iconScale = patch.iconScale;
|
|
556
|
+
for (const key of Object.keys(patch)) if (patch[key] !== void 0 && patch[key] !== form[key]) {
|
|
557
|
+
touched[key] = true;
|
|
558
|
+
if (key === "trayIconPath") trayIconIsSolid = false;
|
|
559
|
+
}
|
|
560
|
+
form = {
|
|
561
|
+
...form,
|
|
562
|
+
...patch
|
|
563
|
+
};
|
|
564
|
+
publishForm();
|
|
565
|
+
},
|
|
566
|
+
/** Forward base64-encoded terminal keystroke bytes to the preview command. */
|
|
567
|
+
terminalInput(data) {
|
|
568
|
+
if (state !== "running" && state !== "discovered") return;
|
|
569
|
+
run?.write(data);
|
|
570
|
+
},
|
|
571
|
+
/** Forward terminal dimensions to the pseudo-terminal. */
|
|
572
|
+
terminalResize(size) {
|
|
573
|
+
if (state !== "running" && state !== "discovered") return;
|
|
574
|
+
run?.resize(size);
|
|
575
|
+
},
|
|
576
|
+
confirm() {
|
|
577
|
+
if (state !== "idle" && state !== "running" && state !== "discovered" && state !== "failed") throw new Error(`cannot confirm while ${state}`);
|
|
578
|
+
resolvedServicePort = selectedPort ?? 0;
|
|
579
|
+
resolvedTargetDir = currentDefaults().targetDir;
|
|
580
|
+
const defaults = currentDefaults();
|
|
581
|
+
let resolvedForm = {
|
|
582
|
+
...form,
|
|
583
|
+
appId: form.appId.trim().length > 0 ? form.appId : defaults.appId,
|
|
584
|
+
appName: form.appName.trim().length > 0 ? form.appName : defaults.appName
|
|
585
|
+
};
|
|
586
|
+
if (resolvedForm.iconPath.trim().length > 0) {
|
|
587
|
+
currentIconPath = resolvedForm.iconPath.trim();
|
|
588
|
+
currentIconUrl = void 0;
|
|
589
|
+
}
|
|
590
|
+
frozenIconPath = currentIconPath;
|
|
591
|
+
frozenIconUrl = currentIconUrl;
|
|
592
|
+
const resolvedTrayIconPath = resolvedForm.trayIconPath.trim().length > 0 ? resolvedForm.trayIconPath.trim() : resolvedForm.iconPath.trim().length > 0 ? resolvedForm.iconPath.trim() : currentIconPath ?? "";
|
|
593
|
+
resolvedForm = {
|
|
594
|
+
...resolvedForm,
|
|
595
|
+
trayIconPath: resolvedTrayIconPath
|
|
596
|
+
};
|
|
597
|
+
currentTrayIconPath = resolvedTrayIconPath;
|
|
598
|
+
form = resolvedForm;
|
|
599
|
+
stopTimers();
|
|
600
|
+
frozenForm = { ...form };
|
|
601
|
+
setState("frozen");
|
|
602
|
+
publishForm();
|
|
603
|
+
},
|
|
604
|
+
cancel() {
|
|
605
|
+
if (state !== "frozen") throw new Error(`cannot cancel while ${state}`);
|
|
606
|
+
frozenForm = void 0;
|
|
607
|
+
frozenIconPath = void 0;
|
|
608
|
+
frozenIconUrl = void 0;
|
|
609
|
+
resolvedServicePort = void 0;
|
|
610
|
+
resolvedTargetDir = void 0;
|
|
611
|
+
setState(selectedPort === void 0 ? "running" : "discovered");
|
|
612
|
+
publishForm();
|
|
613
|
+
if (runAlive && discovery !== void 0) startDiscoveryPolling();
|
|
614
|
+
},
|
|
615
|
+
async exportFrozen(exportOptions) {
|
|
616
|
+
const frozen = frozenForm;
|
|
617
|
+
if (frozen === void 0) return {
|
|
618
|
+
ok: false,
|
|
619
|
+
code: "state_error",
|
|
620
|
+
message: "share requires a confirmed (frozen) parameter set"
|
|
621
|
+
};
|
|
622
|
+
if (currentTokens.length === 0) return {
|
|
623
|
+
ok: false,
|
|
624
|
+
code: "state_error",
|
|
625
|
+
message: "no command recorded"
|
|
626
|
+
};
|
|
627
|
+
let vector;
|
|
628
|
+
try {
|
|
629
|
+
vector = await (options.resolveVector ?? resolveLaunchVector)({
|
|
630
|
+
tokens: currentTokens,
|
|
631
|
+
cwd: effectiveCwd()
|
|
632
|
+
});
|
|
633
|
+
} catch (error) {
|
|
634
|
+
return {
|
|
635
|
+
ok: false,
|
|
636
|
+
code: "resolve_failed",
|
|
637
|
+
message: `failed to resolve the launch vector: ${error instanceof Error ? error.message : String(error)}`
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
const envOverlay = commandEnv();
|
|
641
|
+
const env = Object.keys(envOverlay).length > 0 ? envOverlay : vector.env;
|
|
642
|
+
const embedded = [];
|
|
643
|
+
const iconRef = async (path, url, flag) => {
|
|
644
|
+
if (path === void 0 || path.trim().length === 0) return { mode: "none" };
|
|
645
|
+
const hasUrl = url !== void 0 && /^https?:\/\//u.test(url);
|
|
646
|
+
if (!(exportOptions.inlineIcon ?? !hasUrl)) {
|
|
647
|
+
if (hasUrl) return {
|
|
648
|
+
ref: {
|
|
649
|
+
path: "icon.png",
|
|
650
|
+
format: "png",
|
|
651
|
+
sha256: "",
|
|
652
|
+
source: {
|
|
653
|
+
kind: "http",
|
|
654
|
+
ref: url
|
|
655
|
+
}
|
|
656
|
+
},
|
|
657
|
+
mode: "url"
|
|
658
|
+
};
|
|
659
|
+
return {
|
|
660
|
+
ref: {
|
|
661
|
+
path: basename(path),
|
|
662
|
+
format: "png",
|
|
663
|
+
sha256: "",
|
|
664
|
+
source: {
|
|
665
|
+
kind: "file",
|
|
666
|
+
ref: path
|
|
667
|
+
}
|
|
668
|
+
},
|
|
669
|
+
mode: "local"
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
try {
|
|
673
|
+
const bytes = new Uint8Array(await readFile(path));
|
|
674
|
+
const format = detectImageFormat(bytes);
|
|
675
|
+
if (format === void 0) return { mode: "none" };
|
|
676
|
+
embedded.push({
|
|
677
|
+
flag,
|
|
678
|
+
filename: basename(path),
|
|
679
|
+
bytes
|
|
680
|
+
});
|
|
681
|
+
return {
|
|
682
|
+
ref: {
|
|
683
|
+
path: basename(path),
|
|
684
|
+
format,
|
|
685
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
686
|
+
source: {
|
|
687
|
+
kind: "file",
|
|
688
|
+
ref: path
|
|
689
|
+
}
|
|
690
|
+
},
|
|
691
|
+
mode: "embedded"
|
|
692
|
+
};
|
|
693
|
+
} catch {
|
|
694
|
+
return { mode: "none" };
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
const appIconSource = frozenIconPath ?? currentIconPath;
|
|
698
|
+
const appIconUrl = frozenIconUrl ?? currentIconUrl;
|
|
699
|
+
const trayIconSource = currentTrayIconPath;
|
|
700
|
+
const trayIconUrl = currentTrayIconUrl;
|
|
701
|
+
const appIconRef = await iconRef(appIconSource, appIconUrl, "app-icon");
|
|
702
|
+
const appIcon = appIconRef.ref;
|
|
703
|
+
const trayIcon = (trayIconSource !== void 0 && trayIconSource !== appIconSource ? await iconRef(trayIconSource, trayIconUrl, "tray-icon") : {
|
|
704
|
+
ref: void 0,
|
|
705
|
+
mode: "none"
|
|
706
|
+
}).ref;
|
|
707
|
+
const config = {
|
|
708
|
+
schemaVersion: 1,
|
|
709
|
+
appId: frozen.appId,
|
|
710
|
+
appName: frozen.appName,
|
|
711
|
+
command: {
|
|
712
|
+
executable: vector.command,
|
|
713
|
+
args: [...vector.args],
|
|
714
|
+
cwd: vector.cwd,
|
|
715
|
+
...env === void 0 ? {} : { env }
|
|
716
|
+
},
|
|
717
|
+
packageManager: frozen.pm,
|
|
718
|
+
icons: {
|
|
719
|
+
imageSmoothingEnabled: frozen.imageSmoothingEnabled !== false,
|
|
720
|
+
background: frozen.iconBackground ?? "transparent",
|
|
721
|
+
scale: frozen.iconScale ?? .8,
|
|
722
|
+
...trayIconIsSolid === true ? { trayTemplate: true } : {},
|
|
723
|
+
...appIcon === void 0 ? {} : { appIcon },
|
|
724
|
+
...trayIcon === void 0 ? {} : { trayIcon }
|
|
725
|
+
},
|
|
726
|
+
window: {
|
|
727
|
+
width: 1200,
|
|
728
|
+
height: 800
|
|
729
|
+
},
|
|
730
|
+
developerMode: frozen.developerMode === true
|
|
731
|
+
};
|
|
732
|
+
if (Object.keys(config.command.env ?? {}).length > 0 && exportOptions.acknowledgeEnv !== true) return {
|
|
733
|
+
ok: false,
|
|
734
|
+
code: "env_ack_required",
|
|
735
|
+
message: "environment entries present; acknowledgement required"
|
|
736
|
+
};
|
|
737
|
+
if (exportOptions.format === "command") {
|
|
738
|
+
const plan = buildExportPlan({
|
|
739
|
+
config,
|
|
740
|
+
embeddedResources: embedded,
|
|
741
|
+
forceCopy: exportOptions.forceCopy === true
|
|
742
|
+
});
|
|
743
|
+
if (!plan.ok) return {
|
|
744
|
+
ok: false,
|
|
745
|
+
code: "export_unsafe",
|
|
746
|
+
message: plan.error.message
|
|
747
|
+
};
|
|
748
|
+
if (plan.value.directCommand === null) return {
|
|
749
|
+
ok: false,
|
|
750
|
+
code: "export_unsafe",
|
|
751
|
+
message: plan.value.directCommandBlockedReason ?? "direct copy requires force-copy"
|
|
752
|
+
};
|
|
753
|
+
return {
|
|
754
|
+
ok: true,
|
|
755
|
+
kind: "command",
|
|
756
|
+
command: formatPosixCommandLine(plan.value.directCommand.command)
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
const script = buildScriptExport({
|
|
760
|
+
config,
|
|
761
|
+
embeddedResources: embedded
|
|
762
|
+
}, exportOptions.format === "sh" ? "sh" : "powershell");
|
|
763
|
+
if (!script.ok) return {
|
|
764
|
+
ok: false,
|
|
765
|
+
code: "export_unsafe",
|
|
766
|
+
message: script.error.message
|
|
767
|
+
};
|
|
768
|
+
const iconReference = appIconUrl !== void 0 && /^https?:\/\//u.test(appIconUrl) ? "url" : appIconSource !== void 0 && appIconSource.trim().length > 0 ? "local" : "none";
|
|
769
|
+
return {
|
|
770
|
+
ok: true,
|
|
771
|
+
kind: "script",
|
|
772
|
+
filename: script.value.filename,
|
|
773
|
+
content: script.value.content,
|
|
774
|
+
commandLine: script.value.commandLine,
|
|
775
|
+
requiresEnvAcknowledgement: script.value.requiresEnvAcknowledgement,
|
|
776
|
+
iconSharedAs: appIconRef.mode,
|
|
777
|
+
iconReference
|
|
778
|
+
};
|
|
779
|
+
},
|
|
780
|
+
async create() {
|
|
781
|
+
if (state !== "frozen") throw new Error(`cannot create while ${state}`);
|
|
782
|
+
const frozen = frozenForm ?? form;
|
|
783
|
+
if (currentTokens.length === 0) throw new Error("no command recorded");
|
|
784
|
+
setState("materializing");
|
|
785
|
+
if (run !== void 0) {
|
|
786
|
+
await run.kill();
|
|
787
|
+
run = void 0;
|
|
788
|
+
}
|
|
789
|
+
try {
|
|
790
|
+
resolvedVector = await (options.resolveVector ?? resolveLaunchVector)({
|
|
791
|
+
tokens: currentTokens,
|
|
792
|
+
cwd: effectiveCwd()
|
|
793
|
+
});
|
|
794
|
+
const envOverlay = commandEnv();
|
|
795
|
+
if (Object.keys(envOverlay).length > 0) resolvedVector = {
|
|
796
|
+
...resolvedVector,
|
|
797
|
+
env: envOverlay
|
|
798
|
+
};
|
|
799
|
+
} catch (error) {
|
|
800
|
+
setState("failed", error instanceof Error ? error.message : String(error));
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
try {
|
|
804
|
+
result = await materialize({
|
|
805
|
+
config: {
|
|
806
|
+
schemaVersion: 1,
|
|
807
|
+
appId: frozen.appId,
|
|
808
|
+
appName: frozen.appName,
|
|
809
|
+
command: resolvedVector,
|
|
810
|
+
service: { port: resolvedServicePort ?? 0 },
|
|
811
|
+
window: {
|
|
812
|
+
width: 1200,
|
|
813
|
+
height: 800
|
|
814
|
+
},
|
|
815
|
+
...frozen.developerMode === true ? { developerMode: true } : {}
|
|
816
|
+
},
|
|
817
|
+
targetDir: resolvedTargetDir ?? currentDefaults().targetDir,
|
|
818
|
+
dependencyRange: options.dependencyRange,
|
|
819
|
+
iconSourcePath: frozenIconPath ?? currentIconPath,
|
|
820
|
+
...frozen.iconBackground === void 0 ? {} : { iconBackground: frozen.iconBackground },
|
|
821
|
+
...frozen.iconScale === void 0 ? {} : { iconScale: frozen.iconScale },
|
|
822
|
+
...currentTrayIconPath === void 0 ? {} : { trayIconSourcePath: currentTrayIconPath },
|
|
823
|
+
...trayIconIsSolid ? { trayIconIsSolid: true } : {},
|
|
824
|
+
shell: {
|
|
825
|
+
showTerminal: frozen.showStartupTerminal,
|
|
826
|
+
showAddressBar: frozen.showAddressBar
|
|
827
|
+
},
|
|
828
|
+
...frozen.imageSmoothingEnabled === false ? { imageSmoothingEnabled: false } : {},
|
|
829
|
+
...frozen.developerMode === true ? { developerMode: true } : {},
|
|
830
|
+
packageManager: frozen.pm,
|
|
831
|
+
skipInstall: options.skipInstall,
|
|
832
|
+
force: frozen.force
|
|
833
|
+
}, {
|
|
834
|
+
log: (event) => {
|
|
835
|
+
if (event.type === "step") {
|
|
836
|
+
emit({
|
|
837
|
+
type: "materialize-step",
|
|
838
|
+
step: event.step,
|
|
839
|
+
message: event.message
|
|
840
|
+
});
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
emit({
|
|
844
|
+
type: "materialize-log",
|
|
845
|
+
message: event.message
|
|
846
|
+
});
|
|
847
|
+
},
|
|
848
|
+
...options.platform === void 0 ? {} : { platform: options.platform },
|
|
849
|
+
...options.materializeContext ?? {}
|
|
850
|
+
});
|
|
851
|
+
setState("success");
|
|
852
|
+
emit({
|
|
853
|
+
type: "success",
|
|
854
|
+
projectDir: result.projectDir,
|
|
855
|
+
pinHint: pinningHint()
|
|
856
|
+
});
|
|
857
|
+
} catch (error) {
|
|
858
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
859
|
+
const occupied = message.includes("target directory is not empty");
|
|
860
|
+
setState("failed", occupied ? `${message};可在「高级选项」中开启 强制覆盖 后重试` : message);
|
|
861
|
+
}
|
|
862
|
+
},
|
|
863
|
+
async stop() {
|
|
864
|
+
stopTimers();
|
|
865
|
+
discovery.stop();
|
|
866
|
+
if (run !== void 0) {
|
|
867
|
+
await run.kill();
|
|
868
|
+
run = void 0;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
publishCommandOptions();
|
|
873
|
+
return session;
|
|
874
|
+
};
|
|
875
|
+
//#endregion
|
|
876
|
+
//#region src/workbench-api.ts
|
|
877
|
+
const exists = async (path) => {
|
|
878
|
+
try {
|
|
879
|
+
await access(path);
|
|
880
|
+
return true;
|
|
881
|
+
} catch {
|
|
882
|
+
return false;
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
const moduleDirectory = dirname(fileURLToPath(import.meta.url));
|
|
886
|
+
/** Resolve the packaged skill root served to the WebUI help center. */
|
|
887
|
+
const resolveSkillRoot = async () => {
|
|
888
|
+
const candidates = [
|
|
889
|
+
join(moduleDirectory, "skill"),
|
|
890
|
+
join(moduleDirectory, "..", "..", "skill"),
|
|
891
|
+
join(moduleDirectory, "..", "skill")
|
|
892
|
+
];
|
|
893
|
+
for (const candidate of candidates) try {
|
|
894
|
+
await readdir(candidate);
|
|
895
|
+
return resolve(candidate);
|
|
896
|
+
} catch {}
|
|
897
|
+
};
|
|
898
|
+
const SKILL_PATH_PATTERN = /^(?!-_)[A-Za-z0-9._-]+(?:\/(?!_)[A-Za-z0-9._-]+)*$/u;
|
|
899
|
+
const containedSkillPath = (logical) => {
|
|
900
|
+
if (!SKILL_PATH_PATTERN.test(logical) || logical.includes("..") || logical.includes("\0")) return;
|
|
901
|
+
return logical;
|
|
902
|
+
};
|
|
903
|
+
/**
|
|
904
|
+
* Handle one workbench API request. Returns undefined when the pathname is
|
|
905
|
+
* not a workbench route (the caller falls through to its own handling).
|
|
906
|
+
*/
|
|
907
|
+
const handleWorkbenchApi = async (request) => {
|
|
908
|
+
const { pathname } = request;
|
|
909
|
+
if (pathname === "/api/apps" && request.method === "GET") {
|
|
910
|
+
const entries = await listCreateEntries();
|
|
911
|
+
return {
|
|
912
|
+
status: 200,
|
|
913
|
+
body: await Promise.all(entries.map(async (entry) => {
|
|
914
|
+
if (entry.source === "wizard") {
|
|
915
|
+
const projectDir = entry.dir;
|
|
916
|
+
return {
|
|
917
|
+
key: entry.key,
|
|
918
|
+
source: "wizard",
|
|
919
|
+
appId: entry.config?.appId,
|
|
920
|
+
appName: entry.config?.appName,
|
|
921
|
+
status: "healthy",
|
|
922
|
+
registrationDir: entry.dir,
|
|
923
|
+
payloadPath: entry.dir,
|
|
924
|
+
projectDir,
|
|
925
|
+
isLink: false,
|
|
926
|
+
hasEnv: entry.config !== void 0 && Object.keys(entry.config.command.env ?? {}).length > 0,
|
|
927
|
+
hasIcon: await exists(join(projectDir, "app-icon", "app-icon.png")),
|
|
928
|
+
...entry.config === void 0 ? { error: {
|
|
929
|
+
code: "invalid_config",
|
|
930
|
+
message: "unreadable wizard project config"
|
|
931
|
+
} } : {}
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
const projectDir = entry.record.payloadPath ?? entry.record.appDir;
|
|
935
|
+
return {
|
|
936
|
+
key: entry.key,
|
|
937
|
+
source: "registered",
|
|
938
|
+
appId: entry.record.config?.appId,
|
|
939
|
+
appName: entry.record.config?.appName,
|
|
940
|
+
status: entry.record.status,
|
|
941
|
+
registrationDir: entry.record.dir,
|
|
942
|
+
payloadPath: entry.record.payloadPath,
|
|
943
|
+
projectDir,
|
|
944
|
+
isLink: entry.record.isLink,
|
|
945
|
+
hasEnv: entry.record.config !== void 0 && Object.keys(entry.record.config.command.env ?? {}).length > 0,
|
|
946
|
+
hasIcon: await exists(join(projectDir, "app-icon", "app-icon.png")),
|
|
947
|
+
...entry.record.error === void 0 ? {} : { error: {
|
|
948
|
+
code: entry.record.error.code,
|
|
949
|
+
message: entry.record.error.message
|
|
950
|
+
} }
|
|
951
|
+
};
|
|
952
|
+
}))
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
const iconMatch = /^\/api\/apps\/([^/]+)\/icon$/.exec(pathname);
|
|
956
|
+
if (iconMatch !== null && request.method === "GET") {
|
|
957
|
+
const key = decodeURIComponent(iconMatch[1]);
|
|
958
|
+
const entry = await findCreateEntry(key);
|
|
959
|
+
if (entry === void 0) return {
|
|
960
|
+
status: 404,
|
|
961
|
+
body: {
|
|
962
|
+
code: "not_found",
|
|
963
|
+
message: `no application at key ${key}`
|
|
964
|
+
}
|
|
965
|
+
};
|
|
966
|
+
const projectDir = entry.source === "wizard" ? entry.dir : entry.record.payloadPath;
|
|
967
|
+
if (projectDir === void 0) return {
|
|
968
|
+
status: 404,
|
|
969
|
+
body: {
|
|
970
|
+
code: "missing_payload",
|
|
971
|
+
message: "application payload is unavailable"
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
try {
|
|
975
|
+
const bytes = new Uint8Array(await readFile(join(projectDir, "app-icon", "app-icon.png")));
|
|
976
|
+
return {
|
|
977
|
+
status: 200,
|
|
978
|
+
body: { dataUrl: `data:image/png;base64,${Buffer.from(bytes).toString("base64")}` }
|
|
979
|
+
};
|
|
980
|
+
} catch {
|
|
981
|
+
return {
|
|
982
|
+
status: 404,
|
|
983
|
+
body: {
|
|
984
|
+
code: "not_found",
|
|
985
|
+
message: "no app icon asset"
|
|
986
|
+
}
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
const readMatch = /^\/api\/apps\/([^/]+)\/config$/.exec(pathname);
|
|
991
|
+
if (readMatch !== null && request.method === "GET") {
|
|
992
|
+
const key = decodeURIComponent(readMatch[1]);
|
|
993
|
+
const entry = await findCreateEntry(key);
|
|
994
|
+
if (entry === void 0) return {
|
|
995
|
+
status: 404,
|
|
996
|
+
body: {
|
|
997
|
+
code: "not_found",
|
|
998
|
+
message: `no application at key ${key}`
|
|
999
|
+
}
|
|
1000
|
+
};
|
|
1001
|
+
if (entry.source === "wizard") {
|
|
1002
|
+
if (entry.config === void 0) return {
|
|
1003
|
+
status: 500,
|
|
1004
|
+
body: {
|
|
1005
|
+
code: "invalid_config",
|
|
1006
|
+
message: "unreadable wizard project config"
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
return {
|
|
1010
|
+
status: 200,
|
|
1011
|
+
body: {
|
|
1012
|
+
schemaVersion: 1,
|
|
1013
|
+
appId: entry.config.appId,
|
|
1014
|
+
appName: entry.config.appName,
|
|
1015
|
+
command: {
|
|
1016
|
+
executable: entry.config.command.executable,
|
|
1017
|
+
args: [...entry.config.command.args],
|
|
1018
|
+
cwd: entry.config.command.cwd,
|
|
1019
|
+
...entry.config.command.env === void 0 ? {} : { env: { ...entry.config.command.env } }
|
|
1020
|
+
},
|
|
1021
|
+
packageManager: entry.config.packageManager,
|
|
1022
|
+
icons: {
|
|
1023
|
+
imageSmoothingEnabled: true,
|
|
1024
|
+
background: "transparent",
|
|
1025
|
+
scale: .8
|
|
1026
|
+
},
|
|
1027
|
+
window: entry.config.window,
|
|
1028
|
+
developerMode: entry.config.developerMode
|
|
1029
|
+
}
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
if (entry.record.config === void 0) return {
|
|
1033
|
+
status: 500,
|
|
1034
|
+
body: {
|
|
1035
|
+
code: "invalid_config",
|
|
1036
|
+
message: "unreadable registration"
|
|
1037
|
+
}
|
|
1038
|
+
};
|
|
1039
|
+
return {
|
|
1040
|
+
status: 200,
|
|
1041
|
+
body: entry.record.config
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
const openMatch = /^\/api\/apps\/([^/]+)\/open$/.exec(pathname);
|
|
1045
|
+
if (openMatch !== null && request.method === "POST") {
|
|
1046
|
+
const key = decodeURIComponent(openMatch[1]);
|
|
1047
|
+
const entry = await findCreateEntry(key);
|
|
1048
|
+
if (entry === void 0) return {
|
|
1049
|
+
status: 404,
|
|
1050
|
+
body: {
|
|
1051
|
+
code: "not_found",
|
|
1052
|
+
message: `no application at key ${key}`
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
const projectDir = entry.source === "wizard" ? entry.dir : entry.record.payloadPath;
|
|
1056
|
+
if (projectDir === void 0) return {
|
|
1057
|
+
status: 409,
|
|
1058
|
+
body: {
|
|
1059
|
+
code: "missing_payload",
|
|
1060
|
+
message: "application payload is unavailable"
|
|
1061
|
+
}
|
|
1062
|
+
};
|
|
1063
|
+
const opened = await openMaterializedApp({
|
|
1064
|
+
projectDir,
|
|
1065
|
+
bundlePath: void 0
|
|
1066
|
+
});
|
|
1067
|
+
return {
|
|
1068
|
+
status: opened.ok ? 200 : 500,
|
|
1069
|
+
body: opened
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
const uninstallMatch = /^\/api\/apps\/([^/]+)\/uninstall$/.exec(pathname);
|
|
1073
|
+
if (uninstallMatch !== null && request.method === "POST") {
|
|
1074
|
+
const key = decodeURIComponent(uninstallMatch[1]);
|
|
1075
|
+
const entry = await findCreateEntry(key);
|
|
1076
|
+
if (entry === void 0) return {
|
|
1077
|
+
status: 404,
|
|
1078
|
+
body: {
|
|
1079
|
+
code: "not_found",
|
|
1080
|
+
message: `no application at key ${key}`
|
|
1081
|
+
}
|
|
1082
|
+
};
|
|
1083
|
+
if (entry.source === "wizard") {
|
|
1084
|
+
const result = await uninstallWizardProject({
|
|
1085
|
+
key,
|
|
1086
|
+
stopRunning: request.body.stopRunning === true
|
|
1087
|
+
});
|
|
1088
|
+
if (!result.ok) return {
|
|
1089
|
+
status: result.error.code === "not_found" ? 404 : result.error.code === "app_running" ? 409 : 500,
|
|
1090
|
+
body: result.error
|
|
1091
|
+
};
|
|
1092
|
+
return {
|
|
1093
|
+
status: 200,
|
|
1094
|
+
body: result.value
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
const appId = entry?.source === "registered" && entry.record.config !== void 0 ? entry.record.config.appId : typeof request.body.appId === "string" ? request.body.appId : void 0;
|
|
1098
|
+
if (appId === void 0) return {
|
|
1099
|
+
status: 400,
|
|
1100
|
+
body: {
|
|
1101
|
+
code: "invalid_config",
|
|
1102
|
+
message: "appId is required"
|
|
1103
|
+
}
|
|
1104
|
+
};
|
|
1105
|
+
const result = await uninstallApp({
|
|
1106
|
+
appId,
|
|
1107
|
+
stopRunning: request.body.stopRunning === true,
|
|
1108
|
+
purgeTarget: request.body.purgeTarget === true
|
|
1109
|
+
});
|
|
1110
|
+
if (!result.ok) return {
|
|
1111
|
+
status: result.error.code === "not_found" ? 404 : result.error.code === "app_running" ? 409 : 500,
|
|
1112
|
+
body: result.error
|
|
1113
|
+
};
|
|
1114
|
+
return {
|
|
1115
|
+
status: 200,
|
|
1116
|
+
body: result.value
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
if (/^\/api\/apps\/([^/]+)\/stop$/.exec(pathname) !== null && request.method === "POST") {
|
|
1120
|
+
const appId = typeof request.body.appId === "string" ? request.body.appId : void 0;
|
|
1121
|
+
if (appId === void 0) return {
|
|
1122
|
+
status: 400,
|
|
1123
|
+
body: {
|
|
1124
|
+
code: "invalid_config",
|
|
1125
|
+
message: "appId is required"
|
|
1126
|
+
}
|
|
1127
|
+
};
|
|
1128
|
+
const result = await stopRunningApp({ appId });
|
|
1129
|
+
if (!result.ok) return {
|
|
1130
|
+
status: 500,
|
|
1131
|
+
body: result.error
|
|
1132
|
+
};
|
|
1133
|
+
return {
|
|
1134
|
+
status: 200,
|
|
1135
|
+
body: result.value
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
const exportMatch = /^\/api\/apps\/([^/]+)\/export$/.exec(pathname);
|
|
1139
|
+
if (exportMatch !== null && request.method === "POST") {
|
|
1140
|
+
const key = decodeURIComponent(exportMatch[1]);
|
|
1141
|
+
const entry = await findCreateEntry(key);
|
|
1142
|
+
if (entry === void 0) return {
|
|
1143
|
+
status: 404,
|
|
1144
|
+
body: {
|
|
1145
|
+
code: "not_found",
|
|
1146
|
+
message: `no application at key ${key}`
|
|
1147
|
+
}
|
|
1148
|
+
};
|
|
1149
|
+
let config;
|
|
1150
|
+
const embedded = [];
|
|
1151
|
+
let iconSharedAs = "none";
|
|
1152
|
+
let iconReference = "none";
|
|
1153
|
+
const inlineIcon = request.body.inlineIcon;
|
|
1154
|
+
if (entry.source === "wizard") {
|
|
1155
|
+
if (entry.config === void 0) return {
|
|
1156
|
+
status: 500,
|
|
1157
|
+
body: {
|
|
1158
|
+
code: "invalid_config",
|
|
1159
|
+
message: "unreadable wizard project config"
|
|
1160
|
+
}
|
|
1161
|
+
};
|
|
1162
|
+
const icon = await readWizardProjectIcon(entry.dir);
|
|
1163
|
+
config = {
|
|
1164
|
+
schemaVersion: 1,
|
|
1165
|
+
appId: entry.config.appId,
|
|
1166
|
+
appName: entry.config.appName,
|
|
1167
|
+
command: {
|
|
1168
|
+
executable: entry.config.command.executable,
|
|
1169
|
+
args: [...entry.config.command.args],
|
|
1170
|
+
cwd: entry.config.command.cwd,
|
|
1171
|
+
...entry.config.command.env === void 0 ? {} : { env: { ...entry.config.command.env } }
|
|
1172
|
+
},
|
|
1173
|
+
packageManager: entry.config.packageManager,
|
|
1174
|
+
icons: {
|
|
1175
|
+
imageSmoothingEnabled: true,
|
|
1176
|
+
background: "transparent",
|
|
1177
|
+
scale: .8,
|
|
1178
|
+
...icon === void 0 ? {} : { appIcon: {
|
|
1179
|
+
path: "app-icon.png",
|
|
1180
|
+
format: "png",
|
|
1181
|
+
sha256: icon.sha256,
|
|
1182
|
+
source: {
|
|
1183
|
+
kind: "file",
|
|
1184
|
+
ref: icon.path
|
|
1185
|
+
}
|
|
1186
|
+
} }
|
|
1187
|
+
},
|
|
1188
|
+
window: entry.config.window,
|
|
1189
|
+
developerMode: entry.config.developerMode
|
|
1190
|
+
};
|
|
1191
|
+
if (icon !== void 0) {
|
|
1192
|
+
iconReference = "local";
|
|
1193
|
+
if (inlineIcon === false) {
|
|
1194
|
+
config = {
|
|
1195
|
+
...config,
|
|
1196
|
+
icons: {
|
|
1197
|
+
...config.icons,
|
|
1198
|
+
appIcon: {
|
|
1199
|
+
path: "app-icon.png",
|
|
1200
|
+
format: "png",
|
|
1201
|
+
sha256: "",
|
|
1202
|
+
source: {
|
|
1203
|
+
kind: "file",
|
|
1204
|
+
ref: icon.path
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
};
|
|
1209
|
+
iconSharedAs = "local";
|
|
1210
|
+
} else {
|
|
1211
|
+
embedded.push({
|
|
1212
|
+
flag: "app-icon",
|
|
1213
|
+
filename: "app-icon.png",
|
|
1214
|
+
bytes: icon.bytes
|
|
1215
|
+
});
|
|
1216
|
+
iconSharedAs = "embedded";
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
} else {
|
|
1220
|
+
if (entry.record.config === void 0) return {
|
|
1221
|
+
status: 500,
|
|
1222
|
+
body: {
|
|
1223
|
+
code: "invalid_config",
|
|
1224
|
+
message: "unreadable registration"
|
|
1225
|
+
}
|
|
1226
|
+
};
|
|
1227
|
+
config = entry.record.config;
|
|
1228
|
+
for (const ref of [config.icons.appIcon, config.icons.trayIcon]) {
|
|
1229
|
+
if (ref === void 0) continue;
|
|
1230
|
+
const isHttp = ref.source.kind === "http";
|
|
1231
|
+
const isApp = ref === config.icons.appIcon;
|
|
1232
|
+
if (isApp) iconReference = isHttp ? "url" : "local";
|
|
1233
|
+
if (!(inlineIcon ?? !isHttp)) {
|
|
1234
|
+
if (isApp) iconSharedAs = isHttp ? "url" : "local";
|
|
1235
|
+
continue;
|
|
1236
|
+
}
|
|
1237
|
+
const bytes = await readResourceBytes(entry.record.dir, ref);
|
|
1238
|
+
if (bytes.ok) {
|
|
1239
|
+
embedded.push({
|
|
1240
|
+
flag: isApp ? "app-icon" : "tray-icon",
|
|
1241
|
+
filename: ref.path.split("/").pop() ?? "icon.png",
|
|
1242
|
+
bytes: bytes.value
|
|
1243
|
+
});
|
|
1244
|
+
if (isApp) iconSharedAs = "embedded";
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
const envCount = Object.keys(config.command.env ?? {}).length;
|
|
1249
|
+
if (envCount > 0 && request.body.acknowledgeEnv !== true) return {
|
|
1250
|
+
status: 409,
|
|
1251
|
+
body: {
|
|
1252
|
+
code: "env_ack_required",
|
|
1253
|
+
message: "environment entries present; acknowledgement required",
|
|
1254
|
+
envCount
|
|
1255
|
+
}
|
|
1256
|
+
};
|
|
1257
|
+
const format = request.body.format === "sh" || request.body.format === "ps1" || request.body.format === "command" ? request.body.format : "command";
|
|
1258
|
+
if (format === "command") {
|
|
1259
|
+
const plan = buildExportPlan({
|
|
1260
|
+
config,
|
|
1261
|
+
embeddedResources: embedded,
|
|
1262
|
+
forceCopy: request.body.forceCopy === true
|
|
1263
|
+
});
|
|
1264
|
+
if (!plan.ok) return {
|
|
1265
|
+
status: 500,
|
|
1266
|
+
body: plan.error
|
|
1267
|
+
};
|
|
1268
|
+
if (plan.value.directCommand === null) return {
|
|
1269
|
+
status: 409,
|
|
1270
|
+
body: {
|
|
1271
|
+
code: "export_unsafe",
|
|
1272
|
+
message: plan.value.directCommandBlockedReason ?? "direct copy requires force-copy"
|
|
1273
|
+
}
|
|
1274
|
+
};
|
|
1275
|
+
return {
|
|
1276
|
+
status: 200,
|
|
1277
|
+
body: { command: formatPosixCommandLine(plan.value.directCommand.command) }
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
const script = buildScriptExport({
|
|
1281
|
+
config,
|
|
1282
|
+
embeddedResources: embedded
|
|
1283
|
+
}, format === "sh" ? "sh" : "powershell");
|
|
1284
|
+
if (!script.ok) return {
|
|
1285
|
+
status: 500,
|
|
1286
|
+
body: script.error
|
|
1287
|
+
};
|
|
1288
|
+
return {
|
|
1289
|
+
status: 200,
|
|
1290
|
+
body: {
|
|
1291
|
+
filename: script.value.filename,
|
|
1292
|
+
content: script.value.content,
|
|
1293
|
+
commandLine: script.value.commandLine,
|
|
1294
|
+
requiresEnvAcknowledgement: script.value.requiresEnvAcknowledgement,
|
|
1295
|
+
iconSharedAs,
|
|
1296
|
+
iconReference
|
|
1297
|
+
}
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
if (pathname === "/api/skill" && request.method === "GET") {
|
|
1301
|
+
const root = await resolveSkillRoot();
|
|
1302
|
+
if (root === void 0) return {
|
|
1303
|
+
status: 404,
|
|
1304
|
+
body: {
|
|
1305
|
+
code: "not_found",
|
|
1306
|
+
message: "packaged skill not found"
|
|
1307
|
+
}
|
|
1308
|
+
};
|
|
1309
|
+
const requested = request.query.get("path");
|
|
1310
|
+
const logical = containedSkillPath(requested !== null && requested.length > 0 ? requested : "SKILL.md");
|
|
1311
|
+
if (logical === void 0) return {
|
|
1312
|
+
status: 400,
|
|
1313
|
+
body: {
|
|
1314
|
+
code: "path_escape",
|
|
1315
|
+
message: "invalid skill path"
|
|
1316
|
+
}
|
|
1317
|
+
};
|
|
1318
|
+
try {
|
|
1319
|
+
return {
|
|
1320
|
+
status: 200,
|
|
1321
|
+
body: {
|
|
1322
|
+
path: logical,
|
|
1323
|
+
content: await readFile(join(root, logical), "utf8")
|
|
1324
|
+
}
|
|
1325
|
+
};
|
|
1326
|
+
} catch {
|
|
1327
|
+
return {
|
|
1328
|
+
status: 404,
|
|
1329
|
+
body: {
|
|
1330
|
+
code: "not_found",
|
|
1331
|
+
message: `skill file not found: ${logical}`
|
|
1332
|
+
}
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
if (pathname === "/api/skill/list" && request.method === "GET") {
|
|
1337
|
+
const root = await resolveSkillRoot();
|
|
1338
|
+
if (root === void 0) return {
|
|
1339
|
+
status: 404,
|
|
1340
|
+
body: {
|
|
1341
|
+
code: "not_found",
|
|
1342
|
+
message: "packaged skill not found"
|
|
1343
|
+
}
|
|
1344
|
+
};
|
|
1345
|
+
const walk = async (prefix) => {
|
|
1346
|
+
const entries = await readdir(join(root, prefix), { withFileTypes: true });
|
|
1347
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
1348
|
+
const out = [];
|
|
1349
|
+
for (const entry of entries) {
|
|
1350
|
+
const logical = prefix.length === 0 ? entry.name : `${prefix}/${entry.name}`;
|
|
1351
|
+
if (entry.isDirectory()) {
|
|
1352
|
+
out.push({
|
|
1353
|
+
path: logical,
|
|
1354
|
+
type: "directory"
|
|
1355
|
+
});
|
|
1356
|
+
out.push(...await walk(logical));
|
|
1357
|
+
} else if (entry.isFile()) out.push({
|
|
1358
|
+
path: logical,
|
|
1359
|
+
type: "file"
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
return out;
|
|
1363
|
+
};
|
|
1364
|
+
return {
|
|
1365
|
+
status: 200,
|
|
1366
|
+
body: await walk("")
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
};
|
|
1370
|
+
//#endregion
|
|
1371
|
+
//#region src/server.ts
|
|
1372
|
+
const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
|
|
1373
|
+
"127.0.0.1",
|
|
1374
|
+
"localhost",
|
|
1375
|
+
"[::1]",
|
|
1376
|
+
"::1"
|
|
1377
|
+
]);
|
|
1378
|
+
const createWizardServer = async (createSession, options = {}) => {
|
|
1379
|
+
const token = randomBytes(16).toString("hex");
|
|
1380
|
+
const clients = /* @__PURE__ */ new Set();
|
|
1381
|
+
const eventLog = [];
|
|
1382
|
+
const EVENT_LOG_LIMIT = 200;
|
|
1383
|
+
const draftPath = join(tmpdir(), `create-opentray-draft-${options.port ?? 0}.json`);
|
|
1384
|
+
const writeDraft = (event) => {
|
|
1385
|
+
if (event.type !== "form" && event.type !== "command-display" && event.type !== "command-options") return;
|
|
1386
|
+
(async () => {
|
|
1387
|
+
try {
|
|
1388
|
+
const next = {
|
|
1389
|
+
...await readFile(draftPath, "utf8").then((raw) => JSON.parse(raw)).catch(() => ({})),
|
|
1390
|
+
...event.type === "form" ? {
|
|
1391
|
+
form: event.values,
|
|
1392
|
+
defaults: event.defaults
|
|
1393
|
+
} : {},
|
|
1394
|
+
...event.type === "command-display" ? { command: event.command } : {},
|
|
1395
|
+
...event.type === "command-options" ? { commandOptions: event.options } : {},
|
|
1396
|
+
savedAt: Date.now()
|
|
1397
|
+
};
|
|
1398
|
+
await writeFile(draftPath, JSON.stringify(next), "utf8");
|
|
1399
|
+
} catch {}
|
|
1400
|
+
})();
|
|
1401
|
+
};
|
|
1402
|
+
const emit = (event) => {
|
|
1403
|
+
if (event.type !== "log") {
|
|
1404
|
+
eventLog.push(event);
|
|
1405
|
+
if (eventLog.length > EVENT_LOG_LIMIT) eventLog.splice(0, eventLog.length - EVENT_LOG_LIMIT);
|
|
1406
|
+
}
|
|
1407
|
+
writeDraft(event);
|
|
1408
|
+
const frame = `data: ${JSON.stringify(event)}\n\n`;
|
|
1409
|
+
for (const client of clients) client.write(frame);
|
|
1410
|
+
};
|
|
1411
|
+
const session = createSession(emit);
|
|
1412
|
+
const indexHtml = await readWebUiIndex();
|
|
1413
|
+
const server = createServer((request, response) => {
|
|
1414
|
+
handle(request, response).catch((error) => {
|
|
1415
|
+
respond(response, 500, "application/json", `${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n`);
|
|
1416
|
+
});
|
|
1417
|
+
});
|
|
1418
|
+
const handle = async (request, response) => {
|
|
1419
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
1420
|
+
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
1421
|
+
if (!isAuthorized(request, url, token)) {
|
|
1422
|
+
respond(response, 401, "text/plain", "invalid wizard token\n");
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
if (indexHtml === void 0) {
|
|
1426
|
+
respond(response, 500, "text/plain", "wizard page is missing from this installation\n");
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
respond(response, 200, "text/html; charset=utf-8", indexHtml);
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
if (url.pathname === "/api/events") {
|
|
1433
|
+
if (!isAuthorized(request, url, token)) {
|
|
1434
|
+
respond(response, 401, "text/plain", "unauthorized\n");
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
response.writeHead(200, {
|
|
1438
|
+
"content-type": "text/event-stream",
|
|
1439
|
+
"cache-control": "no-cache",
|
|
1440
|
+
connection: "keep-alive"
|
|
1441
|
+
});
|
|
1442
|
+
response.write(": connected\n\n");
|
|
1443
|
+
response.write(`data: ${JSON.stringify(session.snapshot())}\n\n`);
|
|
1444
|
+
for (const event of eventLog) response.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
1445
|
+
clients.add(response);
|
|
1446
|
+
request.once("close", () => {
|
|
1447
|
+
clients.delete(response);
|
|
1448
|
+
});
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
if (url.pathname.startsWith("/assets/")) {
|
|
1452
|
+
await handleAssetFile(url.pathname, response);
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
if (url.pathname === "/logo.png" || FAVICON_PATHS.has(url.pathname)) {
|
|
1456
|
+
await handleAssetFile(url.pathname, response);
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
if (url.pathname === "/ghostty-vt.wasm") {
|
|
1460
|
+
await handleAssetFile("/ghostty-vt.wasm", response);
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
if (url.pathname.startsWith("/vendor/")) {
|
|
1464
|
+
await handleVendorAsset(url.pathname, response);
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
const composedMatch = /^\/api\/icon-composed\/([a-f0-9]+)$/.exec(url.pathname);
|
|
1468
|
+
if (composedMatch !== null) {
|
|
1469
|
+
if (!isAuthorized(request, url, token)) {
|
|
1470
|
+
respond(response, 401, "text/plain", "unauthorized\n");
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
const key = composedMatch[1];
|
|
1474
|
+
const composed = session.iconComposition(key);
|
|
1475
|
+
if (composed === void 0) {
|
|
1476
|
+
respond(response, 404, "text/plain", "not found\n");
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
try {
|
|
1480
|
+
const bytes = await readFile(composed.compositePath);
|
|
1481
|
+
response.writeHead(200, {
|
|
1482
|
+
"content-type": "image/png",
|
|
1483
|
+
"cache-control": "no-store",
|
|
1484
|
+
"content-length": bytes.byteLength
|
|
1485
|
+
});
|
|
1486
|
+
response.end(bytes);
|
|
1487
|
+
return;
|
|
1488
|
+
} catch {
|
|
1489
|
+
respond(response, 404, "text/plain", "not found\n");
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
const iconDataMatch = /^\/api\/icon-data\/(\d+)\/(\d+)$/.exec(url.pathname);
|
|
1494
|
+
if (iconDataMatch !== null) {
|
|
1495
|
+
if (!isAuthorized(request, url, token)) {
|
|
1496
|
+
respond(response, 401, "text/plain", "unauthorized\n");
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
const port = Number.parseInt(iconDataMatch[1], 10);
|
|
1500
|
+
const index = Number.parseInt(iconDataMatch[2], 10);
|
|
1501
|
+
const candidate = session.iconCandidate(port, index);
|
|
1502
|
+
if (candidate === void 0) {
|
|
1503
|
+
respond(response, 404, "text/plain", "not found\n");
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
const bytes = await readFile(candidate.path).catch(() => void 0);
|
|
1507
|
+
if (bytes === void 0) {
|
|
1508
|
+
respond(response, 404, "text/plain", "not found\n");
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
response.writeHead(200, {
|
|
1512
|
+
"content-type": ICON_CONTENT_TYPES[candidate.format] ?? "application/octet-stream",
|
|
1513
|
+
"content-length": bytes.length,
|
|
1514
|
+
"cache-control": "no-store"
|
|
1515
|
+
});
|
|
1516
|
+
response.end(bytes);
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
if (url.pathname.startsWith("/api/")) {
|
|
1520
|
+
if (!isAuthorized(request, url, token)) {
|
|
1521
|
+
respond(response, 401, "application/json", "{\"error\":\"unauthorized\"}\n");
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
if (!isLoopbackHost(request)) {
|
|
1525
|
+
respond(response, 403, "application/json", "{\"error\":\"forbidden host\"}\n");
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
if (url.pathname === "/api/apps" || url.pathname.startsWith("/api/apps/") || url.pathname === "/api/skill" || url.pathname === "/api/skill/list") {
|
|
1529
|
+
let body = {};
|
|
1530
|
+
if (request.method === "POST") body = await readJsonBody(request);
|
|
1531
|
+
const workbench = await handleWorkbenchApi({
|
|
1532
|
+
method: request.method ?? "GET",
|
|
1533
|
+
pathname: url.pathname,
|
|
1534
|
+
query: url.searchParams,
|
|
1535
|
+
body
|
|
1536
|
+
});
|
|
1537
|
+
if (workbench !== void 0) {
|
|
1538
|
+
respond(response, workbench.status, "application/json", `${JSON.stringify(workbench.body)}\n`);
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
await handleApi(url.pathname, request, response, session);
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
respond(response, 404, "text/plain", "not found\n");
|
|
1546
|
+
};
|
|
1547
|
+
await listen(server, options.port);
|
|
1548
|
+
const address = server.address();
|
|
1549
|
+
if (address === null || typeof address === "string") {
|
|
1550
|
+
server.close();
|
|
1551
|
+
throw new Error("wizard server did not bind a loopback port");
|
|
1552
|
+
}
|
|
1553
|
+
return {
|
|
1554
|
+
url: `http://127.0.0.1:${address.port}/?token=${token}`,
|
|
1555
|
+
port: address.port,
|
|
1556
|
+
token,
|
|
1557
|
+
session,
|
|
1558
|
+
close: () => new Promise((resolve) => {
|
|
1559
|
+
for (const client of clients) client.end();
|
|
1560
|
+
clients.clear();
|
|
1561
|
+
server.close(() => resolve());
|
|
1562
|
+
})
|
|
1563
|
+
};
|
|
1564
|
+
};
|
|
1565
|
+
/** Whitelisted static terminal-renderer assets; exact names only, no traversal. */
|
|
1566
|
+
const VENDOR_ASSETS = {
|
|
1567
|
+
"/vendor/ghostty-web.js": {
|
|
1568
|
+
contentType: "text/javascript; charset=utf-8",
|
|
1569
|
+
candidates: () => [join(moduleDir(), "webui", "vendor", "ghostty-web.js"), ghosttyPackageFile("dist/ghostty-web.js")]
|
|
1570
|
+
},
|
|
1571
|
+
"/vendor/ghostty-vt.wasm": {
|
|
1572
|
+
contentType: "application/wasm",
|
|
1573
|
+
candidates: () => [
|
|
1574
|
+
join(moduleDir(), "webui", "vendor", "ghostty-vt.wasm"),
|
|
1575
|
+
ghosttyPackageFile("ghostty-vt.wasm"),
|
|
1576
|
+
ghosttyPackageFile("dist/ghostty-vt.wasm")
|
|
1577
|
+
]
|
|
1578
|
+
}
|
|
1579
|
+
};
|
|
1580
|
+
const moduleDir = () => dirname(fileURLToPath(import.meta.url));
|
|
1581
|
+
/**
|
|
1582
|
+
* Resolve a file inside the installed ghostty-web package. Deep paths are
|
|
1583
|
+
* blocked by the package `exports` map, so resolve the exported main module
|
|
1584
|
+
* and walk from the package root instead.
|
|
1585
|
+
*/
|
|
1586
|
+
const ghosttyPackageFile = (relative) => {
|
|
1587
|
+
try {
|
|
1588
|
+
return join(dirname(dirname(createRequire(import.meta.url).resolve("ghostty-web"))), relative);
|
|
1589
|
+
} catch {
|
|
1590
|
+
return "";
|
|
1591
|
+
}
|
|
1592
|
+
};
|
|
1593
|
+
const ICON_CONTENT_TYPES = {
|
|
1594
|
+
png: "image/png",
|
|
1595
|
+
svg: "image/svg+xml",
|
|
1596
|
+
jpeg: "image/jpeg",
|
|
1597
|
+
webp: "image/webp",
|
|
1598
|
+
gif: "image/gif",
|
|
1599
|
+
ico: "image/x-icon"
|
|
1600
|
+
};
|
|
1601
|
+
const ASSET_CONTENT_TYPES = {
|
|
1602
|
+
".js": "text/javascript; charset=utf-8",
|
|
1603
|
+
".css": "text/css; charset=utf-8",
|
|
1604
|
+
".wasm": "application/wasm",
|
|
1605
|
+
".html": "text/html; charset=utf-8",
|
|
1606
|
+
".svg": "image/svg+xml",
|
|
1607
|
+
".png": "image/png",
|
|
1608
|
+
".json": "application/json"
|
|
1609
|
+
};
|
|
1610
|
+
/**
|
|
1611
|
+
* Serve one file from the built webui directory (dist/webui). Only simple
|
|
1612
|
+
* relative names are accepted: resolve first, then require the resolved path
|
|
1613
|
+
* to stay inside the webui root, so traversal cannot escape.
|
|
1614
|
+
*/
|
|
1615
|
+
const handleAssetFile = async (pathname, response) => {
|
|
1616
|
+
const relative = pathname.replace(/^\/+/, "");
|
|
1617
|
+
if (relative.length === 0 || relative.includes("\0")) {
|
|
1618
|
+
respond(response, 404, "text/plain", "not found\n");
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
const roots = [join(moduleDir(), "webui"), join(moduleDir(), "..", "..", "create-webui", "dist")];
|
|
1622
|
+
for (const root of roots) {
|
|
1623
|
+
const resolved = resolve(root, relative);
|
|
1624
|
+
if (!(resolved === root || resolved.startsWith(`${root}${sep}`))) continue;
|
|
1625
|
+
const bytes = await readFile(resolved).catch(() => void 0);
|
|
1626
|
+
if (bytes === void 0) continue;
|
|
1627
|
+
const extension = extname(resolved).toLowerCase();
|
|
1628
|
+
response.writeHead(200, {
|
|
1629
|
+
"content-type": ASSET_CONTENT_TYPES[extension] ?? "application/octet-stream",
|
|
1630
|
+
"content-length": bytes.length,
|
|
1631
|
+
"cache-control": "no-store"
|
|
1632
|
+
});
|
|
1633
|
+
response.end(bytes);
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
respond(response, 404, "text/plain", "not found\n");
|
|
1637
|
+
};
|
|
1638
|
+
const handleVendorAsset = async (pathname, response) => {
|
|
1639
|
+
const asset = VENDOR_ASSETS[pathname];
|
|
1640
|
+
if (asset === void 0) {
|
|
1641
|
+
respond(response, 404, "text/plain", "not found\n");
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
for (const candidate of asset.candidates()) {
|
|
1645
|
+
if (candidate.length === 0) continue;
|
|
1646
|
+
const bytes = await readFile(candidate).catch(() => void 0);
|
|
1647
|
+
if (bytes !== void 0) {
|
|
1648
|
+
response.writeHead(200, {
|
|
1649
|
+
"content-type": asset.contentType,
|
|
1650
|
+
"content-length": bytes.length,
|
|
1651
|
+
"cache-control": "no-store"
|
|
1652
|
+
});
|
|
1653
|
+
response.end(bytes);
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
respond(response, 404, "text/plain", "terminal renderer asset is missing\n");
|
|
1658
|
+
};
|
|
1659
|
+
/** Containment: icon routes must only read sources the wizard itself
|
|
1660
|
+
* produced (its temp dirs / saved uploads), never arbitrary paths. */
|
|
1661
|
+
const isWizardOwnedIconPath = (session, target) => {
|
|
1662
|
+
for (const root of session.iconSourceRoots()) if (root.length > 0 && target.startsWith(root + sep)) return true;
|
|
1663
|
+
return false;
|
|
1664
|
+
};
|
|
1665
|
+
const handleApi = async (pathname, request, response, session, preReadBody) => {
|
|
1666
|
+
if (request.method !== "POST") {
|
|
1667
|
+
respond(response, 405, "application/json", "{\"error\":\"method not allowed\"}\n");
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
if (pathname === "/api/icon-upload") {
|
|
1671
|
+
const chunks = [];
|
|
1672
|
+
for await (const chunk of request) {
|
|
1673
|
+
const piece = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
1674
|
+
chunks.push(piece);
|
|
1675
|
+
}
|
|
1676
|
+
const bytes = Buffer.concat(chunks);
|
|
1677
|
+
if (bytes.length < 64) {
|
|
1678
|
+
respond(response, 400, "application/json", "{\"error\":\"image bytes are required\"}\n");
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
const path = await session.saveIconUpload(bytes);
|
|
1682
|
+
respond(response, 200, "application/json", JSON.stringify({ path }) + "\n");
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
const body = preReadBody ?? await readJsonBody(request);
|
|
1686
|
+
switch (pathname) {
|
|
1687
|
+
case "/api/command": {
|
|
1688
|
+
if (Array.isArray(body.argv)) {
|
|
1689
|
+
const argv = body.argv.filter((element) => typeof element === "string");
|
|
1690
|
+
if (argv.length === 0 || (argv[0] ?? "").trim().length === 0) {
|
|
1691
|
+
respond(response, 400, "application/json", "{\"error\":\"argv requires the program element\"}\n");
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
await session.submitCommand(argv);
|
|
1695
|
+
respond(response, 200, "application/json", "{\"ok\":true}\n");
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
const command = typeof body.command === "string" ? body.command : "";
|
|
1699
|
+
if (command.trim().length === 0) {
|
|
1700
|
+
respond(response, 400, "application/json", "{\"error\":\"command is required\"}\n");
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
await session.submitCommand(command);
|
|
1704
|
+
respond(response, 200, "application/json", "{\"ok\":true}\n");
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
case "/api/command-options": {
|
|
1708
|
+
const patch = {};
|
|
1709
|
+
if (typeof body.cwd === "string") patch.cwd = body.cwd;
|
|
1710
|
+
if (body.argsMode === "string" || body.argsMode === "array") patch.argsMode = body.argsMode;
|
|
1711
|
+
if (Array.isArray(body.env)) {
|
|
1712
|
+
const entries = [];
|
|
1713
|
+
for (const entry of body.env) if (typeof entry === "object" && entry !== null && typeof entry.key === "string" && typeof entry.value === "string") entries.push({
|
|
1714
|
+
key: entry.key,
|
|
1715
|
+
value: entry.value
|
|
1716
|
+
});
|
|
1717
|
+
patch.env = entries;
|
|
1718
|
+
}
|
|
1719
|
+
session.updateCommandOptions(patch);
|
|
1720
|
+
respond(response, 200, "application/json", "{\"ok\":true}\n");
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
case "/api/prime": {
|
|
1724
|
+
const command = typeof body.command === "string" ? body.command : "";
|
|
1725
|
+
if (command.trim().length === 0) {
|
|
1726
|
+
respond(response, 400, "application/json", "{\"error\":\"command is required\"}\n");
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
session.prime(command);
|
|
1730
|
+
respond(response, 200, "application/json", "{\"ok\":true}\n");
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
case "/api/select-service": {
|
|
1734
|
+
const port = Number(body.port);
|
|
1735
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
1736
|
+
respond(response, 400, "application/json", "{\"error\":\"port is required\"}\n");
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
session.selectService(port);
|
|
1740
|
+
respond(response, 200, "application/json", "{\"ok\":true}\n");
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
case "/api/form": {
|
|
1744
|
+
const patch = {};
|
|
1745
|
+
for (const key of [
|
|
1746
|
+
"appId",
|
|
1747
|
+
"appName",
|
|
1748
|
+
"iconPath",
|
|
1749
|
+
"trayIconPath"
|
|
1750
|
+
]) {
|
|
1751
|
+
const value = body[key];
|
|
1752
|
+
if (typeof value === "string") patch[key] = value;
|
|
1753
|
+
}
|
|
1754
|
+
if (body.pm === "npm" || body.pm === "pnpm" || body.pm === "bun") patch.pm = body.pm;
|
|
1755
|
+
if (body.iconBackground === "black" || body.iconBackground === "white" || body.iconBackground === "transparent") patch.iconBackground = body.iconBackground;
|
|
1756
|
+
if (typeof body.iconScale === "number" && body.iconScale >= .5 && body.iconScale <= .95) patch.iconScale = body.iconScale;
|
|
1757
|
+
for (const key of [
|
|
1758
|
+
"showStartupTerminal",
|
|
1759
|
+
"showAddressBar",
|
|
1760
|
+
"imageSmoothingEnabled",
|
|
1761
|
+
"developerMode",
|
|
1762
|
+
"force"
|
|
1763
|
+
]) {
|
|
1764
|
+
const value = body[key];
|
|
1765
|
+
if (typeof value === "boolean") patch[key] = value;
|
|
1766
|
+
}
|
|
1767
|
+
session.updateForm(patch);
|
|
1768
|
+
respond(response, 200, "application/json", "{\"ok\":true}\n");
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
case "/api/cancel":
|
|
1772
|
+
try {
|
|
1773
|
+
session.cancel();
|
|
1774
|
+
respond(response, 200, "application/json", "{\"ok\":true}\n");
|
|
1775
|
+
} catch (error) {
|
|
1776
|
+
respond(response, 409, "application/json", `${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n`);
|
|
1777
|
+
}
|
|
1778
|
+
return;
|
|
1779
|
+
case "/api/confirm":
|
|
1780
|
+
try {
|
|
1781
|
+
session.confirm();
|
|
1782
|
+
respond(response, 200, "application/json", "{\"ok\":true}\n");
|
|
1783
|
+
} catch (error) {
|
|
1784
|
+
respond(response, 409, "application/json", `${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n`);
|
|
1785
|
+
}
|
|
1786
|
+
return;
|
|
1787
|
+
case "/api/export": {
|
|
1788
|
+
const format = body.format === "sh" || body.format === "ps1" || body.format === "command" ? body.format : void 0;
|
|
1789
|
+
if (format === void 0) {
|
|
1790
|
+
respond(response, 400, "application/json", "{\"error\":\"format must be command, sh, or ps1\"}\n");
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
const result = await session.exportFrozen({
|
|
1794
|
+
format,
|
|
1795
|
+
acknowledgeEnv: body.acknowledgeEnv === true,
|
|
1796
|
+
forceCopy: body.forceCopy === true,
|
|
1797
|
+
inlineIcon: body.inlineIcon === true
|
|
1798
|
+
});
|
|
1799
|
+
if (!result.ok) {
|
|
1800
|
+
const status = result.code === "env_ack_required" || result.code === "export_unsafe" ? 409 : 400;
|
|
1801
|
+
respond(response, status, "application/json", `${JSON.stringify({
|
|
1802
|
+
code: result.code,
|
|
1803
|
+
message: result.message
|
|
1804
|
+
})}\n`);
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
respond(response, 200, "application/json", `${JSON.stringify(result)}\n`);
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
case "/api/create":
|
|
1811
|
+
try {
|
|
1812
|
+
await session.create();
|
|
1813
|
+
respond(response, 200, "application/json", "{\"ok\":true}\n");
|
|
1814
|
+
} catch (error) {
|
|
1815
|
+
respond(response, 409, "application/json", `${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n`);
|
|
1816
|
+
}
|
|
1817
|
+
return;
|
|
1818
|
+
case "/api/terminal-input": {
|
|
1819
|
+
const data = typeof body.data === "string" ? body.data : void 0;
|
|
1820
|
+
if (data === void 0) {
|
|
1821
|
+
respond(response, 400, "application/json", "{\"error\":\"data is required\"}\n");
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
session.terminalInput(data);
|
|
1825
|
+
respond(response, 200, "application/json", "{\"ok\":true}\n");
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
case "/api/icon-analyze": {
|
|
1829
|
+
const path = typeof body.path === "string" ? body.path.trim() : "";
|
|
1830
|
+
if (path.length === 0) {
|
|
1831
|
+
respond(response, 400, "application/json", "{\"error\":\"path is required\"}\n");
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
if (!isWizardOwnedIconPath(session, path)) {
|
|
1835
|
+
respond(response, 403, "application/json", "{\"error\":\"path is not a wizard icon source\"}\n");
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
try {
|
|
1839
|
+
const analysis = await session.analyzeIconForeground(path);
|
|
1840
|
+
respond(response, 200, "application/json", JSON.stringify(analysis) + "\n");
|
|
1841
|
+
} catch (error) {
|
|
1842
|
+
respond(response, 500, "application/json", JSON.stringify({ error: String(error) }) + "\n");
|
|
1843
|
+
}
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
case "/api/icon-compose": {
|
|
1847
|
+
const foregroundPath = typeof body.foregroundPath === "string" ? body.foregroundPath.trim() : "";
|
|
1848
|
+
if (foregroundPath.length === 0) {
|
|
1849
|
+
respond(response, 400, "application/json", "{\"error\":\"foregroundPath is required\"}\n");
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1852
|
+
if (!isWizardOwnedIconPath(session, foregroundPath)) {
|
|
1853
|
+
respond(response, 403, "application/json", "{\"error\":\"foregroundPath is not a wizard icon source\"}\n");
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
const background = body.background === "black" || body.background === "white" || body.background === "transparent" ? body.background : void 0;
|
|
1857
|
+
const scale = typeof body.scale === "number" && body.scale >= .5 && body.scale <= .95 ? body.scale : void 0;
|
|
1858
|
+
try {
|
|
1859
|
+
const composed = await session.composeIcon({
|
|
1860
|
+
foregroundPath,
|
|
1861
|
+
...background === void 0 ? {} : { background },
|
|
1862
|
+
...scale === void 0 ? {} : { scale }
|
|
1863
|
+
});
|
|
1864
|
+
respond(response, 200, "application/json", JSON.stringify(composed) + "\n");
|
|
1865
|
+
} catch (error) {
|
|
1866
|
+
respond(response, 500, "application/json", JSON.stringify({ error: String(error) }) + "\n");
|
|
1867
|
+
}
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
case "/api/tray-icon-select": {
|
|
1871
|
+
const port = typeof body.port === "number" ? body.port : NaN;
|
|
1872
|
+
const index = typeof body.index === "number" ? body.index : NaN;
|
|
1873
|
+
if (!Number.isInteger(port) || !Number.isInteger(index)) {
|
|
1874
|
+
respond(response, 400, "application/json", "{\"error\":\"port and index are required\"}\n");
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
const ok = session.selectTrayIconCandidate(port, index);
|
|
1878
|
+
respond(response, 200, "application/json", JSON.stringify({ ok }) + "\n");
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1881
|
+
case "/api/icon-select": {
|
|
1882
|
+
const port = typeof body.port === "number" ? body.port : NaN;
|
|
1883
|
+
const index = typeof body.index === "number" ? body.index : NaN;
|
|
1884
|
+
if (!Number.isInteger(port) || !Number.isInteger(index)) {
|
|
1885
|
+
respond(response, 400, "application/json", "{\"error\":\"port and index are required\"}\n");
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
const ok = session.selectIconCandidate(port, index);
|
|
1889
|
+
respond(response, 200, "application/json", JSON.stringify({ ok }) + "\n");
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
case "/api/icon-source": {
|
|
1893
|
+
const path = typeof body.path === "string" ? body.path.trim() : "";
|
|
1894
|
+
if (path.length === 0) {
|
|
1895
|
+
respond(response, 400, "application/json", "{\"error\":\"path is required\"}\n");
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
session.updateForm({ iconPath: path });
|
|
1899
|
+
respond(response, 200, "application/json", "{\"ok\":true}\n");
|
|
1900
|
+
return;
|
|
1901
|
+
}
|
|
1902
|
+
case "/api/terminal-resize": {
|
|
1903
|
+
const cols = Number(body.cols);
|
|
1904
|
+
const rows = Number(body.rows);
|
|
1905
|
+
if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols <= 0 || rows <= 0) {
|
|
1906
|
+
respond(response, 400, "application/json", "{\"error\":\"cols and rows are required\"}\n");
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1909
|
+
session.terminalResize({
|
|
1910
|
+
cols,
|
|
1911
|
+
rows
|
|
1912
|
+
});
|
|
1913
|
+
respond(response, 200, "application/json", "{\"ok\":true}\n");
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
case "/api/stop":
|
|
1917
|
+
await session.stop();
|
|
1918
|
+
respond(response, 200, "application/json", "{\"ok\":true}\n");
|
|
1919
|
+
return;
|
|
1920
|
+
case "/api/open-app": {
|
|
1921
|
+
const result = session.result;
|
|
1922
|
+
if (result === void 0) {
|
|
1923
|
+
respond(response, 409, "application/json", "{\"error\":\"no materialized app\"}\n");
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
const opened = await openMaterializedApp({
|
|
1927
|
+
projectDir: result.projectDir,
|
|
1928
|
+
bundlePath: void 0
|
|
1929
|
+
});
|
|
1930
|
+
respond(response, opened.ok ? 200 : 500, "application/json", `${JSON.stringify({
|
|
1931
|
+
ok: opened.ok,
|
|
1932
|
+
detail: opened.detail
|
|
1933
|
+
})}\n`);
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
default: respond(response, 404, "application/json", "{\"error\":\"unknown endpoint\"}\n");
|
|
1937
|
+
}
|
|
1938
|
+
};
|
|
1939
|
+
const listen = (server, port) => new Promise((resolve, reject) => {
|
|
1940
|
+
server.once("error", reject);
|
|
1941
|
+
server.listen(port ?? 0, "127.0.0.1", () => {
|
|
1942
|
+
server.off("error", reject);
|
|
1943
|
+
resolve();
|
|
1944
|
+
});
|
|
1945
|
+
});
|
|
1946
|
+
const isAuthorized = (request, url, token) => {
|
|
1947
|
+
if (request.headers.authorization === `Bearer ${token}`) return true;
|
|
1948
|
+
return url.searchParams.get("token") === token;
|
|
1949
|
+
};
|
|
1950
|
+
const isLoopbackHost = (request) => {
|
|
1951
|
+
const hostname = (request.headers.host ?? "").replace(/:\d+$/u, "").toLowerCase();
|
|
1952
|
+
return LOOPBACK_HOSTS.has(hostname);
|
|
1953
|
+
};
|
|
1954
|
+
const readJsonBody = async (request) => {
|
|
1955
|
+
const chunks = [];
|
|
1956
|
+
let size = 0;
|
|
1957
|
+
for await (const chunk of request) {
|
|
1958
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
1959
|
+
size += buffer.length;
|
|
1960
|
+
if (size > 1 << 20) throw new Error("request body too large");
|
|
1961
|
+
chunks.push(buffer);
|
|
1962
|
+
}
|
|
1963
|
+
if (chunks.length === 0) return {};
|
|
1964
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
1965
|
+
if (typeof parsed !== "object" || parsed === null) return {};
|
|
1966
|
+
return parsed;
|
|
1967
|
+
};
|
|
1968
|
+
const respond = (response, status, contentType, body) => {
|
|
1969
|
+
response.writeHead(status, {
|
|
1970
|
+
"content-type": contentType,
|
|
1971
|
+
"content-length": Buffer.byteLength(body),
|
|
1972
|
+
"cache-control": "no-store"
|
|
1973
|
+
});
|
|
1974
|
+
response.end(body);
|
|
1975
|
+
};
|
|
1976
|
+
/** Built package: dist/webui/index.html; source checkout falls back to the create-webui vite output. */
|
|
1977
|
+
const webUiIndexPaths = () => [join(moduleDir(), "webui", "index.html"), join(moduleDir(), "..", "..", "create-webui", "dist", "index.html")];
|
|
1978
|
+
const readWebUiIndex = async () => {
|
|
1979
|
+
for (const candidate of webUiIndexPaths()) {
|
|
1980
|
+
const html = await readFile(candidate, "utf8").catch(() => void 0);
|
|
1981
|
+
if (html !== void 0) return html;
|
|
1982
|
+
}
|
|
1983
|
+
};
|
|
1984
|
+
/** Favicon variants served from the webui root (see create-webui/public). */
|
|
1985
|
+
const FAVICON_PATHS = /* @__PURE__ */ new Set([
|
|
1986
|
+
"/favicon-64.png",
|
|
1987
|
+
"/favicon-32.png",
|
|
1988
|
+
"/favicon-16.png"
|
|
1989
|
+
]);
|
|
1990
|
+
//#endregion
|
|
1991
|
+
//#region src/bin.ts
|
|
1992
|
+
const parseWizardCli = (argv) => {
|
|
1993
|
+
const options = {
|
|
1994
|
+
open: true,
|
|
1995
|
+
skipInstall: false,
|
|
1996
|
+
force: false
|
|
1997
|
+
};
|
|
1998
|
+
const positional = [];
|
|
1999
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
2000
|
+
const arg = argv[index];
|
|
2001
|
+
if (arg === void 0) continue;
|
|
2002
|
+
if (arg === "--no-open") options.open = false;
|
|
2003
|
+
else if (arg === "--skip-install") options.skipInstall = true;
|
|
2004
|
+
else if (arg === "--force") options.force = true;
|
|
2005
|
+
else if (arg === "--port") {
|
|
2006
|
+
const value = argv[index + 1];
|
|
2007
|
+
const port = Number.parseInt(value ?? "", 10);
|
|
2008
|
+
if (Number.isInteger(port) && port > 0 && port < 65536) {
|
|
2009
|
+
options.port = port;
|
|
2010
|
+
index += 1;
|
|
2011
|
+
}
|
|
2012
|
+
} else if (arg === "--pm") {
|
|
2013
|
+
const value = argv[index + 1];
|
|
2014
|
+
if (value === "npm" || value === "pnpm" || value === "bun") {
|
|
2015
|
+
options.pm = value;
|
|
2016
|
+
index += 1;
|
|
2017
|
+
}
|
|
2018
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
2019
|
+
options.open = false;
|
|
2020
|
+
positional.length = 0;
|
|
2021
|
+
break;
|
|
2022
|
+
} else if (!arg.startsWith("--")) positional.push(arg);
|
|
2023
|
+
}
|
|
2024
|
+
const [target] = positional;
|
|
2025
|
+
return {
|
|
2026
|
+
open: options.open,
|
|
2027
|
+
port: options.port,
|
|
2028
|
+
pm: options.pm,
|
|
2029
|
+
skipInstall: options.skipInstall,
|
|
2030
|
+
force: options.force,
|
|
2031
|
+
targetDir: target
|
|
2032
|
+
};
|
|
2033
|
+
};
|
|
2034
|
+
const readDependencyRange = async () => {
|
|
2035
|
+
const packageJsonUrl = new URL("../package.json", import.meta.url);
|
|
2036
|
+
try {
|
|
2037
|
+
const parsed = JSON.parse(await readFile(packageJsonUrl, "utf8"));
|
|
2038
|
+
if (typeof parsed.version === "string" && /^\d/u.test(parsed.version)) return `^${parsed.version}`;
|
|
2039
|
+
} catch {}
|
|
2040
|
+
return "latest";
|
|
2041
|
+
};
|
|
2042
|
+
const openBrowser = async (url) => {
|
|
2043
|
+
const platform = process.platform;
|
|
2044
|
+
spawn(platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open", platform === "win32" ? [
|
|
2045
|
+
"/c",
|
|
2046
|
+
"start",
|
|
2047
|
+
"",
|
|
2048
|
+
url
|
|
2049
|
+
] : platform === "darwin" ? [url] : [url], {
|
|
2050
|
+
stdio: "ignore",
|
|
2051
|
+
detached: true,
|
|
2052
|
+
windowsHide: true
|
|
2053
|
+
}).unref();
|
|
2054
|
+
};
|
|
2055
|
+
const runWebAdapter = async (options) => {
|
|
2056
|
+
ensureLoopbackNoProxy();
|
|
2057
|
+
const invocationDir = process.env.INIT_CWD || process.cwd();
|
|
2058
|
+
/** Read a persisted wizard draft for a port (best-effort). */
|
|
2059
|
+
const readDraft = async (port) => {
|
|
2060
|
+
try {
|
|
2061
|
+
const raw = await readFile(join(tmpdir(), `create-opentray-draft-${port ?? 0}.json`), "utf8");
|
|
2062
|
+
const parsed = JSON.parse(raw);
|
|
2063
|
+
if (parsed.form === void 0) return void 0;
|
|
2064
|
+
return {
|
|
2065
|
+
form: parsed.form,
|
|
2066
|
+
command: parsed.command
|
|
2067
|
+
};
|
|
2068
|
+
} catch {
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
};
|
|
2072
|
+
const dependencyRange = await readDependencyRange();
|
|
2073
|
+
const draftSeed = await readDraft(options.port);
|
|
2074
|
+
const server = await createWizardServer((emit) => {
|
|
2075
|
+
const session = createWizardSession({
|
|
2076
|
+
cwd: invocationDir,
|
|
2077
|
+
skipInstall: false,
|
|
2078
|
+
force: false,
|
|
2079
|
+
dependencyRange,
|
|
2080
|
+
emit
|
|
2081
|
+
});
|
|
2082
|
+
if (draftSeed !== void 0) {
|
|
2083
|
+
session.updateForm(draftSeed.form);
|
|
2084
|
+
if (draftSeed.command !== void 0 && draftSeed.command.length > 0) session.prime(draftSeed.command);
|
|
2085
|
+
}
|
|
2086
|
+
return session;
|
|
2087
|
+
}, options.port === void 0 ? {} : { port: options.port });
|
|
2088
|
+
console.log(`create-opentray wizard: ${server.url}`);
|
|
2089
|
+
console.log(`working directory: ${invocationDir}`);
|
|
2090
|
+
if (options.open) await openBrowser(server.url);
|
|
2091
|
+
const shutdown = () => {
|
|
2092
|
+
(async () => {
|
|
2093
|
+
await server.session.stop();
|
|
2094
|
+
await server.close();
|
|
2095
|
+
process.exit(0);
|
|
2096
|
+
})();
|
|
2097
|
+
};
|
|
2098
|
+
process.once("SIGINT", shutdown);
|
|
2099
|
+
process.once("SIGTERM", shutdown);
|
|
2100
|
+
await new Promise(() => {});
|
|
2101
|
+
return 0;
|
|
2102
|
+
};
|
|
2103
|
+
const main = async (argv) => {
|
|
2104
|
+
const legacy = parseWizardCli(argv.filter((arg) => !arg.startsWith("--") || [
|
|
2105
|
+
"--no-open",
|
|
2106
|
+
"--port",
|
|
2107
|
+
"--pm",
|
|
2108
|
+
"--skip-install",
|
|
2109
|
+
"--force",
|
|
2110
|
+
"--help",
|
|
2111
|
+
"-h"
|
|
2112
|
+
].includes(arg)));
|
|
2113
|
+
return dispatchCli(argv, {
|
|
2114
|
+
streams: consoleStreams,
|
|
2115
|
+
runWeb: async (webOptions) => runWebAdapter({
|
|
2116
|
+
...webOptions,
|
|
2117
|
+
...webOptions.port === void 0 && legacy.port !== void 0 ? { port: legacy.port } : {},
|
|
2118
|
+
open: webOptions.open && legacy.open
|
|
2119
|
+
})
|
|
2120
|
+
});
|
|
2121
|
+
};
|
|
2122
|
+
const isMainModule = () => {
|
|
2123
|
+
const entryPath = process.argv[1];
|
|
2124
|
+
if (entryPath === void 0) return false;
|
|
2125
|
+
const sameFile = (a, b) => {
|
|
2126
|
+
try {
|
|
2127
|
+
return realpathSync(resolve(a)) === realpathSync(resolve(b));
|
|
2128
|
+
} catch {
|
|
2129
|
+
return resolve(a) === resolve(b);
|
|
2130
|
+
}
|
|
2131
|
+
};
|
|
2132
|
+
const modulePath = fileURLToPath(import.meta.url);
|
|
2133
|
+
if (sameFile(entryPath, modulePath)) return true;
|
|
2134
|
+
return sameFile(entryPath, modulePath.replace(/bin-[^/]*\.mjs$/u, "bin.mjs"));
|
|
2135
|
+
};
|
|
2136
|
+
if (isMainModule()) main(process.argv.slice(2)).then((code) => {
|
|
2137
|
+
process.exitCode = code;
|
|
2138
|
+
});
|
|
2139
|
+
//#endregion
|
|
2140
|
+
export { isAuthorized as a, createWizardServer as i, openBrowser as n, isLoopbackHost as o, parseWizardCli as r, createWizardSession as s, main as t };
|
|
2141
|
+
|
|
2142
|
+
//# sourceMappingURL=bin-sZB5nWq0.mjs.map
|