sparkbun 0.2.6 → 0.2.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparkbun",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Build fast, lightweight, cross-platform desktop apps with TypeScript and Bun.",
5
5
  "license": "MIT",
6
6
  "author": "SparkBun Contributors",
@@ -167,6 +167,12 @@ export interface SparkBunConfig {
167
167
  * Source directory (relative to project root or absolute). The
168
168
  * directory's own name becomes the archive's top-level folder,
169
169
  * so it extracts into the correct subdirectory.
170
+ *
171
+ * May also point at a PREBUILT `.tar` / `.tar.gz` / `.tar.zst`
172
+ * file, which is embedded verbatim (no per-build tar+compress —
173
+ * useful when a large payload is slow to compress and rarely
174
+ * changes). Format is taken from the extension; `format`/
175
+ * `compression` are ignored for prebuilt archives.
170
176
  */
171
177
  path: string;
172
178
  /**
@@ -177,9 +183,23 @@ export interface SparkBunConfig {
177
183
  */
178
184
  format?: "zstd" | "gzip";
179
185
  /**
180
- * Compression level. zstd: 1–22 (22 = maximum, large window).
181
- * gzip: 1–9. Higher is smaller but slower to compress.
182
- * @default 19 for zstd, 9 for gzip
186
+ * Compression tier.
187
+ * - "basic" Bun's built-in CompressionStream (zstd level 3,
188
+ * gzip level 6). Fast, streaming, no level control. (default)
189
+ * - number — explicit level via node:zlib streaming transforms.
190
+ * zstd: 1–22 (22 = maximum; large window, smallest output).
191
+ * gzip: 1–9. Higher is smaller but slower to compress.
192
+ * - "none" — no compression; a plain `.tar`. For payloads that
193
+ * are already compressed (video, media) where recompression
194
+ * wastes build time. The manifest records format "none" and
195
+ * the app's extractor must skip its decompression step.
196
+ * @default "basic"
197
+ */
198
+ compression?: "none" | "basic" | number;
199
+ /**
200
+ * Legacy alias for a numeric `compression` tier. Ignored when
201
+ * `compression` is set.
202
+ * @deprecated Use `compression` instead.
183
203
  */
184
204
  level?: number;
185
205
  }>;
@@ -922,17 +922,16 @@ if %rmRetry% GEQ 10 goto rmfailed
922
922
  timeout /t 2 /nobreak >nul
923
923
  goto rmloop
924
924
  :rmfailed
925
- echo Update failed: could not remove "${runningAppWin}" after retries.
926
- echo Files may still be locked by a helper process.
927
- pause
925
+ :: The script runs in a hidden window, so log instead of echo/pause (a hidden
926
+ :: pause would hang invisibly forever).
927
+ echo Update failed: could not remove "${runningAppWin}" after retries. Files may still be locked by a helper process. >> "%~dp0update-error.log"
928
928
  exit /b 1
929
929
  :rmdone
930
930
 
931
931
  :: Move new app to current location (safe now that destination is gone)
932
932
  move "${newAppWin}" "${runningAppWin}"
933
933
  if not exist "${launcherPathWin}" (
934
- echo Update failed: launcher not found at "${launcherPathWin}" after move.
935
- pause
934
+ echo Update failed: launcher not found at "${launcherPathWin}" after move. >> "%~dp0update-error.log"
936
935
  exit /b 1
937
936
  )
938
937
 
@@ -947,24 +946,40 @@ for /f "tokens=1" %%t in ('schtasks /query /fo list ^| findstr /i "SparkBunUpdat
947
946
  schtasks /delete /tn "%%t" /f >nul 2>&1
948
947
  )
949
948
 
950
- :: Delete this update script after a short delay
949
+ :: Delete this update script (and its hidden-window launcher) after a short delay
951
950
  ping -n 2 127.0.0.1 >nul
951
+ del "%~dp0update.vbs" 2>nul
952
952
  del "%~f0"
