electrobun 1.16.1-beta.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.
@@ -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
  */
@@ -225,7 +225,7 @@ export class BrowserView<T extends RPCWithTransport = RPCWithTransport> {
225
225
  loadURL(url: string) {
226
226
  console.log(`DEBUG: loadURL called for webview ${this.id}: ${url}`);
227
227
  this.url = url;
228
- native.symbols.loadURLInWebView(this.ptr, toCString(this.url));
228
+ native!.symbols.loadURLInWebView(this.ptr, toCString(this.url));
229
229
  }
230
230
 
231
231
  loadHTML(html: string) {
@@ -237,18 +237,18 @@ export class BrowserView<T extends RPCWithTransport = RPCWithTransport> {
237
237
 
238
238
  if (this.renderer === "cef") {
239
239
  // For CEF, store HTML content in native map and use scheme handler
240
- native.symbols.setWebviewHTMLContent(this.id, toCString(html));
240
+ native!.symbols.setWebviewHTMLContent(this.id, toCString(html));
241
241
  this.loadURL("views://internal/index.html");
242
242
  } else {
243
243
  // For WKWebView, load HTML content directly
244
- native.symbols.loadHTMLInWebView(this.ptr, toCString(html));
244
+ native!.symbols.loadHTMLInWebView(this.ptr, toCString(html));
245
245
  }
246
246
  }
247
247
 
248
248
  setNavigationRules(rules: string[]) {
249
249
  this.navigationRules = JSON.stringify(rules);
250
250
  const rulesJson = JSON.stringify(rules);
251
- native.symbols.setWebviewNavigationRules(this.ptr, toCString(rulesJson));
251
+ native!.symbols.setWebviewNavigationRules(this.ptr, toCString(rulesJson));
252
252
  }
253
253
 
254
254
  findInPage(
@@ -257,7 +257,7 @@ export class BrowserView<T extends RPCWithTransport = RPCWithTransport> {
257
257
  ) {
258
258
  const forward = options?.forward ?? true;
259
259
  const matchCase = options?.matchCase ?? false;
260
- native.symbols.webviewFindInPage(
260
+ native!.symbols.webviewFindInPage(
261
261
  this.ptr,
262
262
  toCString(searchText),
263
263
  forward,
@@ -266,19 +266,19 @@ export class BrowserView<T extends RPCWithTransport = RPCWithTransport> {
266
266
  }
267
267
 
268
268
  stopFindInPage() {
269
- native.symbols.webviewStopFind(this.ptr);
269
+ native!.symbols.webviewStopFind(this.ptr);
270
270
  }
271
271
 
272
272
  openDevTools() {
273
- native.symbols.webviewOpenDevTools(this.ptr);
273
+ native!.symbols.webviewOpenDevTools(this.ptr);
274
274
  }
275
275
 
276
276
  closeDevTools() {
277
- native.symbols.webviewCloseDevTools(this.ptr);
277
+ native!.symbols.webviewCloseDevTools(this.ptr);
278
278
  }
279
279
 
280
280
  toggleDevTools() {
281
- native.symbols.webviewToggleDevTools(this.ptr);
281
+ native!.symbols.webviewToggleDevTools(this.ptr);
282
282
  }
283
283
 
284
284
  /**
@@ -286,7 +286,7 @@ export class BrowserView<T extends RPCWithTransport = RPCWithTransport> {
286
286
  * @param zoomLevel - The zoom level (1.0 = 100%, 1.5 = 150%, etc.)
287
287
  */
288
288
  setPageZoom(zoomLevel: number) {
289
- native.symbols.webviewSetPageZoom(this.ptr, zoomLevel);
289
+ native!.symbols.webviewSetPageZoom(this.ptr, zoomLevel);
290
290
  }
291
291
 
292
292
  /**
@@ -294,7 +294,7 @@ export class BrowserView<T extends RPCWithTransport = RPCWithTransport> {
294
294
  * @returns The current zoom level (1.0 = 100%)
295
295
  */
296
296
  getPageZoom(): number {
297
- return native.symbols.webviewGetPageZoom(this.ptr) as number;
297
+ return native!.symbols.webviewGetPageZoom(this.ptr) as number;
298
298
  }
299
299
 
300
300
  // todo (yoav): move this to a class that also has off, append, prepend, etc.
@@ -362,7 +362,7 @@ export class BrowserView<T extends RPCWithTransport = RPCWithTransport> {
362
362
  });
363
363
  this.rpcHandler = undefined;
364
364
  this.ptr = null as any;
365
- native.symbols.webviewRemove(ptr);
365
+ native!.symbols.webviewRemove(ptr);
366
366
  }
367
367
 
368
368
  static getById(id: number) {
@@ -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.