sparkbun 0.1.8 → 0.2.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 (29) hide show
  1. package/dist-win-x64/nsis/Bin/makensis.exe +0 -0
  2. package/dist-win-x64/nsis/Stubs/bzip2-amd64-unicode +0 -0
  3. package/dist-win-x64/nsis/Stubs/bzip2-x86-ansi +0 -0
  4. package/dist-win-x64/nsis/Stubs/bzip2-x86-unicode +0 -0
  5. package/dist-win-x64/nsis/Stubs/bzip2_solid-amd64-unicode +0 -0
  6. package/dist-win-x64/nsis/Stubs/bzip2_solid-x86-ansi +0 -0
  7. package/dist-win-x64/nsis/Stubs/bzip2_solid-x86-unicode +0 -0
  8. package/dist-win-x64/nsis/Stubs/lz4-amd64-unicode +0 -0
  9. package/dist-win-x64/nsis/Stubs/lz4-x86-ansi +0 -0
  10. package/dist-win-x64/nsis/Stubs/lz4-x86-unicode +0 -0
  11. package/dist-win-x64/nsis/Stubs/lz4_solid-amd64-unicode +0 -0
  12. package/dist-win-x64/nsis/Stubs/lz4_solid-x86-ansi +0 -0
  13. package/dist-win-x64/nsis/Stubs/lz4_solid-x86-unicode +0 -0
  14. package/dist-win-x64/nsis/Stubs/lzma-amd64-unicode +0 -0
  15. package/dist-win-x64/nsis/Stubs/lzma-x86-ansi +0 -0
  16. package/dist-win-x64/nsis/Stubs/lzma-x86-unicode +0 -0
  17. package/dist-win-x64/nsis/Stubs/lzma_solid-amd64-unicode +0 -0
  18. package/dist-win-x64/nsis/Stubs/lzma_solid-x86-ansi +0 -0
  19. package/dist-win-x64/nsis/Stubs/lzma_solid-x86-unicode +0 -0
  20. package/dist-win-x64/nsis/Stubs/uninst +0 -0
  21. package/dist-win-x64/nsis/Stubs/zlib-amd64-unicode +0 -0
  22. package/dist-win-x64/nsis/Stubs/zlib-x86-ansi +0 -0
  23. package/dist-win-x64/nsis/Stubs/zlib-x86-unicode +0 -0
  24. package/dist-win-x64/nsis/Stubs/zlib_solid-amd64-unicode +0 -0
  25. package/dist-win-x64/nsis/Stubs/zlib_solid-x86-ansi +0 -0
  26. package/dist-win-x64/nsis/Stubs/zlib_solid-x86-unicode +0 -0
  27. package/package.json +1 -1
  28. package/src/cli/index.ts +395 -3
  29. package/src/installer/installer-wrapper-template.ts +158 -0
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparkbun",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "description": "Build fast, lightweight, cross-platform desktop apps with TypeScript and Bun.",
5
5
  "license": "MIT",
6
6
  "author": "SparkBun Contributors",
package/src/cli/index.ts CHANGED
@@ -76,7 +76,7 @@ const commandArg = process.argv[indexOfCli + 1] || "build";
76
76
  // Walk up from projectRoot to find electrobun in node_modules (supports hoisted monorepo layouts)
77
77
  function resolveSparkBunDir(): string {
78
78
  // When running from SparkBun source (src/cli/index.ts), the package root is two levels up
79
- const cliDir = dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"));
79
+ const cliDir = dirname(decodeURIComponent(new URL(import.meta.url).pathname).replace(/^\/([A-Za-z]:)/, "$1"));
80
80
  const sourcePackageDir = join(cliDir, "..", "..");
81
81
  if (existsSync(join(sourcePackageDir, "package.json")) && existsSync(join(sourcePackageDir, "src", "cli"))) {
82
82
  return sourcePackageDir;
@@ -1663,6 +1663,17 @@ ${utiDecls}
1663
1663
  }
1664
1664
  await runAppWithSignalHandling(config);
1665
1665
  }