953
953
  `;
954
954
 
955
955
  await Bun.write(updateScriptPath, updateScript);
956
956
 
957
+ // VBS shim so the batch script runs with a HIDDEN window (style 0).
958
+ // schtasks can't hide a console app's window itself, and without
959
+ // this the whole update — wait loop included — sits in a visible
960
+ // cmd window. wscript.exe is a GUI-subsystem host, so nothing
961
+ // flashes. Errors are logged to update-error.log by the bat.
962
+ const scriptPathWin = updateScriptPath.replace(/\//g, "\\");
963
+ const vbsPath = join(parentDir, "update.vbs");
964
+ const vbsPathWin = vbsPath.replace(/\//g, "\\");
965
+ await Bun.write(
966
+ vbsPath,
967
+ `CreateObject("WScript.Shell").Run "cmd /c ""${scriptPathWin}""", 0, False\r\n`,
968
+ );
969
+
957
970
  // Use Windows Task Scheduler to run the update script independently
958
971
  // This ensures the script runs even after the app exits
959
- const scriptPathWin = updateScriptPath.replace(/\//g, "\\");
960
972
  const taskName = `SparkBunUpdate_${Date.now()}`;
961
973
 
962
- // Create a scheduled task that runs immediately and deletes itself
974
+ // Create a scheduled task that runs immediately and deletes itself.
975
+ // windowsHide stops the schtasks invocations themselves from
976
+ // flashing console windows (the app is a GUI-subsystem process,
977
+ // so each unhidden child would allocate a fresh visible console).
963
978
  execSync(
964
- `schtasks /create /tn "${taskName}" /tr "cmd /c \\"${scriptPathWin}\\"" /sc once /st 00:00 /f`,
965
- { stdio: "ignore" },
979
+ `schtasks /create /tn "${taskName}" /tr "wscript.exe //B \\"${vbsPathWin}\\"" /sc once /st 00:00 /f`,
980
+ { stdio: "ignore", windowsHide: true },
966
981
  );
967
- execSync(`schtasks /run /tn "${taskName}"`, { stdio: "ignore" });
982
+ execSync(`schtasks /run /tn "${taskName}"`, { stdio: "ignore", windowsHide: true });
968
983
  // The task will be cleaned up by Windows after it runs, or we delete it in the batch script
969
984
 
970
985
  // Use quit() for graceful shutdown - this closes all windows and processes
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Streaming, pure-Bun tar + compression for installer payload archives.
3
+ *
4
+ * The tar layer is hand-rolled ustar, emitted entry-by-entry with file bodies
5
+ * read in 8 MiB slices, so build memory stays bounded regardless of payload
6
+ * size — and there is no system `tar` dependency. (Shelling out to tar was a
7
+ * reliability problem: macOS bsdtar emits xattr pax headers that GNU tar
8
+ * rejects, and GNU tar parses "C:\..." as a remote-host spec.) The output is
9
+ * plain ustar — long paths via the name/prefix split, file modes preserved
10
+ * (executables stay executable), symlinks as typeflag '2' — readable by every
11
+ * tar implementation and by apps' streaming extractors.
12
+ *
13
+ * Compression tiers, chosen per archive (config `compression` field or
14
+ * `--compression` flag):
15
+ * - "basic" — Bun's built-in CompressionStream (zstd default ≈ level 3;
16
+ * gzip default ≈ level 6). Fast, streaming, no level control.
17
+ * The default tier.
18
+ * - <number> — explicit level via node:zlib streaming transforms (zstd 1–22,
19
+ * gzip 1–9). zstd 22 uses a large window; its ratio depends on
20
+ * it. Slower to build, smallest output.
21
+ * - "none" — no compression; a plain `.tar`. For payloads that are already
22
+ * compressed (video, archives) where recompression wastes time.
23
+ */
24
+
25
+ import { readdirSync, statSync, lstatSync, readlinkSync, createWriteStream } from "node:fs";
26
+ import { pipeline } from "node:stream/promises";
27
+ import zlib from "node:zlib";
28
+ import { join, basename } from "node:path";
29
+
30
+ export type ArchiveFormat = "zstd" | "gzip";
31
+ export type ArchiveCompression = "none" | "basic" | number;
32
+
33
+ const READ_CHUNK = 8 * 1024 * 1024; // file read slice size (bounds memory)
34
+
35
+ /** File extension for an archive of the given format + compression. */
36
+ export function archiveExtension(format: ArchiveFormat, compression: ArchiveCompression): string {
37
+ if (compression === "none") return "tar";
38
+ return format === "gzip" ? "tar.gz" : "tar.zst";
39
+ }
40
+
41
+ /**
42
+ * The effective numeric level recorded in the manifest: 0 for "none", the
43
+ * library default for "basic" (zstd 3 / gzip 6 — approximate; CompressionStream
44
+ * exposes no level), or the explicit number.
45
+ */
46
+ export function effectiveLevel(format: ArchiveFormat, compression: ArchiveCompression): number {
47
+ if (compression === "none") return 0;
48
+ if (compression === "basic") return format === "gzip" ? 6 : 3;
49
+ return compression;
50
+ }
51
+
52
+ /** Human label for build logs, e.g. "zstd level 22", "zstd (basic)", "uncompressed". */
53
+ export function compressionLabel(format: ArchiveFormat, compression: ArchiveCompression): string {
54
+ if (compression === "none") return "uncompressed";
55
+ if (compression === "basic") return `${format} (basic)`;
56
+ return `${format} level ${compression}`;
57
+ }
58
+
59
+ /** A POSIX ustar 512-byte header. Splits long paths across name/prefix. */
60
+ function ustarHeader(
61
+ path: string,
62
+ size: number,
63
+ type: "0" | "2" | "5",
64
+ mode: number,
65
+ linkname = "",
66
+ ): Uint8Array {
67
+ const bl = (s: string) => Buffer.byteLength(s);
68
+ let name = path;
69
+ let prefix = "";
70
+ if (bl(name) > 100) {
71
+ // Split at a "/" so the suffix fits the 100-byte name field and the
72
+ // prefix fits the 155-byte prefix field (extractors rejoin them).
73
+ let split = -1;
74
+ for (let i = 0; i < path.length; i++) {
75
+ if (path[i] !== "/") continue;
76
+ if (bl(path.slice(0, i)) <= 155 && bl(path.slice(i + 1)) <= 100) {
77
+ split = i;
78
+ break;
79
+ }
80
+ }
81
+ if (split < 0) throw new Error(`Path too long for ustar: ${path}`);
82
+ prefix = path.slice(0, split);
83
+ name = path.slice(split + 1);
84
+ }
85
+ if (bl(linkname) > 100) throw new Error(`Symlink target too long for ustar: ${linkname}`);
86
+ const h = Buffer.alloc(512);
87
+ h.write(name, 0);
88
+ const putOct = (off: number, len: number, v: number) =>
89
+ h.write(v.toString(8).padStart(len - 1, "0") + "\0", off);
90
+ putOct(100, 8, mode & 0o7777);
91
+ putOct(108, 8, 0); // uid
92
+ putOct(116, 8, 0); // gid
93
+ putOct(124, 12, size);
94
+ putOct(136, 12, 0); // mtime
95
+ h.write(type, 156);
96
+ h.write(linkname, 157);
97
+ h.write("ustar\0", 257);
98
+ h.write("00", 263);
99
+ h.write(prefix, 345);
100
+ h.fill(0x20, 148, 156); // checksum field = spaces while summing
101
+ let sum = 0;
102
+ for (let i = 0; i < 512; i++) sum += h[i]!;
103
+ h.write(sum.toString(8).padStart(6, "0") + "\0 ", 148);
104
+ return new Uint8Array(h.buffer, h.byteOffset, h.length);
105
+ }
106
+
107
+ type WalkEntry = {
108
+ abs: string;
109
+ rel: string;
110
+ kind: "dir" | "file" | "symlink";
111
+ size: number;
112
+ mode: number;
113
+ };
114
+
115
+ /** Depth-first walk; directories before their contents; "/" separators. */
116
+ function* walk(absDir: string, rel: string): Generator<WalkEntry> {
117
+ const entries = readdirSync(absDir, { withFileTypes: true }).sort((x, y) =>
118
+ x.name.localeCompare(y.name),
119
+ );
120
+ for (const e of entries) {
121
+ if (e.name === ".DS_Store") continue;
122
+ const abs = join(absDir, e.name);
123
+ const r = `${rel}/${e.name}`;
124
+ if (e.isSymbolicLink()) {
125
+ yield { abs, rel: r, kind: "symlink", size: 0, mode: 0o777 };
126
+ } else if (e.isDirectory()) {
127
+ yield { abs, rel: r, kind: "dir", size: 0, mode: lstatSync(abs).mode };
128
+ yield* walk(abs, r);
129
+ } else if (e.isFile()) {
130
+ const st = statSync(abs);
131
+ yield { abs, rel: r, kind: "file", size: st.size, mode: st.mode };
132
+ }
133
+ // Sockets/FIFOs/devices are skipped.
134
+ }
135
+ }
136
+
137
+ /**
138
+ * The tar bytes for the tree rooted at `srcDir`, as a lazy async sequence.
139
+ * The directory's own name becomes the archive root.
140
+ */
141
+ export async function* tarBytes(srcDir: string): AsyncGenerator<Uint8Array> {
142
+ const rootName = basename(srcDir);
143
+ yield ustarHeader(`${rootName}/`, 0, "5", statSync(srcDir).mode);
144
+ for (const e of walk(srcDir, rootName)) {
145
+ if (e.kind === "dir") {
146
+ yield ustarHeader(`${e.rel}/`, 0, "5", e.mode);
147
+ continue;
148
+ }
149
+ if (e.kind === "symlink") {
150
+ yield ustarHeader(e.rel, 0, "2", e.mode, readlinkSync(e.abs));
151
+ continue;
152
+ }
153
+ yield ustarHeader(e.rel, e.size, "0", e.mode);
154
+ const f = Bun.file(e.abs);
155
+ for (let off = 0; off < e.size; off += READ_CHUNK) {
156
+ yield new Uint8Array(await f.slice(off, Math.min(off + READ_CHUNK, e.size)).arrayBuffer());
157
+ }
158
+ const pad = (512 - (e.size % 512)) % 512;
159
+ if (pad) yield new Uint8Array(pad);
160
+ }
161
+ yield new Uint8Array(1024); // end-of-archive: two zero blocks
162
+ }
163
+
164
+ /**
165
+ * Scan a prebuilt tar archive (optionally zstd/gzip compressed) and total its
166
+ * file payload — the uncompressedBytes/fileCount progress denominators the
167
+ * manifest needs. Streaming: headers are parsed and bodies skipped, so memory
168
+ * stays bounded.
169
+ */
170
+ export async function scanTarArchive(
171
+ path: string,
172
+ format: ArchiveFormat | "none",
173
+ ): Promise<{ uncompressedBytes: number; fileCount: number }> {
174
+ let stream = Bun.file(path).stream() as unknown as ReadableStream<Uint8Array>;
175
+ if (format !== "none") {
176
+ const ds = new DecompressionStream(
177
+ (format === "gzip" ? "gzip" : "zstd") as CompressionFormat,
178
+ ) as unknown as ReadableWritablePair<Uint8Array, Uint8Array>;
179
+ stream = stream.pipeThrough(ds);
180
+ }
181
+ const reader = stream.getReader();
182
+ const td = new TextDecoder();
183
+ let buf: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
184
+ let uncompressedBytes = 0;
185
+ let fileCount = 0;
186
+ let skip = 0; // bytes of body+padding left to discard
187
+ while (true) {
188
+ const { done, value } = await reader.read();
189
+ if (done) break;
190
+ if (buf.length === 0) buf = value;
191
+ else {
192
+ const merged = new Uint8Array(buf.length + value.length);
193
+ merged.set(buf, 0);
194
+ merged.set(value, buf.length);
195
+ buf = merged;
196
+ }
197
+ while (true) {
198
+ if (skip > 0) {
199
+ const n = Math.min(skip, buf.length);
200
+ buf = buf.subarray(n);
201
+ skip -= n;
202
+ if (skip > 0) break;
203
+ }
204
+ if (buf.length < 512) break;
205
+ const h = buf.subarray(0, 512);
206
+ buf = buf.subarray(512);
207
+ let allZero = true;
208
+ for (let i = 0; i < 512; i++) {
209
+ if (h[i] !== 0) { allZero = false; break; }
210
+ }
211
+ if (allZero) continue; // end-of-archive marker block
212
+ const size =
213
+ parseInt(td.decode(h.subarray(124, 136)).replace(/\0.*$/, "").trim() || "0", 8) || 0;
214
+ const typeflag = String.fromCharCode(h[156] || 0);
215
+ // Regular files count toward the payload; metadata entries (long-name
216
+ // 'L'/'K', pax 'x'/'g') and dirs/links don't, but their bodies are
217
+ // still skipped.
218
+ if (typeflag === "0" || typeflag === "\0") {
219
+ uncompressedBytes += size;
220
+ fileCount++;
221
+ }
222
+ skip = size + ((512 - (size % 512)) % 512);
223
+ }
224
+ }
225
+ return { uncompressedBytes, fileCount };
226
+ }
227
+
228
+ /** Wrap an async generator as a web ReadableStream (for CompressionStream). */
229
+ function toReadable(gen: AsyncGenerator<Uint8Array>): ReadableStream<Uint8Array> {
230
+ return new ReadableStream<Uint8Array>({
231
+ async pull(c) {
232
+ const { done, value } = await gen.next();
233
+ if (done) c.close();
234
+ else c.enqueue(value);
235
+ },
236
+ });
237
+ }
238
+
239
+ /**
240
+ * Tar `srcDir` and write it to `destPath`, compressed per the tier. Streaming
241
+ * end to end: nothing close to the payload size is ever in memory.
242
+ */
243
+ export async function writeTarArchive(
244
+ srcDir: string,
245
+ destPath: string,
246
+ format: ArchiveFormat,
247
+ compression: ArchiveCompression,
248
+ ): Promise<void> {
249
+ const tar = tarBytes(srcDir);
250
+
251
+ if (compression === "none") {
252
+ await pipeline(tar, createWriteStream(destPath));
253
+ return;
254
+ }
255
+
256
+ if (compression === "basic") {
257
+ // Bun's built-in CompressionStream. "zstd" is valid in Bun but missing
258
+ // from the lib.dom CompressionFormat union (and the stream generics
259
+ // predate Uint8Array<ArrayBuffer>), hence the casts.
260
+ const cs = new CompressionStream(
261
+ (format === "gzip" ? "gzip" : "zstd") as CompressionFormat,
262
+ ) as unknown as ReadableWritablePair<Uint8Array, Uint8Array>;
263
+ const compressed = toReadable(tar).pipeThrough(cs);
264
+ const sink = Bun.file(destPath).writer();
265
+ const reader = compressed.getReader();
266
+ try {
267
+ while (true) {
268
+ const { done, value } = await reader.read();
269
+ if (done) break;
270
+ sink.write(value);
271
+ }
272
+ } finally {
273
+ await sink.end();
274
+ }
275
+ return;
276
+ }
277
+
278
+ // Explicit level via node:zlib streaming transforms. The zstd APIs exist in
279
+ // Bun's node:zlib but not yet in the bundled @types, hence the cast.
280
+ const z = zlib as unknown as {
281
+ createGzip(opts: { level: number }): NodeJS.ReadWriteStream;
282
+ createZstdCompress(opts: { params: Record<number, number> }): NodeJS.ReadWriteStream;
283
+ constants: { ZSTD_c_compressionLevel: number };
284
+ };
285
+ const transform =
286
+ format === "gzip"
287
+ ? z.createGzip({ level: compression })
288
+ : z.createZstdCompress({
289
+ params: { [z.constants.ZSTD_c_compressionLevel]: compression },
290
+ });
291
+ await pipeline(tar, transform, createWriteStream(destPath));
292
+ }
package/src/cli/index.ts CHANGED
@@ -33,6 +33,15 @@ import {
33
33
  getMacOSBundleDisplayName,
34
34
  } from "../shared/naming";
35
35
  import { getTemplate, getTemplateNames } from "./templates/embedded";
36
+ import {
37
+ writeTarArchive,
38
+ scanTarArchive,
39
+ archiveExtension,
40
+ effectiveLevel,
41
+ compressionLabel,
42
+ type ArchiveFormat,
43
+ type ArchiveCompression,
44
+ } from "./archive-stream";
36
45
  // MacOS named pipes hang at around 4KB
37
46
  // @ts-expect-error - reserved for future use
38
47
  const _MAX_CHUNK_SIZE = 1024 * 2;
@@ -64,6 +73,38 @@ function createTar(tarPath: string, cwd: string, entries: string[]) {
64
73
  );
65
74
  }
66
75
 
76
+ // Compile an executable via Bun.build({ compile }) with a retry around a known
77
+ // transient failure: on Windows the compile stamps PE version metadata through
78
+ // resource updates (EndUpdateResourceW), and antivirus/indexer scans of the
79
+ // freshly written exe intermittently fail that commit with "Failed to set
80
+ // Windows metadata: FailedToCommit". Clear any stale output first (a scanned
81
+ // leftover exe can hold locks), then retry a few times before giving up.
82
+ async function buildCompiledExeWithRetry(
83
+ options: Parameters<typeof Bun.build>[0],
84
+ outputPath: string,
85
+ ): Promise<Awaited<ReturnType<typeof Bun.build>>> {
86
+ try { rmSync(outputPath, { force: true }); } catch {}
87
+ for (let attempt = 1; ; attempt++) {
88
+ try {
89
+ return await Bun.build(options);
90
+ } catch (err: any) {
91
+ // Bun surfaces compile errors as BuildMessage-like objects whose
92
+ // .message can be empty — the text only shows through Bun's inspect
93
+ // formatting, so match against every representation we can get.
94
+ let msg = "";
95
+ try {
96
+ msg = `${err?.message ?? ""} ${String(err)} ${Bun.inspect(err)}`;
97
+ } catch {}
98
+ if (attempt < 4 && /FailedToCommit|Failed to set Windows metadata/i.test(msg)) {
99
+ console.log(` PE metadata commit failed (attempt ${attempt}/4) — retrying in 2s...`);
100
+ await Bun.sleep(2000);
101
+ continue;
102
+ }
103
+ throw err;
104
+ }
105
+ }
106
+ }
107
+
67
108
  // Recursively measure a directory's total file bytes and file count.
68
109
  // Used to embed accurate progress denominators in the installer archive manifest.
69
110
  function measureDir(dir: string): { bytes: number; fileCount: number } {
@@ -84,28 +125,6 @@ function measureDir(dir: string): { bytes: number; fileCount: number } {
84
125
  return { bytes, fileCount };
85
126
  }
86
127
 
87
- // Stream-compress a file with zstd or gzip at a specific level, using node:zlib
88
- // transforms so build memory stays bounded regardless of input size. Web
89
- // CompressionStream("zstd") is intentionally not used — it has no level control,
90
- // and the high compression ratio depends on level 22's large window.
91
- async function streamCompressFile(
92
- srcPath: string,
93
- destPath: string,
94
- format: "zstd" | "gzip",
95
- level: number,
96
- ): Promise<void> {
97
- const zlib = require("node:zlib");
98
- const { createReadStream, createWriteStream } = require("node:fs");
99
- const { pipeline } = require("node:stream/promises");
100
- const transform =
101
- format === "gzip"
102
- ? zlib.createGzip({ level })
103
- : zlib.createZstdCompress({
104
- params: { [zlib.constants.ZSTD_c_compressionLevel]: level },
105
- });
106
- await pipeline(createReadStream(srcPath), transform, createWriteStream(destPath));
107
- }
108
-
109
128
  // this when run as an npm script this will be where the folder where package.json is.
110
129
  const projectRoot = process.cwd();
111
130
 
@@ -1313,6 +1332,30 @@ function escapeXml(str: string): string {
1313
1332
  .replace(/'/g, "&apos;");
1314
1333
  }
1315
1334
 
1335
+ /**
1336
+ * Flip the PE subsystem from CONSOLE (3) to WINDOWS GUI (2) so the exe never
1337
+ * opens a console window. Byte-identical to `editbin /SUBSYSTEM:WINDOWS`.
1338
+ *
1339
+ * This exists because Bun's `compile.windows.hideConsole` is a silent no-op:
1340
+ * the only code that applies it sits after a switch in which every prong
1341
+ * returns early (oven-sh/bun#19916; fix PR oven-sh/bun#20338 unmerged as of
1342
+ * Bun 1.3.14). Bun's intended implementation writes the exact same byte at the
1343
+ * exact same offset, so when upstream merges the fix, `hideConsole: true`
1344
+ * takes over and this patch degrades to a no-op (the `current !== 2` check).
1345
+ *
1346
+ * Unlike Bun's flag, this also works when cross-compiling from macOS/Linux
1347
+ * (Bun only applies windows options when the build host is Windows).
1348
+ *
1349
+ * Must run AFTER all other PE edits (payload injection, rescle metadata) and
1350
+ * BEFORE any future code signing — mutating a signed exe invalidates the
1351
+ * signature.
1352
+ *
1353
+ * Historical note: a May 2026 commit (cf58be7) blamed hideConsole for breaking
1354
+ * the ShellExecuteW("runas") elevation flow. Since hideConsole never touched
1355
+ * the binary, that breakage came from elsewhere (likely the experimental
1356
+ * signature-stripping variant of this function that was being tested at the
1357
+ * same time and was reverted in the same commit).
1358
+ */
1316
1359
  function patchPeSubsystem(exePath: string): void {
1317
1360
  const buf = readFileSync(exePath);
1318
1361
  const peOffset = buf.readUInt32LE(0x3c);
@@ -2144,8 +2187,9 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
2144
2187
  if (targetOS === "win") {
2145
2188
  if (OS !== "win") {
2146
2189
  console.warn(
2147
- `\n⚠️ Cross-compiling for Windows: icon, hideConsole, and PE metadata (title, version, publisher, etc.) will be ignored.\n` +
2190
+ `\n⚠️ Cross-compiling for Windows: icon and PE metadata (title, version, publisher, etc.) will be ignored.\n` +
2148
2191
  ` Bun's Windows-specific compile options require building on a Windows host.\n` +
