sparkbun 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/bin/sparkbun.cjs +18 -0
  2. package/dist-linux-arm64/bsdiff +0 -0
  3. package/dist-linux-arm64/bspatch +0 -0
  4. package/dist-linux-arm64/libElectrobunCore.so +0 -0
  5. package/dist-linux-arm64/libNativeWrapper.so +0 -0
  6. package/dist-linux-arm64/libasar.so +0 -0
  7. package/dist-linux-x64/bsdiff +0 -0
  8. package/dist-linux-x64/bspatch +0 -0
  9. package/dist-linux-x64/libElectrobunCore.so +0 -0
  10. package/dist-linux-x64/libNativeWrapper.so +0 -0
  11. package/dist-linux-x64/libasar.so +0 -0
  12. package/dist-macos-arm64/bsdiff +0 -0
  13. package/dist-macos-arm64/bspatch +0 -0
  14. package/dist-macos-arm64/libElectrobunCore.dylib +0 -0
  15. package/dist-macos-arm64/libNativeWrapper.dylib +0 -0
  16. package/dist-macos-arm64/libasar.dylib +0 -0
  17. package/dist-macos-arm64/libwebgpu_dawn.dylib +0 -0
  18. package/dist-macos-arm64/preload-full.js +885 -0
  19. package/dist-macos-arm64/preload-sandboxed.js +111 -0
  20. package/dist-macos-arm64/process_helper +0 -0
  21. package/dist-win-x64/ElectrobunCore.dll +0 -0
  22. package/dist-win-x64/WebView2Loader.dll +0 -0
  23. package/dist-win-x64/bsdiff.exe +0 -0
  24. package/dist-win-x64/bspatch.exe +0 -0
  25. package/dist-win-x64/libNativeWrapper.dll +0 -0
  26. package/dist-win-x64/zig-asar/arm64/libasar.dll +0 -0
  27. package/dist-win-x64/zig-asar/x64/libasar.dll +0 -0
  28. package/package.json +47 -0
  29. package/scripts/build-and-upload-artifacts.js +207 -0
  30. package/scripts/gen-webgpu-ffi.mjs +162 -0
  31. package/scripts/install-windows-deps.ps1 +80 -0
  32. package/scripts/package-release.js +237 -0
  33. package/scripts/push-version.js +84 -0
  34. package/scripts/update-bun-version.ts +122 -0
  35. package/scripts/update-cef-version.ts +145 -0
  36. package/src/browser/builtinrpcSchema.ts +19 -0
  37. package/src/browser/global.d.ts +36 -0
  38. package/src/browser/index.ts +234 -0
  39. package/src/browser/webviewtag.ts +88 -0
  40. package/src/browser/wgputag.ts +48 -0
  41. package/src/bun/SparkBunConfig.ts +497 -0
  42. package/src/bun/__tests__/ffi-contract.test.ts +105 -0
  43. package/src/bun/core/ApplicationMenu.ts +70 -0
  44. package/src/bun/core/BrowserView.ts +416 -0
  45. package/src/bun/core/BrowserWindow.ts +396 -0
  46. package/src/bun/core/BuildConfig.ts +71 -0
  47. package/src/bun/core/ContextMenu.ts +75 -0
  48. package/src/bun/core/GpuWindow.ts +289 -0
  49. package/src/bun/core/Paths.ts +5 -0
  50. package/src/bun/core/Socket.ts +22 -0
  51. package/src/bun/core/Tray.ts +197 -0
  52. package/src/bun/core/Updater.ts +1131 -0
  53. package/src/bun/core/Utils.ts +487 -0
  54. package/src/bun/core/WGPUView.ts +167 -0
  55. package/src/bun/core/menuRoles.ts +181 -0
  56. package/src/bun/events/ApplicationEvents.ts +22 -0
  57. package/src/bun/events/event.ts +27 -0
  58. package/src/bun/events/eventEmitter.ts +45 -0
  59. package/src/bun/events/trayEvents.ts +11 -0
  60. package/src/bun/events/webviewEvents.ts +39 -0
  61. package/src/bun/events/windowEvents.ts +23 -0
  62. package/src/bun/index.ts +120 -0
  63. package/src/bun/preload/.generated/compiled.ts +2 -0
  64. package/src/bun/preload/build.ts +65 -0
  65. package/src/bun/preload/dragRegions.ts +41 -0
  66. package/src/bun/preload/encryption.ts +86 -0
  67. package/src/bun/preload/events.ts +171 -0
  68. package/src/bun/preload/globals.d.ts +45 -0
  69. package/src/bun/preload/index-sandboxed.ts +28 -0
  70. package/src/bun/preload/index.ts +77 -0
  71. package/src/bun/preload/internalRpc.ts +80 -0
  72. package/src/bun/preload/overlaySync.ts +107 -0
  73. package/src/bun/preload/webviewTag.ts +451 -0
  74. package/src/bun/preload/wgpuTag.ts +246 -0
  75. package/src/bun/proc/linux.md +43 -0
  76. package/src/bun/proc/native.ts +3253 -0
  77. package/src/bun/webGPU.ts +346 -0
  78. package/src/bun/webgpuAdapter.ts +3011 -0
  79. package/src/cli/bun.lockb +0 -0
  80. package/src/cli/index.ts +4653 -0
  81. package/src/cli/package-lock.json +81 -0
  82. package/src/cli/package.json +11 -0
  83. package/src/cli/templates/embedded.ts +2 -0
  84. package/src/core/build.zig +16 -0
  85. package/src/core/main.zig +3378 -0
  86. package/src/extractor/build.zig +22 -0
  87. package/src/installer/installer-template.ts +216 -0
  88. package/src/launcher/main.ts +221 -0
  89. package/src/native/build/libNativeWrapper.so +0 -0
  90. package/src/native/linux/build/nativeWrapper.o +0 -0
  91. package/src/native/linux/cef_loader.cpp +110 -0
  92. package/src/native/linux/cef_loader.h +28 -0
  93. package/src/native/linux/cef_process_helper_linux.cpp +160 -0
  94. package/src/native/linux/nativeWrapper.cpp +11768 -0
  95. package/src/native/macos/cef_process_helper_mac.cc +160 -0
  96. package/src/native/macos/nativeWrapper.mm +9172 -0
  97. package/src/native/shared/accelerator_parser.h +72 -0
  98. package/src/native/shared/app_paths.h +110 -0
  99. package/src/native/shared/asar.h +35 -0
  100. package/src/native/shared/cache_migration.h +244 -0
  101. package/src/native/shared/callbacks.h +57 -0
  102. package/src/native/shared/cef_response_filter.h +189 -0
  103. package/src/native/shared/chromium_flags.h +181 -0
  104. package/src/native/shared/config.h +66 -0
  105. package/src/native/shared/download_event.h +197 -0
  106. package/src/native/shared/ffi_helpers.h +139 -0
  107. package/src/native/shared/glob_match.h +59 -0
  108. package/src/native/shared/json_menu_parser.h +223 -0
  109. package/src/native/shared/mime_types.h +101 -0
  110. package/src/native/shared/navigation_rules.h +98 -0
  111. package/src/native/shared/partition_context.h +137 -0
  112. package/src/native/shared/pending_resize_queue.h +45 -0
  113. package/src/native/shared/permissions.h +118 -0
  114. package/src/native/shared/permissions_cef.h +74 -0
  115. package/src/native/shared/preload_script.h +71 -0
  116. package/src/native/shared/shutdown_guard.h +134 -0
  117. package/src/native/shared/thread_safe_map.h +138 -0
  118. package/src/native/shared/webview_storage.h +91 -0
  119. package/src/native/win/cef_process_helper_win.cpp +143 -0
  120. package/src/native/win/dcomp_compositor.h +352 -0
  121. package/src/native/win/nativeWrapper.cpp +12434 -0
  122. package/src/npmbin/index.js +34 -0
  123. package/src/shared/bun-version.ts +3 -0
  124. package/src/shared/cef-version.ts +5 -0
  125. package/src/shared/naming.test.ts +327 -0
  126. package/src/shared/naming.ts +188 -0
  127. package/src/shared/platform.ts +48 -0
  128. package/src/shared/rpc.ts +541 -0
  129. package/src/shared/sparkbun-version.ts +2 -0
  130. package/src/types/three.d.ts +1 -0
  131. package/tsconfig.json +31 -0
