electrobun 1.17.3-beta.9 → 1.18.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.
package/src/cli/index.ts CHANGED
@@ -1462,6 +1462,13 @@ const _commandDefaults = {
1462
1462
  },
1463
1463
  };
1464
1464
 
1465
+ type FileAssociation = {
1466
+ ext: string[];
1467
+ name: string;
1468
+ role?: "Editor" | "Viewer" | "Shell" | "None";
1469
+ icon?: string;
1470
+ };
1471
+
1465
1472
  // Default values merged with user's electrobun.config.ts
1466
1473
  // For the user-facing type, see ElectrobunConfig in src/bun/ElectrobunConfig.ts
1467
1474
  const defaultConfig = {
@@ -1471,6 +1478,7 @@ const defaultConfig = {
1471
1478
  version: "0.1.0",
1472
1479
  description: "" as string | undefined,
1473
1480
  urlSchemes: undefined as string[] | undefined,
1481
+ fileAssociations: undefined as FileAssociation[] | undefined,
1474
1482
  },
1475
1483
  build: {
1476
1484
  buildFolder: "build",
@@ -1779,6 +1787,151 @@ ${schemesXml}
1779
1787
  </array>`;
1780
1788
  }
1781
1789
 
1790
+ // Generates CFBundleDocumentTypes and UTExportedTypeDeclarations for file associations.
1791
+ // Each association gets a UTI derived from the app identifier (e.g., com.example.app.myext).
1792
+ // LSItemContentTypes in CFBundleDocumentTypes references these UTIs so Launch Services
1793
+ // properly associates files with the app on modern macOS.
1794
+ function generateDocumentTypes(
1795
+ fileAssociations: FileAssociation[] | undefined,
1796
+ projectRoot: string,
1797
+ appIdentifier: string,
1798
+ ): string {
1799
+ if (!fileAssociations || fileAssociations.length === 0) {
1800
+ return "";
1801
+ }
1802
+
1803
+ const validAssociations = fileAssociations.filter((assoc) => {
1804
+ if (!assoc.ext || assoc.ext.length === 0) {
1805
+ console.log(
1806
+ `WARNING: fileAssociations entry "${assoc.name || "(unnamed)"}" has no extensions — skipping`,
1807
+ );
1808
+ return false;
1809
+ }
1810
+ if (!assoc.name) {
1811
+ console.log(
1812
+ `WARNING: fileAssociations entry with extensions [${assoc.ext.join(", ")}] has no name — skipping`,
1813
+ );
1814
+ return false;
1815
+ }
1816
+ return true;
1817
+ });
1818
+
1819
+ if (validAssociations.length === 0) {
1820
+ return "";
1821
+ }
1822
+
1823
+ // Clean extensions and warn about leading dots
1824
+ const cleaned = validAssociations.map((assoc) => ({
1825
+ ...assoc,
1826
+ ext: assoc.ext.map((ext) => {
1827
+ const clean = ext.replace(/^\./, "");
1828
+ if (clean !== ext) {
1829
+ console.log(
1830
+ `WARNING: fileAssociations ext "${ext}" has a leading dot — stripping to "${clean}"`,
1831
+ );
1832
+ }
1833
+ return clean;
1834
+ }),
1835
+ }));
1836
+
1837
+ // Generate CFBundleDocumentTypes with LSItemContentTypes
1838
+ const docTypes = cleaned
1839
+ .map((assoc) => {
1840
+ const role = assoc.role || "Viewer";
1841
+ // Resolve icon: only reference if file exists to avoid dangling plist entries
1842
+ let iconName = "";
1843
+ if (assoc.icon) {
1844
+ const iconSourcePath = join(projectRoot, assoc.icon);
1845
+ if (existsSync(iconSourcePath)) {
1846
+ iconName = basename(assoc.icon).replace(/\.icns$/i, "");
1847
+ } else {
1848
+ console.log(
1849
+ `WARNING: Document type icon not found: ${iconSourcePath} — skipping icon reference`,
1850
+ );
1851
+ }
1852
+ }
1853
+ const iconLine = iconName
1854
+ ? ` <key>CFBundleTypeIconFile</key>\n <string>${escapeXml(iconName)}</string>\n`
1855
+ : "";
1856
+ // One UTI per extension, all listed under LSItemContentTypes
1857
+ const utiXml = assoc.ext
1858
+ .map(
1859
+ (ext) =>
1860
+ ` <string>${escapeXml(appIdentifier)}.${escapeXml(ext)}</string>`,
1861
+ )
1862
+ .join("\n");
1863
+ const extsXml = assoc.ext
1864
+ .map(
1865
+ (ext) =>
1866
+ ` <string>${escapeXml(ext)}</string>`,
1867
+ )
1868
+ .join("\n");
1869
+
1870
+ return ` <dict>
1871
+ <key>CFBundleTypeName</key>
1872
+ <string>${escapeXml(assoc.name)}</string>
1873
+ <key>CFBundleTypeRole</key>
1874
+ <string>${escapeXml(role)}</string>
1875
+ ${iconLine} <key>LSItemContentTypes</key>
1876
+ <array>
1877
+ ${utiXml}
1878
+ </array>
1879
+ <key>CFBundleTypeExtensions</key>
1880
+ <array>
1881
+ ${extsXml}
1882
+ </array>
1883
+ </dict>`;
1884
+ })
1885
+ .join("\n");
1886
+
1887
+ // Generate UTExportedTypeDeclarations — one per extension
1888
+ const utiDecls = cleaned
1889
+ .flatMap((assoc) => {
1890
+ let iconName = "";
1891
+ if (assoc.icon) {
1892
+ const iconSourcePath = join(projectRoot, assoc.icon);
1893
+ if (existsSync(iconSourcePath)) {
1894
+ iconName = basename(assoc.icon).replace(/\.icns$/i, "");
1895
+ }
1896
+ }
1897
+ const iconLine = iconName
1898
+ ? ` <key>UTTypeIconFiles</key>
1899
+ <array>
1900
+ <string>${escapeXml(iconName)}</string>
1901
+ </array>\n`
1902
+ : "";
1903
+ return assoc.ext.map(
1904
+ (ext) => ` <dict>
1905
+ <key>UTTypeIdentifier</key>
1906
+ <string>${escapeXml(appIdentifier)}.${escapeXml(ext)}</string>
1907
+ <key>UTTypeDescription</key>
1908
+ <string>${escapeXml(assoc.name)}</string>
1909
+ <key>UTTypeConformsTo</key>
1910
+ <array>
1911
+ <string>public.data</string>
1912
+ </array>
1913
+ ${iconLine} <key>UTTypeTagSpecification</key>
1914
+ <dict>
1915
+ <key>public.filename-extension</key>
1916
+ <array>
1917
+ <string>${escapeXml(ext)}</string>
1918
+ </array>
1919
+ </dict>
1920
+ </dict>`,
1921
+ );
1922
+ })
1923
+ .join("\n");
1924
+
1925
+ return ` <key>CFBundleDocumentTypes</key>
1926
+ <array>
1927
+ ${docTypes}
1928
+ </array>
1929
+ <key>UTExportedTypeDeclarations</key>
1930
+ <array>
1931
+ ${utiDecls}
1932
+ </array>`;
1933
+ }
1934
+
1782
1935
  // Execute command handling
1783
1936
  (async () => {
1784
1937
  if (commandArg === "init") {
@@ -1913,7 +2066,7 @@ ${schemesXml}
1913
2066
  console.log(
1914
2067
  "Different architecture, different APIs. Do not use Electron patterns.",
1915
2068
  );
1916
- console.log("Docs: https://blackboard.sh/electrobun/llms.txt");
2069
+ console.log("Docs: https://docs.electrobunny.ai/electrobun/llms.txt");
1917
2070
  console.log(
1918
2071
  "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
1919
2072
  );
@@ -1932,9 +2085,7 @@ ${schemesXml}
1932
2085
  try {
1933
2086
  await runBuild(config, buildEnvironment);
1934
2087
  } catch (error) {
1935
- if (error instanceof Error) {
1936
- console.error(error.message);
1937
- }
2088
+ console.error("Build failed:", error);
1938
2089
  process.exit(1);
1939
2090
  }
1940
2091
  } else if (commandArg === "run") {
@@ -1950,9 +2101,7 @@ ${schemesXml}
1950
2101
  try {
1951
2102
  await runBuild(config, "dev");
1952
2103
  } catch (error) {
1953
- if (error instanceof Error) {
1954
- console.error(error.message);
1955
- }
2104
+ console.error("Build failed:", error);
1956
2105
  process.exit(1);
1957
2106
  }
1958
2107
  await runAppWithSignalHandling(config);
@@ -2059,18 +2208,97 @@ ${schemesXml}
2059
2208
  const iconDestPath = join(appBundleFolderResourcesPath, "AppIcon.icns");
2060
2209
  if (existsSync(iconSourceFolder)) {
2061
2210
  if (OS === "macos") {
2062
- // Use iconutil to convert .iconset folder to .icns
2063
- Bun.spawnSync(
2064
- ["iconutil", "-c", "icns", "-o", iconDestPath, iconSourceFolder],
2065
- {
2066
- cwd: appBundleFolderResourcesPath,
2067
- stdio: ["ignore", "inherit", "inherit"],
2068
- env: {
2069
- ...process.env,
2070
- ELECTROBUN_BUILD_ENV: buildEnvironment,
2211
+ if (config.build.mac.icons.endsWith(".icon")) {
2212
+ // .icon format (Icon Composer) — compile with actool
2213
+ // Produces Assets.car (Liquid Glass on macOS 26+) and .icns fallback
2214
+ const actoolCheck = Bun.spawnSync(
2215
+ ["xcrun", "--find", "actool"],
2216
+ { stdio: ["ignore", "pipe", "pipe"] },
2217
+ );
2218
+ if (actoolCheck.exitCode !== 0) {
2219
+ throw new Error(
2220
+ "Building .icon files requires Xcode (actool is not available from Command Line Tools alone). " +
2221
+ "Install Xcode from the App Store, or set mac.icons to an .iconset folder instead.",
2222
+ );
2223
+ }
2224
+
2225
+ const iconStem = basename(config.build.mac.icons, ".icon");
2226
+ const partialPlistPath = join(
2227
+ buildFolder,
2228
+ ".actool-partial-info.plist",
2229
+ );
2230
+
2231
+ console.log(
2232
+ "Compiling .icon file with actool (requires Xcode)...",
2233
+ );
2234
+ const result = Bun.spawnSync(
2235
+ [
2236
+ "xcrun",
2237
+ "actool",
2238
+ "--compile",
2239
+ appBundleFolderResourcesPath,
2240
+ "--app-icon",
2241
+ iconStem,
2242
+ "--platform",
2243
+ "macosx",
2244
+ "--minimum-deployment-target",
2245
+ "11.0",
2246
+ "--output-partial-info-plist",
2247
+ partialPlistPath,
2248
+ iconSourceFolder,
2249
+ ],
2250
+ {
2251
+ cwd: projectRoot,
2252
+ stdio: ["ignore", "inherit", "inherit"],
2253
+ env: {
2254
+ ...process.env,
2255
+ ELECTROBUN_BUILD_ENV: buildEnvironment,
2256
+ },
2071
2257
  },
2072
- },
2073
- );
2258
+ );
2259
+
2260
+ if (result.exitCode !== 0) {
2261
+ throw new Error(
2262
+ `actool failed to compile ${config.build.mac.icons} (exit code ${result.exitCode})`,
2263
+ );
2264
+ }
2265
+
2266
+ // actool produces <stem>.icns — rename to AppIcon.icns so
2267
+ // CFBundleIconFile ("AppIcon") resolves correctly
2268
+ const actoolIcns = join(
2269
+ appBundleFolderResourcesPath,
2270
+ `${iconStem}.icns`,
2271
+ );
2272
+ if (existsSync(actoolIcns) && actoolIcns !== iconDestPath) {
2273
+ renameSync(actoolIcns, iconDestPath);
2274
+ }
2275
+ } else {
2276
+ // Use iconutil to convert .iconset folder to .icns
2277
+ const result = Bun.spawnSync(
2278
+ [
2279
+ "iconutil",
2280
+ "-c",
2281
+ "icns",
2282
+ "-o",
2283
+ iconDestPath,
2284
+ iconSourceFolder,
2285
+ ],
2286
+ {
2287
+ cwd: appBundleFolderResourcesPath,
2288
+ stdio: ["ignore", "inherit", "inherit"],
2289
+ env: {
2290
+ ...process.env,
2291
+ ELECTROBUN_BUILD_ENV: buildEnvironment,
2292
+ },
2293
+ },
2294
+ );
2295
+
2296
+ if (result.exitCode !== 0) {
2297
+ throw new Error(
2298
+ `iconutil failed to convert ${config.build.mac.icons} (exit code ${result.exitCode})`,
2299
+ );
2300
+ }
2301
+ }
2074
2302
  } else {
2075
2303
  console.log(
2076
2304
  `WARNING: Cannot build macOS icons on ${OS} - iconutil is only available on macOS`,
@@ -2137,6 +2365,26 @@ Categories=Utility;Application;
2137
2365
  cpSync(iconPath, targetIconPath, { dereference: true });
2138
2366
  }
2139
2367
  }
2368
+
2369
+ // Copy document type icon files to the app bundle Resources folder
2370
+ if (targetOS === "macos" && config.app.fileAssociations) {
2371
+ for (const assoc of config.app.fileAssociations) {
2372
+ if (assoc.icon) {
2373
+ const iconSourcePath = join(projectRoot, assoc.icon);
2374
+ if (existsSync(iconSourcePath)) {
2375
+ const iconFileName = basename(iconSourcePath);
2376
+ const iconDestPath = join(
2377
+ appBundleFolderResourcesPath,
2378
+ iconFileName,
2379
+ );
2380
+ cpSync(iconSourcePath, iconDestPath, {
2381
+ dereference: true,
2382
+ });
2383
+ }
2384
+ // Missing icon warning is handled by generateDocumentTypes
2385
+ }
2386
+ }
2387
+ }
2140
2388
  };
2141
2389
 
2142
2390
  // Run preBuild hook before anything starts
@@ -2212,8 +2460,20 @@ Categories=Utility;Application;
2212
2460
  config.app.urlSchemes,
2213
2461
  config.app.identifier,
2214
2462
  );
2463
+ // Generate document type associations
2464
+ const documentTypes = generateDocumentTypes(
2465
+ config.app.fileAssociations,
2466
+ projectRoot,
2467
+ config.app.identifier,
2468
+ );
2469
+
2470
+ // When using .icon format, CFBundleIconName is needed for Assets.car lookup
2471
+ const iconName = config.build.mac?.icons?.endsWith(".icon")
2472
+ ? basename(config.build.mac.icons, ".icon")
2473
+ : null;
2215
2474
 
2216
2475
  InfoPlistContents = `<?xml version="1.0" encoding="UTF-8"?>
2476
+
2217
2477
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2218
2478
  <plist version="1.0">
2219
2479
  <dict>
@@ -2228,7 +2488,9 @@ Categories=Utility;Application;
2228
2488
  <key>CFBundlePackageType</key>
2229
2489
  <string>APPL</string>
2230
2490
  <key>CFBundleIconFile</key>
2231
- <string>AppIcon</string>${usageDescriptions ? "\n" + usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}
2491
+ <string>AppIcon</string>${iconName ? `\n <key>CFBundleIconName</key>\n <string>${iconName}</string>` : ""}${usageDescriptions ? "\n" +
2492
+ usageDescriptions : ""}${urlTypes ? "\n" + urlTypes : ""}${documentTypes ?
2493
+ "\n" + documentTypes : ""}
2232
2494
  </dict>
2233
2495
  </plist>`;
2234
2496
 
@@ -2328,10 +2590,12 @@ Categories=Utility;Application;
2328
2590
  }
2329
2591
 
2330
2592
  // Use rcedit to embed the icon into launcher.exe
2331
- const rcedit = (await import("rcedit")).default;
2332
- await rcedit(bunCliLauncherDestination, {
2333
- icon: iconPath,
2334
- });
2593
+ const { execFileSync } = await import("child_process");
2594
+ const rceditPkgPath = require.resolve("rcedit/package.json");
2595
+ const rceditDir = dirname(rceditPkgPath);
2596
+ const rceditX64 = join(rceditDir, "bin", "rcedit-x64.exe");
2597
+ const rceditExe = existsSync(rceditX64) ? rceditX64 : join(rceditDir, "bin", "rcedit.exe");
2598
+ execFileSync(rceditExe, [bunCliLauncherDestination, "--set-icon", iconPath]);
2335
2599
  console.log(`Successfully embedded icon into launcher.exe`);
2336
2600
 
2337
2601
  // Clean up temp ICO file
@@ -2425,10 +2689,12 @@ Categories=Utility;Application;
2425
2689
  }
2426
2690
 
2427
2691
  // Use rcedit to embed the icon into bun.exe
2428
- const rcedit = (await import("rcedit")).default;
2429
- await rcedit(bunBinaryDestInBundlePath, {
2430
- icon: iconPath,
2431
- });
2692
+ const { execFileSync } = await import("child_process");
2693
+ const rceditPkgPath = require.resolve("rcedit/package.json");
2694
+ const rceditDir = dirname(rceditPkgPath);
2695
+ const rceditX64 = join(rceditDir, "bin", "rcedit-x64.exe");
2696
+ const rceditExe = existsSync(rceditX64) ? rceditX64 : join(rceditDir, "bin", "rcedit.exe");
2697
+ execFileSync(rceditExe, [bunBinaryDestInBundlePath, "--set-icon", iconPath]);
2432
2698
  console.log(`Successfully embedded icon into bun.exe`);
2433
2699
 
2434
2700
  // Clean up temp ICO file
@@ -4610,10 +4876,12 @@ Categories=Utility;Application;
4610
4876
  }
4611
4877
 
4612
4878
  // Use rcedit to embed the icon
4613
- const rcedit = (await import("rcedit")).default;
4614
- await rcedit(outputExePath, {
4615
- icon: iconPath,
4616
- });
4879
+ const { execFileSync } = await import("child_process");
4880
+ const rceditPkgPath = require.resolve("rcedit/package.json");
4881
+ const rceditDir = dirname(rceditPkgPath);
4882
+ const rceditX64 = join(rceditDir, "bin", "rcedit-x64.exe");
4883
+ const rceditExe = existsSync(rceditX64) ? rceditX64 : join(rceditDir, "bin", "rcedit.exe");
4884
+ execFileSync(rceditExe, [outputExePath, "--set-icon", iconPath]);
4617
4885
  console.log(`Successfully embedded icon into ${setupFileName}`);
4618
4886
 
4619
4887
  // Clean up temp ICO file