sparkbun 0.2.0 → 0.2.2

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.
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparkbun",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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
@@ -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",
@@ -3529,6 +3546,7 @@ ${archiveExports.join("\n")}
3529
3546
  const installerCompileOptions: any = {
3530
3547
  target: `bun-${targetOSName}-${targetARCH}`,
3531
3548
  outfile: outputPath,
3549
+ minify: true,
3532
3550
  };
3533
3551
 
3534
3552
  if (isWindows && OS === "win") {
@@ -3547,7 +3565,9 @@ ${archiveExports.join("\n")}
3547
3565
  }
3548
3566
  }
3549
3567
  installerCompileOptions.windows = {
3550
- 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.
3551
3571
  ...(icoPath && { icon: icoPath }),
3552
3572
  title: `${installerName} Setup`,
3553
3573
  version: installerVersion,
package/src/core/main.zig CHANGED
@@ -900,8 +900,6 @@ export fn configureWebviewRuntime(
900
900
  ) bool {
901
901
  clearLastError();
902
902
 
903
- webview_runtime_state.rpc_port = rpc_port;
904
-
905
903
  if (!replaceOptionalOwnedZ(&webview_runtime_state.preload_script, preload_script)) {
906
904
  return false;
907
905
  }
@@ -913,6 +911,16 @@ export fn configureWebviewRuntime(
913
911
  return false;
914
912
  }
915
913
 
914
+ // On Windows, skip the WebSocket transport server — RPC goes through
915
+ // the native WebView2 host bridge (postMessage) instead.
916
+ if (comptime builtin.os.tag == .windows) {
917
+ webview_runtime_state.rpc_port = 0;
918
+ webview_runtime_state.configured = true;
919
+ return true;
920
+ }
921
+
922
+ webview_runtime_state.rpc_port = rpc_port;
923
+
916
924
  if (!startHostTransportServer(rpc_port)) {
917
925
  return false;
918
926
  }
@@ -1,119 +1,57 @@
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
+
1
31
  import runtimeArchive from "./runtime-bundle.tar.gz" with { type: "file" };
2
32
  import metadataJson from "./metadata.json" with { type: "file" };
3
33
  import { embeddedArchives } from "./embedded-archives";
4
- import { join, dirname, resolve } from "path";
5
- import { existsSync, mkdirSync, rmSync, readdirSync, writeFileSync } from "fs";
34
+ import { join, dirname, basename } from "path";
35
+ import { existsSync, mkdirSync, readdirSync, copyFileSync } from "fs";
6
36
  import { dlopen, suffix, ptr } from "bun:ffi";
7
37
 
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
- }
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));
27
44
 
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);
45
+ if (hasRuntime) {
46
+ // ── Phase 2: DLLs are next to us — run the SparkBun app ──
32
47
 
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
48
  process.chdir(binDir);
59
49
 
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
- }
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;
116
53
 
54
+ const coreLibPath = join(binDir, coreLibName);
117
55
  const lib = dlopen(coreLibPath, {
118
56
  sparkbun_core_run_main_thread: {
119
57
  args: ["cstring", "cstring", "cstring", "i32"],
@@ -121,38 +59,87 @@ try {
121
59
  },
122
60
  });
123
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 || "";
124
68
  let channel = "";
125
- let identifier = "";
126
- let name = "";
127
69
  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 || "";
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 || "";
134
76
  }
135
77
  } catch {}
136
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)
137
89
  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"),
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"))),
141
93
  0,
142
94
  );
95
+ } else {
96
+ // ── Phase 1: No DLLs — extract runtime, copy self, launch elevated ──
143
97
 
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; }
98
+ const tempDir = join(
99
+ process.env.TEMP || process.env.LOCALAPPDATA || ".",
100
+ `.sparkbun-installer-${metadata.name.replace(/[^a-zA-Z0-9]/g, "_")}`,
101
+ );
102
+
103
+ mkdirSync(tempDir, { recursive: true });
104
+ const archiveBytes = await Bun.file(runtimeArchive).bytes();
105
+ const archive = new Bun.Archive(archiveBytes);
106
+ await archive.extract(tempDir);
107
+
108
+ // The runtime bundle extracts as AppName/bin/ with DLLs inside
109
+ let extractedBinDir = tempDir;
110
+ for (const entry of readdirSync(tempDir)) {
111
+ const entryPath = join(tempDir, entry);
112
+ if (existsSync(join(entryPath, "bin"))) {
113
+ extractedBinDir = join(entryPath, "bin");
114
+ break;
115
+ }
116
+ }
117
+
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);
121
+
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 });
156
143
  }
157
- process.exit(1);
144
+ await Bun.sleep(1000);
158
145
  }