@@ -0,0 +1,4653 @@
1
+ import { join, dirname, basename } from "path";
2
+ import * as path from "path";
3
+ import {
4
+ existsSync,
5
+ readFileSync,
6
+ writeFileSync,
7
+ cpSync,
8
+ rmSync,
9
+ mkdirSync,
10
+ createWriteStream,
11
+ unlinkSync,
12
+ readdirSync,
13
+ symlinkSync,
14
+ statSync,
15
+ copyFileSync,
16
+ renameSync,
17
+ } from "fs";
18
+ import { execSync } from "child_process";
19
+ import * as readline from "readline";
20
+ import { OS, ARCH } from "../shared/platform";
21
+ import { DEFAULT_CEF_VERSION_STRING } from "../shared/cef-version";
22
+ import { BUN_VERSION } from "../shared/bun-version";
23
+ import { SPARKBUN_VERSION } from "../shared/sparkbun-version";
24
+ import {
25
+ getAppFileName,
26
+ getBundleFileName,
27
+ getPlatformPrefix,
28
+ getTarballFileName,
29
+ getWindowsSetupFileName,
30
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
31
+ sanitizeVolumeNameForHdiutil as _sanitizeVolumeNameForHdiutil,
32
+ getDmgVolumeName,
33
+ getMacOSBundleDisplayName,
34
+ } from "../shared/naming";
35
+ import { getTemplate, getTemplateNames } from "./templates/embedded";
36
+ // import { loadBsdiff, loadBspatch } from 'bsdiff-wasm';
37
+ // MacOS named pipes hang at around 4KB
38
+ // @ts-expect-error - reserved for future use
39
+ const _MAX_CHUNK_SIZE = 1024 * 2;
40
+
41
+ // const binExt = OS === 'win' ? '.exe' : '';
42
+
43
+ // Create a tar file using system tar command (preserves file permissions unlike Bun.Archive)
44
+ function createTar(tarPath: string, cwd: string, entries: string[]) {
45
+ // Use a relative path for the tar output on Windows to avoid bsdtar
46
+ // interpreting the "C:" drive letter as a remote host specifier.
47
+ const resolvedTarPath =
48
+ process.platform === "win32" ? path.relative(cwd, tarPath) : tarPath;
49
+ execSync(
50
+ `tar -cf "${resolvedTarPath}" ${entries.map((e) => `"${e}"`).join(" ")}`,
51
+ {
52
+ cwd,
53
+ stdio: "pipe",
54
+ // Prevent macOS tar from including Apple Double (._*) files. No-op on other platforms.
55
+ env: { ...process.env, COPYFILE_DISABLE: "1" },
56
+ },
57
+ );
58
+ }
59
+
60
+ // Create a tar.gz file using system tar command
61
+
62
+ // this when run as an npm script this will be where the folder where package.json is.
63
+ const projectRoot = process.cwd();
64
+
65
+ // Find TypeScript ESM config file
66
+ function findConfigFile(): string | null {
67
+ const configFile = join(projectRoot, "sparkbun.config.ts");
68
+ return existsSync(configFile) ? configFile : null;
69
+ }
70
+
71
+ // Note: cli args can be called via npm bun /path/to/electorbun/binary arg1 arg2
72
+ const indexOfCli = process.argv.findIndex((arg) =>
73
+ arg.toLowerCase().includes("electrobun") || arg.toLowerCase().includes("sparkbun"),
74
+ );
75
+ const commandArg = process.argv[indexOfCli + 1] || "build";
76
+
77
+ // Walk up from projectRoot to find electrobun in node_modules (supports hoisted monorepo layouts)
78
+ function resolveSparkBunDir(): string {
79
+ // When running from SparkBun source (src/cli/index.ts), the package root is two levels up
80
+ const cliDir = dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"));
81
+ const sourcePackageDir = join(cliDir, "..", "..");
82
+ if (existsSync(join(sourcePackageDir, "package.json")) && existsSync(join(sourcePackageDir, "src", "cli"))) {
83
+ return sourcePackageDir;
84
+ }
85
+ let dir = projectRoot;
86
+ while (dir !== dirname(dir)) {
87
+ const candidate = join(dir, "node_modules", "sparkbun");
88
+ if (existsSync(join(candidate, "package.json"))) {
89
+ return candidate;
90
+ }
91
+ dir = dirname(dir);
92
+ }
93
+ return join(projectRoot, "node_modules", "sparkbun");
94
+ }
95
+
96
+ const SPARKBUN_DEP_PATH = resolveSparkBunDir();
97
+ const SPARKBUN_CACHE_PATH = join(dirname(SPARKBUN_DEP_PATH), ".sparkbun-cache");
98
+
99
+ // When debugging sparkbun with the example app use the builds (dev or release) right from the source folder
100
+ // For developers using sparkbun cli via npm use the release versions in /dist
101
+ // This lets us not have to commit src build folders to git and provide pre-built binaries
102
+
103
+ // Function to get platform-specific paths
104
+ function getPlatformPaths(
105
+ targetOS: "macos" | "win" | "linux",
106
+ targetArch: "arm64" | "x64",
107
+ ) {
108
+ const binExt = targetOS === "win" ? ".exe" : "";
109
+ const platformDistDir = join(
110
+ SPARKBUN_DEP_PATH,
111
+ `dist-${targetOS}-${targetArch}`,
112
+ );
113
+ return {
114
+ // Platform-specific binaries (from dist-OS-ARCH/)
115
+ BUN_BINARY: join(platformDistDir, "bun") + binExt,
116
+ LAUNCHER_DEV: join(platformDistDir, "sparkbun") + binExt,
117
+ LAUNCHER_RELEASE: join(platformDistDir, "launcher") + binExt,
118
+ CORE_MACOS: join(platformDistDir, "libElectrobunCore.dylib"),
119
+ CORE_WIN: join(platformDistDir, "ElectrobunCore.dll"),
120
+ CORE_LINUX: join(platformDistDir, "libElectrobunCore.so"),
121
+ NATIVE_WRAPPER_MACOS: join(platformDistDir, "libNativeWrapper.dylib"),
122
+ NATIVE_WRAPPER_WIN: join(platformDistDir, "libNativeWrapper.dll"),
123
+ NATIVE_WRAPPER_LINUX: join(platformDistDir, "libNativeWrapper.so"),
124
+ NATIVE_WRAPPER_LINUX_CEF: join(platformDistDir, "libNativeWrapper_cef.so"),
125
+ WEBVIEW2LOADER_WIN: join(platformDistDir, "WebView2Loader.dll"),
126
+ BSPATCH: join(platformDistDir, "bspatch") + binExt,
127
+ EXTRACTOR: join(platformDistDir, "extractor") + binExt,
128
+ BSDIFF: join(platformDistDir, "bsdiff") + binExt,
129
+ CEF_FRAMEWORK_MACOS: join(
130
+ platformDistDir,
131
+ "cef",
132
+ "Chromium Embedded Framework.framework",
133
+ ),
134
+ CEF_HELPER_MACOS: join(platformDistDir, "process_helper"),
135
+ CEF_HELPER_WIN: join(platformDistDir, "process_helper.exe"),
136
+ CEF_HELPER_LINUX: join(platformDistDir, "process_helper"),
137
+ CEF_DIR: join(platformDistDir, "cef"),
138
+
139
+ PRELOAD_FULL_JS: join(platformDistDir, "preload-full.js"),
140
+ PRELOAD_SANDBOXED_JS: join(platformDistDir, "preload-sandboxed.js"),
141
+ };
142
+ }
143
+
144
+ // Default PATHS for host platform (backward compatibility)
145
+ // @ts-expect-error - reserved for future use
146
+ const _PATHS = getPlatformPaths(OS, ARCH);
147
+
148
+ function getCEFHelperNames(): string[] {
149
+ return [
150
+ "bun Helper",
151
+ "bun Helper (Alerts)",
152
+ "bun Helper (GPU)",
153
+ "bun Helper (Plugin)",
154
+ "bun Helper (Renderer)",
155
+ ];
156
+ }
157
+
158
+ async function ensureCoreDependencies(
159
+ targetOS?: "macos" | "win" | "linux",
160
+ targetArch?: "arm64" | "x64",
161
+ ) {
162
+ // Use provided target platform or default to host platform
163
+ const platformOS = targetOS || OS;
164
+ const platformArch = targetArch || ARCH;
165
+
166
+ // Get platform-specific paths
167
+ const platformPaths = getPlatformPaths(platformOS, platformArch);
168
+
169
+ // Check platform-specific binaries
170
+ // BUN_BINARY not required — SparkBun compiles the launcher via bun build --compile
171
+ const requiredBinaries = [
172
+ platformPaths.BSDIFF,
173
+ platformPaths.BSPATCH,
174
+ ];
175
+ if (platformOS === "macos") {
176
+ requiredBinaries.push(
177
+ platformPaths.NATIVE_WRAPPER_MACOS,
178
+ );
179
+ } else if (platformOS === "win") {
180
+ requiredBinaries.push(platformPaths.NATIVE_WRAPPER_WIN);
181
+ } else {
182
+ requiredBinaries.push(platformPaths.NATIVE_WRAPPER_LINUX);
183
+ }
184
+
185
+ const missingBinaries = requiredBinaries.filter((file) => !existsSync(file));
186
+
187
+ // Only download if platform-specific binaries are missing
188
+ if (missingBinaries.length === 0) {
189
+ return;
190
+ }
191
+
192
+ // Show which binaries are missing
193
+ console.log(
194
+ `Core dependencies not found for ${platformOS}-${platformArch}. Missing files:`,
195
+ missingBinaries.map((f) => f.replace(SPARKBUN_DEP_PATH, ".")).join(", "),
196
+ );
197
+ console.log(`Downloading core binaries for ${platformOS}-${platformArch}...`);
198
+
199
+ const version = `v${SPARKBUN_VERSION}`;
200
+
201
+ const platformName =
202
+ platformOS === "macos" ? "darwin" : platformOS === "win" ? "win" : "linux";
203
+ const archName = platformArch;
204
+ const coreTarballUrl = `https://github.com/gruntlord5/SparkBun/releases/download/${version}/sparkbun-core-${platformName}-${archName}.tar.gz`;
205
+
206
+ console.log(`Downloading core binaries from: ${coreTarballUrl}`);
207
+
208
+ try {
209
+ // Download core binaries tarball
210
+ const response = await fetch(coreTarballUrl);
211
+ if (!response.ok) {
212
+ throw new Error(
213
+ `Failed to download binaries: ${response.status} ${response.statusText}`,
214
+ );
215
+ }
216
+
217
+ // Create temp file
218
+ const tempFile = join(
219
+ SPARKBUN_DEP_PATH,
220
+ `core-${platformOS}-${platformArch}-temp.tar.gz`,
221
+ );
222
+ const fileStream = createWriteStream(tempFile);
223
+
224
+ // Write response to file
225
+ if (response.body) {
226
+ const reader = response.body.getReader();
227
+ let totalBytes = 0;
228
+ while (true) {
229
+ const { done, value } = await reader.read();
230
+ if (done) break;
231
+ const buffer = Buffer.from(value);
232
+ fileStream.write(buffer);
233
+ totalBytes += buffer.length;
234
+ }
235
+ console.log(
236
+ `Downloaded ${totalBytes} bytes for ${platformOS}-${platformArch}`,
237
+ );
238
+ }
239
+
240
+ // Ensure file is properly closed before proceeding
241
+ await new Promise((resolve, reject) => {
242
+ fileStream.end((err: Error | null | undefined) => {
243
+ if (err) reject(err);
244
+ else resolve(null);
245
+ });
246
+ });
247
+
248
+ // Verify the downloaded file exists and has content
249
+ if (!existsSync(tempFile)) {
250
+ throw new Error(`Downloaded file not found: ${tempFile}`);
251
+ }
252
+
253
+ const fileSize = require("fs").statSync(tempFile).size;
254
+ if (fileSize === 0) {
255
+ throw new Error(`Downloaded file is empty: ${tempFile}`);
256
+ }
257
+
258
+ console.log(`Verified download: ${tempFile} (${fileSize} bytes)`);
259
+
260
+ // Extract to platform-specific dist directory
261
+ console.log(
262
+ `Extracting core dependencies for ${platformOS}-${platformArch}...`,
263
+ );
264
+ const platformDistPath = join(
265
+ SPARKBUN_DEP_PATH,
266
+ `dist-${platformOS}-${platformArch}`,
267
+ );
268
+ mkdirSync(platformDistPath, { recursive: true });
269
+
270
+ const tarBytes = await Bun.file(tempFile).arrayBuffer();
271
+ const archive = new Bun.Archive(tarBytes);
272
+ await archive.extract(platformDistPath);
273
+
274
+ // NOTE: We no longer copy main.js from platform-specific downloads
275
+ // Platform-specific downloads should only contain native binaries
276
+ // main.js and api/ should be shipped via npm in the shared dist/ folder
277
+
278
+ // Clean up temp file
279
+ unlinkSync(tempFile);
280
+
281
+ // Debug: List what was actually extracted
282
+ try {
283
+ const extractedFiles = readdirSync(platformDistPath);
284
+ console.log(`Extracted files to ${platformDistPath}:`, extractedFiles);
285
+
286
+ // Check if files are in subdirectories
287
+ for (const file of extractedFiles) {
288
+ const filePath = join(platformDistPath, file);
289
+ const stat = require("fs").statSync(filePath);
290
+ if (stat.isDirectory()) {
291
+ const subFiles = readdirSync(filePath);
292
+ console.log(` ${file}/: ${subFiles.join(", ")}`);
293
+ }
294
+ }
295
+ } catch (e) {
296
+ console.error("Could not list extracted files:", e);
297
+ }
298
+
299
+ // Verify extraction completed successfully - check platform-specific binaries only
300
+ const requiredBinaries = [
301
+ platformPaths.BUN_BINARY,
302
+ platformPaths.BSDIFF,
303
+ platformPaths.BSPATCH,
304
+ ];
305
+ if (platformOS === "macos") {
306
+ requiredBinaries.push(
307
+ platformPaths.LAUNCHER_RELEASE,
308
+ platformPaths.NATIVE_WRAPPER_MACOS,
309
+ );
310
+ } else if (platformOS === "win") {
311
+ requiredBinaries.push(platformPaths.NATIVE_WRAPPER_WIN);
312
+ } else {
313
+ requiredBinaries.push(platformPaths.NATIVE_WRAPPER_LINUX);
314
+ }
315
+
316
+ const missingBinaries = requiredBinaries.filter(
317
+ (file) => !existsSync(file),
318
+ );
319
+ if (missingBinaries.length > 0) {
320
+ console.error(
321
+ `Missing binaries after extraction: ${missingBinaries.map((f) => f.replace(SPARKBUN_DEP_PATH, ".")).join(", ")}`,
322
+ );
323
+ console.error(
324
+ "This suggests the tarball structure is different than expected",
325
+ );
326
+ }
327
+
328
+ // Note: We no longer need to remove or re-add signatures from downloaded binaries
329
+ // The CI-added adhoc signatures are actually required for macOS to run the binaries
330
+
331
+ // For development: if main.js doesn't exist in shared dist/, copy from platform-specific download as fallback
332
+ const sharedDistPath = join(SPARKBUN_DEP_PATH, "dist");
333
+ const extractedMainJs = join(platformDistPath, "main.js");
334
+ const sharedMainJs = join(sharedDistPath, "main.js");
335
+
336
+ if (existsSync(extractedMainJs) && !existsSync(sharedMainJs)) {
337
+ console.log(
338
+ "Development fallback: copying main.js from platform-specific download to shared dist/",
339
+ );
340
+ mkdirSync(sharedDistPath, { recursive: true });
341
+ cpSync(extractedMainJs, sharedMainJs, { dereference: true });
342
+ }
343
+
344
+ console.log(
345
+ `Core dependencies for ${platformOS}-${platformArch} downloaded and cached successfully`,
346
+ );
347
+ } catch (error: any) {
348
+ console.error(
349
+ `Failed to download core dependencies for ${platformOS}-${platformArch}:`,
350
+ error.message,
351
+ );
352
+ console.error(
353
+ "Please ensure you have an internet connection and the release exists.",
354
+ );
355
+ process.exit(1);
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Returns the effective CEF directory path. When a custom cefVersion is set,
361
+ * CEF files are stored in node_modules/.sparkbun-cache/ which survives
362
+ * both dist rebuilds and bun install (which replaces node_modules/electrobun).
363
+ * When using the default version, returns the standard dist-{platform}/cef/ path.
364
+ */
365
+ function getEffectiveCEFDir(
366
+ platformOS: "macos" | "win" | "linux",
367
+ platformArch: "arm64" | "x64",
368
+ cefVersion?: string,
369
+ ): string {
370
+ if (cefVersion) {
371
+ return join(SPARKBUN_CACHE_PATH, "cef-override", `${platformOS}-${platformArch}`);
372
+ }
373
+ return getPlatformPaths(platformOS, platformArch).CEF_DIR;
374
+ }
375
+
376
+ /**
377
+ * Returns the effective WGPU directory path. WGPU files are stored in
378
+ * node_modules/.sparkbun-cache/ to survive dist rebuilds and bun install.
379
+ */
380
+ function getEffectiveWGPUDir(
381
+ platformOS: "macos" | "win" | "linux",
382
+ platformArch: "arm64" | "x64",
383
+ ): string {
384
+ return join(
385
+ projectRoot,
386
+ "node_modules",
387
+ ".sparkbun-cache",
388
+ "wgpu",
389
+ `${platformOS}-${platformArch}`,
390
+ );
391
+ }
392
+
393
+ /**
394
+ * Trims an ICU .dat file to only include the specified locales.
395
+ * Uses icupkg (from ICU tools) to list and remove unwanted locale data.
396
+ */
397
+ async function trimICUData(
398
+ source: string,
399
+ dest: string,
400
+ locales: string[],
401
+ ): Promise<void> {
402
+ // Copy the full .dat file first
403
+ cpSync(source, dest);
404
+
405
+ // Try to find icupkg in PATH or common locations
406
+ let icupkgPath = "icupkg";
407
+ try {
408
+ execSync(`${icupkgPath} --help`, { stdio: "ignore" });
409
+ } catch {
410
+ // icupkg not available, skip trimming
411
+ throw new Error(
412
+ "icupkg not found in PATH. Install ICU tools to enable locale trimming.",
413
+ );
414
+ }
415
+
416
+ // List all items in the .dat file
417
+ const listOutput = execSync(`${icupkgPath} -l "${dest}"`, {
418
+ encoding: "utf-8",
419
+ });
420
+ const allItems = listOutput.split("\n").filter((line) => line.trim());
421
+
422
+ // Locale-specific directories in ICU data
423
+ const localeDirs = [
424
+ "brkitr/",
425
+ "coll/",
426
+ "curr/",
427
+ "lang/",
428
+ "locales/",
429
+ "rbnf/",
430
+ "region/",
431
+ "unit/",
432
+ "zone/",
433
+ ];
434
+
435
+ const toRemove = allItems.filter((item) => {
436
+ // Only consider items in locale-specific directories
437
+ const isLocaleItem = localeDirs.some((dir) => item.startsWith(dir));
438
+ if (!isLocaleItem) return false;
439
+
440
+ // Extract the basename (after the last /)
441
+ const basename = item.split("/").pop() || "";
442
+ // Remove file extension for matching
443
+ const name = basename.replace(/\.res$/, "");
444
+
445
+ // Keep items matching requested locales (exact match or with region suffix)
446
+ return !locales.some(
447
+ (l) =>
448
+ name === l ||
449
+ name === "root" ||
450
+ name.startsWith(`${l}_`) ||
451
+ name.startsWith(`${l}-`),
452
+ );
453
+ });
454
+
455
+ if (toRemove.length > 0) {
456
+ // Write removal list to temp file
457
+ const { tmpdir } = await import("os");
458
+ const removeListPath = join(tmpdir(), "icu-remove.txt");
459
+ writeFileSync(removeListPath, toRemove.join("\n"));
460
+
461
+ try {
462
+ execSync(`${icupkgPath} -r "@${removeListPath}" "${dest}"`, {
463
+ stdio: "inherit",
464
+ });
465
+ } finally {
466
+ try {
467
+ unlinkSync(removeListPath);
468
+ } catch {
469
+ // ignore cleanup errors
470
+ }
471
+ }
472
+ }
473
+ }
474
+
475
+ async function ensureCEFDependencies(
476
+ targetOS?: "macos" | "win" | "linux",
477
+ targetArch?: "arm64" | "x64",
478
+ cefVersion?: string,
479
+ ): Promise<string> {
480
+ // Use provided target platform or default to host platform
481
+ const platformOS = targetOS || OS;
482
+ const platformArch = targetArch || ARCH;
483
+
484
+ // Get platform-specific paths
485
+ const platformPaths = getPlatformPaths(platformOS, platformArch);
486
+
487
+ // If custom CEF version specified, download from Spotify CDN
488
+ // Custom CEF is stored in vendors/cef-override/ to survive dist rebuilds
489
+ if (cefVersion) {
490
+ const overrideDir = getEffectiveCEFDir(
491
+ platformOS,
492
+ platformArch,
493
+ cefVersion,
494
+ );
495
+ // Check if already downloaded with matching version
496
+ const cefVersionFile = join(overrideDir, ".cef-version");
497
+ if (existsSync(overrideDir) && existsSync(cefVersionFile)) {
498
+ const cachedVersion = readFileSync(cefVersionFile, "utf8").trim();
499
+ if (cachedVersion === cefVersion) {
500
+ console.log(
501
+ `Custom CEF ${cefVersion} already cached for ${platformOS}-${platformArch} at ${overrideDir}`,
502
+ );
503
+ return overrideDir;
504
+ }
505
+ // Version mismatch - remove stale cache
506
+ console.log(
507
+ `Cached CEF version "${cachedVersion}" does not match requested "${cefVersion}", re-downloading...`,
508
+ );
509
+ rmSync(overrideDir, { recursive: true, force: true });
510
+ } else if (existsSync(overrideDir)) {
511
+ // Override dir exists but no version stamp - remove it
512
+ rmSync(overrideDir, { recursive: true, force: true });
513
+ }
514
+
515
+ await downloadAndExtractCustomCEF(cefVersion, platformOS, platformArch);
516
+ return overrideDir;
517
+ }
518
+
519
+ // Check if CEF dependencies already exist
520
+ if (existsSync(platformPaths.CEF_DIR)) {
521
+ console.log(
522
+ `CEF dependencies found for ${platformOS}-${platformArch}, using cached version`,
523
+ );
524
+ return platformPaths.CEF_DIR;
525
+ }
526
+
527
+ console.log(
528
+ `CEF dependencies not found for ${platformOS}-${platformArch}, downloading...`,
529
+ );
530
+
531
+ const version = `v${SPARKBUN_VERSION}`;
532
+
533
+ const platformName =
534
+ platformOS === "macos" ? "darwin" : platformOS === "win" ? "win" : "linux";
535
+ const archName = platformArch;
536
+ const cefTarballUrl = `https://github.com/gruntlord5/SparkBun/releases/download/${version}/sparkbun-cef-${platformName}-${archName}.tar.gz`;
537
+
538
+ // Helper function to download with retry logic
539
+ async function downloadWithRetry(
540
+ url: string,
541
+ filePath: string,
542
+ maxRetries = 3,
543
+ ): Promise<void> {
544
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
545
+ try {
546
+ console.log(
547
+ `Downloading CEF (attempt ${attempt}/${maxRetries}) from: ${url}`,
548
+ );
549
+
550
+ const response = await fetch(url);
551
+ if (!response.ok) {
552
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
553
+ }
554
+
555
+ // Get content length for progress tracking
556
+ const contentLength = response.headers.get("content-length");
557
+ const totalSize = contentLength ? parseInt(contentLength, 10) : 0;
558
+
559
+ // Create temp file with unique name to avoid conflicts
560
+ const fileStream = createWriteStream(filePath);
561
+ let downloadedSize = 0;
562
+ let lastReportedPercent = -1;
563
+
564
+ // Stream download with progress
565
+ if (response.body) {
566
+ const reader = response.body.getReader();
567
+ while (true) {
568
+ const { done, value } = await reader.read();
569
+ if (done) break;
570
+
571
+ const chunk = Buffer.from(value);
572
+ fileStream.write(chunk);
573
+ downloadedSize += chunk.length;
574
+
575
+ if (totalSize > 0) {
576
+ const percent = Math.round((downloadedSize / totalSize) * 100);
577
+ const percentTier = Math.floor(percent / 10) * 10;
578
+ if (percentTier > lastReportedPercent && percentTier <= 100) {
579
+ console.log(
580
+ ` Progress: ${percentTier}% (${Math.round(downloadedSize / 1024 / 1024)}MB/${Math.round(totalSize / 1024 / 1024)}MB)`,
581
+ );
582
+ lastReportedPercent = percentTier;
583
+ }
584
+ }
585
+ }
586
+ }
587
+
588
+ await new Promise((resolve, reject) => {
589
+ fileStream.end((error: any) => {
590
+ if (error) reject(error);
591
+ else resolve(void 0);
592
+ });
593
+ });
594
+
595
+ // Verify file size if content-length was provided
596
+ if (totalSize > 0) {
597
+ const actualSize = (await import("fs")).statSync(filePath).size;
598
+ if (actualSize !== totalSize) {
599
+ throw new Error(
600
+ `Downloaded file size mismatch: expected ${totalSize}, got ${actualSize}`,
601
+ );
602
+ }
603
+ }
604
+
605
+ console.log(
606
+ `✓ Download completed successfully (${Math.round(downloadedSize / 1024 / 1024)}MB)`,
607
+ );
608
+ return; // Success, exit retry loop
609
+ } catch (error: any) {
610
+ console.error(`Download attempt ${attempt} failed:`, error.message);
611
+
612
+ // Clean up partial download
613
+ if (existsSync(filePath)) {
614
+ unlinkSync(filePath);
615
+ }
616
+
617
+ if (attempt === maxRetries) {
618
+ throw new Error(
619
+ `Failed to download after ${maxRetries} attempts: ${error.message}`,
620
+ );
621
+ }
622
+
623
+ // Wait before retrying (exponential backoff)
624
+ const delay = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s...
625
+ console.log(`Retrying in ${delay / 1000} seconds...`);
626
+ await new Promise((resolve) => setTimeout(resolve, delay));
627
+ }
628
+ }
629
+ }
630
+
631
+ try {
632
+ // Create temp file with unique name
633
+ const tempFile = join(
634
+ SPARKBUN_DEP_PATH,
635
+ `cef-${platformOS}-${platformArch}-${Date.now()}.tar.gz`,
636
+ );
637
+
638
+ // Download with retry logic
639
+ await downloadWithRetry(cefTarballUrl, tempFile);
640
+
641
+ // Extract to platform-specific dist directory
642
+ console.log(
643
+ `Extracting CEF dependencies for ${platformOS}-${platformArch}...`,
644
+ );
645
+ const platformDistPath = join(
646
+ SPARKBUN_DEP_PATH,
647
+ `dist-${platformOS}-${platformArch}`,
648
+ );
649
+ mkdirSync(platformDistPath, { recursive: true });
650
+
651
+ // Helper function to validate tar file before extraction
652
+ async function validateTarFile(filePath: string): Promise<void> {
653
+ try {
654
+ // Quick validation - try to read the tar file header
655
+ const fd = await import("fs").then((fs) =>
656
+ fs.promises.readFile(filePath),
657
+ );
658
+
659
+ // Check if it's a gzip file (magic bytes: 1f 8b)
660
+ if (fd.length < 2 || fd[0] !== 0x1f || fd[1] !== 0x8b) {
661
+ throw new Error("Invalid gzip header - file may be corrupted");
662
+ }
663
+
664
+ console.log(
665
+ `✓ Tar file validation passed (${Math.round(fd.length / 1024 / 1024)}MB)`,
666
+ );
667
+ } catch (error: any) {
668
+ throw new Error(`Tar file validation failed: ${error.message}`);
669
+ }
670
+ }
671
+
672
+ // Validate downloaded file before extraction
673
+ await validateTarFile(tempFile);
674
+
675
+ try {
676
+ const cefTarBytes = await Bun.file(tempFile).arrayBuffer();
677
+ const cefArchive = new Bun.Archive(cefTarBytes);
678
+ await cefArchive.extract(platformDistPath);
679
+
680
+ console.log(`✓ Extraction completed successfully`);
681
+ } catch (error: any) {
682
+ // Check if CEF directory was created despite the error (partial extraction)
683
+ const cefDir = join(platformDistPath, "cef");
684
+ if (existsSync(cefDir)) {
685
+ const cefFiles = readdirSync(cefDir);
686
+ if (cefFiles.length > 0) {
687
+ console.warn(`⚠️ Extraction warning: ${error.message}`);
688
+ console.warn(
689
+ ` However, CEF files were extracted (${cefFiles.length} files found).`,
690
+ );
691
+ console.warn(
692
+ ` Proceeding with partial extraction - this usually works fine.`,
693
+ );
694
+ // Don't throw - continue with what we have
695
+ } else {
696
+ // No files extracted, this is a real failure
697
+ throw new Error(
698
+ `Extraction failed (no files extracted): ${error.message}`,
699
+ );
700
+ }
701
+ } else {
702
+ // No CEF directory created, this is a real failure
703
+ throw new Error(
704
+ `Extraction failed (no CEF directory created): ${error.message}`,
705
+ );
706
+ }
707
+ }
708
+
709
+ // Clean up temp file only after successful extraction
710
+ try {
711
+ unlinkSync(tempFile);
712
+ } catch (cleanupError) {
713
+ console.warn("Could not clean up temp file:", cleanupError);
714
+ }
715
+
716
+ // Debug: List what was actually extracted for CEF
717
+ try {
718
+ const extractedFiles = readdirSync(platformDistPath);
719
+ console.log(
720
+ `CEF extracted files to ${platformDistPath}:`,
721
+ extractedFiles,
722
+ );
723
+
724
+ // Check if CEF directory was created
725
+ const cefDir = join(platformDistPath, "cef");
726
+ if (existsSync(cefDir)) {
727
+ const cefFiles = readdirSync(cefDir);
728
+ console.log(
729
+ `CEF directory contents: ${cefFiles.slice(0, 10).join(", ")}${cefFiles.length > 10 ? "..." : ""}`,
730
+ );
731
+ }
732
+ } catch (e) {
733
+ console.error("Could not list CEF extracted files:", e);
734
+ }
735
+
736
+ console.log(
737
+ `✓ CEF dependencies for ${platformOS}-${platformArch} downloaded and cached successfully`,
738
+ );
739
+ return platformPaths.CEF_DIR;
740
+ } catch (error: any) {
741
+ console.error(
742
+ `Failed to download CEF dependencies for ${platformOS}-${platformArch}:`,
743
+ error.message,
744
+ );
745
+
746
+ // Provide helpful guidance based on the error
747
+ if (
748
+ error.message.includes("corrupted download") ||
749
+ error.message.includes("zlib") ||
750
+ error.message.includes("unexpected end")
751
+ ) {
752
+ console.error(
753
+ "\n💡 This appears to be a download corruption issue. Suggestions:",
754
+ );
755
+ console.error(" • Check your internet connection stability");
756
+ console.error(
757
+ " • Try running the command again (it will retry automatically)",
758
+ );
759
+ console.error(" • Clear the cache if the issue persists:");
760
+ console.error(` rm -rf "${SPARKBUN_DEP_PATH}"`);
761
+ } else if (
762
+ error.message.includes("HTTP 404") ||
763
+ error.message.includes("Not Found")
764
+ ) {
765
+ console.error("\n💡 The CEF release was not found. This could mean:");
766
+ console.error(
767
+ " • The version specified doesn't have CEF binaries available",
768
+ );
769
+ console.error(" • You're using a development/unreleased version");
770
+ console.error(" • Try using a stable version instead");
771
+ } else {
772
+ console.error(
773
+ "\nPlease ensure you have an internet connection and the release exists.",
774
+ );
775
+ console.error(
776
+ `If the problem persists, try clearing the cache: rm -rf "${SPARKBUN_DEP_PATH}"`,
777
+ );
778
+ }
779
+
780
+ process.exit(1);
781
+ }
782
+ }
783
+
784
+ async function ensureWGPUDependencies(
785
+ targetOS?: "macos" | "win" | "linux",
786
+ targetArch?: "arm64" | "x64",
787
+ wgpuVersion?: string,
788
+ ): Promise<string> {
789
+ const platformOS = targetOS || OS;
790
+ const platformArch = targetArch || ARCH;
791
+ const wgpuDir = getEffectiveWGPUDir(platformOS, platformArch);
792
+ const versionFile = join(wgpuDir, ".wgpu-version");
793
+
794
+ const normalizedVersion =
795
+ wgpuVersion && wgpuVersion.length > 0
796
+ ? wgpuVersion.startsWith("v")
797
+ ? wgpuVersion
798
+ : `v${wgpuVersion}`
799
+ : "latest";
800
+
801
+ if (existsSync(wgpuDir) && existsSync(versionFile)) {
802
+ const cachedVersion = readFileSync(versionFile, "utf8").trim();
803
+ if (cachedVersion === normalizedVersion) {
804
+ console.log(
805
+ `WGPU ${normalizedVersion} already cached for ${platformOS}-${platformArch} at ${wgpuDir}`,
806
+ );
807
+ return wgpuDir;
808
+ }
809
+ console.log(
810
+ `Cached WGPU version "${cachedVersion}" does not match requested "${normalizedVersion}", re-downloading...`,
811
+ );
812
+ rmSync(wgpuDir, { recursive: true, force: true });
813
+ } else if (existsSync(wgpuDir)) {
814
+ rmSync(wgpuDir, { recursive: true, force: true });
815
+ }
816
+
817
+ const platformName =
818
+ platformOS === "macos" ? "darwin" : platformOS === "win" ? "win32" : "linux";
819
+ const archName = platformArch;
820
+ const baseUrl =
821
+ normalizedVersion === "latest"
822
+ ? "https://github.com/blackboardsh/sparkbun-dawn/releases/latest/download"
823
+ : `https://github.com/blackboardsh/sparkbun-dawn/releases/download/${normalizedVersion}`;
824
+ const tarballUrl = `${baseUrl}/sparkbun-dawn-${platformName}-${archName}.tar.gz`;
825
+
826
+ async function downloadWithRetry(
827
+ url: string,
828
+ filePath: string,
829
+ maxRetries = 3,
830
+ ): Promise<void> {
831
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
832
+ try {
833
+ console.log(
834
+ `Downloading WGPU (attempt ${attempt}/${maxRetries}) from: ${url}`,
835
+ );
836
+ const response = await fetch(url);
837
+ if (!response.ok) {
838
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
839
+ }
840
+
841
+ const fileStream = createWriteStream(filePath);
842
+ if (response.body) {
843
+ const reader = response.body.getReader();
844
+ while (true) {
845
+ const { done, value } = await reader.read();
846
+ if (done) break;
847
+ fileStream.write(Buffer.from(value));
848
+ }
849
+ }
850
+
851
+ await new Promise((resolve, reject) => {
852
+ fileStream.end((err: Error | null | undefined) => {
853
+ if (err) reject(err);
854
+ else resolve(null);
855
+ });
856
+ });
857
+ return;
858
+ } catch (error) {
859
+ if (attempt === maxRetries) throw error;
860
+ console.log(`Download failed, retrying... (${attempt}/${maxRetries})`);
861
+ }
862
+ }
863
+ }
864
+
865
+ try {
866
+ console.log(
867
+ `WGPU dependencies not found for ${platformOS}-${platformArch}, downloading...`,
868
+ );
869
+ const tempFile = join(
870
+ SPARKBUN_DEP_PATH,
871
+ `wgpu-${platformOS}-${platformArch}-${Date.now()}.tar.gz`,
872
+ );
873
+ await downloadWithRetry(tarballUrl, tempFile);
874
+
875
+ if (!existsSync(tempFile)) {
876
+ throw new Error(`Downloaded file not found: ${tempFile}`);
877
+ }
878
+ const fileSize = require("fs").statSync(tempFile).size;
879
+ if (fileSize === 0) {
880
+ throw new Error(`Downloaded file is empty: ${tempFile}`);
881
+ }
882
+
883
+ const tempExtractDir = join(
884
+ SPARKBUN_DEP_PATH,
885
+ `wgpu-extract-${platformOS}-${platformArch}-${Date.now()}`,
886
+ );
887
+ mkdirSync(tempExtractDir, { recursive: true });
888
+
889
+ const tarBytes = await Bun.file(tempFile).arrayBuffer();
890
+ const archive = new Bun.Archive(tarBytes);
891
+ await archive.extract(tempExtractDir);
892
+
893
+ mkdirSync(wgpuDir, { recursive: true });
894
+ const extractedItems = readdirSync(tempExtractDir);
895
+
896
+ const moveAll = (fromDir: string) => {
897
+ for (const item of readdirSync(fromDir)) {
898
+ const src = join(fromDir, item);
899
+ const dest = join(wgpuDir, item);
900
+ if (existsSync(dest)) rmSync(dest, { recursive: true, force: true });
901
+ renameSync(src, dest);
902
+ }
903
+ };
904
+
905
+ if (extractedItems.length === 1) {
906
+ const firstItem = extractedItems[0];
907
+ if (!firstItem) {
908
+ throw new Error(`No extracted items found in ${tempExtractDir}`);
909
+ }
910
+ const single = join(tempExtractDir, firstItem);
911
+ const stat = require("fs").statSync(single);
912
+ if (stat.isDirectory()) {
913
+ moveAll(single);
914
+ } else {
915
+ moveAll(tempExtractDir);
916
+ }
917
+ } else {
918
+ moveAll(tempExtractDir);
919
+ }
920
+
921
+ writeFileSync(versionFile, normalizedVersion);
922
+
923
+ rmSync(tempExtractDir, { recursive: true, force: true });
924
+ unlinkSync(tempFile);
925
+
926
+ console.log(
927
+ `✓ WGPU dependencies for ${platformOS}-${platformArch} downloaded and cached successfully`,
928
+ );
929
+ return wgpuDir;
930
+ } catch (error: any) {
931
+ if (existsSync(wgpuDir)) {
932
+ try {
933
+ rmSync(wgpuDir, { recursive: true, force: true });
934
+ } catch {}
935
+ }
936
+ console.error(
937
+ `Failed to download WGPU dependencies for ${platformOS}-${platformArch}:`,
938
+ error.message,
939
+ );
940
+ console.error("Please ensure you have an internet connection and the release exists.");
941
+ process.exit(1);
942
+ }
943
+ }
944
+
945
+ /**
946
+ * Downloads CEF runtime files from Spotify CDN for a custom version override.
947
+ * Extracts the minimal distribution and restructures runtime files to the
948
+ * layout the CLI expects. No compilation is needed — process_helper ships in
949
+ * the core tarball and uses CEF's stable C API at runtime.
950
+ *
951
+ * The C API is designed for ABI stability within the same major version line.
952
+ * Across major versions, breaking changes are possible.
953
+ */
954
+ async function downloadAndExtractCustomCEF(
955
+ cefVersion: string,
956
+ platformOS: "macos" | "win" | "linux",
957
+ platformArch: "arm64" | "x64",
958
+ ) {
959
+ // Parse "CEF_VERSION+chromium-CHROMIUM_VERSION"
960
+ const match = cefVersion.match(/^(.+)\+chromium-(.+)$/);
961
+ if (!match) {
962
+ throw new Error(
963
+ `Invalid cefVersion format: "${cefVersion}". ` +
964
+ `Expected: "CEF_VERSION+chromium-CHROMIUM_VERSION" ` +
965
+ `(e.g. "144.0.11+ge135be2+chromium-144.0.7559.97")`,
966
+ );
967
+ }
968
+ const cefVer = match[1]!;
969
+ const chromiumVer = match[2]!;
970
+
971
+ // Map platform names to Spotify CDN naming
972
+ const cefPlatformMap: Record<string, string> = {
973
+ "macos-arm64": "macosarm64",
974
+ "macos-x64": "macosx64",
975
+ "win-x64": "windows64",
976
+ "win-arm64": "windowsarm64",
977
+ "linux-x64": "linux64",
978
+ "linux-arm64": "linuxarm64",
979
+ };
980
+ const cefPlatform = cefPlatformMap[`${platformOS}-${platformArch}`];
981
+ if (!cefPlatform) {
982
+ throw new Error(
983
+ `Unsupported platform/arch for custom CEF: ${platformOS}-${platformArch}`,
984
+ );
985
+ }
986
+
987
+ // URL-encode the + as %2B
988
+ const encodedCefVer = cefVer.replace(/\+/g, "%2B");
989
+ const cefUrl = `https://cef-builds.spotifycdn.com/cef_binary_${encodedCefVer}%2Bchromium-${chromiumVer}_${cefPlatform}_minimal.tar.bz2`;
990
+
991
+ console.log(`Using custom CEF version: ${cefVersion}`);
992
+ console.log(`Downloading from: ${cefUrl}`);
993
+
994
+ // Store custom CEF in .sparkbun-cache so it survives dist rebuilds and bun install
995
+ const cefDir = getEffectiveCEFDir(platformOS, platformArch, cefVersion);
996
+ console.log(`Caching custom CEF to ${cefDir}`);
997
+ mkdirSync(cefDir, { recursive: true });
998
+
999
+ // Download to temp file
1000
+ const tempFile = join(
1001
+ SPARKBUN_DEP_PATH,
1002
+ `cef-custom-${platformOS}-${platformArch}-${Date.now()}.tar.bz2`,
1003
+ );
1004
+
1005
+ try {
1006
+ console.log(`Downloading custom CEF...`);
1007
+ const response = await fetch(cefUrl);
1008
+ if (!response.ok) {
1009
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
1010
+ }
1011
+
1012
+ const contentLength = response.headers.get("content-length");
1013
+ const totalSize = contentLength ? parseInt(contentLength, 10) : 0;
1014
+ const fileStream = createWriteStream(tempFile);
1015
+ let downloadedSize = 0;
1016
+ let lastReportedPercent = -1;
1017
+
1018
+ if (response.body) {
1019
+ const reader = response.body.getReader();
1020
+ while (true) {
1021
+ const { done, value } = await reader.read();
1022
+ if (done) break;
1023
+
1024
+ const chunk = Buffer.from(value);
1025
+ fileStream.write(chunk);
1026
+ downloadedSize += chunk.length;
1027
+
1028
+ if (totalSize > 0) {
1029
+ const percent = Math.round((downloadedSize / totalSize) * 100);
1030
+ const percentTier = Math.floor(percent / 10) * 10;
1031
+ if (percentTier > lastReportedPercent && percentTier <= 100) {
1032
+ console.log(
1033
+ ` Progress: ${percentTier}% (${Math.round(downloadedSize / 1024 / 1024)}MB/${Math.round(totalSize / 1024 / 1024)}MB)`,
1034
+ );
1035
+ lastReportedPercent = percentTier;
1036
+ }
1037
+ }
1038
+ }
1039
+ }
1040
+
1041
+ await new Promise((resolve, reject) => {
1042
+ fileStream.end((error: any) => {
1043
+ if (error) reject(error);
1044
+ else resolve(void 0);
1045
+ });
1046
+ });
1047
+
1048
+ console.log(
1049
+ `Download completed (${Math.round(downloadedSize / 1024 / 1024)}MB), extracting...`,
1050
+ );
1051
+
1052
+ // Extract tar.bz2 using system tar (bz2 requires it)
1053
+ execSync(`tar -xjf "${tempFile}" --strip-components=1 -C "${cefDir}"`, {
1054
+ stdio: "inherit",
1055
+ });
1056
+
1057
+ // The Spotify distribution layout has runtime files in Release/ and Resources/
1058
+ // subdirectories, but the CLI expects them at the cef/ root. Copy them up.
1059
+ console.log("Copying CEF runtime files to expected locations...");
1060
+ const releaseDir = join(cefDir, "Release");
1061
+ const resourcesDir = join(cefDir, "Resources");
1062
+
1063
+ if (platformOS === "macos") {
1064
+ // macOS: copy the framework from Release/ to cef/ root
1065
+ const fwSrc = join(releaseDir, "Chromium Embedded Framework.framework");
1066
+ const fwDst = join(cefDir, "Chromium Embedded Framework.framework");
1067
+ if (existsSync(fwSrc) && !existsSync(fwDst)) {
1068
+ cpSync(fwSrc, fwDst, { recursive: true, dereference: true });
1069
+ }
1070
+ } else {
1071
+ // Windows and Linux: copy all files from Release/ and Resources/ to cef/ root
1072
+ if (existsSync(releaseDir)) {
1073
+ for (const entry of readdirSync(releaseDir)) {
1074
+ const src = join(releaseDir, entry);
1075
+ const dst = join(cefDir, entry);
1076
+ if (!existsSync(dst)) {
1077
+ cpSync(src, dst, { recursive: true, dereference: true });
1078
+ }
1079
+ }
1080
+ }
1081
+ if (existsSync(resourcesDir)) {
1082
+ for (const entry of readdirSync(resourcesDir)) {
1083
+ const src = join(resourcesDir, entry);
1084
+ const dst = join(cefDir, entry);
1085
+ if (!existsSync(dst)) {
1086
+ cpSync(src, dst, { recursive: true, dereference: true });
1087
+ }
1088
+ }
1089
+ }
1090
+ }
1091
+
1092
+ // Write version stamp
1093
+ writeFileSync(join(cefDir, ".cef-version"), cefVersion);
1094
+
1095
+ console.log(
1096
+ `Custom CEF ${cefVersion} for ${platformOS}-${platformArch} set up successfully`,
1097
+ );
1098
+ console.log(
1099
+ `Note: process_helper ships in the core tarball and uses CEF's stable C API.`,
1100
+ );
1101
+ console.log(
1102
+ `C API compatibility is expected within the same major version line.`,
1103
+ );
1104
+ } catch (error: any) {
1105
+ // Clean up on failure
1106
+ if (existsSync(cefDir)) {
1107
+ try {
1108
+ rmSync(cefDir, { recursive: true, force: true });
1109
+ } catch {}
1110
+ }
1111
+
1112
+ console.error(
1113
+ `Failed to set up custom CEF ${cefVersion} for ${platformOS}-${platformArch}:`,
1114
+ error.message,
1115
+ );
1116
+ console.error(
1117
+ `\nVerify the CEF version string and that it exists at: https://cef-builds.spotifycdn.com/`,
1118
+ );
1119
+ console.error(
1120
+ `Note: CEF's C API is ABI-stable within the same major version. ` +
1121
+ `Across major versions, breaking changes are possible.`,
1122
+ );
1123
+ process.exit(1);
1124
+ } finally {
1125
+ // Clean up temp file
1126
+ if (existsSync(tempFile)) {
1127
+ try {
1128
+ unlinkSync(tempFile);
1129
+ } catch {}
1130
+ }
1131
+ }
1132
+ }
1133
+
1134
+ // @ts-expect-error - reserved for future use
1135
+ const _commandDefaults = {
1136
+ init: {
1137
+ projectRoot,
1138
+ config: "sparkbun.config",
1139
+ },
1140
+ build: {
1141
+ projectRoot,
1142
+ config: "sparkbun.config",
1143
+ },
1144
+ dev: {
1145
+ projectRoot,
1146
+ config: "sparkbun.config",
1147
+ },
1148
+ };
1149
+
1150
+ type FileAssociation = {
1151
+ ext: string[];
1152
+ name: string;
1153
+ role?: "Editor" | "Viewer" | "Shell" | "None";
1154
+ icon?: string;
1155
+ };
1156
+
1157
+ // Default values merged with user's sparkbun.config.ts
1158
+ // For the user-facing type, see SparkBunConfig in src/bun/SparkBunConfig.ts
1159
+ const defaultConfig = {
1160
+ app: {
1161
+ name: "MyApp",
1162
+ identifier: "com.example.myapp",
1163
+ version: "0.1.0",
1164
+ description: "" as string | undefined,
1165
+ urlSchemes: undefined as string[] | undefined,
1166
+ fileAssociations: undefined as FileAssociation[] | undefined,
1167
+ },
1168
+ build: {
1169
+ buildFolder: "build",
1170
+ artifactFolder: "artifacts",
1171
+ mainProcess: "bun" as const,
1172
+ cefVersion: undefined as string | undefined, // Override CEF version: "CEF_VERSION+chromium-CHROMIUM_VERSION"
1173
+ wgpuVersion: undefined as string | undefined, // Override Dawn (WebGPU) version: "0.2.3" or "v0.2.3-beta.0"
1174
+ bunVersion: undefined as string | undefined, // Override Bun runtime version: "1.4.2"
1175
+ locales: undefined as string[] | "*" | undefined, // ICU locales subset (Linux/Windows)
1176
+ mac: {
1177
+ codesign: false,
1178
+ createDmg: true,
1179
+ notarize: false,
1180
+ bundleCEF: false,
1181
+ bundleWGPU: false,
1182
+ entitlements: {
1183
+ // This entitlement is required for SparkBun apps with a hardened runtime (required for notarization) to run on macos
1184
+ "com.apple.security.cs.allow-jit": true,
1185
+ // Required for bun runtime to work with dynamic code execution and JIT compilation when signed
1186
+ "com.apple.security.cs.allow-unsigned-executable-memory": true,
1187
+ "com.apple.security.cs.disable-library-validation": true,
1188
+ } as Record<string, boolean | string>,
1189
+ icons: "icon.iconset",
1190
+ defaultRenderer: undefined as "native" | "cef" | undefined,
1191
+ chromiumFlags: undefined as Record<string, string | boolean> | undefined,
1192
+ },
1193
+ win: {
1194
+ bundleCEF: false,
1195
+ bundleWGPU: false,
1196
+ icon: undefined as string | undefined,
1197
+ defaultRenderer: undefined as "native" | "cef" | undefined,
1198
+ chromiumFlags: undefined as Record<string, string | boolean> | undefined,
1199
+ },
1200
+ linux: {
1201
+ bundleCEF: false,
1202
+ bundleWGPU: false,
1203
+ icon: undefined as string | undefined,
1204
+ defaultRenderer: undefined as "native" | "cef" | undefined,
1205
+ chromiumFlags: undefined as Record<string, string | boolean> | undefined,
1206
+ },
1207
+ bun: {
1208
+ entrypoint: "src/bun/index.ts",
1209
+ },
1210
+ views: undefined as
1211
+ | Record<string, { entrypoint: string; [key: string]: unknown }>
1212
+ | undefined,
1213
+ copy: undefined as Record<string, string> | undefined,
1214
+ watch: undefined as string[] | undefined,
1215
+ watchIgnore: undefined as string[] | undefined,
1216
+ },
1217
+ runtime: {} as Record<string, unknown>,
1218
+ scripts: {
1219
+ preBuild: "",
1220
+ postBuild: "",
1221
+ postWrap: "",
1222
+ postPackage: "",
1223
+ },
1224
+ release: {
1225
+ baseUrl: "",
1226
+ generatePatch: true,
1227
+ },
1228
+ };
1229
+
1230
+ // Mapping of entitlements to their corresponding Info.plist usage description keys
1231
+ const ENTITLEMENT_TO_PLIST_KEY: Record<string, string> = {
1232
+ "com.apple.security.device.camera": "NSCameraUsageDescription",
1233
+ "com.apple.security.device.microphone": "NSMicrophoneUsageDescription",
1234
+ "com.apple.security.device.audio-input": "NSMicrophoneUsageDescription",
1235
+ "com.apple.security.personal-information.location":
1236
+ "NSLocationUsageDescription",
1237
+ "com.apple.security.personal-information.location-when-in-use":
1238
+ "NSLocationWhenInUseUsageDescription",
1239
+ "com.apple.security.personal-information.contacts":
1240
+ "NSContactsUsageDescription",
1241
+ "com.apple.security.personal-information.calendars":
1242
+ "NSCalendarsUsageDescription",
1243
+ "com.apple.security.personal-information.reminders":
1244
+ "NSRemindersUsageDescription",
1245
+ "com.apple.security.personal-information.photos-library":
1246
+ "NSPhotoLibraryUsageDescription",
1247
+ "com.apple.security.personal-information.apple-music-library":
1248
+ "NSAppleMusicUsageDescription",
1249
+ "com.apple.security.personal-information.motion": "NSMotionUsageDescription",
1250
+ "com.apple.security.personal-information.speech-recognition":
1251
+ "NSSpeechRecognitionUsageDescription",
1252
+ "com.apple.security.device.bluetooth": "NSBluetoothAlwaysUsageDescription",
1253
+ "com.apple.security.files.user-selected.read-write":
1254
+ "NSDocumentsFolderUsageDescription",
1255
+ "com.apple.security.files.downloads.read-write":
1256
+ "NSDownloadsFolderUsageDescription",
1257
+ "com.apple.security.files.desktop.read-write":
1258
+ "NSDesktopFolderUsageDescription",
1259
+ };
1260
+
1261
+ // Helper function to escape XML special characters
1262
+ function escapeXml(str: string): string {
1263
+ return str
1264
+ .replace(/&/g, "&amp;")
1265
+ .replace(/</g, "&lt;")
1266
+ .replace(/>/g, "&gt;")
1267
+ .replace(/"/g, "&quot;")
1268
+ .replace(/'/g, "&apos;");
1269
+ }
1270
+
1271
+ function patchPeSubsystem(exePath: string): void {
1272
+ const buf = Buffer.from(readFileSync(exePath));
1273
+ const peOffset = buf.readUInt32LE(0x3c);
1274
+ const subsystemOffset = peOffset + 0x5c;
1275
+ const current = buf.readUInt16LE(subsystemOffset);
1276
+ if (current !== 2) {
1277
+ buf.writeUInt16LE(2, subsystemOffset);
1278
+ writeFileSync(exePath, buf);
1279
+ console.log(`Patched PE subsystem: ${current} -> 2 (WINDOWS)`);
1280
+ }
1281
+ }
1282
+
1283
+ // Helper functions
1284
+ function escapePathForTerminal(path: string): string {
1285
+ if (OS === "win") {
1286
+ return `"${path.replace(/"/g, '""')}"`;
1287
+ } else {
1288
+ return `'${path.replace(/'/g, "'\\''")}'`;
1289
+ }
1290
+ }
1291
+
1292
+ /**
1293
+ * Creates a Linux installer tar.gz containing:
1294
+ * - Self-extracting installer executable (with embedded app archive)
1295
+ * - README.txt with instructions
1296
+ *
1297
+ * This replaces the AppImage-based installer to avoid libfuse2 dependency.
1298
+ * The installer executable has the compressed app archive embedded within it
1299
+ * using magic markers, similar to how Windows installers work.
1300
+ */
1301
+ // Helper function to generate usage description entries for Info.plist
1302
+ function generateUsageDescriptions(
1303
+ entitlements: Record<string, boolean | string | string[]>,
1304
+ ): string {
1305
+ const usageEntries: string[] = [];
1306
+
1307
+ for (const [entitlement, value] of Object.entries(entitlements)) {
1308
+ const plistKey = ENTITLEMENT_TO_PLIST_KEY[entitlement];
1309
+ if (plistKey && value) {
1310
+ // Use the string value as description, or a default if it's just true
1311
+ const description =
1312
+ typeof value === "string"
1313
+ ? escapeXml(value)
1314
+ : `This app requires access for ${entitlement.split(".").pop()?.replace("-", " ")}`;
1315
+
1316
+ usageEntries.push(
1317
+ ` <key>${plistKey}</key>\n <string>${description}</string>`,
1318
+ );
1319
+ }
1320
+ }
1321
+
1322
+ return usageEntries.join("\n");
1323
+ }
1324
+
1325
+ // Helper function to generate CFBundleURLTypes for custom URL schemes
1326
+ function generateURLTypes(
1327
+ urlSchemes: string[] | undefined,
1328
+ identifier: string,
1329
+ ): string {
1330
+ if (!urlSchemes || urlSchemes.length === 0) {
1331
+ return "";
1332
+ }
1333
+
1334
+ const schemesXml = urlSchemes
1335
+ .map((scheme) => ` <string>${escapeXml(scheme)}</string>`)
1336
+ .join("\n");
1337
+
1338
+ return ` <key>CFBundleURLTypes</key>
1339
+ <array>
1340
+ <dict>
1341
+ <key>CFBundleURLName</key>
1342
+ <string>${escapeXml(identifier)}</string>
1343
+ <key>CFBundleTypeRole</key>
1344
+ <string>Viewer</string>
1345
+ <key>CFBundleURLSchemes</key>
1346
+ <array>
1347
+ ${schemesXml}
1348
+ </array>
1349
+ </dict>
1350
+ </array>`;
1351
+ }
1352
+
1353
+ // Generates CFBundleDocumentTypes and UTExportedTypeDeclarations for file associations.
1354
+ // Each association gets a UTI derived from the app identifier (e.g., com.example.app.myext).
1355
+ // LSItemContentTypes in CFBundleDocumentTypes references these UTIs so Launch Services
1356
+ // properly associates files with the app on modern macOS.
1357
+ function generateDocumentTypes(
1358
+ fileAssociations: FileAssociation[] | undefined,
1359
+ projectRoot: string,
1360
+ appIdentifier: string,
1361
+ ): string {
1362
+ if (!fileAssociations || fileAssociations.length === 0) {
1363
+ return "";
1364
+ }
1365
+
1366
+ const validAssociations = fileAssociations.filter((assoc) => {
1367
+ if (!assoc.ext || assoc.ext.length === 0) {
1368
+ console.log(
1369
+ `WARNING: fileAssociations entry "${assoc.name || "(unnamed)"}" has no extensions — skipping`,
1370
+ );
1371
+ return false;
1372
+ }
1373
+ if (!assoc.name) {
1374
+ console.log(
1375
+ `WARNING: fileAssociations entry with extensions [${assoc.ext.join(", ")}] has no name — skipping`,
1376
+ );
1377
+ return false;
1378
+ }
1379
+ return true;
1380
+ });
1381
+
1382
+ if (validAssociations.length === 0) {
1383
+ return "";
1384
+ }
1385
+
1386
+ // Clean extensions and warn about leading dots
1387
+ const cleaned = validAssociations.map((assoc) => ({
1388
+ ...assoc,
1389
+ ext: assoc.ext.map((ext) => {
1390
+ const clean = ext.replace(/^\./, "");
1391
+ if (clean !== ext) {
1392
+ console.log(
1393
+ `WARNING: fileAssociations ext "${ext}" has a leading dot — stripping to "${clean}"`,
1394
+ );
1395
+ }
1396
+ return clean;
1397
+ }),
1398
+ }));
1399
+
1400
+ // Generate CFBundleDocumentTypes with LSItemContentTypes
1401
+ const docTypes = cleaned
1402
+ .map((assoc) => {
1403
+ const role = assoc.role || "Viewer";
1404
+ // Resolve icon: only reference if file exists to avoid dangling plist entries
1405
+ let iconName = "";
1406
+ if (assoc.icon) {
1407
+ const iconSourcePath = join(projectRoot, assoc.icon);
1408
+ if (existsSync(iconSourcePath)) {
1409
+ iconName = basename(assoc.icon).replace(/\.icns$/i, "");
1410
+ } else {
1411
+ console.log(
1412
+ `WARNING: Document type icon not found: ${iconSourcePath} — skipping icon reference`,
1413
+ );
1414
+ }
1415
+ }
1416
+ const iconLine = iconName
1417
+ ? ` <key>CFBundleTypeIconFile</key>\n <string>${escapeXml(iconName)}</string>\n`
1418
+ : "";
1419
+ // One UTI per extension, all listed under LSItemContentTypes
1420
+ const utiXml = assoc.ext
1421
+ .map(
1422
+ (ext) =>
1423
+ ` <string>${escapeXml(appIdentifier)}.${escapeXml(ext)}</string>`,
1424
+ )
1425
+ .join("\n");
1426
+ const extsXml = assoc.ext
1427
+ .map(
1428
+ (ext) =>
1429
+ ` <string>${escapeXml(ext)}</string>`,
1430
+ )
1431
+ .join("\n");
1432
+
1433
+ return ` <dict>
1434
+ <key>CFBundleTypeName</key>
1435
+ <string>${escapeXml(assoc.name)}</string>
1436
+ <key>CFBundleTypeRole</key>
1437
+ <string>${escapeXml(role)}</string>
1438
+ ${iconLine} <key>LSItemContentTypes</key>
1439
+ <array>
1440
+ ${utiXml}
1441
+ </array>
1442
+ <key>CFBundleTypeExtensions</key>
1443
+ <array>
1444
+ ${extsXml}
1445
+ </array>
1446
+ </dict>`;
1447
+ })
1448
+ .join("\n");
1449
+
1450
+ // Generate UTExportedTypeDeclarations — one per extension
1451
+ const utiDecls = cleaned
1452
+ .flatMap((assoc) => {
1453
+ let iconName = "";
1454
+ if (assoc.icon) {
1455
+ const iconSourcePath = join(projectRoot, assoc.icon);
1456
+ if (existsSync(iconSourcePath)) {
1457
+ iconName = basename(assoc.icon).replace(/\.icns$/i, "");
1458
+ }
1459
+ }
1460
+ const iconLine = iconName
1461
+ ? ` <key>UTTypeIconFiles</key>
1462
+ <array>
1463
+ <string>${escapeXml(iconName)}</string>
1464
+ </array>\n`
1465
+ : "";
1466
+ return assoc.ext.map(
1467
+ (ext) => ` <dict>
1468
+ <key>UTTypeIdentifier</key>
1469
+ <string>${escapeXml(appIdentifier)}.${escapeXml(ext)}</string>
1470
+ <key>UTTypeDescription</key>
1471
+ <string>${escapeXml(assoc.name)}</string>
1472
+ <key>UTTypeConformsTo</key>
1473
+ <array>
1474
+ <string>public.data</string>
1475
+ </array>
1476
+ ${iconLine} <key>UTTypeTagSpecification</key>
1477
+ <dict>
1478
+ <key>public.filename-extension</key>
1479
+ <array>
1480
+ <string>${escapeXml(ext)}</string>
1481
+ </array>
1482
+ </dict>
1483
+ </dict>`,
1484
+ );
1485
+ })
1486
+ .join("\n");
1487
+
1488
+ return ` <key>CFBundleDocumentTypes</key>
1489
+ <array>
1490
+ ${docTypes}
1491
+ </array>
1492
+ <key>UTExportedTypeDeclarations</key>
1493
+ <array>
1494
+ ${utiDecls}
1495
+ </array>`;
1496
+ }
1497
+
1498
+ // Execute command handling
1499
+ (async () => {
1500
+ if (commandArg === "init") {
1501
+ await (async () => {
1502
+ const secondArg = process.argv[indexOfCli + 2];
1503
+ const availableTemplates = getTemplateNames();
1504
+
1505
+ let projectName: string;
1506
+ let templateName: string;
1507
+
1508
+ // Check if --template= flag is used
1509
+ const templateFlag = process.argv.find((arg) =>
1510
+ arg.startsWith("--template="),
1511
+ );
1512
+ if (templateFlag) {
1513
+ // Traditional usage: sparkbun init my-project --template=photo-booth
1514
+ projectName = secondArg || "my-sparkbun-app";
1515
+ templateName = templateFlag.split("=")[1]!;
1516
+ } else if (secondArg && availableTemplates.includes(secondArg)) {
1517
+ // New intuitive usage: sparkbun init photo-booth
1518
+ projectName = secondArg; // Use template name as project name
1519
+ templateName = secondArg;
1520
+ } else {
1521
+ // Interactive menu when no template specified
1522
+ console.log("🚀 Welcome to SparkBun!");
1523
+ console.log("");
1524
+ console.log("Available templates:");
1525
+ availableTemplates.forEach((template, index) => {
1526
+ console.log(` ${index + 1}. ${template}`);
1527
+ });
1528
+ console.log("");
1529
+
1530
+ // Simple CLI selection using readline
1531
+ const rl = readline.createInterface({
1532
+ input: process.stdin,
1533
+ output: process.stdout,
1534
+ });
1535
+
1536
+ const choice = await new Promise<string>((resolve) => {
1537
+ rl.question("Select a template (enter number): ", (answer) => {
1538
+ rl.close();
1539
+ resolve(answer.trim());
1540
+ });
1541
+ });
1542
+
1543
+ const templateIndex = parseInt(choice) - 1;
1544
+ if (templateIndex < 0 || templateIndex >= availableTemplates.length) {
1545
+ console.error(
1546
+ `❌ Invalid selection. Please enter a number between 1 and ${availableTemplates.length}.`,
1547
+ );
1548
+ process.exit(1);
1549
+ }
1550
+
1551
+ templateName = availableTemplates[templateIndex]!;
1552
+
1553
+ // Ask for project name
1554
+ const rl2 = readline.createInterface({
1555
+ input: process.stdin,
1556
+ output: process.stdout,
1557
+ });
1558
+
1559
+ projectName = await new Promise<string>((resolve) => {
1560
+ rl2.question(
1561
+ `Enter project name (default: my-${templateName}-app): `,
1562
+ (answer) => {
1563
+ rl2.close();
1564
+ resolve(answer.trim() || `my-${templateName}-app`);
1565
+ },
1566
+ );
1567
+ });
1568
+ }
1569
+
1570
+ console.log(`🚀 Initializing SparkBun project: ${projectName}`);
1571
+ console.log(`📋 Using template: ${templateName}`);
1572
+
1573
+ // Validate template name
1574
+ if (!availableTemplates.includes(templateName)) {
1575
+ console.error(`❌ Template "${templateName}" not found.`);
1576
+ console.log(`Available templates: ${availableTemplates.join(", ")}`);
1577
+ process.exit(1);
1578
+ }
1579
+
1580
+ const template = getTemplate(templateName);
1581
+ if (!template) {
1582
+ console.error(`❌ Could not load template "${templateName}"`);
1583
+ process.exit(1);
1584
+ }
1585
+
1586
+ // Create project directory
1587
+ const projectPath = join(process.cwd(), projectName);
1588
+ if (existsSync(projectPath)) {
1589
+ console.error(`❌ Directory "${projectName}" already exists.`);
1590
+ process.exit(1);
1591
+ }
1592
+
1593
+ mkdirSync(projectPath, { recursive: true });
1594
+
1595
+ // Extract template files
1596
+ let fileCount = 0;
1597
+ for (const [relativePath, content] of Object.entries(template.files)) {
1598
+ const fullPath = join(projectPath, relativePath);
1599
+ const dir = dirname(fullPath);
1600
+
1601
+ // Create directory if it doesn't exist
1602
+ mkdirSync(dir, { recursive: true });
1603
+
1604
+ // Write file (binary files are base64-encoded with a "base64:" prefix)
1605
+ if (content.startsWith("base64:")) {
1606
+ writeFileSync(fullPath, new Uint8Array(Buffer.from(content.slice(7), "base64")));
1607
+ } else {
1608
+ writeFileSync(fullPath, content, "utf-8");
1609
+ }
1610
+ fileCount++;
1611
+ }
1612
+
1613
+ console.log(
1614
+ `✅ Created ${fileCount} files from "${templateName}" template`,
1615
+ );
1616
+ console.log(`📁 Project created at: ${projectPath}`);
1617
+ console.log("");
1618
+ console.log("📦 Next steps:");
1619
+ console.log(` cd ${projectName}`);
1620
+ console.log(" bun install");
1621
+ console.log(" bun start");
1622
+ console.log("");
1623
+ console.log("🎉 Happy building with SparkBun!");
1624
+ console.log("");
1625
+ console.log(
1626
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
1627
+ );
1628
+ console.log("NOTE FOR AI ASSISTANTS: SparkBun is NOT Electron.");
1629
+ console.log(
1630
+ "Different architecture, different APIs. Do not use Electron patterns.",
1631
+ );
1632
+ console.log("SparkBun — Bun-native desktop app framework");
1633
+ console.log(
1634
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
1635
+ );
1636
+ })();
1637
+ } else if (commandArg === "build") {
1638
+ // Get config
1639
+ const config = await getConfig();
1640
+
1641
+ // Get environment
1642
+ const envArg =
1643
+ process.argv.find((arg) => arg.startsWith("--env="))?.split("=")[1] || "";
1644
+ const buildEnvironment: "dev" | "canary" | "stable" = ["dev", "canary", "stable"].includes(
1645
+ envArg,
1646
+ )
1647
+ ? (envArg as "dev" | "canary" | "stable")
1648
+ : "dev";
1649
+
1650
+ try {
1651
+ await runBuild(config, buildEnvironment);
1652
+ } catch (error) {
1653
+ console.error("Build failed:", error);
1654
+ process.exit(1);
1655
+ }
1656
+ } else if (commandArg === "run") {
1657
+ const config = await getConfig();
1658
+ await runAppWithSignalHandling(config);
1659
+ } else if (commandArg === "dev") {
1660
+ const config = await getConfig();
1661
+ const watchMode = process.argv.includes("--watch");
1662
+
1663
+ if (watchMode) {
1664
+ await runDevWatch(config);
1665
+ } else {
1666
+ try {
1667
+ await runBuild(config, "dev");
1668
+ } catch (error) {
1669
+ console.error("Build failed:", error);
1670
+ process.exit(1);
1671
+ }
1672
+ await runAppWithSignalHandling(config);
1673
+ }
1674
+ }
1675
+
1676
+ async function runBuild(
1677
+ config: Awaited<ReturnType<typeof getConfig>>,
1678
+ buildEnvironment: "dev" | "canary" | "stable",
1679
+ ) {
1680
+ // Determine target platform — allow override via --target=linux-arm64 etc.
1681
+ const targetArg = process.argv.find((arg) => arg.startsWith("--target="))?.split("=")[1];
1682
+ let currentTarget: { os: "macos" | "win" | "linux"; arch: "arm64" | "x64" } = { os: OS, arch: ARCH };
1683
+ if (targetArg) {
1684
+ const [tOS, tArch] = targetArg.split("-") as [string, string];
1685
+ const osMap: Record<string, "macos" | "win" | "linux"> = { macos: "macos", darwin: "macos", win: "win", windows: "win", linux: "linux" };
1686
+ const archMap: Record<string, "arm64" | "x64"> = { arm64: "arm64", aarch64: "arm64", x64: "x64", x86_64: "x64" };
1687
+ if (osMap[tOS] && archMap[tArch]) {
1688
+ currentTarget = { os: osMap[tOS], arch: archMap[tArch] };
1689
+ console.log(`Cross-compiling for ${currentTarget.os}-${currentTarget.arch}`);
1690
+ } else {
1691
+ console.error(`Invalid target: ${targetArg}. Use format: linux-arm64, win-x64, macos-arm64`);
1692
+ process.exit(1);
1693
+ }
1694
+ }
1695
+
1696
+ // Set up build variables
1697
+ const targetOS = currentTarget.os;
1698
+ const targetARCH = currentTarget.arch;
1699
+ const targetBinExt = targetOS === "win" ? ".exe" : "";
1700
+ const appFileName = getAppFileName(config.app.name, buildEnvironment);
1701
+ // macOS bundle display name preserves spaces for the actual .app folder
1702
+ const macOSBundleDisplayName = getMacOSBundleDisplayName(
1703
+ config.app.name,
1704
+ buildEnvironment,
1705
+ );
1706
+ const platformPrefix = getPlatformPrefix(
1707
+ buildEnvironment,
1708
+ currentTarget.os,
1709
+ currentTarget.arch,
1710
+ );
1711
+ const buildFolder = join(
1712
+ projectRoot,
1713
+ config.build.buildFolder,
1714
+ platformPrefix,
1715
+ );
1716
+ // @ts-expect-error - reserved for future use
1717
+ const _bundleFileName = getBundleFileName(
1718
+ config.app.name,
1719
+ buildEnvironment,
1720
+ targetOS,
1721
+ );
1722
+ const artifactFolder = join(projectRoot, config.build.artifactFolder);
1723
+
1724
+ // Ensure core binaries are available for the target platform before starting build
1725
+ await ensureCoreDependencies(currentTarget.os, currentTarget.arch);
1726
+
1727
+ // Get platform-specific paths for the current target
1728
+ const targetPaths = getPlatformPaths(currentTarget.os, currentTarget.arch);
1729
+
1730
+ // Helper to run lifecycle hook scripts
1731
+ const runHook = (
1732
+ hookName: keyof typeof config.scripts,
1733
+ extraEnv: Record<string, string> = {},
1734
+ ) => {
1735
+ const hookScript = config.scripts[hookName];
1736
+ if (!hookScript) return;
1737
+
1738
+ console.log(`Running ${String(hookName)} script:`, hookScript);
1739
+ // Use host platform's bun binary for running scripts, not target platform's
1740
+ const hostPaths = getPlatformPaths(OS, ARCH);
1741
+
1742
+ const result = Bun.spawnSync([hostPaths.BUN_BINARY, hookScript], {
1743
+ stdio: ["ignore", "inherit", "inherit"],
1744
+ cwd: projectRoot,
1745
+ env: {
1746
+ ...process.env,
1747
+ SPARKBUN_BUILD_ENV: buildEnvironment,
1748
+ SPARKBUN_OS: targetOS,
1749
+ SPARKBUN_ARCH: targetARCH,
1750
+ SPARKBUN_BUILD_DIR: buildFolder,
1751
+ SPARKBUN_APP_NAME: appFileName,
1752
+ SPARKBUN_APP_VERSION: config.app.version,
1753
+ SPARKBUN_APP_IDENTIFIER: config.app.identifier,
1754
+ SPARKBUN_ARTIFACT_DIR: artifactFolder,
1755
+ ...extraEnv,
1756
+ },
1757
+ });
1758
+
1759
+ if (result.exitCode !== 0) {
1760
+ console.error(
1761
+ `${String(hookName)} script failed with exit code:`,
1762
+ result.exitCode,
1763
+ );
1764
+ if (result.stderr) {
1765
+ console.error(
1766
+ "stderr:",
1767
+ new TextDecoder().decode(result.stderr as Uint8Array),
1768
+ );
1769
+ }
1770
+ console.error("Tried to run with bun at:", hostPaths.BUN_BINARY);
1771
+ console.error("Script path:", hookScript);
1772
+ console.error("Working directory:", projectRoot);
1773
+ throw new Error("Build failed: hook script failed");
1774
+ }
1775
+ };
1776
+
1777
+ const buildIcons = (
1778
+ appBundleFolderResourcesPath: string,
1779
+ appBundleFolderPath: string,
1780
+ ) => {
1781
+ // Platform-specific icon handling
1782
+ if (targetOS === "macos" && config.build.mac?.icons) {
1783
+ // macOS uses .iconset folders that get converted to .icns using iconutil
1784
+ // This only works when building on macOS since iconutil is a macOS-only tool
1785
+ const iconSourceFolder = join(projectRoot, config.build.mac.icons);
1786
+ const iconDestPath = join(appBundleFolderResourcesPath, "AppIcon.icns");
1787
+ if (existsSync(iconSourceFolder)) {
1788
+ if (OS === "macos") {
1789
+ if (config.build.mac.icons.endsWith(".icon")) {
1790
+ // .icon format (Icon Composer) — compile with actool
1791
+ // Produces Assets.car (Liquid Glass on macOS 26+) and .icns fallback
1792
+ const actoolCheck = Bun.spawnSync(
1793
+ ["xcrun", "--find", "actool"],
1794
+ { stdio: ["ignore", "pipe", "pipe"] },
1795
+ );
1796
+ if (actoolCheck.exitCode !== 0) {
1797
+ throw new Error(
1798
+ "Building .icon files requires Xcode (actool is not available from Command Line Tools alone). " +
1799
+ "Install Xcode from the App Store, or set mac.icons to an .iconset folder instead.",
1800
+ );
1801
+ }
1802
+
1803
+ const iconStem = basename(config.build.mac.icons, ".icon");
1804
+ const partialPlistPath = join(
1805
+ buildFolder,
1806
+ ".actool-partial-info.plist",
1807
+ );
1808
+
1809
+ console.log(
1810
+ "Compiling .icon file with actool (requires Xcode)...",
1811
+ );
1812
+ const result = Bun.spawnSync(
1813
+ [
1814
+ "xcrun",
1815
+ "actool",
1816
+ "--compile",
1817
+ appBundleFolderResourcesPath,
1818
+ "--app-icon",
1819
+ iconStem,
1820
+ "--platform",
1821
+ "macosx",
1822
+ "--minimum-deployment-target",
1823
+ "11.0",
1824
+ "--output-partial-info-plist",
1825
+ partialPlistPath,
1826
+ iconSourceFolder,
1827
+ ],
1828
+ {
1829
+ cwd: projectRoot,
1830
+ stdio: ["ignore", "inherit", "inherit"],
1831
+ env: {
1832
+ ...process.env,
1833
+ SPARKBUN_BUILD_ENV: buildEnvironment,
1834
+ },
1835
+ },
1836
+ );
1837
+
1838
+ if (result.exitCode !== 0) {
1839
+ throw new Error(
1840
+ `actool failed to compile ${config.build.mac.icons} (exit code ${result.exitCode})`,
1841
+ );
1842
+ }
1843
+
1844
+ // actool produces <stem>.icns — rename to AppIcon.icns so
1845
+ // CFBundleIconFile ("AppIcon") resolves correctly
1846
+ const actoolIcns = join(
1847
+ appBundleFolderResourcesPath,
1848
+ `${iconStem}.icns`,
1849
+ );
1850
+ if (existsSync(actoolIcns) && actoolIcns !== iconDestPath) {
1851
+ renameSync(actoolIcns, iconDestPath);
1852
+ }
1853
+ } else {
1854
+ // Use iconutil to convert .iconset folder to .icns
1855
+ const result = Bun.spawnSync(
1856
+ [
1857
+ "iconutil",
1858
+ "-c",
1859
+ "icns",
1860
+ "-o",
1861
+ iconDestPath,
1862
+ iconSourceFolder,
1863
+ ],
1864
+ {
1865
+ cwd: appBundleFolderResourcesPath,
1866
+ stdio: ["ignore", "inherit", "inherit"],
1867
+ env: {
1868
+ ...process.env,
1869
+ SPARKBUN_BUILD_ENV: buildEnvironment,
1870
+ },
1871
+ },
1872
+ );
1873
+
1874
+ if (result.exitCode !== 0) {
1875
+ throw new Error(
1876
+ `iconutil failed to convert ${config.build.mac.icons} (exit code ${result.exitCode})`,
1877
+ );
1878
+ }
1879
+ }
1880
+ } else {
1881
+ console.log(
1882
+ `WARNING: Cannot build macOS icons on ${OS} - iconutil is only available on macOS`,
1883
+ );
1884
+ }
1885
+ }
1886
+ } else if (targetOS === "linux" && config.build.linux?.icon) {
1887
+ const iconSourcePath = join(projectRoot, config.build.linux.icon);
1888
+ if (existsSync(iconSourcePath)) {
1889
+ const standardIconPath = join(
1890
+ appBundleFolderResourcesPath,
1891
+ "appIcon.png",
1892
+ );
1893
+
1894
+ // Ensure Resources directory exists
1895
+ mkdirSync(appBundleFolderResourcesPath, { recursive: true });
1896
+
1897
+ // Copy the icon to standard location
1898
+ cpSync(iconSourcePath, standardIconPath, { dereference: true });
1899
+ console.log(
1900
+ `Copied Linux icon from ${iconSourcePath} to ${standardIconPath}`,
1901
+ );
1902
+
1903
+ // Also copy icon for the extractor
1904
+ const extractorIconPath = join(
1905
+ appBundleFolderResourcesPath,
1906
+ "app",
1907
+ "icon.png",
1908
+ );
1909
+ mkdirSync(join(appBundleFolderResourcesPath, "app"), {
1910
+ recursive: true,
1911
+ });
1912
+ cpSync(iconSourcePath, extractorIconPath, { dereference: true });
1913
+ console.log(
1914
+ `Copied Linux icon for extractor from ${iconSourcePath} to ${extractorIconPath}`,
1915
+ );
1916
+ } else {
1917
+ console.log(`WARNING: Linux icon not found: ${iconSourcePath}`);
1918
+ }
1919
+
1920
+ // Create desktop file template for Linux
1921
+ const linuxRequireAdmin = config.build?.linux?.requireAdmin;
1922
+ const desktopContent = `[Desktop Entry]
1923
+ Version=1.0
1924
+ Type=Application
1925
+ Name=${config.app.name}
1926
+ Comment=${config.app.description || `${config.app.name} application`}
1927
+ Exec=${linuxRequireAdmin ? "pkexec launcher" : "launcher"}
1928
+ Icon=appIcon.png
1929
+ Terminal=false
1930
+ StartupWMClass=${config.app.name}
1931
+ Categories=Utility;
1932
+ `;
1933
+
1934
+ const desktopFilePath = join(
1935
+ appBundleFolderPath,
1936
+ `${config.app.name}.desktop`,
1937
+ );
1938
+ writeFileSync(desktopFilePath, desktopContent);
1939
+ console.log(`Created Linux desktop file: ${desktopFilePath}`);
1940
+ } else if (targetOS === "win" && config.build.win?.icon) {
1941
+ const iconPath = join(projectRoot, config.build.win.icon);
1942
+ if (existsSync(iconPath)) {
1943
+ const targetIconPath = join(appBundleFolderResourcesPath, "app.ico");
1944
+ cpSync(iconPath, targetIconPath, { dereference: true });
1945
+ }
1946
+ }
1947
+
1948
+ // Copy document type icon files to the app bundle Resources folder
1949
+ if (targetOS === "macos" && config.app.fileAssociations) {
1950
+ for (const assoc of config.app.fileAssociations) {
1951
+ if (assoc.icon) {
1952
+ const iconSourcePath = join(projectRoot, assoc.icon);
1953
+ if (existsSync(iconSourcePath)) {
1954
+ const iconFileName = basename(iconSourcePath);
1955
+ const iconDestPath = join(
1956
+ appBundleFolderResourcesPath,
1957
+ iconFileName,
1958
+ );
1959
+ cpSync(iconSourcePath, iconDestPath, {
1960
+ dereference: true,
1961
+ });
1962
+ }
1963
+ // Missing icon warning is handled by generateDocumentTypes
1964
+ }
1965
+ }
1966
+ }
1967
+ };
1968
+
1969
+ // Run preBuild hook before anything starts
1970
+ runHook("preBuild");
1971
+
1972
+ // refresh build folder
1973
+ if (existsSync(buildFolder)) {
1974
+ rmSync(buildFolder, { recursive: true, force: true });
1975
+ }
1976
+ mkdirSync(buildFolder, { recursive: true });
1977
+
1978
+ const mainProcess = "bun";
1979
+ const bunConfig = config.build.bun;
1980
+ const bunSource = join(projectRoot, bunConfig.entrypoint);
1981
+
1982
+ if (!existsSync(bunSource)) {
1983
+ throw new Error(
1984
+ `failed to bundle ${bunSource} because it doesn't exist.\n You need a config.build.bun.entrypoint source file to build.`,
1985
+ );
1986
+ }
1987
+
1988
+ // build macos bundle
1989
+ // Use display name (with spaces) for macOS bundle folders, sanitized name for other platforms
1990
+ let appBundleFolderPath: string;
1991
+ let appBundleFolderContentsPath: string;
1992
+ let appBundleMacOSPath: string;
1993
+ let appBundleFolderResourcesPath: string;
1994
+ let appBundleFolderFrameworksPath: string;
1995
+ let appBundleAppCodePath: string;
1996
+ const bundleName =
1997
+ targetOS === "macos" ? macOSBundleDisplayName : appFileName;
1998
+
1999
+ const bundle = createAppBundle(bundleName, buildFolder, targetOS);
2000
+ appBundleFolderPath = bundle.appBundleFolderPath;
2001
+ appBundleFolderContentsPath = bundle.appBundleFolderContentsPath;
2002
+ appBundleMacOSPath = bundle.appBundleMacOSPath;
2003
+ appBundleFolderResourcesPath = bundle.appBundleFolderResourcesPath;
2004
+ appBundleFolderFrameworksPath = bundle.appBundleFolderFrameworksPath;
2005
+ appBundleAppCodePath = join(appBundleFolderResourcesPath, "app");
2006
+ mkdirSync(appBundleAppCodePath, { recursive: true });
2007
+
2008
+
2009
+ // const bundledBunPath = join(appBundleMacOSPath, 'bun');
2010
+ // cpSync(bunPath, bundledBunPath);
2011
+
2012
+ // Note: for sandboxed apps, MacOS will use the CFBundleIdentifier to create a unique container for the app,
2013
+ // mirroring folders like Application Support, Caches, etc. in the user's Library folder that the sandboxed app
2014
+ // gets access to.
2015
+
2016
+ // We likely want to let users configure this for different environments (eg: dev, canary, stable) and/or
2017
+ // provide methods to help segment data in those folders based on channel/environment
2018
+
2019
+ let InfoPlistContents = "";
2020
+
2021
+ // Generate usage descriptions from entitlements
2022
+ const usageDescriptions = generateUsageDescriptions(
2023
+ config.build.mac.entitlements || {},
2024
+ );
2025
+ // Generate URL scheme handlers
2026
+ const urlTypes = generateURLTypes(
2027
+ config.app.urlSchemes,
2028
+ config.app.identifier,
2029
+ );
2030
+ // Generate document type associations
2031
+ const documentTypes = generateDocumentTypes(
2032
+ config.app.fileAssociations,
2033
+ projectRoot,
2034
+ config.app.identifier,
2035
+ );
2036
+
2037
+ // When using .icon format, CFBundleIconName is needed for Assets.car lookup
2038
+ const iconName = config.build.mac?.icons?.endsWith(".icon")
2039
+ ? basename(config.build.mac.icons, ".icon")
2040
+ : null;
2041
+
2042
+ InfoPlistContents = `<?xml version="1.0" encoding="UTF-8"?>
2043
+
2044
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2045
+ <plist version="1.0">
2046
+ <dict>
2047
+ <key>CFBundleExecutable</key>
2048
+ <string>${config.app.name.replace(/ /g, "")}</string>
2049
+ <key>CFBundleIdentifier</key>
2050
+ <string>${config.app.identifier}</string>
2051
+ <key>CFBundleName</key>
2052
+ <string>${bundleName}</string>
2053
+ <key>CFBundleVersion</key>
2054
+ <string>${config.app.version}</string>
2055
+ <key>CFBundlePackageType</key>
2056
+ <string>APPL</string>
2057
+ <key>CFBundleIconFile</key>
2058
+ <string>AppIcon</string>${iconName ? `\n <key>CFBundleIconName</key>\n <string>${iconName}</string>` : ""}${usageDescriptions ? "\n" +
2059
+ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
2060
+ "\n" + documentTypes : ""}
2061
+ </dict>
2062
+ </plist>`;
2063
+
2064
+ await Bun.write(
2065
+ join(appBundleFolderContentsPath, "Info.plist"),
2066
+ InfoPlistContents,
2067
+ );
2068
+ // Compile launcher using bun build --compile
2069
+ const launcherBinaryName = config.app.name.replace(/ /g, "");
2070
+ const bunCliLauncherDestination =
2071
+ join(appBundleMacOSPath, launcherBinaryName) + targetBinExt;
2072
+ const destLauncherFolder = dirname(bunCliLauncherDestination);
2073
+ if (!existsSync(destLauncherFolder)) {
2074
+ mkdirSync(destLauncherFolder, { recursive: true });
2075
+ }
2076
+
2077
+ // Compile launcher from source using Bun.build() API
2078
+ const cliDir = dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"));
2079
+ const sparkbunRoot = join(cliDir, "..", "..");
2080
+ const launcherSourcePath = join(sparkbunRoot, "src", "launcher", "main.ts");
2081
+ const bunTarget = `bun-${targetOS === "macos" ? "darwin" : targetOS === "win" ? "windows" : "linux"}-${currentTarget.arch}` as const;
2082
+
2083
+ const compileOptions: any = {
2084
+ target: bunTarget,
2085
+ outfile: bunCliLauncherDestination,
2086
+ };
2087
+
2088
+ if (targetOS === "win") {
2089
+ let icoPath: string | undefined;
2090
+ if (config.build.win?.icon) {
2091
+ const iconSrc = config.build.win.icon.startsWith("/") || config.build.win.icon.match(/^[a-zA-Z]:/)
2092
+ ? config.build.win.icon
2093
+ : join(projectRoot, config.build.win.icon);
2094
+ if (existsSync(iconSrc)) {
2095
+ icoPath = iconSrc;
2096
+ if (iconSrc.toLowerCase().endsWith(".png")) {
2097
+ const pngToIco = (await import("png-to-ico")).default;
2098
+ const tempIcoPath = join(buildFolder, "temp-launcher-icon.ico");
2099
+ const icoBuffer = await pngToIco(iconSrc);
2100
+ writeFileSync(tempIcoPath, new Uint8Array(icoBuffer));
2101
+ icoPath = tempIcoPath;
2102
+ }
2103
+ }
2104
+ }
2105
+ compileOptions.windows = {
2106
+ hideConsole: true,
2107
+ ...(icoPath && { icon: icoPath }),
2108
+ title: config.app.name,
2109
+ version: config.app.version,
2110
+ description: config.app.name,
2111
+ publisher: config.app.publisher || " ",
2112
+ copyright: config.app.copyright || " ",
2113
+ };
2114
+ }
2115
+
2116
+ console.log(`Compiling launcher with Bun.build()...`);
2117
+ const launcherBuild = await Bun.build({
2118
+ entrypoints: [launcherSourcePath],
2119
+ compile: compileOptions,
2120
+ });
2121
+ if (!launcherBuild.success) {
2122
+ console.error("Launcher compilation failed:", launcherBuild.logs);
2123
+ throw new Error("Launcher compilation failed");
2124
+ }
2125
+ console.log(`Compiled launcher: ${bunCliLauncherDestination}`);
2126
+
2127
+ if (targetOS === "win") {
2128
+ patchPeSubsystem(bunCliLauncherDestination);
2129
+ }
2130
+
2131
+ cpSync(targetPaths.PRELOAD_FULL_JS, join(appBundleFolderResourcesPath, "preload-full.js"), {
2132
+ dereference: true,
2133
+ });
2134
+ cpSync(
2135
+ targetPaths.PRELOAD_SANDBOXED_JS,
2136
+ join(appBundleFolderResourcesPath, "preload-sandboxed.js"),
2137
+ {
2138
+ dereference: true,
2139
+ },
2140
+ );
2141
+
2142
+
2143
+ // copy native wrapper dynamic library
2144
+ if (targetOS === "macos") {
2145
+ cpSync(targetPaths.CORE_MACOS, join(appBundleMacOSPath, "libElectrobunCore.dylib"), {
2146
+ dereference: true,
2147
+ });
2148
+
2149
+ const nativeWrapperMacosSource = targetPaths.NATIVE_WRAPPER_MACOS;
2150
+ const nativeWrapperMacosDestination = join(
2151
+ appBundleMacOSPath,
2152
+ "libNativeWrapper.dylib",
2153
+ );
2154
+ cpSync(nativeWrapperMacosSource, nativeWrapperMacosDestination, {
2155
+ dereference: true,
2156
+ });
2157
+ } else if (targetOS === "win") {
2158
+ cpSync(targetPaths.CORE_WIN, join(appBundleMacOSPath, "ElectrobunCore.dll"), {
2159
+ dereference: true,
2160
+ });
2161
+
2162
+ const nativeWrapperMacosSource = targetPaths.NATIVE_WRAPPER_WIN;
2163
+ const nativeWrapperMacosDestination = join(
2164
+ appBundleMacOSPath,
2165
+ "libNativeWrapper.dll",
2166
+ );
2167
+ cpSync(nativeWrapperMacosSource, nativeWrapperMacosDestination, {
2168
+ dereference: true,
2169
+ });
2170
+
2171
+ const webview2LibSource = targetPaths.WEBVIEW2LOADER_WIN;
2172
+ const webview2LibDestination = join(
2173
+ appBundleMacOSPath,
2174
+ "WebView2Loader.dll",
2175
+ );
2176
+ // copy webview2 system webview library
2177
+ cpSync(webview2LibSource, webview2LibDestination, { dereference: true });
2178
+ } else if (targetOS === "linux") {
2179
+ // Choose the appropriate native wrapper based on bundleCEF setting
2180
+ const useCEF = config.build.linux?.bundleCEF;
2181
+ cpSync(targetPaths.CORE_LINUX, join(appBundleMacOSPath, "libElectrobunCore.so"), {
2182
+ dereference: true,
2183
+ });
2184
+ const nativeWrapperLinuxSource = useCEF
2185
+ ? targetPaths.NATIVE_WRAPPER_LINUX_CEF
2186
+ : targetPaths.NATIVE_WRAPPER_LINUX;
2187
+ const nativeWrapperLinuxDestination = join(
2188
+ appBundleMacOSPath,
2189
+ "libNativeWrapper.so",
2190
+ );
2191
+
2192
+ if (existsSync(nativeWrapperLinuxSource)) {
2193
+ cpSync(nativeWrapperLinuxSource, nativeWrapperLinuxDestination, {
2194
+ dereference: true,
2195
+ });
2196
+ console.log(
2197
+ `Using ${useCEF ? "CEF (with weak linking)" : "GTK-only"} native wrapper for Linux`,
2198
+ );
2199
+ } else {
2200
+ throw new Error(
2201
+ `Native wrapper not found: ${nativeWrapperLinuxSource}`,
2202
+ );
2203
+ }
2204
+
2205
+ // Copy icon if specified for Linux to a standard location
2206
+ if (config.build.linux?.icon) {
2207
+ const iconSourcePath = join(projectRoot, config.build.linux.icon);
2208
+ if (existsSync(iconSourcePath)) {
2209
+ const standardIconPath = join(
2210
+ appBundleFolderResourcesPath,
2211
+ "appIcon.png",
2212
+ );
2213
+
2214
+ // Ensure Resources directory exists
2215
+ mkdirSync(appBundleFolderResourcesPath, { recursive: true });
2216
+
2217
+ // Copy the icon to standard location
2218
+ cpSync(iconSourcePath, standardIconPath, { dereference: true });
2219
+ console.log(
2220
+ `Copied Linux icon from ${iconSourcePath} to ${standardIconPath}`,
2221
+ );
2222
+
2223
+ // Also copy icon for the extractor
2224
+ const extractorIconPath = join(
2225
+ appBundleFolderResourcesPath,
2226
+ "app",
2227
+ "icon.png",
2228
+ );
2229
+ mkdirSync(join(appBundleFolderResourcesPath, "app"), {
2230
+ recursive: true,
2231
+ });
2232
+ cpSync(iconSourcePath, extractorIconPath, { dereference: true });
2233
+ console.log(
2234
+ `Copied Linux icon for extractor from ${iconSourcePath} to ${extractorIconPath}`,
2235
+ );
2236
+ } else {
2237
+ console.log(`WARNING: Linux icon not found: ${iconSourcePath}`);
2238
+ }
2239
+ }
2240
+ }
2241
+
2242
+ // Download CEF binaries if needed when bundleCEF is enabled
2243
+ if (
2244
+ (targetOS === "macos" && config.build.mac?.bundleCEF) ||
2245
+ (targetOS === "win" && config.build.win?.bundleCEF) ||
2246
+ (targetOS === "linux" && config.build.linux?.bundleCEF)
2247
+ ) {
2248
+ const effectiveCEFDir = await ensureCEFDependencies(
2249
+ currentTarget.os,
2250
+ currentTarget.arch,
2251
+ config.build.cefVersion,
2252
+ );
2253
+ if (targetOS === "macos") {
2254
+ const cefFrameworkSource = join(
2255
+ effectiveCEFDir,
2256
+ "Chromium Embedded Framework.framework",
2257
+ );
2258
+ const cefFrameworkDestination = join(
2259
+ appBundleFolderFrameworksPath,
2260
+ "Chromium Embedded Framework.framework",
2261
+ );
2262
+
2263
+ cpSync(cefFrameworkSource, cefFrameworkDestination, {
2264
+ recursive: true,
2265
+ dereference: true,
2266
+ });
2267
+
2268
+ // cef helpers
2269
+ const cefHelperNames = getCEFHelperNames();
2270
+
2271
+ const helperSourcePath = targetPaths.CEF_HELPER_MACOS;
2272
+ cefHelperNames.forEach((helperName) => {
2273
+ const destinationPath = join(
2274
+ appBundleFolderFrameworksPath,
2275
+ `${helperName}.app`,
2276
+ `Contents`,
2277
+ `MacOS`,
2278
+ `${helperName}`,
2279
+ );
2280
+
2281
+ const destFolder4 = dirname(destinationPath);
2282
+ if (!existsSync(destFolder4)) {
2283
+ // console.info('creating folder: ', destFolder4);
2284
+ mkdirSync(destFolder4, { recursive: true });
2285
+ }
2286
+ cpSync(helperSourcePath, destinationPath, {
2287
+ recursive: true,
2288
+ dereference: true,
2289
+ });
2290
+ });
2291
+ } else if (targetOS === "win") {
2292
+ // Copy CEF DLLs from CEF directory to the main executable directory
2293
+ const cefSourcePath = effectiveCEFDir;
2294
+ const cefDllFiles = [
2295
+ "libcef.dll",
2296
+ "chrome_elf.dll",
2297
+ "d3dcompiler_47.dll",
2298
+ "dxcompiler.dll",
2299
+ "dxil.dll",
2300
+ "libEGL.dll",
2301
+ "libGLESv2.dll",
2302
+ "vk_swiftshader.dll",
2303
+ "vulkan-1.dll",
2304
+ ];
2305
+
2306
+ cefDllFiles.forEach((dllFile) => {
2307
+ const sourcePath = join(cefSourcePath, dllFile);
2308
+ const destPath = join(appBundleMacOSPath, dllFile);
2309
+ if (existsSync(sourcePath)) {
2310
+ cpSync(sourcePath, destPath, { dereference: true });
2311
+ }
2312
+ });
2313
+
2314
+ // Copy icudtl.dat to MacOS root (same folder as libcef.dll) - required for CEF initialization
2315
+ const icuDataSource = join(cefSourcePath, "icudtl.dat");
2316
+ const icuDataDest = join(appBundleMacOSPath, "icudtl.dat");
2317
+ if (existsSync(icuDataSource)) {
2318
+ cpSync(icuDataSource, icuDataDest, { dereference: true });
2319
+ }
2320
+
2321
+ // Copy essential CEF pak files to MacOS root (same folder as libcef.dll) - required for CEF resources
2322
+ const essentialPakFiles = [
2323
+ "chrome_100_percent.pak",
2324
+ "resources.pak",
2325
+ "v8_context_snapshot.bin",
2326
+ ];
2327
+ essentialPakFiles.forEach((pakFile) => {
2328
+ const sourcePath = join(cefSourcePath, pakFile);
2329
+ const destPath = join(appBundleMacOSPath, pakFile);
2330
+
2331
+ if (existsSync(sourcePath)) {
2332
+ cpSync(sourcePath, destPath, { dereference: true });
2333
+ } else {
2334
+ console.log(`WARNING: Missing CEF file: ${sourcePath}`);
2335
+ }
2336
+ });
2337
+
2338
+ // Copy CEF resources to MacOS/cef/ subdirectory for other resources like locales
2339
+ const cefResourcesSource = effectiveCEFDir;
2340
+ const cefResourcesDestination = join(appBundleMacOSPath, "cef");
2341
+
2342
+ if (existsSync(cefResourcesSource)) {
2343
+ cpSync(cefResourcesSource, cefResourcesDestination, {
2344
+ recursive: true,
2345
+ dereference: true,
2346
+ });
2347
+ }
2348
+
2349
+ // Copy CEF helper processes with different names
2350
+ const cefHelperNames = getCEFHelperNames();
2351
+
2352
+ const helperSourcePath = targetPaths.CEF_HELPER_WIN;
2353
+ if (existsSync(helperSourcePath)) {
2354
+ cefHelperNames.forEach((helperName) => {
2355
+ const destinationPath = join(
2356
+ appBundleMacOSPath,
2357
+ `${helperName}.exe`,
2358
+ );
2359
+ cpSync(helperSourcePath, destinationPath, { dereference: true });
2360
+ });
2361
+ } else {
2362
+ console.log(`WARNING: Missing CEF helper: ${helperSourcePath}`);
2363
+ }
2364
+ } else if (targetOS === "linux") {
2365
+ // Copy CEF shared libraries from platform-specific dist/cef/ to the main executable directory
2366
+ const cefSourcePath = effectiveCEFDir;
2367
+
2368
+ if (existsSync(cefSourcePath)) {
2369
+ const cefSoFiles = [
2370
+ "libcef.so",
2371
+ "libEGL.so",
2372
+ "libGLESv2.so",
2373
+ "libvk_swiftshader.so",
2374
+ "libvulkan.so.1",
2375
+ ];
2376
+
2377
+ // Copy CEF .so files to main directory as symlinks to cef/ subdirectory
2378
+ cefSoFiles.forEach((soFile) => {
2379
+ const sourcePath = join(cefSourcePath, soFile);
2380
+ // @ts-expect-error - reserved for future use
2381
+ const _destPath = join(appBundleMacOSPath, soFile);
2382
+ if (existsSync(sourcePath)) {
2383
+ // We'll create the actual file in cef/ and symlink from main directory
2384
+ // This will be done after the cef/ directory is populated
2385
+ }
2386
+ });
2387
+
2388
+ // Copy icudtl.dat to MacOS root (same folder as libcef.so) - required for CEF initialization
2389
+ const icuDataSource = join(cefSourcePath, "icudtl.dat");
2390
+ const icuDataDest = join(appBundleMacOSPath, "icudtl.dat");
2391
+ if (existsSync(icuDataSource)) {
2392
+ cpSync(icuDataSource, icuDataDest, { dereference: true });
2393
+ }
2394
+
2395
+ // Copy .pak files and other CEF resources to the main executable directory
2396
+ const pakFiles = [
2397
+ "icudtl.dat",
2398
+ "v8_context_snapshot.bin",
2399
+ "snapshot_blob.bin",
2400
+ "resources.pak",
2401
+ "chrome_100_percent.pak",
2402
+ "chrome_200_percent.pak",
2403
+ "locales",
2404
+ "chrome-sandbox",
2405
+ "vk_swiftshader_icd.json",
2406
+ ];
2407
+ pakFiles.forEach((pakFile) => {
2408
+ const sourcePath = join(cefSourcePath, pakFile);
2409
+ const destPath = join(appBundleMacOSPath, pakFile);
2410
+ if (existsSync(sourcePath)) {
2411
+ cpSync(sourcePath, destPath, {
2412
+ recursive: true,
2413
+ dereference: true,
2414
+ });
2415
+ }
2416
+ });
2417
+
2418
+ // Copy locales to cef subdirectory
2419
+ const cefResourcesDestination = join(appBundleMacOSPath, "cef");
2420
+ if (!existsSync(cefResourcesDestination)) {
2421
+ mkdirSync(cefResourcesDestination, { recursive: true });
2422
+ }
2423
+
2424
+ // Copy all CEF shared libraries to cef subdirectory as well (for RPATH $ORIGIN/cef)
2425
+ cefSoFiles.forEach((soFile) => {
2426
+ const sourcePath = join(cefSourcePath, soFile);
2427
+ const destPath = join(cefResourcesDestination, soFile);
2428
+ if (existsSync(sourcePath)) {
2429
+ cpSync(sourcePath, destPath, { dereference: true });
2430
+ console.log(`Copied CEF library to cef subdirectory: ${soFile}`);
2431
+ } else {
2432
+ console.log(`WARNING: Missing CEF library: ${sourcePath}`);
2433
+ }
2434
+ });
2435
+
2436
+ // Copy essential CEF files to cef subdirectory as well (for RPATH $ORIGIN/cef)
2437
+ const cefEssentialFiles = ["vk_swiftshader_icd.json"];
2438
+ cefEssentialFiles.forEach((cefFile) => {
2439
+ const sourcePath = join(cefSourcePath, cefFile);
2440
+ const destPath = join(cefResourcesDestination, cefFile);
2441
+ if (existsSync(sourcePath)) {
2442
+ cpSync(sourcePath, destPath, { dereference: true });
2443
+ console.log(
2444
+ `Copied CEF essential file to cef subdirectory: ${cefFile}`,
2445
+ );
2446
+ } else {
2447
+ console.log(`WARNING: Missing CEF essential file: ${sourcePath}`);
2448
+ }
2449
+ });
2450
+
2451
+ // Create symlinks from main directory to cef/ subdirectory for .so files
2452
+ console.log("Creating symlinks for CEF libraries...");
2453
+ cefSoFiles.forEach((soFile) => {
2454
+ const cefFilePath = join(cefResourcesDestination, soFile);
2455
+ const mainDirPath = join(appBundleMacOSPath, soFile);
2456
+
2457
+ if (existsSync(cefFilePath)) {
2458
+ try {
2459
+ // Remove any existing file/symlink in main directory
2460
+ if (existsSync(mainDirPath)) {
2461
+ rmSync(mainDirPath);
2462
+ }
2463
+ // Create symlink from main directory to cef/ subdirectory
2464
+ symlinkSync(join("cef", soFile), mainDirPath);
2465
+ console.log(
2466
+ `Created symlink for CEF library: ${soFile} -> cef/${soFile}`,
2467
+ );
2468
+ } catch (error) {
2469
+ console.log(
2470
+ `WARNING: Failed to create symlink for ${soFile}: ${error}`,
2471
+ );
2472
+ // Fallback to copying the file
2473
+ cpSync(cefFilePath, mainDirPath, { dereference: true });
2474
+ console.log(
2475
+ `Fallback: Copying CEF library to main directory: ${soFile}`,
2476
+ );
2477
+ }
2478
+ }
2479
+ });
2480
+
2481
+ // Copy CEF helper processes with different names
2482
+ const cefHelperNames = getCEFHelperNames();
2483
+
2484
+ const helperSourcePath = targetPaths.CEF_HELPER_LINUX;
2485
+ if (existsSync(helperSourcePath)) {
2486
+ cefHelperNames.forEach((helperName) => {
2487
+ const destinationPath = join(appBundleMacOSPath, helperName);
2488
+ cpSync(helperSourcePath, destinationPath, { dereference: true });
2489
+ // console.log(`Copied CEF helper: ${helperName}`);
2490
+ });
2491
+ } else {
2492
+ console.log(`WARNING: Missing CEF helper: ${helperSourcePath}`);
2493
+ }
2494
+ }
2495
+ }
2496
+ }
2497
+
2498
+ // Download WGPU (Dawn) binaries if needed when bundleWGPU is enabled
2499
+ if (
2500
+ (targetOS === "macos" && config.build.mac?.bundleWGPU) ||
2501
+ (targetOS === "win" && config.build.win?.bundleWGPU) ||
2502
+ (targetOS === "linux" && config.build.linux?.bundleWGPU)
2503
+ ) {
2504
+ const effectiveWGPUDir = await ensureWGPUDependencies(
2505
+ currentTarget.os,
2506
+ currentTarget.arch,
2507
+ config.build.wgpuVersion,
2508
+ );
2509
+
2510
+ const libCandidates =
2511
+ targetOS === "macos"
2512
+ ? [
2513
+ join(effectiveWGPUDir, "lib", "libwebgpu_dawn.dylib"),
2514
+ join(effectiveWGPUDir, "lib", "libwebgpu_dawn_shared.dylib"),
2515
+ ]
2516
+ : targetOS === "win"
2517
+ ? [
2518
+ join(effectiveWGPUDir, "bin", "webgpu_dawn.dll"),
2519
+ join(effectiveWGPUDir, "bin", "libwebgpu_dawn.dll"),
2520
+ join(effectiveWGPUDir, "lib", "webgpu_dawn.dll"),
2521
+ join(effectiveWGPUDir, "lib", "libwebgpu_dawn.dll"),
2522
+ ]
2523
+ : [
2524
+ join(effectiveWGPUDir, "lib", "libwebgpu_dawn.so"),
2525
+ join(effectiveWGPUDir, "lib", "libwebgpu_dawn_shared.so"),
2526
+ ];
2527
+
2528
+ const libSource = libCandidates.find((p) => existsSync(p));
2529
+ if (!libSource) {
2530
+ throw new Error(
2531
+ `WGPU shared library not found in ${effectiveWGPUDir}. Checked: ${libCandidates.join(", ")}`,
2532
+ );
2533
+ }
2534
+
2535
+ const libDest = join(appBundleMacOSPath, basename(libSource));
2536
+ cpSync(libSource, libDest, { dereference: true });
2537
+ console.log(`Copied WGPU library to bundle: ${libDest}`);
2538
+
2539
+ // On Windows, Dawn needs d3dcompiler_47.dll for D3D shader compilation.
2540
+ // ARM64 Windows doesn't have an x64 version in system directories,
2541
+ // so bundle it alongside the WGPU library.
2542
+ if (targetOS === "win") {
2543
+ const d3dCandidates = [
2544
+ join(effectiveWGPUDir, "bin", "d3dcompiler_47.dll"),
2545
+ join(SPARKBUN_DEP_PATH, `dist-win-${currentTarget.arch}`, "d3dcompiler_47.dll"),
2546
+ join(SPARKBUN_DEP_PATH, "dist", "d3dcompiler_47.dll"),
2547
+ join(targetPaths.CEF_DIR, "d3dcompiler_47.dll"),
2548
+ ];
2549
+ const d3dSource = d3dCandidates.find((p) => existsSync(p));
2550
+ if (d3dSource) {
2551
+ const d3dDest = join(appBundleMacOSPath, "d3dcompiler_47.dll");
2552
+ cpSync(d3dSource, d3dDest, { dereference: true });
2553
+ console.log(`Copied d3dcompiler_47.dll to bundle: ${d3dDest}`);
2554
+ }
2555
+ }
2556
+ }
2557
+
2558
+ // copy native bindings
2559
+ const bsPatchSource = targetPaths.BSPATCH;
2560
+ const bsPatchDestination =
2561
+ join(appBundleMacOSPath, "bspatch") + targetBinExt;
2562
+ const bsPatchDestFolder = dirname(bsPatchDestination);
2563
+ if (!existsSync(bsPatchDestFolder)) {
2564
+ mkdirSync(bsPatchDestFolder, { recursive: true });
2565
+ }
2566
+
2567
+ cpSync(bsPatchSource, bsPatchDestination, {
2568
+ recursive: true,
2569
+ dereference: true,
2570
+ });
2571
+
2572
+ // libasar is still loaded by ElectrobunCore at runtime — needed until
2573
+ // native wrappers are recompiled without ASAR support
2574
+ const libExt = targetOS === "win" ? ".dll" : targetOS === "macos" ? ".dylib" : ".so";
2575
+ const asarLibSource = join(dirname(targetPaths.BSPATCH), "libasar" + libExt);
2576
+ if (existsSync(asarLibSource)) {
2577
+ cpSync(asarLibSource, join(appBundleMacOSPath, "libasar" + libExt), {
2578
+ dereference: true,
2579
+ });
2580
+ }
2581
+
2582
+ if (mainProcess === "bun") {
2583
+ // transpile developer's bun code
2584
+ const bunDestFolder = join(appBundleAppCodePath, "bun");
2585
+ const { entrypoint: _bunEntrypoint, ...bunBuildOptions } = bunConfig;
2586
+ const buildResult = await Bun.build({
2587
+ ...bunBuildOptions,
2588
+ entrypoints: [bunSource],
2589
+ outdir: bunDestFolder,
2590
+ target: "bun",
2591
+ });
2592
+
2593
+ if (!buildResult.success) {
2594
+ console.error("failed to build", bunSource);
2595
+ printBuildLogs(buildResult.logs);
2596
+ throw new Error("Build failed: bun build failed");
2597
+ }
2598
+ }
2599
+
2600
+ // transpile developer's view code
2601
+ // Build webview-javascript ts files
2602
+ // bundle all the bundles
2603
+ for (const viewName in config.build.views) {
2604
+ const viewConfig = config.build.views[viewName]!;
2605
+
2606
+ const viewSource = join(projectRoot, viewConfig.entrypoint);
2607
+ if (!existsSync(viewSource)) {
2608
+ console.error(
2609
+ `failed to bundle ${viewSource} because it doesn't exist.`,
2610
+ );
2611
+ continue;
2612
+ }
2613
+
2614
+ const viewDestFolder = join(appBundleAppCodePath, "views", viewName);
2615
+
2616
+ if (!existsSync(viewDestFolder)) {
2617
+ // console.info('creating folder: ', viewDestFolder);
2618
+ mkdirSync(viewDestFolder, { recursive: true });
2619
+ } else {
2620
+ console.error(
2621
+ "continuing, but ",
2622
+ viewDestFolder,
2623
+ "unexpectedly already exists in the build folder",
2624
+ );
2625
+ }
2626
+
2627
+ // console.info(`bundling ${viewSource} to ${viewDestFolder} with config: `, viewConfig);
2628
+
2629
+ const { entrypoint: _viewEntrypoint, ...viewBuildOptions } = viewConfig;
2630
+ const buildResult = await Bun.build({
2631
+ ...viewBuildOptions,
2632
+ entrypoints: [viewSource],
2633
+ outdir: viewDestFolder,
2634
+ target: "browser",
2635
+ });
2636
+
2637
+ if (!buildResult.success) {
2638
+ console.error("failed to build", viewSource);
2639
+ printBuildLogs(buildResult.logs);
2640
+ continue;
2641
+ }
2642
+ }
2643
+
2644
+ // Copy assets like html, css, images, and other files
2645
+ for (const relSource in config.build.copy) {
2646
+ const source = join(projectRoot, relSource);
2647
+ if (!existsSync(source)) {
2648
+ console.error(`failed to copy ${source} because it doesn't exist.`);
2649
+ continue;
2650
+ }
2651
+
2652
+ const destination = join(
2653
+ appBundleAppCodePath,
2654
+ config.build.copy[relSource]!,
2655
+ );
2656
+ const destFolder = dirname(destination);
2657
+
2658
+ if (!existsSync(destFolder)) {
2659
+ // console.info('creating folder: ', destFolder);
2660
+ mkdirSync(destFolder, { recursive: true });
2661
+ }
2662
+
2663
+ cpSync(source, destination, { recursive: true, dereference: true });
2664
+ }
2665
+
2666
+ buildIcons(appBundleFolderResourcesPath, appBundleFolderPath);
2667
+
2668
+ runHook("postBuild", {});
2669
+
2670
+ // Create a content hash for version.json. In non-dev builds this is used
2671
+ // by the updater to detect changes. For dev builds we skip it since
2672
+ // the updater isn't relevant.
2673
+ let hash: string;
2674
+ if (buildEnvironment === "dev") {
2675
+ hash = "dev";
2676
+ } else {
2677
+ // Walk the app bundle and create an in-memory tar for hashing
2678
+ // (no temp file on disk). This hashes the
2679
+ // hash reflects the final shipped bundle contents.
2680
+ console.time("Generate Bundle hash");
2681
+ const bundleFiles: Record<string, Blob> = {};
2682
+ const bundleBase = basename(appBundleFolderPath);
2683
+ const entries = readdirSync(appBundleFolderPath, {
2684
+ recursive: true,
2685
+ } as any) as string[];
2686
+ for (const entry of entries) {
2687
+ const entryPath = entry.toString();
2688
+ const fullPath = join(appBundleFolderPath, entryPath);
2689
+ if (statSync(fullPath).isFile()) {
2690
+ bundleFiles[join(bundleBase, entryPath)] = Bun.file(fullPath);
2691
+ }
2692
+ }
2693
+ // Check if Bun.Archive is available (Bun 1.3.0+)
2694
+ if (typeof Bun.Archive !== "undefined") {
2695
+ const archiveBytes = await new Bun.Archive(bundleFiles).bytes();
2696
+ // Note: wyhash is the default in Bun.hash but that may change in the future
2697
+ // so we're being explicit here.
2698
+ hash = Bun.hash.wyhash(archiveBytes, 43770n).toString(36);
2699
+ } else {
2700
+ // Fallback for older Bun versions - use a simple hash of file paths
2701
+ console.warn("Bun.Archive not available, using fallback hash method");
2702
+ const fileList = Object.keys(bundleFiles).sort().join("\n");
2703
+ hash = Bun.hash.wyhash(fileList).toString(36);
2704
+ }
2705
+ console.timeEnd("Generate Bundle hash");
2706
+ }
2707
+
2708
+ // const bunVersion = execSync(`${bunBinarySourcePath} --version`).toString().trim();
2709
+
2710
+ // version.json inside the app bundle
2711
+ const versionJsonContent = JSON.stringify({
2712
+ version: config.app.version,
2713
+ // The first tar file does not include this, it gets hashed,
2714
+ // then the hash is included in another tar file. That later one
2715
+ // then gets used for patching and updating.
2716
+ hash: hash,
2717
+ channel: buildEnvironment,
2718
+ baseUrl: config.release.baseUrl,
2719
+ name: appFileName,
2720
+ identifier: config.app.identifier,
2721
+ });
2722
+
2723
+ await Bun.write(
2724
+ join(appBundleFolderResourcesPath, "version.json"),
2725
+ versionJsonContent,
2726
+ );
2727
+
2728
+ // build.json inside the app bundle - runtime build configuration
2729
+ const platformConfig =
2730
+ targetOS === "macos"
2731
+ ? config.build?.mac
2732
+ : targetOS === "win"
2733
+ ? config.build?.win
2734
+ : config.build?.linux;
2735
+
2736
+ const bundlesCEF = platformConfig?.bundleCEF ?? false;
2737
+
2738
+ const buildJsonObj: Record<string, unknown> = {
2739
+ mainProcess,
2740
+ defaultRenderer: platformConfig?.defaultRenderer ?? "native",
2741
+ availableRenderers: bundlesCEF ? ["native", "cef"] : ["native"],
2742
+ runtime: config.runtime ?? {},
2743
+ ...(bundlesCEF
2744
+ ? { cefVersion: config.build?.cefVersion ?? DEFAULT_CEF_VERSION_STRING }
2745
+ : {}),
2746
+ ...(mainProcess === "bun"
2747
+ ? { bunVersion: config.build?.bunVersion ?? BUN_VERSION }
2748
+ : {}),
2749
+ };
2750
+
2751
+ // Include chromiumFlags only if the developer defined them
2752
+ if (
2753
+ platformConfig?.chromiumFlags &&
2754
+ Object.keys(platformConfig.chromiumFlags).length > 0
2755
+ ) {
2756
+ buildJsonObj["chromiumFlags"] = platformConfig.chromiumFlags;
2757
+ }
2758
+
2759
+ if (platformConfig?.requireAdmin) {
2760
+ buildJsonObj["requireAdmin"] = true;
2761
+ }
2762
+
2763
+ const buildJsonContent = JSON.stringify(buildJsonObj);
2764
+
2765
+ await Bun.write(
2766
+ join(appBundleFolderResourcesPath, "build.json"),
2767
+ buildJsonContent,
2768
+ );
2769
+
2770
+ // Only codesign/notarize when building macOS targets on macOS host
2771
+ const shouldCodesign =
2772
+ buildEnvironment !== "dev" &&
2773
+ targetOS === "macos" &&
2774
+ OS === "macos" &&
2775
+ config.build.mac.codesign;
2776
+ const shouldNotarize = shouldCodesign && config.build.mac.notarize;
2777
+
2778
+ if (shouldCodesign) {
2779
+ codesignAppBundle(
2780
+ appBundleFolderPath,
2781
+ join(buildFolder, "entitlements.plist"),
2782
+ config,
2783
+ );
2784
+ } else {
2785
+ console.log("skipping codesign");
2786
+ }
2787
+
2788
+ // codesign
2789
+ // NOTE: Codesigning fails in dev mode (when using a single-file-executable bun cli as the launcher)
2790
+ // see https://github.com/oven-sh/bun/issues/7208
2791
+ if (shouldNotarize) {
2792
+ notarizeAndStaple(appBundleFolderPath, config);
2793
+ } else {
2794
+ console.log("skipping notarization");
2795
+ }
2796
+
2797
+ const artifactsToUpload = [];
2798
+
2799
+ // Linux bundle preparation (skip tar creation for dev environment)
2800
+ // For Linux, the app bundle is already in the correct directory structure
2801
+ // The tar will be created in the common code path below
2802
+
2803
+ if (buildEnvironment !== "dev") {
2804
+ // Archive compression (gzip via Bun)
2805
+ // tar https://github.com/isaacs/node-tar
2806
+
2807
+ // steps:
2808
+ // 1. [done] build the app bundle, code sign, notarize, staple.
2809
+ // 2. tar and gzip the app bundle
2810
+ // 3. build another app bundle for the self-extracting app bundle with the archive in Resources
2811
+ // 4. code sign and notarize the self-extracting app bundle
2812
+ // 5. while waiting for that notarization, download the prev app bundle, extract the tar, and generate a bsdiff patch
2813
+ // 6. when notarization is complete, generate a dmg of the self-extracting app bundle
2814
+ // 6.5. code sign and notarize the dmg
2815
+ // 7. copy artifacts to directory [self-extractor dmg, gzip app bundle, bsdiff patch, update.json]
2816
+
2817
+ // Platform suffix is only used for folder names, not file names
2818
+ const platformSuffix = `-${targetOS}-${targetARCH}`;
2819
+ // Use sanitized appFileName for tarball path (URL-safe), but tar content uses actual bundle folder name
2820
+ const tarPath = join(
2821
+ buildFolder,
2822
+ `${appFileName}${targetOS === "macos" ? ".app" : ""}.tar`,
2823
+ );
2824
+
2825
+ // Tar the app bundle for all platforms
2826
+ createTar(tarPath, buildFolder, [basename(appBundleFolderPath)]);
2827
+
2828
+ // Build .deb before deleting the app bundle (needs the full directory tree)
2829
+ if (targetOS === "linux" && config.build?.linux?.createDeb) {
2830
+ const debPath = await createDebPackage(
2831
+ buildFolder,
2832
+ appBundleFolderPath,
2833
+ config,
2834
+ targetARCH,
2835
+ projectRoot,
2836
+ );
2837
+ artifactsToUpload.push(debPath);
2838
+ }
2839
+
2840
+ // This branch only runs for non-dev release packaging, so the temp app bundle
2841
+ // can always be removed after the tarball is produced.
2842
+ rmSync(appBundleFolderPath, { recursive: true });
2843
+
2844
+ // generate bsdiff
2845
+ // https://storage.googleapis.com/eggbun-static/sparkbun-playground/canary/SparkBunPlayground-canary.app.tar.gz
2846
+ console.log("baseUrl: ", config.release.baseUrl);
2847
+
2848
+ console.log("generating a patch from the previous version...");
2849
+
2850
+ // Skip patch generation if disabled
2851
+ if (config.release.generatePatch === false) {
2852
+ console.log(
2853
+ "Patch generation disabled (release.generatePatch = false)",
2854
+ );
2855
+ } else if (
2856
+ !config.release.baseUrl ||
2857
+ config.release.baseUrl.trim() === ""
2858
+ ) {
2859
+ console.log("No baseUrl configured, skipping patch generation");
2860
+ console.log(
2861
+ "To enable patch generation, configure baseUrl in your sparkbun.config",
2862
+ );
2863
+ } else {
2864
+ const urlToPrevUpdateJson = `${config.release.baseUrl.replace(/\/+$/, "")}/${platformPrefix}-update.json`;
2865
+ const cacheBuster = Math.random().toString(36).substring(7);
2866
+ const updateJsonResponse = await fetch(
2867
+ urlToPrevUpdateJson + `?${cacheBuster}`,
2868
+ ).catch((err) => {
2869
+ console.log("baseUrl not found: ", err);
2870
+ });
2871
+
2872
+ const tarballFileName = getTarballFileName(appFileName, OS);
2873
+ const urlToLatestTarball = `${config.release.baseUrl.replace(/\/+$/, "")}/${platformPrefix}-${tarballFileName}`;
2874
+
2875
+ // attempt to get the previous version to create a patch file
2876
+ if (updateJsonResponse && updateJsonResponse.ok) {
2877
+ const prevUpdateJson = await updateJsonResponse!.json();
2878
+
2879
+ const prevHash = prevUpdateJson.hash;
2880
+ console.log("PREVIOUS HASH", prevHash);
2881
+
2882
+
2883
+ const response = await fetch(urlToLatestTarball + `?${cacheBuster}`);
2884
+ const prevVersionCompressedTarballPath = join(
2885
+ buildFolder,
2886
+ "prev.tar.gz",
2887
+ );
2888
+
2889
+ if (response && response.ok && response.body) {
2890
+ const reader = response.body.getReader();
2891
+ const totalBytesHeader = response.headers.get("content-length");
2892
+ const totalBytes = totalBytesHeader
2893
+ ? Number(totalBytesHeader)
2894
+ : undefined;
2895
+ let downloadedBytes = 0;
2896
+ let lastLogTime = Date.now();
2897
+ const logIntervalMs = 5_000;
2898
+
2899
+ const writer = Bun.file(prevVersionCompressedTarballPath).writer();
2900
+
2901
+ while (true) {
2902
+ const { done, value } = await reader.read();
2903
+ if (done) break;
2904
+ downloadedBytes += value.length;
2905
+ const now = Date.now();
2906
+ if (now - lastLogTime >= logIntervalMs) {
2907
+ if (totalBytes && Number.isFinite(totalBytes)) {
2908
+ const percent = (
2909
+ (downloadedBytes / totalBytes) *
2910
+ 100
2911
+ ).toFixed(1);
2912
+ console.log(
2913
+ `Downloading previous version... ${percent}% (${downloadedBytes}/${totalBytes} bytes)`,
2914
+ );
2915
+ } else {
2916
+ console.log(
2917
+ `Downloading previous version... ${downloadedBytes} bytes`,
2918
+ );
2919
+ }
2920
+ lastLogTime = now;
2921
+ }
2922
+ await writer.write(value);
2923
+ }
2924
+ await writer.flush();
2925
+ writer.end();
2926
+
2927
+ console.log("decompress prev bundle...");
2928
+ const prevTarballPath = join(buildFolder, "prev.tar");
2929
+ let canGeneratePatch = true;
2930
+
2931
+ try {
2932
+ const compressed = readFileSync(prevVersionCompressedTarballPath);
2933
+ const decompressed = Bun.gunzipSync(compressed);
2934
+ writeFileSync(prevTarballPath, decompressed);
2935
+ } catch (err) {
2936
+ console.log(
2937
+ `Failed to decompress previous tarball: ${err}, skipping patch generation`,
2938
+ );
2939
+ canGeneratePatch = false;
2940
+ }
2941
+
2942
+ if (existsSync(prevVersionCompressedTarballPath)) {
2943
+ unlinkSync(prevVersionCompressedTarballPath);
2944
+ }
2945
+
2946
+ if (canGeneratePatch) {
2947
+ console.log("diff previous and new tarballs...");
2948
+ // Run it as a separate process to leverage multi-threadedness
2949
+ // especially for creating multiple diffs in parallel
2950
+ const bsdiffpath = targetPaths.BSDIFF;
2951
+ const patchFilePath = join(buildFolder, `${prevHash}.patch`);
2952
+ const result = Bun.spawnSync(
2953
+ [
2954
+ bsdiffpath,
2955
+ prevTarballPath,
2956
+ tarPath,
2957
+ patchFilePath,
2958
+ ],
2959
+ {
2960
+ cwd: buildFolder,
2961
+ stdout: "inherit",
2962
+ stderr: "inherit",
2963
+ },
2964
+ );
2965
+ if (!result.success) {
2966
+ // Patch generation is non-critical - users will just download full updates instead of delta patches
2967
+ console.error("\n" + "=".repeat(80));
2968
+ console.error(
2969
+ "WARNING: Patch generation failed (exit code " +
2970
+ result.exitCode +
2971
+ ")",
2972
+ );
2973
+ console.error(
2974
+ "Delta updates will not be available for this release.",
2975
+ );
2976
+ console.error("Users will download the full update instead.");
2977
+ console.error("=".repeat(80) + "\n");
2978
+ } else {
2979
+ // Only add patch to artifacts if it was successfully created
2980
+ artifactsToUpload.push(patchFilePath);
2981
+ }
2982
+
2983
+ // Clean up previous tarball now that bsdiff is done
2984
+ if (existsSync(prevTarballPath)) {
2985
+ unlinkSync(prevTarballPath);
2986
+ }
2987
+ }
2988
+ } else {
2989
+ console.log(
2990
+ "Failed to fetch previous tarball, skipping patch generation",
2991
+ );
2992
+ }
2993
+ } else {
2994
+ console.log("prevoius version not found at: ", urlToLatestTarball);
2995
+ console.log("skipping diff generation");
2996
+ }
2997
+ } // End of baseUrl validation block
2998
+
2999
+ let compressedTarPath = `${tarPath}.gz`;
3000
+
3001
+ {
3002
+ console.log("compressing tarball with gzip...");
3003
+ const tarBytes = readFileSync(tarPath);
3004
+ const gzipped = Bun.gzipSync(tarBytes);
3005
+ writeFileSync(compressedTarPath, gzipped);
3006
+ artifactsToUpload.push(compressedTarPath);
3007
+ }
3008
+
3009
+ // Remove the uncompressed tar now that compression and diffing are done.
3010
+ if (existsSync(tarPath)) {
3011
+ unlinkSync(tarPath);
3012
+ }
3013
+
3014
+ const selfExtractingBundle = createAppBundle(
3015
+ bundleName,
3016
+ buildFolder,
3017
+ targetOS,
3018
+ );
3019
+ const compressedTarballInExtractingBundlePath = join(
3020
+ selfExtractingBundle.appBundleFolderResourcesPath,
3021
+ `${hash}.tar.gz`,
3022
+ );
3023
+
3024
+ // copy the gzip tarball to the self-extracting app bundle
3025
+ cpSync(compressedTarPath, compressedTarballInExtractingBundlePath, {
3026
+ dereference: true,
3027
+ });
3028
+
3029
+ // macOS uses the Zig extractor in the self-extracting .app bundle.
3030
+ // Windows/Linux use createCompiledInstaller instead.
3031
+ if (targetOS === "macos") {
3032
+ const selfExtractorBinSourcePath = targetPaths.EXTRACTOR;
3033
+ const selfExtractorBinDestinationPath = join(
3034
+ selfExtractingBundle.appBundleMacOSPath,
3035
+ config.app.name.replace(/ /g, ""),
3036
+ );
3037
+
3038
+ cpSync(selfExtractorBinSourcePath, selfExtractorBinDestinationPath, {
3039
+ dereference: true,
3040
+ });
3041
+ }
3042
+
3043
+ buildIcons(
3044
+ selfExtractingBundle.appBundleFolderResourcesPath,
3045
+ selfExtractingBundle.appBundleFolderPath,
3046
+ );
3047
+ await Bun.write(
3048
+ join(selfExtractingBundle.appBundleFolderContentsPath, "Info.plist"),
3049
+ InfoPlistContents,
3050
+ );
3051
+
3052
+ // Write metadata.json to outer bundle (consistent with Windows/Linux)
3053
+ const extractorMetadata = {
3054
+ identifier: config.app.identifier,
3055
+ name: config.app.name,
3056
+ channel: buildEnvironment,
3057
+ hash: hash,
3058
+ };
3059
+ await Bun.write(
3060
+ join(
3061
+ selfExtractingBundle.appBundleFolderResourcesPath,
3062
+ "metadata.json",
3063
+ ),
3064
+ JSON.stringify(extractorMetadata, null, 2),
3065
+ );
3066
+
3067
+ // Run postWrap hook after self-extracting bundle is created, before code signing
3068
+ // This is where you can add files to the wrapper (e.g., for liquid glass support)
3069
+ runHook("postWrap", {
3070
+ SPARKBUN_WRAPPER_BUNDLE_PATH:
3071
+ selfExtractingBundle.appBundleFolderPath,
3072
+ });
3073
+
3074
+ if (shouldCodesign) {
3075
+ codesignAppBundle(
3076
+ selfExtractingBundle.appBundleFolderPath,
3077
+ join(buildFolder, "entitlements.plist"),
3078
+ config,
3079
+ );
3080
+ } else {
3081
+ console.log("skipping codesign");
3082
+ }
3083
+
3084
+ // Note: we need to notarize the original app bundle, the self-extracting app bundle, and the dmg
3085
+ if (shouldNotarize) {
3086
+ notarizeAndStaple(selfExtractingBundle.appBundleFolderPath, config);
3087
+ } else {
3088
+ console.log("skipping notarization");
3089
+ }
3090
+
3091
+ // DMG creation for macOS only
3092
+ if (targetOS === "macos" && config.build.mac?.createDmg !== false) {
3093
+ console.log("creating dmg...");
3094
+ const finalDmgPath = join(buildFolder, `${appFileName}.dmg`);
3095
+ // NOTE: For some ungodly reason using the bare name in CI can conflict with some mysterious
3096
+ // already mounted volume. I suspect the sanitized appFileName can match your github repo
3097
+ // or some other tool is mounting something somewhere. Either way, as a workaround
3098
+ // while creating the dmg for a stable build we temporarily give it a -stable suffix
3099
+ // to match the behaviour of -canary builds.
3100
+ const dmgCreationPath =
3101
+ buildEnvironment === "stable"
3102
+ ? join(buildFolder, `${appFileName}-stable.dmg`)
3103
+ : finalDmgPath;
3104
+ const dmgVolumeName = getDmgVolumeName(
3105
+ config.app.name,
3106
+ buildEnvironment,
3107
+ );
3108
+
3109
+ // Create a staging directory for DMG contents (app + Applications shortcut)
3110
+ const dmgStagingDir = join(buildFolder, ".dmg-staging");
3111
+ if (existsSync(dmgStagingDir)) {
3112
+ rmSync(dmgStagingDir, { recursive: true });
3113
+ }
3114
+ mkdirSync(dmgStagingDir, { recursive: true });
3115
+ try {
3116
+ // Copy the app bundle to the staging directory
3117
+ const stagedAppPath = join(
3118
+ dmgStagingDir,
3119
+ basename(selfExtractingBundle.appBundleFolderPath),
3120
+ );
3121
+ execSync(
3122
+ `cp -R ${escapePathForTerminal(selfExtractingBundle.appBundleFolderPath)} ${escapePathForTerminal(stagedAppPath)}`,
3123
+ );
3124
+
3125
+ // Create a symlink to /Applications for easy drag-and-drop installation
3126
+ const applicationsLink = join(dmgStagingDir, "Applications");
3127
+ symlinkSync("/Applications", applicationsLink);
3128
+
3129
+ // hdiutil create -volname "YourAppName" -srcfolder /path/to/staging -ov -format UDZO YourAppName.dmg
3130
+ // Note: use ULFO (lzfse) for better compatibility with large CEF frameworks and modern macOS
3131
+ execSync(
3132
+ `hdiutil create -volname "${dmgVolumeName}" -srcfolder ${escapePathForTerminal(
3133
+ dmgStagingDir,
3134
+ )} -ov -format ULFO ${escapePathForTerminal(dmgCreationPath)}`,
3135
+ );
3136
+
3137
+ if (
3138
+ buildEnvironment === "stable" &&
3139
+ dmgCreationPath !== finalDmgPath
3140
+ ) {
3141
+ renameSync(dmgCreationPath, finalDmgPath);
3142
+ }
3143
+ artifactsToUpload.push(finalDmgPath);
3144
+
3145
+ if (shouldCodesign) {
3146
+ codesignAppBundle(finalDmgPath, undefined, config);
3147
+ } else {
3148
+ console.log("skipping codesign");
3149
+ }
3150
+
3151
+ if (shouldNotarize) {
3152
+ notarizeAndStaple(finalDmgPath, config);
3153
+ } else {
3154
+ console.log("skipping notarization");
3155
+ }
3156
+ } finally {
3157
+ if (existsSync(dmgStagingDir)) {
3158
+ rmSync(dmgStagingDir, { recursive: true });
3159
+ }
3160
+ }
3161
+ } else {
3162
+ if (targetOS === "macos") {
3163
+ console.log("skipping dmg");
3164
+ }
3165
+ // For Windows and Linux, add the self-extracting bundle directly
3166
+ // @ts-expect-error - reserved for future use
3167
+ const _platformBundlePath = join(
3168
+ buildFolder,
3169
+ `${appFileName}${platformSuffix}${targetOS === "win" ? ".exe" : ""}`,
3170
+ );
3171
+ // Copy the self-extracting bundle to platform-specific filename
3172
+ if (targetOS === "win" || targetOS === "linux") {
3173
+ const installerPath = await createCompiledInstaller(
3174
+ buildFolder,
3175
+ compressedTarPath,
3176
+ appFileName,
3177
+ targetPaths,
3178
+ buildEnvironment,
3179
+ hash,
3180
+ config,
3181
+ projectRoot,
3182
+ );
3183
+ artifactsToUpload.push(installerPath);
3184
+ }
3185
+
3186
+ }
3187
+
3188
+ // refresh artifacts folder
3189
+ console.log("creating artifacts folder...");
3190
+ if (existsSync(artifactFolder)) {
3191
+ console.info("deleting artifact folder: ", artifactFolder);
3192
+ rmSync(artifactFolder, { recursive: true });
3193
+ }
3194
+
3195
+ mkdirSync(artifactFolder, { recursive: true });
3196
+
3197
+ console.log("creating update.json...");
3198
+ // update.json for the channel in that channel's build folder
3199
+ const updateJsonContent = JSON.stringify({
3200
+ // The version isn't really used for updating, but it's nice to have for
3201
+ // the download button or display on your marketing site or in the app.
3202
+ version: config.app.version,
3203
+ hash: hash.toString(),
3204
+ platform: OS,
3205
+ arch: ARCH,
3206
+ // channel: buildEnvironment,
3207
+ // baseUrl: config.release.baseUrl
3208
+ });
3209
+
3210
+ // update.json with platform prefix for flat naming structure
3211
+ await Bun.write(
3212
+ join(artifactFolder, `${platformPrefix}-update.json`),
3213
+ updateJsonContent,
3214
+ );
3215
+
3216
+ // compress all the upload files
3217
+ console.log("moving artifacts...");
3218
+
3219
+ artifactsToUpload.forEach((filePath) => {
3220
+ const filename = basename(filePath);
3221
+ const usePrefix = !filename.endsWith(".deb");
3222
+ const destination = join(
3223
+ artifactFolder,
3224
+ usePrefix ? `${platformPrefix}-${filename}` : filename,
3225
+ );
3226
+ try {
3227
+ renameSync(filePath, destination);
3228
+ } catch {
3229
+ cpSync(filePath, destination, { dereference: true });
3230
+ if (existsSync(filePath)) {
3231
+ unlinkSync(filePath);
3232
+ }
3233
+ }
3234
+ });
3235
+
3236
+ // todo: now just upload the artifacts to your bucket replacing the ones that exist
3237
+ // you'll end up with a sequence of patch files that will
3238
+ }
3239
+
3240
+ // Run postPackage hook at the very end of the build process
3241
+ runHook("postPackage");
3242
+
3243
+ // NOTE: verify codesign
3244
+ // codesign --verify --deep --strict --verbose=2 <app path>
3245
+
3246
+ // Note: verify notarization
3247
+ // spctl --assess --type execute --verbose <app path>
3248
+
3249
+ // Note: for .dmg spctl --assess will respond with "rejected (*the code is valid* but does not seem to be an app)" which is valid
3250
+ // an actual failed response for a dmg is "source=no usable signature"
3251
+ // for a dmg.
3252
+ // can also use stapler validate -v to validate the dmg and look for teamId, signingId, and the response signedTicket
3253
+ // stapler validate -v <app path>
3254
+ }
3255
+
3256
+ // Take over as the terminal's foreground process group (macOS/Linux).
3257
+ // This prevents the parent bun script runner from receiving SIGINT
3258
+ // when Ctrl+C is pressed, keeping the terminal busy until the app
3259
+ // finishes shutting down gracefully.
3260
+ // Call once per CLI session — returns a restore function.
3261
+ async function takeoverForeground(): Promise<() => void> {
3262
+ let restoreFn = () => {};
3263
+ if (OS === "win") return restoreFn;
3264
+ try {
3265
+ const { dlopen, ptr } = await import("bun:ffi");
3266
+ const libName = OS === "macos" ? "libSystem.B.dylib" : "libc.so.6";
3267
+ const libc = dlopen(libName, {
3268
+ open: { args: ["ptr", "i32"], returns: "i32" },
3269
+ close: { args: ["i32"], returns: "i32" },
3270
+ getpid: { args: [], returns: "i32" },
3271
+ setpgid: { args: ["i32", "i32"], returns: "i32" },
3272
+ tcgetpgrp: { args: ["i32"], returns: "i32" },
3273
+ tcsetpgrp: { args: ["i32", "i32"], returns: "i32" },
3274
+ signal: { args: ["i32", "ptr"], returns: "ptr" },
3275
+ });
3276
+
3277
+ const ttyPathBuf = new Uint8Array(Buffer.from("/dev/tty\0"));
3278
+ const ttyFd = libc.symbols.open(ptr(ttyPathBuf), 2); // O_RDWR
3279
+
3280
+ if (ttyFd >= 0) {
3281
+ const originalPgid = libc.symbols.tcgetpgrp(ttyFd);
3282
+ if (originalPgid >= 0) {
3283
+ // Ignore SIGTTOU at C level so tcsetpgrp works from background group.
3284
+ // bun's process.on("SIGTTOU") doesn't set the C-level disposition.
3285
+ // SIG_IGN = (void(*)(int))1, SIGTTOU = 22 on macOS/Linux
3286
+ libc.symbols.signal(22, 1);
3287
+
3288
+ if (libc.symbols.setpgid(0, 0) === 0) {
3289
+ const myPid = libc.symbols.getpid();
3290
+ if (libc.symbols.tcsetpgrp(ttyFd, myPid) === 0) {
3291
+ restoreFn = () => {
3292
+ try {
3293
+ libc.symbols.signal(22, 1);
3294
+ libc.symbols.tcsetpgrp(ttyFd, originalPgid);
3295
+ libc.symbols.close(ttyFd);
3296
+ } catch {}
3297
+ };
3298
+ } else {
3299
+ libc.symbols.setpgid(0, originalPgid);
3300
+ libc.symbols.close(ttyFd);
3301
+ }
3302
+ } else {
3303
+ libc.symbols.close(ttyFd);
3304
+ }
3305
+ } else {
3306
+ libc.symbols.close(ttyFd);
3307
+ }
3308
+ }
3309
+ } catch {
3310
+ // Fall back to default behavior (prompt may return early on Ctrl+C)
3311
+ }
3312
+ return restoreFn;
3313
+ }
3314
+
3315
+ async function runApp(
3316
+ config: Awaited<ReturnType<typeof getConfig>>,
3317
+ options?: { onExit?: () => void },
3318
+ ): Promise<{ kill: () => void; exited: Promise<number> }> {
3319
+ // Launch the already-built dev bundle
3320
+
3321
+ const buildEnvironment = "dev";
3322
+ const appFileName = getAppFileName(config.app.name, buildEnvironment);
3323
+ const macOSBundleDisplayName = getMacOSBundleDisplayName(
3324
+ config.app.name,
3325
+ buildEnvironment,
3326
+ );
3327
+ const buildSubFolder = `${buildEnvironment}-${OS}-${ARCH}`;
3328
+ const buildFolder = join(
3329
+ projectRoot,
3330
+ config.build.buildFolder,
3331
+ buildSubFolder,
3332
+ );
3333
+ const bundleFileName =
3334
+ OS === "macos" ? `${macOSBundleDisplayName}.app` : appFileName;
3335
+
3336
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3337
+ let mainProc: any;
3338
+ let bundleExecPath: string;
3339
+ // @ts-expect-error - reserved for future use
3340
+ let _bundleResourcesPath: string;
3341
+ if (OS === "macos") {
3342
+ bundleExecPath = join(buildFolder, bundleFileName, "Contents", "MacOS");
3343
+ _bundleResourcesPath = join(
3344
+ buildFolder,
3345
+ bundleFileName,
3346
+ "Contents",
3347
+ "Resources",
3348
+ );
3349
+ } else if (OS === "linux") {
3350
+ bundleExecPath = join(buildFolder, bundleFileName, "bin");
3351
+ _bundleResourcesPath = join(buildFolder, bundleFileName, "Resources");
3352
+ } else if (OS === "win") {
3353
+ bundleExecPath = join(buildFolder, bundleFileName, "bin");
3354
+ _bundleResourcesPath = join(buildFolder, bundleFileName, "Resources");
3355
+ } else {
3356
+ throw new Error(`Unsupported OS: ${OS}`);
3357
+ }
3358
+
3359
+ if (OS === "macos" || OS === "linux") {
3360
+ // For Linux dev mode, update libNativeWrapper.so based on bundleCEF setting
3361
+ if (OS === "linux") {
3362
+ const currentLibPath = join(bundleExecPath, "libNativeWrapper.so");
3363
+ const targetPaths = getPlatformPaths("linux", ARCH);
3364
+ const correctLibSource = config.build.linux?.bundleCEF
3365
+ ? targetPaths.NATIVE_WRAPPER_LINUX_CEF
3366
+ : targetPaths.NATIVE_WRAPPER_LINUX;
3367
+
3368
+ if (existsSync(correctLibSource)) {
3369
+ try {
3370
+ cpSync(correctLibSource, currentLibPath, { dereference: true });
3371
+ console.log(
3372
+ `Updated libNativeWrapper.so for ${config.build.linux?.bundleCEF ? "CEF (with weak linking)" : "GTK-only"} mode`,
3373
+ );
3374
+ } catch (error) {
3375
+ console.warn("Failed to update libNativeWrapper.so:", error);
3376
+ }
3377
+ }
3378
+ }
3379
+
3380
+ mainProc = Bun.spawn([join(bundleExecPath, config.app.name.replace(/ /g, ""))], {
3381
+ stdio: ["inherit", "inherit", "inherit"],
3382
+ cwd: bundleExecPath,
3383
+ });
3384
+ } else if (OS === "win") {
3385
+ mainProc = Bun.spawn([join(bundleExecPath, "launcher.exe")], {
3386
+ stdio: ["inherit", "inherit", "inherit"],
3387
+ cwd: bundleExecPath,
3388
+ });
3389
+ }
3390
+
3391
+ if (!mainProc) {
3392
+ throw new Error("Failed to spawn app process");
3393
+ }
3394
+
3395
+ const exitedPromise = mainProc.exited.then((code: number) => {
3396
+ options?.onExit?.();
3397
+ return code ?? 0;
3398
+ });
3399
+
3400
+ return {
3401
+ kill: () => {
3402
+ try {
3403
+ mainProc.kill();
3404
+ } catch {}
3405
+ },
3406
+ exited: exitedPromise,
3407
+ };
3408
+ }
3409
+
3410
+ async function runAppWithSignalHandling(
3411
+ config: Awaited<ReturnType<typeof getConfig>>,
3412
+ ) {
3413
+ const restoreForeground = await takeoverForeground();
3414
+ const handle = await runApp(config);
3415
+
3416
+ let sigintCount = 0;
3417
+ process.on("SIGINT", () => {
3418
+ sigintCount++;
3419
+ if (sigintCount === 1) {
3420
+ console.log(
3421
+ "\n[sparkbun dev] Shutting down gracefully... (press Ctrl+C again to force quit)",
3422
+ );
3423
+ } else {
3424
+ console.log("\n[sparkbun dev] Force quitting...");
3425
+ try {
3426
+ process.kill(0, "SIGKILL");
3427
+ } catch {}
3428
+ process.exit(0);
3429
+ }
3430
+ });
3431
+
3432
+ const code = await handle.exited;
3433
+ restoreForeground();
3434
+ process.exit(code);
3435
+ }
3436
+
3437
+ async function runDevWatch(config: Awaited<ReturnType<typeof getConfig>>) {
3438
+ const { watch } = await import("fs");
3439
+
3440
+ // Collect watch directories from config entrypoints
3441
+ const watchDirs = new Set<string>();
3442
+
3443
+ // Bun entrypoint directory
3444
+ if (config.build.bun?.entrypoint) {
3445
+ watchDirs.add(join(projectRoot, dirname(config.build.bun.entrypoint)));
3446
+ }
3447
+
3448
+ // View entrypoint directories
3449
+ if (config.build.views) {
3450
+ for (const viewConfig of Object.values(config.build.views)) {
3451
+ if (viewConfig.entrypoint) {
3452
+ watchDirs.add(join(projectRoot, dirname(viewConfig.entrypoint)));
3453
+ }
3454
+ }
3455
+ }
3456
+
3457
+ // Copy source directories
3458
+ if (config.build.copy) {
3459
+ for (const src of Object.keys(config.build.copy)) {
3460
+ const srcPath = join(projectRoot, src);
3461
+ try {
3462
+ const stat = statSync(srcPath);
3463
+ watchDirs.add(stat.isDirectory() ? srcPath : dirname(srcPath));
3464
+ } catch {
3465
+ watchDirs.add(dirname(srcPath));
3466
+ }
3467
+ }
3468
+ }
3469
+
3470
+ // User-specified additional watch paths
3471
+ if (config.build.watch) {
3472
+ for (const entry of config.build.watch) {
3473
+ const entryPath = join(projectRoot, entry);
3474
+ try {
3475
+ const stat = statSync(entryPath);
3476
+ watchDirs.add(stat.isDirectory() ? entryPath : dirname(entryPath));
3477
+ } catch {
3478
+ // Path doesn't exist yet — watch its parent directory
3479
+ watchDirs.add(dirname(entryPath));
3480
+ }
3481
+ }
3482
+ }
3483
+
3484
+ // Deduplicate overlapping directories (remove children if parent is watched)
3485
+ const sortedDirs = [...watchDirs].sort();
3486
+ const dedupedDirs = sortedDirs.filter((dir, i) => {
3487
+ return !sortedDirs.some(
3488
+ (other, j) => j < i && dir.startsWith(other + "/"),
3489
+ );
3490
+ });
3491
+
3492
+ if (dedupedDirs.length === 0) {
3493
+ console.error(
3494
+ "[sparkbun dev --watch] No directories to watch. Check your config entrypoints.",
3495
+ );
3496
+ process.exit(1);
3497
+ }
3498
+
3499
+ console.log(`
3500
+ ╔══════════════════════════════════════════════════════════════╗
3501
+ ║ SPARKBUN DEV --watch ║
3502
+ ║ Watching ${String(dedupedDirs.length).padEnd(2)} director${dedupedDirs.length === 1 ? "y " : "ies"} ║
3503
+ ╚══════════════════════════════════════════════════════════════╝
3504
+ `);
3505
+ for (const dir of dedupedDirs) {
3506
+ console.log(` ${dir}`);
3507
+ }
3508
+
3509
+ // Set up terminal foreground takeover once for the whole session
3510
+ const restoreForeground = await takeoverForeground();
3511
+
3512
+ // Paths to ignore in file watcher (build output, node_modules, artifacts)
3513
+ const buildDir = join(projectRoot, config.build.buildFolder);
3514
+ const artifactDir = join(projectRoot, config.build.artifactFolder);
3515
+ const ignoreDirs = [
3516
+ buildDir,
3517
+ artifactDir,
3518
+ join(projectRoot, "node_modules"),
3519
+ ];
3520
+
3521
+ // Compile watchIgnore glob patterns
3522
+ const ignoreGlobs = (config.build.watchIgnore || []).map(
3523
+ (pattern) => new Bun.Glob(pattern),
3524
+ );
3525
+
3526
+ function shouldIgnore(fullPath: string): boolean {
3527
+ const resolvedFullPath = path.resolve(fullPath);
3528
+ const pathSegments = resolvedFullPath.split(path.sep).filter(Boolean);
3529
+ const genericIgnoredSegments = new Set([
3530
+ "node_modules",
3531
+ path.basename(buildDir),
3532
+ path.basename(artifactDir),
3533
+ ".sparkbun-cache",
3534
+ ]);
3535
+ if (pathSegments.some((segment) => genericIgnoredSegments.has(segment))) {
3536
+ return true;
3537
+ }
3538
+ // Check built-in ignore dirs
3539
+ if (
3540
+ ignoreDirs.some(
3541
+ (ignored) => {
3542
+ const relativeToIgnored = path.relative(ignored, resolvedFullPath);
3543
+ return (
3544
+ relativeToIgnored === "" ||
3545
+ (!relativeToIgnored.startsWith("..") &&
3546
+ !path.isAbsolute(relativeToIgnored))
3547
+ );
3548
+ },
3549
+ )
3550
+ ) {
3551
+ return true;
3552
+ }
3553
+ // Check user-configured watchIgnore globs (match against project-relative path)
3554
+ const relativePath = path
3555
+ .relative(projectRoot, resolvedFullPath)
3556
+ .split(path.sep)
3557
+ .join("/");
3558
+ if (ignoreGlobs.some((glob) => glob.match(relativePath))) {
3559
+ return true;
3560
+ }
3561
+ return false;
3562
+ }
3563
+
3564
+ let appHandle: { kill: () => void; exited: Promise<number> } | null = null;
3565
+ let lastChangedFile = "";
3566
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
3567
+ let shuttingDown = false;
3568
+ let isBuilding = false;
3569
+ let rebuildPending = false;
3570
+ let watchers: ReturnType<typeof watch>[] = [];
3571
+
3572
+ function startWatchers() {
3573
+ for (const dir of dedupedDirs) {
3574
+ const watcher = watch(dir, { recursive: true }, (_event, filename) => {
3575
+ if (shuttingDown) return;
3576
+
3577
+ if (filename) {
3578
+ const fullPath = join(dir, filename);
3579
+ if (shouldIgnore(fullPath)) {
3580
+ return;
3581
+ }
3582
+ lastChangedFile = fullPath;
3583
+ }
3584
+
3585
+ if (debounceTimer) clearTimeout(debounceTimer);
3586
+ debounceTimer = setTimeout(() => {
3587
+ triggerRebuild();
3588
+ }, 300);
3589
+ });
3590
+ watchers.push(watcher);
3591
+ }
3592
+ }
3593
+
3594
+ function stopWatchers() {
3595
+ for (const watcher of watchers) {
3596
+ try { watcher.close(); } catch {}
3597
+ }
3598
+ watchers = [];
3599
+ }
3600
+
3601
+ async function triggerRebuild() {
3602
+ if (shuttingDown) return;
3603
+
3604
+ // Guard against concurrent builds — if already building, mark
3605
+ // that another rebuild is needed and let the current one finish.
3606
+ if (isBuilding) {
3607
+ rebuildPending = true;
3608
+ return;
3609
+ }
3610
+ isBuilding = true;
3611
+ rebuildPending = false;
3612
+
3613
+ // Stop watching during build so build output doesn't trigger more events
3614
+ stopWatchers();
3615
+
3616
+ // Cancel any lingering debounce timer that may have been queued
3617
+ // before stopWatchers took effect.
3618
+ if (debounceTimer) {
3619
+ clearTimeout(debounceTimer);
3620
+ debounceTimer = null;
3621
+ }
3622
+
3623
+ const changedDisplay = lastChangedFile
3624
+ ? lastChangedFile.replace(projectRoot + "/", "")
3625
+ : "unknown";
3626
+ console.log(`
3627
+ ╔══════════════════════════════════════════════════════════════╗
3628
+ ║ FILE CHANGED: ${changedDisplay.padEnd(44)}║
3629
+ ║ Rebuilding... ║
3630
+ ╚══════════════════════════════════════════════════════════════╝
3631
+ `);
3632
+
3633
+ // Kill running app if any
3634
+ if (appHandle) {
3635
+ appHandle.kill();
3636
+ try {
3637
+ await appHandle.exited;
3638
+ } catch {}
3639
+ appHandle = null;
3640
+ }
3641
+
3642
+ try {
3643
+ await runBuild(config, "dev");
3644
+ console.log(
3645
+ "[sparkbun dev --watch] Build succeeded, launching app...",
3646
+ );
3647
+
3648
+ appHandle = await runApp(config, {
3649
+ onExit: () => {
3650
+ appHandle = null;
3651
+ },
3652
+ });
3653
+ } catch (error) {
3654
+ console.error("[sparkbun dev --watch] Build failed:", error);
3655
+ console.log("[sparkbun dev --watch] Waiting for file changes...");
3656
+ }
3657
+
3658
+ isBuilding = false;
3659
+
3660
+ // Resume watching after build + hooks are done
3661
+ if (!shuttingDown) {
3662
+ startWatchers();
3663
+ }
3664
+
3665
+ // If a file change came in while we were building, rebuild again.
3666
+ if (rebuildPending && !shuttingDown) {
3667
+ triggerRebuild();
3668
+ }
3669
+ }
3670
+
3671
+ function cleanup() {
3672
+ shuttingDown = true;
3673
+ if (debounceTimer) clearTimeout(debounceTimer);
3674
+ stopWatchers();
3675
+ if (appHandle) {
3676
+ appHandle.kill();
3677
+ }
3678
+ restoreForeground();
3679
+ }
3680
+
3681
+ // Ctrl+C handling for watch mode
3682
+ let sigintCount = 0;
3683
+ process.on("SIGINT", () => {
3684
+ sigintCount++;
3685
+ if (sigintCount === 1) {
3686
+ console.log(
3687
+ "\n[sparkbun dev --watch] Shutting down... (press Ctrl+C again to force quit)",
3688
+ );
3689
+ cleanup();
3690
+ // Wait briefly for app to exit, then exit
3691
+ setTimeout(() => process.exit(0), 2000);
3692
+ } else {
3693
+ try {
3694
+ process.kill(0, "SIGKILL");
3695
+ } catch {}
3696
+ process.exit(0);
3697
+ }
3698
+ });
3699
+
3700
+ // Initial build + launch (watchers start after build completes)
3701
+ try {
3702
+ await runBuild(config, "dev");
3703
+ appHandle = await runApp(config, {
3704
+ onExit: () => {
3705
+ appHandle = null;
3706
+ },
3707
+ });
3708
+ } catch (error) {
3709
+ console.error("[sparkbun dev --watch] Initial build failed:", error);
3710
+ console.log("[sparkbun dev --watch] Waiting for file changes...");
3711
+ }
3712
+
3713
+ // Start watching only after initial build + all hooks are done
3714
+ startWatchers();
3715
+
3716
+ // Keep the process alive
3717
+ await new Promise(() => {});
3718
+ }
3719
+
3720
+ // Helper functions
3721
+
3722
+ function formatBuildLogEntry(entry: any): string {
3723
+ if (!entry || typeof entry !== "object") return String(entry);
3724
+ const level = entry.level || "error";
3725
+ let message = entry.message || entry.text || String(entry);
3726
+ if (entry.location) {
3727
+ const loc = entry.location;
3728
+ const file = loc.file || loc.path || "unknown";
3729
+ const line = loc.line ?? loc.lineText ?? loc.lineNumber ?? "?";
3730
+ const col = loc.column ?? loc.col ?? loc.columnNumber ?? "?";
3731
+ message += ` (${file}:${line}:${col})`;
3732
+ }
3733
+ return `[bun.build:${level}] ${message}`;
3734
+ }
3735
+
3736
+ function printBuildLogs(logs: any[] | undefined | null) {
3737
+ if (!logs || logs.length === 0) {
3738
+ console.error("[bun.build] No logs returned from Bun.build");
3739
+ return;
3740
+ }
3741
+ for (const entry of logs) {
3742
+ console.error(formatBuildLogEntry(entry));
3743
+ if (entry?.notes?.length) {
3744
+ for (const note of entry.notes) {
3745
+ console.error(` note: ${note.text ?? String(note)}`);
3746
+ }
3747
+ }
3748
+ }
3749
+ }
3750
+
3751
+ async function getConfig() {
3752
+ let loadedConfig: Partial<typeof defaultConfig> & Record<string, unknown> =
3753
+ {};
3754
+ const foundConfigPath = findConfigFile();
3755
+
3756
+ if (foundConfigPath) {
3757
+ console.log(`Using config file: ${basename(foundConfigPath)}`);
3758
+
3759
+ try {
3760
+ // Use dynamic import for TypeScript ESM files
3761
+ // Bun handles TypeScript natively, no transpilation needed
3762
+ const configModule = await import(foundConfigPath);
3763
+ loadedConfig = configModule.default || configModule;
3764
+
3765
+ // Validate that we got a valid config object
3766
+ if (!loadedConfig || typeof loadedConfig !== "object") {
3767
+ console.error("Config file must export a default object");
3768
+ console.error("using default config instead");
3769
+ loadedConfig = {};
3770
+ }
3771
+ } catch (error) {
3772
+ console.error("Failed to load config file:", error);
3773
+ console.error("using default config instead");
3774
+ }
3775
+ }
3776
+
3777
+ return {
3778
+ ...defaultConfig,
3779
+ ...loadedConfig,
3780
+ app: {
3781
+ ...defaultConfig.app,
3782
+ ...(loadedConfig?.app || {}),
3783
+ },
3784
+ build: {
3785
+ ...defaultConfig.build,
3786
+ ...(loadedConfig?.build || {}),
3787
+ mac: {
3788
+ ...defaultConfig.build.mac,
3789
+ ...(loadedConfig?.build?.mac || {}),
3790
+ entitlements: {
3791
+ ...defaultConfig.build.mac.entitlements,
3792
+ ...(loadedConfig?.build?.mac?.entitlements || {}),
3793
+ },
3794
+ },
3795
+ win: {
3796
+ ...defaultConfig.build.win,
3797
+ ...(loadedConfig?.build?.win || {}),
3798
+ },
3799
+ linux: {
3800
+ ...defaultConfig.build.linux,
3801
+ ...(loadedConfig?.build?.linux || {}),
3802
+ },
3803
+ bun: {
3804
+ ...defaultConfig.build.bun,
3805
+ ...(loadedConfig?.build?.bun || {}),
3806
+ },
3807
+ zig: {
3808
+ ...defaultConfig.build.zig,
3809
+ ...(loadedConfig?.build?.zig || {}),
3810
+ },
3811
+ },
3812
+ runtime: {
3813
+ ...defaultConfig.runtime,
3814
+ ...((loadedConfig as Record<string, any>)?.["runtime"] || {}),
3815
+ },
3816
+ scripts: {
3817
+ ...defaultConfig.scripts,
3818
+ ...(loadedConfig?.scripts || {}),
3819
+ },
3820
+ release: {
3821
+ ...defaultConfig.release,
3822
+ ...(loadedConfig?.release || {}),
3823
+ },
3824
+ };
3825
+ }
3826
+
3827
+ function buildEntitlementsFile(
3828
+ entitlements: Record<string, boolean | string | string[]>,
3829
+ ) {
3830
+ return `<?xml version="1.0" encoding="UTF-8"?>
3831
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3832
+ <plist version="1.0">
3833
+ <dict>
3834
+ ${Object.keys(entitlements)
3835
+ .map((key) => {
3836
+ return `<key>${key}</key>\n${getEntitlementValue(entitlements[key]!)}`;
3837
+ })
3838
+ .join("\n")}
3839
+ </dict>
3840
+ </plist>
3841
+ `;
3842
+ }
3843
+
3844
+ function getEntitlementValue(value: boolean | string | string[]) {
3845
+ if (typeof value === "boolean") {
3846
+ return `<${value.toString()}/>`;
3847
+ } else if (Array.isArray(value)) {
3848
+ return `<array>\n${value.map((v) => ` <string>${v}</string>`).join("\n")}\n </array>`;
3849
+ } else {
3850
+ return `<string>${value}</string>`;
3851
+ }
3852
+ }
3853
+
3854
+ async function createCompiledInstaller(
3855
+ buildFolder: string,
3856
+ compressedTarPath: string,
3857
+ _appFileName: string,
3858
+ _targetPaths: any,
3859
+ buildEnvironment: string,
3860
+ hash: string,
3861
+ config: any,
3862
+ projectRoot: string,
3863
+ ): Promise<string> {
3864
+ const targetOSName = OS === "macos" ? "darwin" : OS === "win" ? "windows" : "linux";
3865
+ const isWindows = OS === "win";
3866
+
3867
+ const setupFileName = isWindows
3868
+ ? getWindowsSetupFileName(config.app.name, buildEnvironment)
3869
+ : _appFileName;
3870
+ const outputPath = join(buildFolder, setupFileName + (isWindows ? "" : ""));
3871
+
3872
+ console.log(`Creating ${isWindows ? "Windows" : "Linux"} installer via Bun.build()...`);
3873
+
3874
+ // Stage installer files
3875
+ const stagingDir = join(buildFolder, ".installer-staging");
3876
+ if (existsSync(stagingDir)) rmSync(stagingDir, { recursive: true, force: true });
3877
+ mkdirSync(stagingDir, { recursive: true });
3878
+
3879
+ // Copy archive
3880
+ copyFileSync(compressedTarPath, join(stagingDir, "app-archive.tar.gz"));
3881
+
3882
+ // Write metadata
3883
+ const platformConfig = OS === "macos" ? config.build?.mac
3884
+ : OS === "win" ? config.build?.win
3885
+ : config.build?.linux;
3886
+ const metadata = {
3887
+ identifier: config.app.identifier,
3888
+ name: config.app.name,
3889
+ channel: buildEnvironment,
3890
+ hash: hash,
3891
+ ...(platformConfig?.requireAdmin ? { requireAdmin: true } : {}),
3892
+ };
3893
+ writeFileSync(join(stagingDir, "metadata.json"), JSON.stringify(metadata, null, 2));
3894
+
3895
+ // Copy installer template
3896
+ const cliDir = dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"));
3897
+ const templatePath = join(cliDir, "..", "installer", "installer-template.ts");
3898
+ copyFileSync(templatePath, join(stagingDir, "installer.ts"));
3899
+
3900
+ const installerCompileOptions: any = {
3901
+ target: `bun-${targetOSName}-${ARCH}`,
3902
+ outfile: outputPath,
3903
+ };
3904
+
3905
+ if (isWindows) {
3906
+ let icoPath: string | undefined;
3907
+ if (config.build.win?.icon) {
3908
+ const iconSrc = config.build.win.icon.startsWith("/") || config.build.win.icon.match(/^[a-zA-Z]:/)
3909
+ ? config.build.win.icon
3910
+ : join(projectRoot, config.build.win.icon);
3911
+ if (existsSync(iconSrc)) {
3912
+ icoPath = iconSrc;
3913
+ if (iconSrc.toLowerCase().endsWith(".png")) {
3914
+ const pngToIco = (await import("png-to-ico")).default;
3915
+ const tempIcoPath = join(buildFolder, "temp-installer-icon.ico");
3916
+ const icoBuffer = await pngToIco(iconSrc);
3917
+ writeFileSync(tempIcoPath, new Uint8Array(icoBuffer));
3918
+ icoPath = tempIcoPath;
3919
+ }
3920
+ }
3921
+ }
3922
+ installerCompileOptions.windows = {
3923
+ hideConsole: true,
3924
+ ...(icoPath && { icon: icoPath }),
3925
+ title: `${config.app.name} Installer`,
3926
+ version: config.app.version,
3927
+ description: `Installs ${config.app.name}`,
3928
+ publisher: config.app.publisher || " ",
3929
+ copyright: config.app.copyright || " ",
3930
+ };
3931
+ }
3932
+
3933
+ console.log("Compiling installer...");
3934
+ const installerBuild = await Bun.build({
3935
+ entrypoints: [join(stagingDir, "installer.ts")],
3936
+ compile: installerCompileOptions,
3937
+ });
3938
+ if (!installerBuild.success) {
3939
+ console.error("Installer compilation failed:", installerBuild.logs);
3940
+ throw new Error("Installer compilation failed");
3941
+ }
3942
+
3943
+ if (isWindows) {
3944
+ patchPeSubsystem(outputPath);
3945
+ } else {
3946
+ execSync(`chmod +x ${escapePathForTerminal(outputPath)}`);
3947
+ }
3948
+
3949
+ // Clean up staging
3950
+ rmSync(stagingDir, { recursive: true, force: true });
3951
+
3952
+ const exeSize = statSync(outputPath).size;
3953
+ console.log(`Created installer: ${outputPath} (${(exeSize / 1024 / 1024).toFixed(2)} MB)`);
3954
+
3955
+ return outputPath;
3956
+ }
3957
+
3958
+ function createArHeader(name: string, size: number, mode: number = 0o100644): Buffer {
3959
+ const header = Buffer.alloc(60, 0x20); // fill with spaces
3960
+ const now = Math.floor(Date.now() / 1000).toString();
3961
+ Buffer.from(name.padEnd(16, " ")).copy(header, 0); // filename
3962
+ Buffer.from(now.padEnd(12, " ")).copy(header, 16); // timestamp
3963
+ Buffer.from("0".padEnd(6, " ")).copy(header, 28); // owner
3964
+ Buffer.from("0".padEnd(6, " ")).copy(header, 34); // group
3965
+ Buffer.from(mode.toString(8).padEnd(8, " ")).copy(header, 40); // mode
3966
+ Buffer.from(size.toString().padEnd(10, " ")).copy(header, 48); // size
3967
+ header[58] = 0x60; // magic
3968
+ header[59] = 0x0A;
3969
+ return header;
3970
+ }
3971
+
3972
+ function buildArArchive(members: { name: string; data: Buffer; mode?: number }[]): Buffer {
3973
+ const magic = Buffer.from("!<arch>\n");
3974
+ const parts: Buffer[] = [magic];
3975
+ for (const member of members) {
3976
+ parts.push(createArHeader(member.name, member.data.length, member.mode));
3977
+ parts.push(member.data);
3978
+ if (member.data.length % 2 !== 0) {
3979
+ parts.push(Buffer.from("\n"));
3980
+ }
3981
+ }
3982
+ return Buffer.concat(parts);
3983
+ }
3984
+
3985
+ function collectFiles(dir: string, prefix: string = ""): { path: string; fullPath: string; isDir: boolean }[] {
3986
+ const results: { path: string; fullPath: string; isDir: boolean }[] = [];
3987
+ const entries = readdirSync(dir, { withFileTypes: true });
3988
+ for (const entry of entries) {
3989
+ const fullPath = join(dir, entry.name);
3990
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
3991
+ if (entry.isDirectory()) {
3992
+ results.push({ path: relPath + "/", fullPath, isDir: true });
3993
+ results.push(...collectFiles(fullPath, relPath));
3994
+ } else {
3995
+ results.push({ path: relPath, fullPath, isDir: false });
3996
+ }
3997
+ }
3998
+ return results;
3999
+ }
4000
+
4001
+ function writeTarHeader(header: Buffer, file: { path: string; fullPath: string; isDir: boolean }, content?: Buffer): void {
4002
+ const tarPath = file.path;
4003
+ Buffer.from(tarPath).copy(header, 0, 0, Math.min(tarPath.length, 100));
4004
+
4005
+ // mtime from actual file
4006
+ const mtime = Math.floor(statSync(file.fullPath).mtimeMs / 1000);
4007
+ Buffer.from(mtime.toString(8).padStart(11, "0") + "\0").copy(header, 136);
4008
+
4009
+ // uid/gid = 0 (root)
4010
+ Buffer.from("0000000\0").copy(header, 108);
4011
+ Buffer.from("0000000\0").copy(header, 116);
4012
+
4013
+ // ustar magic
4014
+ Buffer.from("ustar\0").copy(header, 257);
4015
+ Buffer.from("00").copy(header, 263);
4016
+ Buffer.from("root\0").copy(header, 265);
4017
+ Buffer.from("root\0").copy(header, 297);
4018
+
4019
+ if (file.isDir) {
4020
+ Buffer.from("0040755\0").copy(header, 100);
4021
+ Buffer.from("00000000000\0").copy(header, 124);
4022
+ header[156] = 0x35; // '5'
4023
+ } else {
4024
+ const isExecutable = file.path.match(/\/bin\/|\/postinst$|\/preinst$|\/postrm$|\/prerm$/);
4025
+ Buffer.from(isExecutable ? "0100755\0" : "0100644\0").copy(header, 100);
4026
+ Buffer.from((content!.length).toString(8).padStart(11, "0") + "\0").copy(header, 124);
4027
+ header[156] = 0x30; // '0'
4028
+ }
4029
+
4030
+ // checksum (must be computed last)
4031
+ Buffer.from(" ").copy(header, 148);
4032
+ let checksum = 0;
4033
+ for (let i = 0; i < 512; i++) checksum += header[i];
4034
+ Buffer.from(checksum.toString(8).padStart(6, "0") + "\0 ").copy(header, 148);
4035
+ }
4036
+
4037
+ function buildTarGz(baseDir: string, prefix: string = "./"): Buffer {
4038
+ const files = collectFiles(baseDir);
4039
+ const blocks: Buffer[] = [];
4040
+
4041
+ for (const file of files) {
4042
+ const prefixedFile = { ...file, path: prefix + file.path };
4043
+ const header = Buffer.alloc(512, 0);
4044
+
4045
+ if (file.isDir) {
4046
+ writeTarHeader(header, prefixedFile);
4047
+ blocks.push(header);
4048
+ } else {
4049
+ const content = readFileSync(file.fullPath);
4050
+ writeTarHeader(header, prefixedFile, content);
4051
+ blocks.push(header);
4052
+ blocks.push(content);
4053
+ const padding = 512 - (content.length % 512);
4054
+ if (padding < 512) blocks.push(Buffer.alloc(padding, 0));
4055
+ }
4056
+ }
4057
+
4058
+ blocks.push(Buffer.alloc(1024, 0));
4059
+ const tarData = Buffer.concat(blocks);
4060
+ return Buffer.from(Bun.gzipSync(tarData));
4061
+ }
4062
+
4063
+ async function createDebPackage(
4064
+ buildFolder: string,
4065
+ appBundleFolderPath: string,
4066
+ config: any,
4067
+ targetArch: string,
4068
+ projectRoot: string,
4069
+ ): Promise<string> {
4070
+ const debArch = targetArch === "arm64" ? "arm64" : "amd64";
4071
+ const appNameNoSpaces = config.app.name.replace(/ /g, "");
4072
+ const pkgName = config.app.name.toLowerCase().replace(/ /g, "-");
4073
+ const installDir = config.build?.linux?.installDir || `/opt/${appNameNoSpaces}`;
4074
+ const version = config.app.version || "1.0.0";
4075
+ const exeName = appNameNoSpaces;
4076
+
4077
+ const defaultDeps = [
4078
+ "libwebkit2gtk-4.1-0",
4079
+ "libgtk-3-0t64 | libgtk-3-0",
4080
+ "libsoup-3.0-0",
4081
+ "libjavascriptcoregtk-4.1-0",
4082
+ "libayatana-appindicator3-1",
4083
+ "libgdk-pixbuf-2.0-0",
4084
+ ];
4085
+ const deps = config.build?.linux?.debDependencies || defaultDeps;
4086
+ const requireAdmin = config.build?.linux?.requireAdmin;
4087
+ if (requireAdmin && !deps.some((d: string) => d.includes("pkexec"))) {
4088
+ deps.push("pkexec | policykit-1");
4089
+ }
4090
+
4091
+ const maintainer = config.build?.linux?.debMaintainer
4092
+ || config.app.publisher
4093
+ || "Unknown";
4094
+ const category = config.build?.linux?.category || "Utility;Application;";
4095
+
4096
+ console.log(`Creating .deb package (${debArch})...`);
4097
+
4098
+ const stagingDir = join(buildFolder, ".deb-staging");
4099
+ if (existsSync(stagingDir)) rmSync(stagingDir, { recursive: true, force: true });
4100
+
4101
+ // Staging directories: control files and data files separately
4102
+ const controlDir = join(stagingDir, "control");
4103
+ const dataDir = join(stagingDir, "data");
4104
+ const appDestDir = join(dataDir, installDir.slice(1));
4105
+ const applicationsDir = join(dataDir, "usr", "share", "applications");
4106
+ mkdirSync(controlDir, { recursive: true });
4107
+ mkdirSync(appDestDir, { recursive: true });
4108
+ mkdirSync(applicationsDir, { recursive: true });
4109
+
4110
+ // Copy app files
4111
+ cpSync(appBundleFolderPath, appDestDir, { recursive: true, dereference: true });
4112
+
4113
+ // Patch all WM class occurrences in libNativeWrapper.so
4114
+ const nativeLib = join(appDestDir, "bin", "libNativeWrapper.so");
4115
+ const oldWmName = Buffer.from("SparkBunKitchenSink-dev");
4116
+ const wmClass = config.app.name.slice(0, oldWmName.length);
4117
+ if (existsSync(nativeLib)) {
4118
+ const newName = Buffer.alloc(oldWmName.length, 0);
4119
+ Buffer.from(wmClass).copy(newName);
4120
+ const data = readFileSync(nativeLib);
4121
+ let patched = 0;
4122
+ let offset = 0;
4123
+ while (true) {
4124
+ const idx = data.indexOf(oldWmName, offset);
4125
+ if (idx === -1) break;
4126
+ newName.copy(data, idx);
4127
+ offset = idx + oldWmName.length;
4128
+ patched++;
4129
+ }
4130
+ if (patched > 0) {
4131
+ writeFileSync(nativeLib, data);
4132
+ console.log(` Patched WM class in libNativeWrapper.so (${patched} occurrences)`);
4133
+ }
4134
+ }
4135
+
4136
+ // Install app icon to system icon theme
4137
+ let iconName = pkgName;
4138
+ const resourcesDir = join(appDestDir, "Resources");
4139
+ if (existsSync(resourcesDir)) {
4140
+ const pngs = readdirSync(resourcesDir).filter((f: string) => f.endsWith(".png"));
4141
+ if (pngs.length > 0) {
4142
+ const iconSrc = join(resourcesDir, pngs[0]);
4143
+ for (const size of ["256x256", "128x128"]) {
4144
+ const iconDir = join(dataDir, "usr", "share", "icons", "hicolor", size, "apps");
4145
+ mkdirSync(iconDir, { recursive: true });
4146
+ cpSync(iconSrc, join(iconDir, `${pkgName}.png`));
4147
+ }
4148
+ console.log(" Installed app icon to system icon theme");
4149
+ }
4150
+ }
4151
+
4152
+ // .desktop file with absolute paths
4153
+ const execLine = requireAdmin
4154
+ ? `pkexec ${installDir}/bin/${exeName}`
4155
+ : `${installDir}/bin/${exeName}`;
4156
+
4157
+ const desktopContent = `[Desktop Entry]
4158
+ Version=1.0
4159
+ Type=Application
4160
+ Name=${config.app.name}
4161
+ Comment=${config.app.description || `${config.app.name} application`}
4162
+ Exec=${execLine}
4163
+ Icon=${iconName}
4164
+ Terminal=false
4165
+ StartupWMClass=${wmClass}
4166
+ Categories=${category}
4167
+ `;
4168
+ writeFileSync(join(applicationsDir, `${pkgName}.desktop`), desktopContent);
4169
+
4170
+ // Polkit policy for admin elevation
4171
+ if (requireAdmin) {
4172
+ const policyId = config.app.identifier.replace(/-/g, ".");
4173
+ const policyDir = join(dataDir, "usr", "share", "polkit-1", "actions");
4174
+ mkdirSync(policyDir, { recursive: true });
4175
+ const policyContent = `<?xml version="1.0" encoding="UTF-8"?>
4176
+ <!DOCTYPE policyconfig PUBLIC
4177
+ "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
4178
+ "http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
4179
+ <policyconfig>
4180
+ <action id="${policyId}.run">
4181
+ <description>Run ${config.app.name}</description>
4182
+ <message>Authentication is required to run ${config.app.name}</message>
4183
+ <defaults>
4184
+ <allow_any>auth_admin</allow_any>
4185
+ <allow_inactive>auth_admin</allow_inactive>
4186
+ <allow_active>auth_admin_keep</allow_active>
4187
+ </defaults>
4188
+ <annotate key="org.freedesktop.policykit.exec.path">${installDir}/bin/${exeName}</annotate>
4189
+ <annotate key="org.freedesktop.policykit.exec.allow_gui">true</annotate>
4190
+ </action>
4191
+ </policyconfig>
4192
+ `;
4193
+ writeFileSync(join(policyDir, `${policyId}.policy`), policyContent);
4194
+ }
4195
+
4196
+ // control file — Description format: short synopsis on first line,
4197
+ // long description on subsequent lines indented with a single space
4198
+ const shortDesc = config.app.name;
4199
+ const longDesc = config.app.description
4200
+ ? `\n ${config.app.description.replace(/\n/g, "\n ")}`
4201
+ : "";
4202
+ writeFileSync(join(controlDir, "control"), `Package: ${pkgName}
4203
+ Version: ${version}
4204
+ Section: utils
4205
+ Priority: optional
4206
+ Architecture: ${debArch}
4207
+ Depends: ${deps.join(", ")}
4208
+ Maintainer: ${maintainer}
4209
+ Description: ${shortDesc}${longDesc}
4210
+ `);
4211
+
4212
+ // postinst
4213
+ writeFileSync(join(controlDir, "postinst"), `#!/bin/sh
4214
+ chmod +x ${installDir}/bin/${exeName}
4215
+ chmod +x ${installDir}/bin/bspatch 2>/dev/null || true
4216
+ gtk-update-icon-cache -f -t /usr/share/icons/hicolor 2>/dev/null || true
4217
+ update-desktop-database /usr/share/applications 2>/dev/null || true
4218
+ `);
4219
+
4220
+ // Build .deb as ar archive: debian-binary + control.tar.gz + data.tar.gz
4221
+ const debianBinary = Buffer.from("2.0\n");
4222
+ const controlTarGz = buildTarGz(controlDir, "./");
4223
+ const dataTarGz = buildTarGz(dataDir, "./");
4224
+
4225
+ const debData = buildArArchive([
4226
+ { name: "debian-binary", data: debianBinary },
4227
+ { name: "control.tar.gz", data: controlTarGz },
4228
+ { name: "data.tar.gz", data: dataTarGz },
4229
+ ]);
4230
+
4231
+ const debFileName = `${pkgName}_${version}_${debArch}.deb`;
4232
+ const debOutputPath = join(buildFolder, debFileName);
4233
+ writeFileSync(debOutputPath, debData);
4234
+
4235
+ // Clean up
4236
+ rmSync(stagingDir, { recursive: true, force: true });
4237
+
4238
+ const debSize = statSync(debOutputPath).size;
4239
+ console.log(`Created .deb: ${debOutputPath} (${(debSize / 1024 / 1024).toFixed(2)} MB)`);
4240
+
4241
+ return debOutputPath;
4242
+ }
4243
+
4244
+ function codesignAppBundle(
4245
+ appBundleOrDmgPath: string,
4246
+ entitlementsFilePath: string | undefined,
4247
+ config: Awaited<ReturnType<typeof getConfig>>,
4248
+ ) {
4249
+ console.log("code signing...");
4250
+ if (OS !== "macos" || !config.build.mac.codesign) {
4251
+ return;
4252
+ }
4253
+
4254
+ const SPARKBUN_DEVELOPER_ID = process.env["SPARKBUN_DEVELOPER_ID"];
4255
+
4256
+ if (!SPARKBUN_DEVELOPER_ID) {
4257
+ console.error("Env var SPARKBUN_DEVELOPER_ID is required to codesign");
4258
+ process.exit(1);
4259
+ }
4260
+
4261
+ // If this is a DMG file, sign it directly
4262
+ if (appBundleOrDmgPath.endsWith(".dmg")) {
4263
+ execSync(
4264
+ `codesign --force --verbose --timestamp --sign "${SPARKBUN_DEVELOPER_ID}" ${escapePathForTerminal(
4265
+ appBundleOrDmgPath,
4266
+ )}`,
4267
+ );
4268
+ return;
4269
+ }
4270
+
4271
+ // For app bundles, sign binaries individually to avoid --deep issues with notarization
4272
+ const contentsPath = join(appBundleOrDmgPath, "Contents");
4273
+ const macosPath = join(contentsPath, "MacOS");
4274
+
4275
+ // Prepare entitlements if provided
4276
+ if (entitlementsFilePath) {
4277
+ const entitlementsFileContents = buildEntitlementsFile(
4278
+ config.build.mac.entitlements,
4279
+ );
4280
+ Bun.write(entitlementsFilePath, entitlementsFileContents);
4281
+ }
4282
+
4283
+ // Sign frameworks first (CEF framework requires special handling)
4284
+ const frameworksPath = join(contentsPath, "Frameworks");
4285
+ if (existsSync(frameworksPath)) {
4286
+ try {
4287
+ const frameworks = readdirSync(frameworksPath);
4288
+ for (const framework of frameworks) {
4289
+ if (framework.endsWith(".framework")) {
4290
+ const frameworkPath = join(frameworksPath, framework);
4291
+
4292
+ if (framework === "Chromium Embedded Framework.framework") {
4293
+ console.log(`Signing CEF framework components: ${framework}`);
4294
+
4295
+ // Sign CEF libraries first
4296
+ const librariesPath = join(frameworkPath, "Libraries");
4297
+ if (existsSync(librariesPath)) {
4298
+ const libraries = readdirSync(librariesPath);
4299
+ for (const library of libraries) {
4300
+ if (library.endsWith(".dylib")) {
4301
+ const libraryPath = join(librariesPath, library);
4302
+ console.log(`Signing CEF library: ${library}`);
4303
+ execSync(
4304
+ `codesign --force --verbose --timestamp --sign "${SPARKBUN_DEVELOPER_ID}" --options runtime ${escapePathForTerminal(libraryPath)}`,
4305
+ );
4306
+ }
4307
+ }
4308
+ }
4309
+
4310
+ // CEF helper apps are in the main Frameworks directory, not inside the CEF framework
4311
+ // We'll sign them after signing all frameworks
4312
+ }
4313
+
4314
+ // Sign the framework bundle itself (for CEF and any other frameworks)
4315
+ console.log(`Signing framework bundle: ${framework}`);
4316
+ execSync(
4317
+ `codesign --force --verbose --timestamp --sign "${SPARKBUN_DEVELOPER_ID}" --options runtime ${escapePathForTerminal(frameworkPath)}`,
4318
+ );
4319
+ }
4320
+ }
4321
+ } catch (err) {
4322
+ console.log("Error signing frameworks:", err);
4323
+ throw err; // Re-throw to fail the build since framework signing is critical
4324
+ }
4325
+ }
4326
+
4327
+ // Sign CEF helper apps (they're in the main Frameworks directory, not inside CEF framework)
4328
+ const mainProcess = config.build.mainProcess ?? "bun";
4329
+ const cefHelperApps = getCEFHelperNames().map(
4330
+ (helperName) => `${helperName}.app`,
4331
+ );
4332
+
4333
+ for (const helperApp of cefHelperApps) {
4334
+ const helperPath = join(frameworksPath, helperApp);
4335
+ if (existsSync(helperPath)) {
4336
+ const helperExecutablePath = join(
4337
+ helperPath,
4338
+ "Contents",
4339
+ "MacOS",
4340
+ helperApp.replace(".app", ""),
4341
+ );
4342
+ if (existsSync(helperExecutablePath)) {
4343
+ console.log(`Signing CEF helper executable: ${helperApp}`);
4344
+ const entitlementFlag = entitlementsFilePath
4345
+ ? `--entitlements ${entitlementsFilePath}`
4346
+ : "";
4347
+ execSync(
4348
+ `codesign --force --verbose --timestamp --sign "${SPARKBUN_DEVELOPER_ID}" --options runtime ${entitlementFlag} ${escapePathForTerminal(helperExecutablePath)}`,
4349
+ );
4350
+ }
4351
+
4352
+ console.log(`Signing CEF helper bundle: ${helperApp}`);
4353
+ const entitlementFlag = entitlementsFilePath
4354
+ ? `--entitlements ${entitlementsFilePath}`
4355
+ : "";
4356
+ execSync(
4357
+ `codesign --force --verbose --timestamp --sign "${SPARKBUN_DEVELOPER_ID}" --options runtime ${entitlementFlag} ${escapePathForTerminal(helperPath)}`,
4358
+ );
4359
+ }
4360
+ }
4361
+
4362
+ // Sign all binaries and libraries in MacOS folder and subdirectories
4363
+ console.log("Signing all binaries in MacOS folder...");
4364
+
4365
+ // Recursively find all executables and libraries in MacOS folder
4366
+ function findExecutables(dir: string): string[] {
4367
+ let executables: string[] = [];
4368
+
4369
+ try {
4370
+ const entries = readdirSync(dir, { withFileTypes: true });
4371
+
4372
+ for (const entry of entries) {
4373
+ const fullPath = join(dir, entry.name);
4374
+
4375
+ if (entry.isDirectory()) {
4376
+ // Recursively search subdirectories
4377
+ executables = executables.concat(findExecutables(fullPath));
4378
+ } else if (entry.isFile()) {
4379
+ // Check if it's an executable or library
4380
+ try {
4381
+ const fileInfo = execSync(`file -b ${escapePathForTerminal(fullPath)}`, {
4382
+ encoding: "utf8",
4383
+ }).trim();
4384
+ if (
4385
+ fileInfo.includes("Mach-O") ||
4386
+ entry.name.endsWith(".dylib")
4387
+ ) {
4388
+ executables.push(fullPath);
4389
+ }
4390
+ } catch {
4391
+ // If file command fails, check by extension
4392
+ if (entry.name.endsWith(".dylib") || !entry.name.includes(".")) {
4393
+ // No extension often means executable
4394
+ executables.push(fullPath);
4395
+ }
4396
+ }
4397
+ }
4398
+ }
4399
+ } catch (err) {
4400
+ console.error(`Error scanning directory ${dir}:`, err);
4401
+ }
4402
+
4403
+ return executables;
4404
+ }
4405
+
4406
+ const executablesInMacOS = findExecutables(macosPath);
4407
+
4408
+ // Sign each found executable
4409
+ for (const execPath of executablesInMacOS) {
4410
+ const fileName = basename(execPath);
4411
+ const relativePath = execPath.replace(macosPath + "/", "");
4412
+
4413
+ // Use filename as identifier (without extension)
4414
+ const identifier = fileName.replace(/\.[^.]+$/, "");
4415
+
4416
+ console.log(`Signing ${relativePath} with identifier ${identifier}`);
4417
+ const entitlementFlag = entitlementsFilePath
4418
+ ? `--entitlements ${entitlementsFilePath}`
4419
+ : "";
4420
+
4421
+ try {
4422
+ execSync(
4423
+ `codesign --force --verbose --timestamp --sign "${SPARKBUN_DEVELOPER_ID}" --options runtime --identifier ${identifier} ${entitlementFlag} ${escapePathForTerminal(execPath)}`,
4424
+ );
4425
+ } catch (err) {
4426
+ console.error(
4427
+ `Failed to sign ${relativePath}:`,
4428
+ (err as Error).message,
4429
+ );
4430
+ // Continue signing other files even if one fails
4431
+ }
4432
+ }
4433
+
4434
+ // Sign .node native modules in Resources/app/bun
4435
+ const resourcesPath = join(contentsPath, "Resources", "app", "bun");
4436
+ if (existsSync(resourcesPath)) {
4437
+ console.log("Signing native modules in Resources/app/bun...");
4438
+ try {
4439
+ const nodeFiles = execSync(`find ${escapePathForTerminal(resourcesPath)} -name "*.node" -type f`, {
4440
+ encoding: "utf8",
4441
+ }).trim().split("\n").filter(Boolean);
4442
+
4443
+ for (const nodeFile of nodeFiles) {
4444
+ if (nodeFile) {
4445
+ console.log(`Signing native module: ${nodeFile.replace(resourcesPath + "/", "")}`);
4446
+ execSync(
4447
+ `codesign --force --verbose --timestamp --sign "${SPARKBUN_DEVELOPER_ID}" --options runtime ${escapePathForTerminal(nodeFile)}`,
4448
+ );
4449
+ }
4450
+ }
4451
+ } catch (err) {
4452
+ console.error("Error signing native modules:", err);
4453
+ }
4454
+ }
4455
+
4456
+ // Note: main.js is now in Resources and will be automatically sealed when signing the app bundle
4457
+
4458
+ // Sign the main executable
4459
+ const launcherPath = join(macosPath, config.app.name.replace(/ /g, ""));
4460
+ if (existsSync(launcherPath)) {
4461
+ console.log("Signing main executable (launcher)");
4462
+ const entitlementFlag = entitlementsFilePath
4463
+ ? `--entitlements ${entitlementsFilePath}`
4464
+ : "";
4465
+ try {
4466
+ execSync(
4467
+ `codesign --force --verbose --timestamp --sign "${SPARKBUN_DEVELOPER_ID}" --options runtime ${entitlementFlag} ${escapePathForTerminal(launcherPath)}`,
4468
+ );
4469
+ } catch (error) {
4470
+ console.error("Failed to sign launcher:", (error as Error).message);
4471
+ console.log("Attempting to sign launcher without runtime hardening...");
4472
+ execSync(
4473
+ `codesign --force --verbose --timestamp --sign "${SPARKBUN_DEVELOPER_ID}" ${entitlementFlag} ${escapePathForTerminal(launcherPath)}`,
4474
+ );
4475
+ }
4476
+ }
4477
+
4478
+ // Finally, sign the app bundle itself (without --deep)
4479
+ console.log("Signing app bundle");
4480
+ const entitlementFlag = entitlementsFilePath
4481
+ ? `--entitlements ${entitlementsFilePath}`
4482
+ : "";
4483
+ execSync(
4484
+ `codesign --force --verbose --timestamp --sign "${SPARKBUN_DEVELOPER_ID}" --options runtime ${entitlementFlag} ${escapePathForTerminal(appBundleOrDmgPath)}`,
4485
+ );
4486
+ }
4487
+
4488
+ function notarizeAndStaple(
4489
+ appOrDmgPath: string,
4490
+ config: Awaited<ReturnType<typeof getConfig>>,
4491
+ ) {
4492
+ if (OS !== "macos" || !config.build.mac.notarize) {
4493
+ return;
4494
+ }
4495
+
4496
+ let fileToNotarize = appOrDmgPath;
4497
+ // codesign
4498
+ // NOTE: Codesigning fails in dev mode (when using a single-file-executable bun cli as the launcher)
4499
+ // see https://github.com/oven-sh/bun/issues/7208
4500
+ // if (shouldNotarize) {
4501
+ console.log("notarizing...");
4502
+ const zipPath = appOrDmgPath + ".zip";
4503
+ // if (appOrDmgPath.endsWith('.app')) {
4504
+ const appBundleFileName = basename(appOrDmgPath);
4505
+ // if we're codesigning the .app we have to zip it first
4506
+ execSync(
4507
+ `zip -y -r -9 ${escapePathForTerminal(zipPath)} ${escapePathForTerminal(
4508
+ appBundleFileName,
4509
+ )}`,
4510
+ {
4511
+ cwd: dirname(appOrDmgPath),
4512
+ },
4513
+ );
4514
+ fileToNotarize = zipPath;
4515
+ // }
4516
+
4517
+ const SPARKBUN_APPLEID = process.env["SPARKBUN_APPLEID"];
4518
+ const SPARKBUN_APPLEIDPASS = process.env["SPARKBUN_APPLEIDPASS"];
4519
+ const SPARKBUN_TEAMID = process.env["SPARKBUN_TEAMID"];
4520
+
4521
+ const SPARKBUN_APPLEAPIISSUER = process.env["SPARKBUN_APPLEAPIISSUER"];
4522
+ const SPARKBUN_APPLEAPIKEY = process.env["SPARKBUN_APPLEAPIKEY"];
4523
+ const SPARKBUN_APPLEAPIKEYPATH = process.env["SPARKBUN_APPLEAPIKEYPATH"];
4524
+
4525
+ const useApiKey = SPARKBUN_APPLEAPIISSUER && SPARKBUN_APPLEAPIKEY && SPARKBUN_APPLEAPIKEYPATH;
4526
+ const useAppleId = SPARKBUN_APPLEID && SPARKBUN_APPLEIDPASS && SPARKBUN_TEAMID;
4527
+
4528
+ if (!useApiKey && !useAppleId) {
4529
+ console.error("Provide either App Store Connect API key credentials (SPARKBUN_APPLEAPIISSUER, SPARKBUN_APPLEAPIKEY, SPARKBUN_APPLEAPIKEYPATH) or Apple ID credentials (SPARKBUN_APPLEID, SPARKBUN_APPLEIDPASS, SPARKBUN_TEAMID) to notarize");
4530
+ process.exit(1);
4531
+ }
4532
+
4533
+ let statusInfo: string;
4534
+ if (useApiKey) {
4535
+ if (!existsSync(SPARKBUN_APPLEAPIKEYPATH)) {
4536
+ console.error(`SPARKBUN_APPLEAPIKEYPATH does not exist: ${SPARKBUN_APPLEAPIKEYPATH}`);
4537
+ process.exit(1);
4538
+ }
4539
+ statusInfo = execSync(
4540
+ `xcrun notarytool submit --key "${SPARKBUN_APPLEAPIKEYPATH}" --key-id "${SPARKBUN_APPLEAPIKEY}" --issuer "${SPARKBUN_APPLEAPIISSUER}" --wait ${escapePathForTerminal(
4541
+ fileToNotarize,
4542
+ )}`,
4543
+ ).toString();
4544
+ } else {
4545
+ // notarize
4546
+ statusInfo = execSync(
4547
+ `xcrun notarytool submit --apple-id "${SPARKBUN_APPLEID}" --password "${SPARKBUN_APPLEIDPASS}" --team-id "${SPARKBUN_TEAMID}" --wait ${escapePathForTerminal(
4548
+ fileToNotarize,
4549
+ )}`,
4550
+ ).toString();
4551
+ }
4552
+ const uuid = statusInfo.match(/id: ([^\n]+)/)?.[1];
4553
+ console.log("statusInfo", statusInfo);
4554
+ console.log("uuid", uuid);
4555
+
4556
+ if (statusInfo.match("Current status: Invalid")) {
4557
+ console.error("notarization failed", statusInfo);
4558
+ let log: string;
4559
+ if (useApiKey) {
4560
+ log = execSync(
4561
+ `xcrun notarytool log --key "${SPARKBUN_APPLEAPIKEYPATH}" --key-id "${SPARKBUN_APPLEAPIKEY}" --issuer "${SPARKBUN_APPLEAPIISSUER}" ${uuid}`,
4562
+ ).toString();
4563
+ } else {
4564
+ log = execSync(
4565
+ `xcrun notarytool log --apple-id "${SPARKBUN_APPLEID}" --password "${SPARKBUN_APPLEIDPASS}" --team-id "${SPARKBUN_TEAMID}" ${uuid}`,
4566
+ ).toString();
4567
+ }
4568
+ console.log("log", log);
4569
+ process.exit(1);
4570
+ }
4571
+ // check notarization
4572
+ // use `notarytool info` or some other request thing to check separately from the wait above
4573
+
4574
+ // stable notarization
4575
+ console.log("stapling...");
4576
+ execSync(`xcrun stapler staple ${escapePathForTerminal(appOrDmgPath)}`);
4577
+
4578
+ if (existsSync(zipPath)) {
4579
+ unlinkSync(zipPath);
4580
+ }
4581
+ }
4582
+
4583
+ // Note: supposedly the app bundle name is relevant to code sign/notarization so we need to make the app bundle and the self-extracting wrapper app bundle
4584
+ // have the same name but different subfolders in our build directory. or I guess delete the first one after tar/compression and then create the other one.
4585
+ // either way you can pass in the parent folder here for that flexibility.
4586
+ // for intel/arm builds on mac we'll probably have separate subfolders as well and build them in parallel.
4587
+ function createAppBundle(
4588
+ bundleName: string,
4589
+ parentFolder: string,
4590
+ targetOS: "macos" | "win" | "linux",
4591
+ ) {
4592
+ if (targetOS === "macos") {
4593
+ // macOS bundle structure
4594
+ const bundleFileName = `${bundleName}.app`;
4595
+ const appBundleFolderPath = join(parentFolder, bundleFileName);
4596
+ const appBundleFolderContentsPath = join(appBundleFolderPath, "Contents");
4597
+ const appBundleMacOSPath = join(appBundleFolderContentsPath, "MacOS");
4598
+ const appBundleFolderResourcesPath = join(
4599
+ appBundleFolderContentsPath,
4600
+ "Resources",
4601
+ );
4602
+ const appBundleFolderFrameworksPath = join(
4603
+ appBundleFolderContentsPath,
4604
+ "Frameworks",
4605
+ );
4606
+
4607
+ // we don't have to make all the folders, just the deepest ones
4608
+ mkdirSync(appBundleMacOSPath, { recursive: true });
4609
+ mkdirSync(appBundleFolderResourcesPath, { recursive: true });
4610
+ mkdirSync(appBundleFolderFrameworksPath, { recursive: true });
4611
+
4612
+ return {
4613
+ appBundleFolderPath,
4614
+ appBundleFolderContentsPath,
4615
+ appBundleMacOSPath,
4616
+ appBundleFolderResourcesPath,
4617
+ appBundleFolderFrameworksPath,
4618
+ };
4619
+ } else if (targetOS === "linux" || targetOS === "win") {
4620
+ // Linux/Windows simpler structure
4621
+ const appBundleFolderPath = join(parentFolder, bundleName);
4622
+ const appBundleFolderContentsPath = appBundleFolderPath; // No Contents folder needed
4623
+ const appBundleMacOSPath = join(appBundleFolderPath, "bin"); // Use bin instead of MacOS
4624
+ const appBundleFolderResourcesPath = join(
4625
+ appBundleFolderPath,
4626
+ "Resources",
4627
+ );
4628
+ const appBundleFolderFrameworksPath = join(appBundleFolderPath, "lib"); // Use lib instead of Frameworks
4629
+
4630
+ // Create directories
4631
+ mkdirSync(appBundleMacOSPath, { recursive: true });
4632
+ mkdirSync(appBundleFolderResourcesPath, { recursive: true });
4633
+ mkdirSync(appBundleFolderFrameworksPath, { recursive: true });
4634
+
4635
+ return {
4636
+ appBundleFolderPath,
4637
+ appBundleFolderContentsPath,
4638
+ appBundleMacOSPath,
4639
+ appBundleFolderResourcesPath,
4640
+ appBundleFolderFrameworksPath,
4641
+ };
4642
+ } else {
4643
+ throw new Error(`Unsupported OS: ${targetOS}`);
4644
+ }
4645
+ }
4646
+
4647
+ // Close the command handling if/else chain
4648
+
4649
+ // Close and execute the async IIFE
4650
+ })().catch((error) => {
4651
+ console.error("Fatal error:", error);
4652
+ process.exit(1);
4653
+ });