electrobun 1.18.1 → 1.18.4-beta.5

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.
@@ -3,13 +3,13 @@ import electrobunEventEmitter from "../events/eventEmitter";
3
3
  import { BrowserView } from "./BrowserView";
4
4
  import { type Pointer } from "bun:ffi";
5
5
  import { BuildConfig } from "./BuildConfig";
6
- import { quit } from "./Utils";
7
6
  import { type RPCWithTransport } from "../../shared/rpc.js";
8
- import { getNextWindowId } from "./windowIds";
9
- import { GpuWindowMap } from "./GpuWindow";
10
7
  import { WGPUView } from "./WGPUView";
11
8
 
12
- const buildConfig = await BuildConfig.get();
9
+ const buildConfig = BuildConfig.getSync();
10
+ ffi.request.setExitOnLastWindowClosed({
11
+ enabled: buildConfig.runtime?.exitOnLastWindowClosed ?? true,
12
+ });
13
13
 
14
14
  export type WindowOptionsType<T = undefined> = {
15
15
  trafficLightOffset?: {
@@ -73,7 +73,7 @@ export const BrowserWindowMap: {
73
73
  [id: number]: BrowserWindow<RPCWithTransport>;
74
74
  } = {};
75
75
 
76
- // Clean up the window map when a window closes and optionally quit the app
76
+ // Clean up JS wrapper state when a window closes. Native child cleanup is core-owned.
77
77
  electrobunEventEmitter.on("close", (event: { data: { id: number } }) => {
78
78
  const windowId = event.data.id;
79
79
  delete BrowserWindowMap[windowId];
@@ -89,35 +89,16 @@ electrobunEventEmitter.on("close", (event: { data: { id: number } }) => {
89
89
  const wgpuViews = WGPUView.getAll().filter(v => v.windowId === windowId);
90
90
  for (const view of wgpuViews) {
91
91
  try {
92
- // If ptr is null, the view was already cleaned up by the renderer or native cleanup
93
- if (view.ptr === null) {
94
- // Already cleaned up, skip
95
- } else {
96
- // Programmatic close path - remove the view
97
- view.remove();
98
- }
92
+ view.remove();
99
93
  } catch (e) {
100
94
  console.error(`Error cleaning up WGPU view ${view.id}:`, e);
101
- // If remove() failed, at least mark it as cleaned up
102
- view.ptr = null as any;
103
95
  }
104
96
  }
105
97
 
106
- const exitOnLastWindowClosed =
107
- buildConfig.runtime?.exitOnLastWindowClosed ?? true;
108
-
109
- if (
110
- exitOnLastWindowClosed &&
111
- Object.keys(BrowserWindowMap).length === 0 &&
112
- Object.keys(GpuWindowMap).length === 0
113
- ) {
114
- quit();
115
- }
116
98
  });
117
99
 
118
100
  export class BrowserWindow<T extends RPCWithTransport = RPCWithTransport> {
119
- id: number = getNextWindowId();
120
- ptr!: Pointer;
101
+ id = 0;
121
102
  title: string = "Electrobun";
122
103
  state: "creating" | "created" = "creating";
123
104
  url: string | null = null;
@@ -146,6 +127,10 @@ export class BrowserWindow<T extends RPCWithTransport = RPCWithTransport> {
146
127
  // todo (yoav): make this an array of ids or something
147
128
  webviewId!: number;
148
129
 
130
+ get ptr(): Pointer | null {
131
+ return ffi.request.getWindowPointer({ winId: this.id }) as Pointer | null;
132
+ }
133
+
149
134
  constructor(options: Partial<WindowOptionsType<T>> = defaultOptions) {
150
135
  this.title = options.title || "New Window";
151
136
  this.frame = options.frame
@@ -177,8 +162,7 @@ export class BrowserWindow<T extends RPCWithTransport = RPCWithTransport> {
177
162
  hidden,
178
163
  activate,
179
164
  }: Partial<WindowOptionsType<T>>) {
180
- this.ptr = ffi.request.createWindow({
181
- id: this.id,
165
+ const windowId = ffi.request.createWindow({
182
166
  title: this.title,
183
167
  url: this.url || "",
184
168
  frame: {
@@ -221,7 +205,13 @@ export class BrowserWindow<T extends RPCWithTransport = RPCWithTransport> {
221
205
  hidden: hidden ?? false,
222
206
  activate: activate ?? true,
223
207
  trafficLightOffset: this.trafficLightOffset,
224
- }) as Pointer;
208
+ });
209
+
210
+ if (!windowId) {
211
+ throw "Failed to create window";
212
+ }
213
+
214
+ this.id = windowId as number;
225
215
 
226
216
  BrowserWindowMap[this.id] = this;
227
217
 
@@ -1,3 +1,5 @@
1
+ import { readFileSync } from "fs";
2
+
1
3
  export type BuildConfigType = {
2
4
  defaultRenderer: "native" | "cef";
3
5
  availableRenderers: ("native" | "cef")[];
@@ -11,6 +13,13 @@ export type BuildConfigType = {
11
13
 
12
14
  let buildConfig: BuildConfigType | null = null;
13
15
 
16
+ function fallbackBuildConfig(): BuildConfigType {
17
+ return {
18
+ defaultRenderer: "native",
19
+ availableRenderers: ["native"],
20
+ };
21
+ }
22
+
14
23
  const BuildConfig = {
15
24
  /**
16
25
  * Get the build configuration. Loads from build.json on first call, then returns cached value.
@@ -26,10 +35,28 @@ const BuildConfig = {
26
35
  return buildConfig!;
27
36
  } catch (error) {
28
37
  // Fallback for dev mode or missing file
29
- buildConfig = {
30
- defaultRenderer: "native",
31
- availableRenderers: ["native"],
32
- };
38
+ buildConfig = fallbackBuildConfig();
39
+ return buildConfig;
40
+ }
41
+ },
42
+
43
+ /**
44
+ * Get the build configuration synchronously.
45
+ * Useful for modules that cannot use top-level await.
46
+ */
47
+ getSync: (): BuildConfigType => {
48
+ if (buildConfig) {
49
+ return buildConfig;
50
+ }
51
+
52
+ try {
53
+ const resourcesDir = "Resources";
54
+ buildConfig = JSON.parse(
55
+ readFileSync(`../${resourcesDir}/build.json`, "utf8"),
56
+ ) as BuildConfigType;
57
+ return buildConfig;
58
+ } catch (error) {
59
+ buildConfig = fallbackBuildConfig();
33
60
  return buildConfig;
34
61
  }
35
62
  },
@@ -2,7 +2,7 @@ import { ffi } from "../proc/native";
2
2
  import electrobunEventEmitter from "../events/eventEmitter";
3
3
  import { type Pointer } from "bun:ffi";
4
4
  import { WGPUView } from "./WGPUView";
5
- import { getNextWindowId } from "./windowIds";
5
+ import { BuildConfig } from "./BuildConfig";
6
6
 
7
7
 
8
8
  export type GpuWindowOptionsType = {
@@ -35,11 +35,16 @@ const defaultOptions: GpuWindowOptionsType = {
35
35
  transparent: false,
36
36
  };
37
37
 
38
+ const buildConfig = BuildConfig.getSync();
39
+ ffi.request.setExitOnLastWindowClosed({
40
+ enabled: buildConfig.runtime?.exitOnLastWindowClosed ?? true,
41
+ });
42
+
38
43
  export const GpuWindowMap: {
39
44
  [id: number]: GpuWindow;
40
45
  } = {};
41
46
 
42
- // Clean up the window map when a window closes and optionally quit the app
47
+ // Clean up JS wrapper state when a window closes. Native child cleanup is core-owned.
43
48
  electrobunEventEmitter.on("close", (event: { data: { id: number } }) => {
44
49
  const windowId = event.data.id;
45
50
  delete GpuWindowMap[windowId];
@@ -54,8 +59,7 @@ electrobunEventEmitter.on("close", (event: { data: { id: number } }) => {
54
59
  });
55
60
 
56
61
  export class GpuWindow {
57
- id: number = getNextWindowId();
58
- ptr!: Pointer;
62
+ id = 0;
59
63
  title: string = "Electrobun";
60
64
  state: "creating" | "created" = "creating";
61
65
  transparent: boolean = false;
@@ -73,6 +77,10 @@ export class GpuWindow {
73
77
  };
74
78
  wgpuViewId!: number;
75
79
 
80
+ get ptr(): Pointer | null {
81
+ return ffi.request.getWindowPointer({ winId: this.id }) as Pointer | null;
82
+ }
83
+
76
84
  constructor(options: Partial<GpuWindowOptionsType> = defaultOptions) {
77
85
  this.title = options.title || "New Window";
78
86
  this.frame = options.frame
@@ -93,8 +101,7 @@ export class GpuWindow {
93
101
  transparent,
94
102
  activate,
95
103
  }: Partial<GpuWindowOptionsType>) {
96
- this.ptr = ffi.request.createWindow({
97
- id: this.id,
104
+ const windowId = ffi.request.createWindow({
98
105
  title: this.title,
99
106
  url: "",
100
107
  frame: {
@@ -136,7 +143,13 @@ export class GpuWindow {
136
143
  transparent: transparent ?? false,
137
144
  activate: activate ?? true,
138
145
  trafficLightOffset: this.trafficLightOffset,
139
- }) as Pointer;
146
+ });
147
+
148
+ if (!windowId) {
149
+ throw "Failed to create window";
150
+ }
151
+
152
+ this.id = windowId as number;
140
153
 
141
154
  GpuWindowMap[this.id] = this;
142
155
 
@@ -1,205 +1,22 @@
1
- import type { Server, ServerWebSocket } from "bun";
2
- import { BrowserView } from "./BrowserView";
3
- import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
4
-
5
- function base64ToUint8Array(base64: string) {
6
- {
7
- return new Uint8Array(
8
- atob(base64)
9
- .split("")
10
- .map((char) => char.charCodeAt(0)),
11
- );
12
- }
13
- }
14
-
15
- // Encrypt function
16
- function encrypt(secretKey: Uint8Array, text: string) {
17
- const iv = new Uint8Array(randomBytes(12)); // IV for AES-GCM
18
- const cipher = createCipheriv("aes-256-gcm", secretKey, iv);
19
- const encrypted = Buffer.concat([
20
- new Uint8Array(cipher.update(text, "utf8")),
21
- new Uint8Array(cipher.final()),
22
- ]).toString("base64");
23
- const tag = cipher.getAuthTag().toString("base64");
24
- return { encrypted, iv: Buffer.from(iv).toString("base64"), tag };
25
- }
26
-
27
- // Decrypt function
28
- function decrypt(
29
- secretKey: Uint8Array,
30
- encryptedData: Uint8Array,
31
- iv: Uint8Array,
32
- tag: Uint8Array,
33
- ) {
34
- const decipher = createDecipheriv("aes-256-gcm", secretKey, iv);
35
- decipher.setAuthTag(tag);
36
- const decrypted = Buffer.concat([
37
- new Uint8Array(decipher.update(encryptedData)),
38
- new Uint8Array(decipher.final()),
39
- ]);
40
- return decrypted.toString("utf8");
41
- }
42
-
43
- export const socketMap: {
44
- [webviewId: string]: {
45
- socket: null | ServerWebSocket<unknown>;
46
- queue: string[];
47
- };
48
- } = {};
1
+ import { ffi } from "../proc/native";
49
2
 
50
3
  export const removeSocketForWebview = (webviewId: number) => {
51
- const rpc = socketMap[webviewId];
52
- if (!rpc) return;
53
-
54
- rpc.socket = null;
55
- delete socketMap[webviewId];
4
+ ffi.request.clearWebviewHostTransport({ id: webviewId });
56
5
  };
57
6
 
58
- const startRPCServer = () => {
59
- const startPort = 50000;
60
- const endPort = 65535;
61
- const payloadLimit = 1024 * 1024 * 500; // 500MB
62
- let port = startPort;
63
- let server = null;
64
-
65
- while (port <= endPort) {
66
- try {
67
- server = Bun.serve<{ webviewId: number }>({
68
- port,
69
- fetch(req: Request, server: Server<{ webviewId: number }>) {
70
- const url = new URL(req.url);
71
- // const token = new URL(req.url).searchParams.get("token");
72
- // if (token !== AUTH_TOKEN)
73
- // return new Response("Unauthorized", { status: 401 });
74
- // console.log("fetch!!", url.pathname);
75
- if (url.pathname === "/socket") {
76
- const webviewIdString = url.searchParams.get("webviewId");
77
- if (!webviewIdString) {
78
- return new Response("Missing webviewId", { status: 400 });
79
- }
80
- const webviewId = parseInt(webviewIdString, 10);
81
- const success = server.upgrade(req, { data: { webviewId } });
82
- return success
83
- ? undefined
84
- : new Response("Upgrade failed", { status: 500 });
85
- }
86
-
87
- console.log("unhandled RPC Server request", req.url);
88
- },
89
- websocket: {
90
- idleTimeout: 960,
91
- // 500MB max payload should be plenty
92
- maxPayloadLength: payloadLimit,
93
- // Anything beyond the backpressure limit will be dropped
94
- backpressureLimit: payloadLimit * 2,
95
- open(ws: ServerWebSocket<{ webviewId: number }>) {
96
- if (!ws?.data) {
97
- return;
98
- }
99
- const { webviewId } = ws.data;
100
-
101
- if (!socketMap[webviewId]) {
102
- socketMap[webviewId] = { socket: ws, queue: [] };
103
- } else {
104
- socketMap[webviewId].socket = ws;
105
- }
106
- },
107
- close(ws: ServerWebSocket<{ webviewId: number }>, _code: number, _reason: string) {
108
- if (!ws?.data) {
109
- return;
110
- }
111
- const { webviewId } = ws.data;
112
- // console.log("Closed:", webviewId, code, reason);
113
- if (socketMap[webviewId]) {
114
- socketMap[webviewId].socket = null;
115
- }
116
- },
117
-
118
- message(ws: ServerWebSocket<{ webviewId: number }>, message: string | Buffer) {
119
- if (!ws?.data) {
120
- return;
121
- }
122
- const { webviewId } = ws.data;
123
- const browserView = BrowserView.getById(webviewId);
124
- if (!browserView) {
125
- return;
126
- }
127
-
128
- if (browserView.rpcHandler) {
129
- if (typeof message === "string") {
130
- try {
131
- const encryptedPacket = JSON.parse(message);
132
- const decrypted = decrypt(
133
- browserView.secretKey,
134
- base64ToUint8Array(encryptedPacket.encryptedData),
135
- base64ToUint8Array(encryptedPacket.iv),
136
- base64ToUint8Array(encryptedPacket.tag),
137
- );
138
-
139
- // Note: At this point the secretKey for the webview id would
140
- // have had to match the encrypted packet data, so we can trust
141
- // that this message can be passed to this browserview's rpc
142
- // methods.
143
- browserView.rpcHandler(JSON.parse(decrypted));
144
- } catch (error) {
145
- console.log("Error handling message:", error);
146
- }
147
- } else if (message instanceof ArrayBuffer) {
148
- console.log("TODO: Received ArrayBuffer message:", message);
149
- }
150
- }
151
- },
152
- },
153
- });
154
-
155
- break;
156
- } catch (error: any) {
157
- if (error.code === "EADDRINUSE") {
158
- console.log(`Port ${port} in use, trying next port...`);
159
- port++;
160
- } else {
161
- throw error;
162
- }
163
- }
164
- }
165
-
166
- return { rpcServer: server, rpcPort: port };
167
- };
168
-
169
- export const { rpcServer, rpcPort } = startRPCServer();
170
-
171
- // Will return true if message was sent over websocket
172
- // false if it was not (caller should fallback to postMessage/evaluateJS rpc)
7
+ // Will return true if message was sent over the core-owned websocket transport.
8
+ // False means the caller should fall back to the native bridge / evaluateJS path.
173
9
  export const sendMessageToWebviewViaSocket = (
174
10
  webviewId: number,
175
- message: any,
11
+ message: unknown,
176
12
  ): boolean => {
177
- const rpc = socketMap[webviewId];
178
- const browserView = BrowserView.getById(webviewId);
179
-
180
- if (!browserView) return false;
181
-
182
- if (rpc?.socket?.readyState === WebSocket.OPEN) {
183
- try {
184
- const unencryptedString = JSON.stringify(message);
185
- const encrypted = encrypt(browserView.secretKey, unencryptedString);
186
-
187
- const encryptedPacket = {
188
- encryptedData: encrypted.encrypted,
189
- iv: encrypted.iv,
190
- tag: encrypted.tag,
191
- };
192
-
193
- const encryptedPacketString = JSON.stringify(encryptedPacket);
194
-
195
- rpc.socket.send(encryptedPacketString);
196
- return true;
197
- } catch (error) {
198
- console.error("Error sending message to webview via socket:", error);
199
- }
13
+ try {
14
+ return ffi.request.sendHostMessageToWebviewViaTransport({
15
+ id: webviewId,
16
+ messageJson: JSON.stringify(message),
17
+ }) as boolean;
18
+ } catch (error) {
19
+ console.error("Error sending message to webview via host transport:", error);
20
+ return false;
200
21
  }
201
-
202
- return false;
203
22
  };
204
-
205
- console.log("Server started at", rpcServer?.url.origin);
@@ -2,14 +2,12 @@ import { ffi, type MenuItemConfig, type Rectangle } from "../proc/native";
2
2
  import electrobunEventEmitter from "../events/eventEmitter";
3
3
  import { VIEWS_FOLDER } from "./Paths";
4
4
  import { join } from "path";
5
- import { type Pointer } from "bun:ffi";
6
5
 
7
6
  type NonDividerMenuItem = Exclude<
8
7
  MenuItemConfig,
9
8
  { type: "divider" | "separator" }
10
9
  >;
11
10
 
12
- let nextTrayId = 1;
13
11
  const TrayMap: { [id: number]: Tray } = {};
14
12
 
15
13
  export type TrayOptions = {
@@ -21,9 +19,8 @@ export type TrayOptions = {
21
19
  };
22
20
 
23
21
  export class Tray {
24
- id: number = nextTrayId++;
25
- ptr: Pointer | null = null;
26
- visible = true;
22
+ id = 0;
23
+ visible = false;
27
24
  title = "";
28
25
  image = "";
29
26
  template = true;
@@ -44,36 +41,35 @@ export class Tray {
44
41
  this.width = width;
45
42
  this.height = height;
46
43
 
47
- this.createNativeTray();
48
-
49
- TrayMap[this.id] = this;
44
+ if (this.createNativeTray()) {
45
+ TrayMap[this.id] = this;
46
+ }
50
47
  }
51
48
 
52
- private createNativeTray() {
49
+ private createNativeTray(): boolean {
53
50
  try {
54
- this.ptr = ffi.request.createTray({
55
- id: this.id,
51
+ const trayId = ffi.request.createTray({
56
52
  title: this.title,
57
53
  image: this.resolveImagePath(this.image),
58
54
  template: this.template,
59
55
  width: this.width,
60
56
  height: this.height,
61
- }) as Pointer;
57
+ }) as number;
58
+
59
+ if (!trayId) {
60
+ throw new Error("Tray creation returned an invalid id");
61
+ }
62
+
63
+ this.id = trayId;
62
64
  this.visible = true;
65
+ return true;
63
66
  } catch (error) {
64
67
  console.warn("Tray creation failed:", error);
65
68
  console.warn(
66
69
  "System tray functionality may not be available on this platform",
67
70
  );
68
- this.ptr = null;
69
71
  this.visible = false;
70
- }
71
-
72
- if (this.ptr && this.menu) {
73
- ffi.request.setTrayMenu({
74
- id: this.id,
75
- menuConfig: JSON.stringify(menuConfigWithDefaults(this.menu)),
76
- });
72
+ return false;
77
73
  }
78
74
  }
79
75
 
@@ -88,13 +84,13 @@ export class Tray {
88
84
 
89
85
  setTitle(title: string) {
90
86
  this.title = title;
91
- if (!this.ptr) return;
87
+ if (!this.id) return;
92
88
  ffi.request.setTrayTitle({ id: this.id, title });
93
89
  }
94
90
 
95
91
  setImage(imgPath: string) {
96
92
  this.image = imgPath;
97
- if (!this.ptr) return;
93
+ if (!this.id) return;
98
94
  ffi.request.setTrayImage({
99
95
  id: this.id,
100
96
  image: this.resolveImagePath(imgPath),
@@ -103,7 +99,7 @@ export class Tray {
103
99
 
104
100
  setMenu(menu: Array<MenuItemConfig>) {
105
101
  this.menu = menu;
106
- if (!this.ptr) return;
102
+ if (!this.id) return;
107
103
  const menuWithDefaults = menuConfigWithDefaults(menu);
108
104
  ffi.request.setTrayMenu({
109
105
  id: this.id,
@@ -122,15 +118,19 @@ export class Tray {
122
118
  }
123
119
 
124
120
  if (!visible) {
125
- if (this.ptr) {
126
- ffi.request.removeTray({ id: this.id });
127
- this.ptr = null;
128
- }
121
+ if (this.id) ffi.request.hideTray({ id: this.id });
129
122
  this.visible = false;
130
123
  return;
131
124
  }
132
125
 
133
- this.createNativeTray();
126
+ if (!this.id) {
127
+ if (this.createNativeTray()) {
128
+ TrayMap[this.id] = this;
129
+ }
130
+ return;
131
+ }
132
+
133
+ this.visible = ffi.request.showTray({ id: this.id }) as boolean;
134
134
  }
135
135
 
136
136
  getBounds(): Rectangle {
@@ -139,12 +139,13 @@ export class Tray {
139
139
 
140
140
  remove() {
141
141
  console.log("Tray.remove() called for id:", this.id);
142
- if (this.ptr) {
143
- ffi.request.removeTray({ id: this.id });
144
- this.ptr = null;
142
+ const trayId = this.id;
143
+ if (trayId) {
144
+ ffi.request.removeTray({ id: trayId });
145
145
  }
146
146
  this.visible = false;
147
- delete TrayMap[this.id];
147
+ delete TrayMap[trayId];
148
+ this.id = 0;
148
149
  console.log("Tray removed from TrayMap");
149
150
  }
150
151
 
@@ -137,9 +137,7 @@ export const quit = () => {
137
137
  }
138
138
 
139
139
  if (native) {
140
- native.symbols.stopEventLoop();
141
- native.symbols.waitForShutdownComplete(5000);
142
- native.symbols.forceExit(0);
140
+ ffi.request.quitGracefully({ code: 0, timeoutMs: 5000 });
143
141
  } else {
144
142
  process.exit(0);
145
143
  }
@@ -150,7 +148,7 @@ const _originalProcessExit = process.exit;
150
148
  process.exit = ((code?: number) => {
151
149
  if (native) {
152
150
  if (isQuitting) {
153
- native.symbols.forceExit(code ?? 0);
151
+ ffi.request.quitGracefully({ code: code ?? 0, timeoutMs: 0 });
154
152
  return;
155
153
  }
156
154
  quit();