sparkbun 0.1.9 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist-win-arm64/libNativeWrapper.dll +0 -0
  2. package/dist-win-x64/libNativeWrapper.dll +0 -0
  3. package/dist-win-x64/nsis/Bin/makensis.exe +0 -0
  4. package/dist-win-x64/nsis/Stubs/bzip2-amd64-unicode +0 -0
  5. package/dist-win-x64/nsis/Stubs/bzip2-x86-ansi +0 -0
  6. package/dist-win-x64/nsis/Stubs/bzip2-x86-unicode +0 -0
  7. package/dist-win-x64/nsis/Stubs/bzip2_solid-amd64-unicode +0 -0
  8. package/dist-win-x64/nsis/Stubs/bzip2_solid-x86-ansi +0 -0
  9. package/dist-win-x64/nsis/Stubs/bzip2_solid-x86-unicode +0 -0
  10. package/dist-win-x64/nsis/Stubs/lz4-amd64-unicode +0 -0
  11. package/dist-win-x64/nsis/Stubs/lz4-x86-ansi +0 -0
  12. package/dist-win-x64/nsis/Stubs/lz4-x86-unicode +0 -0
  13. package/dist-win-x64/nsis/Stubs/lz4_solid-amd64-unicode +0 -0
  14. package/dist-win-x64/nsis/Stubs/lz4_solid-x86-ansi +0 -0
  15. package/dist-win-x64/nsis/Stubs/lz4_solid-x86-unicode +0 -0
  16. package/dist-win-x64/nsis/Stubs/lzma-amd64-unicode +0 -0
  17. package/dist-win-x64/nsis/Stubs/lzma-x86-ansi +0 -0
  18. package/dist-win-x64/nsis/Stubs/lzma-x86-unicode +0 -0
  19. package/dist-win-x64/nsis/Stubs/lzma_solid-amd64-unicode +0 -0
  20. package/dist-win-x64/nsis/Stubs/lzma_solid-x86-ansi +0 -0
  21. package/dist-win-x64/nsis/Stubs/lzma_solid-x86-unicode +0 -0
  22. package/dist-win-x64/nsis/Stubs/uninst +0 -0
  23. package/dist-win-x64/nsis/Stubs/zlib-amd64-unicode +0 -0
  24. package/dist-win-x64/nsis/Stubs/zlib-x86-ansi +0 -0
  25. package/dist-win-x64/nsis/Stubs/zlib-x86-unicode +0 -0
  26. package/dist-win-x64/nsis/Stubs/zlib_solid-amd64-unicode +0 -0
  27. package/dist-win-x64/nsis/Stubs/zlib_solid-x86-ansi +0 -0
  28. package/dist-win-x64/nsis/Stubs/zlib_solid-x86-unicode +0 -0
  29. package/package.json +1 -1
  30. package/src/cli/index.ts +141 -128
  31. package/src/installer/installer-wrapper-template.ts +128 -52
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparkbun",
3
- "version": "0.1.9",
3
+ "version": "0.2.1",
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;
@@ -2078,7 +2078,7 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
2078
2078
  }
2079
2079
 
2080
2080
  // Compile launcher from source using Bun.build() API
2081
- 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"));
2082
2082
  const sparkbunRoot = join(cliDir, "..", "..");
2083
2083
  const launcherSourcePath = join(sparkbunRoot, "src", "launcher", "main.ts");
2084
2084
  const bunTarget = `bun-${targetOS === "macos" ? "darwin" : targetOS === "win" ? "windows" : "linux"}-${currentTarget.arch}` as const;
@@ -3198,6 +3198,23 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3198
3198
  // stapler validate -v <app path>
3199
3199
  }
3200
3200
 
