electrobun 1.16.0 → 1.17.0-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.
package/README.md CHANGED
@@ -27,9 +27,43 @@ Visit <a href="https://blackboard.sh/electrobun/">https://blackboard.sh/electrob
27
27
  - Provide everything you need in one tightly integrated workflow to start writing code in 5 minutes and distribute in 10.
28
28
 
29
29
  ## Apps Built with Electrobun
30
+ - [24agents](https://github.com/jhsu/24agents) - Hyperprompter
31
+ - [act-track-ai](https://github.com/IrdanGu/act-track-ai) - personal desktop productivity tracker
32
+ - [Agents Council](https://github.com/MrLesk/agents-council) - agent-to-agent MCP communication tool for feedback requests
33
+ - [ai-wrapped](https://github.com/gulivan/ai-wrapped) - Wrapped-style desktop dashboard for your AI coding agent activity
30
34
  - [Audio TTS](https://github.com/blackboardsh/audio-tts) - desktop text-to-speech app using Qwen3-TTS for voice design, cloning, and generation
35
+ - [aueio-player-desktop](https://github.com/tuomashatakka/aueio-player-desktop) - beautiful, minimal cross-platform audio player
36
+ - [bestdiff](https://github.com/tesmond/bestdiff) - a git diff checker with curved connectors
37
+ - [BuddyWriter](https://github.com/OxFrancesco/BuddyWriter) - BuddyWriter desktop and mobile apps
38
+ - [burns](https://github.com/l3wi/burns) - a Smithers manager
39
+ - [cbx-tool](https://github.com/jebin2/cbx-tool) - desktop app for reading and editing comic book archives (.cbz/.cbr)
31
40
  - [Co(lab)](https://blackboard.sh/colab/) - a hybrid web browser + code editor for deep work
41
+ - [codlogs](https://github.com/tobitege/codlogs) - search and export local Codex sessions via CLI or desktop app
42
+ - [Codex Agents Composer](https://github.com/MrLesk/codex-agents-composer) - desktop app for managing your Codex agents and their skills
43
+ - [codex-devtools](https://github.com/gulivan/codex-devtools) - desktop inspector for Codex session data; browse conversations, search messages, and analyze agent activity
44
+ - [Deskdown](https://github.com/guarana-studio/deskdown) - transform any web address into a desktop app in under 20 seconds
45
+ - [dev-3.0](https://github.com/h0x91b/dev-3.0) - helps you not get lost while managing multiple AI agents across projects
32
46
  - [DOOM](https://github.com/blackboardsh/electrobun-doom) - DOOM implemented in 2 ways: bun -> (c doom -> bundled wgpu) and (full ts port bun -> bundled wgpu)
47
+ - [electrobun-rms](https://github.com/khanhthanhdev/electrobun-rms) - fast Electrobun desktop app template with React, Tailwind CSS, and Vite
48
+ - [golb](https://github.com/chrisdadev13/golb) - desktop AI coding workspace built with React, Vite, and Tailwind
49
+ - [GOG Achievements GUI](https://github.com/timendum/gog-achievements-gui) - desktop app for managing GOG achievements
50
+ - [groov](https://github.com/laurenzcodes/groov) - desktop audio deck monitor
51
+ - [Guerilla Glass](https://github.com/okikeSolutions/guerillaglass) - open-source cross-platform creator studio for fast Record -> Edit -> Deliver workflows
52
+ - [Marginalia](https://github.com/lars-hoeijmans/Marginalia) - a simple note taking app
53
+ - [md-browse](https://github.com/needle-tools/md-browse) - a markdown-first browser that converts web pages to clean markdown
54
+ - [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
55
+ - [PLEXI](https://github.com/ianjamesburke/PLEXI) - a multi-dimensional terminal multiplexer for the agentic era
56
+ - [Prometheus](https://github.com/opensourcectl/prometheus) - desktop utility toolbox for file cleanup, document manipulation, and image processing
57
+ - [Quiver](https://ataraxy-labs.github.io/quiver/) - desktop app for GitHub PR reviews, merge conflict resolution, and AI commit messages
58
+ - [remotecode.io](https://github.com/samuelfaj/remotecode.io) - continue local AI coding sessions (Claude Code or Codex) from your mobile device
59
+ - [sirene](https://github.com/KevinBonnoron/sirene) - self-hosted multi-backend text-to-speech platform with voice cloning
60
+ - [StoryForge](https://github.com/vrrdnt/StoryForge) - desktop app for Vintage Story players to switch between game versions, modpacks, servers, and accounts
61
+ - [Tensamin Client](https://github.com/Tensamin/Client) - web, desktop, and mobile app for accessing Tensamin
62
+ - [tokenpass-desktop](https://github.com/b-open-io/tokenpass-desktop) - desktop app that runs the Sigma Identity stack locally for Bitcoin-backed authentication
63
+ - [typsmthng-desktop](https://github.com/aaditagrawal/typsmthng-desktop) - experimental desktop typing application
64
+ - [VibesOS](https://github.com/popmechanic/VibesOS) - A GUI for Claude Code that makes it easy to vibe code simple, un-hackable apps
65
+ - [VoiceVault](https://github.com/PJH720/VoiceVault) - AI-powered voice recorder with transcription, summarization, and RAG search
66
+ - [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
33
67
 
34
68
  ### Video Demos
35
69
 
@@ -41,12 +75,12 @@ Visit <a href="https://blackboard.sh/electrobun/">https://blackboard.sh/electrob
41
75
 
42
76
  ## Star History
43
77
 
44
- [![Star History Chart](https://api.star-history.com/svg?repos=blackboardsh/electrobun&type=date&legend=top-left&cache=2)](https://www.star-history.com/#blackboardsh/electrobun&type=date&legend=top-left)
78
+ [![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)
45
79
 
46
80
  ## Contributing
47
81
  Ways to get involved:
48
82
 
49
- - Follow us on X for updates <a href="https://twitter.com/BlackboardTech">@BlackboardTech</a> or <a href="https://bsky.app/profile/yoav.codes">@yoav.codes</a>
83
+ - 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>
50
84
  - Join the conversation on <a href="https://discord.gg/ueKE4tjaCE">Discord</a>
51
85
  - Create and participate in Github issues and discussions
52
86
  - Let me know what you're building with Electrobun
@@ -198,6 +198,22 @@ export interface ElectrobunConfig {
198
198
  */
199
199
  watchIgnore?: string[];
200
200
 
201
+ /**
202
+ * Carrot build configuration.
203
+ * When present, the build also produces a carrot artifact alongside the standalone app.
204
+ * Set `carrotOnly: true` to skip the standalone app build entirely.
205
+ */
206
+ carrot?: {
207
+ id: string;
208
+ name: string;
209
+ description?: string;
210
+ mode?: "window" | "background";
211
+ carrotOnly?: boolean;
212
+ permissions?: Record<string, unknown>;
213
+ dependencies?: Record<string, string>;
214
+ remoteUIs?: Record<string, { entrypoint: string; [key: string]: unknown }>;
215
+ };
216
+
201
217
  /**
202
218
  * macOS-specific build configuration
203
219
  */
@@ -9,7 +9,11 @@ import {
9
9
  } from "../../shared/rpc.js";
10
10
  import { Updater } from "./Updater";
11
11
  import { BuildConfig } from "./BuildConfig";
12
- import { rpcPort, sendMessageToWebviewViaSocket } from "./Socket";
12
+ import {
13
+ rpcPort,
14
+ sendMessageToWebviewViaSocket,
15
+ removeSocketForWebview,
16
+ } from "./Socket";
13
17
  import { randomBytes } from "crypto";
14
18
  import { type Pointer } from "bun:ffi";
15
19
 
@@ -102,6 +106,7 @@ export class BrowserView<T extends RPCWithTransport = RPCWithTransport> {
102
106
  sandbox: boolean = false;
103
107
  startTransparent: boolean = false;
104
108
  startPassthrough: boolean = false;
109
+ isRemoved: boolean = false;
105
110
 
106
111
  constructor(options: Partial<BrowserViewOptions<T>> = defaultOptions) {
107
112
  // const rpc = options.rpc;
@@ -211,13 +216,16 @@ export class BrowserView<T extends RPCWithTransport = RPCWithTransport> {
211
216
  // so we have to chunk it
212
217
  // TODO: is this still needed after switching from named pipes
213
218
  executeJavascript(js: string) {
219
+ if (!this.ptr || this.isRemoved) {
220
+ return;
221
+ }
214
222
  ffi.request.evaluateJavascriptWithNoCompletion({ id: this.id, js });
215
223
  }
216
224
 
217
225
  loadURL(url: string) {
218
226
  console.log(`DEBUG: loadURL called for webview ${this.id}: ${url}`);
219
227
  this.url = url;
220
- native.symbols.loadURLInWebView(this.ptr, toCString(this.url));
228
+ native!.symbols.loadURLInWebView(this.ptr, toCString(this.url));
221
229
  }
222
230
 
223
231
  loadHTML(html: string) {
@@ -229,18 +237,18 @@ export class BrowserView<T extends RPCWithTransport = RPCWithTransport> {
229
237
 
230
238
  if (this.renderer === "cef") {
231
239
  // For CEF, store HTML content in native map and use scheme handler
232
- native.symbols.setWebviewHTMLContent(this.id, toCString(html));
240
+ native!.symbols.setWebviewHTMLContent(this.id, toCString(html));
233
241
  this.loadURL("views://internal/index.html");
234
242
  } else {
235
243
  // For WKWebView, load HTML content directly
236
- native.symbols.loadHTMLInWebView(this.ptr, toCString(html));
244
+ native!.symbols.loadHTMLInWebView(this.ptr, toCString(html));
237
245
  }
238
246
  }
239
247
 
240
248
  setNavigationRules(rules: string[]) {
241
249
  this.navigationRules = JSON.stringify(rules);
242
250
  const rulesJson = JSON.stringify(rules);
243
- native.symbols.setWebviewNavigationRules(this.ptr, toCString(rulesJson));
251
+ native!.symbols.setWebviewNavigationRules(this.ptr, toCString(rulesJson));
244
252
  }
245
253
 
246
254
  findInPage(
@@ -249,7 +257,7 @@ export class BrowserView<T extends RPCWithTransport = RPCWithTransport> {
249
257
  ) {
250
258
  const forward = options?.forward ?? true;
251
259
  const matchCase = options?.matchCase ?? false;
252
- native.symbols.webviewFindInPage(
260
+ native!.symbols.webviewFindInPage(
253
261
  this.ptr,
254
262
  toCString(searchText),
255
263
  forward,
@@ -258,19 +266,19 @@ export class BrowserView<T extends RPCWithTransport = RPCWithTransport> {
258
266
  }
259
267
 
260
268
  stopFindInPage() {
261
- native.symbols.webviewStopFind(this.ptr);
269
+ native!.symbols.webviewStopFind(this.ptr);
262
270
  }
263
271
 
264
272
  openDevTools() {
265
- native.symbols.webviewOpenDevTools(this.ptr);
273
+ native!.symbols.webviewOpenDevTools(this.ptr);
266
274
  }
267
275
 
268
276
  closeDevTools() {
269
- native.symbols.webviewCloseDevTools(this.ptr);
277
+ native!.symbols.webviewCloseDevTools(this.ptr);
270
278
  }
271
279
 
272
280
  toggleDevTools() {
273
- native.symbols.webviewToggleDevTools(this.ptr);
281
+ native!.symbols.webviewToggleDevTools(this.ptr);
274
282
  }
275
283
 
276
284
  /**
@@ -278,7 +286,7 @@ export class BrowserView<T extends RPCWithTransport = RPCWithTransport> {
278
286
  * @param zoomLevel - The zoom level (1.0 = 100%, 1.5 = 150%, etc.)
279
287
  */
280
288
  setPageZoom(zoomLevel: number) {
281
- native.symbols.webviewSetPageZoom(this.ptr, zoomLevel);
289
+ native!.symbols.webviewSetPageZoom(this.ptr, zoomLevel);
282
290
  }
283
291
 
284
292
  /**
@@ -286,7 +294,7 @@ export class BrowserView<T extends RPCWithTransport = RPCWithTransport> {
286
294
  * @returns The current zoom level (1.0 = 100%)
287
295
  */
288
296
  getPageZoom(): number {
289
- return native.symbols.webviewGetPageZoom(this.ptr) as number;
297
+ return native!.symbols.webviewGetPageZoom(this.ptr) as number;
290
298
  }
291
299
 
292
300
  // todo (yoav): move this to a class that also has off, append, prepend, etc.
@@ -315,6 +323,9 @@ export class BrowserView<T extends RPCWithTransport = RPCWithTransport> {
315
323
 
316
324
  return {
317
325
  send(message: any) {
326
+ if (!that.ptr || that.isRemoved) {
327
+ return;
328
+ }
318
329
  const sentOverSocket = sendMessageToWebviewViaSocket(that.id, message);
319
330
 
320
331
  if (!sentOverSocket) {
@@ -327,14 +338,31 @@ export class BrowserView<T extends RPCWithTransport = RPCWithTransport> {
327
338
  }
328
339
  },
329
340
  registerHandler(handler: (msg: unknown) => void) {
341
+ if (that.isRemoved) {
342
+ return;
343
+ }
330
344
  that.rpcHandler = handler;
331
345
  },
332
346
  };
333
347
  };
334
348
 
335
349
  remove() {
336
- native.symbols.webviewRemove(this.ptr);
350
+ if (!this.ptr || this.isRemoved) {
351
+ return;
352
+ }
353
+ const ptr = this.ptr;
354
+ this.isRemoved = true;
355
+ // Drop JS-side references first so late callbacks cannot target a stale view.
337
356
  delete BrowserViewMap[this.id];
357
+ removeSocketForWebview(this.id);
358
+ this.rpc?.setTransport({
359
+ send() {},
360
+ registerHandler() {},
361
+ unregisterHandler() {},
362
+ });
363
+ this.rpcHandler = undefined;
364
+ this.ptr = null as any;
365
+ native!.symbols.webviewRemove(ptr);
338
366
  }
339
367
 
340
368
  static getById(id: number) {
@@ -47,6 +47,14 @@ export const socketMap: {
47
47
  };
48
48
  } = {};
49
49
 
50
+ export const removeSocketForWebview = (webviewId: number) => {
51
+ const rpc = socketMap[webviewId];
52
+ if (!rpc) return;
53
+
54
+ rpc.socket = null;
55
+ delete socketMap[webviewId];
56
+ };
57
+
50
58
  const startRPCServer = () => {
51
59
  const startPort = 50000;
52
60
  const endPort = 65535;
@@ -1119,11 +1119,9 @@ del "%~f0"
1119
1119
  localInfo = await Bun.file(`../${resourcesDir}/version.json`).json();
1120
1120
  return localInfo;
1121
1121
  } catch (error) {
1122
- // Handle the error
1123
1122
  console.error("Failed to read version.json", error);
1124
-
1125
- // Then rethrow so the app crashes
1126
- throw error;
1123
+ localInfo = { identifier: "", channel: "", version: "", hash: "", baseUrl: "", name: "" };
1124
+ return localInfo;
1127
1125
  }
1128
1126
  },
1129
1127
  };
@@ -136,25 +136,27 @@ export const quit = () => {
136
136
  return;
137
137
  }
138
138
 
139
- // Phase 1: Signal the native event loop to stop
140
- native.symbols.stopEventLoop();
141
- // Phase 2: Wait for native shutdown to complete (CefShutdown etc.)
142
- // This blocks the worker thread until the main thread finishes cleanup.
143
- native.symbols.waitForShutdownComplete(5000);
144
- // Phase 3: Now safe to exit - CEF is fully shut down.
145
- // Use _exit() via forceExit to guarantee termination. process.exit() from
146
- // a Worker thread can fail to terminate when the main thread is blocked in FFI.
147
- native.symbols.forceExit(0);
139
+ if (native) {
140
+ native.symbols.stopEventLoop();
141
+ native.symbols.waitForShutdownComplete(5000);
142
+ native.symbols.forceExit(0);
143
+ } else {
144
+ process.exit(0);
145
+ }
148
146
  };
149
147
 
150
148
  // Override process.exit so that calling it triggers proper native cleanup
149
+ const _originalProcessExit = process.exit;
151
150
  process.exit = ((code?: number) => {
152
- if (isQuitting) {
153
- // Already in quit sequence — force-terminate immediately
154
- native.symbols.forceExit(code ?? 0);
155
- return;
151
+ if (native) {
152
+ if (isQuitting) {
153
+ native.symbols.forceExit(code ?? 0);
154
+ return;
155
+ }
156
+ quit();
157
+ } else {
158
+ _originalProcessExit(code ?? 0);
156
159
  }
157
- quit();
158
160
  }) as typeof process.exit;
159
161
 
160
162
  export const openFileDialog = async (
@@ -358,7 +360,8 @@ function getVersionInfo(): { identifier: string; channel: string } {
358
360
  return _versionInfo;
359
361
  } catch (error) {
360
362
  console.error("Failed to read version.json", error);
361
- throw error;
363
+ _versionInfo = { identifier: "", channel: "" };
364
+ return _versionInfo;
362
365
  }
363
366
  }
364
367
 
@@ -44,6 +44,143 @@ import type {
44
44
  ApplicationMenuItemConfig,
45
45
  } from "./proc/native";
46
46
  import { BuildConfig, type BuildConfigType } from "./core/BuildConfig";
47
+ import { bridge, hasFFI } from "./proc/native";
48
+
49
+ // Carrot boot state — populated from __bunnyCarrotBootstrap injected by Bunny Ears
50
+ let _carrotManifest: Record<string, unknown> | null = null;
51
+ let _carrotContext: { currentDir?: string; statePath?: string; logsPath?: string; permissions?: string[]; grantedPermissions?: Record<string, unknown>; authToken?: string | null; channel?: string } | null = null;
52
+
53
+ const _bootstrap = (globalThis as any).__bunnyCarrotBootstrap as { manifest?: any; context?: any } | undefined;
54
+ if (_bootstrap) {
55
+ _carrotManifest = _bootstrap.manifest ?? null;
56
+ _carrotContext = _bootstrap.context ?? null;
57
+ }
58
+
59
+ if (bridge) {
60
+ bridge.on("init", (payload: any) => {
61
+ if (payload?.manifest) _carrotManifest = payload.manifest;
62
+ if (payload?.context) _carrotContext = payload.context;
63
+ });
64
+
65
+ // Forward host events to the local event emitter so ApplicationMenu.on(),
66
+ // ContextMenu.on(), etc. work in carrot workers
67
+ for (const eventName of ["application-menu-clicked", "context-menu-clicked"]) {
68
+ bridge.on(eventName, (payload: unknown) => {
69
+ electobunEventEmmitter.emitEvent({ type: eventName, data: payload } as any);
70
+ });
71
+ }
72
+
73
+ // Update local auth token when the host notifies of a change (e.g., Farm login)
74
+ bridge.on("auth-token-changed", (payload: unknown) => {
75
+ const token = (payload as any)?.token;
76
+ if (token && _carrotContext) {
77
+ _carrotContext.authToken = token;
78
+ }
79
+ });
80
+
81
+ // Clear local auth token on logout
82
+ bridge.on("auth-token-cleared", () => {
83
+ if (_carrotContext) {
84
+ _carrotContext.authToken = null;
85
+ }
86
+ });
87
+ }
88
+
89
+ export const Carrots = {
90
+ async invoke<T = unknown>(
91
+ carrotId: string,
92
+ method: string,
93
+ params?: unknown,
94
+ options?: { windowId?: string },
95
+ ): Promise<T> {
96
+ if (!bridge) throw new Error("Carrots.invoke() is only available when running as a carrot inside Bunny Ears");
97
+ return bridge.requestHost<T>("invoke-carrot", { carrotId, method, params, windowId: options?.windowId });
98
+ },
99
+ emit(carrotId: string, name: string, payload?: unknown) {
100
+ if (!bridge) throw new Error("Carrots.emit() is only available when running as a carrot inside Bunny Ears");
101
+ bridge.sendAction("emit-carrot-event", { carrotId, name, payload });
102
+ },
103
+ async list() {
104
+ if (!bridge) throw new Error("Carrots.list() is only available when running as a carrot inside Bunny Ears");
105
+ return bridge.requestHost<Array<{
106
+ id: string; name: string; description: string; version: string;
107
+ mode: string; permissions: string[]; status: string; devMode: boolean;
108
+ }>>("list-carrots");
109
+ },
110
+ async start(carrotId: string) {
111
+ if (!bridge) throw new Error("Carrots.start() is only available when running as a carrot inside Bunny Ears");
112
+ return bridge.requestHost<{ ok: boolean }>("start-carrot", { id: carrotId });
113
+ },
114
+ async stop(carrotId: string) {
115
+ if (!bridge) throw new Error("Carrots.stop() is only available when running as a carrot inside Bunny Ears");
116
+ return bridge.requestHost<{ ok: boolean }>("stop-carrot", { id: carrotId });
117
+ },
118
+ };
119
+
120
+ export const app = {
121
+ on(name: string, handler: (payload: unknown) => void) {
122
+ if (bridge) {
123
+ return bridge.on(name, handler);
124
+ }
125
+ electobunEventEmmitter.on(name, (e: { data: unknown }) => handler(e.data));
126
+ return () => {};
127
+ },
128
+ quit() {
129
+ Utils.quit();
130
+ },
131
+ get isCarrotMode() {
132
+ return !hasFFI;
133
+ },
134
+ get manifest() {
135
+ return _carrotManifest;
136
+ },
137
+ get permissions() {
138
+ return _carrotContext?.permissions ?? [];
139
+ },
140
+ get grantedPermissions() {
141
+ return _carrotContext?.grantedPermissions ?? {};
142
+ },
143
+ get currentDir() {
144
+ return _carrotContext?.currentDir ?? "";
145
+ },
146
+ get statePath() {
147
+ return _carrotContext?.statePath ?? "";
148
+ },
149
+ get logsPath() {
150
+ return _carrotContext?.logsPath ?? "";
151
+ },
152
+ get authToken() {
153
+ return _carrotContext?.authToken ?? null;
154
+ },
155
+ async fetchAuthToken(): Promise<string | null> {
156
+ if (!bridge) return null;
157
+ const result = await bridge.requestHost<{ token: string | null }>("get-auth-token");
158
+ if (result?.token && _carrotContext) {
159
+ _carrotContext.authToken = result.token;
160
+ }
161
+ return result?.token ?? null;
162
+ },
163
+ async setAuthToken(token: string): Promise<void> {
164
+ if (!bridge) return;
165
+ await bridge.requestHost("set-auth-token", { token });
166
+ if (_carrotContext) {
167
+ _carrotContext.authToken = token;
168
+ }
169
+ },
170
+ get channel() {
171
+ return _carrotContext?.channel ?? "";
172
+ },
173
+ openManager() {
174
+ if (bridge) bridge.sendAction("open-manager");
175
+ },
176
+ openBunnyWindow(payload?: { screenX?: number; screenY?: number }) {
177
+ if (bridge) bridge.sendAction("open-bunny-window", payload);
178
+ },
179
+ async getWindowFrame(windowId?: string) {
180
+ if (!bridge) return null;
181
+ return bridge.requestHost<{ x: number; y: number; width: number; height: number } | null>("window-get-frame", { windowId });
182
+ },
183
+ };
47
184
 
48
185
  // Named Exports
49
186
  export {
@@ -17,7 +17,7 @@ async function buildPreload() {
17
17
  const fullResult = await Bun.build({
18
18
  entrypoints: [fullPreloadEntry],
19
19
  target: "browser",
20
- format: "iife", // IIFE format for script injection (no export statements)
20
+ format: "esm",
21
21
  minify: false,
22
22
  });
23
23
 
@@ -31,7 +31,7 @@ async function buildPreload() {
31
31
  const sandboxedResult = await Bun.build({
32
32
  entrypoints: [sandboxedPreloadEntry],
33
33
  target: "browser",
34
- format: "iife",
34
+ format: "esm",
35
35
  minify: false,
36
36
  });
37
37
 
@@ -40,8 +40,10 @@ async function buildPreload() {
40
40
  throw new Error("Failed to build sandboxed preload script");
41
41
  }
42
42
 
43
- const fullPreloadJs = await fullResult.outputs[0]!.text();
44
- const sandboxedPreloadJs = await sandboxedResult.outputs[0]!.text();
43
+ // Bun does not currently support iife output, so we wrap the ESM bundle manually
44
+ // to keep preload globals scoped for script injection.
45
+ const fullPreloadJs = `(function(){${await fullResult.outputs[0]!.text()}})();`;
46
+ const sandboxedPreloadJs = `(function(){${await sandboxedResult.outputs[0]!.text()}})();`;
45
47
 
46
48
  const outputContent = `// Auto-generated file. Do not edit directly.
47
49
  // Run "bun build.ts" or "bun build:dev" from the package folder to regenerate.