sparkbun 0.2.6 → 0.2.7

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.7",
4
4
  "description": "Build fast, lightweight, cross-platform desktop apps with TypeScript and Bun.",
5
5
  "license": "MIT",
6
6
  "author": "SparkBun Contributors",
@@ -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
package/src/cli/index.ts CHANGED
@@ -1313,6 +1313,30 @@ function escapeXml(str: string): string {
1313
1313
  .replace(/'/g, "'");
1314
1314
  }
1315
1315
 
1316
+ /**
1317
+ * Flip the PE subsystem from CONSOLE (3) to WINDOWS GUI (2) so the exe never
1318
+ * opens a console window. Byte-identical to `editbin /SUBSYSTEM:WINDOWS`.
1319
+ *
1320
+ * This exists because Bun's `compile.windows.hideConsole` is a silent no-op:
1321
+ * the only code that applies it sits after a switch in which every prong
1322
+ * returns early (oven-sh/bun#19916; fix PR oven-sh/bun#20338 unmerged as of
1323
+ * Bun 1.3.14). Bun's intended implementation writes the exact same byte at the
1324
+ * exact same offset, so when upstream merges the fix, `hideConsole: true`
1325
+ * takes over and this patch degrades to a no-op (the `current !== 2` check).
1326
+ *
1327
+ * Unlike Bun's flag, this also works when cross-compiling from macOS/Linux
1328
+ * (Bun only applies windows options when the build host is Windows).
1329
+ *
1330
+ * Must run AFTER all other PE edits (payload injection, rescle metadata) and
1331
+ * BEFORE any future code signing — mutating a signed exe invalidates the
1332
+ * signature.
1333
+ *
1334
+ * Historical note: a May 2026 commit (cf58be7) blamed hideConsole for breaking
1335
+ * the ShellExecuteW("runas") elevation flow. Since hideConsole never touched
1336
+ * the binary, that breakage came from elsewhere (likely the experimental
1337
+ * signature-stripping variant of this function that was being tested at the
1338
+ * same time and was reverted in the same commit).
1339
+ */
1316
1340
  function patchPeSubsystem(exePath: string): void {
1317
1341
  const buf = readFileSync(exePath);
1318
1342
  const peOffset = buf.readUInt32LE(0x3c);
@@ -2144,8 +2168,9 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
2144
2168
  if (targetOS === "win") {
2145
2169
  if (OS !== "win") {
2146
2170
  console.warn(
2147
- `\n⚠️ Cross-compiling for Windows: icon, hideConsole, and PE metadata (title, version, publisher, etc.) will be ignored.\n` +
2171
+ `\n⚠️ Cross-compiling for Windows: icon and PE metadata (title, version, publisher, etc.) will be ignored.\n` +
2148
2172
  ` Bun's Windows-specific compile options require building on a Windows host.\n` +
2173
+ ` (The console window is still hidden — the PE subsystem patch runs on any host.)\n` +
2149
2174
  ` See: https://bun.com/docs/bundler/executables#windows-specific-flags\n`
2150
2175
  );
2151
2176
  } else {
@@ -3177,6 +3202,8 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3177
3202
  hash,
3178
3203
  config,
3179
3204
  projectRoot,
3205
+ targetOS,
3206
+ currentTarget.arch,
3180
3207
  );
3181
3208
  artifactsToUpload.push(installerPath);
3182
3209
  }
@@ -3199,8 +3226,10 @@ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
3199
3226
  // the download button or display on your marketing site or in the app.
3200
3227
  version: config.app.version,
3201
3228
  hash: hash.toString(),
3202
- platform: OS,
3203
- arch: ARCH,
3229
+ // The TARGET platform of these artifacts (host != target when
3230
+ // cross-compiling, e.g. --target=win-x64 on macOS).
3231
+ platform: targetOS,
3232
+ arch: currentTarget.arch,
3204
3233
  // channel: buildEnvironment,
3205
3234
  // baseUrl: config.release.baseUrl
3206
3235
  });
