electrobun 1.17.3-beta.10 → 1.17.3-beta.12

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/README.md CHANGED
@@ -14,16 +14,27 @@
14
14
  ## What is Electrobun?
15
15
 
16
16
  Electrobun aims to be a complete **solution-in-a-box** for building, updating, and shipping ultra fast, tiny, and cross-platform desktop applications written in Typescript.
17
- Under the hood it uses <a href="https://bun.sh">bun</a> to execute the main process and to bundle webview typescript, and has native bindings written in <a href="https://ziglang.org/">zig</a>.
17
+ Under the hood it uses <a href="https://bun.sh">bun</a> to execute the main process and to bundle webview typescript, and has native bindings written in Objc, C++, and several core parts written in <a href="https://ziglang.org/">zig</a>.
18
18
 
19
- Visit <a href="https://blackboard.sh/electrobun/">https://blackboard.sh/electrobun/</a> to see api documentation, guides, and more.
19
+ Visit <a href="https://docs.electrobunny.ai/electrobun/">https://docs.electrobunny.ai/electrobun/</a> to see api documentation, guides, and more.
20
+
21
+ You use it via npm.
22
+
23
+ Don't miss our:
24
+ - self-extracting bundles that use ZSTD compression for more compact distributables as small as 16MB
25
+ - zig optimized BSDIFF implementation that lets you ship tiny app updates as small as 4KB
26
+ - `bundleCEF` flag to bundle and pin Chromium for those that want that tradeoff of consistency over file size
27
+ - `bundleWGPU` that lets you use Bun Typescript -> WGPU to control a native GPU surface without a webview
28
+ - Our Three.js and Babylon.js adapters that work right in Bun
29
+ - Our `<electrobun-webview>` and `<electrobun-wpgu>` html elements that let you composit proper OOPIFs and native GPU surfaces into your UIs
30
+ - so much more.
20
31
 
21
32
  **Project Goals**
22
33
 
23
34
  - Write typescript for the main process and webviews without having to think about it.
24
35
  - Isolation between main and webview processes with fast, typed, easy to implement RPC between them.
25
- - Small self-extracting app bundles ~12MB (when using system webview, most of this is the bun runtime)
26
- - Even smaller app updates as small as 14KB (using bsdiff it only downloads tiny patches between versions)
36
+ - Small self-extracting app bundles ~14MB (when using system webview, most of this is the bun runtime)
37
+ - Even smaller app updates as small as 4KB (using bsdiff it only downloads tiny patches between versions)
27
38
  - Provide everything you need in one tightly integrated workflow to start writing code in 5 minutes and distribute in 10.
28
39
 
29
40
  ## Apps Built with Electrobun