1666
+ } else if (commandArg === "installer") {
1667
+ const config = await getConfig();
1668
+ const envArg =
1669
+ process.argv.find((arg) => arg.startsWith("--env="))?.split("=")[1] || "";
1670
+ const buildEnvironment: "dev" | "stable" = envArg === "dev" ? "dev" : "stable";
1671
+ try {
1672
+ await runInstallerBuild(config, buildEnvironment);
1673
+ } catch (error) {
1674
+ console.error("Installer build failed:", error);
1675
+ process.exit(1);
1676
+ }
1666
1677
  }
1667
1678
 
1668
1679
  async function runBuild(
@@ -2067,7 +2078,7 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
2067
2078
  }
2068
2079
 
2069
2080
  // Compile launcher from source using Bun.build() API
2070
- const cliDir = dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"));
2081
+ const cliDir = dirname(decodeURIComponent(new URL(import.meta.url).pathname).replace(/^\/([A-Za-z]:)/, "$1"));
2071
2082
  const sparkbunRoot = join(cliDir, "..", "..");
2072
2083
  const launcherSourcePath = join(sparkbunRoot, "src", "launcher", "main.ts");
2073
2084
  const bunTarget = `bun-${targetOS === "macos" ? "darwin" : targetOS === "win" ? "windows" : "linux"}-${currentTarget.arch}` as const;
@@ -3187,6 +3198,387 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3187
3198
  // stapler validate -v <app path>
3188
3199
  }
3189
3200
 