@@ -3681,9 +3710,14 @@ ${archiveExports.join("\n")}
3681
3710
  }
3682
3711
  }
3683
3712
  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.
3713
+ // Currently a no-op in Bun (oven-sh/bun#19916 the subsystem edit is
3714
+ // unreachable code; fix PR #20338 unmerged as of 1.3.14), so the
3715
+ // console is actually hidden by patchPeSubsystem() below. Set it
3716
+ // anyway: it's harmless today and becomes the primary mechanism the
3717
+ // day Bun fixes it (the patch then no-ops). See patchPeSubsystem's
3718
+ // doc comment for why the old "conflicts with elevation" story here
3719
+ // was a misattribution.
3720
+ hideConsole: true,
3687
3721
  ...(icoPath && { icon: icoPath }),
3688
3722
  title: `${installerName} Setup`,
3689
3723
  version: installerVersion,
@@ -4318,9 +4352,16 @@ ${archiveExports.join("\n")}
4318
4352
  hash: string,
4319
4353
  config: any,
4320
4354
  projectRoot: string,
4355
+ // The TARGET platform being built for — not the build host. These used to
4356
+ // be derived from the host OS/ARCH globals, so cross-compiling (e.g.
4357
+ // `--target=win-x64` on macOS) wrongly built a host-platform installer
4358
+ // whose extensionless outfile then collided with the app bundle directory
4359
+ // ("is a directory" build failure).
4360
+ targetOS: "win" | "linux" | "macos",
4361
+ targetArch: string,
4321
4362
  ): Promise<string> {
4322
- const targetOSName = OS === "macos" ? "darwin" : OS === "win" ? "windows" : "linux";
4323
- const isWindows = OS === "win";
4363
+ const targetOSName = targetOS === "macos" ? "darwin" : targetOS === "win" ? "windows" : "linux";
4364
+ const isWindows = targetOS === "win";
4324
4365
 
4325
4366
  const setupFileName = isWindows
4326
4367
  ? getWindowsSetupFileName(config.app.name, buildEnvironment)
@@ -4337,9 +4378,9 @@ ${archiveExports.join("\n")}
4337
4378
  // Copy archive
4338
4379
  copyFileSync(compressedTarPath, join(stagingDir, "app-archive.tar.gz"));
4339
4380
 
4340
- // Write metadata
4341
- const platformConfig = OS === "macos" ? config.build?.mac
4342
- : OS === "win" ? config.build?.win
4381
+ // Write metadata (platform config of the TARGET, not the build host)
4382
+ const platformConfig = targetOS === "macos" ? config.build?.mac
4383
+ : targetOS === "win" ? config.build?.win
4343
4384
  : config.build?.linux;
4344
4385
  const metadata = {
4345
4386
  identifier: config.app.identifier,
@@ -4356,11 +4397,18 @@ ${archiveExports.join("\n")}
4356
4397
  copyFileSync(templatePath, join(stagingDir, "installer.ts"));
4357
4398
 
4358
4399
  const installerCompileOptions: any = {
4359
- target: `bun-${targetOSName}-${ARCH}`,
4400
+ target: `bun-${targetOSName}-${targetArch}`,
4360
4401
  outfile: outputPath,
4361
4402
  };
4362
4403
 
4363
- if (isWindows) {
4404
+ if (isWindows && OS !== "win") {
4405
+ // Bun only applies windows compile options on a Windows host; the
4406
+ // console is still hidden by patchPeSubsystem below (works anywhere).
4407
+ console.warn(
4408
+ `\n⚠️ Cross-compiling the Windows installer: icon and PE metadata (title, version, publisher, etc.) will be ignored.\n` +
4409
+ ` Build on a Windows host if the installer exe needs them.\n`
4410
+ );
4411
+ } else if (isWindows) {
4364
4412
  let icoPath: string | undefined;
4365
4413
  if (config.build.win?.icon) {
4366
4414
  const iconSrc = config.build.win.icon.startsWith("/") || config.build.win.icon.match(/^[a-zA-Z]:/)
@@ -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
  }