sparkbun 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/sparkbun.cjs +18 -0
- package/dist-linux-arm64/bsdiff +0 -0
- package/dist-linux-arm64/bspatch +0 -0
- package/dist-linux-arm64/libElectrobunCore.so +0 -0
- package/dist-linux-arm64/libNativeWrapper.so +0 -0
- package/dist-linux-arm64/libasar.so +0 -0
- package/dist-linux-x64/bsdiff +0 -0
- package/dist-linux-x64/bspatch +0 -0
- package/dist-linux-x64/libElectrobunCore.so +0 -0
- package/dist-linux-x64/libNativeWrapper.so +0 -0
- package/dist-linux-x64/libasar.so +0 -0
- package/dist-macos-arm64/bsdiff +0 -0
- package/dist-macos-arm64/bspatch +0 -0
- package/dist-macos-arm64/libElectrobunCore.dylib +0 -0
- package/dist-macos-arm64/libNativeWrapper.dylib +0 -0
- package/dist-macos-arm64/libasar.dylib +0 -0
- package/dist-macos-arm64/libwebgpu_dawn.dylib +0 -0
- package/dist-macos-arm64/preload-full.js +885 -0
- package/dist-macos-arm64/preload-sandboxed.js +111 -0
- package/dist-macos-arm64/process_helper +0 -0
- package/dist-win-x64/ElectrobunCore.dll +0 -0
- package/dist-win-x64/WebView2Loader.dll +0 -0
- package/dist-win-x64/bsdiff.exe +0 -0
- package/dist-win-x64/bspatch.exe +0 -0
- package/dist-win-x64/libNativeWrapper.dll +0 -0
- package/dist-win-x64/zig-asar/arm64/libasar.dll +0 -0
- package/dist-win-x64/zig-asar/x64/libasar.dll +0 -0
- package/package.json +47 -0
- package/scripts/build-and-upload-artifacts.js +207 -0
- package/scripts/gen-webgpu-ffi.mjs +162 -0
- package/scripts/install-windows-deps.ps1 +80 -0
- package/scripts/package-release.js +237 -0
- package/scripts/push-version.js +84 -0
- package/scripts/update-bun-version.ts +122 -0
- package/scripts/update-cef-version.ts +145 -0
- package/src/browser/builtinrpcSchema.ts +19 -0
- package/src/browser/global.d.ts +36 -0
- package/src/browser/index.ts +234 -0
- package/src/browser/webviewtag.ts +88 -0
- package/src/browser/wgputag.ts +48 -0
- package/src/bun/SparkBunConfig.ts +497 -0
- package/src/bun/__tests__/ffi-contract.test.ts +105 -0
- package/src/bun/core/ApplicationMenu.ts +70 -0
- package/src/bun/core/BrowserView.ts +416 -0
- package/src/bun/core/BrowserWindow.ts +396 -0
- package/src/bun/core/BuildConfig.ts +71 -0
- package/src/bun/core/ContextMenu.ts +75 -0
- package/src/bun/core/GpuWindow.ts +289 -0
- package/src/bun/core/Paths.ts +5 -0
- package/src/bun/core/Socket.ts +22 -0
- package/src/bun/core/Tray.ts +197 -0
- package/src/bun/core/Updater.ts +1131 -0
- package/src/bun/core/Utils.ts +487 -0
- package/src/bun/core/WGPUView.ts +167 -0
- package/src/bun/core/menuRoles.ts +181 -0
- package/src/bun/events/ApplicationEvents.ts +22 -0
- package/src/bun/events/event.ts +27 -0
- package/src/bun/events/eventEmitter.ts +45 -0
- package/src/bun/events/trayEvents.ts +11 -0
- package/src/bun/events/webviewEvents.ts +39 -0
- package/src/bun/events/windowEvents.ts +23 -0
- package/src/bun/index.ts +120 -0
- package/src/bun/preload/.generated/compiled.ts +2 -0
- package/src/bun/preload/build.ts +65 -0
- package/src/bun/preload/dragRegions.ts +41 -0
- package/src/bun/preload/encryption.ts +86 -0
- package/src/bun/preload/events.ts +171 -0
- package/src/bun/preload/globals.d.ts +45 -0
- package/src/bun/preload/index-sandboxed.ts +28 -0
- package/src/bun/preload/index.ts +77 -0
- package/src/bun/preload/internalRpc.ts +80 -0
- package/src/bun/preload/overlaySync.ts +107 -0
- package/src/bun/preload/webviewTag.ts +451 -0
- package/src/bun/preload/wgpuTag.ts +246 -0
- package/src/bun/proc/linux.md +43 -0
- package/src/bun/proc/native.ts +3253 -0
- package/src/bun/webGPU.ts +346 -0
- package/src/bun/webgpuAdapter.ts +3011 -0
- package/src/cli/bun.lockb +0 -0
- package/src/cli/index.ts +4653 -0
- package/src/cli/package-lock.json +81 -0
- package/src/cli/package.json +11 -0
- package/src/cli/templates/embedded.ts +2 -0
- package/src/core/build.zig +16 -0
- package/src/core/main.zig +3378 -0
- package/src/extractor/build.zig +22 -0
- package/src/installer/installer-template.ts +216 -0
- package/src/launcher/main.ts +221 -0
- package/src/native/build/libNativeWrapper.so +0 -0
- package/src/native/linux/build/nativeWrapper.o +0 -0
- package/src/native/linux/cef_loader.cpp +110 -0
- package/src/native/linux/cef_loader.h +28 -0
- package/src/native/linux/cef_process_helper_linux.cpp +160 -0
- package/src/native/linux/nativeWrapper.cpp +11768 -0
- package/src/native/macos/cef_process_helper_mac.cc +160 -0
- package/src/native/macos/nativeWrapper.mm +9172 -0
- package/src/native/shared/accelerator_parser.h +72 -0
- package/src/native/shared/app_paths.h +110 -0
- package/src/native/shared/asar.h +35 -0
- package/src/native/shared/cache_migration.h +244 -0
- package/src/native/shared/callbacks.h +57 -0
- package/src/native/shared/cef_response_filter.h +189 -0
- package/src/native/shared/chromium_flags.h +181 -0
- package/src/native/shared/config.h +66 -0
- package/src/native/shared/download_event.h +197 -0
- package/src/native/shared/ffi_helpers.h +139 -0
- package/src/native/shared/glob_match.h +59 -0
- package/src/native/shared/json_menu_parser.h +223 -0
- package/src/native/shared/mime_types.h +101 -0
- package/src/native/shared/navigation_rules.h +98 -0
- package/src/native/shared/partition_context.h +137 -0
- package/src/native/shared/pending_resize_queue.h +45 -0
- package/src/native/shared/permissions.h +118 -0
- package/src/native/shared/permissions_cef.h +74 -0
- package/src/native/shared/preload_script.h +71 -0
- package/src/native/shared/shutdown_guard.h +134 -0
- package/src/native/shared/thread_safe_map.h +138 -0
- package/src/native/shared/webview_storage.h +91 -0
- package/src/native/win/cef_process_helper_win.cpp +143 -0
- package/src/native/win/dcomp_compositor.h +352 -0
- package/src/native/win/nativeWrapper.cpp +12434 -0
- package/src/npmbin/index.js +34 -0
- package/src/shared/bun-version.ts +3 -0
- package/src/shared/cef-version.ts +5 -0
- package/src/shared/naming.test.ts +327 -0
- package/src/shared/naming.ts +188 -0
- package/src/shared/platform.ts +48 -0
- package/src/shared/rpc.ts +541 -0
- package/src/shared/sparkbun-version.ts +2 -0
- package/src/types/three.d.ts +1 -0
- package/tsconfig.json +31 -0
|
@@ -0,0 +1,1131 @@
|
|
|
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
|
+
readFileSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "fs";
|
|
13
|
+
import { execSync } from "child_process";
|
|
14
|
+
import { OS as currentOS, ARCH as currentArch } from "../../shared/platform";
|
|
15
|
+
import { getPlatformPrefix, getTarballFileName } from "../../shared/naming";
|
|
16
|
+
import { quit } from "./Utils";
|
|
17
|
+
|
|
18
|
+
// Update status types for granular progress tracking
|
|
19
|
+
export type UpdateStatusType =
|
|
20
|
+
| "idle"
|
|
21
|
+
| "checking"
|
|
22
|
+
| "check-complete"
|
|
23
|
+
| "no-update"
|
|
24
|
+
| "update-available"
|
|
25
|
+
| "downloading"
|
|
26
|
+
| "download-starting"
|
|
27
|
+
| "checking-local-tar"
|
|
28
|
+
| "local-tar-found"
|
|
29
|
+
| "local-tar-missing"
|
|
30
|
+
| "fetching-patch"
|
|
31
|
+
| "patch-found"
|
|
32
|
+
| "patch-not-found"
|
|
33
|
+
| "downloading-patch"
|
|
34
|
+
| "applying-patch"
|
|
35
|
+
| "patch-applied"
|
|
36
|
+
| "patch-failed"
|
|
37
|
+
| "extracting-version"
|
|
38
|
+
| "patch-chain-complete"
|
|
39
|
+
| "downloading-full-bundle"
|
|
40
|
+
| "download-progress"
|
|
41
|
+
| "decompressing"
|
|
42
|
+
| "download-complete"
|
|
43
|
+
| "applying"
|
|
44
|
+
| "extracting"
|
|
45
|
+
| "replacing-app"
|
|
46
|
+
| "launching-new-version"
|
|
47
|
+
| "complete"
|
|
48
|
+
| "error";
|
|
49
|
+
|
|
50
|
+
export interface UpdateStatusDetails {
|
|
51
|
+
fromHash?: string;
|
|
52
|
+
toHash?: string;
|
|
53
|
+
currentHash?: string;
|
|
54
|
+
latestHash?: string;
|
|
55
|
+
patchNumber?: number;
|
|
56
|
+
totalPatchesApplied?: number;
|
|
57
|
+
progress?: number;
|
|
58
|
+
bytesDownloaded?: number;
|
|
59
|
+
totalBytes?: number;
|
|
60
|
+
usedPatchPath?: boolean;
|
|
61
|
+
errorMessage?: string;
|
|
62
|
+
url?: string;
|
|
63
|
+
exitCode?: number | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface UpdateStatusEntry {
|
|
67
|
+
status: UpdateStatusType;
|
|
68
|
+
message: string;
|
|
69
|
+
timestamp: number;
|
|
70
|
+
details?: UpdateStatusDetails;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Status history and callback
|
|
74
|
+
const statusHistory: UpdateStatusEntry[] = [];
|
|
75
|
+
let onStatusChangeCallback: ((entry: UpdateStatusEntry) => void) | null = null;
|
|
76
|
+
|
|
77
|
+
function emitStatus(
|
|
78
|
+
status: UpdateStatusType,
|
|
79
|
+
message: string,
|
|
80
|
+
details?: UpdateStatusDetails,
|
|
81
|
+
): void {
|
|
82
|
+
const entry: UpdateStatusEntry = {
|
|
83
|
+
status,
|
|
84
|
+
message,
|
|
85
|
+
timestamp: Date.now(),
|
|
86
|
+
details,
|
|
87
|
+
};
|
|
88
|
+
statusHistory.push(entry);
|
|
89
|
+
if (onStatusChangeCallback) {
|
|
90
|
+
onStatusChangeCallback(entry);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// setTimeout(async () => {
|
|
95
|
+
// console.log('killing')
|
|
96
|
+
// const { native } = await import('../proc/native');
|
|
97
|
+
// native.symbols.killApp();
|
|
98
|
+
// }, 1000)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
// Cross-platform app data directory
|
|
102
|
+
function getAppDataDir(): string {
|
|
103
|
+
switch (currentOS) {
|
|
104
|
+
case "macos":
|
|
105
|
+
return join(homedir(), "Library", "Application Support");
|
|
106
|
+
case "win":
|
|
107
|
+
// Use LOCALAPPDATA to match extractor location
|
|
108
|
+
return process.env["LOCALAPPDATA"] || join(homedir(), "AppData", "Local");
|
|
109
|
+
case "linux":
|
|
110
|
+
// Use XDG_DATA_HOME or fallback to ~/.local/share to match extractor
|
|
111
|
+
return process.env["XDG_DATA_HOME"] || join(homedir(), ".local", "share");
|
|
112
|
+
default:
|
|
113
|
+
// Fallback to home directory with .config
|
|
114
|
+
return join(homedir(), ".config");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
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.gz",
|
|
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
|
+
try {
|
|
684
|
+
const compressed = readFileSync(prevVersionCompressedTarballPath);
|
|
685
|
+
const decompressed = Bun.gunzipSync(compressed);
|
|
686
|
+
writeFileSync(latestTarPath, decompressed);
|
|
687
|
+
emitStatus("decompressing", "Decompression complete");
|
|
688
|
+
} catch (err) {
|
|
689
|
+
updateInfo.error = `Decompression failed: ${err}`;
|
|
690
|
+
emitStatus("error", updateInfo.error);
|
|
691
|
+
console.error("Decompression failed:", err);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
unlinkSync(prevVersionCompressedTarballPath);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Note: Bun.file().exists() caches the result, so we nee d an new instance of Bun.file() here
|
|
699
|
+
// to check again
|
|
700
|
+
if (await Bun.file(latestTarPath).exists()) {
|
|
701
|
+
// download patch for this version, apply it.
|
|
702
|
+
// check for patch from that tar and apply it, until it matches the latest version
|
|
703
|
+
// as a fallback it should just download and unpack the latest version
|
|
704
|
+
updateInfo.updateReady = true;
|
|
705
|
+
emitStatus(
|
|
706
|
+
"download-complete",
|
|
707
|
+
`Update ready to install (used ${usedPatchPath ? "patch" : "full download"} path)`,
|
|
708
|
+
{
|
|
709
|
+
latestHash,
|
|
710
|
+
usedPatchPath,
|
|
711
|
+
totalPatchesApplied: patchesApplied,
|
|
712
|
+
},
|
|
713
|
+
);
|
|
714
|
+
} else {
|
|
715
|
+
updateInfo.error = "Failed to download latest version";
|
|
716
|
+
emitStatus("error", "Failed to download latest version", { latestHash });
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Clean up stale files in the extraction folder (old tars, patches, backups, etc.)
|
|
720
|
+
cleanupExtractionFolder(extractionFolder, latestHash);
|
|
721
|
+
},
|
|
722
|
+
|
|
723
|
+
applyUpdate: async () => {
|
|
724
|
+
if (updateInfo?.updateReady) {
|
|
725
|
+
emitStatus("applying", "Starting update installation...");
|
|
726
|
+
const appDataFolder = await Updater.appDataFolder();
|
|
727
|
+
const extractionFolder = join(appDataFolder, "self-extraction");
|
|
728
|
+
if (!(await Bun.file(extractionFolder).exists())) {
|
|
729
|
+
mkdirSync(extractionFolder, { recursive: true });
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
let latestHash = (await Updater.checkForUpdate()).hash;
|
|
733
|
+
const latestTarPath = join(extractionFolder, `${latestHash}.tar`);
|
|
734
|
+
|
|
735
|
+
let appBundleSubpath: string = "";
|
|
736
|
+
|
|
737
|
+
if (await Bun.file(latestTarPath).exists()) {
|
|
738
|
+
emitStatus(
|
|
739
|
+
"extracting",
|
|
740
|
+
`Extracting update to ${latestHash.slice(0, 8)}...`,
|
|
741
|
+
{ latestHash },
|
|
742
|
+
);
|
|
743
|
+
|
|
744
|
+
// Windows needs a temporary directory to avoid file locking issues
|
|
745
|
+
const extractionDir =
|
|
746
|
+
currentOS === "win"
|
|
747
|
+
? join(extractionFolder, `temp-${latestHash}`)
|
|
748
|
+
: extractionFolder;
|
|
749
|
+
|
|
750
|
+
if (currentOS === "win") {
|
|
751
|
+
mkdirSync(extractionDir, { recursive: true });
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const latestTarBytes = await Bun.file(latestTarPath).arrayBuffer();
|
|
755
|
+
const latestArchive = new Bun.Archive(latestTarBytes);
|
|
756
|
+
await latestArchive.extract(extractionDir);
|
|
757
|
+
|
|
758
|
+
if (currentOS === "macos") {
|
|
759
|
+
// Find the .app bundle by scanning extracted directory
|
|
760
|
+
const extractedFiles = readdirSync(extractionDir);
|
|
761
|
+
for (const file of extractedFiles) {
|
|
762
|
+
if (file.endsWith('.app')) {
|
|
763
|
+
appBundleSubpath = file + "/";
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
} else {
|
|
768
|
+
appBundleSubpath = "./";
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
console.log(
|
|
772
|
+
`Tar extraction completed. Found appBundleSubpath: ${appBundleSubpath}`,
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
if (!appBundleSubpath) {
|
|
776
|
+
console.error("Failed to find app in tarball");
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Note: resolve here removes the extra trailing / that the tar file adds
|
|
781
|
+
const extractedAppPath = resolve(join(extractionDir, appBundleSubpath));
|
|
782
|
+
|
|
783
|
+
// Platform-specific path handling
|
|
784
|
+
let newAppBundlePath: string;
|
|
785
|
+
if (currentOS === "linux") {
|
|
786
|
+
// On Linux, the tarball contains a directory bundle
|
|
787
|
+
// Find the actual extracted app directory name instead of guessing
|
|
788
|
+
const extractedFiles = readdirSync(extractionDir);
|
|
789
|
+
const appBundleDir = extractedFiles.find(file => {
|
|
790
|
+
const filePath = join(extractionDir, file);
|
|
791
|
+
return statSync(filePath).isDirectory() && !file.endsWith('.tar');
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
if (!appBundleDir) {
|
|
795
|
+
console.error("Could not find app bundle directory in extraction");
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
newAppBundlePath = join(extractionDir, appBundleDir);
|
|
800
|
+
|
|
801
|
+
// Verify the app bundle directory exists
|
|
802
|
+
const bundleStats = statSync(newAppBundlePath, { throwIfNoEntry: false });
|
|
803
|
+
if (!bundleStats || !bundleStats.isDirectory()) {
|
|
804
|
+
console.error(`App bundle directory not found at: ${newAppBundlePath}`);
|
|
805
|
+
console.log("Contents of extraction directory:");
|
|
806
|
+
try {
|
|
807
|
+
const files = readdirSync(extractionDir);
|
|
808
|
+
for (const file of files) {
|
|
809
|
+
console.log(` - ${file}`);
|
|
810
|
+
// Also list contents of subdirectories
|
|
811
|
+
const subPath = join(extractionDir, file);
|
|
812
|
+
if (statSync(subPath).isDirectory()) {
|
|
813
|
+
const subFiles = readdirSync(subPath);
|
|
814
|
+
for (const subFile of subFiles) {
|
|
815
|
+
console.log(` - ${subFile}`);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
} catch (e) {
|
|
820
|
+
console.log("Could not list directory contents:", e);
|
|
821
|
+
}
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
} else if (currentOS === "win") {
|
|
825
|
+
// On Windows, the actual app is inside a subdirectory.
|
|
826
|
+
// version.json's `name` field already contains the formatted app
|
|
827
|
+
// file name (e.g. "MyApp-canary" for non-stable, "MyApp" for stable),
|
|
828
|
+
// so don't re-apply getAppFileName or it doubles the channel suffix.
|
|
829
|
+
newAppBundlePath = join(extractionDir, localInfo.name);
|
|
830
|
+
|
|
831
|
+
// Verify the extracted app exists
|
|
832
|
+
if (!statSync(newAppBundlePath, { throwIfNoEntry: false })) {
|
|
833
|
+
console.error(`Extracted app not found at: ${newAppBundlePath}`);
|
|
834
|
+
console.log("Contents of extraction directory:");
|
|
835
|
+
try {
|
|
836
|
+
const files = readdirSync(extractionDir);
|
|
837
|
+
for (const file of files) {
|
|
838
|
+
console.log(` - ${file}`);
|
|
839
|
+
}
|
|
840
|
+
} catch (e) {
|
|
841
|
+
console.log("Could not list directory contents:", e);
|
|
842
|
+
}
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
} else {
|
|
846
|
+
// On macOS, use the extracted app path directly
|
|
847
|
+
newAppBundlePath = extractedAppPath;
|
|
848
|
+
}
|
|
849
|
+
// Platform-specific app path calculation
|
|
850
|
+
let runningAppBundlePath: string;
|
|
851
|
+
const appDataFolder = await Updater.appDataFolder();
|
|
852
|
+
|
|
853
|
+
if (currentOS === "macos") {
|
|
854
|
+
// On macOS, executable is at Contents/MacOS/binary inside .app bundle
|
|
855
|
+
runningAppBundlePath = resolve(dirname(process.execPath), "..", "..");
|
|
856
|
+
} else if (currentOS === "linux" || currentOS === "win") {
|
|
857
|
+
// On Linux and Windows, use fixed 'app' folder to match extractor
|
|
858
|
+
runningAppBundlePath = join(appDataFolder, "app");
|
|
859
|
+
} else {
|
|
860
|
+
throw new Error(`Unsupported platform: ${currentOS}`);
|
|
861
|
+
}
|
|
862
|
+
try {
|
|
863
|
+
emitStatus("replacing-app", "Removing old version...");
|
|
864
|
+
|
|
865
|
+
if (currentOS === "macos") {
|
|
866
|
+
// Remove existing app before installing the new one
|
|
867
|
+
if (statSync(runningAppBundlePath, { throwIfNoEntry: false })) {
|
|
868
|
+
rmSync(runningAppBundlePath, { recursive: true });
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
emitStatus("replacing-app", "Installing new version...");
|
|
872
|
+
// Move new app to running location
|
|
873
|
+
renameSync(newAppBundlePath, runningAppBundlePath);
|
|
874
|
+
|
|
875
|
+
// Remove quarantine extended attributes to prevent "damaged" error
|
|
876
|
+
// The inner bundle is already signed/notarized, but macOS applies
|
|
877
|
+
// quarantine attributes when extracting from a downloaded archive
|
|
878
|
+
try {
|
|
879
|
+
execSync(
|
|
880
|
+
`xattr -r -d com.apple.quarantine "${runningAppBundlePath}"`,
|
|
881
|
+
{ stdio: "ignore" },
|
|
882
|
+
);
|
|
883
|
+
} catch (e) {
|
|
884
|
+
// Ignore errors - attribute may not exist
|
|
885
|
+
}
|
|
886
|
+
} else if (currentOS === "linux") {
|
|
887
|
+
// On Linux, we now have directory bundles instead of AppImage files
|
|
888
|
+
// The app is stored in {appDataFolder}/app/
|
|
889
|
+
const appBundleDir = join(appDataFolder, "app");
|
|
890
|
+
|
|
891
|
+
// Remove existing app directory if it exists
|
|
892
|
+
if (statSync(appBundleDir, { throwIfNoEntry: false })) {
|
|
893
|
+
rmSync(appBundleDir, { recursive: true });
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// Move new app bundle directory to app location
|
|
897
|
+
renameSync(newAppBundlePath, appBundleDir);
|
|
898
|
+
|
|
899
|
+
// Ensure launcher binary is executable
|
|
900
|
+
const launcherPath = join(appBundleDir, "bin", "launcher");
|
|
901
|
+
if (statSync(launcherPath, { throwIfNoEntry: false })) {
|
|
902
|
+
execSync(`chmod +x "${launcherPath}"`);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Also ensure other binaries are executable
|
|
906
|
+
const bunPath = join(appBundleDir, "bin", "bun");
|
|
907
|
+
if (statSync(bunPath, { throwIfNoEntry: false })) {
|
|
908
|
+
execSync(`chmod +x "${bunPath}"`);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Clean up stale files in extraction folder
|
|
913
|
+
if (currentOS !== "win") {
|
|
914
|
+
cleanupExtractionFolder(extractionFolder, latestHash);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (currentOS === "win") {
|
|
918
|
+
// On Windows, files are locked while in use, so we need a helper script
|
|
919
|
+
// that runs after the app exits to do the replacement
|
|
920
|
+
const parentDir = dirname(runningAppBundlePath);
|
|
921
|
+
const updateScriptPath = join(parentDir, "update.bat");
|
|
922
|
+
const launcherPath = join(
|
|
923
|
+
runningAppBundlePath,
|
|
924
|
+
"bin",
|
|
925
|
+
"launcher.exe",
|
|
926
|
+
);
|
|
927
|
+
|
|
928
|
+
// Convert paths to Windows format
|
|
929
|
+
const runningAppWin = runningAppBundlePath.replace(/\//g, "\\");
|
|
930
|
+
const newAppWin = newAppBundlePath.replace(/\//g, "\\");
|
|
931
|
+
const extractionDirWin = extractionDir.replace(/\//g, "\\");
|
|
932
|
+
const launcherPathWin = launcherPath.replace(/\//g, "\\");
|
|
933
|
+
|
|
934
|
+
// Create a batch script that will:
|
|
935
|
+
// 1. Wait for the current app and its helper processes to exit
|
|
936
|
+
// 2. Remove current app folder (with retries — CEF helpers may briefly
|
|
937
|
+
// keep libcef.dll locked after launcher.exe exits)
|
|
938
|
+
// 3. Move new app to current location (only if old folder is fully gone,
|
|
939
|
+
// otherwise `move` would put it inside as a subdirectory)
|
|
940
|
+
// 4. Launch the new app
|
|
941
|
+
// 5. Clean up
|
|
942
|
+
const updateScript = `@echo off
|
|
943
|
+
setlocal
|
|
944
|
+
|
|
945
|
+
:: Wait for the app and any CEF helper processes to fully exit.
|
|
946
|
+
:: launcher.exe spawns bun.exe which spawns "bun Helper*.exe" processes that
|
|
947
|
+
:: keep libcef.dll locked; if we proceed too early, rmdir partially fails.
|
|
948
|
+
:waitloop
|
|
949
|
+
tasklist /FI "IMAGENAME eq launcher.exe" 2>NUL | find /I /N "launcher.exe">NUL && goto waitsleep
|
|
950
|
+
tasklist /FI "IMAGENAME eq bun.exe" 2>NUL | find /I /N "bun.exe">NUL && goto waitsleep
|
|
951
|
+
tasklist /FI "IMAGENAME eq bun Helper.exe" 2>NUL | find /I /N "bun Helper.exe">NUL && goto waitsleep
|
|
952
|
+
tasklist 2>NUL | find /I "bun Helper">NUL && goto waitsleep
|
|
953
|
+
goto waitdone
|
|
954
|
+
:waitsleep
|
|
955
|
+
timeout /t 1 /nobreak >nul
|
|
956
|
+
goto waitloop
|
|
957
|
+
:waitdone
|
|
958
|
+
|
|
959
|
+
:: Small extra delay to ensure all file handles are released
|
|
960
|
+
timeout /t 2 /nobreak >nul
|
|
961
|
+
|
|
962
|
+
:: Remove current app folder, retrying if rmdir fails (locked files etc.)
|
|
963
|
+
set rmRetry=0
|
|
964
|
+
:rmloop
|
|
965
|
+
if not exist "${runningAppWin}" goto rmdone
|
|
966
|
+
rmdir /s /q "${runningAppWin}" 2>nul
|
|
967
|
+
if not exist "${runningAppWin}" goto rmdone
|
|
968
|
+
set /a rmRetry=rmRetry+1
|
|
969
|
+
if %rmRetry% GEQ 10 goto rmfailed
|
|
970
|
+
timeout /t 2 /nobreak >nul
|
|
971
|
+
goto rmloop
|
|
972
|
+
:rmfailed
|
|
973
|
+
echo Update failed: could not remove "${runningAppWin}" after retries.
|
|
974
|
+
echo Files may still be locked by a helper process.
|
|
975
|
+
pause
|
|
976
|
+
exit /b 1
|
|
977
|
+
:rmdone
|
|
978
|
+
|
|
979
|
+
:: Move new app to current location (safe now that destination is gone)
|
|
980
|
+
move "${newAppWin}" "${runningAppWin}"
|
|
981
|
+
if not exist "${launcherPathWin}" (
|
|
982
|
+
echo Update failed: launcher not found at "${launcherPathWin}" after move.
|
|
983
|
+
pause
|
|
984
|
+
exit /b 1
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
:: Clean up extraction directory
|
|
988
|
+
rmdir /s /q "${extractionDirWin}" 2>nul
|
|
989
|
+
|
|
990
|
+
:: Launch the new app
|
|
991
|
+
start "" "${launcherPathWin}"
|
|
992
|
+
|
|
993
|
+
:: Clean up scheduled tasks starting with SparkBunUpdate_
|
|
994
|
+
for /f "tokens=1" %%t in ('schtasks /query /fo list ^| findstr /i "SparkBunUpdate_"') do (
|
|
995
|
+
schtasks /delete /tn "%%t" /f >nul 2>&1
|
|
996
|
+
)
|
|
997
|
+
|
|
998
|
+
:: Delete this update script after a short delay
|
|
999
|
+
ping -n 2 127.0.0.1 >nul
|
|
1000
|
+
del "%~f0"
|
|
1001
|
+
`;
|
|
1002
|
+
|
|
1003
|
+
await Bun.write(updateScriptPath, updateScript);
|
|
1004
|
+
|
|
1005
|
+
// Use Windows Task Scheduler to run the update script independently
|
|
1006
|
+
// This ensures the script runs even after the app exits
|
|
1007
|
+
const scriptPathWin = updateScriptPath.replace(/\//g, "\\");
|
|
1008
|
+
const taskName = `SparkBunUpdate_${Date.now()}`;
|
|
1009
|
+
|
|
1010
|
+
// Create a scheduled task that runs immediately and deletes itself
|
|
1011
|
+
execSync(
|
|
1012
|
+
`schtasks /create /tn "${taskName}" /tr "cmd /c \\"${scriptPathWin}\\"" /sc once /st 00:00 /f`,
|
|
1013
|
+
{ stdio: "ignore" },
|
|
1014
|
+
);
|
|
1015
|
+
execSync(`schtasks /run /tn "${taskName}"`, { stdio: "ignore" });
|
|
1016
|
+
// The task will be cleaned up by Windows after it runs, or we delete it in the batch script
|
|
1017
|
+
|
|
1018
|
+
// Use quit() for graceful shutdown - this closes all windows and processes
|
|
1019
|
+
quit();
|
|
1020
|
+
}
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
emitStatus(
|
|
1023
|
+
"error",
|
|
1024
|
+
`Failed to replace app: ${(error as Error).message}`,
|
|
1025
|
+
{
|
|
1026
|
+
errorMessage: (error as Error).message,
|
|
1027
|
+
},
|
|
1028
|
+
);
|
|
1029
|
+
console.error("Failed to replace app with new version", error);
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
emitStatus("launching-new-version", "Launching updated version...");
|
|
1034
|
+
|
|
1035
|
+
// Cross-platform app launch (Windows is handled above with its own update script)
|
|
1036
|
+
if (currentOS === "macos") {
|
|
1037
|
+
// Wait for the current process to fully exit before relaunching.
|
|
1038
|
+
// macOS 'open' on an already-running app just activates the existing
|
|
1039
|
+
// instance instead of launching a new one, so we must ensure the
|
|
1040
|
+
// current process has exited first. The detached shell survives our
|
|
1041
|
+
// exit and polls until the process is gone.
|
|
1042
|
+
const pid = process.pid;
|
|
1043
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1044
|
+
Bun.spawn(
|
|
1045
|
+
[
|
|
1046
|
+
"sh",
|
|
1047
|
+
"-c",
|
|
1048
|
+
`while kill -0 ${pid} 2>/dev/null; do sleep 0.5; done; sleep 1; open "${runningAppBundlePath}"`,
|
|
1049
|
+
],
|
|
1050
|
+
{
|
|
1051
|
+
detached: true,
|
|
1052
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
1053
|
+
} as any,
|
|
1054
|
+
);
|
|
1055
|
+
} else if (currentOS === "linux") {
|
|
1056
|
+
// On Linux, launch the launcher binary inside the app directory
|
|
1057
|
+
const launcherPath = join(runningAppBundlePath, "bin", "launcher");
|
|
1058
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1059
|
+
Bun.spawn(["sh", "-c", `"${launcherPath}" &`], {
|
|
1060
|
+
detached: true,
|
|
1061
|
+
} as any);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
emitStatus("complete", "Update complete, restarting application...");
|
|
1065
|
+
// Use quit() for graceful shutdown
|
|
1066
|
+
quit();
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
},
|
|
1070
|
+
|
|
1071
|
+
channelBucketUrl: async () => {
|
|
1072
|
+
await Updater.getLocalInfo();
|
|
1073
|
+
// With flat prefix-based naming, channelBucketUrl is just the baseUrl
|
|
1074
|
+
// Users can also use Updater.localInfo.baseUrl() directly
|
|
1075
|
+
return localInfo.baseUrl;
|
|
1076
|
+
},
|
|
1077
|
+
|
|
1078
|
+
appDataFolder: async () => {
|
|
1079
|
+
await Updater.getLocalInfo();
|
|
1080
|
+
// Use identifier + channel for the app data folder
|
|
1081
|
+
// e.g., ~/Library/Application Support/sh.blackboard.myapp/canary/
|
|
1082
|
+
const appDataFolder = join(
|
|
1083
|
+
getAppDataDir(),
|
|
1084
|
+
localInfo.identifier,
|
|
1085
|
+
localInfo.channel,
|
|
1086
|
+
);
|
|
1087
|
+
|
|
1088
|
+
return appDataFolder;
|
|
1089
|
+
},
|
|
1090
|
+
|
|
1091
|
+
// TODO: consider moving this from "Updater.localInfo" to "BuildVars"
|
|
1092
|
+
localInfo: {
|
|
1093
|
+
version: async () => {
|
|
1094
|
+
return (await Updater.getLocalInfo()).version;
|
|
1095
|
+
},
|
|
1096
|
+
hash: async () => {
|
|
1097
|
+
return (await Updater.getLocalInfo()).hash;
|
|
1098
|
+
},
|
|
1099
|
+
channel: async () => {
|
|
1100
|
+
return (await Updater.getLocalInfo()).channel;
|
|
1101
|
+
},
|
|
1102
|
+
baseUrl: async () => {
|
|
1103
|
+
return (await Updater.getLocalInfo()).baseUrl;
|
|
1104
|
+
},
|
|
1105
|
+
},
|
|
1106
|
+
|
|
1107
|
+
getLocalInfo: async () => {
|
|
1108
|
+
if (localInfo) {
|
|
1109
|
+
return localInfo;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
try {
|
|
1113
|
+
const resourcesDir = "Resources"; // Always use capitalized Resources
|
|
1114
|
+
localInfo = await Bun.file(`../${resourcesDir}/version.json`).json();
|
|
1115
|
+
return localInfo;
|
|
1116
|
+
} catch (error) {
|
|
1117
|
+
console.error("Failed to read version.json", error);
|
|
1118
|
+
localInfo = { identifier: "", channel: "", version: "", hash: "", baseUrl: "", name: "" };
|
|
1119
|
+
return localInfo;
|
|
1120
|
+
}
|
|
1121
|
+
},
|
|
1122
|
+
getLocallocalInfo: async () => {
|
|
1123
|
+
console.error(
|
|
1124
|
+
"[SparkBun] Updater.getLocallocalInfo() is deprecated. Use Updater.getLocalInfo() instead.",
|
|
1125
|
+
);
|
|
1126
|
+
|
|
1127
|
+
return Updater.getLocalInfo();
|
|
1128
|
+
},
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
export { Updater };
|