2192
+ ` (The console window is still hidden — the PE subsystem patch runs on any host.)\n` +
2149
2193
  ` See: https://bun.com/docs/bundler/executables#windows-specific-flags\n`
2150
2194
  );
2151
2195
  } else {
@@ -3177,6 +3221,8 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3177
3221
  hash,
3178
3222
  config,
3179
3223
  projectRoot,
3224
+ targetOS,
3225
+ currentTarget.arch,
3180
3226
  );
3181
3227
  artifactsToUpload.push(installerPath);
3182
3228
  }
@@ -3199,8 +3245,10 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3199
3245
  // the download button or display on your marketing site or in the app.
3200
3246
  version: config.app.version,
3201
3247
  hash: hash.toString(),
3202
- platform: OS,
3203
- arch: ARCH,
3248
+ // The TARGET platform of these artifacts (host != target when
3249
+ // cross-compiling, e.g. --target=win-x64 on macOS).
3250
+ platform: targetOS,
3251
+ arch: currentTarget.arch,
3204
3252
  // channel: buildEnvironment,
3205
3253
  // baseUrl: config.release.baseUrl
3206
3254
  });
@@ -3284,24 +3332,53 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3284
3332
 
3285
3333
  // Compression defaults for archives supplied via --archive flags.
3286
3334
  // (Archives declared in sparkbun.config.ts carry their own per-archive
