electrobun 1.18.4-beta.6 → 2.0.1-beta.13

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +31 -170
  3. package/bin/electrobun.cjs +230 -153
  4. package/package.json +18 -49
  5. package/bun.lock +0 -119
  6. package/dist/api/browser/builtinrpcSchema.ts +0 -19
  7. package/dist/api/browser/global.d.ts +0 -36
  8. package/dist/api/browser/index.ts +0 -234
  9. package/dist/api/browser/webviewtag.ts +0 -88
  10. package/dist/api/browser/wgputag.ts +0 -48
  11. package/dist/api/bun/ElectrobunConfig.ts +0 -530
  12. package/dist/api/bun/__tests__/ffi-contract.test.ts +0 -105
  13. package/dist/api/bun/core/ApplicationMenu.ts +0 -70
  14. package/dist/api/bun/core/BrowserView.ts +0 -419
  15. package/dist/api/bun/core/BrowserWindow.ts +0 -400
  16. package/dist/api/bun/core/BuildConfig.ts +0 -71
  17. package/dist/api/bun/core/ContextMenu.ts +0 -75
  18. package/dist/api/bun/core/GpuWindow.ts +0 -289
  19. package/dist/api/bun/core/Paths.ts +0 -5
  20. package/dist/api/bun/core/Socket.ts +0 -22
  21. package/dist/api/bun/core/Tray.ts +0 -197
  22. package/dist/api/bun/core/Updater.ts +0 -1162
  23. package/dist/api/bun/core/Utils.ts +0 -487
  24. package/dist/api/bun/core/WGPUView.ts +0 -167
  25. package/dist/api/bun/core/menuRoles.ts +0 -181
  26. package/dist/api/bun/events/ApplicationEvents.ts +0 -22
  27. package/dist/api/bun/events/event.ts +0 -29
  28. package/dist/api/bun/events/eventEmitter.ts +0 -45
  29. package/dist/api/bun/events/trayEvents.ts +0 -11
  30. package/dist/api/bun/events/webviewEvents.ts +0 -39
  31. package/dist/api/bun/events/windowEvents.ts +0 -23
  32. package/dist/api/bun/index.ts +0 -298
  33. package/dist/api/bun/preload/.generated/compiled.ts +0 -8
  34. package/dist/api/bun/preload/build.ts +0 -65
  35. package/dist/api/bun/preload/dragRegions.ts +0 -41
  36. package/dist/api/bun/preload/encryption.ts +0 -86
  37. package/dist/api/bun/preload/events.ts +0 -171
  38. package/dist/api/bun/preload/globals.d.ts +0 -45
  39. package/dist/api/bun/preload/index-sandboxed.ts +0 -28
  40. package/dist/api/bun/preload/index.ts +0 -77
  41. package/dist/api/bun/preload/internalRpc.ts +0 -80
  42. package/dist/api/bun/preload/overlaySync.ts +0 -107
  43. package/dist/api/bun/preload/webviewTag.ts +0 -451
  44. package/dist/api/bun/preload/wgpuTag.ts +0 -246
  45. package/dist/api/bun/proc/linux.md +0 -43
  46. package/dist/api/bun/proc/native.ts +0 -3385
  47. package/dist/api/bun/webGPU.ts +0 -346
  48. package/dist/api/bun/webgpuAdapter.ts +0 -3011
  49. package/dist/api/shared/bun-version.ts +0 -3
  50. package/dist/api/shared/cef-version.ts +0 -5
  51. package/dist/api/shared/electrobun-version.ts +0 -2
  52. package/dist/api/shared/naming.test.ts +0 -327
  53. package/dist/api/shared/naming.ts +0 -188
  54. package/dist/api/shared/platform.ts +0 -48
  55. package/dist/api/shared/rpc.ts +0 -541
  56. package/dist/main.js +0 -168
  57. package/dist/preload-full.js +0 -913
  58. package/dist/preload-sandboxed.js +0 -111
  59. package/dist/zig-sdk/electrobun.zig +0 -1993
  60. package/src/cli/bun.lockb +0 -0
  61. package/src/cli/index.ts +0 -5689
  62. package/src/cli/package-lock.json +0 -81
  63. package/src/cli/package.json +0 -11