@@ -42,8 +53,10 @@ Visit <a href="https://blackboard.sh/electrobun/">https://blackboard.sh/electrob
42
53
  - [Codex Agents Composer](https://github.com/MrLesk/codex-agents-composer) - desktop app for managing your Codex agents and their skills
43
54
  - [codex-devtools](https://github.com/gulivan/codex-devtools) - desktop inspector for Codex session data; browse conversations, search messages, and analyze agent activity
44
55
  - [Deskdown](https://github.com/guarana-studio/deskdown) - transform any web address into a desktop app in under 20 seconds
56
+ - [Dictate](https://github.com/siddhantparadox/dictate) - Windows dictation app with local and BYOK cloud transcription
45
57
  - [dev-3.0](https://github.com/h0x91b/dev-3.0) - helps you not get lost while managing multiple AI agents across projects
46
58
  - [DOOM](https://github.com/blackboardsh/electrobun-doom) - DOOM implemented in 2 ways: bun -> (c doom -> bundled wgpu) and (full ts port bun -> bundled wgpu)
59
+ - [dotlock](https://github.com/tsconfigdotjson/dotlock) - macOS desktop app for managing `.env` files across your projects
47
60
  - [electrobun-pdf](https://github.com/GijungKim/electrobun-pdf) - local-first PDF & DOCX editor for opening, annotating, and exporting documents without leaving your machine
48
61
  - [electrobun-rms](https://github.com/khanhthanhdev/electrobun-rms) - fast Electrobun desktop app template with React, Tailwind CSS, and Vite
49
62
  - [golb](https://github.com/chrisdadev13/golb) - desktop AI coding workspace built with React, Vite, and Tailwind
@@ -55,6 +68,7 @@ Visit <a href="https://blackboard.sh/electrobun/">https://blackboard.sh/electrob
55
68
  - [md-browse](https://github.com/needle-tools/md-browse) - a markdown-first browser that converts web pages to clean markdown
56
69
  - [Patchline](https://github.com/adwaithks/Patchline) - lightweight desktop Git client for reading patches and line diffs, then staging and committing changes
57
70
  - [peekachu](https://github.com/needle-tools/peekachu) - password manager for AIs; store secrets in your OS keychain and scrub output so AI assistants never see actual values
71
+ - [PiBun](https://github.com/khairold/pibun) - desktop GUI for the Pi coding agent with chat, terminal, git integration, and plugin system
58
72
  - [PLEXI](https://github.com/ianjamesburke/PLEXI) - a multi-dimensional terminal multiplexer for the agentic era
59
73
  - [Prometheus](https://github.com/opensourcectl/prometheus) - desktop utility toolbox for file cleanup, document manipulation, and image processing
60
74
  - [Quiver](https://ataraxy-labs.github.io/quiver/) - desktop app for GitHub PR reviews, merge conflict resolution, and AI commit messages
@@ -67,6 +81,7 @@ Visit <a href="https://blackboard.sh/electrobun/">https://blackboard.sh/electrob
67
81
  - [VibesOS](https://github.com/popmechanic/VibesOS) - A GUI for Claude Code that makes it easy to vibe code simple, un-hackable apps
68
82
  - [VoiceVault](https://github.com/PJH720/VoiceVault) - AI-powered voice recorder with transcription, summarization, and RAG search
69
83
  - [warren](https://github.com/Loa212/warren) - open-source, peer-to-peer terminal mesh for accessing your machines from any device without SSH keys or config files
84
+ - [whatsapp-reminder](https://github.com/FatahChan/whatsapp-reminder) - managed scheduled WhatsApp messages
70
85
 
71
86
  ### Video Demos
72
87
 
@@ -81,8 +96,11 @@ Visit <a href="https://blackboard.sh/electrobun/">https://blackboard.sh/electrob
81
96
  [![Star History Chart](https://api.star-history.com/svg?repos=blackboardsh/electrobun&type=date&legend=top-left&cache=3)](https://www.star-history.com/#blackboardsh/electrobun&type=date&legend=top-left)
82
97
 
83
98
  ## Contributing
99
+ Electrobun is one piece of a vision I'm building. I'm optimizing for focus and execution. Issues and PRs can be used to share ideas, but there should be no expectation that I will review, respond to, or merge them.
100
+
84
101
  Ways to get involved:
85
102
 
103
+ - Read the [Contribution guidelines](./CONTRIBUTING.md)
86
104
  - Follow us on X for updates <a href="https://twitter.com/BlackboardTech">@BlackboardTech</a> and <a href="https://twitter.com/YoavCodes">@YoavCodes</a> or on bluesky <a href="https://bsky.app/profile/yoav.codes">@yoav.codes</a>
87
105
  - Join the conversation on <a href="https://discord.gg/ueKE4tjaCE">Discord</a>
88
106
  - Create and participate in Github issues and discussions
@@ -32,6 +32,16 @@ const platform = getPlatform();
32
32
  const arch = platform === 'win' ? 'x64' : getArch();
33
33
  const binExt = platform === 'win' ? '.exe' : '';
34
34
 
35
+ function getTarCommand() {
36
+ if (platform !== 'win') {
37
+ return 'tar';
38
+ }
39
+
40
+ // Git Bash tar can treat C:\... as a remote path. Force the built-in Windows tar.
41
+ const systemTar = join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe');
42
+ return existsSync(systemTar) ? `"${systemTar}"` : 'tar';
43
+ }
44
+
35
45
  // Paths
36
46
  const electrobunDir = join(__dirname, '..');
37
47
  const cacheDir = join(electrobunDir, '.cache');
@@ -98,7 +108,7 @@ async function ensureCliBinary() {
98
108
  await downloadFile(tarballUrl, tarballPath);
99
109
 
100
110
  // Extract using system tar (available on macOS, Linux, and Windows 10+)
101
- execSync(`tar -xzf "${tarballPath}"`, { cwd: cacheDir, stdio: 'pipe' });
111
+ execSync(`${getTarCommand()} -xzf "${tarballPath}"`, { cwd: cacheDir, stdio: 'pipe' });
102
112
 
103
113
  // Clean up tarball
104
114
  unlinkSync(tarballPath);
@@ -56,6 +56,39 @@ export interface ElectrobunConfig {
56
56
  * ```
57
57
  */
58
58
  urlSchemes?: string[];
59
+
60
+ /**
61
+ * File type associations for the application.
62
+ * Registers document types so the OS can open files with your app
63
+ * (e.g., double-click in Finder, "Open With" menu, drag-to-dock).
64
+ *
65
+ * Platform support:
66
+ * - macOS: Fully supported. Generates CFBundleDocumentTypes in Info.plist.
67
+ * - Windows/Linux: Not yet supported.
68
+ *
69
+ * Files arrive as file:// URLs via the existing "open-url" event:
70
+ * ```typescript
71
+ * Electrobun.events.on("open-url", (e) => {
72
+ * if (e.data.url.startsWith("file://")) {
73
+ * console.log("Opened file:", e.data.url);
74
+ * }
75
+ * });
76
+ * ```
77
+ */
78
+ fileAssociations?: Array<{
79
+ /** File extensions without the leading dot (e.g., ["dotlock", "json"]) */
80
+ ext: string[];
81
+ /** Human-readable name for this file type (e.g., "DotLock Document") */
82
+ name: string;
83
+ /** The app's role for this file type. @default "Viewer" */
84
+ role?: "Editor" | "Viewer" | "Shell" | "None";
85
+ /**
86
+ * Path to an .icns file for this document type (macOS only).
87
+ * The file is automatically copied into the app bundle's Resources folder
88
+ * during the build. Only the filename (without path) is written to Info.plist.
89
+ */
90
+ icon?: string;
91
+ }>;
59
92
  };
60
93
 
61
94
  /**
@@ -278,7 +311,15 @@ export interface ElectrobunConfig {
278
311
  entitlements?: Record<string, boolean | string | string[]>;
279
312
 
280
313
  /**
281
- * Path to .iconset folder containing app icons
314
+ * Path to .iconset folder or .icon file (from Icon Composer)
315
+ * containing app icons.
316
+ *
317
+ * - `.iconset` folders are converted to .icns via iconutil
318
+ * (requires Command Line Tools)
319
+ * - `.icon` files are compiled via actool, producing Assets.car
320
+ * for Liquid Glass on macOS 26+ and a .icns fallback for older
321
+ * macOS versions (requires Xcode)
322
+ *
282
323
  * @default "icon.iconset"
283
324
  */
284
325
  icons?: string;
@@ -0,0 +1,105 @@
1
+ // Contract tests for the bun:ffi APIs that electrobun depends on.
2
+ //
3
+ // Bun bumps don't usually break electrobun, but when they do, the breakage
4
+ // almost always lives in this surface — JSCallback marshaling, FFIType
5
+ // encoding, dlopen behavior. These tests are a tripwire: if a new Bun release
6
+ // breaks any of them, we want to know before cutting an electrobun release,
7
+ // not after a user reports a crash.
8
+ //
9
+ // Skipped on Windows for now since the bun-check workflow runs on Linux and
10
+ // the system library paths differ. If we add a Windows runner later, switch
11
+ // the libc path resolution to include msvcrt/ucrtbase.
12
+
13
+ import { describe, expect, it } from "bun:test";
14
+ import {
15
+ CString,
16
+ FFIType,
17
+ JSCallback,
18
+ dlopen,
19
+ ptr,
20
+ toArrayBuffer,
21
+ type Pointer,
22
+ } from "bun:ffi";
23
+
24
+ const isUnix =
25
+ process.platform === "darwin" || process.platform === "linux";
26
+
27
+ const libcPath =
28
+ process.platform === "darwin" ? "libSystem.B.dylib" : "libc.so.6";
29
+
30
+ (isUnix ? describe : describe.skip)(
31
+ "bun:ffi contract used by electrobun",
32
+ () => {
33
+ it("dlopen + FFIType.cstring + FFIType.u64 (strlen)", () => {
34
+ const lib = dlopen(libcPath, {
35
+ strlen: {
36
+ args: [FFIType.cstring],
37
+ returns: FFIType.u64,
38
+ },
39
+ });
40
+
41
+ const len = lib.symbols.strlen(
42
+ new TextEncoder().encode("hello\0"),
43
+ );
44
+ expect(Number(len)).toBe(5);
45
+
46
+ lib.close();
47
+ });
48
+
49
+ it("ptr + toArrayBuffer round-trip", () => {
50
+ const src = new Uint8Array([1, 2, 3, 4, 5]);
51
+ const back = new Uint8Array(
52
+ toArrayBuffer(ptr(src), 0, src.byteLength),
53
+ );
54
+ expect(Array.from(back)).toEqual([1, 2, 3, 4, 5]);
55
+ });
56
+
57
+ it("CString reads null-terminated bytes", () => {
58
+ const buf = new Uint8Array([72, 105, 33, 0]); // "Hi!\0"
59
+ const s = new CString(ptr(buf));
60
+ expect(s.toString()).toBe("Hi!");
61
+ });
62
+
63
+ it("JSCallback: native invokes JS via function pointer (qsort)", () => {
64
+ const lib = dlopen(libcPath, {
65
+ qsort: {
66
+ args: [
67
+ FFIType.ptr,
68
+ FFIType.u64,
69
+ FFIType.u64,
70
+ FFIType.function,
71
+ ],
72
+ returns: FFIType.void,
73
+ },
74
+ });
75
+
76
+ let comparisonCount = 0;
77
+ const compare = new JSCallback(
78
+ (aPtr: Pointer, bPtr: Pointer) => {
79
+ comparisonCount++;
80
+ const a = new Int32Array(toArrayBuffer(aPtr, 0, 4))[0]!;
81
+ const b = new Int32Array(toArrayBuffer(bPtr, 0, 4))[0]!;
82
+ return a - b;
83
+ },
84
+ {
85
+ args: [FFIType.ptr, FFIType.ptr],
86
+ returns: FFIType.i32,
87
+ },
88
+ );
89
+
90
+ const arr = new Int32Array([5, 2, 8, 1, 3]);
91
+ lib.symbols.qsort(
92
+ ptr(arr),
93
+ BigInt(arr.length),
94
+ 4n,
95
+ compare.ptr,
96
+ );
97
+
98
+ expect(Array.from(arr)).toEqual([1, 2, 3, 5, 8]);
99
+ expect(comparisonCount).toBeGreaterThan(0);
100
+
101
+ compare.close();
102
+ lib.close();
103
+ });
104
+ },
105
+ );
@@ -74,7 +74,7 @@ const randomId = Math.random().toString(36).substring(7);
74
74
 
75
75
  export class BrowserView<T extends RPCWithTransport = RPCWithTransport> {
76
76
  id: number = nextWebviewId++;
77
- ptr!: Pointer;
77
+ ptr: Pointer | null = null;
78
78
  hostWebviewId?: number;
79
79
  windowId!: number;
80
80
  renderer!: "cef" | "native";
@@ -361,8 +361,10 @@ export class BrowserView<T extends RPCWithTransport = RPCWithTransport> {
361
361
  unregisterHandler() {},
362
362
  });
363
363
  this.rpcHandler = undefined;
364
- this.ptr = null as any;
365
- native!.symbols.webviewRemove(ptr);
364
+
365
+ this.rpcHandler = undefined;
366
+ this.ptr = null;
367
+ native!.symbols.webviewRemove(ptr);
366
368
  }
367
369
 
368
370
  static getById(id: number) {
@@ -12,6 +12,11 @@ import { WGPUView } from "./WGPUView";
12
12
  const buildConfig = await BuildConfig.get();
13
13
 
14
14
  export type WindowOptionsType<T = undefined> = {
15
+ trafficLightOffset?: {
16
+ x: number;
17
+ y: number;
18
+ };
19
+ activate?: boolean;
15
20
  title: string;
16
21
  frame: {
17
22
  x: number;
@@ -123,6 +128,7 @@ export class BrowserWindow<T extends RPCWithTransport = RPCWithTransport> {
123
128
  transparent: boolean = false;
124
129
  passthrough: boolean = false;
125
130
  hidden: boolean = false;
131
+ trafficLightOffset: { x: number; y: number } = { x: 0, y: 0 };
126
132
  navigationRules: string | null = null;
127
133
  // Sandbox mode disables RPC and only allows event emission (for untrusted content)
128
134
  sandbox: boolean = false;
@@ -153,6 +159,10 @@ export class BrowserWindow<T extends RPCWithTransport = RPCWithTransport> {
153
159
  this.transparent = options.transparent ?? false;
154
160
  this.passthrough = options.passthrough ?? false;
155
161
  this.hidden = options.hidden ?? false;
162
+ this.trafficLightOffset = {
163
+ x: options.trafficLightOffset?.x ?? 0,
164
+ y: options.trafficLightOffset?.y ?? 0,
165
+ };
156
166
  this.navigationRules = options.navigationRules || null;
157
167
  this.sandbox = options.sandbox ?? false;
158
168
 
@@ -165,6 +175,7 @@ export class BrowserWindow<T extends RPCWithTransport = RPCWithTransport> {
165
175
  titleBarStyle,
166
176
  transparent,
167
177
  hidden,
178
+ activate,
168
179
  }: Partial<WindowOptionsType<T>>) {
169
180
  this.ptr = ffi.request.createWindow({
170
181
  id: this.id,
@@ -208,6 +219,8 @@ export class BrowserWindow<T extends RPCWithTransport = RPCWithTransport> {
208
219
  titleBarStyle: titleBarStyle || "default",
209
220
  transparent: transparent ?? false,
210
221
  hidden: hidden ?? false,
222
+ activate: activate ?? true,
223
+ trafficLightOffset: this.trafficLightOffset,
211
224
  }) as Pointer;
212
225
 
213
226
  BrowserWindowMap[this.id] = this;
@@ -263,12 +276,27 @@ export class BrowserWindow<T extends RPCWithTransport = RPCWithTransport> {
263
276
  return ffi.request.closeWindow({ winId: this.id });
264
277
  }
265
278
 
279
+ activate() {
280
+ return ffi.request.activateWindow({ winId: this.id });
281
+ }
282
+
266
283
  focus() {
267
- return ffi.request.focusWindow({ winId: this.id });
284
+ console.log(
285
+ "[electrobun] BrowserWindow.focus() is deprecated. Use window.activate() instead.",
286
+ );
287
+ return this.activate();
268
288
  }
269
289
 
270
290
  show() {
271
- return ffi.request.focusWindow({ winId: this.id });
291
+ return ffi.request.showWindow({ winId: this.id, activate: true });
292
+ }
293
+
294
+ showInactive() {
295
+ return ffi.request.showWindow({ winId: this.id, activate: false });
296
+ }
297
+
298
+ hide() {
299
+ return ffi.request.hideWindow({ winId: this.id });
272
300
  }
273
301
 
274
302
  minimize() {
@@ -325,6 +353,10 @@ export class BrowserWindow<T extends RPCWithTransport = RPCWithTransport> {
325
353
  return ffi.request.setWindowPosition({ winId: this.id, x, y });
326
354
  }
327
355
 
356
+ setWindowButtonPosition(x: number, y: number) {
357
+ return ffi.request.setWindowButtonPosition({ winId: this.id, x, y });
358
+ }
359
+
328
360
  setSize(width: number, height: number) {
329
361
  this.frame.width = width;
330
362
  this.frame.height = height;
@@ -6,6 +6,11 @@ import { getNextWindowId } from "./windowIds";
6
6
 
7
7
 
8
8
  export type GpuWindowOptionsType = {
9
+ trafficLightOffset?: {
10
+ x: number;
11
+ y: number;
12
+ };
13
+ activate?: boolean;
9
14
  title: string;
10
15
  frame: {
11
16
  x: number;
@@ -54,6 +59,7 @@ export class GpuWindow {
54
59
  title: string = "Electrobun";
55
60
  state: "creating" | "created" = "creating";
56
61
  transparent: boolean = false;
62
+ trafficLightOffset: { x: number; y: number } = { x: 0, y: 0 };
57
63
  frame: {
58
64
  x: number;
59
65
  y: number;
@@ -73,6 +79,10 @@ export class GpuWindow {
73
79
  ? { ...defaultOptions.frame, ...options.frame }
74
80
  : { ...defaultOptions.frame };
75
81
  this.transparent = options.transparent ?? false;
82
+ this.trafficLightOffset = {
83
+ x: options.trafficLightOffset?.x ?? 0,
84
+ y: options.trafficLightOffset?.y ?? 0,
85
+ };
76
86
 
77
87
  this.init(options);
78
88
  }
@@ -81,6 +91,7 @@ export class GpuWindow {
81
91
  styleMask,
82
92
  titleBarStyle,
83
93
  transparent,
94
+ activate,
84
95
  }: Partial<GpuWindowOptionsType>) {
85
96
  this.ptr = ffi.request.createWindow({
86
97
  id: this.id,
@@ -123,6 +134,8 @@ export class GpuWindow {
123
134
  },
124
135
  titleBarStyle: titleBarStyle || "default",
125
136
  transparent: transparent ?? false,
137
+ activate: activate ?? true,
138
+ trafficLightOffset: this.trafficLightOffset,
126
139
  }) as Pointer;
127
140
 
128
141
  GpuWindowMap[this.id] = this;
@@ -160,12 +173,23 @@ export class GpuWindow {
160
173
  return ffi.request.closeWindow({ winId: this.id });
161
174
  }
162
175
 
176
+ activate() {
177
+ return ffi.request.activateWindow({ winId: this.id });
178
+ }
179
+
163
180
  focus() {
164
- return ffi.request.focusWindow({ winId: this.id });
181
+ console.log(
182
+ "[electrobun] GpuWindow.focus() is deprecated. Use window.activate() instead.",
183
+ );
184
+ return this.activate();
165
185
  }
166
186
 
167
187
  show() {
168
- return ffi.request.focusWindow({ winId: this.id });
188
+ return ffi.request.showWindow({ winId: this.id, activate: true });
189
+ }
190
+
191
+ showInactive() {
192
+ return ffi.request.showWindow({ winId: this.id, activate: false });
169
193
  }
170
194
 
171
195
  minimize() {
@@ -214,6 +238,10 @@ export class GpuWindow {
214
238
  return ffi.request.setWindowPosition({ winId: this.id, x, y });
215
239
  }
216
240
 
241
+ setWindowButtonPosition(x: number, y: number) {
242
+ return ffi.request.setWindowButtonPosition({ winId: this.id, x, y });
243
+ }
244
+
217
245
  setSize(width: number, height: number) {
218
246
  this.frame.width = width;
219
247
  this.frame.height = height;
@@ -1,5 +1,5 @@
1
1
  import { resolve } from "path";
2
2
 
3
- const RESOURCES_FOLDER = resolve("../Resources/");
3
+ export const RESOURCES_FOLDER = resolve("../Resources/");
4
4
 
5
5
  export const VIEWS_FOLDER = resolve(RESOURCES_FOLDER, "app/views");
@@ -179,7 +179,7 @@ const Updater = {
179
179
  // todo: allow switching channels, by default will check the current channel
180
180
  checkForUpdate: async () => {
181
181
  emitStatus("checking", "Checking for updates...");
182
- const localInfo = await Updater.getLocallocalInfo();
182
+ const localInfo = await Updater.getLocalInfo();
183
183
 
184
184
  if (localInfo.channel === "dev") {
185
185
  emitStatus("no-update", "Dev channel - updates disabled", {
@@ -283,7 +283,7 @@ const Updater = {
283
283
  await Updater.channelBucketUrl(); // Ensure localInfo is loaded
284
284
  const appFileName = localInfo.name;
285
285
 
286
- let currentHash = (await Updater.getLocallocalInfo()).hash;
286
+ let currentHash = (await Updater.getLocalInfo()).hash;
287
287
  let latestHash = (await Updater.checkForUpdate()).hash;
288
288
 
289
289
  const extractionFolder = join(appDataFolder, "self-extraction");
@@ -1074,14 +1074,14 @@ del "%~f0"
1074
1074
  },
1075
1075
 
1076
1076
  channelBucketUrl: async () => {
1077
- await Updater.getLocallocalInfo();
1077
+ await Updater.getLocalInfo();
1078
1078
  // With flat prefix-based naming, channelBucketUrl is just the baseUrl
1079
1079
  // Users can also use Updater.localInfo.baseUrl() directly
1080
1080
  return localInfo.baseUrl;
1081
1081
  },
1082
1082
 
1083
1083
  appDataFolder: async () => {
1084
- await Updater.getLocallocalInfo();
1084
+ await Updater.getLocalInfo();
1085
1085
  // Use identifier + channel for the app data folder
1086
1086
  // e.g., ~/Library/Application Support/sh.blackboard.myapp/canary/
1087
1087
  const appDataFolder = join(
@@ -1096,20 +1096,20 @@ del "%~f0"
1096
1096
  // TODO: consider moving this from "Updater.localInfo" to "BuildVars"
1097
1097
  localInfo: {
1098
1098
  version: async () => {
1099
- return (await Updater.getLocallocalInfo()).version;
1099
+ return (await Updater.getLocalInfo()).version;
1100
1100
  },
1101
1101
  hash: async () => {
1102
- return (await Updater.getLocallocalInfo()).hash;
1102
+ return (await Updater.getLocalInfo()).hash;
1103
1103
  },
1104
1104
  channel: async () => {
1105
- return (await Updater.getLocallocalInfo()).channel;
1105
+ return (await Updater.getLocalInfo()).channel;
1106
1106
  },
1107
1107
  baseUrl: async () => {
1108
- return (await Updater.getLocallocalInfo()).baseUrl;
1108
+ return (await Updater.getLocalInfo()).baseUrl;
1109
1109
  },
1110
1110
  },
1111
1111
 
1112
- getLocallocalInfo: async () => {
1112
+ getLocalInfo: async () => {
1113
1113
  if (localInfo) {
1114
1114
  return localInfo;
1115
1115
  }
@@ -1122,8 +1122,15 @@ del "%~f0"
1122
1122
  console.error("Failed to read version.json", error);
1123
1123
  localInfo = { identifier: "", channel: "", version: "", hash: "", baseUrl: "", name: "" };
1124
1124
  return localInfo;
1125
- }
1126
- },
1127
- };
1125
+ }
1126
+ },
1127
+ getLocallocalInfo: async () => {
1128
+ console.error(
1129
+ "[Electrobun] Updater.getLocallocalInfo() is deprecated. Use Updater.getLocalInfo() instead.",
1130
+ );
1131
+
1132
+ return Updater.getLocalInfo();
1133
+ },
1134
+ };
1128
1135
 
1129
1136
  export { Updater };
@@ -229,6 +229,7 @@ export {
229
229
  Screen,
230
230
  Session,
231
231
  WGPUBridge,
232
+
232
233
  BuildConfig,
233
234
  };
234
235
 
@@ -247,6 +248,7 @@ const Electrobun = {
247
248
  Screen,
248
249
  Session,
249
250
  WGPUBridge,
251
+
250
252
  BuildConfig,
251
253
  events: electobunEventEmmitter,
252
254
  PATHS,
@@ -97,6 +97,8 @@ export const native = (() => {
97
97
  FFIType.u32, // styleMask
98
98
  FFIType.cstring, // titleBarStyle
99
99
  FFIType.bool, // transparent
100
+ FFIType.f64, // trafficLightOffsetX
101
+ FFIType.f64, // trafficLightOffsetY
100
102
  FFIType.function, // closeHandler
101
103
  FFIType.function, // moveHandler
102
104
  FFIType.function, // resizeHandler
@@ -116,9 +118,20 @@ export const native = (() => {
116
118
  showWindow: {
117
119
  args: [
118
120
  FFIType.ptr, // window ptr
121
+ FFIType.bool, // activate
119
122
  ],
120
123
  returns: FFIType.void,
121
124
  },
125
+ activateWindow: {
126
+ args: [
127
+ FFIType.ptr, // window ptr
128
+ ],
129
+ returns: FFIType.void,
130
+ },
131
+ hideWindow: {
132
+ args: [FFIType.ptr],
133
+ returns: FFIType.void,
134
+ },
122
135
  closeWindow: {
123
136
  args: [
124
137
  FFIType.ptr, // window ptr
@@ -173,14 +186,18 @@ export const native = (() => {
173
186
  args: [FFIType.ptr],
174
187
  returns: FFIType.bool,
175
188
  },
176
- setWindowPosition: {
177
- args: [FFIType.ptr, FFIType.f64, FFIType.f64],
178
- returns: FFIType.void,
179
- },
180
- setWindowSize: {
181
- args: [FFIType.ptr, FFIType.f64, FFIType.f64],
182
- returns: FFIType.void,
183
- },
189
+ setWindowPosition: {
190
+ args: [FFIType.ptr, FFIType.f64, FFIType.f64],
191
+ returns: FFIType.void,
192
+ },
193
+ setWindowButtonPosition: {
194
+ args: [FFIType.ptr, FFIType.f64, FFIType.f64],
195
+ returns: FFIType.void,
196
+ },
197
+ setWindowSize: {
198
+ args: [FFIType.ptr, FFIType.f64, FFIType.f64],
199
+ returns: FFIType.void,
200
+ },
184
201
  setWindowFrame: {
185
202
  args: [FFIType.ptr, FFIType.f64, FFIType.f64, FFIType.f64, FFIType.f64],
186
203
  returns: FFIType.void,
@@ -835,6 +852,11 @@ const _ffiImpl = {
835
852
  titleBarStyle: string;
836
853
  transparent: boolean;
837
854
  hidden?: boolean;
855
+ activate?: boolean;
856
+ trafficLightOffset?: {
857
+ x: number;
858
+ y: number;
859
+ };
838
860
  }): FFIType.ptr => {
839
861
  const {
840
862
  id,
@@ -858,6 +880,8 @@ const _ffiImpl = {
858
880
  titleBarStyle,
859
881
  transparent,
860
882
  hidden = false,
883
+ activate = true,
884
+ trafficLightOffset = { x: 0, y: 0 },
861
885
  } = params;
862
886
 
863
887
  const styleMask = native_.symbols.getWindowStyle(
@@ -886,6 +910,8 @@ const _ffiImpl = {
886
910
  // style
887
911
  toCString(titleBarStyle),
888
912
  transparent,
913
+ trafficLightOffset.x,
914
+ trafficLightOffset.y,
889
915
  // callbacks
890
916
  windowCloseCallback,
891
917
  windowMoveCallback,
@@ -901,7 +927,7 @@ const _ffiImpl = {
901
927
 
902
928
  native_.symbols.setWindowTitle(windowPtr, toCString(title));
903
929
  if (!hidden) {
904
- native_.symbols.showWindow(windowPtr);
930
+ native_.symbols.showWindow(windowPtr, activate);
905
931
  }
906
932
 
907
933
  return windowPtr;
@@ -930,15 +956,37 @@ const _ffiImpl = {
930
956
  // Note: Cleanup of BrowserWindowMap happens in the windowCloseCallback
931
957
  },
932
958
 
933
- focusWindow: (params: { winId: number }) => {
959
+ showWindow: (params: { winId: number; activate?: boolean }) => {
960
+ const { winId } = params;
961
+ const windowPtr = getWindowPtr(winId);
962
+
963
+ if (!windowPtr) {
964
+ throw `Can't show window. Window no longer exists`;
965
+ }
966
+
967
+ native_.symbols.showWindow(windowPtr, params.activate ?? true);
968
+ },
969
+
970
+ activateWindow: (params: { winId: number }) => {
934
971
  const { winId } = params;
935
972
  const windowPtr = getWindowPtr(winId);
936
973
 
937
974
  if (!windowPtr) {
938
- throw `Can't focus window. Window no longer exists`;
975
+ throw `Can't activate window. Window no longer exists`;
939
976
  }
940
977
 
941
- native_.symbols.showWindow(windowPtr);
978
+ native_.symbols.activateWindow(windowPtr);
979
+ },
980
+
981
+ hideWindow: (params: { winId: number }) => {
982
+ const { winId } = params;
983
+ const windowPtr = getWindowPtr(winId);
984
+
985
+ if (!windowPtr) {
986
+ throw `Can't hide window. Window no longer exists`;
987
+ }
988
+
989
+ native_.symbols.hideWindow(windowPtr);
942
990
  },
943
991
 
944
992
  minimizeWindow: (params: { winId: number }) => {
@@ -1079,21 +1127,32 @@ const _ffiImpl = {
1079
1127
  return native_.symbols.isWindowVisibleOnAllWorkspaces(windowPtr);
1080
1128
  },
1081
1129
 
1082
- setWindowPosition: (params: { winId: number; x: number; y: number }) => {
1083
- const { winId, x, y } = params;
1084
- const windowPtr = getWindowPtr(winId);
1130
+ setWindowPosition: (params: { winId: number; x: number; y: number }) => {
1131
+ const { winId, x, y } = params;
1132
+ const windowPtr = getWindowPtr(winId);
1085
1133
 
1086
- if (!windowPtr) {
1087
- throw `Can't set window position. Window no longer exists`;
1088
- }
1134
+ if (!windowPtr) {
1135
+ throw `Can't set window position. Window no longer exists`;
1136
+ }
1089
1137
 
1090
- native_.symbols.setWindowPosition(windowPtr, x, y);
1091
- },
1138
+ native_.symbols.setWindowPosition(windowPtr, x, y);
1139
+ },
1092
1140
 
1093
- setWindowSize: (params: {
1094
- winId: number;
1095
- width: number;
1096
- height: number;
1141
+ setWindowButtonPosition: (params: { winId: number; x: number; y: number }) => {
1142
+ const { winId, x, y } = params;
1143
+ const windowPtr = getWindowPtr(winId);
1144
+
1145
+ if (!windowPtr) {
1146
+ throw `Can't set window button position. Window no longer exists`;
1147
+ }
1148
+
1149
+ native_.symbols.setWindowButtonPosition(windowPtr, x, y);
1150
+ },
1151
+
1152
+ setWindowSize: (params: {
1153
+ winId: number;
1154
+ width: number;
1155
+ height: number;
1097
1156
  }) => {
1098
1157
  const { winId, width, height } = params;
1099
1158
  const windowPtr = getWindowPtr(winId);
@@ -1814,6 +1873,7 @@ export const WGPUBridge = {
1814
1873
  },
1815
1874
  };
1816
1875
 
1876
+
1817
1877
  // Worker management. Move to a different file
1818
1878
  process.on("uncaughtException", (err) => {
1819
1879
  console.error("Uncaught exception in worker:", err);
@@ -291,43 +291,20 @@ const WGPU_LIB_NAMES: Record<string, string[]> = {
291
291
  };
292
292
 
293
293
  function findWgpuLibraryPath(): string | null {
294
- const debug = process.env["ELECTROBUN_WGPU_DEBUG"] === "1";
295
- const envPath = process.env["ELECTROBUN_WGPU_PATH"];
296
- if (envPath && existsSync(envPath)) {
297
- if (debug) console.log("[WGPU] using ELECTROBUN_WGPU_PATH:", envPath);
298
- return envPath;
299
- } else if (envPath && debug) {
300
- console.warn("[WGPU] ELECTROBUN_WGPU_PATH not found:", envPath);
301
- }
294
+ const envPath = process.env['ELECTROBUN_WGPU_PATH'];
295
+ if (envPath && existsSync(envPath)) return envPath;
302
296
 
303
297
  const names = WGPU_LIB_NAMES[process.platform] ?? ["libwebgpu_dawn." + suffix];
304
298
  for (const name of names) {
305
299
  const cwdCandidate = join(process.cwd(), name);
306
- if (existsSync(cwdCandidate)) {
307
- if (debug) console.log("[WGPU] found in cwd:", cwdCandidate);
308
- return cwdCandidate;
309
- }
300
+ if (existsSync(cwdCandidate)) return cwdCandidate;
310
301
  const execDir = dirname(process.execPath);
311
302
  const macCandidate = join(execDir, "..", "MacOS", name);
312
- if (existsSync(macCandidate)) {
313
- if (debug) console.log("[WGPU] found in bundle MacOS:", macCandidate);
314
- return macCandidate;
315
- }
303
+ if (existsSync(macCandidate)) return macCandidate;
316
304
  const resCandidate = join(execDir, "..", "Resources", name);
317
- if (existsSync(resCandidate)) {
318
- if (debug) console.log("[WGPU] found in bundle Resources:", resCandidate);
319
- return resCandidate;
320
- }
305
+ if (existsSync(resCandidate)) return resCandidate;
321
306
  const execCandidate = join(execDir, name);
322
- if (existsSync(execCandidate)) {
323
- if (debug) console.log("[WGPU] found next to exec:", execCandidate);
324
- return execCandidate;
325
- }
326
- }
327
-
328
- if (debug) {
329
- console.warn("[WGPU] not found. platform:", process.platform, "execPath:", process.execPath, "cwd:", process.cwd());
330
- console.warn("[WGPU] names:", names);
307
+ if (existsSync(execCandidate)) return execCandidate;
331
308
  }
332
309
 
333
310
  return null;
@@ -352,9 +329,7 @@ export const native = (() => {
352
329
  symbols: lib.symbols,
353
330
  close: lib.close,
354
331
  };
355
- } catch (err) {
356
- const message = err instanceof Error ? err.message : String(err);
357
- console.warn("[WGPU] dlopen failed:", libPath, message);
332
+ } catch {
358
333
  return {
359
334
  available: false,
360
335
  path: libPath,
@@ -811,11 +811,6 @@ function makeCommandBufferArray(cmdPtr: number) {
811
811
  return { buffer, ptr: ptr(buffer) };
812
812
  }
813
813
 
814
- function makeSurfaceTexture() {
815
- const buffer = new ArrayBuffer(24);
816
- return { buffer, view: new DataView(buffer), ptr: ptr(buffer) };
817
- }
818
-
819
814
  function makeSurfaceCapabilities() {
820
815
  const buffer = new ArrayBuffer(64);
821
816
  const view = new DataView(buffer);
@@ -1,3 +1,3 @@
1
1
  // Default Bun version shipped with this Electrobun release.
2
2
  // All platforms use the same version. Update this when bumping Bun.
3
- export const BUN_VERSION = "1.3.11";
3
+ export const BUN_VERSION = "1.3.13";
@@ -1,5 +1,5 @@
1
1
  // Default CEF version shipped with this Electrobun release.
2
2
  // All platforms use the same version. Update this single pair when bumping CEF.
3
- export const CEF_VERSION = `145.0.23+g3e7fe1c`;
4
- export const CHROMIUM_VERSION = `145.0.7632.68`;
3
+ export const CEF_VERSION = `147.0.10+gd58e84d`;
4
+ export const CHROMIUM_VERSION = `147.0.7727.118`;
5
5
  export const DEFAULT_CEF_VERSION_STRING = `${CEF_VERSION}+chromium-${CHROMIUM_VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "electrobun",
3
- "version": "1.17.3-beta.10",
3
+ "version": "1.17.3-beta.12",
4
4
  "description": "Build ultra fast, tiny, and cross-platform desktop apps with Typescript.",
5
5
  "license": "MIT",
6
6
  "author": "Blackboard Technologies Inc.",
@@ -46,7 +46,7 @@
46
46
  "push:stable": "bun run typecheck && bun scripts/push-version.js stable",
47
47
  "build:push:artifacts": "bun scripts/build-and-upload-artifacts.js",
48
48
  "test": "bun install && bun build:dev && bun build:cli && cd ../tests && bun install && bun build:dev && bun start",
49
- "test:unit": "bun test src/shared",
49
+ "test:unit": "bun test src/shared src/bun",
50
50
  "bump-cef": "bun scripts/update-cef-version.ts"
51
51
  },
52
52
  "devDependencies": {
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