3287
- // format/level, so each project can mix tight and fast compression.)
3288
- const globalFormat = ((getFlag("--format=") as "zstd" | "gzip") || "zstd");
3335
+ // format/compression, so each project can mix tight and fast compression.)
3336
+ const globalFormat = ((getFlag("--format=") as ArchiveFormat) || "zstd");
3289
3337
  const globalLevelFlag = getFlag("--level=");
3290
- const defaultLevelFor = (fmt: "zstd" | "gzip") => (fmt === "gzip" ? 9 : 19);
3338
+ const globalCompressionFlag = getFlag("--compression=");
3339
+
3340
+ // Parse a compression tier: "none" | "basic" | numeric level. Validates
3341
+ // the level range for the format (zstd 1–22, gzip 1–9).
3342
+ const parseCompression = (
3343
+ raw: string | number,
3344
+ fmt: ArchiveFormat,
3345
+ source: string,
3346
+ ): ArchiveCompression => {
3347
+ if (raw === "none" || raw === "basic") return raw;
3348
+ const level = Number(raw);
3349
+ const max = fmt === "gzip" ? 9 : 22;
3350
+ if (!Number.isInteger(level) || level < 1 || level > max) {
3351
+ console.error(
3352
+ `Invalid compression for ${source}: "${raw}". Expected "none", "basic", or a ${fmt} level 1-${max}.`,
3353
+ );
3354
+ process.exit(1);
3355
+ }
3356
+ return level;
3357
+ };
3358
+
3359
+ // Compression tier for archives that don't specify their own: the
3360
+ // --compression / legacy --level flags, else "basic" (Bun's built-in
3361
+ // CompressionStream — fast, streaming, no level control).
3362
+ const defaultCompressionFor = (fmt: ArchiveFormat, source: string): ArchiveCompression =>
3363
+ globalCompressionFlag
3364
+ ? parseCompression(globalCompressionFlag, fmt, source)
3365
+ : globalLevelFlag
3366
+ ? parseCompression(globalLevelFlag, fmt, source)
3367
+ : "basic";
3291
3368
 