3201
+ /**
3202
+ * Build a graphical installer as a single-file executable.
3203
+ *
3204
+ * The output is a SparkBun app compiled into one binary. It uses a two-phase
3205
+ * execution model: on first launch it extracts a small runtime bundle (~1MB
3206
+ * of DLLs, views, app code) to temp, copies itself there, and re-launches
3207
+ * with admin elevation. On second launch (from temp, with DLLs present) it
3208
+ * loads SparkBunCore and runs the webview-based installer UI.
3209
+ *
3210
+ * Payload archives are compressed with LZMA via NSIS and embedded directly
3211
+ * in the binary. They stay embedded until the user triggers installation,
3212
+ * at which point the app code writes them to disk and runs them as silent
3213
+ * self-extractors.
3214
+ *
3215
+ * This is a separate pipeline from `runBuild` — it does not produce update
3216
+ * artifacts, delta patches, DMGs, or .deb packages.
3217
+ */
3201
3218
  async function runInstallerBuild(
3202
3219
  config: Awaited<ReturnType<typeof getConfig>>,
3203
3220
  buildEnvironment: "dev" | "stable",
@@ -3282,76 +3299,74 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3282
3299
  }
3283
3300
  mkdirSync(buildFolder, { recursive: true });
3284
3301
 
3285
- // --- Create app bundle ---
3302
+ const cliDir = dirname(decodeURIComponent(new URL(import.meta.url).pathname).replace(/^\/([A-Za-z]:)/, "$1"));
3303
+ const sparkbunRoot = join(cliDir, "..", "..");
3304
+ const bunTarget = `bun-${targetOS === "macos" ? "darwin" : targetOS === "win" ? "windows" : "linux"}-${targetARCH}` as const;
3305
+ const targetOSName = targetOS === "macos" ? "darwin" : targetOS === "win" ? "windows" : "linux";
3306
+ const isWindows = targetOS === "win";
3286
3307
 
3287
- const appFileName = getAppFileName(config.app.name, buildEnvironment);
3288
- const bundle = createAppBundle(appFileName, buildFolder, targetOS);
3289
- const appBundleFolderPath = bundle.appBundleFolderPath;
3290
- const appBundleMacOSPath = bundle.appBundleMacOSPath;
3291
- const appBundleFolderResourcesPath = bundle.appBundleFolderResourcesPath;
3292
- const appBundleAppCodePath = join(appBundleFolderResourcesPath, "app");
3293
- mkdirSync(appBundleAppCodePath, { recursive: true });
3308
+ // --- Create archives first (they go into the compiled binary, not the runtime bundle) ---
3294
3309
 
3295
- // --- Compile launcher ---
3310
+ const stagingDir = join(buildFolder, ".installer-staging");
3311
+ if (existsSync(stagingDir)) rmSync(stagingDir, { recursive: true, force: true });
3312
+ mkdirSync(stagingDir, { recursive: true });
3296
3313
 
3297
- const launcherBinaryName = config.app.name.replace(/ /g, "");
3298
- const launcherDestination = join(appBundleMacOSPath, launcherBinaryName) + targetBinExt;
3299
- mkdirSync(dirname(launcherDestination), { recursive: true });
3314
+ const archiveManifest: Record<string, string> = {};
3300
3315
 
