electrobun 0.0.13 → 0.0.14

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.
@@ -0,0 +1,105 @@
1
+ import { zigRPC, type MenuItemConfig } from "../proc/zig";
2
+ import electrobunEventEmitter from "../events/eventEmitter";
3
+ import { VIEWS_FOLDER } from "./Paths";
4
+ import { join } from "path";
5
+
6
+ let nextTrayId = 1;
7
+ const TrayMap = {};
8
+
9
+ type ConstructorOptions = {
10
+ title?: string;
11
+ image?: string;
12
+ template?: boolean;
13
+ width?: number;
14
+ height?: number;
15
+ };
16
+
17
+ export class Tray {
18
+ id: number = nextTrayId++;
19
+
20
+ constructor({
21
+ title = "",
22
+ image = "",
23
+ template = true,
24
+ width = 16,
25
+ height = 16,
26
+ }: ConstructorOptions = {}) {
27
+ console.log("img", image);
28
+ console.log("img", this.resolveImagePath(image));
29
+ zigRPC.request.createTray({
30
+ id: this.id,
31
+ title,
32
+ image: this.resolveImagePath(image),
33
+ template,
34
+ width,
35
+ height,
36
+ });
37
+
38
+ TrayMap[this.id] = this;
39
+ }
40
+
41
+ resolveImagePath(imgPath: string) {
42
+ if (imgPath.startsWith("views://")) {
43
+ return join(VIEWS_FOLDER, imgPath.replace("views://", ""));
44
+ } else {
45
+ // can specify any file path here
46
+ return imgPath;
47
+ }
48
+ }
49
+
50
+ setTitle(title: string) {
51
+ zigRPC.request.setTrayTitle({ id: this.id, title });
52
+ }
53
+
54
+ setImage(imgPath: string) {
55
+ zigRPC.request.setTrayImage({
56
+ id: this.id,
57
+ image: this.resolveImagePath(imgPath),
58
+ });
59
+ }
60
+
61
+ setMenu(menu: Array<MenuItemConfig>) {
62
+ const menuWithDefaults = menuConfigWithDefaults(menu);
63
+ zigRPC.request.setTrayMenu({
64
+ id: this.id,
65
+ menuConfig: JSON.stringify(menuWithDefaults),
66
+ });
67
+ }
68
+
69
+ on(name: "tray-clicked", handler) {
70
+ const specificName = `${name}-${this.id}`;
71
+ electrobunEventEmitter.on(specificName, handler);
72
+ }
73
+
74
+ static getById(id: number) {
75
+ return TrayMap[id];
76
+ }
77
+
78
+ static getAll() {
79
+ return Object.values(TrayMap);
80
+ }
81
+ }
82
+
83
+ const menuConfigWithDefaults = (
84
+ menu: Array<MenuItemConfig>
85
+ ): Array<MenuItemConfig> => {
86
+ return menu.map((item) => {
87
+ if (item.type === "divider" || item.type === "separator") {
88
+ return { type: "divider" };
89
+ } else {
90
+ return {
91
+ label: item.label || "",
92
+ type: item.type || "normal",
93
+ action: item.action || "",
94
+ // default enabled to true unless explicitly set to false
95
+ enabled: item.enabled === false ? false : true,
96
+ checked: Boolean(item.checked),
97
+ hidden: Boolean(item.hidden),
98
+ tooltip: item.tooltip || undefined,
99
+ ...(item.submenu
100
+ ? { submenu: menuConfigWithDefaults(item.submenu) }
101
+ : {}),
102
+ };
103
+ }
104
+ });
105
+ };
@@ -0,0 +1,387 @@
1
+ import { join, dirname, resolve } from "path";
2
+ import { homedir } from "os";
3
+ import { renameSync, unlinkSync, mkdirSync, rmdirSync, statSync } from "fs";
4
+ import tar from "tar";
5
+ import { ZstdInit } from "@oneidentity/zstd-js/wasm";
6
+
7
+ const appSupportDir = join(homedir(), "Library", "Application Support");
8
+
9
+ // todo (yoav): share type with cli
10
+ let localInfo: {
11
+ version: string;
12
+ hash: string;
13
+ bucketUrl: string;
14
+ channel: string;
15
+ name: string;
16
+ identifier: string;
17
+ };
18
+
19
+ let updateInfo: {
20
+ version: string;
21
+ hash: string;
22
+ updateAvailable: boolean;
23
+ updateReady: boolean;
24
+ error: string;
25
+ };
26
+
27
+ const Updater = {
28
+ // workaround for some weird state stuff in this old version of bun
29
+ // todo: revisit after updating to the latest bun
30
+ updateInfo: () => {
31
+ return updateInfo;
32
+ },
33
+ // todo: allow switching channels, by default will check the current channel
34
+ checkForUpdate: async () => {
35
+ const localInfo = await Updater.getLocallocalInfo();
36
+
37
+ if (localInfo.channel === "dev") {
38
+ return {
39
+ version: localInfo.version,
40
+ hash: localInfo.hash,
41
+ updateAvailable: false,
42
+ updateReady: false,
43
+ error: "",
44
+ };
45
+ }
46
+
47
+ const channelBucketUrl = await Updater.channelBucketUrl();
48
+ const cacheBuster = Math.random().toString(36).substring(7);
49
+ const updateInfoUrl = join(channelBucketUrl, `update.json?${cacheBuster}`);
50
+
51
+ try {
52
+ const updateInfoResponse = await fetch(updateInfoUrl);
53
+
54
+ if (updateInfoResponse.ok) {
55
+ // todo: this seems brittle
56
+ updateInfo = await updateInfoResponse.json();
57
+
58
+ if (updateInfo.hash !== localInfo.hash) {
59
+ updateInfo.updateAvailable = true;
60
+ }
61
+ } else {
62
+ return {
63
+ version: "",
64
+ hash: "",
65
+ updateAvailable: false,
66
+ updateReady: false,
67
+ error: `Failed to fetch update info from ${updateInfoUrl}`,
68
+ };
69
+ }
70
+ } catch (error) {
71
+ return {
72
+ version: "",
73
+ hash: "",
74
+ updateAvailable: false,
75
+ updateReady: false,
76
+ error: `Failed to fetch update info from ${updateInfoUrl}`,
77
+ };
78
+ }
79
+
80
+ return updateInfo;
81
+ },
82
+
83
+ downloadUpdate: async () => {
84
+ const appDataFolder = await Updater.appDataFolder();
85
+ const channelBucketUrl = await Updater.channelBucketUrl();
86
+ const appFileName = localInfo.name;
87
+
88
+ let currentHash = (await Updater.getLocallocalInfo()).hash;
89
+ let latestHash = (await Updater.checkForUpdate()).hash;
90
+
91
+ let currentTarPath = join(
92
+ appDataFolder,
93
+ "self-extraction",
94
+ `${currentHash}.tar`
95
+ );
96
+ const latestTarPath = join(
97
+ appDataFolder,
98
+ "self-extraction",
99
+ `${latestHash}.tar`
100
+ );
101
+
102
+ const seenHashes = [];
103
+
104
+ // todo (yoav): add a check to the while loop that checks for a hash we've seen before
105
+ // so that update loops that are cyclical can be broken
106
+ if (!(await Bun.file(latestTarPath).exists())) {
107
+ while (currentHash !== latestHash) {
108
+ seenHashes.push(currentHash);
109
+ const currentTar = Bun.file(currentTarPath);
110
+
111
+ if (!(await currentTar.exists())) {
112
+ // tar file of the current version not found
113
+ // so we can't patch it. We need the byte-for-byte tar file
114
+ // so break out and download the full version
115
+ break;
116
+ }
117
+
118
+ // check if there's a patch file for it
119
+ const patchResponse = await fetch(
120
+ join(channelBucketUrl, `${currentHash}.patch`)
121
+ );
122
+
123
+ if (!patchResponse.ok) {
124
+ // patch not found
125
+ break;
126
+ }
127
+
128
+ // The patch file's name is the hash of the "from" version
129
+ const patchFilePath = join(
130
+ appDataFolder,
131
+ "self-extraction",
132
+ `${currentHash}.patch`
133
+ );
134
+ await Bun.write(patchFilePath, await patchResponse.arrayBuffer());
135
+ // patch it to a tmp name
136
+ const tmpPatchedTarFilePath = join(
137
+ appDataFolder,
138
+ "self-extraction",
139
+ `from-${currentHash}.tar`
140
+ );
141
+
142
+ // Note: cwd should be Contents/MacOS/ where the binaries are in the amc app bundle
143
+ try {
144
+ Bun.spawnSync([
145
+ "bspatch",
146
+ currentTarPath,
147
+ tmpPatchedTarFilePath,
148
+ patchFilePath,
149
+ ]);
150
+ } catch (error) {
151
+ break;
152
+ }
153
+
154
+ let versionSubpath = "";
155
+ const untarDir = join(appDataFolder, "self-extraction", "tmpuntar");
156
+ mkdirSync(untarDir, { recursive: true });
157
+
158
+ // extract just the version.json from the patched tar file so we can see what hash it is now
159
+ await tar.x({
160
+ // gzip: false,
161
+ file: tmpPatchedTarFilePath,
162
+ cwd: untarDir,
163
+ filter: (path, stat) => {
164
+ if (path.endsWith("Resources/version.json")) {
165
+ versionSubpath = path;
166
+ return true;
167
+ } else {
168
+ return false;
169
+ }
170
+ },
171
+ });
172
+
173
+ const currentVersionJson = await Bun.file(
174
+ join(untarDir, versionSubpath)
175
+ ).json();
176
+ const nextHash = currentVersionJson.hash;
177
+
178
+ if (seenHashes.includes(nextHash)) {
179
+ console.log("Warning: cyclical update detected");
180
+ break;
181
+ }
182
+
183
+ seenHashes.push(nextHash);
184
+
185
+ if (!nextHash) {
186
+ break;
187
+ }
188
+ // Sync the patched tar file to the new hash
189
+ const updatedTarPath = join(
190
+ appDataFolder,
191
+ "self-extraction",
192
+ `${nextHash}.tar`
193
+ );
194
+ renameSync(tmpPatchedTarFilePath, updatedTarPath);
195
+
196
+ // delete the old tar file
197
+ unlinkSync(currentTarPath);
198
+ unlinkSync(patchFilePath);
199
+ rmdirSync(untarDir, { recursive: true });
200
+
201
+ currentHash = nextHash;
202
+ currentTarPath = join(
203
+ appDataFolder,
204
+ "self-extraction",
205
+ `${currentHash}.tar`
206
+ );
207
+ // loop through applying patches until we reach the latest version
208
+ // if we get stuck then exit and just download the full latest version
209
+ }
210
+
211
+ // If we weren't able to apply patches to the current version,
212
+ // then just download it and unpack it
213
+ if (currentHash !== latestHash) {
214
+ const cacheBuster = Math.random().toString(36).substring(7);
215
+ const urlToLatestTarball = join(
216
+ channelBucketUrl,
217
+ `${appFileName}.app.tar.zst`
218
+ );
219
+ const prevVersionCompressedTarballPath = join(
220
+ appDataFolder,
221
+ "self-extraction",
222
+ "latest.tar.zst"
223
+ );
224
+ const response = await fetch(urlToLatestTarball + `?${cacheBuster}`);
225
+
226
+ if (response.ok && response.body) {
227
+ const reader = response.body.getReader();
228
+
229
+ const writer = Bun.file(prevVersionCompressedTarballPath).writer();
230
+
231
+ while (true) {
232
+ const { done, value } = await reader.read();
233
+ if (done) break;
234
+ await writer.write(value);
235
+ }
236
+ await writer.flush();
237
+ writer.end();
238
+ } else {
239
+ console.log("latest version not found at: ", urlToLatestTarball);
240
+ }
241
+
242
+ await ZstdInit().then(async ({ ZstdSimple }) => {
243
+ const data = new Uint8Array(
244
+ await Bun.file(prevVersionCompressedTarballPath).arrayBuffer()
245
+ );
246
+ const uncompressedData = ZstdSimple.decompress(data);
247
+
248
+ await Bun.write(latestTarPath, uncompressedData);
249
+ });
250
+
251
+ unlinkSync(prevVersionCompressedTarballPath);
252
+ try {
253
+ unlinkSync(currentTarPath);
254
+ } catch (error) {
255
+ // Note: ignore the error. it may have already been deleted by the patching process
256
+ // if the patching process only got halfway
257
+ }
258
+ }
259
+ }
260
+
261
+ // Note: Bun.file().exists() caches the result, so we nee d an new instance of Bun.file() here
262
+ // to check again
263
+ if (await Bun.file(latestTarPath).exists()) {
264
+ // download patch for this version, apply it.
265
+ // check for patch from that tar and apply it, until it matches the latest version
266
+ // as a fallback it should just download and unpack the latest version
267
+ updateInfo.updateReady = true;
268
+ } else {
269
+ updateInfo.error = "Failed to download latest version";
270
+ }
271
+ },
272
+
273
+ // todo (yoav): this should emit an event so app can cleanup or block the restart
274
+ // todo (yoav): rename this to quitAndApplyUpdate or something
275
+ applyUpdate: async () => {
276
+ if (updateInfo?.updateReady) {
277
+ const appDataFolder = await Updater.appDataFolder();
278
+ const extractionFolder = join(appDataFolder, "self-extraction");
279
+ let latestHash = (await Updater.checkForUpdate()).hash;
280
+ const latestTarPath = join(extractionFolder, `${latestHash}.tar`);
281
+
282
+ let appBundleSubpath: string = "";
283
+
284
+ if (await Bun.file(latestTarPath).exists()) {
285
+ await tar.x({
286
+ // gzip: false,
287
+ file: latestTarPath,
288
+ cwd: extractionFolder,
289
+ onentry: (entry) => {
290
+ // find the first .app bundle in the tarball
291
+ // Some apps may have nested .app bundles
292
+ if (!appBundleSubpath && entry.path.endsWith(".app/")) {
293
+ appBundleSubpath = entry.path;
294
+ }
295
+ },
296
+ });
297
+
298
+ if (!appBundleSubpath) {
299
+ console.error("Failed to find app bundle in tarball");
300
+ return;
301
+ }
302
+
303
+ // Note: resolve here removes the extra trailing / that the tar file adds
304
+ const newAppBundlePath = resolve(
305
+ join(extractionFolder, appBundleSubpath)
306
+ );
307
+ // Note: dirname(process.execPath) is the path to the running app bundle's
308
+ // Contents/MacOS directory
309
+ const runningAppBundlePath = resolve(
310
+ dirname(process.execPath),
311
+ "..",
312
+ ".."
313
+ );
314
+ const backupAppBundlePath = join(extractionFolder, "backup.app");
315
+
316
+ try {
317
+ // const backupState = statSync(backupAppBundlePath);
318
+ if (statSync(backupAppBundlePath, { throwIfNoEntry: false })) {
319
+ rmdirSync(backupAppBundlePath, { recursive: true });
320
+ } else {
321
+ console.log("backupAppBundlePath does not exist");
322
+ }
323
+ renameSync(runningAppBundlePath, backupAppBundlePath);
324
+ renameSync(newAppBundlePath, runningAppBundlePath);
325
+ } catch (error) {
326
+ console.error("Failed to replace app with new version", error);
327
+ return;
328
+ }
329
+
330
+ await Bun.spawn(["open", runningAppBundlePath]);
331
+ process.exit(0);
332
+ }
333
+ }
334
+ },
335
+
336
+ channelBucketUrl: async () => {
337
+ await Updater.getLocallocalInfo();
338
+ // todo: tmp hardcode canary
339
+ return join(localInfo.bucketUrl, localInfo.channel);
340
+ },
341
+
342
+ appDataFolder: async () => {
343
+ await Updater.getLocallocalInfo();
344
+ const appDataFolder = join(
345
+ appSupportDir,
346
+ localInfo.identifier,
347
+ localInfo.name
348
+ );
349
+
350
+ return appDataFolder;
351
+ },
352
+
353
+ // TODO: consider moving this from "Updater.localInfo" to "BuildVars"
354
+ localInfo: {
355
+ version: async () => {
356
+ return (await Updater.getLocallocalInfo()).version;
357
+ },
358
+ hash: async () => {
359
+ return (await Updater.getLocallocalInfo()).hash;
360
+ },
361
+ channel: async () => {
362
+ return (await Updater.getLocallocalInfo()).channel;
363
+ },
364
+ bucketUrl: async () => {
365
+ return (await Updater.getLocallocalInfo()).bucketUrl;
366
+ },
367
+ },
368
+
369
+ getLocallocalInfo: async () => {
370
+ if (localInfo) {
371
+ return localInfo;
372
+ }
373
+
374
+ try {
375
+ localInfo = await Bun.file("../Resources/version.json").json();
376
+ return localInfo;
377
+ } catch (error) {
378
+ // Handle the error
379
+ console.error("Failed to read version.json", error);
380
+
381
+ // Then rethrow so the app crashes
382
+ throw error;
383
+ }
384
+ },
385
+ };
386
+
387
+ export { Updater };
@@ -0,0 +1,48 @@
1
+ import { zigRPC } from "../proc/zig";
2
+
3
+ // TODO: move this to a more appropriate namespace
4
+ export const moveToTrash = (path: string) => {
5
+ return zigRPC.request.moveToTrash({ path });
6
+ };
7
+
8
+ export const showItemInFolder = (path: string) => {
9
+ return zigRPC.request.showItemInFolder({ path });
10
+ };
11
+
12
+ export const openFileDialog = async (
13
+ opts: {
14
+ startingFolder?: string;
15
+ allowedFileTypes?: string;
16
+ canChooseFiles?: boolean;
17
+ canChooseDirectory?: boolean;
18
+ allowsMultipleSelection?: boolean;
19
+ } = {}
20
+ ): Promise<string[]> => {
21
+ const optsWithDefault = {
22
+ ...{
23
+ startingFolder: "~/",
24
+ allowedFileTypes: "*",
25
+ canChooseFiles: true,
26
+ canChooseDirectory: true,
27
+ allowsMultipleSelection: true,
28
+ },
29
+ ...opts,
30
+ };
31
+
32
+ // todo: extend the timeout for this one (this version of rpc-anywhere doesn't seem to be able to set custom timeouts per request)
33
+ // we really want it to be infinity since the open file dialog blocks everything anyway.
34
+ // todo: there's the timeout between bun and zig, and the timeout between browser and bun since user likely requests
35
+ // from a browser context
36
+ const result = await zigRPC.request.openFileDialog({
37
+ startingFolder: optsWithDefault.startingFolder,
38
+ allowedFileTypes: optsWithDefault.allowedFileTypes,
39
+ canChooseFiles: optsWithDefault.canChooseFiles,
40
+ canChooseDirectory: optsWithDefault.canChooseDirectory,
41
+ allowsMultipleSelection: optsWithDefault.allowsMultipleSelection,
42
+ });
43
+
44
+ const filePaths = result.openFileDialogResponse.split(",");
45
+
46
+ // todo: it's nested like this due to zig union types. needs a zig refactor and revisit
47
+ return filePaths;
48
+ };
@@ -0,0 +1,14 @@
1
+ import ElectrobunEvent from "./event";
2
+
3
+ export default {
4
+ applicationMenuClicked: (data) =>
5
+ new ElectrobunEvent<{ id: number; action: string }, { allow: boolean }>(
6
+ "application-menu-clicked",
7
+ data
8
+ ),
9
+ contextMenuClicked: (data) =>
10
+ new ElectrobunEvent<{ id: number; action: string }, { allow: boolean }>(
11
+ "context-menu-clicked",
12
+ data
13
+ ),
14
+ };
@@ -0,0 +1,29 @@
1
+ export default class ElectrobunEvent<DataType, ResponseType> {
2
+ // todo (yoav): make most of these readonly except for response
3
+ name: string;
4
+ data: DataType;
5
+ // todo (yoav): define getters and setters for response
6
+ _response: ResponseType | undefined;
7
+ responseWasSet: boolean = false;
8
+
9
+ constructor(name: string, data: DataType) {
10
+ this.name = name;
11
+ this.data = data;
12
+ }
13
+
14
+ // Getter for response
15
+ get response(): ResponseType | undefined {
16
+ return this._response;
17
+ }
18
+
19
+ // Setter for response
20
+ set response(value: ResponseType) {
21
+ this._response = value;
22
+ this.responseWasSet = true; // Update flag when response is set
23
+ }
24
+
25
+ clearResponse() {
26
+ this._response = undefined;
27
+ this.responseWasSet = false;
28
+ }
29
+ }
@@ -0,0 +1,45 @@
1
+ import EventEmitter from "events";
2
+ import windowEvents from "./windowEvents";
3
+ import webviewEvents from "./webviewEvents";
4
+ import trayEvents from "./trayEvents";
5
+ import applicationEvents from "./applicationEvents";
6
+ import ElectrobunEvent from "./event";
7
+
8
+ class ElectrobunEventEmitter extends EventEmitter {
9
+ constructor() {
10
+ super();
11
+ }
12
+
13
+ // optionally pass in a specifier to make the event name specific.
14
+ // eg: will-navigate is listened to globally for all webviews, but
15
+ // will-navigate-1 is listened to for a specific webview with id 1
16
+ emitEvent(
17
+ ElectrobunEvent: ElectrobunEvent<any, any>,
18
+ specifier?: number | string
19
+ ) {
20
+ if (specifier) {
21
+ this.emit(`${ElectrobunEvent.name}-${specifier}`, ElectrobunEvent);
22
+ } else {
23
+ this.emit(ElectrobunEvent.name, ElectrobunEvent);
24
+ }
25
+ }
26
+
27
+ events = {
28
+ window: {
29
+ ...windowEvents,
30
+ },
31
+ webview: {
32
+ ...webviewEvents,
33
+ },
34
+ tray: {
35
+ ...trayEvents,
36
+ },
37
+ app: {
38
+ ...applicationEvents,
39
+ },
40
+ };
41
+ }
42
+
43
+ export const electrobunEventEmitter = new ElectrobunEventEmitter();
44
+
45
+ export default electrobunEventEmitter;
@@ -0,0 +1,9 @@
1
+ import ElectrobunEvent from "./event";
2
+
3
+ export default {
4
+ trayClicked: (data) =>
5
+ new ElectrobunEvent<{ id: number; action: string }, { allow: boolean }>(
6
+ "tray-clicked",
7
+ data
8
+ ),
9
+ };
@@ -0,0 +1,19 @@
1
+ import ElectrobunEvent from "./event";
2
+
3
+ export default {
4
+ willNavigate: (data) =>
5
+ new ElectrobunEvent<{ url: string; windowId: number }, { allow: boolean }>(
6
+ "will-navigate",
7
+ data
8
+ ),
9
+ didNavigate: (data) =>
10
+ new ElectrobunEvent<{ detail: string }, {}>("did-navigate", data),
11
+ didNavigateInPage: (data) =>
12
+ new ElectrobunEvent<{ detail: string }, {}>("did-navigate-in-page", data),
13
+ didCommitNavigation: (data) =>
14
+ new ElectrobunEvent<{ detail: string }, {}>("did-commit-navigation", data),
15
+ domReady: (data) =>
16
+ new ElectrobunEvent<{ detail: string }, {}>("dom-ready", data),
17
+ newWindowOpen: (data) =>
18
+ new ElectrobunEvent<{ detail: string }, {}>("new-window-open", data),
19
+ };
@@ -0,0 +1,12 @@
1
+ import ElectrobunEvent from "./event";
2
+
3
+ export default {
4
+ close: (data) => new ElectrobunEvent<{ id: number }, {}>("close", data),
5
+ resize: (data) =>
6
+ new ElectrobunEvent<
7
+ { id: number; x: number; y: number; width: number; height: number },
8
+ {}
9
+ >("resize", data),
10
+ move: (data) =>
11
+ new ElectrobunEvent<{ id: number; x: number; y: number }, {}>("move", data),
12
+ };