3292
3369
  type ArchiveSpec = {
3293
3370
  name: string;
3294
3371
  path: string;
3295
- format: "zstd" | "gzip";
3296
- level: number;
3372
+ format: ArchiveFormat;
3373
+ compression: ArchiveCompression;
3297
3374
  };
3298
3375
  const archives: ArchiveSpec[] = [];
3299
3376
 
3300
3377
  const addArchive = (
3301
3378
  name: string,
3302
3379
  rawPath: string,
3303
- fmt: "zstd" | "gzip",
3304
- level: number,
3380
+ fmt: ArchiveFormat,
3381
+ compression: ArchiveCompression,
3305
3382
  ) => {
3306
3383
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
3307
3384
  console.error(`Invalid archive name: "${name}". Must be a valid identifier ([a-zA-Z_][a-zA-Z0-9_]*).`);
@@ -3316,22 +3393,35 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3316
3393
  console.error(`Archive path does not exist: ${resolvedPath}`);
3317
3394
  process.exit(1);
3318
3395
  }
3319
- archives.push({ name, path: resolvedPath, format: fmt, level });
3396
+ archives.push({ name, path: resolvedPath, format: fmt, compression });
3320
3397
  };
3321
3398
 
3322
- // Archives declared in sparkbun.config.ts (build.installer.archives)
3399
+ // Archives declared in sparkbun.config.ts (build.installer.archives).
3400
+ // `compression` ("none" | "basic" | level) is the preferred knob; the
3401
+ // legacy `level` field still works as a numeric tier.
3323
3402
  const configArchives = (config.build as any)?.installer?.archives as