3301
- const cliDir = dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"));
3302
- const sparkbunRoot = join(cliDir, "..", "..");
3303
- const launcherSourcePath = join(sparkbunRoot, "src", "launcher", "main.ts");
3304
- const bunTarget = `bun-${targetOS === "macos" ? "darwin" : targetOS === "win" ? "windows" : "linux"}-${targetARCH}` as const;
3316
+ if (archives.length > 0) {
3317
+ console.log("Creating archives (LZMA solid via NSIS)...");
3305
3318
 
3306
- const compileOptions: any = {
3307
- target: bunTarget,
3308
- outfile: launcherDestination,
3309
- };
3319
+ const nsisDir = join(SPARKBUN_DEP_PATH, `dist-${targetOS}-${targetARCH}`, "nsis");
3320
+ const makensisPath = join(nsisDir, "Bin", "makensis.exe");
3321
+ if (!existsSync(makensisPath)) {
3322
+ throw new Error(`makensis not found at ${makensisPath}. LZMA compression requires NSIS binaries in dist-${targetOS}-${targetARCH}/nsis/`);
3323
+ }
3310
3324
 
3311
- if (targetOS === "win" && OS === "win") {
3312
- let icoPath: string | undefined;
3313
- if (config.build.win?.icon) {
3314
- const iconSrc = config.build.win.icon.startsWith("/") || config.build.win.icon.match(/^[a-zA-Z]:/)
3315
- ? config.build.win.icon
3316
- : join(projectRoot, config.build.win.icon);
3317
- if (existsSync(iconSrc)) {
3318
- icoPath = iconSrc;
3319
- if (iconSrc.toLowerCase().endsWith(".png")) {
3320
- const pngToIco = (await import("png-to-ico")).default;
3321
- const tempIcoPath = join(buildFolder, "temp-launcher-icon.ico");
3322
- const icoBuffer = await pngToIco(iconSrc);
3323
- writeFileSync(tempIcoPath, new Uint8Array(icoBuffer));
3324
- icoPath = tempIcoPath;
3325
- }
3325
+ for (const { name, path: archiveSrcPath } of archives) {
3326
+ const archiveFileName = `archive-${name}.exe`;
3327
+ const archiveDestPath = join(stagingDir, archiveFileName);
3328
+ const nsiPath = join(stagingDir, `archive-${name}.nsi`);
3329
+
3330
+ console.log(` Archiving ${name}: ${archiveSrcPath}`);
3331
+
3332
+ 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`;
3333
+ writeFileSync(nsiPath, nsiContent);
3334
+
3335
+ const result = Bun.spawnSync([makensisPath, nsiPath], {
3336
+ cwd: nsisDir,
3337
+ env: { ...process.env, NSISDIR: nsisDir },
3338
+ stdout: "pipe",
3339
+ stderr: "pipe",
3340
+ });
3341
+
3342
+ if (result.exitCode !== 0) {
3343
+ const stderr = result.stderr.toString();
3344
+ const stdout = result.stdout.toString();
3345
+ console.error(`makensis failed for ${name}:`, stderr || stdout);
3346
+ throw new Error(`Failed to compress archive: ${name}`);
3326
3347
  }
3327
- }
3328
- compileOptions.windows = {
3329
- hideConsole: true,
3330
- ...(icoPath && { icon: icoPath }),
3331
- title: config.app.name,
3332
- version: installerVersion,
3333
- description: config.app.name,
3334
- publisher: installerPublisher || " ",
3335
- copyright: (config.app as any).copyright || " ",
3336
- };
3337
- }
3338
3348
 
3339
- console.log("Compiling installer launcher...");
3340
- const launcherBuild = await Bun.build({
3341
- entrypoints: [launcherSourcePath],
3342
- compile: compileOptions,
3343
- });
3344
- if (!launcherBuild.success) {
3345
- console.error("Launcher compilation failed:", launcherBuild.logs);
3346
- throw new Error("Launcher compilation failed");
3347
- }
3349
+ unlinkSync(nsiPath);
3348
3350
 
3349
- if (targetOS === "win") {
3350
- patchPeSubsystem(launcherDestination);
3351
+ const archiveSize = statSync(archiveDestPath).size;
3352
+ console.log(` ${name}: ${(archiveSize / 1024 / 1024).toFixed(2)} MB`);
3353
+ archiveManifest[name] = archiveFileName;
3354
+ }
3351
3355
  }
3352
3356
 
3353
- // --- Copy native wrappers ---
3357
+ // --- Build the runtime bundle (DLLs, views, app code — NO launcher binary, NO archives) ---
3354
3358
 
3359
+ console.log("Building runtime bundle...");
3360
+ const appFileName = getAppFileName(config.app.name, buildEnvironment);
3361
+ const bundle = createAppBundle(appFileName, buildFolder, targetOS);
3362
+ const appBundleFolderPath = bundle.appBundleFolderPath;
3363
+ const appBundleMacOSPath = bundle.appBundleMacOSPath;
3364
+ const appBundleFolderResourcesPath = bundle.appBundleFolderResourcesPath;
3365
+ const appBundleAppCodePath = join(appBundleFolderResourcesPath, "app");
3366
+ mkdirSync(appBundleAppCodePath, { recursive: true });
3367
+ mkdirSync(appBundleMacOSPath, { recursive: true });
3368
+
3369
+ // Copy native wrappers
3355
3370
  if (targetOS === "win") {
3356
3371
  cpSync(targetPaths.CORE_WIN, join(appBundleMacOSPath, "SparkBunCore.dll"), { dereference: true });
3357
3372
  cpSync(targetPaths.NATIVE_WRAPPER_WIN, join(appBundleMacOSPath, "libNativeWrapper.dll"), { dereference: true });
@@ -3372,8 +3387,7 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3372
3387
  cpSync(asarLibSource, join(appBundleMacOSPath, "libasar" + libExt), { dereference: true });
3373
3388
  }
3374
3389
 
3375
- // --- Bundle bun code ---
3376
-
3390
+ // Bundle bun code
3377
3391
  const bunConfig = config.build.bun;
3378
3392
  const bunSource = join(projectRoot, bunConfig.entrypoint);
3379
3393
  if (!existsSync(bunSource)) {
@@ -3394,8 +3408,7 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3394
3408
  throw new Error("Build failed: bun build failed");
3395
3409
  }
3396
3410
 
3397
- // --- Bundle views ---
3398
-
3411
+ // Bundle views
3399
3412
  for (const viewName in config.build.views) {
3400
3413
  const viewConfig = config.build.views[viewName]!;
3401
3414
  const viewSource = join(projectRoot, viewConfig.entrypoint);
@@ -3420,8 +3433,7 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3420
3433
  }
3421
3434
  }
3422
3435
 
3423
- // --- Copy assets ---
3424
-
3436
+ // Copy assets
3425
3437
  for (const relSource in config.build.copy) {
3426
3438
  const source = join(projectRoot, relSource);
3427
3439
  if (!existsSync(source)) {
@@ -3438,8 +3450,7 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3438
3450
  cpSync(source, destination, { recursive: true, dereference: true });
3439
3451
  }
3440
3452
 
3441
- // --- Write build.json ---
3442
-
3453
+ // Write build.json
3443
3454
  const buildJson = {
3444
3455
  name: config.app.name,
3445
3456
  identifier: config.app.identifier,
@@ -3449,69 +3460,42 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3449
3460
  };
3450
3461
  await Bun.write(join(appBundleAppCodePath, "build.json"), JSON.stringify(buildJson, null, 2));
3451
3462
 
3452
- // --- Create and embed archives ---
3453
-
3454
- if (archives.length > 0) {
3455
- console.log("Creating archives...");
3456
- const archiveManifest: Record<string, string> = {};
3457
-
3458
- for (const { name, path: archiveSrcPath } of archives) {
3459
- const archiveFileName = `archive-${name}.tar.gz`;
3460
- const archiveDestPath = join(appBundleFolderResourcesPath, archiveFileName);
3461
-
3462
- console.log(` Archiving ${name}: ${archiveSrcPath}`);
3463
- const tarPath = join(buildFolder, `archive-${name}.tar`);
3464
- createTar(tarPath, dirname(archiveSrcPath), [basename(archiveSrcPath)]);
3465
-
3466
- const tarBytes = readFileSync(tarPath);
3467
- const gzipped = Bun.gzipSync(tarBytes);
3468
- writeFileSync(archiveDestPath, gzipped);
3469
-
3470
- // Clean up intermediate tar
3471
- unlinkSync(tarPath);
3463
+ // Write archives.json manifest into runtime bundle so app code knows archive names
3464
+ await Bun.write(
3465
+ join(appBundleFolderResourcesPath, "archives.json"),
3466
+ JSON.stringify(archiveManifest, null, 2),
3467
+ );
3472
3468
 
3473
- const gzSize = statSync(archiveDestPath).size;
3474
- console.log(` ${name}: ${(gzSize / 1024 / 1024).toFixed(2)} MB`);
3475
- archiveManifest[name] = archiveFileName;
3469
+ // --- Create runtime bundle tar.gz (small — no launcher binary, no archives) ---
3470
+
3471
+ console.log("Packaging runtime bundle (gzip level 12)...");
3472
+ const runtimeFiles: Record<string, ArrayBuffer> = {};
3473
+ const bundleBase = basename(appBundleFolderPath);
3474
+ async function walkBundle(dir: string, prefix: string) {
3475
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
3476
+ const fullPath = join(dir, entry.name);
3477
+ const archivePath = prefix ? `${prefix}/${entry.name}` : entry.name;
3478
+ if (entry.isDirectory()) {
3479
+ await walkBundle(fullPath, archivePath);
3480
+ } else {
3481
+ runtimeFiles[archivePath] = await Bun.file(fullPath).arrayBuffer();
3482
+ }
3476
3483
  }
3477
-
3478
- // Write archives.json manifest
3479
- await Bun.write(
3480
- join(appBundleFolderResourcesPath, "archives.json"),
3481
- JSON.stringify(archiveManifest, null, 2),
3482
- );
3483
- console.log(`Archives manifest written (${archives.length} archives)`);
3484
3484
  }
3485
+ await walkBundle(appBundleFolderPath, bundleBase);
3485
3486
 
3486
- // --- Tar + gzip the app bundle ---
3487
-
3488
- console.log("Packaging app bundle...");
3489
- const tarPath = join(buildFolder, `${appFileName}.tar`);
3490
- createTar(tarPath, buildFolder, [basename(appBundleFolderPath)]);
3491
-
3492
- const compressedTarPath = `${tarPath}.gz`;
3493
- const tarBytes = readFileSync(tarPath);
3494
- const gzipped = Bun.gzipSync(tarBytes);
3495
- writeFileSync(compressedTarPath, gzipped);
3496
- unlinkSync(tarPath);
3497
-
3498
- // Clean up the uncompressed app bundle
3487
+ const runtimeArchive = new Bun.Archive(runtimeFiles, { compress: "gzip", level: 12 });
3488
+ const runtimeCompressedPath = join(buildFolder, `${appFileName}-runtime.tar.gz`);
3489
+ await Bun.write(runtimeCompressedPath, await runtimeArchive.bytes());
3499
3490
  rmSync(appBundleFolderPath, { recursive: true });
3500
3491
 
3501
- // --- Compile outer installer exe ---
3492
+ const runtimeSize = statSync(runtimeCompressedPath).size;
3493
+ console.log(` Runtime bundle: ${(runtimeSize / 1024 / 1024).toFixed(2)} MB`);
3502
3494
 
3503
- const defaultOutName = `${installerName.replace(/ /g, "-")}-Setup${targetBinExt}`;
3504
- const outputPath = outPath
3505
- ? path.resolve(projectRoot, outPath)
3506
- : join(buildFolder, defaultOutName);
3495
+ // --- Generate entrypoint and compile single installer exe ---
3507
3496
 
3508
- console.log("Compiling installer executable...");
3509
- const stagingDir = join(buildFolder, ".installer-wrapper-staging");
3510
- if (existsSync(stagingDir)) rmSync(stagingDir, { recursive: true, force: true });
3511
- mkdirSync(stagingDir, { recursive: true });
3512
-
3513
- // Copy app bundle archive
3514
- copyFileSync(compressedTarPath, join(stagingDir, "app-archive.tar.gz"));
3497
+ // Copy runtime bundle into staging
3498
+ copyFileSync(runtimeCompressedPath, join(stagingDir, "runtime-bundle.tar.gz"));
3515
3499
 
3516
3500
  // Write metadata
3517
3501
  const metadata: Record<string, unknown> = {
@@ -3519,6 +3503,7 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3519
3503
  version: installerVersion,
3520
3504
  identifier: config.app.identifier,
3521
3505
  channel: buildEnvironment,
3506
+ requireAdmin: (config.build?.win as any)?.requireAdmin || (config.build?.linux as any)?.requireAdmin || false,
3522
3507
  };
3523
3508
  if (metadataPath) {
3524
3509
  const userMetadata = JSON.parse(readFileSync(path.resolve(projectRoot, metadataPath), "utf-8"));
@@ -3526,16 +3511,42 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3526
3511
  }
3527
3512
  writeFileSync(join(stagingDir, "metadata.json"), JSON.stringify(metadata, null, 2));
3528
3513
 
3529
- // Copy wrapper template
3514
+ // Generate the entrypoint that imports the wrapper template + all archive files
3515
+ // The wrapper template imports runtime-bundle.tar.gz and metadata.json.
3516
+ // We generate an archives module that the app code can import at runtime.
3530
3517
  const wrapperTemplatePath = join(cliDir, "..", "installer", "installer-wrapper-template.ts");
3531
3518
  copyFileSync(wrapperTemplatePath, join(stagingDir, "installer.ts"));
3532
3519
 
3533
- const targetOSName = targetOS === "macos" ? "darwin" : targetOS === "win" ? "windows" : "linux";
3534
- const isWindows = targetOS === "win";
3520
+ // Generate archives.ts that embeds each archive via static imports
3521
+ const archiveImports: string[] = [];
3522
+ const archiveExports: string[] = [];
3523
+ for (const { name } of archives) {
3524
+ const fileName = archiveManifest[name];
3525
+ archiveImports.push(`import archive_${name} from "./${fileName}" with { type: "file" };`);
3526
+ archiveExports.push(` "${name}": archive_${name},`);
3527
+ }
3528
+
3529
+ const archivesModuleContent = `${archiveImports.join("\n")}
3530
+
3531
+ export const embeddedArchives: Record<string, string> = {
3532
+ ${archiveExports.join("\n")}
3533
+ };
3534
+ `;
3535
+ writeFileSync(join(stagingDir, "embedded-archives.ts"), archivesModuleContent);
3536
+
3537
+ // --- Compile single installer exe ---
3538
+
3539
+ const defaultOutName = `${installerName.replace(/ /g, "-")}-Setup${targetBinExt}`;
3540
+ const outputPath = outPath
3541
+ ? path.resolve(projectRoot, outPath)
3542
+ : join(buildFolder, defaultOutName);
3543
+
3544
+ console.log("Compiling installer executable...");
3535
3545
 
3536
3546
  const installerCompileOptions: any = {
3537
3547
  target: `bun-${targetOSName}-${targetARCH}`,
3538
3548
  outfile: outputPath,
3549
+ minify: true,
3539
3550
  };
3540
3551
 
3541
3552
  if (isWindows && OS === "win") {
@@ -3554,7 +3565,9 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3554
3565
  }
3555
3566
  }
3556
3567
  installerCompileOptions.windows = {
3557
- hideConsole: true,
3568
+ // Don't set hideConsole — it conflicts with the ShellExecuteW("runas")
3569
+ // elevation flow in the wrapper template. The PE subsystem patch
3570
+ // (CONSOLE -> WINDOWS) applied after compilation hides the console.
3558
3571
  ...(icoPath && { icon: icoPath }),
3559
3572
  title: `${installerName} Setup`,
3560
3573
  version: installerVersion,
@@ -4226,7 +4239,7 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
4226
4239
  writeFileSync(join(stagingDir, "metadata.json"), JSON.stringify(metadata, null, 2));
4227
4240
 
4228
4241
  // Copy installer template
4229
- const cliDir = dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"));
4242
+ const cliDir = dirname(decodeURIComponent(new URL(import.meta.url).pathname).replace(/^\/([A-Za-z]:)/, "$1"));
4230
4243
  const templatePath = join(cliDir, "..", "installer", "installer-template.ts");
4231
4244
  copyFileSync(templatePath, join(stagingDir, "installer.ts"));
4232
4245
 
@@ -1,69 +1,145 @@
1
- import archiveData from "./app-archive.tar.gz" with { type: "file" };
1
+ /**
2
+ * SparkBun Installer Wrapper Template
3
+ *
4
+ * This file is the entrypoint for installers built with `bun sparkbun installer`.
5
+ * It gets compiled into a single executable that contains:
6
+ * - A small runtime bundle (DLLs, views, app code — ~1MB compressed)
7
+ * - The installer's payload archives (LZMA compressed via NSIS)
8
+ *
9
+ * The binary uses a two-phase execution model with a single Bun runtime:
10
+ *
11
+ * Phase 1 (first launch — user double-clicks the exe):
12
+ * No native DLLs next to the exe, so we can't load SparkBunCore yet.
13
+ * Extract the runtime bundle to a temp directory, copy ourselves there
14
+ * (so the same binary now has DLLs next to it), and re-launch the copy
15
+ * with admin elevation via ShellExecuteW("runas"). The UAC prompt shows
16
+ * the exe's PE metadata (app name, publisher) since Windows reads it
17
+ * from the binary being elevated.
18
+ *
19
+ * Phase 2 (second launch — from temp, elevated):
20
+ * DLLs are next to us. Load SparkBunCore via FFI, start the app code
21
+ * as a Worker thread, and run the native event loop. The app code opens
22
+ * a webview window (the installer wizard UI) and can access the embedded
23
+ * payload archives via globalThis.__installerArchives when the user
24
+ * triggers installation.
25
+ *
26
+ * The payload archives stay embedded in the binary at all times — they are
27
+ * never extracted to temp. Only when the user clicks "Install" does the app
28
+ * code write them to disk and run them (NSIS silent self-extractors).
29
+ */
30
+
31
+ import runtimeArchive from "./runtime-bundle.tar.gz" with { type: "file" };
2
32
  import metadataJson from "./metadata.json" with { type: "file" };
3
- import { join } from "path";
4
- import { existsSync, mkdirSync, rmSync } from "fs";
33
+ import { embeddedArchives } from "./embedded-archives";
34
+ import { join, dirname, basename } from "path";
35
+ import { existsSync, mkdirSync, readdirSync, copyFileSync } from "fs";
36
+ import { dlopen, suffix, ptr } from "bun:ffi";
5
37
 
6
- interface Metadata {
7
- name: string;
8
- version?: string;
9
- }
38
+ const metadata = await Bun.file(metadataJson).json();
39
+ const binDir = dirname(process.execPath);
40
+ const coreLibName = process.platform === "win32"
41
+ ? "SparkBunCore.dll"
42
+ : `libSparkBunCore.${suffix}`;
43
+ const hasRuntime = existsSync(join(binDir, coreLibName));
10
44
 
11
- const metadata: Metadata = await Bun.file(metadataJson).json();
12
- const tempDir = join(
13
- process.env.TEMP || process.env.TMP || process.env.LOCALAPPDATA || ".",
14
- `.sparkbun-installer-${Date.now()}`,
15
- );
45
+ if (hasRuntime) {
46
+ // ── Phase 2: DLLs are next to us — run the SparkBun app ──
16
47
 
17
- try {
18
- console.log(`Starting ${metadata.name} installer...`);
48
+ process.chdir(binDir);
49
+
50
+ // Expose embedded archives to the app code via globalThis.
51
+ // The app's install logic reads these paths to extract payloads.
52
+ (globalThis as any).__installerArchives = embeddedArchives;
53
+
54
+ const coreLibPath = join(binDir, coreLibName);
55
+ const lib = dlopen(coreLibPath, {
56
+ sparkbun_core_run_main_thread: {
57
+ args: ["cstring", "cstring", "cstring", "i32"],
58
+ returns: "i32",
59
+ },
60
+ });
61
+
62
+ const resourcesDir = join(binDir, "..", "Resources");
63
+ const appEntrypointPath = join(resourcesDir, "app", "bun", "index.js");
64
+
65
+ // Read app identity from build.json for SparkBunCore initialization
66
+ let identifier = metadata.name || "";
67
+ let name = metadata.name || "";
68
+ let channel = "";
69
+ try {
70
+ const buildJsonPath = join(resourcesDir, "app", "build.json");
71
+ if (existsSync(buildJsonPath)) {
72
+ const buildConfig = JSON.parse(require("fs").readFileSync(buildJsonPath, "utf-8"));
73
+ identifier = buildConfig.identifier || identifier;
74
+ name = buildConfig.name || name;
75
+ channel = buildConfig.channel || "";
76
+ }
77
+ } catch {}
78
+
79
+ // Suppress default signal handling so the native event loop controls shutdown
80
+ process.on("SIGINT", () => {});
81
+ process.on("SIGTERM", () => {});
82
+
83
+ // Start the app code in a Worker thread (same pattern as SparkBun's launcher).
84
+ // The Worker runs the developer's Bun entrypoint which creates BrowserWindows,
85
+ // sets up RPC handlers, etc.
86
+ new Worker(appEntrypointPath);
87
+
88
+ // Run the native event loop on the main thread (blocks until app exits)
89
+ lib.symbols.sparkbun_core_run_main_thread(
90
+ ptr(new Uint8Array(Buffer.from(identifier + "\0", "utf8"))),
91
+ ptr(new Uint8Array(Buffer.from(name + "\0", "utf8"))),
92
+ ptr(new Uint8Array(Buffer.from(channel + "\0", "utf8"))),
93
+ 0,
94
+ );
95
+ } else {
96
+ // ── Phase 1: No DLLs — extract runtime, copy self, launch elevated ──
97
+
98
+ const tempDir = join(
99
+ process.env.TEMP || process.env.LOCALAPPDATA || ".",
100
+ `.sparkbun-installer-${metadata.name.replace(/[^a-zA-Z0-9]/g, "_")}`,
101
+ );
19
102
 
20
- const archiveBytes = await Bun.file(archiveData).bytes();
21
- const archive = new Bun.Archive(archiveBytes);
22
103
  mkdirSync(tempDir, { recursive: true });
104
+ const archiveBytes = await Bun.file(runtimeArchive).bytes();
105
+ const archive = new Bun.Archive(archiveBytes);
23
106
  await archive.extract(tempDir);
24
107
 
25
- // Find the launcher exe inside the extracted bundle
26
- const { readdirSync } = await import("fs");
27
- let appDir = tempDir;
28
- const entries = readdirSync(tempDir);
29
- for (const entry of entries) {
108
+ // The runtime bundle extracts as AppName/bin/ with DLLs inside
109
+ let extractedBinDir = tempDir;
110
+ for (const entry of readdirSync(tempDir)) {
30
111
  const entryPath = join(tempDir, entry);
31
112
  if (existsSync(join(entryPath, "bin"))) {
32
- appDir = entryPath;
113
+ extractedBinDir = join(entryPath, "bin");
33
114
  break;
34
115
  }
35
116
  }
36
117
 
37
- const exeName = metadata.name.replace(/ /g, "");
38
- const binExt = process.platform === "win32" ? ".exe" : "";
39
- const launcherPath = join(appDir, "bin", `${exeName}${binExt}`);
40
-
41
- if (!existsSync(launcherPath)) {
42
- throw new Error(`Launcher not found: ${launcherPath}`);
43
- }
44
-
45
- const proc = Bun.spawn([launcherPath], {
46
- cwd: join(appDir, "bin"),
47
- stdio: ["inherit", "inherit", "inherit"],
48
- });
118
+ // Copy ourselves next to the DLLs so the second launch detects hasRuntime
119
+ const destExe = join(extractedBinDir, basename(process.execPath));
120
+ copyFileSync(process.execPath, destExe);
49
121
 
50
- const exitCode = await proc.exited;
51
-
52
- // Clean up temp directory
53
- try {
54
- rmSync(tempDir, { recursive: true, force: true });
55
- } catch {}
56
-
57
- process.exit(exitCode);
58
- } catch (e: any) {
59
- console.error(`Installer failed: ${e.message}`);
60
- // Clean up on failure
61
- try {
62
- rmSync(tempDir, { recursive: true, force: true });
63
- } catch {}
64
- if (process.platform === "win32") {
65
- console.log("Press Enter to exit...");
66
- for await (const _ of console) { break; }
122
+ // Launch the copy with elevation using the same pattern as installer-template.ts.
123
+ // ShellExecuteW("runas") triggers the UAC prompt showing the exe's PE metadata.
124
+ if (process.platform === "win32" && metadata.requireAdmin) {
125
+ const shell32 = dlopen("shell32.dll", {
126
+ ShellExecuteW: {
127
+ args: ["ptr", "ptr", "ptr", "ptr", "ptr", "i32"],
128
+ returns: "ptr",
129
+ },
130
+ });
131
+ const encode = (s: string) => ptr(new Uint8Array(Buffer.from(s + "\0", "utf-16le")));
132
+ shell32.symbols.ShellExecuteW(
133
+ null,
134
+ encode("runas"),
135
+ encode(destExe),
136
+ null,
137
+ encode(extractedBinDir),
138
+ 1,
139
+ );
140
+ shell32.close();
141
+ } else {
142
+ Bun.spawn([destExe], { cwd: extractedBinDir });
67
143
  }
68
- process.exit(1);
144
+ await Bun.sleep(1000);
69
145
  }