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.
- package/dist-win-arm64/SparkBunCore.dll +0 -0
- package/dist-win-arm64/libNativeWrapper.dll +0 -0
- package/dist-win-x64/SparkBunCore.dll +0 -0
- package/dist-win-x64/libNativeWrapper.dll +0 -0
- package/package.json +1 -1
- package/src/cli/index.ts +21 -1
- package/src/core/main.zig +10 -2
- package/src/installer/installer-wrapper-template.ts +117 -130
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
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
|
|
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,
|
|
5
|
-
import { existsSync, mkdirSync,
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
129
|
-
if (existsSync(
|
|
130
|
-
const
|
|
131
|
-
identifier =
|
|
132
|
-
name =
|
|
133
|
-
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(
|
|
139
|
-
Buffer.from(
|
|
140
|
-
Buffer.from(channel + "\0", "
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
144
|
+
await Bun.sleep(1000);
|
|
158
145
|
}
|