electrobun 1.13.1 → 1.14.1-beta.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 (2) hide show
  1. package/package.json +4 -7
  2. package/src/cli/index.ts +548 -230
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "electrobun",
3
- "version": "1.13.1",
3
+ "version": "1.14.1-beta.0",
4
4
  "description": "Build ultra fast, tiny, and cross-platform desktop apps with Typescript.",
5
5
  "license": "MIT",
6
6
  "author": "Blackboard Technologies Inc.",
@@ -26,17 +26,14 @@
26
26
  "url": "git+https://github.com/blackboardsh/electrobun.git"
27
27
  },
28
28
  "scripts": {
29
- "build:cli": "mkdir -p bin && vendors/bun/bun build src/cli/index.ts --compile --outfile bin/electrobun",
30
29
  "start": "bun src/bun/index.ts",
31
30
  "check-zig-version": "vendors/zig/zig version",
32
31
  "build:dev": "bun build.ts",
33
32
  "build:release": "bun build.ts --release",
34
- "dev": "bun install && bun build:dev && bun build:cli && cd ../kitchen && bun install && bun build:dev && bun start",
33
+ "dev": "bun install && bun build:dev && cd ../kitchen && bun install && bun dev",
35
34
  "dev:clean": "cd ../kitchen && rm -rf node_modules && rm -rf vendors/cef && cd ../package && bun dev",
36
- "dev:rerun": "cd ../kitchen && bun start",
37
- "dev:canary": "bun install && bun build:release && bun build:cli && cd ../kitchen && bun install && bun build:canary",
38
- "dev:stable": "bun install && bun build:release && bun build:cli && cd ../kitchen && bun install && bun build:stable",
39
- "dev:docs": "cd ../documentation && bun start",
35
+ "dev:canary": "bun install && bun build:release && cd ../kitchen && bun install && bun build:canary",
36
+ "dev:stable": "bun install && bun build:release && cd ../kitchen && bun install && bun build:stable",
40
37
  "build:docs:release": "cd ../documentation && bun run build",
41
38
  "npm:publish": "bun build:release && npm publish",
42
39
  "npm:publish:beta": "bun build:release && npm publish --tag beta",
package/src/cli/index.ts CHANGED
@@ -367,7 +367,13 @@ function getEffectiveCEFDir(
367
367
  cefVersion?: string,
368
368
  ): string {
369
369
  if (cefVersion) {
370
- return join(projectRoot, "node_modules", ".electrobun-cache", "cef-override", `${platformOS}-${platformArch}`);
370
+ return join(
371
+ projectRoot,
372
+ "node_modules",
373
+ ".electrobun-cache",
374
+ "cef-override",
375
+ `${platformOS}-${platformArch}`,
376
+ );
371
377
  }
372
378
  return getPlatformPaths(platformOS, platformArch).CEF_DIR;
373
379
  }
@@ -387,7 +393,13 @@ async function ensureBunBinary(
387
393
  }
388
394
 
389
395
  const binExt = targetOS === "win" ? ".exe" : "";
390
- const overrideDir = join(projectRoot, "node_modules", ".electrobun-cache", "bun-override", `${targetOS}-${targetArch}`);
396
+ const overrideDir = join(
397
+ projectRoot,
398
+ "node_modules",
399
+ ".electrobun-cache",
400
+ "bun-override",
401
+ `${targetOS}-${targetArch}`,
402
+ );
391
403
  const overrideBinary = join(overrideDir, `bun${binExt}`);
392
404
  const versionFile = join(overrideDir, ".bun-version");
393
405
 
@@ -431,17 +443,29 @@ async function downloadCustomBun(
431
443
  bunUrlSegment = "bun-windows-x64-baseline.zip";
432
444
  bunDirName = "bun-windows-x64-baseline";
433
445
  } else if (platformOS === "macos") {
434
- bunUrlSegment = platformArch === "arm64" ? "bun-darwin-aarch64.zip" : "bun-darwin-x64.zip";
435
- bunDirName = platformArch === "arm64" ? "bun-darwin-aarch64" : "bun-darwin-x64";
446
+ bunUrlSegment =
447
+ platformArch === "arm64"
448
+ ? "bun-darwin-aarch64.zip"
449
+ : "bun-darwin-x64.zip";
450
+ bunDirName =
451
+ platformArch === "arm64" ? "bun-darwin-aarch64" : "bun-darwin-x64";
436
452
  } else if (platformOS === "linux") {
437
- bunUrlSegment = platformArch === "arm64" ? "bun-linux-aarch64.zip" : "bun-linux-x64.zip";
438
- bunDirName = platformArch === "arm64" ? "bun-linux-aarch64" : "bun-linux-x64";
453
+ bunUrlSegment =
454
+ platformArch === "arm64" ? "bun-linux-aarch64.zip" : "bun-linux-x64.zip";
455
+ bunDirName =
456
+ platformArch === "arm64" ? "bun-linux-aarch64" : "bun-linux-x64";
439
457
  } else {
440
458
  throw new Error(`Unsupported platform for custom Bun: ${platformOS}`);
441
459
  }
442
460
 
443
461
  const binExt = platformOS === "win" ? ".exe" : "";
444
- const overrideDir = join(projectRoot, "node_modules", ".electrobun-cache", "bun-override", `${platformOS}-${platformArch}`);
462
+ const overrideDir = join(
463
+ projectRoot,
464
+ "node_modules",
465
+ ".electrobun-cache",
466
+ "bun-override",
467
+ `${platformOS}-${platformArch}`,
468
+ );
445
469
  const overrideBinary = join(overrideDir, `bun${binExt}`);
446
470
  const bunUrl = `https://github.com/oven-sh/bun/releases/download/bun-v${bunVersion}/${bunUrlSegment}`;
447
471
 
@@ -516,7 +540,9 @@ async function downloadCustomBun(
516
540
  if (existsSync(extractedBinary)) {
517
541
  renameSync(extractedBinary, overrideBinary);
518
542
  } else {
519
- throw new Error(`Bun binary not found after extraction at ${extractedBinary}`);
543
+ throw new Error(
544
+ `Bun binary not found after extraction at ${extractedBinary}`,
545
+ );
520
546
  }
521
547
 
522
548
  // Set execute permissions on non-Windows
@@ -530,7 +556,8 @@ async function downloadCustomBun(
530
556
  // Clean up
531
557
  if (existsSync(tempZipPath)) unlinkSync(tempZipPath);
532
558
  const extractedDir = join(overrideDir, bunDirName);
533
- if (existsSync(extractedDir)) rmSync(extractedDir, { recursive: true, force: true });
559
+ if (existsSync(extractedDir))
560
+ rmSync(extractedDir, { recursive: true, force: true });
534
561
 
535
562
  console.log(
536
563
  `Custom Bun ${bunVersion} for ${platformOS}-${platformArch} set up successfully`,
@@ -569,7 +596,11 @@ async function ensureCEFDependencies(
569
596
  // If custom CEF version specified, download from Spotify CDN
570
597
  // Custom CEF is stored in vendors/cef-override/ to survive dist rebuilds
571
598
  if (cefVersion) {
572
- const overrideDir = getEffectiveCEFDir(platformOS, platformArch, cefVersion);
599
+ const overrideDir = getEffectiveCEFDir(
600
+ platformOS,
601
+ platformArch,
602
+ cefVersion,
603
+ );
573
604
  // Check if already downloaded with matching version
574
605
  const cefVersionFile = join(overrideDir, ".cef-version");
575
606
  if (existsSync(overrideDir) && existsSync(cefVersionFile)) {
@@ -978,10 +1009,9 @@ async function downloadAndExtractCustomCEF(
978
1009
  );
979
1010
 
980
1011
  // Extract tar.bz2 using system tar (bz2 requires it)
981
- execSync(
982
- `tar -xjf "${tempFile}" --strip-components=1 -C "${cefDir}"`,
983
- { stdio: "inherit" },
984
- );
1012
+ execSync(`tar -xjf "${tempFile}" --strip-components=1 -C "${cefDir}"`, {
1013
+ stdio: "inherit",
1014
+ });
985
1015
 
986
1016
  // The Spotify distribution layout has runtime files in Release/ and Resources/
987
1017
  // subdirectories, but the CLI expects them at the cef/ root. Copy them up.
@@ -1127,6 +1157,8 @@ const defaultConfig = {
1127
1157
  | Record<string, { entrypoint: string; [key: string]: unknown }>
1128
1158
  | undefined,
1129
1159
  copy: undefined as Record<string, string> | undefined,
1160
+ watch: undefined as string[] | undefined,
1161
+ watchIgnore: undefined as string[] | undefined,
1130
1162
  },
1131
1163
  runtime: {} as Record<string, unknown>,
1132
1164
  scripts: {
@@ -1191,73 +1223,73 @@ function escapePathForTerminal(path: string): string {
1191
1223
  * Creates a Linux installer tar.gz containing:
1192
1224
  * - Self-extracting installer executable (with embedded app archive)
1193
1225
  * - README.txt with instructions
1194
- *
1226
+ *
1195
1227
  * This replaces the AppImage-based installer to avoid libfuse2 dependency.
1196
1228
  * The installer executable has the compressed app archive embedded within it
1197
1229
  * using magic markers, similar to how Windows installers work.
1198
1230
  */
1199
1231
  async function createLinuxInstallerArchive(
1200
- buildFolder: string,
1201
- compressedTarPath: string,
1202
- appFileName: string,
1203
- config: any,
1204
- buildEnvironment: string,
1205
- hash: string,
1206
- targetPaths: ReturnType<typeof getPlatformPaths>,
1232
+ buildFolder: string,
1233
+ compressedTarPath: string,
1234
+ appFileName: string,
1235
+ config: any,
1236
+ buildEnvironment: string,
1237
+ hash: string,
1238
+ targetPaths: ReturnType<typeof getPlatformPaths>,
1207
1239
  ): Promise<string> {
1208
- console.log("Creating Linux installer archive...");
1209
-
1210
- // Create installer name using sanitized app file name (no spaces, URL-safe)
1211
- // Note: appFileName already includes the channel suffix for non-stable builds
1212
- const installerName = `${appFileName}-Setup`;
1213
-
1214
- // Create temp directory for staging
1215
- const stagingDir = join(buildFolder, `${installerName}-staging`);
1216
- if (existsSync(stagingDir)) {
1217
- rmSync(stagingDir, { recursive: true, force: true });
1218
- }
1219
- mkdirSync(stagingDir, { recursive: true });
1220
-
1221
- try {
1222
- // 1. Create self-extracting installer binary
1223
- // Read the extractor binary
1224
- const extractorBinary = readFileSync(targetPaths.EXTRACTOR);
1225
-
1226
- // Read the compressed archive
1227
- const compressedArchive = readFileSync(compressedTarPath);
1228
-
1229
- // Create metadata JSON
1230
- const metadata = {
1231
- identifier: config.app.identifier,
1232
- name: config.app.name,
1233
- channel: buildEnvironment,
1234
- hash: hash,
1235
- };
1236
- const metadataJson = JSON.stringify(metadata);
1237
- const metadataBuffer = Buffer.from(metadataJson, "utf8");
1238
-
1239
- // Create marker buffers
1240
- const metadataMarker = Buffer.from("ELECTROBUN_METADATA_V1", "utf8");
1241
- const archiveMarker = Buffer.from("ELECTROBUN_ARCHIVE_V1", "utf8");
1242
-
1243
- // Combine extractor + metadata marker + metadata + archive marker + archive
1244
- const combinedBuffer = Buffer.concat([
1245
- new Uint8Array(extractorBinary),
1246
- new Uint8Array(metadataMarker),
1247
- new Uint8Array(metadataBuffer),
1248
- new Uint8Array(archiveMarker),
1249
- new Uint8Array(compressedArchive),
1250
- ]);
1251
-
1252
- // Write the self-extracting installer
1253
- const installerPath = join(stagingDir, "installer");
1254
- writeFileSync(installerPath, new Uint8Array(combinedBuffer), {
1255
- mode: 0o755,
1256
- });
1257
- execSync(`chmod +x ${escapePathForTerminal(installerPath)}`);
1258
-
1259
- // 2. Create README for clarity
1260
- const readmeContent = `${config.app.name} Installer
1240
+ console.log("Creating Linux installer archive...");
1241
+
1242
+ // Create installer name using sanitized app file name (no spaces, URL-safe)
1243
+ // Note: appFileName already includes the channel suffix for non-stable builds
1244
+ const installerName = `${appFileName}-Setup`;
1245
+
1246
+ // Create temp directory for staging
1247
+ const stagingDir = join(buildFolder, `${installerName}-staging`);
1248
+ if (existsSync(stagingDir)) {
1249
+ rmSync(stagingDir, { recursive: true, force: true });
1250
+ }
1251
+ mkdirSync(stagingDir, { recursive: true });
1252
+
1253
+ try {
1254
+ // 1. Create self-extracting installer binary
1255
+ // Read the extractor binary
1256
+ const extractorBinary = readFileSync(targetPaths.EXTRACTOR);
1257
+
1258
+ // Read the compressed archive
1259
+ const compressedArchive = readFileSync(compressedTarPath);
1260
+
1261
+ // Create metadata JSON
1262
+ const metadata = {
1263
+ identifier: config.app.identifier,
1264
+ name: config.app.name,
1265
+ channel: buildEnvironment,
1266
+ hash: hash,
1267
+ };
1268
+ const metadataJson = JSON.stringify(metadata);
1269
+ const metadataBuffer = Buffer.from(metadataJson, "utf8");
1270
+
1271
+ // Create marker buffers
1272
+ const metadataMarker = Buffer.from("ELECTROBUN_METADATA_V1", "utf8");
1273
+ const archiveMarker = Buffer.from("ELECTROBUN_ARCHIVE_V1", "utf8");
1274
+
1275
+ // Combine extractor + metadata marker + metadata + archive marker + archive
1276
+ const combinedBuffer = Buffer.concat([
1277
+ new Uint8Array(extractorBinary),
1278
+ new Uint8Array(metadataMarker),
1279
+ new Uint8Array(metadataBuffer),
1280
+ new Uint8Array(archiveMarker),
1281
+ new Uint8Array(compressedArchive),
1282
+ ]);
1283
+
1284
+ // Write the self-extracting installer
1285
+ const installerPath = join(stagingDir, "installer");
1286
+ writeFileSync(installerPath, new Uint8Array(combinedBuffer), {
1287
+ mode: 0o755,
1288
+ });
1289
+ execSync(`chmod +x ${escapePathForTerminal(installerPath)}`);
1290
+
1291
+ // 2. Create README for clarity
1292
+ const readmeContent = `${config.app.name} Installer
1261
1293
  ========================
1262
1294
 
1263
1295
  To install ${config.app.name}:
@@ -1269,46 +1301,44 @@ The installer will:
1269
1301
  - Extract the application to ~/.local/share/
1270
1302
  - Create a desktop shortcut with the app's icon
1271
1303
 
1272
- For more information, visit: ${config.app.homepage || 'https://electrobun.dev'}
1304
+ For more information, visit: ${config.app.homepage || "https://electrobun.dev"}
1273
1305
  `;
1274
1306
 
1275
- writeFileSync(join(stagingDir, "README.txt"), readmeContent);
1276
-
1277
- // 3. Create the tar.gz archive (extract contents directly, no nested folder)
1278
- const archiveName = `${installerName}.tar.gz`;
1279
- const archivePath = join(buildFolder, archiveName);
1280
-
1281
- console.log(`Creating installer archive: ${archivePath}`);
1282
-
1283
- // Use tar to create the archive, preserving executable permissions
1284
- // The -C changes to the staging dir, then . archives its contents directly
1285
- execSync(
1286
- `tar -czf ${escapePathForTerminal(archivePath)} -C ${escapePathForTerminal(stagingDir)} .`,
1287
- { stdio: 'inherit', env: { ...process.env, COPYFILE_DISABLE: "1" } }
1288
- );
1289
-
1290
- // Verify the archive was created
1291
- if (!existsSync(archivePath)) {
1292
- throw new Error(`Installer archive was not created at expected path: ${archivePath}`);
1293
- }
1294
-
1295
- const stats = statSync(archivePath);
1296
- console.log(
1297
- `✓ Linux installer archive created: ${archivePath} (${(stats.size / 1024 / 1024).toFixed(2)} MB)`
1298
- );
1299
-
1300
- return archivePath;
1301
- } finally {
1302
- // Clean up staging directory
1303
- if (existsSync(stagingDir)) {
1304
- rmSync(stagingDir, { recursive: true, force: true });
1305
- }
1306
- }
1307
- }
1307
+ writeFileSync(join(stagingDir, "README.txt"), readmeContent);
1308
1308
 
1309
+ // 3. Create the tar.gz archive (extract contents directly, no nested folder)
1310
+ const archiveName = `${installerName}.tar.gz`;
1311
+ const archivePath = join(buildFolder, archiveName);
1309
1312
 
1313
+ console.log(`Creating installer archive: ${archivePath}`);
1310
1314
 
1315
+ // Use tar to create the archive, preserving executable permissions
1316
+ // The -C changes to the staging dir, then . archives its contents directly
1317
+ execSync(
1318
+ `tar -czf ${escapePathForTerminal(archivePath)} -C ${escapePathForTerminal(stagingDir)} .`,
1319
+ { stdio: "inherit", env: { ...process.env, COPYFILE_DISABLE: "1" } },
1320
+ );
1311
1321
 
1322
+ // Verify the archive was created
1323
+ if (!existsSync(archivePath)) {
1324
+ throw new Error(
1325
+ `Installer archive was not created at expected path: ${archivePath}`,
1326
+ );
1327
+ }
1328
+
1329
+ const stats = statSync(archivePath);
1330
+ console.log(
1331
+ `✓ Linux installer archive created: ${archivePath} (${(stats.size / 1024 / 1024).toFixed(2)} MB)`,
1332
+ );
1333
+
1334
+ return archivePath;
1335
+ } finally {
1336
+ // Clean up staging directory
1337
+ if (existsSync(stagingDir)) {
1338
+ rmSync(stagingDir, { recursive: true, force: true });
1339
+ }
1340
+ }
1341
+ }
1312
1342
 
1313
1343
  // Helper function to generate usage description entries for Info.plist
1314
1344
  function generateUsageDescriptions(
@@ -1508,6 +1538,40 @@ ${schemesXml}
1508
1538
  ? envArg
1509
1539
  : "dev";
1510
1540
 
1541
+ try {
1542
+ await runBuild(config, buildEnvironment);
1543
+ } catch (error) {
1544
+ if (error instanceof Error) {
1545
+ console.error(error.message);
1546
+ }
1547
+ process.exit(1);
1548
+ }
1549
+ } else if (commandArg === "run") {
1550
+ const config = await getConfig();
1551
+ await runAppWithSignalHandling(config);
1552
+ } else if (commandArg === "dev") {
1553
+ const config = await getConfig();
1554
+ const watchMode = process.argv.includes("--watch");
1555
+
1556
+ if (watchMode) {
1557
+ await runDevWatch(config);
1558
+ } else {
1559
+ try {
1560
+ await runBuild(config, "dev");
1561
+ } catch (error) {
1562
+ if (error instanceof Error) {
1563
+ console.error(error.message);
1564
+ }
1565
+ process.exit(1);
1566
+ }
1567
+ await runAppWithSignalHandling(config);
1568
+ }
1569
+ }
1570
+
1571
+ async function runBuild(
1572
+ config: Awaited<ReturnType<typeof getConfig>>,
1573
+ buildEnvironment: string,
1574
+ ) {
1511
1575
  // Determine current platform as default target
1512
1576
  const currentTarget = { os: OS, arch: ARCH };
1513
1577
 
@@ -1588,11 +1652,14 @@ ${schemesXml}
1588
1652
  console.error("Tried to run with bun at:", hostPaths.BUN_BINARY);
1589
1653
  console.error("Script path:", hookScript);
1590
1654
  console.error("Working directory:", projectRoot);
1591
- process.exit(1);
1655
+ throw new Error("Build failed: hook script failed");
1592
1656
  }
1593
1657
  };
1594
1658
 
1595
- const buildIcons = (appBundleFolderResourcesPath: string, appBundleFolderPath: string) => {
1659
+ const buildIcons = (
1660
+ appBundleFolderResourcesPath: string,
1661
+ appBundleFolderPath: string,
1662
+ ) => {
1596
1663
  // Platform-specific icon handling
1597
1664
  if (targetOS === "macos" && config.build.mac?.icons) {
1598
1665
  // macOS uses .iconset folders that get converted to .icns using iconutil
@@ -1666,10 +1733,12 @@ StartupWMClass=${config.app.name}
1666
1733
  Categories=Utility;Application;
1667
1734
  `;
1668
1735
 
1669
- const desktopFilePath = join(appBundleFolderPath, `${config.app.name}.desktop`);
1736
+ const desktopFilePath = join(
1737
+ appBundleFolderPath,
1738
+ `${config.app.name}.desktop`,
1739
+ );
1670
1740
  writeFileSync(desktopFilePath, desktopContent);
1671
1741
  console.log(`Created Linux desktop file: ${desktopFilePath}`);
1672
-
1673
1742
  } else if (targetOS === "win" && config.build.win?.icon) {
1674
1743
  const iconPath = join(projectRoot, config.build.win.icon);
1675
1744
  if (existsSync(iconPath)) {
@@ -1692,10 +1761,9 @@ Categories=Utility;Application;
1692
1761
  const bunSource = join(projectRoot, bunConfig.entrypoint);
1693
1762
 
1694
1763
  if (!existsSync(bunSource)) {
1695
- console.error(
1764
+ throw new Error(
1696
1765
  `failed to bundle ${bunSource} because it doesn't exist.\n You need a config.build.bun.entrypoint source file to build.`,
1697
1766
  );
1698
- process.exit(1);
1699
1767
  }
1700
1768
 
1701
1769
  // build macos bundle
@@ -1872,7 +1940,11 @@ Categories=Utility;Application;
1872
1940
 
1873
1941
  // Bun runtime binary
1874
1942
  // todo (yoav): this only works for the current architecture
1875
- const bunBinarySourcePath = await ensureBunBinary(currentTarget.os, currentTarget.arch, config.build.bunVersion);
1943
+ const bunBinarySourcePath = await ensureBunBinary(
1944
+ currentTarget.os,
1945
+ currentTarget.arch,
1946
+ config.build.bunVersion,
1947
+ );
1876
1948
  // Note: .bin/bun binary in node_modules is a symlink to the versioned one in another place
1877
1949
  // in node_modules, so we have to dereference here to get the actual binary in the bundle.
1878
1950
  const bunBinaryDestInBundlePath =
@@ -1970,7 +2042,9 @@ Categories=Utility;Application;
1970
2042
  cpSync(nativeWrapperLinuxSource, nativeWrapperLinuxDestination, {
1971
2043
  dereference: true,
1972
2044
  });
1973
- console.log(`Using ${useCEF ? "CEF (with weak linking)" : "GTK-only"} native wrapper for Linux`);
2045
+ console.log(
2046
+ `Using ${useCEF ? "CEF (with weak linking)" : "GTK-only"} native wrapper for Linux`,
2047
+ );
1974
2048
  } else {
1975
2049
  throw new Error(
1976
2050
  `Native wrapper not found: ${nativeWrapperLinuxSource}`,
@@ -2020,9 +2094,16 @@ Categories=Utility;Application;
2020
2094
  (targetOS === "win" && config.build.win?.bundleCEF) ||
2021
2095
  (targetOS === "linux" && config.build.linux?.bundleCEF)
2022
2096
  ) {
2023
- const effectiveCEFDir = await ensureCEFDependencies(currentTarget.os, currentTarget.arch, config.build.cefVersion);
2097
+ const effectiveCEFDir = await ensureCEFDependencies(
2098
+ currentTarget.os,
2099
+ currentTarget.arch,
2100
+ config.build.cefVersion,
2101
+ );
2024
2102
  if (targetOS === "macos") {
2025
- const cefFrameworkSource = join(effectiveCEFDir, "Chromium Embedded Framework.framework");
2103
+ const cefFrameworkSource = join(
2104
+ effectiveCEFDir,
2105
+ "Chromium Embedded Framework.framework",
2106
+ );
2026
2107
  const cefFrameworkDestination = join(
2027
2108
  appBundleFolderFrameworksPath,
2028
2109
  "Chromium Embedded Framework.framework",
@@ -2387,7 +2468,7 @@ Categories=Utility;Application;
2387
2468
 
2388
2469
  if (!buildResult.success) {
2389
2470
  console.error("failed to build", bunSource, buildResult.logs);
2390
- process.exit(1);
2471
+ throw new Error("Build failed: bun build failed");
2391
2472
  }
2392
2473
 
2393
2474
  // transpile developer's view code
@@ -2532,7 +2613,7 @@ Categories=Utility;Application;
2532
2613
  if (!existsSync(zigAsarCli)) {
2533
2614
  console.error(`zig-asar CLI not found at: ${zigAsarCli}`);
2534
2615
  console.error("Make sure to run setup/vendoring first");
2535
- process.exit(1);
2616
+ throw new Error("Build failed: zig-asar CLI not found");
2536
2617
  }
2537
2618
 
2538
2619
  // Build zig-asar command arguments
@@ -2601,13 +2682,14 @@ Categories=Utility;Application;
2601
2682
  );
2602
2683
  }
2603
2684
  console.error("Command:", zigAsarCli, ...asarArgs);
2604
- process.exit(1);
2685
+ throw new Error("Build failed: ASAR packing failed");
2605
2686
  }
2606
2687
 
2607
2688
  // Verify ASAR was created
2608
2689
  if (!existsSync(asarPath)) {
2609
- console.error("ASAR file was not created:", asarPath);
2610
- process.exit(1);
2690
+ throw new Error(
2691
+ "Build failed: ASAR file was not created: " + asarPath,
2692
+ );
2611
2693
  }
2612
2694
 
2613
2695
  console.log("✓ Created app.asar");
@@ -2642,7 +2724,7 @@ Categories=Utility;Application;
2642
2724
  }
2643
2725
  }
2644
2726
  // Check if Bun.Archive is available (Bun 1.3.0+)
2645
- if (typeof Bun.Archive !== 'undefined') {
2727
+ if (typeof Bun.Archive !== "undefined") {
2646
2728
  const archiveBytes = await new Bun.Archive(bundleFiles).bytes();
2647
2729
  // Note: wyhash is the default in Bun.hash but that may change in the future
2648
2730
  // so we're being explicit here.
@@ -2650,7 +2732,7 @@ Categories=Utility;Application;
2650
2732
  } else {
2651
2733
  // Fallback for older Bun versions - use a simple hash of file paths
2652
2734
  console.warn("Bun.Archive not available, using fallback hash method");
2653
- const fileList = Object.keys(bundleFiles).sort().join('\n');
2735
+ const fileList = Object.keys(bundleFiles).sort().join("\n");
2654
2736
  hash = Bun.hash.wyhash(fileList).toString(36);
2655
2737
  }
2656
2738
  console.timeEnd("Generate Bundle hash");
@@ -2690,7 +2772,9 @@ Categories=Utility;Application;
2690
2772
  defaultRenderer: platformConfig?.defaultRenderer ?? "native",
2691
2773
  availableRenderers: bundlesCEF ? ["native", "cef"] : ["native"],
2692
2774
  runtime: config.runtime ?? {},
2693
- ...(bundlesCEF ? { cefVersion: config.build?.cefVersion ?? DEFAULT_CEF_VERSION_STRING } : {}),
2775
+ ...(bundlesCEF
2776
+ ? { cefVersion: config.build?.cefVersion ?? DEFAULT_CEF_VERSION_STRING }
2777
+ : {}),
2694
2778
  bunVersion: config.build?.bunVersion ?? BUN_VERSION,
2695
2779
  };
2696
2780
 
@@ -2767,7 +2851,7 @@ Categories=Utility;Application;
2767
2851
 
2768
2852
  // Tar the app bundle for all platforms
2769
2853
  createTar(tarPath, buildFolder, [basename(appBundleFolderPath)]);
2770
-
2854
+
2771
2855
  // Remove the app bundle folder after tarring (except on Linux where it might be needed for dev)
2772
2856
  if (targetOS !== "linux" || buildEnvironment !== "dev") {
2773
2857
  rmdirSync(appBundleFolderPath, { recursive: true });
@@ -3025,7 +3109,10 @@ Categories=Utility;Application;
3025
3109
  dereference: true,
3026
3110
  });
3027
3111
 
3028
- buildIcons(selfExtractingBundle.appBundleFolderResourcesPath, selfExtractingBundle.appBundleFolderPath);
3112
+ buildIcons(
3113
+ selfExtractingBundle.appBundleFolderResourcesPath,
3114
+ selfExtractingBundle.appBundleFolderPath,
3115
+ );
3029
3116
  await Bun.write(
3030
3117
  join(selfExtractingBundle.appBundleFolderContentsPath, "Info.plist"),
3031
3118
  InfoPlistContents,
@@ -3255,49 +3342,88 @@ Categories=Utility;Application;
3255
3342
  // for a dmg.
3256
3343
  // can also use stapler validate -v to validate the dmg and look for teamId, signingId, and the response signedTicket
3257
3344
  // stapler validate -v <app path>
3258
- } else if (commandArg === "dev") {
3259
- // todo (yoav): rename to start
3345
+ }
3260
3346
 
3261
- // run the project in dev mode
3262
- // this runs the bundled bun binary with main.js directly
3347
+ // Take over as the terminal's foreground process group (macOS/Linux).
3348
+ // This prevents the parent bun script runner from receiving SIGINT
3349
+ // when Ctrl+C is pressed, keeping the terminal busy until the app
3350
+ // finishes shutting down gracefully.
3351
+ // Call once per CLI session — returns a restore function.
3352
+ async function takeoverForeground(): Promise<() => void> {
3353
+ let restoreFn = () => {};
3354
+ if (OS === "win") return restoreFn;
3355
+ try {
3356
+ const { dlopen, ptr } = await import("bun:ffi");
3357
+ const libName = OS === "macos" ? "libSystem.B.dylib" : "libc.so.6";
3358
+ const libc = dlopen(libName, {
3359
+ open: { args: ["ptr", "i32"], returns: "i32" },
3360
+ close: { args: ["i32"], returns: "i32" },
3361
+ getpid: { args: [], returns: "i32" },
3362
+ setpgid: { args: ["i32", "i32"], returns: "i32" },
3363
+ tcgetpgrp: { args: ["i32"], returns: "i32" },
3364
+ tcsetpgrp: { args: ["i32", "i32"], returns: "i32" },
3365
+ signal: { args: ["i32", "ptr"], returns: "ptr" },
3366
+ });
3263
3367
 
3264
- // Get config for dev mode
3265
- const config = await getConfig();
3368
+ const ttyPathBuf = new Uint8Array(Buffer.from("/dev/tty\0"));
3369
+ const ttyFd = libc.symbols.open(ptr(ttyPathBuf), 2); // O_RDWR
3370
+
3371
+ if (ttyFd >= 0) {
3372
+ const originalPgid = libc.symbols.tcgetpgrp(ttyFd);
3373
+ if (originalPgid >= 0) {
3374
+ // Ignore SIGTTOU at C level so tcsetpgrp works from background group.
3375
+ // bun's process.on("SIGTTOU") doesn't set the C-level disposition.
3376
+ // SIG_IGN = (void(*)(int))1, SIGTTOU = 22 on macOS/Linux
3377
+ libc.symbols.signal(22, 1);
3378
+
3379
+ if (libc.symbols.setpgid(0, 0) === 0) {
3380
+ const myPid = libc.symbols.getpid();
3381
+ if (libc.symbols.tcsetpgrp(ttyFd, myPid) === 0) {
3382
+ restoreFn = () => {
3383
+ try {
3384
+ libc.symbols.signal(22, 1);
3385
+ libc.symbols.tcsetpgrp(ttyFd, originalPgid);
3386
+ libc.symbols.close(ttyFd);
3387
+ } catch {}
3388
+ };
3389
+ } else {
3390
+ libc.symbols.setpgid(0, originalPgid);
3391
+ libc.symbols.close(ttyFd);
3392
+ }
3393
+ } else {
3394
+ libc.symbols.close(ttyFd);
3395
+ }
3396
+ } else {
3397
+ libc.symbols.close(ttyFd);
3398
+ }
3399
+ }
3400
+ } catch {
3401
+ // Fall back to default behavior (prompt may return early on Ctrl+C)
3402
+ }
3403
+ return restoreFn;
3404
+ }
3405
+
3406
+ async function runApp(
3407
+ config: Awaited<ReturnType<typeof getConfig>>,
3408
+ options?: { onExit?: () => void },
3409
+ ): Promise<{ kill: () => void; exited: Promise<number> }> {
3410
+ // Launch the already-built dev bundle
3266
3411
 
3267
- // Set up dev build variables (similar to build mode)
3268
3412
  const buildEnvironment = "dev";
3269
- const currentTarget = { os: OS, arch: ARCH };
3270
3413
  const appFileName = getAppFileName(config.app.name, buildEnvironment);
3271
- // macOS uses display name with spaces for the actual .app folder
3272
3414
  const macOSBundleDisplayName = getMacOSBundleDisplayName(
3273
3415
  config.app.name,
3274
3416
  buildEnvironment,
3275
3417
  );
3276
- const buildSubFolder = `${buildEnvironment}-${currentTarget.os}-${currentTarget.arch}`;
3418
+ const buildSubFolder = `${buildEnvironment}-${OS}-${ARCH}`;
3277
3419
  const buildFolder = join(
3278
3420
  projectRoot,
3279
3421
  config.build.buildFolder,
3280
3422
  buildSubFolder,
3281
3423
  );
3282
- // Use display name for macOS bundles (with spaces), sanitized name for other platforms
3283
3424
  const bundleFileName =
3284
3425
  OS === "macos" ? `${macOSBundleDisplayName}.app` : appFileName;
3285
3426
 
3286
- // Note: this cli will be a bun single-file-executable
3287
- // Note: we want to use the version of bun that's packaged with electrobun
3288
- // const bunPath = join(projectRoot, 'node_modules', '.bin', 'bun');
3289
- // const mainPath = join(buildFolder, 'bun', 'index.js');
3290
- // const mainPath = join(buildFolder, bundleFileName);
3291
- // console.log('running ', bunPath, mainPath);
3292
-
3293
- // Note: open will open the app bundle as a completely different process
3294
- // This is critical to fully test the app (including plist configuration, etc.)
3295
- // but also to get proper cmd+tab and dock behaviour and not run the windowed app
3296
- // as a child of the terminal process which steels keyboard focus from any descendant nswindows.
3297
- // Bun.spawn(["open", mainPath], {
3298
- // env: {},
3299
- // });
3300
-
3301
3427
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
3302
3428
  let mainProc: any;
3303
3429
  let bundleExecPath: string;
@@ -3312,7 +3438,6 @@ Categories=Utility;Application;
3312
3438
  "Resources",
3313
3439
  );
3314
3440
  } else if (OS === "linux") {
3315
- // Directory bundle mode
3316
3441
  bundleExecPath = join(buildFolder, bundleFileName, "bin");
3317
3442
  _bundleResourcesPath = join(buildFolder, bundleFileName, "Resources");
3318
3443
  } else if (OS === "win") {
@@ -3322,62 +3447,6 @@ Categories=Utility;Application;
3322
3447
  throw new Error(`Unsupported OS: ${OS}`);
3323
3448
  }
3324
3449
 
3325
- // Take over as the terminal's foreground process group (macOS/Linux).
3326
- // This prevents the parent bun script runner from receiving SIGINT
3327
- // when Ctrl+C is pressed, keeping the terminal busy until the app
3328
- // finishes shutting down gracefully.
3329
- let restoreForeground = () => {};
3330
- if (OS !== "win") {
3331
- try {
3332
- const { dlopen, ptr } = await import("bun:ffi");
3333
- const libName = OS === "macos" ? "libSystem.B.dylib" : "libc.so.6";
3334
- const libc = dlopen(libName, {
3335
- open: { args: ["ptr", "i32"], returns: "i32" },
3336
- close: { args: ["i32"], returns: "i32" },
3337
- getpid: { args: [], returns: "i32" },
3338
- setpgid: { args: ["i32", "i32"], returns: "i32" },
3339
- tcgetpgrp: { args: ["i32"], returns: "i32" },
3340
- tcsetpgrp: { args: ["i32", "i32"], returns: "i32" },
3341
- signal: { args: ["i32", "ptr"], returns: "ptr" },
3342
- });
3343
-
3344
- const ttyPathBuf = new Uint8Array(Buffer.from("/dev/tty\0"));
3345
- const ttyFd = libc.symbols.open(ptr(ttyPathBuf), 2); // O_RDWR
3346
-
3347
- if (ttyFd >= 0) {
3348
- const originalPgid = libc.symbols.tcgetpgrp(ttyFd);
3349
- if (originalPgid >= 0) {
3350
- // Ignore SIGTTOU at C level so tcsetpgrp works from background group.
3351
- // bun's process.on("SIGTTOU") doesn't set the C-level disposition.
3352
- // SIG_IGN = (void(*)(int))1, SIGTTOU = 22 on macOS/Linux
3353
- libc.symbols.signal(22, 1);
3354
-
3355
- if (libc.symbols.setpgid(0, 0) === 0) {
3356
- const myPid = libc.symbols.getpid();
3357
- if (libc.symbols.tcsetpgrp(ttyFd, myPid) === 0) {
3358
- restoreForeground = () => {
3359
- try {
3360
- libc.symbols.signal(22, 1);
3361
- libc.symbols.tcsetpgrp(ttyFd, originalPgid);
3362
- libc.symbols.close(ttyFd);
3363
- } catch {}
3364
- };
3365
- } else {
3366
- libc.symbols.setpgid(0, originalPgid);
3367
- libc.symbols.close(ttyFd);
3368
- }
3369
- } else {
3370
- libc.symbols.close(ttyFd);
3371
- }
3372
- } else {
3373
- libc.symbols.close(ttyFd);
3374
- }
3375
- }
3376
- } catch {
3377
- // Fall back to default behavior (prompt may return early on Ctrl+C)
3378
- }
3379
- }
3380
-
3381
3450
  if (OS === "macos" || OS === "linux") {
3382
3451
  // For Linux dev mode, update libNativeWrapper.so based on bundleCEF setting
3383
3452
  if (OS === "linux") {
@@ -3399,7 +3468,6 @@ Categories=Utility;Application;
3399
3468
  }
3400
3469
  }
3401
3470
 
3402
- // Use the zig launcher for macOS and Linux
3403
3471
  mainProc = Bun.spawn([join(bundleExecPath, "launcher")], {
3404
3472
  stdio: ["inherit", "inherit", "inherit"],
3405
3473
  cwd: bundleExecPath,
@@ -3411,36 +3479,288 @@ Categories=Utility;Application;
3411
3479
  });
3412
3480
  }
3413
3481
 
3414
- let sigintCount = 0;
3482
+ if (!mainProc) {
3483
+ throw new Error("Failed to spawn app process");
3484
+ }
3415
3485
 
3486
+ const exitedPromise = mainProc.exited.then((code: number) => {
3487
+ options?.onExit?.();
3488
+ return code ?? 0;
3489
+ });
3490
+
3491
+ return {
3492
+ kill: () => {
3493
+ try {
3494
+ mainProc.kill();
3495
+ } catch {}
3496
+ },
3497
+ exited: exitedPromise,
3498
+ };
3499
+ }
3500
+
3501
+ async function runAppWithSignalHandling(
3502
+ config: Awaited<ReturnType<typeof getConfig>>,
3503
+ ) {
3504
+ const restoreForeground = await takeoverForeground();
3505
+ const handle = await runApp(config);
3506
+
3507
+ let sigintCount = 0;
3416
3508
  process.on("SIGINT", () => {
3417
3509
  sigintCount++;
3418
-
3419
3510
  if (sigintCount === 1) {
3420
- // First Ctrl+C: The app already received SIGINT from the process group.
3421
- // Its SIGINT handler calls quit() which fires beforeQuit and shuts down
3422
- // gracefully. Don't send another signal - just wait.
3423
3511
  console.log(
3424
3512
  "\n[electrobun dev] Shutting down gracefully... (press Ctrl+C again to force quit)",
3425
3513
  );
3426
3514
  } else {
3427
- // Second Ctrl+C: force kill entire process group
3515
+ console.log("\n[electrobun dev] Force quitting...");
3516
+ try {
3517
+ process.kill(0, "SIGKILL");
3518
+ } catch {}
3519
+ process.exit(0);
3520
+ }
3521
+ });
3522
+
3523
+ const code = await handle.exited;
3524
+ restoreForeground();
3525
+ process.exit(code);
3526
+ }
3527
+
3528
+ async function runDevWatch(config: Awaited<ReturnType<typeof getConfig>>) {
3529
+ const { watch } = await import("fs");
3530
+
3531
+ // Collect watch directories from config entrypoints
3532
+ const watchDirs = new Set<string>();
3533
+
3534
+ // Bun entrypoint directory
3535
+ if (config.build.bun?.entrypoint) {
3536
+ watchDirs.add(join(projectRoot, dirname(config.build.bun.entrypoint)));
3537
+ }
3538
+
3539
+ // View entrypoint directories
3540
+ if (config.build.views) {
3541
+ for (const viewConfig of Object.values(config.build.views)) {
3542
+ if (viewConfig.entrypoint) {
3543
+ watchDirs.add(join(projectRoot, dirname(viewConfig.entrypoint)));
3544
+ }
3545
+ }
3546
+ }
3547
+
3548
+ // Copy source directories
3549
+ if (config.build.copy) {
3550
+ for (const src of Object.keys(config.build.copy)) {
3551
+ const srcPath = join(projectRoot, src);
3552
+ try {
3553
+ const stat = statSync(srcPath);
3554
+ watchDirs.add(stat.isDirectory() ? srcPath : dirname(srcPath));
3555
+ } catch {
3556
+ watchDirs.add(dirname(srcPath));
3557
+ }
3558
+ }
3559
+ }
3560
+
3561
+ // User-specified additional watch paths
3562
+ if (config.build.watch) {
3563
+ for (const entry of config.build.watch) {
3564
+ const entryPath = join(projectRoot, entry);
3565
+ try {
3566
+ const stat = statSync(entryPath);
3567
+ watchDirs.add(stat.isDirectory() ? entryPath : dirname(entryPath));
3568
+ } catch {
3569
+ // Path doesn't exist yet — watch its parent directory
3570
+ watchDirs.add(dirname(entryPath));
3571
+ }
3572
+ }
3573
+ }
3574
+
3575
+ // Deduplicate overlapping directories (remove children if parent is watched)
3576
+ const sortedDirs = [...watchDirs].sort();
3577
+ const dedupedDirs = sortedDirs.filter((dir, i) => {
3578
+ return !sortedDirs.some(
3579
+ (other, j) => j < i && dir.startsWith(other + "/"),
3580
+ );
3581
+ });
3582
+
3583
+ if (dedupedDirs.length === 0) {
3584
+ console.error(
3585
+ "[electrobun dev --watch] No directories to watch. Check your config entrypoints.",
3586
+ );
3587
+ process.exit(1);
3588
+ }
3589
+
3590
+ console.log(`
3591
+ ╔══════════════════════════════════════════════════════════════╗
3592
+ ║ ELECTROBUN DEV --watch ║
3593
+ ║ Watching ${String(dedupedDirs.length).padEnd(2)} director${dedupedDirs.length === 1 ? "y " : "ies"} ║
3594
+ ╚══════════════════════════════════════════════════════════════╝
3595
+ `);
3596
+ for (const dir of dedupedDirs) {
3597
+ console.log(` ${dir}`);
3598
+ }
3599
+
3600
+ // Set up terminal foreground takeover once for the whole session
3601
+ const restoreForeground = await takeoverForeground();
3602
+
3603
+ // Paths to ignore in file watcher (build output, node_modules, artifacts)
3604
+ const buildDir = join(projectRoot, config.build.buildFolder);
3605
+ const artifactDir = join(projectRoot, config.build.artifactFolder);
3606
+ const ignoreDirs = [
3607
+ buildDir,
3608
+ artifactDir,
3609
+ join(projectRoot, "node_modules"),
3610
+ ];
3611
+
3612
+ // Compile watchIgnore glob patterns
3613
+ const ignoreGlobs = (config.build.watchIgnore || []).map(
3614
+ (pattern) => new Bun.Glob(pattern),
3615
+ );
3616
+
3617
+ function shouldIgnore(fullPath: string): boolean {
3618
+ // Check built-in ignore dirs
3619
+ if (
3620
+ ignoreDirs.some(
3621
+ (ignored) =>
3622
+ fullPath.startsWith(ignored + "/") || fullPath === ignored,
3623
+ )
3624
+ ) {
3625
+ return true;
3626
+ }
3627
+ // Check user-configured watchIgnore globs (match against project-relative path)
3628
+ const relativePath = fullPath.replace(projectRoot + "/", "");
3629
+ if (ignoreGlobs.some((glob) => glob.match(relativePath))) {
3630
+ return true;
3631
+ }
3632
+ return false;
3633
+ }
3634
+
3635
+ let appHandle: { kill: () => void; exited: Promise<number> } | null = null;
3636
+ let lastChangedFile = "";
3637
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
3638
+ let shuttingDown = false;
3639
+ let watchers: ReturnType<typeof watch>[] = [];
3640
+
3641
+ function startWatchers() {
3642
+ for (const dir of dedupedDirs) {
3643
+ const watcher = watch(dir, { recursive: true }, (_event, filename) => {
3644
+ if (shuttingDown) return;
3645
+
3646
+ if (filename) {
3647
+ const fullPath = join(dir, filename);
3648
+ if (shouldIgnore(fullPath)) {
3649
+ return;
3650
+ }
3651
+ lastChangedFile = fullPath;
3652
+ }
3653
+
3654
+ if (debounceTimer) clearTimeout(debounceTimer);
3655
+ debounceTimer = setTimeout(() => {
3656
+ triggerRebuild();
3657
+ }, 300);
3658
+ });
3659
+ watchers.push(watcher);
3660
+ }
3661
+ }
3662
+
3663
+ function stopWatchers() {
3664
+ for (const watcher of watchers) {
3665
+ try { watcher.close(); } catch {}
3666
+ }
3667
+ watchers = [];
3668
+ }
3669
+
3670
+ async function triggerRebuild() {
3671
+ if (shuttingDown) return;
3672
+
3673
+ // Stop watching during build so build output doesn't trigger more events
3674
+ stopWatchers();
3675
+
3676
+ const changedDisplay = lastChangedFile
3677
+ ? lastChangedFile.replace(projectRoot + "/", "")
3678
+ : "unknown";
3679
+ console.log(`
3680
+ ╔══════════════════════════════════════════════════════════════╗
3681
+ ║ FILE CHANGED: ${changedDisplay.padEnd(44)}║
3682
+ ║ Rebuilding... ║
3683
+ ╚══════════════════════════════════════════════════════════════╝
3684
+ `);
3685
+
3686
+ // Kill running app if any
3687
+ if (appHandle) {
3688
+ appHandle.kill();
3689
+ try {
3690
+ await appHandle.exited;
3691
+ } catch {}
3692
+ appHandle = null;
3693
+ }
3694
+
3695
+ try {
3696
+ await runBuild(config, "dev");
3428
3697
  console.log(
3429
- "\n[electrobun dev] Force quitting...",
3698
+ "[electrobun dev --watch] Build succeeded, launching app...",
3430
3699
  );
3431
- try { process.kill(0, "SIGKILL"); } catch {}
3700
+
3701
+ appHandle = await runApp(config, {
3702
+ onExit: () => {
3703
+ appHandle = null;
3704
+ },
3705
+ });
3706
+ } catch (error) {
3707
+ console.error("[electrobun dev --watch] Build failed:", error);
3708
+ console.log("[electrobun dev --watch] Waiting for file changes...");
3709
+ }
3710
+
3711
+ // Resume watching after build + hooks are done
3712
+ if (!shuttingDown) {
3713
+ startWatchers();
3714
+ }
3715
+ }
3716
+
3717
+ function cleanup() {
3718
+ shuttingDown = true;
3719
+ if (debounceTimer) clearTimeout(debounceTimer);
3720
+ stopWatchers();
3721
+ if (appHandle) {
3722
+ appHandle.kill();
3723
+ }
3724
+ restoreForeground();
3725
+ }
3726
+
3727
+ // Ctrl+C handling for watch mode
3728
+ let sigintCount = 0;
3729
+ process.on("SIGINT", () => {
3730
+ sigintCount++;
3731
+ if (sigintCount === 1) {
3732
+ console.log(
3733
+ "\n[electrobun dev --watch] Shutting down... (press Ctrl+C again to force quit)",
3734
+ );
3735
+ cleanup();
3736
+ // Wait briefly for app to exit, then exit
3737
+ setTimeout(() => process.exit(0), 2000);
3738
+ } else {
3739
+ try {
3740
+ process.kill(0, "SIGKILL");
3741
+ } catch {}
3432
3742
  process.exit(0);
3433
3743
  }
3434
3744
  });
3435
3745
 
3436
- // Wait for the child process to exit before returning.
3437
- // This keeps the CLI alive so it doesn't return to the shell prompt
3438
- // while the app is still shutting down.
3439
- if (mainProc) {
3440
- const code = await mainProc.exited;
3441
- restoreForeground();
3442
- process.exit(code ?? 0);
3746
+ // Initial build + launch (watchers start after build completes)
3747
+ try {
3748
+ await runBuild(config, "dev");
3749
+ appHandle = await runApp(config, {
3750
+ onExit: () => {
3751
+ appHandle = null;
3752
+ },
3753
+ });
3754
+ } catch (error) {
3755
+ console.error("[electrobun dev --watch] Initial build failed:", error);
3756
+ console.log("[electrobun dev --watch] Waiting for file changes...");
3443
3757
  }
3758
+
3759
+ // Start watching only after initial build + all hooks are done
3760
+ startWatchers();
3761
+
3762
+ // Keep the process alive
3763
+ await new Promise(() => {});
3444
3764
  }
3445
3765
 
3446
3766
  // Helper functions
@@ -3709,8 +4029,6 @@ Categories=Utility;Application;
3709
4029
  });
3710
4030
  }
3711
4031
 
3712
-
3713
-
3714
4032
  function codesignAppBundle(
3715
4033
  appBundleOrDmgPath: string,
3716
4034
  entitlementsFilePath: string | undefined,