3201
+ async function runInstallerBuild(
3202
+ config: Awaited<ReturnType<typeof getConfig>>,
3203
+ buildEnvironment: "dev" | "stable",
3204
+ ) {
3205
+ // --- Parse installer-specific CLI flags ---
3206
+
3207
+ const archives: { name: string; path: string }[] = [];
3208
+ for (let i = 0; i < process.argv.length; i++) {
3209
+ if (process.argv[i] === "--archive" && process.argv[i + 1]) {
3210
+ const arg = process.argv[i + 1]!;
3211
+ const eqIdx = arg.indexOf("=");
3212
+ if (eqIdx === -1) {
3213
+ console.error(`Invalid --archive format: "${arg}". Expected: name=path`);
3214
+ process.exit(1);
3215
+ }
3216
+ const name = arg.substring(0, eqIdx);
3217
+ const archivePath = arg.substring(eqIdx + 1);
3218
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
3219
+ console.error(`Invalid archive name: "${name}". Must be a valid identifier ([a-zA-Z_][a-zA-Z0-9_]*).`);
3220
+ process.exit(1);
3221
+ }
3222
+ if (archives.some((a) => a.name === name)) {
3223
+ console.error(`Duplicate archive name: "${name}".`);
3224
+ process.exit(1);
3225
+ }
3226
+ const resolvedPath = path.resolve(projectRoot, archivePath);
3227
+ if (!existsSync(resolvedPath)) {
3228
+ console.error(`Archive path does not exist: ${resolvedPath}`);
3229
+ process.exit(1);
3230
+ }
3231
+ archives.push({ name, path: resolvedPath });
3232
+ }
3233
+ }
3234
+
3235
+ const getFlag = (prefix: string): string | undefined =>
3236
+ process.argv.find((arg) => arg.startsWith(prefix))?.split("=").slice(1).join("=");
3237
+
3238
+ const outPath = getFlag("--out=");
3239
+ const iconPath = getFlag("--icon=");
3240
+ const installerName = getFlag("--name=") || config.app.name;
3241
+ const installerVersion = getFlag("--version=") || config.app.version;
3242
+ const installerPublisher = getFlag("--publisher=") || (config.app as any).publisher || "";
3243
+ const metadataPath = getFlag("--metadata=");
3244
+
3245
+ // Determine target platform
3246
+ const targetArg = getFlag("--target=");
3247
+ let targetOS: "macos" | "win" | "linux" = OS;
3248
+ let targetARCH: "arm64" | "x64" = ARCH;
3249
+ if (targetArg) {
3250
+ const [tOS, tArch] = targetArg.split("-") as [string, string];
3251
+ const osMap: Record<string, "macos" | "win" | "linux"> = { macos: "macos", darwin: "macos", win: "win", windows: "win", linux: "linux" };
3252
+ const archMap: Record<string, "arm64" | "x64"> = { arm64: "arm64", aarch64: "arm64", x64: "x64", x86_64: "x64" };
3253
+ if (osMap[tOS] && archMap[tArch]) {
3254
+ targetOS = osMap[tOS];
3255
+ targetARCH = archMap[tArch];
3256
+ console.log(`Cross-compiling installer for ${targetOS}-${targetARCH}`);
3257
+ } else {
3258
+ console.error(`Invalid target: ${targetArg}. Use format: win-x64, linux-arm64, macos-arm64`);
3259
+ process.exit(1);
3260
+ }
3261
+ }
3262
+
3263
+ const targetBinExt = targetOS === "win" ? ".exe" : "";
3264
+
3265
+ console.log(`Building installer: ${installerName} v${installerVersion}`);
3266
+ if (archives.length > 0) {
3267
+ console.log(`Archives: ${archives.map((a) => `${a.name}=${a.path}`).join(", ")}`);
3268
+ }
3269
+
3270
+ // --- Ensure native dependencies ---
3271
+
3272
+ await ensureCoreDependencies(targetOS, targetARCH);
3273
+ const targetPaths = getPlatformPaths(targetOS, targetARCH);
3274
+
3275
+ // --- Set up build folder ---
3276
+
3277
+ const platformPrefix = getPlatformPrefix(buildEnvironment, targetOS, targetARCH);
3278
+ const buildFolder = join(projectRoot, config.build.buildFolder, `installer-${platformPrefix}`);
3279
+
3280
+ if (existsSync(buildFolder)) {
3281
+ rmSync(buildFolder, { recursive: true, force: true });
3282
+ }
3283
+ mkdirSync(buildFolder, { recursive: true });
3284
+
3285
+ const cliDir = dirname(decodeURIComponent(new URL(import.meta.url).pathname).replace(/^\/([A-Za-z]:)/, "$1"));
3286
+ const sparkbunRoot = join(cliDir, "..", "..");
3287
+ const bunTarget = `bun-${targetOS === "macos" ? "darwin" : targetOS === "win" ? "windows" : "linux"}-${targetARCH}` as const;
3288
+ const targetOSName = targetOS === "macos" ? "darwin" : targetOS === "win" ? "windows" : "linux";
3289
+ const isWindows = targetOS === "win";
3290
+
3291
+ // --- Create archives first (they go into the compiled binary, not the runtime bundle) ---
3292
+
3293
+ const stagingDir = join(buildFolder, ".installer-staging");
3294
+ if (existsSync(stagingDir)) rmSync(stagingDir, { recursive: true, force: true });
3295
+ mkdirSync(stagingDir, { recursive: true });
3296
+
3297
+ const archiveManifest: Record<string, string> = {};
3298
+
3299
+ if (archives.length > 0) {
3300
+ console.log("Creating archives (LZMA solid via NSIS)...");
3301
+
3302
+ const nsisDir = join(SPARKBUN_DEP_PATH, `dist-${targetOS}-${targetARCH}`, "nsis");
3303
+ const makensisPath = join(nsisDir, "Bin", "makensis.exe");
3304
+ if (!existsSync(makensisPath)) {
3305
+ throw new Error(`makensis not found at ${makensisPath}. LZMA compression requires NSIS binaries in dist-${targetOS}-${targetARCH}/nsis/`);
3306
+ }
3307
+
3308
+ for (const { name, path: archiveSrcPath } of archives) {
3309
+ const archiveFileName = `archive-${name}.exe`;
3310
+ const archiveDestPath = join(stagingDir, archiveFileName);
3311
+ const nsiPath = join(stagingDir, `archive-${name}.nsi`);
3312
+
3313
+ console.log(` Archiving ${name}: ${archiveSrcPath}`);
3314
+
3315
+ const nsiContent = `SetCompressor /SOLID lzma\nSilentInstall silent\nName "${name}"\nOutFile "${archiveDestPath.replace(/\\/g, "\\\\")}"\nInstallDir "$TEMP\\sparkbun-extract-${name}"\nSection\n SetOutPath "$INSTDIR"\n File /r "${archiveSrcPath.replace(/\\/g, "\\\\")}\\*.*"\nSectionEnd\n`;
3316
+ writeFileSync(nsiPath, nsiContent);
3317
+
3318
+ const result = Bun.spawnSync([makensisPath, nsiPath], {
3319
+ cwd: nsisDir,
3320
+ env: { ...process.env, NSISDIR: nsisDir },
3321
+ stdout: "pipe",
3322
+ stderr: "pipe",
3323
+ });
3324
+
3325
+ if (result.exitCode !== 0) {
3326
+ const stderr = result.stderr.toString();
3327
+ const stdout = result.stdout.toString();
3328
+ console.error(`makensis failed for ${name}:`, stderr || stdout);
3329
+ throw new Error(`Failed to compress archive: ${name}`);
3330
+ }
3331
+
3332
+ unlinkSync(nsiPath);
3333
+
3334
+ const archiveSize = statSync(archiveDestPath).size;
3335
+ console.log(` ${name}: ${(archiveSize / 1024 / 1024).toFixed(2)} MB`);
3336
+ archiveManifest[name] = archiveFileName;
3337
+ }
3338
+ }
3339
+
3340
+ // --- Build the runtime bundle (DLLs, views, app code — NO launcher binary, NO archives) ---
3341
+
3342
+ console.log("Building runtime bundle...");
3343
+ const appFileName = getAppFileName(config.app.name, buildEnvironment);
3344
+ const bundle = createAppBundle(appFileName, buildFolder, targetOS);
3345
+ const appBundleFolderPath = bundle.appBundleFolderPath;
3346
+ const appBundleMacOSPath = bundle.appBundleMacOSPath;
3347
+ const appBundleFolderResourcesPath = bundle.appBundleFolderResourcesPath;
3348
+ const appBundleAppCodePath = join(appBundleFolderResourcesPath, "app");
3349
+ mkdirSync(appBundleAppCodePath, { recursive: true });
3350
+ mkdirSync(appBundleMacOSPath, { recursive: true });
3351
+
3352
+ // Copy native wrappers
3353
+ if (targetOS === "win") {
3354
+ cpSync(targetPaths.CORE_WIN, join(appBundleMacOSPath, "SparkBunCore.dll"), { dereference: true });
3355
+ cpSync(targetPaths.NATIVE_WRAPPER_WIN, join(appBundleMacOSPath, "libNativeWrapper.dll"), { dereference: true });
3356
+ cpSync(targetPaths.WEBVIEW2LOADER_WIN, join(appBundleMacOSPath, "WebView2Loader.dll"), { dereference: true });
3357
+ } else if (targetOS === "macos") {
3358
+ cpSync(targetPaths.CORE_MACOS, join(appBundleMacOSPath, "libSparkBunCore.dylib"), { dereference: true });
3359
+ cpSync(targetPaths.NATIVE_WRAPPER_MACOS, join(appBundleMacOSPath, "libNativeWrapper.dylib"), { dereference: true });
3360
+ } else if (targetOS === "linux") {
3361
+ cpSync(targetPaths.CORE_LINUX, join(appBundleMacOSPath, "libSparkBunCore.so"), { dereference: true });
3362
+ cpSync(targetPaths.NATIVE_WRAPPER_LINUX, join(appBundleMacOSPath, "libNativeWrapper.so"), { dereference: true });
3363
+ }
3364
+
3365
+ // libasar stub
3366
+ const libExt = targetOS === "win" ? ".dll" : targetOS === "macos" ? ".dylib" : ".so";
3367
+ const platformDistDir = join(SPARKBUN_DEP_PATH, `dist-${targetOS}-${targetARCH}`);
3368
+ const asarLibSource = join(platformDistDir, "libasar" + libExt);
3369
+ if (existsSync(asarLibSource)) {
3370
+ cpSync(asarLibSource, join(appBundleMacOSPath, "libasar" + libExt), { dereference: true });
3371
+ }
3372
+
3373
+ // Bundle bun code
3374
+ const bunConfig = config.build.bun;
3375
+ const bunSource = join(projectRoot, bunConfig.entrypoint);
3376
+ if (!existsSync(bunSource)) {
3377
+ throw new Error(`Bun entrypoint not found: ${bunSource}`);
3378
+ }
3379
+
3380
+ const bunDestFolder = join(appBundleAppCodePath, "bun");
3381
+ const { entrypoint: _bunEntrypoint, ...bunBuildOptions } = bunConfig;
3382
+ const bunBuildResult = await Bun.build({
3383
+ ...bunBuildOptions,
3384
+ entrypoints: [bunSource],
3385
+ outdir: bunDestFolder,
3386
+ target: "bun",
3387
+ });
3388
+ if (!bunBuildResult.success) {
3389
+ console.error("Failed to build bun entrypoint:", bunSource);
3390
+ printBuildLogs(bunBuildResult.logs);
3391
+ throw new Error("Build failed: bun build failed");
3392
+ }
3393
+
3394
+ // Bundle views
3395
+ for (const viewName in config.build.views) {
3396
+ const viewConfig = config.build.views[viewName]!;
3397
+ const viewSource = join(projectRoot, viewConfig.entrypoint);
3398
+ if (!existsSync(viewSource)) {
3399
+ console.error(`View entrypoint not found: ${viewSource}`);
3400
+ continue;
3401
+ }
3402
+
3403
+ const viewDestFolder = join(appBundleAppCodePath, "views", viewName);
3404
+ mkdirSync(viewDestFolder, { recursive: true });
3405
+
3406
+ const { entrypoint: _viewEntrypoint, ...viewBuildOptions } = viewConfig;
3407
+ const viewBuildResult = await Bun.build({
3408
+ ...viewBuildOptions,
3409
+ entrypoints: [viewSource],
3410
+ outdir: viewDestFolder,
3411
+ target: "browser",
3412
+ });
3413
+ if (!viewBuildResult.success) {
3414
+ console.error("Failed to build view:", viewSource);
3415
+ printBuildLogs(viewBuildResult.logs);
3416
+ }
3417
+ }
3418
+
3419
+ // Copy assets
3420
+ for (const relSource in config.build.copy) {
3421
+ const source = join(projectRoot, relSource);
3422
+ if (!existsSync(source)) {
3423
+ console.error(`Asset not found: ${source}`);
3424
+ continue;
3425
+ }
3426
+
3427
+ const destination = join(appBundleAppCodePath, config.build.copy[relSource]!);
3428
+ const destFolder = dirname(destination);
3429
+ if (!existsSync(destFolder)) {
3430
+ mkdirSync(destFolder, { recursive: true });
3431
+ }
3432
+
3433
+ cpSync(source, destination, { recursive: true, dereference: true });
3434
+ }
3435
+
3436
+ // Write build.json
3437
+ const buildJson = {
3438
+ name: config.app.name,
3439
+ identifier: config.app.identifier,
3440
+ version: installerVersion,
3441
+ channel: buildEnvironment,
3442
+ ...config.runtime,
3443
+ };
3444
+ await Bun.write(join(appBundleAppCodePath, "build.json"), JSON.stringify(buildJson, null, 2));
3445
+
3446
+ // Write archives.json manifest into runtime bundle so app code knows archive names
3447
+ await Bun.write(
3448
+ join(appBundleFolderResourcesPath, "archives.json"),
3449
+ JSON.stringify(archiveManifest, null, 2),
3450
+ );
3451
+
3452
+ // --- Create runtime bundle tar.gz (small — no launcher binary, no archives) ---
3453
+
3454
+ console.log("Packaging runtime bundle (gzip level 12)...");
3455
+ const runtimeFiles: Record<string, ArrayBuffer> = {};
3456
+ const bundleBase = basename(appBundleFolderPath);
3457
+ async function walkBundle(dir: string, prefix: string) {
3458
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
3459
+ const fullPath = join(dir, entry.name);
3460
+ const archivePath = prefix ? `${prefix}/${entry.name}` : entry.name;
3461
+ if (entry.isDirectory()) {
3462
+ await walkBundle(fullPath, archivePath);
3463
+ } else {
3464
+ runtimeFiles[archivePath] = await Bun.file(fullPath).arrayBuffer();
3465
+ }
3466
+ }
3467
+ }
3468
+ await walkBundle(appBundleFolderPath, bundleBase);
3469
+
3470
+ const runtimeArchive = new Bun.Archive(runtimeFiles, { compress: "gzip", level: 12 });
3471
+ const runtimeCompressedPath = join(buildFolder, `${appFileName}-runtime.tar.gz`);
3472
+ await Bun.write(runtimeCompressedPath, await runtimeArchive.bytes());
3473
+ rmSync(appBundleFolderPath, { recursive: true });
3474
+
3475
+ const runtimeSize = statSync(runtimeCompressedPath).size;
3476
+ console.log(` Runtime bundle: ${(runtimeSize / 1024 / 1024).toFixed(2)} MB`);
3477
+
3478
+ // --- Generate entrypoint and compile single installer exe ---
3479
+
3480
+ // Copy runtime bundle into staging
3481
+ copyFileSync(runtimeCompressedPath, join(stagingDir, "runtime-bundle.tar.gz"));
3482
+
3483
+ // Write metadata
3484
+ const metadata: Record<string, unknown> = {
3485
+ name: installerName,
3486
+ version: installerVersion,
3487
+ identifier: config.app.identifier,
3488
+ channel: buildEnvironment,
3489
+ requireAdmin: (config.build?.win as any)?.requireAdmin || (config.build?.linux as any)?.requireAdmin || false,
3490
+ };
3491
+ if (metadataPath) {
3492
+ const userMetadata = JSON.parse(readFileSync(path.resolve(projectRoot, metadataPath), "utf-8"));
3493
+ Object.assign(metadata, userMetadata);
3494
+ }
3495
+ writeFileSync(join(stagingDir, "metadata.json"), JSON.stringify(metadata, null, 2));
3496
+
3497
+ // Generate the entrypoint that imports the wrapper template + all archive files
3498
+ // The wrapper template imports runtime-bundle.tar.gz and metadata.json.
3499
+ // We generate an archives module that the app code can import at runtime.
3500
+ const wrapperTemplatePath = join(cliDir, "..", "installer", "installer-wrapper-template.ts");
3501
+ copyFileSync(wrapperTemplatePath, join(stagingDir, "installer.ts"));
3502
+
3503
+ // Generate archives.ts that embeds each archive via static imports
3504
+ const archiveImports: string[] = [];
3505
+ const archiveExports: string[] = [];
3506
+ for (const { name } of archives) {
3507
+ const fileName = archiveManifest[name];
3508
+ archiveImports.push(`import archive_${name} from "./${fileName}" with { type: "file" };`);
3509
+ archiveExports.push(` "${name}": archive_${name},`);
3510
+ }
3511
+
3512
+ const archivesModuleContent = `${archiveImports.join("\n")}
3513
+
3514
+ export const embeddedArchives: Record<string, string> = {
3515
+ ${archiveExports.join("\n")}
3516
+ };
3517
+ `;
3518
+ writeFileSync(join(stagingDir, "embedded-archives.ts"), archivesModuleContent);
3519
+
3520
+ // --- Compile single installer exe ---
3521
+
3522
+ const defaultOutName = `${installerName.replace(/ /g, "-")}-Setup${targetBinExt}`;
3523
+ const outputPath = outPath
3524
+ ? path.resolve(projectRoot, outPath)
3525
+ : join(buildFolder, defaultOutName);
3526
+
3527
+ console.log("Compiling installer executable...");
3528
+
3529
+ const installerCompileOptions: any = {
3530
+ target: `bun-${targetOSName}-${targetARCH}`,
3531
+ outfile: outputPath,
3532
+ };
3533
+
3534
+ if (isWindows && OS === "win") {
3535
+ let icoPath: string | undefined;
3536
+ if (iconPath) {
3537
+ const resolvedIcon = path.resolve(projectRoot, iconPath);
3538
+ if (existsSync(resolvedIcon)) {
3539
+ icoPath = resolvedIcon;
3540
+ if (resolvedIcon.toLowerCase().endsWith(".png")) {
3541
+ const pngToIco = (await import("png-to-ico")).default;
3542
+ const tempIcoPath = join(buildFolder, "temp-installer-icon.ico");
3543
+ const icoBuffer = await pngToIco(resolvedIcon);
3544
+ writeFileSync(tempIcoPath, new Uint8Array(icoBuffer));
3545
+ icoPath = tempIcoPath;
3546
+ }
3547
+ }
3548
+ }
3549
+ installerCompileOptions.windows = {
3550
+ hideConsole: true,
3551
+ ...(icoPath && { icon: icoPath }),
3552
+ title: `${installerName} Setup`,
3553
+ version: installerVersion,
3554
+ description: `Installs ${installerName}`,
3555
+ publisher: installerPublisher || " ",
3556
+ copyright: (config.app as any).copyright || " ",
3557
+ };
3558
+ }
3559
+
3560
+ const installerBuild = await Bun.build({
3561
+ entrypoints: [join(stagingDir, "installer.ts")],
3562
+ compile: installerCompileOptions,
3563
+ });
3564
+ if (!installerBuild.success) {
3565
+ console.error("Installer compilation failed:", installerBuild.logs);
3566
+ throw new Error("Installer compilation failed");
3567
+ }
3568
+
3569
+ if (isWindows) {
3570
+ patchPeSubsystem(outputPath);
3571
+ } else {
3572
+ execSync(`chmod +x ${escapePathForTerminal(outputPath)}`);
3573
+ }
3574
+
3575
+ // Clean up staging
3576
+ rmSync(stagingDir, { recursive: true, force: true });
3577
+
3578
+ const exeSize = statSync(outputPath).size;
3579
+ console.log(`\nInstaller built: ${outputPath} (${(exeSize / 1024 / 1024).toFixed(2)} MB)`);
3580
+ }
3581
+
3190
3582
  // Take over as the terminal's foreground process group (macOS/Linux).