@@ -1,1162 +0,0 @@
1
- import { join, dirname, resolve } from "path";
2
- import { homedir } from "os";
3
- import {
4
- renameSync,
5
- unlinkSync,
6
- mkdirSync,
7
- rmSync,
8
- statSync,
9
- readdirSync,
10
- } from "fs";
11
- import { execSync } from "child_process";
12
- import { OS as currentOS, ARCH as currentArch } from "../../shared/platform";
13
- import { getPlatformPrefix, getTarballFileName } from "../../shared/naming";
14
- import { quit } from "./Utils";
15
-
16
- // Update status types for granular progress tracking
17
- export type UpdateStatusType =
18
- | "idle"
19
- | "checking"
20
- | "check-complete"
21
- | "no-update"
22
- | "update-available"
23
- | "downloading"
24
- | "download-starting"
25
- | "checking-local-tar"
26
- | "local-tar-found"
27
- | "local-tar-missing"
28
- | "fetching-patch"
29
- | "patch-found"
30
- | "patch-not-found"
31
- | "downloading-patch"
32
- | "applying-patch"
33
- | "patch-applied"
34
- | "patch-failed"
35
- | "extracting-version"
36
- | "patch-chain-complete"
37
- | "downloading-full-bundle"
38
- | "download-progress"
39
- | "decompressing"
40
- | "download-complete"
41
- | "applying"
42
- | "extracting"
43
- | "replacing-app"
44
- | "launching-new-version"
45
- | "complete"
46
- | "error";
47
-
48
- export interface UpdateStatusDetails {
49
- fromHash?: string;
50
- toHash?: string;
51
- currentHash?: string;
52
- latestHash?: string;
53
- patchNumber?: number;
54
- totalPatchesApplied?: number;
55
- progress?: number;
56
- bytesDownloaded?: number;
57
- totalBytes?: number;
58
- usedPatchPath?: boolean;
59
- errorMessage?: string;
60
- url?: string;
61
- zstdPath?: string;
62
- exitCode?: number | null;
63
- }
64
-
65
- export interface UpdateStatusEntry {
66
- status: UpdateStatusType;
67
- message: string;
68
- timestamp: number;
69
- details?: UpdateStatusDetails;
70
- }
71
-
72
- // Status history and callback
73
- const statusHistory: UpdateStatusEntry[] = [];
74
- let onStatusChangeCallback: ((entry: UpdateStatusEntry) => void) | null = null;
75
-
76
- function emitStatus(
77
- status: UpdateStatusType,
78
- message: string,
79
- details?: UpdateStatusDetails,
80
- ): void {
81
- const entry: UpdateStatusEntry = {
82
- status,
83
- message,
84
- timestamp: Date.now(),
85
- details,
86
- };
87
- statusHistory.push(entry);
88
- if (onStatusChangeCallback) {
89
- onStatusChangeCallback(entry);
90
- }
91
- }
92
-
93
- // setTimeout(async () => {
94
- // console.log('killing')
95
- // const { native } = await import('../proc/native');
96
- // native.symbols.killApp();
97
- // }, 1000)
98
-
99
-
100
- // Cross-platform app data directory
101
- function getAppDataDir(): string {
102
- switch (currentOS) {
103
- case "macos":
104
- return join(homedir(), "Library", "Application Support");
105
- case "win":
106
- // Use LOCALAPPDATA to match extractor location
107
- return process.env["LOCALAPPDATA"] || join(homedir(), "AppData", "Local");
108
- case "linux":
109
- // Use XDG_DATA_HOME or fallback to ~/.local/share to match extractor
110
- return process.env["XDG_DATA_HOME"] || join(homedir(), ".local", "share");
111
- default:
112
- // Fallback to home directory with .config
113
- return join(homedir(), ".config");
114
- }
115
- }
116
-
117
- // todo (yoav): share type with cli
118
- let localInfo: {
119
- version: string;
120
- hash: string;
121
- baseUrl: string;
122
- channel: string;
123
- name: string;
124
- identifier: string;
125
- };
126
-
127
- let updateInfo: {
128
- version: string;
129
- hash: string;
130
- updateAvailable: boolean;
131
- updateReady: boolean;
132
- error: string;
133
- };
134
-
135
- function cleanupExtractionFolder(
136
- extractionFolder: string,
137
- keepTarHash: string,
138
- ) {
139
- const keepFile = `${keepTarHash}.tar`;
140
- try {
141
- const entries = readdirSync(extractionFolder);
142
- for (const entry of entries) {
143
- if (entry === keepFile) continue;
144
- const fullPath = join(extractionFolder, entry);
145
- try {
146
- const s = statSync(fullPath);
147
- if (s.isDirectory()) {
148
- rmSync(fullPath, { recursive: true });
149
- } else {
150
- unlinkSync(fullPath);
151
- }
152
- } catch (e) {
153
- // Best effort — file may be in use on Windows
154
- }
155
- }
156
- } catch (e) {
157
- // Ignore errors in cleanup
158
- }
159
- }
160
-
161
- const Updater = {
162
- updateInfo: () => {
163
- return updateInfo;
164
- },
165
-
166
- // Status history and subscription methods
167
- getStatusHistory: () => {
168
- return [...statusHistory];
169
- },
170
-
171
- clearStatusHistory: () => {
172
- statusHistory.length = 0;
173
- },
174
-
175
- onStatusChange: (callback: ((entry: UpdateStatusEntry) => void) | null) => {
176
- onStatusChangeCallback = callback;
177
- },
178
-
179
- // todo: allow switching channels, by default will check the current channel
180
- checkForUpdate: async () => {
181
- emitStatus("checking", "Checking for updates...");
182
- const localInfo = await Updater.getLocalInfo();
183
-
184
- if (localInfo.channel === "dev") {
185
- emitStatus("no-update", "Dev channel - updates disabled", {
186
- currentHash: localInfo.hash,
187
- });
188
- return {
189
- version: localInfo.version,
190
- hash: localInfo.hash,
191
- updateAvailable: false,
192
- updateReady: false,
193
- error: "",
194
- };
195
- }
196
-
197
- const cacheBuster = Math.random().toString(36).substring(7);
198
- const platformPrefix = getPlatformPrefix(
199
- localInfo.channel,
200
- currentOS,
201
- currentArch,
202
- );
203
- const updateInfoUrl = `${localInfo.baseUrl.replace(/\/+$/, "")}/${platformPrefix}-update.json?${cacheBuster}`;
204
-
205
- try {
206
- const updateInfoResponse = await fetch(updateInfoUrl);
207
-
208
- if (updateInfoResponse.ok) {
209
- const responseText = await updateInfoResponse.text();
210
- try {
211
- updateInfo = JSON.parse(responseText);
212
- } catch {
213
- emitStatus("error", "Invalid update.json: failed to parse JSON", {
214
- url: updateInfoUrl,
215
- });
216
- return {
217
- version: "",
218
- hash: "",
219
- updateAvailable: false,
220
- updateReady: false,
221
- error: `Invalid update.json: failed to parse JSON`,
222
- };
223
- }
224
-
225
- if (!updateInfo.hash) {
226
- emitStatus("error", "Invalid update.json: missing hash", {
227
- url: updateInfoUrl,
228
- });
229
- return {
230
- version: "",
231
- hash: "",
232
- updateAvailable: false,
233
- updateReady: false,
234
- error: `Invalid update.json: missing hash`,
235
- };
236
- }
237
-
238
- if (updateInfo.hash !== localInfo.hash) {
239
- updateInfo.updateAvailable = true;
240
- emitStatus(
241
- "update-available",
242
- `Update available: ${localInfo.hash.slice(0, 8)} → ${updateInfo.hash.slice(0, 8)}`,
243
- {
244
- currentHash: localInfo.hash,
245
- latestHash: updateInfo.hash,
246
- },
247
- );
248
- } else {
249
- emitStatus("no-update", "Already on latest version", {
250
- currentHash: localInfo.hash,
251
- });
252
- }
253
- } else {
254
- emitStatus(
255
- "error",
256
- `Failed to fetch update info (HTTP ${updateInfoResponse.status})`,
257
- { url: updateInfoUrl },
258
- );
259
- return {
260
- version: "",
261
- hash: "",
262
- updateAvailable: false,
263
- updateReady: false,
264
- error: `Failed to fetch update info from ${updateInfoUrl}`,
265
- };
266
- }
267
- } catch (error) {
268
- return {
269
- version: "",
270
- hash: "",
271
- updateAvailable: false,
272
- updateReady: false,
273
- error: `Failed to fetch update info from ${updateInfoUrl}`,
274
- };
275
- }
276
-
277
- return updateInfo;
278
- },
279
-
280
- downloadUpdate: async () => {
281
- emitStatus("download-starting", "Starting update download...");
282
- const appDataFolder = await Updater.appDataFolder();
283
- await Updater.channelBucketUrl(); // Ensure localInfo is loaded
284
- const appFileName = localInfo.name;
285
-
286
- let currentHash = (await Updater.getLocalInfo()).hash;
287
- let latestHash = (await Updater.checkForUpdate()).hash;
288
-
289
- const extractionFolder = join(appDataFolder, "self-extraction");
290
- if (!(await Bun.file(extractionFolder).exists())) {
291
- mkdirSync(extractionFolder, { recursive: true });
292
- }
293
-
294
- let currentTarPath = join(extractionFolder, `${currentHash}.tar`);
295
- const latestTarPath = join(extractionFolder, `${latestHash}.tar`);
296
-
297
- const seenHashes: string[] = [];
298
- let patchesApplied = 0;
299
- let usedPatchPath = false;
300
-
301
- if (!(await Bun.file(latestTarPath).exists())) {
302
- emitStatus(
303
- "checking-local-tar",
304
- `Checking for local tar file: ${currentHash.slice(0, 8)}`,
305
- { currentHash },
306
- );
307
-
308
- while (currentHash !== latestHash) {
309
- seenHashes.push(currentHash);
310
- const currentTar = Bun.file(currentTarPath);
311
-
312
- if (!(await currentTar.exists())) {
313
- // tar file of the current version not found
314
- // so we can't patch it. We need the byte-for-byte tar file
315
- // so break out and download the full version
316
- emitStatus(
317
- "local-tar-missing",
318
- `Local tar not found for ${currentHash.slice(0, 8)}, will download full bundle`,
319
- { currentHash },
320
- );
321
- break;
322
- }
323
-
324
- emitStatus(
325
- "local-tar-found",
326
- `Found local tar for ${currentHash.slice(0, 8)}`,
327
- { currentHash },
328
- );
329
-
330
- // check if there's a patch file for it
331
- const platformPrefix = getPlatformPrefix(
332
- localInfo.channel,
333
- currentOS,
334
- currentArch,
335
- );
336
- const patchUrl = `${localInfo.baseUrl.replace(/\/+$/, "")}/${platformPrefix}-${currentHash}.patch`;
337
- emitStatus(
338
- "fetching-patch",
339
- `Checking for patch: ${currentHash.slice(0, 8)}`,
340
- { currentHash, url: patchUrl },
341
- );
342
-
343
- const patchResponse = await fetch(patchUrl);
344
-
345
- if (!patchResponse.ok) {
346
- // patch not found
347
- emitStatus(
348
- "patch-not-found",
349
- `No patch available for ${currentHash.slice(0, 8)}, will download full bundle`,
350
- { currentHash },
351
- );
352
- break;
353
- }
354
-
355
- emitStatus(
356
- "patch-found",
357
- `Patch found for ${currentHash.slice(0, 8)}`,
358
- { currentHash },
359
- );
360
- emitStatus(
361
- "downloading-patch",
362
- `Downloading patch for ${currentHash.slice(0, 8)}...`,
363
- { currentHash },
364
- );
365
-
366
- // The patch file's name is the hash of the "from" version
367
- const patchFilePath = join(
368
- appDataFolder,
369
- "self-extraction",
370
- `${currentHash}.patch`,
371
- );
372
- await Bun.write(patchFilePath, await patchResponse.arrayBuffer());
373
- // patch it to a tmp name
374
- const tmpPatchedTarFilePath = join(
375
- appDataFolder,
376
- "self-extraction",
377
- `from-${currentHash}.tar`,
378
- );
379
-
380
- const bunBinDir = dirname(process.execPath);
381
- const bspatchBinName = currentOS === "win" ? "bspatch.exe" : "bspatch";
382
- const bspatchPath = join(bunBinDir, bspatchBinName);
383
-
384
- emitStatus(
385
- "applying-patch",
386
- `Applying patch ${patchesApplied + 1} for ${currentHash.slice(0, 8)}...`,
387
- {
388
- currentHash,
389
- patchNumber: patchesApplied + 1,
390
- },
391
- );
392
-
393
- // Verify all files exist before invoking bspatch
394
- if (!statSync(bspatchPath, { throwIfNoEntry: false })) {
395
- emitStatus(
396
- "patch-failed",
397
- `bspatch binary not found at ${bspatchPath}`,
398
- {
399
- currentHash,
400
- errorMessage: `bspatch not found: ${bspatchPath}`,
401
- },
402
- );
403
- console.error("bspatch not found:", bspatchPath);
404
- break;
405
- }
406
- if (!statSync(currentTarPath, { throwIfNoEntry: false })) {
407
- emitStatus("patch-failed", `Old tar not found at ${currentTarPath}`, {
408
- currentHash,
409
- errorMessage: `old tar not found: ${currentTarPath}`,
410
- });
411
- console.error("old tar not found:", currentTarPath);
412
- break;
413
- }
414
- if (!statSync(patchFilePath, { throwIfNoEntry: false })) {
415
- emitStatus(
416
- "patch-failed",
417
- `Patch file not found at ${patchFilePath}`,
418
- {
419
- currentHash,
420
- errorMessage: `patch not found: ${patchFilePath}`,
421
- },
422
- );
423
- console.error("patch file not found:", patchFilePath);
424
- break;
425
- }
426
-
427
- try {
428
- const patchResult = Bun.spawnSync([
429
- bspatchPath,
430
- currentTarPath,
431
- tmpPatchedTarFilePath,
432
- patchFilePath,
433
- ]);
434
-
435
- if (patchResult.exitCode !== 0 || patchResult.success === false) {
436
- const stderr = patchResult.stderr
437
- ? patchResult.stderr.toString()
438
- : "";
439
- const stdout = patchResult.stdout
440
- ? patchResult.stdout.toString()
441
- : "";
442
- if (updateInfo) {
443
- updateInfo.error =
444
- stderr ||
445
- `bspatch failed with exit code ${patchResult.exitCode}`;
446
- }
447
- emitStatus(
448
- "patch-failed",
449
- `Patch application failed: ${stderr || `exit code ${patchResult.exitCode}`}`,
450
- {
451
- currentHash,
452
- errorMessage: stderr || `exit code ${patchResult.exitCode}`,
453
- },
454
- );
455
- console.error("bspatch failed", {
456
- exitCode: patchResult.exitCode,
457
- stdout,
458
- stderr,
459
- bspatchPath,
460
- oldTar: currentTarPath,
461
- newTar: tmpPatchedTarFilePath,
462
- patch: patchFilePath,
463
- });
464
- break;
465
- }
466
- } catch (error) {
467
- emitStatus(
468
- "patch-failed",
469
- `Patch threw exception: ${(error as Error).message}`,
470
- {
471
- currentHash,
472
- errorMessage: (error as Error).message,
473
- },
474
- );
475
- console.error("bspatch threw", error, { bspatchPath });
476
- break;
477
- }
478
-
479
- patchesApplied++;
480
- emitStatus(
481
- "patch-applied",
482
- `Patch ${patchesApplied} applied successfully`,
483
- {
484
- currentHash,
485
- patchNumber: patchesApplied,
486
- },
487
- );
488
-
489
- emitStatus(
490
- "extracting-version",
491
- "Extracting version info from patched tar...",
492
- { currentHash },
493
- );
494
-
495
- let hashFilePath = "";
496
-
497
- // Read the hash from the patched tar without full extraction:
498
- // - macOS/Windows: Resources/version.json (inside the app bundle directory)
499
- // - Linux: metadata.json (alongside the app bundle)
500
- const resourcesDir = "Resources";
501
- const patchedTarBytes = await Bun.file(
502
- tmpPatchedTarFilePath,
503
- ).arrayBuffer();
504
- const patchedArchive = new Bun.Archive(patchedTarBytes);
505
- const patchedFiles = await patchedArchive.files();
506
-
507
- for (const [filePath] of patchedFiles) {
508
- if (
509
- filePath.endsWith(`${resourcesDir}/version.json`) ||
510
- filePath.endsWith("metadata.json")
511
- ) {
512
- hashFilePath = filePath;
513
- break;
514
- }
515
- }
516
-
517
- if (!hashFilePath) {
518
- emitStatus(
519
- "error",
520
- "Could not find version/metadata file in patched tar",
521
- { currentHash },
522
- );
523
- console.error(
524
- "Neither Resources/version.json nor metadata.json found in patched tar:",
525
- tmpPatchedTarFilePath,
526
- );
527
- break;
528
- }
529
-
530
- const hashFile = patchedFiles.get(hashFilePath);
531
- const hashFileJson = JSON.parse(await hashFile!.text());
532
- const nextHash = hashFileJson.hash;
533
-
534
- if (seenHashes.includes(nextHash)) {
535
- emitStatus(
536
- "error",
537
- "Cyclical update detected, falling back to full download",
538
- { currentHash: nextHash },
539
- );
540
- console.log("Warning: cyclical update detected");
541
- break;
542
- }
543
-
544
- seenHashes.push(nextHash);
545
-
546
- if (!nextHash) {
547
- emitStatus(
548
- "error",
549
- "Could not determine next hash from patched tar",
550
- { currentHash },
551
- );
552
- break;
553
- }
554
- // Sync the patched tar file to the new hash
555
- const updatedTarPath = join(
556
- appDataFolder,
557
- "self-extraction",
558
- `${nextHash}.tar`,
559
- );
560
- renameSync(tmpPatchedTarFilePath, updatedTarPath);
561
-
562
- // delete the old tar file
563
- unlinkSync(currentTarPath);
564
- unlinkSync(patchFilePath);
565
-
566
- currentHash = nextHash;
567
- currentTarPath = join(
568
- appDataFolder,
569
- "self-extraction",
570
- `${currentHash}.tar`,
571
- );
572
-
573
- emitStatus(
574
- "patch-applied",
575
- `Patched to ${nextHash.slice(0, 8)}, checking for more patches...`,
576
- {
577
- currentHash: nextHash,
578
- toHash: latestHash,
579
- totalPatchesApplied: patchesApplied,
580
- },
581
- );
582
- // loop through applying patches until we reach the latest version
583
- // if we get stuck then exit and just download the full latest version
584
- }
585
-
586
- // Check if patch chain completed successfully
587
- if (currentHash === latestHash && patchesApplied > 0) {
588
- usedPatchPath = true;
589
- emitStatus(
590
- "patch-chain-complete",
591
- `Patch chain complete! Applied ${patchesApplied} patches`,
592
- {
593
- totalPatchesApplied: patchesApplied,
594
- currentHash: latestHash,
595
- usedPatchPath: true,
596
- },
597
- );
598
- }
599
-
600
- // If we weren't able to apply patches to the current version,
601
- // then just download it and unpack it
602
- if (currentHash !== latestHash) {
603
- emitStatus(
604
- "downloading-full-bundle",
605
- "Downloading full update bundle...",
606
- {
607
- currentHash,
608
- latestHash,
609
- usedPatchPath: false,
610
- },
611
- );
612
-
613
- const cacheBuster = Math.random().toString(36).substring(7);
614
- const platformPrefix = getPlatformPrefix(
615
- localInfo.channel,
616
- currentOS,
617
- currentArch,
618
- );
619
- const tarballName = getTarballFileName(appFileName, currentOS);
620
- const urlToLatestTarball = `${localInfo.baseUrl.replace(/\/+$/, "")}/${platformPrefix}-${tarballName}`;
621
- const prevVersionCompressedTarballPath = join(
622
- appDataFolder,
623
- "self-extraction",
624
- "latest.tar.zst",
625
- );
626
-
627
- emitStatus("download-progress", `Fetching ${tarballName}...`, {
628
- url: urlToLatestTarball,
629
- });
630
- const response = await fetch(urlToLatestTarball + `?${cacheBuster}`);
631
-
632
- if (response.ok && response.body) {
633
- const contentLength = response.headers.get("content-length");
634
- const totalBytes = contentLength
635
- ? parseInt(contentLength, 10)
636
- : undefined;
637
- let bytesDownloaded = 0;
638
-
639
- const reader = response.body.getReader();
640
- const writer = Bun.file(prevVersionCompressedTarballPath).writer();
641
-
642
- while (true) {
643
- const { done, value } = await reader.read();
644
- if (done) break;
645
- await writer.write(value);
646
- bytesDownloaded += value.length;
647
-
648
- // Emit progress every ~500KB or so
649
- if (bytesDownloaded % 500000 < value.length) {
650
- emitStatus(
651
- "download-progress",
652
- `Downloading: ${(bytesDownloaded / 1024 / 1024).toFixed(1)} MB`,
653
- {
654
- bytesDownloaded,
655
- totalBytes,
656
- progress: totalBytes
657
- ? Math.round((bytesDownloaded / totalBytes) * 100)
658
- : undefined,
659
- },
660
- );
661
- }
662
- }
663
- await writer.flush();
664
- writer.end();
665
-
666
- emitStatus(
667
- "download-progress",
668
- `Download complete: ${(bytesDownloaded / 1024 / 1024).toFixed(1)} MB`,
669
- {
670
- bytesDownloaded,
671
- totalBytes,
672
- progress: 100,
673
- },
674
- );
675
- } else {
676
- emitStatus("error", `Failed to download: ${urlToLatestTarball}`, {
677
- url: urlToLatestTarball,
678
- });
679
- console.log("latest version not found at: ", urlToLatestTarball);
680
- }
681
-
682
- emitStatus("decompressing", "Decompressing update bundle...");
683
- const bunBinDir = dirname(process.execPath);
684
- const zstdBinName = currentOS === "win" ? "zig-zstd.exe" : "zig-zstd";
685
- const zstdPath = join(bunBinDir, zstdBinName);
686
-
687
- if (!statSync(zstdPath, { throwIfNoEntry: false })) {
688
- updateInfo.error = `zig-zstd not found: ${zstdPath}`;
689
- emitStatus("error", updateInfo.error, { zstdPath });
690
- console.error("zig-zstd not found:", zstdPath);
691
- } else {
692
- const decompressResult = Bun.spawnSync(
693
- [
694
- zstdPath,
695
- "decompress",
696
- "-i",
697
- prevVersionCompressedTarballPath,
698
- "-o",
699
- latestTarPath,
700
- "--no-timing",
701
- ],
702
- {
703
- cwd: extractionFolder,
704
- stdout: "inherit",
705
- stderr: "inherit",
706
- },
707
- );
708
- if (!decompressResult.success) {
709
- updateInfo.error = `zig-zstd failed with exit code ${decompressResult.exitCode}`;
710
- emitStatus("error", updateInfo.error, {
711
- zstdPath,
712
- exitCode: decompressResult.exitCode,
713
- });
714
- console.error("zig-zstd failed", {
715
- exitCode: decompressResult.exitCode,
716
- zstdPath,
717
- });
718
- } else {
719
- emitStatus("decompressing", "Decompression complete");
720
- }
721
- }
722
-
723
- unlinkSync(prevVersionCompressedTarballPath);
724
- }
725
- }
726
-
727
- // Note: Bun.file().exists() caches the result, so we nee d an new instance of Bun.file() here
728
- // to check again
729
- if (await Bun.file(latestTarPath).exists()) {
730
- // download patch for this version, apply it.
731
- // check for patch from that tar and apply it, until it matches the latest version
732
- // as a fallback it should just download and unpack the latest version
733
- updateInfo.updateReady = true;
734
- emitStatus(
735
- "download-complete",
736
- `Update ready to install (used ${usedPatchPath ? "patch" : "full download"} path)`,
737
- {
738
- latestHash,
739
- usedPatchPath,
740
- totalPatchesApplied: patchesApplied,
741
- },
742
- );
743
- } else {
744
- updateInfo.error = "Failed to download latest version";
745
- emitStatus("error", "Failed to download latest version", { latestHash });
746
- }
747
-
748
- // Clean up stale files in the extraction folder (old tars, patches, backups, etc.)
749
- cleanupExtractionFolder(extractionFolder, latestHash);
750
- },
751
-
752
- // todo (yoav): this should emit an event so app can cleanup or block the restart
753
- // todo (yoav): rename this to quitAndApplyUpdate or something
754
- applyUpdate: async () => {
755
- if (updateInfo?.updateReady) {
756
- emitStatus("applying", "Starting update installation...");
757
- const appDataFolder = await Updater.appDataFolder();
758
- const extractionFolder = join(appDataFolder, "self-extraction");
759
- if (!(await Bun.file(extractionFolder).exists())) {
760
- mkdirSync(extractionFolder, { recursive: true });
761
- }
762
-
763
- let latestHash = (await Updater.checkForUpdate()).hash;
764
- const latestTarPath = join(extractionFolder, `${latestHash}.tar`);
765
-
766
- let appBundleSubpath: string = "";
767
-
768
- if (await Bun.file(latestTarPath).exists()) {
769
- emitStatus(
770
- "extracting",
771
- `Extracting update to ${latestHash.slice(0, 8)}...`,
772
- { latestHash },
773
- );
774
-
775
- // Windows needs a temporary directory to avoid file locking issues
776
- const extractionDir =
777
- currentOS === "win"
778
- ? join(extractionFolder, `temp-${latestHash}`)
779
- : extractionFolder;
780
-
781
- if (currentOS === "win") {
782
- mkdirSync(extractionDir, { recursive: true });
783
- }
784
-
785
- const latestTarBytes = await Bun.file(latestTarPath).arrayBuffer();
786
- const latestArchive = new Bun.Archive(latestTarBytes);
787
- await latestArchive.extract(extractionDir);
788
-
789
- if (currentOS === "macos") {
790
- // Find the .app bundle by scanning extracted directory
791
- const extractedFiles = readdirSync(extractionDir);
792
- for (const file of extractedFiles) {
793
- if (file.endsWith('.app')) {
794
- appBundleSubpath = file + "/";
795
- break;
796
- }
797
- }
798
- } else {
799
- appBundleSubpath = "./";
800
- }
801
-
802
- console.log(
803
- `Tar extraction completed. Found appBundleSubpath: ${appBundleSubpath}`,
804
- );
805
-
806
- if (!appBundleSubpath) {
807
- console.error("Failed to find app in tarball");
808
- return;
809
- }
810
-
811
- // Note: resolve here removes the extra trailing / that the tar file adds
812
- const extractedAppPath = resolve(join(extractionDir, appBundleSubpath));
813
-
814
- // Platform-specific path handling
815
- let newAppBundlePath: string;
816
- if (currentOS === "linux") {
817
- // On Linux, the tarball contains a directory bundle
818
- // Find the actual extracted app directory name instead of guessing
819
- const extractedFiles = readdirSync(extractionDir);
820
- const appBundleDir = extractedFiles.find(file => {
821
- const filePath = join(extractionDir, file);
822
- return statSync(filePath).isDirectory() && !file.endsWith('.tar');
823
- });
824
-
825
- if (!appBundleDir) {
826
- console.error("Could not find app bundle directory in extraction");
827
- return;
828
- }
829
-
830
- newAppBundlePath = join(extractionDir, appBundleDir);
831
-
832
- // Verify the app bundle directory exists
833
- const bundleStats = statSync(newAppBundlePath, { throwIfNoEntry: false });
834
- if (!bundleStats || !bundleStats.isDirectory()) {
835
- console.error(`App bundle directory not found at: ${newAppBundlePath}`);
836
- console.log("Contents of extraction directory:");
837
- try {
838
- const files = readdirSync(extractionDir);
839
- for (const file of files) {
840
- console.log(` - ${file}`);
841
- // Also list contents of subdirectories
842
- const subPath = join(extractionDir, file);
843
- if (statSync(subPath).isDirectory()) {
844
- const subFiles = readdirSync(subPath);
845
- for (const subFile of subFiles) {
846
- console.log(` - ${subFile}`);
847
- }
848
- }
849
- }
850
- } catch (e) {
851
- console.log("Could not list directory contents:", e);
852
- }
853
- return;
854
- }
855
- } else if (currentOS === "win") {
856
- // On Windows, the actual app is inside a subdirectory.
857
- // version.json's `name` field already contains the formatted app
858
- // file name (e.g. "MyApp-canary" for non-stable, "MyApp" for stable),
859
- // so don't re-apply getAppFileName or it doubles the channel suffix.
860
- newAppBundlePath = join(extractionDir, localInfo.name);
861
-
862
- // Verify the extracted app exists
863
- if (!statSync(newAppBundlePath, { throwIfNoEntry: false })) {
864
- console.error(`Extracted app not found at: ${newAppBundlePath}`);
865
- console.log("Contents of extraction directory:");
866
- try {
867
- const files = readdirSync(extractionDir);
868
- for (const file of files) {
869
- console.log(` - ${file}`);
870
- }
871
- } catch (e) {
872
- console.log("Could not list directory contents:", e);
873
- }
874
- return;
875
- }
876
- } else {
877
- // On macOS, use the extracted app path directly
878
- newAppBundlePath = extractedAppPath;
879
- }
880
- // Platform-specific app path calculation
881
- let runningAppBundlePath: string;
882
- const appDataFolder = await Updater.appDataFolder();
883
-
884
- if (currentOS === "macos") {
885
- // On macOS, executable is at Contents/MacOS/binary inside .app bundle
886
- runningAppBundlePath = resolve(dirname(process.execPath), "..", "..");
887
- } else if (currentOS === "linux" || currentOS === "win") {
888
- // On Linux and Windows, use fixed 'app' folder to match extractor
889
- runningAppBundlePath = join(appDataFolder, "app");
890
- } else {
891
- throw new Error(`Unsupported platform: ${currentOS}`);
892
- }
893
- try {
894
- emitStatus("replacing-app", "Removing old version...");
895
-
896
- if (currentOS === "macos") {
897
- // Remove existing app before installing the new one
898
- if (statSync(runningAppBundlePath, { throwIfNoEntry: false })) {
899
- rmSync(runningAppBundlePath, { recursive: true });
900
- }
901
-
902
- emitStatus("replacing-app", "Installing new version...");
903
- // Move new app to running location
904
- renameSync(newAppBundlePath, runningAppBundlePath);
905
-
906
- // Remove quarantine extended attributes to prevent "damaged" error
907
- // The inner bundle is already signed/notarized, but macOS applies
908
- // quarantine attributes when extracting from a downloaded archive
909
- try {
910
- execSync(
911
- `xattr -r -d com.apple.quarantine "${runningAppBundlePath}"`,
912
- { stdio: "ignore" },
913
- );
914
- } catch (e) {
915
- // Ignore errors - attribute may not exist
916
- }
917
- } else if (currentOS === "linux") {
918
- // On Linux, we now have directory bundles instead of AppImage files
919
- // The app is stored in {appDataFolder}/app/
920
- const appBundleDir = join(appDataFolder, "app");
921
-
922
- // Remove existing app directory if it exists
923
- if (statSync(appBundleDir, { throwIfNoEntry: false })) {
924
- rmSync(appBundleDir, { recursive: true });
925
- }
926
-
927
- // Move new app bundle directory to app location
928
- renameSync(newAppBundlePath, appBundleDir);
929
-
930
- // Ensure launcher binary is executable
931
- const launcherPath = join(appBundleDir, "bin", "launcher");
932
- if (statSync(launcherPath, { throwIfNoEntry: false })) {
933
- execSync(`chmod +x "${launcherPath}"`);
934
- }
935
-
936
- // Also ensure other binaries are executable
937
- const bunPath = join(appBundleDir, "bin", "bun");
938
- if (statSync(bunPath, { throwIfNoEntry: false })) {
939
- execSync(`chmod +x "${bunPath}"`);
940
- }
941
- }
942
-
943
- // Clean up stale files in extraction folder
944
- if (currentOS !== "win") {
945
- cleanupExtractionFolder(extractionFolder, latestHash);
946
- }
947
-
948
- if (currentOS === "win") {
949
- // On Windows, files are locked while in use, so we need a helper script
950
- // that runs after the app exits to do the replacement
951
- const parentDir = dirname(runningAppBundlePath);
952
- const updateScriptPath = join(parentDir, "update.bat");
953
- const launcherPath = join(
954
- runningAppBundlePath,
955
- "bin",
956
- "launcher.exe",
957
- );
958
-
959
- // Convert paths to Windows format
960
- const runningAppWin = runningAppBundlePath.replace(/\//g, "\\");
961
- const newAppWin = newAppBundlePath.replace(/\//g, "\\");
962
- const extractionDirWin = extractionDir.replace(/\//g, "\\");
963
- const launcherPathWin = launcherPath.replace(/\//g, "\\");
964
-
965
- // Create a batch script that will:
966
- // 1. Wait for the current app and its helper processes to exit
967
- // 2. Remove current app folder (with retries — CEF helpers may briefly
968
- // keep libcef.dll locked after launcher.exe exits)
969
- // 3. Move new app to current location (only if old folder is fully gone,
970
- // otherwise `move` would put it inside as a subdirectory)
971
- // 4. Launch the new app
972
- // 5. Clean up
973
- const updateScript = `@echo off
974
- setlocal
975
-
976
- :: Wait for the app and any CEF helper processes to fully exit.
977
- :: launcher.exe spawns bun.exe which spawns "bun Helper*.exe" processes that
978
- :: keep libcef.dll locked; if we proceed too early, rmdir partially fails.
979
- :waitloop
980
- tasklist /FI "IMAGENAME eq launcher.exe" 2>NUL | find /I /N "launcher.exe">NUL && goto waitsleep
981
- tasklist /FI "IMAGENAME eq bun.exe" 2>NUL | find /I /N "bun.exe">NUL && goto waitsleep
982
- tasklist /FI "IMAGENAME eq bun Helper.exe" 2>NUL | find /I /N "bun Helper.exe">NUL && goto waitsleep
983
- tasklist 2>NUL | find /I "bun Helper">NUL && goto waitsleep
984
- goto waitdone
985
- :waitsleep
986
- timeout /t 1 /nobreak >nul
987
- goto waitloop
988
- :waitdone
989
-
990
- :: Small extra delay to ensure all file handles are released
991
- timeout /t 2 /nobreak >nul
992
-
993
- :: Remove current app folder, retrying if rmdir fails (locked files etc.)
994
- set rmRetry=0
995
- :rmloop
996
- if not exist "${runningAppWin}" goto rmdone
997
- rmdir /s /q "${runningAppWin}" 2>nul
998
- if not exist "${runningAppWin}" goto rmdone
999
- set /a rmRetry=rmRetry+1
1000
- if %rmRetry% GEQ 10 goto rmfailed
1001
- timeout /t 2 /nobreak >nul
1002
- goto rmloop
1003
- :rmfailed
1004
- echo Update failed: could not remove "${runningAppWin}" after retries.
1005
- echo Files may still be locked by a helper process.
1006
- pause
1007
- exit /b 1
1008
- :rmdone
1009
-
1010
- :: Move new app to current location (safe now that destination is gone)
1011
- move "${newAppWin}" "${runningAppWin}"
1012
- if not exist "${launcherPathWin}" (
1013
- echo Update failed: launcher not found at "${launcherPathWin}" after move.
1014
- pause
1015
- exit /b 1
1016
- )
1017
-
1018
- :: Clean up extraction directory
1019
- rmdir /s /q "${extractionDirWin}" 2>nul
1020
-
1021
- :: Launch the new app
1022
- start "" "${launcherPathWin}"
1023
-
1024
- :: Clean up scheduled tasks starting with ElectrobunUpdate_
1025
- for /f "tokens=1" %%t in ('schtasks /query /fo list ^| findstr /i "ElectrobunUpdate_"') do (
1026
- schtasks /delete /tn "%%t" /f >nul 2>&1
1027
- )
1028
-
1029
- :: Delete this update script after a short delay
1030
- ping -n 2 127.0.0.1 >nul
1031
- del "%~f0"
1032
- `;
1033
-
1034
- await Bun.write(updateScriptPath, updateScript);
1035
-
1036
- // Use Windows Task Scheduler to run the update script independently
1037
- // This ensures the script runs even after the app exits
1038
- const scriptPathWin = updateScriptPath.replace(/\//g, "\\");
1039
- const taskName = `ElectrobunUpdate_${Date.now()}`;
1040
-
1041
- // Create a scheduled task that runs immediately and deletes itself
1042
- execSync(
1043
- `schtasks /create /tn "${taskName}" /tr "cmd /c \\"${scriptPathWin}\\"" /sc once /st 00:00 /f`,
1044
- { stdio: "ignore" },
1045
- );
1046
- execSync(`schtasks /run /tn "${taskName}"`, { stdio: "ignore" });
1047
- // The task will be cleaned up by Windows after it runs, or we delete it in the batch script
1048
-
1049
- // Use quit() for graceful shutdown - this closes all windows and processes
1050
- quit();
1051
- }
1052
- } catch (error) {
1053
- emitStatus(
1054
- "error",
1055
- `Failed to replace app: ${(error as Error).message}`,
1056
- {
1057
- errorMessage: (error as Error).message,
1058
- },
1059
- );
1060
- console.error("Failed to replace app with new version", error);
1061
- return;
1062
- }
1063
-
1064
- emitStatus("launching-new-version", "Launching updated version...");
1065
-
1066
- // Cross-platform app launch (Windows is handled above with its own update script)
1067
- if (currentOS === "macos") {
1068
- // Wait for the current process to fully exit before relaunching.
1069
- // macOS 'open' on an already-running app just activates the existing
1070
- // instance instead of launching a new one, so we must ensure the
1071
- // current process has exited first. The detached shell survives our
1072
- // exit and polls until the process is gone.
1073
- const pid = process.pid;
1074
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1075
- Bun.spawn(
1076
- [
1077
- "sh",
1078
- "-c",
1079
- `while kill -0 ${pid} 2>/dev/null; do sleep 0.5; done; sleep 1; open "${runningAppBundlePath}"`,
1080
- ],
1081
- {
1082
- detached: true,
1083
- stdio: ["ignore", "ignore", "ignore"],
1084
- } as any,
1085
- );
1086
- } else if (currentOS === "linux") {
1087
- // On Linux, launch the launcher binary inside the app directory
1088
- const launcherPath = join(runningAppBundlePath, "bin", "launcher");
1089
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1090
- Bun.spawn(["sh", "-c", `"${launcherPath}" &`], {
1091
- detached: true,
1092
- } as any);
1093
- }
1094
-
1095
- emitStatus("complete", "Update complete, restarting application...");
1096
- // Use quit() for graceful shutdown
1097
- quit();
1098
- }
1099
- }
1100
- },
1101
-
1102
- channelBucketUrl: async () => {
1103
- await Updater.getLocalInfo();
1104
- // With flat prefix-based naming, channelBucketUrl is just the baseUrl
1105
- // Users can also use Updater.localInfo.baseUrl() directly
1106
- return localInfo.baseUrl;
1107
- },
1108
-
1109
- appDataFolder: async () => {
1110
- await Updater.getLocalInfo();
1111
- // Use identifier + channel for the app data folder
1112
- // e.g., ~/Library/Application Support/sh.blackboard.myapp/canary/
1113
- const appDataFolder = join(
1114
- getAppDataDir(),
1115
- localInfo.identifier,
1116
- localInfo.channel,
1117
- );
1118
-
1119
- return appDataFolder;
1120
- },
1121
-
1122
- // TODO: consider moving this from "Updater.localInfo" to "BuildVars"
1123
- localInfo: {
1124
- version: async () => {
1125
- return (await Updater.getLocalInfo()).version;
1126
- },
1127
- hash: async () => {
1128
- return (await Updater.getLocalInfo()).hash;
1129
- },
1130
- channel: async () => {
1131
- return (await Updater.getLocalInfo()).channel;
1132
- },
1133
- baseUrl: async () => {
1134
- return (await Updater.getLocalInfo()).baseUrl;
1135
- },
1136
- },
1137
-
1138
- getLocalInfo: async () => {
1139
- if (localInfo) {
1140
- return localInfo;
1141
- }
1142
-
1143
- try {
1144
- const resourcesDir = "Resources"; // Always use capitalized Resources
1145
- localInfo = await Bun.file(`../${resourcesDir}/version.json`).json();
1146
- return localInfo;
1147
- } catch (error) {
1148
- console.error("Failed to read version.json", error);
1149
- localInfo = { identifier: "", channel: "", version: "", hash: "", baseUrl: "", name: "" };
1150
- return localInfo;
1151
- }
1152
- },
1153
- getLocallocalInfo: async () => {
1154
- console.error(
1155
- "[Electrobun] Updater.getLocallocalInfo() is deprecated. Use Updater.getLocalInfo() instead.",
1156
- );
1157
-
1158
- return Updater.getLocalInfo();
1159
- },
1160
- };
1161
-
1162
- export { Updater };