3324
- | Array<{ name: string; path: string; format?: "zstd" | "gzip"; level?: number }>
3403
+ | Array<{
3404
+ name: string;
3405
+ path: string;
3406
+ format?: ArchiveFormat;
3407
+ compression?: "none" | "basic" | number;
3408
+ level?: number;
3409
+ }>
3325
3410
  | undefined;
3326
3411
  if (configArchives) {
3327
3412
  for (const a of configArchives) {
3328
3413
  const fmt = a.format || globalFormat;
3329
- const level = a.level ?? (globalLevelFlag ? Number(globalLevelFlag) : defaultLevelFor(fmt));
3330
- addArchive(a.name, a.path, fmt, level);
3414
+ const compression =
3415
+ a.compression !== undefined
3416
+ ? parseCompression(a.compression, fmt, `archive "${a.name}"`)
3417
+ : a.level !== undefined
3418
+ ? parseCompression(a.level, fmt, `archive "${a.name}"`)
3419
+ : defaultCompressionFor(fmt, `archive "${a.name}"`);
3420
+ addArchive(a.name, a.path, fmt, compression);
3331
3421
  }
3332
3422
  }
3333
3423
 
3334
- // Archives passed via --archive name=path (level/format from global flags)
3424
+ // Archives passed via --archive name=path (format/compression from global flags)
3335
3425
  for (let i = 0; i < process.argv.length; i++) {
3336
3426
  if (process.argv[i] === "--archive" && process.argv[i + 1]) {
3337
3427
  const arg = process.argv[i + 1]!;
@@ -3340,8 +3430,13 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3340
3430
  console.error(`Invalid --archive format: "${arg}". Expected: name=path`);
3341
3431
  process.exit(1);
3342
3432
  }
3343
- const level = globalLevelFlag ? Number(globalLevelFlag) : defaultLevelFor(globalFormat);
3344
- addArchive(arg.substring(0, eqIdx), arg.substring(eqIdx + 1), globalFormat, level);
3433
+ const name = arg.substring(0, eqIdx);
3434
+ addArchive(
3435
+ name,
3436
+ arg.substring(eqIdx + 1),
3437
+ globalFormat,
3438
+ defaultCompressionFor(globalFormat, `--archive ${name}`),
3439
+ );
3345
3440
  }
3346
3441
  }
3347
3442
 
@@ -3409,7 +3504,13 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3409
3504
 