3191
3583
  // This prevents the parent bun script runner from receiving SIGINT
3192
3584
  // when Ctrl+C is pressed, keeping the terminal busy until the app
@@ -3827,7 +4219,7 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3827
4219
  writeFileSync(join(stagingDir, "metadata.json"), JSON.stringify(metadata, null, 2));
3828
4220
 
3829
4221
  // Copy installer template
3830
- const cliDir = dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"));
4222
+ const cliDir = dirname(decodeURIComponent(new URL(import.meta.url).pathname).replace(/^\/([A-Za-z]:)/, "$1"));
3831
4223
  const templatePath = join(cliDir, "..", "installer", "installer-template.ts");
3832
4224
  copyFileSync(templatePath, join(stagingDir, "installer.ts"));
3833
4225
 
@@ -0,0 +1,158 @@
1
+ import runtimeArchive from "./runtime-bundle.tar.gz" with { type: "file" };
2
+ import metadataJson from "./metadata.json" with { type: "file" };
3
+ import { embeddedArchives } from "./embedded-archives";
4
+ import { join, dirname, resolve } from "path";
5
+ import { existsSync, mkdirSync, rmSync, readdirSync, writeFileSync } from "fs";
6
+ import { dlopen, suffix, ptr } from "bun:ffi";
7
+
8
+ interface Metadata {
9
+ name: string;
10
+ version?: string;
11
+ requireAdmin?: boolean;
12
+ }
13
+
14
+ const metadata: Metadata = await Bun.file(metadataJson).json();
15
+
16
+ // Extract runtime files (DLLs, views, app code) to temp.
17
+ // Archives stay embedded in this binary — extracted during install by the app code.
18
+ const tempDir = join(
19
+ process.env.TEMP || process.env.TMP || process.env.LOCALAPPDATA || ".",
20
+ `.sparkbun-installer-${metadata.name.replace(/[^a-zA-Z0-9]/g, "_")}`,
21
+ );
22
+
23
+ try {
24
+ if (existsSync(tempDir)) {
25
+ rmSync(tempDir, { recursive: true, force: true });
26
+ }
27
+
28
+ const archiveBytes = await Bun.file(runtimeArchive).bytes();
29
+ const archive = new Bun.Archive(archiveBytes);
30
+ mkdirSync(tempDir, { recursive: true });
31
+ await archive.extract(tempDir);
32
+
33
+ // Find the bin directory inside the extracted bundle
34
+ let binDir = tempDir;
35
+ let bundleRoot = tempDir;
36
+ for (const entry of readdirSync(tempDir)) {
37
+ const entryPath = join(tempDir, entry);
38
+ if (existsSync(join(entryPath, "bin"))) {
39
+ binDir = join(entryPath, "bin");
40
+ bundleRoot = entryPath;
41
+ break;
42
+ }
43
+ }
44
+
45
+ // Write the embedded archive paths into the Resources directory so the app code can find them.
46
+ // Bun compile embeds files at virtual paths — we resolve them here and pass to the app.
47
+ const resourcesDir = join(bundleRoot, "Resources");
48
+ const archivePaths: Record<string, string> = {};
49
+ for (const [name, filePath] of Object.entries(embeddedArchives)) {
50
+ archivePaths[name] = filePath;
51
+ }
52
+ writeFileSync(
53
+ join(resourcesDir, "archive-paths.json"),
54
+ JSON.stringify(archivePaths, null, 2),
55
+ );
56
+
57
+ // Set CWD to bin dir so the launcher can find DLLs and ../Resources
58
+ process.chdir(binDir);
59
+
60
+ // --- Admin elevation check ---
61
+ if (metadata.requireAdmin && process.platform !== "darwin") {
62
+ const isAdmin = (): boolean => {
63
+ if (process.platform === "win32") {
64
+ const shell32 = dlopen("shell32.dll", {
65
+ IsUserAnAdmin: { args: [], returns: "bool" },
66
+ });
67
+ const admin = shell32.symbols.IsUserAnAdmin();
68
+ shell32.close();
69
+ return admin;
70
+ }
71
+ if (process.platform === "linux") {
72
+ return process.getuid?.() === 0;
73
+ }
74
+ return true;
75
+ };
76
+
77
+ if (!isAdmin()) {
78
+ if (process.platform === "win32") {
79
+ const shell32 = dlopen("shell32.dll", {
80
+ ShellExecuteW: {
81
+ args: ["ptr", "ptr", "ptr", "ptr", "ptr", "i32"],
82
+ returns: "ptr",
83
+ },
84
+ });
85
+ const encode = (s: string) => ptr(new Uint8Array(Buffer.from(s + "\0", "utf-16le")));
86
+ shell32.symbols.ShellExecuteW(
87
+ null,
88
+ encode("runas"),
89
+ encode(process.argv[0]),
90
+ null,
91
+ null,
92
+ 1,
93
+ );
94
+ shell32.close();
95
+ } else if (process.platform === "linux") {
96
+ Bun.spawnSync(["pkexec", process.argv[0], ...process.argv.slice(1)], {
97
+ stdout: "inherit",
98
+ stderr: "inherit",
99
+ });
100
+ }
101
+ rmSync(tempDir, { recursive: true, force: true });
102
+ process.exit(0);
103
+ }
104
+ }
105
+
106
+ // --- Load SparkBunCore and run ---
107
+ const coreLibFileName =
108
+ process.platform === "win32"
109
+ ? "SparkBunCore.dll"
110
+ : `libSparkBunCore.${suffix}`;
111
+ const coreLibPath = join(binDir, coreLibFileName);
112
+
113
+ if (!existsSync(coreLibPath)) {
114
+ throw new Error(`SparkBunCore not found at: ${coreLibPath}`);
115
+ }
116
+
117
+ const lib = dlopen(coreLibPath, {
118
+ sparkbun_core_run_main_thread: {
119
+ args: ["cstring", "cstring", "cstring", "i32"],
120
+ returns: "i32",
121
+ },
122
+ });
123
+
124
+ let channel = "";
125
+ let identifier = "";
126
+ let name = "";
127
+ try {
128
+ const versionJsonPath = join(bundleRoot, "Resources", "version.json");
129
+ if (existsSync(versionJsonPath)) {
130
+ const versionInfo = require(versionJsonPath);
131
+ identifier = versionInfo.identifier || "";
132
+ name = versionInfo.name || "";
133
+ channel = versionInfo.channel || "";
134
+ }
135
+ } catch {}
136
+
137
+ lib.symbols.sparkbun_core_run_main_thread(
138
+ Buffer.from((identifier || metadata.name) + "\0", "utf-8"),
139
+ Buffer.from((name || metadata.name) + "\0", "utf-8"),
140
+ Buffer.from(channel + "\0", "utf-8"),
141
+ 0,
142
+ );
143
+
144
+ // Cleanup temp on exit
145
+ try {
146
+ rmSync(tempDir, { recursive: true, force: true });
147
+ } catch {}
148
+ } catch (e: any) {
149
+ console.error(`Installer failed: ${e.message}`);
150
+ try {
151
+ rmSync(tempDir, { recursive: true, force: true });
152
+ } catch {}
153
+ if (process.platform === "win32") {
154
+ console.log("Press Enter to exit...");
155
+ for await (const _ of console) { break; }
156
+ }
157
+ process.exit(1);
158
+ }