@quickgui/cli 0.0.1

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/src/build.ts ADDED
@@ -0,0 +1,773 @@
1
+ import {
2
+ chmodSync,
3
+ cpSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ realpathSync,
9
+ renameSync,
10
+ rmSync,
11
+ statSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { basename, dirname, extname, join, relative, resolve } from "node:path";
15
+
16
+ import { quickguiSolidPlugin } from "@quickgui/solid/compiler";
17
+ import type { BunPlugin } from "bun";
18
+
19
+ import type { MacOSNotarizationConfig, ResolvedQuickGuiConfig } from "./config.ts";
20
+ import { CliError, errorMessage } from "./error.ts";
21
+ import { targetInfo, type QuickGuiTarget } from "./targets.ts";
22
+
23
+ export type BuildMode = "development" | "production";
24
+
25
+ export interface BuildProjectOptions {
26
+ mode: BuildMode;
27
+ target: QuickGuiTarget;
28
+ outDir?: string;
29
+ signingIdentity?: string;
30
+ notarization?: MacOSNotarizationConfig;
31
+ }
32
+
33
+ export interface BuildResult {
34
+ artifactPath: string;
35
+ executablePath: string;
36
+ target: QuickGuiTarget;
37
+ mode: BuildMode;
38
+ dmgPath?: string;
39
+ }
40
+
41
+ export const nativeExports = [
42
+ "NativePowerAssertion",
43
+ "abortAppHost",
44
+ "addHostedRecentDocument",
45
+ "addRecentDocument",
46
+ "applyBatch",
47
+ "applyHostedBatch",
48
+ "checkForUpdate",
49
+ "clearHostedRecentDocuments",
50
+ "clearRecentDocuments",
51
+ "closeWindow",
52
+ "closeHostedWindow",
53
+ "configureApp",
54
+ "configureHostedApp",
55
+ "createApp",
56
+ "createSystemPopover",
57
+ "createHostedSystemPopover",
58
+ "createHostedApp",
59
+ "createHostedWindow",
60
+ "createWindow",
61
+ "destroyApp",
62
+ "destroyHostedApp",
63
+ "disableAutoStart",
64
+ "dismissHostedNotification",
65
+ "dismissNotification",
66
+ "defaultUpdateTarget",
67
+ "deleteSecureStorage",
68
+ "enableAutoStart",
69
+ "exitApp",
70
+ "exitHostedApp",
71
+ "focusNode",
72
+ "focusHostedNode",
73
+ "getAppInfo",
74
+ "getAppPaths",
75
+ "getCursorScreenPosition",
76
+ "getDesktopIntegrationSupport",
77
+ "getDisplays",
78
+ "getHostedAppInfo",
79
+ "getHostedAppPaths",
80
+ "getHostedCursorScreenPosition",
81
+ "getHostedDesktopIntegrationSupport",
82
+ "getHostedDisplays",
83
+ "getHostedKeyboardLayout",
84
+ "getHostedSystemInfo",
85
+ "getHostedSystemPreferences",
86
+ "getHostedWindowRegistry",
87
+ "getHostedWindowState",
88
+ "getKeyboardLayout",
89
+ "getPermissionStatus",
90
+ "getPowerState",
91
+ "getSecureStorage",
92
+ "getSessionState",
93
+ "getSystemIdleState",
94
+ "getSystemIdleTime",
95
+ "getSystemInfo",
96
+ "getSystemPreferences",
97
+ "getWindowRegistry",
98
+ "getWindowState",
99
+ "installUpdate",
100
+ "isAutoStartEnabled",
101
+ "isAutoStartSupported",
102
+ "isAppReady",
103
+ "isHostedAppReady",
104
+ "isProtocolRegistered",
105
+ "isSecureStorageSupported",
106
+ "performGlobalShortcutAction",
107
+ "performHostedGlobalShortcutAction",
108
+ "performHostedNotificationPermissionRequest",
109
+ "performHostedShellAction",
110
+ "performHostedWindowAction",
111
+ "performHostedWindowImageAction",
112
+ "performNotificationPermissionRequest",
113
+ "performShellAction",
114
+ "performWindowAction",
115
+ "performWindowImageAction",
116
+ "protocolVersion",
117
+ "prepareApp",
118
+ "prepareHostedApp",
119
+ "pumpApp",
120
+ "readClipboard",
121
+ "readHostedClipboard",
122
+ "registerProtocol",
123
+ "relaunchApp",
124
+ "relaunchHostedApp",
125
+ "releaseHostedSingleInstanceLock",
126
+ "releaseSingleInstanceLock",
127
+ "removeHostedTrayIcon",
128
+ "removeTrayIcon",
129
+ "requestFileIcon",
130
+ "requestHostedFileIcon",
131
+ "requestHostedSingleInstanceLock",
132
+ "requestPermission",
133
+ "requestSingleInstanceLock",
134
+ "runAppHost",
135
+ "setApplicationMenu",
136
+ "setDockBadge",
137
+ "setDockIcon",
138
+ "setDockMenu",
139
+ "setHostedApplicationMenu",
140
+ "setHostedDockBadge",
141
+ "setHostedDockIcon",
142
+ "setHostedDockMenu",
143
+ "setHostedTrayIcon",
144
+ "setHostedUserTasks",
145
+ "setSecureStorage",
146
+ "setTrayIcon",
147
+ "setUserTasks",
148
+ "showAboutPanel",
149
+ "showAlertDialog",
150
+ "showHostedAboutPanel",
151
+ "showHostedAlertDialog",
152
+ "showHostedNotification",
153
+ "showHostedOpenDialog",
154
+ "showHostedSaveDialog",
155
+ "showHostedTrayMenu",
156
+ "showOpenDialog",
157
+ "showSaveDialog",
158
+ "showTrayMenu",
159
+ "showNotification",
160
+ "startApp",
161
+ "startHostedApp",
162
+ "stageUpdate",
163
+ "supportsDynamicProtocolRegistration",
164
+ "takeEvents",
165
+ "waitForHostedEvents",
166
+ "unregisterProtocol",
167
+ "verifyUpdate",
168
+ "writeClipboard",
169
+ "writeHostedClipboard",
170
+ ] as const;
171
+
172
+ const nativeBindingShimSuffix = ".quickgui-binding-shim.js";
173
+
174
+ export async function buildProject(
175
+ config: ResolvedQuickGuiConfig,
176
+ options: BuildProjectOptions,
177
+ ): Promise<BuildResult> {
178
+ const info = targetInfo(options.target);
179
+ validateInputs(config, info.platform);
180
+ if (info.platform === "darwin" && options.mode === "production") {
181
+ validateMacPackaging(config, options);
182
+ }
183
+ const baseOutDir = options.outDir
184
+ ? resolve(config.projectRoot, options.outDir)
185
+ : options.mode === "development"
186
+ ? resolve(config.projectRoot, ".quickgui", "dev")
187
+ : config.outDir;
188
+ const targetOutDir = resolve(baseOutDir, options.target);
189
+ mkdirSync(targetOutDir, { recursive: true });
190
+ const stagingRoot = mkdtempSync(join(targetOutDir, ".quickgui-staging-"));
191
+
192
+ try {
193
+ const staged =
194
+ info.platform === "darwin"
195
+ ? await buildMacApp(config, options, stagingRoot)
196
+ : await buildExecutable(config, options, stagingRoot);
197
+ const finalPath = resolve(targetOutDir, basename(staged.artifactPath));
198
+ const finalDmgPath = staged.dmgPath
199
+ ? resolve(targetOutDir, basename(staged.dmgPath))
200
+ : undefined;
201
+ replaceArtifacts(
202
+ [
203
+ { stagedPath: staged.artifactPath, finalPath },
204
+ ...(staged.dmgPath && finalDmgPath
205
+ ? [{ stagedPath: staged.dmgPath, finalPath: finalDmgPath }]
206
+ : []),
207
+ ],
208
+ stagingRoot,
209
+ );
210
+ const executablePath = resolve(finalPath, relative(staged.artifactPath, staged.executablePath));
211
+ return {
212
+ artifactPath: finalPath,
213
+ executablePath,
214
+ target: options.target,
215
+ mode: options.mode,
216
+ ...(finalDmgPath ? { dmgPath: finalDmgPath } : {}),
217
+ };
218
+ } finally {
219
+ if (existsSync(stagingRoot)) rmSync(stagingRoot, { recursive: true, force: true });
220
+ }
221
+ }
222
+
223
+ async function buildMacApp(
224
+ config: ResolvedQuickGuiConfig,
225
+ options: BuildProjectOptions,
226
+ stagingRoot: string,
227
+ ): Promise<BuildResult> {
228
+ if (process.platform !== "darwin") {
229
+ throw new CliError("macOS .app bundles must currently be assembled and signed on macOS");
230
+ }
231
+ const identity = options.signingIdentity ?? config.macos.signingIdentity ?? "-";
232
+ const notarization =
233
+ options.mode === "production"
234
+ ? (options.notarization ?? config.macos.notarization)
235
+ : undefined;
236
+ const displayName = options.mode === "development" ? `${config.name} Dev` : config.name;
237
+ const identifier =
238
+ options.mode === "development" ? `${config.identifier}.dev` : config.identifier;
239
+ const appPath = resolve(stagingRoot, `${config.executableName}.app`);
240
+ const contents = resolve(appPath, "Contents");
241
+ const macos = resolve(contents, "MacOS");
242
+ const resources = resolve(contents, "Resources");
243
+ mkdirSync(macos, { recursive: true });
244
+ mkdirSync(resources, { recursive: true });
245
+ const executablePath = resolve(macos, config.executableName);
246
+ await compileExecutable(config, options, executablePath, stagingRoot);
247
+ chmodSync(executablePath, 0o755);
248
+
249
+ let iconFile: string | undefined;
250
+ if (config.macos.icon) {
251
+ if (extname(config.macos.icon).toLowerCase() !== ".icns") {
252
+ throw new CliError("macos.icon must point to an .icns file");
253
+ }
254
+ iconFile = "AppIcon.icns";
255
+ }
256
+ const reservedResources = new Set<string>([
257
+ ...(iconFile ? [iconFile] : []),
258
+ ]);
259
+ copyResources(config.resources, resources, reservedResources);
260
+ if (config.macos.icon && iconFile) {
261
+ cpSync(config.macos.icon, resolve(resources, iconFile));
262
+ }
263
+ writeFileSync(
264
+ resolve(contents, "Info.plist"),
265
+ macInfoPlist({
266
+ name: config.name,
267
+ displayName,
268
+ executableName: config.executableName,
269
+ identifier,
270
+ version: config.version,
271
+ buildVersion: config.buildVersion,
272
+ minimumSystemVersion: config.macos.minimumSystemVersion,
273
+ category: config.macos.category,
274
+ urlSchemes: config.protocols,
275
+ ...(iconFile ? { iconFile } : {}),
276
+ }),
277
+ );
278
+ writeFileSync(resolve(contents, "PkgInfo"), "APPL????");
279
+
280
+ const signArguments = ["codesign", "--force", "--deep"];
281
+ if (options.mode === "production" && identity !== "-") {
282
+ signArguments.push("--options", "runtime", "--timestamp");
283
+ }
284
+ signArguments.push("--sign", identity);
285
+ if (config.macos.entitlements) {
286
+ signArguments.push("--entitlements", config.macos.entitlements);
287
+ }
288
+ signArguments.push(appPath);
289
+ await run(signArguments, config.projectRoot);
290
+ await run(["codesign", "--verify", "--deep", "--strict", appPath], config.projectRoot);
291
+
292
+ const dmgPath =
293
+ options.mode === "production"
294
+ ? await buildMacDmg(config, appPath, stagingRoot, identity, notarization)
295
+ : undefined;
296
+
297
+ return {
298
+ artifactPath: appPath,
299
+ executablePath,
300
+ target: options.target,
301
+ mode: options.mode,
302
+ ...(dmgPath ? { dmgPath } : {}),
303
+ };
304
+ }
305
+
306
+ async function buildMacDmg(
307
+ config: ResolvedQuickGuiConfig,
308
+ appPath: string,
309
+ stagingRoot: string,
310
+ identity: string,
311
+ notarization?: MacOSNotarizationConfig,
312
+ ): Promise<string> {
313
+ const dmgPath = resolve(stagingRoot, macDmgFilename(config.name, config.version));
314
+ const dmgTitle = config.macos.dmgTitle ?? config.name;
315
+ const createDmgCli = resolveCreateDmgCli();
316
+ await run(
317
+ [
318
+ resolveNodeExecutable(),
319
+ createDmgCli,
320
+ "--overwrite",
321
+ "--no-code-sign",
322
+ `--dmg-title=${dmgTitle}`,
323
+ appPath,
324
+ stagingRoot,
325
+ ],
326
+ config.projectRoot,
327
+ );
328
+ if (!existsSync(dmgPath) || !statSync(dmgPath).isFile()) {
329
+ throw new CliError(`create-dmg did not produce the expected disk image: ${dmgPath}`);
330
+ }
331
+
332
+ if (identity !== "-") {
333
+ await run(
334
+ ["codesign", "--force", "--timestamp", "--sign", identity, dmgPath],
335
+ config.projectRoot,
336
+ );
337
+ await run(["codesign", "--verify", "--strict", dmgPath], config.projectRoot);
338
+ }
339
+
340
+ if (notarization) {
341
+ console.log(`[quickgui] Notarizing ${basename(dmgPath)}`);
342
+ await run(macNotarytoolArguments(dmgPath, notarization), config.projectRoot);
343
+ await run(["xcrun", "stapler", "staple", dmgPath], config.projectRoot);
344
+ await run(["xcrun", "stapler", "validate", dmgPath], config.projectRoot);
345
+ }
346
+
347
+ return dmgPath;
348
+ }
349
+
350
+ function resolveCreateDmgCli(): string {
351
+ try {
352
+ return Bun.resolveSync("create-dmg/cli.js", import.meta.dir);
353
+ } catch (error) {
354
+ throw new CliError("Could not resolve the bundled create-dmg CLI", { cause: error });
355
+ }
356
+ }
357
+
358
+ function resolveNodeExecutable(): string {
359
+ const node = Bun.which("node");
360
+ if (!node) {
361
+ throw new CliError("create-dmg requires Node.js 20 or later to build a macOS disk image");
362
+ }
363
+ return node;
364
+ }
365
+
366
+ export function macDmgFilename(name: string, version: string): string {
367
+ const filename = `${name} ${version}.dmg`;
368
+ if (filename.includes("\0") || basename(filename) !== filename) {
369
+ throw new CliError("Application name and version cannot contain path separators on macOS");
370
+ }
371
+ return filename;
372
+ }
373
+
374
+ export function macNotarytoolArguments(
375
+ dmgPath: string,
376
+ notarization: MacOSNotarizationConfig,
377
+ ): string[] {
378
+ return [
379
+ "xcrun",
380
+ "notarytool",
381
+ "submit",
382
+ dmgPath,
383
+ "--keychain-profile",
384
+ notarization.keychainProfile,
385
+ ...(notarization.keychain ? ["--keychain", notarization.keychain] : []),
386
+ "--wait",
387
+ ];
388
+ }
389
+
390
+ async function buildExecutable(
391
+ config: ResolvedQuickGuiConfig,
392
+ options: BuildProjectOptions,
393
+ stagingRoot: string,
394
+ ): Promise<BuildResult> {
395
+ const info = targetInfo(options.target);
396
+ const suffix = info.platform === "windows" ? ".exe" : "";
397
+ const executablePath = resolve(stagingRoot, `${config.executableName}${suffix}`);
398
+ await compileExecutable(config, options, executablePath, stagingRoot);
399
+ if (info.platform !== "windows") chmodSync(executablePath, 0o755);
400
+ return {
401
+ artifactPath: executablePath,
402
+ executablePath,
403
+ target: options.target,
404
+ mode: options.mode,
405
+ };
406
+ }
407
+
408
+ async function compileExecutable(
409
+ config: ResolvedQuickGuiConfig,
410
+ options: BuildProjectOptions,
411
+ executablePath: string,
412
+ stagingRoot: string,
413
+ ): Promise<void> {
414
+ const info = targetInfo(options.target);
415
+ const windows =
416
+ info.platform === "windows"
417
+ ? {
418
+ hideConsole: config.windows.hideConsole,
419
+ title: config.name,
420
+ version: windowsVersion(config.version),
421
+ ...(config.windows.icon ? { icon: config.windows.icon } : {}),
422
+ ...(config.windows.publisher ? { publisher: config.windows.publisher } : {}),
423
+ ...(config.windows.description ? { description: config.windows.description } : {}),
424
+ ...(config.windows.copyright ? { copyright: config.windows.copyright } : {}),
425
+ }
426
+ : undefined;
427
+
428
+ let result: Bun.BuildOutput;
429
+ try {
430
+ const production = options.mode === "production";
431
+ const hostEntrypoint = resolve(stagingRoot, "quickgui-app-host.ts");
432
+ const workerEntrypoint = resolve(stagingRoot, "quickgui-app-worker.ts");
433
+ const nativeHostModule = Bun.resolveSync("@quickgui/native/host", import.meta.dir);
434
+ const nativeApplicationModule = Bun.resolveSync("@quickgui/native", import.meta.dir);
435
+ const embeddedFonts = config.fonts.map((font) =>
436
+ readFileSync(font).toString("base64"),
437
+ );
438
+ writeFileSync(
439
+ hostEntrypoint,
440
+ `import { runApplicationWorker } from ${JSON.stringify(nativeHostModule)};\n` +
441
+ `const exitCode = await runApplicationWorker("./quickgui-app-worker.ts");\n` +
442
+ `process.exit(exitCode);\n`,
443
+ );
444
+ writeFileSync(
445
+ workerEntrypoint,
446
+ `import { Buffer } from "node:buffer";\n` +
447
+ `postMessage("quickgui:worker-ready");\n` +
448
+ `import { reportWorkerFailure } from ${JSON.stringify(nativeHostModule)};\n` +
449
+ `(globalThis as any).__QUICKGUI_APP_OPTIONS__ = ${JSON.stringify({
450
+ name: config.name,
451
+ version: config.version,
452
+ identifier: config.identifier,
453
+ }).slice(0, -1)}${
454
+ embeddedFonts.length > 0
455
+ ? `,"fonts":[${embeddedFonts
456
+ .map((font) => `Buffer.from(${JSON.stringify(font)},"base64")`)
457
+ .join(",")}]}`
458
+ : "}"
459
+ };\n` +
460
+ `try {\n await import(${JSON.stringify(config.entry)});\n` +
461
+ ` const { app } = await import(${JSON.stringify(nativeApplicationModule)});\n` +
462
+ ` await app.run();\n} catch (error) {\n` +
463
+ ` reportWorkerFailure(error);\n throw error;\n}\n`,
464
+ );
465
+ result = await Bun.build({
466
+ entrypoints: [hostEntrypoint, workerEntrypoint],
467
+ throw: false,
468
+ target: "bun",
469
+ format: "esm",
470
+ conditions: ["browser"],
471
+ plugins: [
472
+ quickguiSolidPlugin({ development: !production, projectRoot: config.projectRoot }),
473
+ nativeBindingPlugin(info.nativeAddon, options.target),
474
+ ],
475
+ minify: production,
476
+ sourcemap: production ? "none" : "inline",
477
+ env: "disable",
478
+ define: {
479
+ "process.env.NODE_ENV": JSON.stringify(
480
+ options.mode === "development" ? "development" : "production",
481
+ ),
482
+ },
483
+ compile: {
484
+ target: info.bunTarget,
485
+ outfile: executablePath,
486
+ autoloadDotenv: false,
487
+ autoloadBunfig: false,
488
+ autoloadPackageJson: !production,
489
+ autoloadTsconfig: !production,
490
+ ...(windows ? { windows } : {}),
491
+ },
492
+ });
493
+ } catch (error) {
494
+ throw new CliError(`Application compilation failed\n${errorMessage(error)}`);
495
+ }
496
+ if (!result.success) {
497
+ throw new CliError(
498
+ `Application compilation failed\n${result.logs.map((log) => String(log)).join("\n")}`,
499
+ );
500
+ }
501
+ }
502
+
503
+ export function nativeBindingPlugin(
504
+ addonFile: string,
505
+ target: QuickGuiTarget,
506
+ ): BunPlugin {
507
+ const packageRoots = new Map<string, string | undefined>();
508
+ return {
509
+ name: "quickgui-native-binding",
510
+ setup(build) {
511
+ build.onResolve({ filter: /^\.\/binding\.js$/ }, (arguments_) => {
512
+ if (!arguments_.importer) return;
513
+ let importer: string;
514
+ try {
515
+ importer = realpathSync(arguments_.importer);
516
+ } catch {
517
+ return;
518
+ }
519
+ let packageRoot = packageRoots.get(importer);
520
+ if (!packageRoots.has(importer)) {
521
+ packageRoot = findPackageRoot(importer, "@quickgui/native");
522
+ packageRoots.set(importer, packageRoot);
523
+ }
524
+ if (!packageRoot) return;
525
+ const addonPath = resolve(packageRoot, addonFile);
526
+ if (!existsSync(addonPath)) {
527
+ throw new CliError(
528
+ `The installed @quickgui/native package does not contain ${addonFile}. ` +
529
+ `Install a native package that supports ${target} or choose an available target.`,
530
+ );
531
+ }
532
+ // Keep the generated JavaScript shim and the actual `.node` module at distinct module
533
+ // identities. Reusing `addonPath` for both makes Bun resolve the shim's own `require()`
534
+ // back to itself, producing a recursive initializer in the standalone executable.
535
+ return {
536
+ path: `${addonPath}${nativeBindingShimSuffix}`,
537
+ namespace: "quickgui-native",
538
+ };
539
+ });
540
+ build.onLoad({ filter: /.*/, namespace: "quickgui-native" }, ({ path }) => ({
541
+ // Bun embeds directly required Node-API addons in standalone executables. The literal
542
+ // target path must point at the real `.node` file, not this virtual shim.
543
+ contents:
544
+ `const nativeBinding = require(${JSON.stringify(path.slice(0, -nativeBindingShimSuffix.length))});\n` +
545
+ `export const { ${nativeExports.join(", ")} } = nativeBinding;\n`,
546
+ loader: "js",
547
+ }));
548
+ },
549
+ };
550
+ }
551
+
552
+ function findPackageRoot(importer: string, expectedName: string): string | undefined {
553
+ let directory = dirname(importer);
554
+ for (;;) {
555
+ const packageJson = resolve(directory, "package.json");
556
+ if (existsSync(packageJson)) {
557
+ try {
558
+ const metadata = JSON.parse(readFileSync(packageJson, "utf8")) as { name?: unknown };
559
+ if (metadata.name === expectedName) return directory;
560
+ } catch {
561
+ return undefined;
562
+ }
563
+ }
564
+ const parent = dirname(directory);
565
+ if (parent === directory) return undefined;
566
+ directory = parent;
567
+ }
568
+ }
569
+
570
+ function replaceArtifacts(
571
+ artifacts: Array<{ stagedPath: string; finalPath: string }>,
572
+ stagingRoot: string,
573
+ ): void {
574
+ const backups: Array<{ finalPath: string; backupPath: string }> = [];
575
+ const installed: Array<{ stagedPath: string; finalPath: string }> = [];
576
+ try {
577
+ for (const [index, artifact] of artifacts.entries()) {
578
+ if (existsSync(artifact.finalPath)) {
579
+ const backupPath = resolve(stagingRoot, `.quickgui-previous-artifact-${index}`);
580
+ renameSync(artifact.finalPath, backupPath);
581
+ backups.push({ finalPath: artifact.finalPath, backupPath });
582
+ }
583
+ renameSync(artifact.stagedPath, artifact.finalPath);
584
+ installed.push(artifact);
585
+ }
586
+ } catch (error) {
587
+ for (const artifact of installed.reverse()) {
588
+ renameSync(artifact.finalPath, artifact.stagedPath);
589
+ }
590
+ for (const backup of backups.reverse()) {
591
+ renameSync(backup.backupPath, backup.finalPath);
592
+ }
593
+ throw error;
594
+ }
595
+ for (const backup of backups) {
596
+ rmSync(backup.backupPath, { recursive: true, force: true });
597
+ }
598
+ }
599
+
600
+ function validateMacPackaging(
601
+ config: ResolvedQuickGuiConfig,
602
+ options: BuildProjectOptions,
603
+ ): void {
604
+ resolveNodeExecutable();
605
+ resolveCreateDmgCli();
606
+ macDmgFilename(config.name, config.version);
607
+ const dmgTitle = config.macos.dmgTitle ?? config.name;
608
+ if (dmgTitle.length > 27) {
609
+ throw new CliError(
610
+ "The macOS DMG title cannot exceed 27 characters; set macos.dmgTitle to a shorter title",
611
+ );
612
+ }
613
+ const identity = options.signingIdentity ?? config.macos.signingIdentity ?? "-";
614
+ const notarization = options.notarization ?? config.macos.notarization;
615
+ if (notarization && identity === "-") {
616
+ throw new CliError(
617
+ "macOS notarization requires a Developer ID signing identity; configure macos.signingIdentity or pass --sign",
618
+ );
619
+ }
620
+ }
621
+
622
+ function validateInputs(
623
+ config: ResolvedQuickGuiConfig,
624
+ platform: "darwin" | "linux" | "windows",
625
+ ): void {
626
+ if (!existsSync(config.entry) || !statSync(config.entry).isFile()) {
627
+ throw new CliError(`Application entrypoint not found: ${config.entry}`);
628
+ }
629
+ for (const resource of config.resources) {
630
+ if (!existsSync(resource)) throw new CliError(`Resource not found: ${resource}`);
631
+ }
632
+ for (const font of config.fonts) {
633
+ if (!existsSync(font) || !statSync(font).isFile()) {
634
+ throw new CliError(`Font file not found: ${font}`);
635
+ }
636
+ }
637
+ if (platform === "darwin") {
638
+ if (config.macos.icon && !existsSync(config.macos.icon)) {
639
+ throw new CliError(`macOS icon not found: ${config.macos.icon}`);
640
+ }
641
+ if (config.macos.entitlements && !existsSync(config.macos.entitlements)) {
642
+ throw new CliError(`macOS entitlements not found: ${config.macos.entitlements}`);
643
+ }
644
+ }
645
+ if (platform === "windows" && config.windows.icon && !existsSync(config.windows.icon)) {
646
+ throw new CliError(`Windows icon not found: ${config.windows.icon}`);
647
+ }
648
+ }
649
+
650
+ function copyResources(
651
+ paths: string[],
652
+ destination: string,
653
+ reservedNames: ReadonlySet<string> = new Set(),
654
+ ): void {
655
+ const names = new Map(
656
+ [...reservedNames].map((name) => [name.toLocaleLowerCase("en-US"), name] as const),
657
+ );
658
+ for (const path of paths) {
659
+ const name = basename(path);
660
+ const normalizedName = name.toLocaleLowerCase("en-US");
661
+ const previous = names.get(normalizedName);
662
+ if (previous) {
663
+ throw new CliError(
664
+ `Resource destination name is reserved or duplicated: ${name} conflicts with ${previous}`,
665
+ );
666
+ }
667
+ names.set(normalizedName, name);
668
+ cpSync(path, resolve(destination, name), { recursive: statSync(path).isDirectory() });
669
+ }
670
+ }
671
+
672
+ async function run(command: string[], cwd: string): Promise<void> {
673
+ const child = Bun.spawn(command, {
674
+ cwd,
675
+ stdin: "ignore",
676
+ stdout: "pipe",
677
+ stderr: "pipe",
678
+ });
679
+ const [status, stdout, stderr] = await Promise.all([
680
+ child.exited,
681
+ new Response(child.stdout).text(),
682
+ new Response(child.stderr).text(),
683
+ ]);
684
+ if (status !== 0) {
685
+ const detail = stderr.trim() || stdout.trim();
686
+ throw new CliError(`Command failed: ${command.join(" ")}${detail ? `\n${detail}` : ""}`);
687
+ }
688
+ }
689
+
690
+ function windowsVersion(version: string): string {
691
+ const parts = version
692
+ .split(".")
693
+ .slice(0, 4)
694
+ .map((part) => (/^\d+$/.test(part) ? Number(part) : 0));
695
+ while (parts.length < 4) parts.push(0);
696
+ return parts.map((part) => Math.max(0, Math.min(65_535, part))).join(".");
697
+ }
698
+
699
+ interface MacInfoPlistOptions {
700
+ name: string;
701
+ displayName: string;
702
+ executableName: string;
703
+ identifier: string;
704
+ version: string;
705
+ buildVersion: string;
706
+ minimumSystemVersion: string;
707
+ category: string;
708
+ urlSchemes?: readonly string[];
709
+ iconFile?: string;
710
+ }
711
+
712
+ export function macInfoPlist(options: MacInfoPlistOptions): string {
713
+ const icon = options.iconFile
714
+ ? `\n <key>CFBundleIconFile</key>\n <string>${xml(options.iconFile)}</string>`
715
+ : "";
716
+ const urlTypes = options.urlSchemes?.length
717
+ ? `
718
+ <key>CFBundleURLTypes</key>
719
+ <array>
720
+ <dict>
721
+ <key>CFBundleTypeRole</key>
722
+ <string>Editor</string>
723
+ <key>CFBundleURLName</key>
724
+ <string>${xml(options.identifier)}</string>
725
+ <key>CFBundleURLSchemes</key>
726
+ <array>${options.urlSchemes
727
+ .map((scheme) => `\n <string>${xml(scheme)}</string>`)
728
+ .join("")}
729
+ </array>
730
+ </dict>
731
+ </array>`
732
+ : "";
733
+ return `<?xml version="1.0" encoding="UTF-8"?>
734
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
735
+ <plist version="1.0">
736
+ <dict>
737
+ <key>CFBundleDevelopmentRegion</key>
738
+ <string>en</string>
739
+ <key>CFBundleDisplayName</key>
740
+ <string>${xml(options.displayName)}</string>
741
+ <key>CFBundleExecutable</key>
742
+ <string>${xml(options.executableName)}</string>${icon}
743
+ <key>CFBundleIdentifier</key>
744
+ <string>${xml(options.identifier)}</string>
745
+ <key>CFBundleInfoDictionaryVersion</key>
746
+ <string>6.0</string>
747
+ <key>CFBundleName</key>
748
+ <string>${xml(options.name)}</string>
749
+ <key>CFBundlePackageType</key>
750
+ <string>APPL</string>
751
+ <key>CFBundleShortVersionString</key>
752
+ <string>${xml(options.version)}</string>
753
+ <key>CFBundleVersion</key>
754
+ <string>${xml(options.buildVersion)}</string>${urlTypes}
755
+ <key>LSApplicationCategoryType</key>
756
+ <string>${xml(options.category)}</string>
757
+ <key>LSMinimumSystemVersion</key>
758
+ <string>${xml(options.minimumSystemVersion)}</string>
759
+ <key>NSHighResolutionCapable</key>
760
+ <true/>
761
+ </dict>
762
+ </plist>
763
+ `;
764
+ }
765
+
766
+ function xml(value: string): string {
767
+ return value
768
+ .replaceAll("&", "&amp;")
769
+ .replaceAll("<", "&lt;")
770
+ .replaceAll(">", "&gt;")
771
+ .replaceAll('"', "&quot;")
772
+ .replaceAll("'", "&apos;");
773
+ }