3410
3505
  type ArchiveInfo = {
3411
3506
  file: string;
3412
- format: "zstd" | "gzip";
3507
+ // "none" = plain .tar, no decompression step at extract time.
3508
+ format: "zstd" | "gzip" | "none";
3509
+ // The tier as configured ("prebuilt" = the config pointed at an
3510
+ // already-built archive file, embedded verbatim); `level` keeps the
3511
+ // effective numeric level for older consumers (0 = uncompressed or
3512
+ // unknown; "basic" ≈ zstd 3 / gzip 6).
3513
+ compression: "none" | "basic" | number | "prebuilt";
3413
3514
  level: number;
3414
3515
  uncompressedBytes: number;
3415
3516
  fileCount: number;
@@ -3418,51 +3519,74 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3418
3519
  const archiveManifest: Record<string, ArchiveInfo> = {};
3419
3520
 
3420
3521
  if (archives.length > 0) {
3421
- console.log("Creating archives (tar + streaming compression)...");
3522
+ console.log("Creating archives (streaming tar + compression)...");
3422
3523
 
3423
3524
  for (const spec of archives) {
3424
- const ext = spec.format === "gzip" ? "tar.gz" : "tar.zst";
3525
+ // A FILE path is a prebuilt archive: embed it verbatim instead of
3526
+ // tarring + compressing a directory. Lets projects with large,
3527
+ // slow-to-compress payloads (e.g. zstd-22 game data) build them
3528
+ // once and skip the per-build recompression entirely. Format comes
3529
+ // from the extension; the manifest's progress denominators come
3530
+ // from a streaming scan of the archive.
3531
+ if (statSync(spec.path).isFile()) {
3532
+ const m = spec.path.match(/\.tar(\.zst|\.gz)?$/i);
3533
+ if (!m) {
3534
+ console.error(
3535
+ `Archive "${spec.name}": file paths must point at a prebuilt .tar/.tar.gz/.tar.zst (got ${spec.path})`,
3536
+ );
3537
+ process.exit(1);
3538
+ }
3539
+ const fmt: "zstd" | "gzip" | "none" =
3540
+ m[1]?.toLowerCase() === ".zst" ? "zstd" : m[1]?.toLowerCase() === ".gz" ? "gzip" : "none";
3541
+ const preExt = fmt === "none" ? "tar" : fmt === "gzip" ? "tar.gz" : "tar.zst";
3542
+ const preFileName = `archive-${spec.name}.${preExt}`;
3543
+ console.log(` Embedding prebuilt ${spec.name}: ${spec.path}`);
3544
+ cpSync(spec.path, join(stagingDir, preFileName));
3545
+ const { uncompressedBytes, fileCount } = await scanTarArchive(spec.path, fmt);
3546
+ const compressedBytes = statSync(spec.path).size;
3547
+ console.log(
3548
+ ` ${spec.name}: ${(uncompressedBytes / 1024 / 1024).toFixed(0)} MB -> ` +
3549
+ `${(compressedBytes / 1024 / 1024).toFixed(0)} MB (prebuilt ${preExt}, ${fileCount} files)`,
3550
+ );
3551
+ archiveManifest[spec.name] = {
3552
+ file: preFileName,
3553
+ format: fmt,
3554
+ compression: "prebuilt",
3555
+ level: 0,
3556
+ uncompressedBytes,
3557
+ fileCount,
3558
+ compressedBytes,
3559
+ };
3560
+ continue;
3561
+ }
3562
+
3563
+ const ext = archiveExtension(spec.format, spec.compression);
3425
3564
  const archiveFileName = `archive-${spec.name}.${ext}`;
3426
3565
  const archiveDestPath = join(stagingDir, archiveFileName);
3427
- const tarTmpPath = join(stagingDir, `archive-${spec.name}.tar`);
3428
3566
 
3429
3567
  console.log(` Archiving ${spec.name}: ${spec.path}`);
3430
3568
 
3431
- // Build the uncompressed tar with the system `tar`. It streams to disk
3432
- // (bounded build memory) and preserves empty directories as real
3433
- // entries. The source folder name is kept as the archive root (e.g.
3434
- // "Dune 2000/...") via `tar -C <parent> <basename>`, so it extracts
3435
- // into the correct subdirectory.
3436
- //
3437
- // The on-disk format may be GNU (Windows/Linux — long paths via
3438
- // "././@LongLink") or pax (macOS bsdtar — long paths via extended
3439
- // headers). The installer's streaming extractor understands both, plus
3440
- // plain ustar, so the archive is portable regardless of build host.
3441
- //
3442
- // (Bun.Archive is intentionally not used here: its lazy Bun.file()
3443
- // inputs write empty file bodies, and eager byte inputs would require
3444
- // loading the entire payload into memory.)
3445
- const parent = dirname(spec.path);
3446
- const baseName = basename(spec.path);
3447
- createTar(tarTmpPath, parent, [baseName]);
3569
+ // Hand-rolled streaming tar piped straight into the compressor (see
3570
+ // archive-stream.ts): plain ustar with the source folder name kept
3571
+ // as the archive root (e.g. "Dune 2000/..."), file modes preserved,
3572
+ // bounded build memory, and no system tar dependency the on-disk
3573
+ // format is identical regardless of build host.
3574
+ await writeTarArchive(spec.path, archiveDestPath, spec.format, spec.compression);
3448
3575
 
3449
3576
  // Measure uncompressed bytes + file count for progress denominators.
3450
3577
  const { bytes: uncompressedBytes, fileCount } = measureDir(spec.path);
3451
3578
 
3452
- // Stream-compress the tar at the requested level (bounded build memory).
3453
- await streamCompressFile(tarTmpPath, archiveDestPath, spec.format, spec.level);
3454
- unlinkSync(tarTmpPath);
3455
-
3456
3579
  const compressedBytes = statSync(archiveDestPath).size;
3457
3580
  console.log(
3458
3581
  ` ${spec.name}: ${(uncompressedBytes / 1024 / 1024).toFixed(0)} MB -> ` +
3459
3582
  `${(compressedBytes / 1024 / 1024).toFixed(0)} MB ` +
3460
- `(${spec.format} level ${spec.level}, ${fileCount} files)`,
3583
+ `(${compressionLabel(spec.format, spec.compression)}, ${fileCount} files)`,
3461
3584
  );
3462
3585
  archiveManifest[spec.name] = {
3463
3586
  file: archiveFileName,
3464
- format: spec.format,
3465
- level: spec.level,
3587
+ format: spec.compression === "none" ? "none" : spec.format,
3588
+ compression: spec.compression,
3589
+ level: effectiveLevel(spec.format, spec.compression),
3466
3590
  uncompressedBytes,
3467
3591
  fileCount,
3468
3592
  compressedBytes,
@@ -3659,6 +3783,8 @@ ${archiveExports.join("\n")}
3659
3783
 
3660
3784
  console.log("Compiling installer executable...");
3661
3785
 
3786
+ // (Compiled via buildCompiledExeWithRetry below — PE metadata commit
3787
+ // failures from AV scans are transient and retried.)
3662
3788
  const installerCompileOptions: any = {
3663
3789
  target: `bun-${targetOSName}-${targetARCH}`,
3664
3790
  outfile: outputPath,
@@ -3681,9 +3807,14 @@ ${archiveExports.join("\n")}
3681
3807
  }
3682
3808
  }
3683
3809
  installerCompileOptions.windows = {
3684
- // Don't set hideConsole it conflicts with the ShellExecuteW("runas")
3685
- // elevation flow in the wrapper template. The PE subsystem patch
3686
- // (CONSOLE -> WINDOWS) applied after compilation hides the console.
3810
+ // Currently a no-op in Bun (oven-sh/bun#19916 the subsystem edit is
3811
+ // unreachable code; fix PR #20338 unmerged as of 1.3.14), so the
3812
+ // console is actually hidden by patchPeSubsystem() below. Set it
3813
+ // anyway: it's harmless today and becomes the primary mechanism the
3814
+ // day Bun fixes it (the patch then no-ops). See patchPeSubsystem's
3815
+ // doc comment for why the old "conflicts with elevation" story here
3816
+ // was a misattribution.
3817
+ hideConsole: true,
3687
3818
  ...(icoPath && { icon: icoPath }),
3688
3819
  title: `${installerName} Setup`,
3689
3820
  version: installerVersion,
@@ -3693,10 +3824,13 @@ ${archiveExports.join("\n")}
3693
3824
  };
3694
3825
  }
3695
3826
 
3696
- const installerBuild = await Bun.build({
3697
- entrypoints: [join(stagingDir, "installer.ts")],
3698
- compile: installerCompileOptions,
3699
- });
3827
+ const installerBuild = await buildCompiledExeWithRetry(
3828
+ {
3829
+ entrypoints: [join(stagingDir, "installer.ts")],
3830
+ compile: installerCompileOptions,
3831
+ },
3832
+ outputPath,
3833
+ );
3700
3834
  if (!installerBuild.success) {
3701
3835
  console.error("Installer compilation failed:", installerBuild.logs);
3702
3836
  throw new Error("Installer compilation failed");
@@ -4318,9 +4452,16 @@ ${archiveExports.join("\n")}
4318
4452
  hash: string,
4319
4453
  config: any,
4320
4454
  projectRoot: string,
4455
+ // The TARGET platform being built for — not the build host. These used to
4456
+ // be derived from the host OS/ARCH globals, so cross-compiling (e.g.
4457
+ // `--target=win-x64` on macOS) wrongly built a host-platform installer
4458
+ // whose extensionless outfile then collided with the app bundle directory
4459
+ // ("is a directory" build failure).
4460
+ targetOS: "win" | "linux" | "macos",
4461
+ targetArch: string,
4321
4462
  ): Promise<string> {
4322
- const targetOSName = OS === "macos" ? "darwin" : OS === "win" ? "windows" : "linux";
4323
- const isWindows = OS === "win";
4463
+ const targetOSName = targetOS === "macos" ? "darwin" : targetOS === "win" ? "windows" : "linux";
4464
+ const isWindows = targetOS === "win";
4324
4465
 
4325
4466
  const setupFileName = isWindows
4326
4467
  ? getWindowsSetupFileName(config.app.name, buildEnvironment)
@@ -4337,9 +4478,9 @@ ${archiveExports.join("\n")}
4337
4478
  // Copy archive
4338
4479
  copyFileSync(compressedTarPath, join(stagingDir, "app-archive.tar.gz"));
4339
4480
 
4340
- // Write metadata
4341
- const platformConfig = OS === "macos" ? config.build?.mac
4342
- : OS === "win" ? config.build?.win
4481
+ // Write metadata (platform config of the TARGET, not the build host)
4482
+ const platformConfig = targetOS === "macos" ? config.build?.mac
4483
+ : targetOS === "win" ? config.build?.win
4343
4484
  : config.build?.linux;
4344
4485
  const metadata = {
4345
4486
  identifier: config.app.identifier,
@@ -4356,11 +4497,18 @@ ${archiveExports.join("\n")}
4356
4497
  copyFileSync(templatePath, join(stagingDir, "installer.ts"));
4357
4498
 
4358
4499
  const installerCompileOptions: any = {
4359
- target: `bun-${targetOSName}-${ARCH}`,
4500
+ target: `bun-${targetOSName}-${targetArch}`,
4360
4501
  outfile: outputPath,
4361
4502
  };
4362
4503
 
4363
- if (isWindows) {
4504
+ if (isWindows && OS !== "win") {
4505
+ // Bun only applies windows compile options on a Windows host; the
4506
+ // console is still hidden by patchPeSubsystem below (works anywhere).
4507
+ console.warn(
4508
+ `\n⚠️ Cross-compiling the Windows installer: icon and PE metadata (title, version, publisher, etc.) will be ignored.\n` +
4509
+ ` Build on a Windows host if the installer exe needs them.\n`
4510
+ );
4511
+ } else if (isWindows) {
4364
4512
  let icoPath: string | undefined;
4365
4513
  if (config.build.win?.icon) {
4366
4514
  const iconSrc = config.build.win.icon.startsWith("/") || config.build.win.icon.match(/^[a-zA-Z]:/)
@@ -4389,10 +4537,13 @@ ${archiveExports.join("\n")}
4389
4537
  }
4390
4538
 
4391
4539
  console.log("Compiling installer...");
4392
- const installerBuild = await Bun.build({
4393
- entrypoints: [join(stagingDir, "installer.ts")],
4394
- compile: installerCompileOptions,
4395
- });
4540
+ const installerBuild = await buildCompiledExeWithRetry(
4541
+ {
4542
+ entrypoints: [join(stagingDir, "installer.ts")],
4543
+ compile: installerCompileOptions,
4544
+ },
4545
+ outputPath,
4546
+ );
4396
4547
  if (!installerBuild.success) {
4397
4548
  console.error("Installer compilation failed:", installerBuild.logs);
4398
4549
  throw new Error("Installer compilation failed");
@@ -92,6 +92,36 @@ function getInstallDir(meta: Metadata): string {
92
92
  }
93
93
  }
94
94
 
95
+ // Create a .lnk via WScript.Shell. windowsHide is essential: the installer is a
96
+ // GUI-subsystem exe, so without it each PowerShell child allocates its own
97
+ // visible console window mid-install. PowerShell is called by absolute path
98
+ // (PATH isn't reliable in every launch context), and the paths are passed
99
+ // through the environment ($env:SB_*) rather than interpolated into the script
100
+ // text, so an app name containing quotes can't break or inject into the command.
101
+ function createShortcut(lnkPath: string, targetPath: string, workingDir: string) {
102
+ const systemRoot = process.env.SystemRoot || process.env.windir || "C:\\Windows";
103
+ const powershell = join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
104
+ const script =
105
+ "$ws = New-Object -ComObject WScript.Shell;" +
106
+ " $s = $ws.CreateShortcut($env:SB_LNK);" +
107
+ " $s.TargetPath = $env:SB_TARGET;" +
108
+ " $s.WorkingDirectory = $env:SB_WORKDIR;" +
109
+ " $s.Save()";
110
+ try {
111
+ Bun.spawnSync([powershell, "-NoProfile", "-Command", script], {
112
+ stdout: "ignore",
113
+ stderr: "ignore",
114
+ windowsHide: true,
115
+ env: {
116
+ ...process.env,
117
+ SB_LNK: lnkPath,
118
+ SB_TARGET: targetPath,
119
+ SB_WORKDIR: workingDir,
120
+ },
121
+ });
122
+ } catch {}
123
+ }
124
+
95
125
  async function createWindowsShortcuts(appDir: string, meta: Metadata) {
96
126
  const binDir = join(appDir, "bin");
97
127
  const exePath = join(binDir, `${meta.name.replace(/ /g, "")}.exe`);
@@ -100,19 +130,11 @@ async function createWindowsShortcuts(appDir: string, meta: Metadata) {
100
130
 
101
131
  // Start Menu shortcut
102
132
  const startMenu = join(process.env.APPDATA || "", "Microsoft", "Windows", "Start Menu", "Programs");
103
- try {
104
- Bun.spawnSync(["powershell", "-NoProfile", "-Command",
105
- `$ws = New-Object -ComObject WScript.Shell; $s = $ws.CreateShortcut('${join(startMenu, meta.name + ".lnk")}'); $s.TargetPath = '${exePath}'; $s.WorkingDirectory = '${binDir}'; $s.Save()`
106
- ]);
107
- } catch {}
133
+ createShortcut(join(startMenu, meta.name + ".lnk"), exePath, binDir);
108
134
 
109
135
  // Desktop shortcut
110
- try {
111
- const desktop = join(process.env.USERPROFILE || "", "Desktop");
112
- Bun.spawnSync(["powershell", "-NoProfile", "-Command",
113
- `$ws = New-Object -ComObject WScript.Shell; $s = $ws.CreateShortcut('${join(desktop, meta.name + ".lnk")}'); $s.TargetPath = '${exePath}'; $s.WorkingDirectory = '${binDir}'; $s.Save()`
114
- ]);
115
- } catch {}
136
+ const desktop = join(process.env.USERPROFILE || "", "Desktop");
137
+ createShortcut(join(desktop, meta.name + ".lnk"), exePath, binDir);
116
138
 
117
139
  console.log("Created Start Menu and Desktop shortcuts");
118
140
  }