sparkbun 0.1.9 → 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 +120 -127
  29. package/src/installer/installer-wrapper-template.ts +114 -25
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.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;
@@ -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;
@@ -3282,76 +3282,74 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3282
3282
  }
3283
3283
  mkdirSync(buildFolder, { recursive: true });
3284
3284
 
3285
- // --- Create app bundle ---
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";
3286
3290
 
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 });
3291
+ // --- Create archives first (they go into the compiled binary, not the runtime bundle) ---
3294
3292
 
3295
- // --- Compile launcher ---
3293
+ const stagingDir = join(buildFolder, ".installer-staging");
3294
+ if (existsSync(stagingDir)) rmSync(stagingDir, { recursive: true, force: true });
3295
+ mkdirSync(stagingDir, { recursive: true });
3296
3296
 
3297
- const launcherBinaryName = config.app.name.replace(/ /g, "");
3298
- const launcherDestination = join(appBundleMacOSPath, launcherBinaryName) + targetBinExt;
3299
- mkdirSync(dirname(launcherDestination), { recursive: true });
3297
+ const archiveManifest: Record<string, string> = {};
3300
3298
 
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;
3299
+ if (archives.length > 0) {
3300
+ console.log("Creating archives (LZMA solid via NSIS)...");
3305
3301
 
3306
- const compileOptions: any = {
3307
- target: bunTarget,
3308
- outfile: launcherDestination,
3309
- };
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
+ }
3310
3307
 
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
- }
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}`);
3326
3330
  }
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
3331
 
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
- }
3332
+ unlinkSync(nsiPath);
3348
3333
 
3349
- if (targetOS === "win") {
3350
- patchPeSubsystem(launcherDestination);
3334
+ const archiveSize = statSync(archiveDestPath).size;
3335
+ console.log(` ${name}: ${(archiveSize / 1024 / 1024).toFixed(2)} MB`);
3336
+ archiveManifest[name] = archiveFileName;
3337
+ }
3351
3338
  }
3352
3339
 
3353
- // --- Copy native wrappers ---
3340
+ // --- Build the runtime bundle (DLLs, views, app code — NO launcher binary, NO archives) ---
3354
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
3355
3353
  if (targetOS === "win") {
3356
3354
  cpSync(targetPaths.CORE_WIN, join(appBundleMacOSPath, "SparkBunCore.dll"), { dereference: true });
3357
3355
  cpSync(targetPaths.NATIVE_WRAPPER_WIN, join(appBundleMacOSPath, "libNativeWrapper.dll"), { dereference: true });
@@ -3372,8 +3370,7 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3372
3370
  cpSync(asarLibSource, join(appBundleMacOSPath, "libasar" + libExt), { dereference: true });
3373
3371
  }
3374
3372
 
3375
- // --- Bundle bun code ---
3376
-
3373
+ // Bundle bun code
3377
3374
  const bunConfig = config.build.bun;
3378
3375
  const bunSource = join(projectRoot, bunConfig.entrypoint);
3379
3376
  if (!existsSync(bunSource)) {
@@ -3394,8 +3391,7 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3394
3391
  throw new Error("Build failed: bun build failed");
3395
3392
  }
3396
3393
 
3397
- // --- Bundle views ---
3398
-
3394
+ // Bundle views
3399
3395
  for (const viewName in config.build.views) {
3400
3396
  const viewConfig = config.build.views[viewName]!;
3401
3397
  const viewSource = join(projectRoot, viewConfig.entrypoint);
@@ -3420,8 +3416,7 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3420
3416
  }
3421
3417
  }
3422
3418
 
3423
- // --- Copy assets ---
3424
-
3419
+ // Copy assets
3425
3420
  for (const relSource in config.build.copy) {
3426
3421
  const source = join(projectRoot, relSource);
3427
3422
  if (!existsSync(source)) {
@@ -3438,8 +3433,7 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3438
3433
  cpSync(source, destination, { recursive: true, dereference: true });
3439
3434
  }
3440
3435
 
3441
- // --- Write build.json ---
3442
-
3436
+ // Write build.json
3443
3437
  const buildJson = {
3444
3438
  name: config.app.name,
3445
3439
  identifier: config.app.identifier,
@@ -3449,69 +3443,42 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3449
3443
  };
3450
3444
  await Bun.write(join(appBundleAppCodePath, "build.json"), JSON.stringify(buildJson, null, 2));
3451
3445
 
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);
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
+ );
3472
3451
 
3473
- const gzSize = statSync(archiveDestPath).size;
3474
- console.log(` ${name}: ${(gzSize / 1024 / 1024).toFixed(2)} MB`);
3475
- archiveManifest[name] = archiveFileName;
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
+ }
3476
3466
  }
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
3467
  }
3468
+ await walkBundle(appBundleFolderPath, bundleBase);
3485
3469
 
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
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());
3499
3473
  rmSync(appBundleFolderPath, { recursive: true });
3500
3474
 
3501
- // --- Compile outer installer exe ---
3475
+ const runtimeSize = statSync(runtimeCompressedPath).size;
3476
+ console.log(` Runtime bundle: ${(runtimeSize / 1024 / 1024).toFixed(2)} MB`);
3502
3477
 
3503
- const defaultOutName = `${installerName.replace(/ /g, "-")}-Setup${targetBinExt}`;
3504
- const outputPath = outPath
3505
- ? path.resolve(projectRoot, outPath)
3506
- : join(buildFolder, defaultOutName);
3478
+ // --- Generate entrypoint and compile single installer exe ---
3507
3479
 
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"));
3480
+ // Copy runtime bundle into staging
3481
+ copyFileSync(runtimeCompressedPath, join(stagingDir, "runtime-bundle.tar.gz"));
3515
3482
 
3516
3483
  // Write metadata
3517
3484
  const metadata: Record<string, unknown> = {
@@ -3519,6 +3486,7 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3519
3486
  version: installerVersion,
3520
3487
  identifier: config.app.identifier,
3521
3488
  channel: buildEnvironment,
3489
+ requireAdmin: (config.build?.win as any)?.requireAdmin || (config.build?.linux as any)?.requireAdmin || false,
3522
3490
  };
3523
3491
  if (metadataPath) {
3524
3492
  const userMetadata = JSON.parse(readFileSync(path.resolve(projectRoot, metadataPath), "utf-8"));
@@ -3526,12 +3494,37 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3526
3494
  }
3527
3495
  writeFileSync(join(stagingDir, "metadata.json"), JSON.stringify(metadata, null, 2));
3528
3496
 
3529
- // Copy wrapper template
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.
3530
3500
  const wrapperTemplatePath = join(cliDir, "..", "installer", "installer-wrapper-template.ts");
3531
3501
  copyFileSync(wrapperTemplatePath, join(stagingDir, "installer.ts"));
3532
3502
 
3533
- const targetOSName = targetOS === "macos" ? "darwin" : targetOS === "win" ? "windows" : "linux";
3534
- const isWindows = targetOS === "win";
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...");
3535
3528
 
3536
3529
  const installerCompileOptions: any = {
3537
3530
  target: `bun-${targetOSName}-${targetARCH}`,
@@ -4226,7 +4219,7 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
4226
4219
  writeFileSync(join(stagingDir, "metadata.json"), JSON.stringify(metadata, null, 2));
4227
4220
 
4228
4221
  // Copy installer template
4229
- 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"));
4230
4223
  const templatePath = join(cliDir, "..", "installer", "installer-template.ts");
4231
4224
  copyFileSync(templatePath, join(stagingDir, "installer.ts"));
4232
4225
 
@@ -1,63 +1,152 @@
1
- import archiveData from "./app-archive.tar.gz" with { type: "file" };
1
+ import runtimeArchive from "./runtime-bundle.tar.gz" with { type: "file" };
2
2
  import metadataJson from "./metadata.json" with { type: "file" };
3
- import { join } from "path";
4
- import { existsSync, mkdirSync, rmSync } from "fs";
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";
5
7
 
6
8
  interface Metadata {
7
9
  name: string;
8
10
  version?: string;
11
+ requireAdmin?: boolean;
9
12
  }
10
13
 
11
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.
12
18
  const tempDir = join(
13
19
  process.env.TEMP || process.env.TMP || process.env.LOCALAPPDATA || ".",
14
- `.sparkbun-installer-${Date.now()}`,
20
+ `.sparkbun-installer-${metadata.name.replace(/[^a-zA-Z0-9]/g, "_")}`,
15
21
  );
16
22
 
17
23
  try {
18
- console.log(`Starting ${metadata.name} installer...`);
24
+ if (existsSync(tempDir)) {
25
+ rmSync(tempDir, { recursive: true, force: true });
26
+ }
19
27
 
20
- const archiveBytes = await Bun.file(archiveData).bytes();
28
+ const archiveBytes = await Bun.file(runtimeArchive).bytes();
21
29
  const archive = new Bun.Archive(archiveBytes);
22
30
  mkdirSync(tempDir, { recursive: true });
23
31
  await archive.extract(tempDir);
24
32
 
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) {
33
+ // Find the bin directory inside the extracted bundle
34
+ let binDir = tempDir;
35
+ let bundleRoot = tempDir;
36
+ for (const entry of readdirSync(tempDir)) {
30
37
  const entryPath = join(tempDir, entry);
31
38
  if (existsSync(join(entryPath, "bin"))) {
32
- appDir = entryPath;
39
+ binDir = join(entryPath, "bin");
40
+ bundleRoot = entryPath;
33
41
  break;
34
42
  }
35
43
  }
36
44
 
37
- const exeName = metadata.name.replace(/ /g, "");
38
- const binExt = process.platform === "win32" ? ".exe" : "";
39
- const launcherPath = join(appDir, "bin", `${exeName}${binExt}`);
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
+ };
40
76
 
41
- if (!existsSync(launcherPath)) {
42
- throw new Error(`Launcher not found: ${launcherPath}`);
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
+ }
43
104
  }
44
105
 
45
- const proc = Bun.spawn([launcherPath], {
46
- cwd: join(appDir, "bin"),
47
- stdio: ["inherit", "inherit", "inherit"],
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
+ },
48
122
  });
49
123
 
50
- const exitCode = await proc.exited;
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
+ );
51
143
 
52
- // Clean up temp directory
144
+ // Cleanup temp on exit
53
145
  try {
54
146
  rmSync(tempDir, { recursive: true, force: true });
55
147
  } catch {}
56
-
57
- process.exit(exitCode);
58
148
  } catch (e: any) {
59
149
  console.error(`Installer failed: ${e.message}`);
60
- // Clean up on failure
61
150
  try {
62
151
  rmSync(tempDir, { recursive: true, force: true });
63
152
  } catch {}