electrobun 0.0.18 → 0.0.19-beta.6

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,1527 @@
1
+ import { join, dirname, basename } from "path";
2
+ import {
3
+ existsSync,
4
+ readFileSync,
5
+ cpSync,
6
+ rmdirSync,
7
+ mkdirSync,
8
+ createWriteStream,
9
+ unlinkSync,
10
+ } from "fs";
11
+ import { execSync } from "child_process";
12
+ import tar from "tar";
13
+ import { ZstdInit } from "@oneidentity/zstd-js/wasm";
14
+ import {platform, arch} from 'os';
15
+ // import { loadBsdiff, loadBspatch } from 'bsdiff-wasm';
16
+ // MacOS named pipes hang at around 4KB
17
+ const MAX_CHUNK_SIZE = 1024 * 2;
18
+
19
+ // TODO: dedup with built.ts
20
+ const OS: 'win' | 'linux' | 'macos' = getPlatform();
21
+ const ARCH: 'arm64' | 'x64' = getArch();
22
+
23
+ function getPlatform() {
24
+ switch (platform()) {
25
+ case "win32":
26
+ return 'win';
27
+ case "darwin":
28
+ return 'macos';
29
+ case 'linux':
30
+ return 'linux';
31
+ default:
32
+ throw 'unsupported platform';
33
+ }
34
+ }
35
+
36
+ function getArch() {
37
+ switch (arch()) {
38
+ case "arm64":
39
+ return 'arm64';
40
+ case "x64":
41
+ return 'x64';
42
+ default:
43
+ throw 'unsupported arch'
44
+ }
45
+ }
46
+
47
+
48
+ const binExt = OS === 'win' ? '.exe' : '';
49
+
50
+ // this when run as an npm script this will be where the folder where package.json is.
51
+ const projectRoot = process.cwd();
52
+ const configName = "electrobun.config";
53
+ const configPath = join(projectRoot, configName);
54
+
55
+ // Note: cli args can be called via npm bun /path/to/electorbun/binary arg1 arg2
56
+ const indexOfElectrobun = process.argv.findIndex((arg) =>
57
+ arg.includes("electrobun")
58
+ );
59
+ const commandArg = process.argv[indexOfElectrobun + 1] || "launcher";
60
+
61
+ const ELECTROBUN_DEP_PATH = join(projectRoot, "node_modules", "electrobun");
62
+
63
+ // When debugging electrobun with the example app use the builds (dev or release) right from the source folder
64
+ // For developers using electrobun cli via npm use the release versions in /dist
65
+ // This lets us not have to commit src build folders to git and provide pre-built binaries
66
+ const PATHS = {
67
+ BUN_BINARY: join(ELECTROBUN_DEP_PATH, "dist", "bun") + binExt,
68
+ LAUNCHER_DEV: join(ELECTROBUN_DEP_PATH, "dist", "electrobun") + binExt,
69
+ LAUNCHER_RELEASE: join(ELECTROBUN_DEP_PATH, "dist", "launcher") + binExt,
70
+ MAIN_JS: join(ELECTROBUN_DEP_PATH, "dist", "main.js"),
71
+ NATIVE_WRAPPER_MACOS: join(
72
+ ELECTROBUN_DEP_PATH,
73
+ "dist",
74
+ "libNativeWrapper.dylib"
75
+ ),
76
+ NATIVE_WRAPPER_WIN: join(ELECTROBUN_DEP_PATH, "dist", "libNativeWrapper.dll"),
77
+ NATIVE_WRAPPER_LINUX: join(ELECTROBUN_DEP_PATH, "dist", "libNativeWrapper.so"),
78
+ WEBVIEW2LOADER_WIN: join(ELECTROBUN_DEP_PATH, "dist", "WebView2Loader.dll"),
79
+ BSPATCH: join(ELECTROBUN_DEP_PATH, "dist", "bspatch") + binExt,
80
+ EXTRACTOR: join(ELECTROBUN_DEP_PATH, "dist", "extractor") + binExt,
81
+ BSDIFF: join(ELECTROBUN_DEP_PATH, "dist", "bsdiff") + binExt,
82
+ CEF_FRAMEWORK_MACOS: join(
83
+ ELECTROBUN_DEP_PATH,
84
+ "dist",
85
+ "cef",
86
+ "Chromium Embedded Framework.framework"
87
+ ),
88
+ CEF_HELPER_MACOS: join(ELECTROBUN_DEP_PATH, "dist", "cef", "process_helper"),
89
+ CEF_HELPER_WIN: join(ELECTROBUN_DEP_PATH, "dist", "cef", "process_helper.exe"),
90
+ CEF_HELPER_LINUX: join(ELECTROBUN_DEP_PATH, "dist", "cef", "process_helper"),
91
+ CEF_DIR: join(ELECTROBUN_DEP_PATH, "dist", "cef"),
92
+ };
93
+
94
+ async function ensureCoreDependencies() {
95
+ // Check if core dependencies already exist
96
+ if (existsSync(PATHS.BUN_BINARY) && existsSync(PATHS.LAUNCHER_RELEASE)) {
97
+ return;
98
+ }
99
+
100
+ console.log('Core dependencies not found, downloading...');
101
+
102
+ // Get the current Electrobun version from package.json
103
+ const packageJsonPath = join(ELECTROBUN_DEP_PATH, 'package.json');
104
+ let version = 'latest';
105
+
106
+ if (existsSync(packageJsonPath)) {
107
+ try {
108
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
109
+ version = `v${packageJson.version}`;
110
+ } catch (error) {
111
+ console.warn('Could not read package version, using latest');
112
+ }
113
+ }
114
+
115
+ const platformName = OS === 'macos' ? 'darwin' : OS === 'win' ? 'win32' : 'linux';
116
+ const archName = ARCH;
117
+ const mainTarballUrl = `https://github.com/blackboardsh/electrobun/releases/download/${version}/electrobun-${platformName}-${archName}.tar.gz`;
118
+
119
+ console.log(`Downloading core binaries from: ${mainTarballUrl}`);
120
+
121
+ try {
122
+ // Download main tarball
123
+ const response = await fetch(mainTarballUrl);
124
+ if (!response.ok) {
125
+ throw new Error(`Failed to download binaries: ${response.status} ${response.statusText}`);
126
+ }
127
+
128
+ // Create temp file
129
+ const tempFile = join(ELECTROBUN_DEP_PATH, 'main-temp.tar.gz');
130
+ const fileStream = createWriteStream(tempFile);
131
+
132
+ // Write response to file
133
+ if (response.body) {
134
+ const reader = response.body.getReader();
135
+ while (true) {
136
+ const { done, value } = await reader.read();
137
+ if (done) break;
138
+ fileStream.write(Buffer.from(value));
139
+ }
140
+ }
141
+ fileStream.end();
142
+
143
+ // Extract to dist directory
144
+ console.log('Extracting core dependencies...');
145
+ await tar.x({
146
+ file: tempFile,
147
+ cwd: join(ELECTROBUN_DEP_PATH, 'dist'),
148
+ });
149
+
150
+ // Clean up temp file
151
+ unlinkSync(tempFile);
152
+
153
+ console.log('Core dependencies downloaded and cached successfully');
154
+
155
+ } catch (error) {
156
+ console.error('Failed to download core dependencies:', error.message);
157
+ console.error('Please ensure you have an internet connection and the release exists.');
158
+ process.exit(1);
159
+ }
160
+ }
161
+
162
+ async function ensureCEFDependencies() {
163
+ // Check if CEF dependencies already exist
164
+ if (existsSync(PATHS.CEF_DIR)) {
165
+ console.log('CEF dependencies found, using cached version');
166
+ return;
167
+ }
168
+
169
+ console.log('CEF dependencies not found, downloading...');
170
+
171
+ // Get the current Electrobun version from package.json
172
+ const packageJsonPath = join(ELECTROBUN_DEP_PATH, 'package.json');
173
+ let version = 'latest';
174
+
175
+ if (existsSync(packageJsonPath)) {
176
+ try {
177
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
178
+ version = `v${packageJson.version}`;
179
+ } catch (error) {
180
+ console.warn('Could not read package version, using latest');
181
+ }
182
+ }
183
+
184
+ const platformName = OS === 'macos' ? 'darwin' : OS === 'win' ? 'win32' : 'linux';
185
+ const archName = ARCH;
186
+ const cefTarballUrl = `https://github.com/blackboardsh/electrobun/releases/download/${version}/electrobun-cef-${platformName}-${archName}.tar.gz`;
187
+
188
+ console.log(`Downloading CEF from: ${cefTarballUrl}`);
189
+
190
+ try {
191
+ // Download CEF tarball
192
+ const response = await fetch(cefTarballUrl);
193
+ if (!response.ok) {
194
+ throw new Error(`Failed to download CEF: ${response.status} ${response.statusText}`);
195
+ }
196
+
197
+ // Create temp file
198
+ const tempFile = join(ELECTROBUN_DEP_PATH, 'cef-temp.tar.gz');
199
+ const fileStream = createWriteStream(tempFile);
200
+
201
+ // Write response to file
202
+ if (response.body) {
203
+ const reader = response.body.getReader();
204
+ while (true) {
205
+ const { done, value } = await reader.read();
206
+ if (done) break;
207
+ fileStream.write(Buffer.from(value));
208
+ }
209
+ }
210
+ fileStream.end();
211
+
212
+ // Extract to dist directory
213
+ console.log('Extracting CEF dependencies...');
214
+ await tar.x({
215
+ file: tempFile,
216
+ cwd: join(ELECTROBUN_DEP_PATH, 'dist'),
217
+ });
218
+
219
+ // Clean up temp file
220
+ unlinkSync(tempFile);
221
+
222
+ console.log('CEF dependencies downloaded and cached successfully');
223
+
224
+ } catch (error) {
225
+ console.error('Failed to download CEF dependencies:', error.message);
226
+ console.error('Please ensure you have an internet connection and the release exists.');
227
+ process.exit(1);
228
+ }
229
+ }
230
+
231
+ const commandDefaults = {
232
+ init: {
233
+ projectRoot,
234
+ config: "electrobun.config",
235
+ },
236
+ build: {
237
+ projectRoot,
238
+ config: "electrobun.config",
239
+ },
240
+ dev: {
241
+ projectRoot,
242
+ config: "electrobun.config",
243
+ },
244
+ launcher: {
245
+ projectRoot,
246
+ config: "electrobun.config",
247
+ },
248
+ };
249
+
250
+ // todo (yoav): add types for config
251
+ const defaultConfig = {
252
+ app: {
253
+ name: "MyApp",
254
+ identifier: "com.example.myapp",
255
+ version: "0.1.0",
256
+ },
257
+ build: {
258
+ buildFolder: "build",
259
+ artifactFolder: "artifacts",
260
+ mac: {
261
+ codesign: false,
262
+ notarize: false,
263
+ bundleCEF: false,
264
+ entitlements: {
265
+ // This entitlement is required for Electrobun apps with a hardened runtime (required for notarization) to run on macos
266
+ "com.apple.security.cs.allow-jit": true,
267
+ },
268
+ icons: "icon.iconset",
269
+ },
270
+ },
271
+ scripts: {
272
+ postBuild: "",
273
+ },
274
+ release: {
275
+ bucketUrl: "",
276
+ },
277
+ };
278
+
279
+ const command = commandDefaults[commandArg];
280
+
281
+ if (!command) {
282
+ console.error("Invalid command: ", commandArg);
283
+ process.exit(1);
284
+ }
285
+
286
+ const config = getConfig();
287
+
288
+ const envArg =
289
+ process.argv.find((arg) => arg.startsWith("env="))?.split("=")[1] || "";
290
+
291
+ const validEnvironments = ["dev", "canary", "stable"];
292
+
293
+ // todo (yoav): dev, canary, and stable;
294
+ const buildEnvironment: "dev" | "canary" | "stable" =
295
+ validEnvironments.includes(envArg) ? envArg : "dev";
296
+
297
+ // todo (yoav): dev builds should include the branch name, and/or allow configuration via external config
298
+ const buildSubFolder = `${buildEnvironment}`;
299
+
300
+ const buildFolder = join(projectRoot, config.build.buildFolder, buildSubFolder);
301
+
302
+ const artifactFolder = join(
303
+ projectRoot,
304
+ config.build.artifactFolder,
305
+ buildSubFolder
306
+ );
307
+
308
+ const buildIcons = (appBundleFolderResourcesPath: string) => {
309
+ if (OS === 'macos' && config.build.mac.icons) {
310
+ const iconSourceFolder = join(projectRoot, config.build.mac.icons);
311
+ const iconDestPath = join(appBundleFolderResourcesPath, "AppIcon.icns");
312
+ if (existsSync(iconSourceFolder)) {
313
+ Bun.spawnSync(
314
+ ["iconutil", "-c", "icns", "-o", iconDestPath, iconSourceFolder],
315
+ {
316
+ cwd: appBundleFolderResourcesPath,
317
+ stdio: ["ignore", "inherit", "inherit"],
318
+ env: {
319
+ ...process.env,
320
+ ELECTROBUN_BUILD_ENV: buildEnvironment,
321
+ },
322
+ }
323
+ );
324
+ }
325
+ }
326
+ };
327
+
328
+ function escapePathForTerminal(filePath: string) {
329
+ // List of special characters to escape
330
+ const specialChars = [
331
+ " ",
332
+ "(",
333
+ ")",
334
+ "&",
335
+ "|",
336
+ ";",
337
+ "<",
338
+ ">",
339
+ "`",
340
+ "\\",
341
+ '"',
342
+ "'",
343
+ "$",
344
+ "*",
345
+ "?",
346
+ "[",
347
+ "]",
348
+ "#",
349
+ ];
350
+
351
+ let escapedPath = "";
352
+ for (const char of filePath) {
353
+ if (specialChars.includes(char)) {
354
+ escapedPath += `\\${char}`;
355
+ } else {
356
+ escapedPath += char;
357
+ }
358
+ }
359
+
360
+ return escapedPath;
361
+ }
362
+ // MyApp
363
+
364
+ // const appName = config.app.name.replace(/\s/g, '-').toLowerCase();
365
+
366
+ const appFileName = (
367
+ buildEnvironment === "stable"
368
+ ? config.app.name
369
+ : `${config.app.name}-${buildEnvironment}`
370
+ )
371
+ .replace(/\s/g, "")
372
+ .replace(/\./g, "-");
373
+ const bundleFileName = OS === 'macos' ? `${appFileName}.app` : appFileName;
374
+
375
+ // const logPath = `/Library/Logs/Electrobun/ExampleApp/dev/out.log`;
376
+
377
+ let proc = null;
378
+
379
+ if (commandArg === "init") {
380
+ // todo (yoav): init a repo folder structure
381
+ console.log("initializing electrobun project");
382
+ } else if (commandArg === "build") {
383
+ // refresh build folder
384
+ if (existsSync(buildFolder)) {
385
+ rmdirSync(buildFolder, { recursive: true });
386
+ }
387
+ mkdirSync(buildFolder, { recursive: true });
388
+ // bundle bun to build/bun
389
+ const bunConfig = config.build.bun;
390
+ const bunSource = join(projectRoot, bunConfig.entrypoint);
391
+
392
+ if (!existsSync(bunSource)) {
393
+ console.error(
394
+ `failed to bundle ${bunSource} because it doesn't exist.\n You need a config.build.bun.entrypoint source file to build.`
395
+ );
396
+ process.exit(1);
397
+ }
398
+
399
+ // build macos bundle
400
+ const {
401
+ appBundleFolderPath,
402
+ appBundleFolderContentsPath,
403
+ appBundleMacOSPath,
404
+ appBundleFolderResourcesPath,
405
+ appBundleFolderFrameworksPath,
406
+ } = createAppBundle(appFileName, buildFolder);
407
+
408
+ const appBundleAppCodePath = join(appBundleFolderResourcesPath, "app");
409
+
410
+ mkdirSync(appBundleAppCodePath, { recursive: true });
411
+
412
+ // const bundledBunPath = join(appBundleMacOSPath, 'bun');
413
+ // cpSync(bunPath, bundledBunPath);
414
+
415
+ // Note: for sandboxed apps, MacOS will use the CFBundleIdentifier to create a unique container for the app,
416
+ // mirroring folders like Application Support, Caches, etc. in the user's Library folder that the sandboxed app
417
+ // gets access to.
418
+
419
+ // We likely want to let users configure this for different environments (eg: dev, canary, stable) and/or
420
+ // provide methods to help segment data in those folders based on channel/environment
421
+ const InfoPlistContents = `<?xml version="1.0" encoding="UTF-8"?>
422
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
423
+ <plist version="1.0">
424
+ <dict>
425
+ <key>CFBundleExecutable</key>
426
+ <string>launcher</string>
427
+ <key>CFBundleIdentifier</key>
428
+ <string>${config.app.identifier}</string>
429
+ <key>CFBundleName</key>
430
+ <string>${appFileName}</string>
431
+ <key>CFBundleVersion</key>
432
+ <string>${config.app.version}</string>
433
+ <key>CFBundlePackageType</key>
434
+ <string>APPL</string>
435
+ <key>CFBundleIconFile</key>
436
+ <string>AppIcon</string>
437
+ </dict>
438
+ </plist>`;
439
+
440
+ await Bun.write(
441
+ join(appBundleFolderContentsPath, "Info.plist"),
442
+ InfoPlistContents
443
+ );
444
+ // in dev builds the log file is a named pipe so we can stream it back to the terminal
445
+ // in canary/stable builds it'll be a regular log file
446
+ // const LauncherContents = `#!/bin/bash
447
+ // # change directory from whatever open was or double clicking on the app to the dir of the bin in the app bundle
448
+ // cd "$(dirname "$0")"/
449
+
450
+ // # Define the log file path
451
+ // LOG_FILE="$HOME/${logPath}"
452
+
453
+ // # Ensure the directory exists
454
+ // mkdir -p "$(dirname "$LOG_FILE")"
455
+
456
+ // if [[ ! -p $LOG_FILE ]]; then
457
+ // mkfifo $LOG_FILE
458
+ // fi
459
+
460
+ // # Execute bun and redirect stdout and stderr to the log file
461
+ // ./bun ../Resources/app/bun/index.js >"$LOG_FILE" 2>&1
462
+ // `;
463
+
464
+ // // Launcher binary
465
+ // // todo (yoav): This will likely be a zig compiled binary in the future
466
+ // Bun.write(join(appBundleMacOSPath, 'MyApp'), LauncherContents);
467
+ // chmodSync(join(appBundleMacOSPath, 'MyApp'), '755');
468
+ // const zigLauncherBinarySource = join(projectRoot, 'node_modules', 'electrobun', 'src', 'launcher', 'zig-out', 'bin', 'launcher');
469
+ // const zigLauncherDestination = join(appBundleMacOSPath, 'MyApp');
470
+ // const destLauncherFolder = dirname(zigLauncherDestination);
471
+ // if (!existsSync(destLauncherFolder)) {
472
+ // // console.info('creating folder: ', destFolder);
473
+ // mkdirSync(destLauncherFolder, {recursive: true});
474
+ // }
475
+ // cpSync(zigLauncherBinarySource, zigLauncherDestination, {recursive: true, dereference: true});
476
+ const bunCliLauncherBinarySource =
477
+ buildEnvironment === "dev"
478
+ ? // Note: in dev use the cli as the launcher
479
+ PATHS.LAUNCHER_DEV
480
+ : // Note: for release use the zig launcher optimized for smol size
481
+ PATHS.LAUNCHER_RELEASE;
482
+ const bunCliLauncherDestination = join(appBundleMacOSPath, "launcher") + binExt;
483
+ const destLauncherFolder = dirname(bunCliLauncherDestination);
484
+ if (!existsSync(destLauncherFolder)) {
485
+ // console.info('creating folder: ', destFolder);
486
+ mkdirSync(destLauncherFolder, { recursive: true });
487
+ }
488
+
489
+ cpSync(bunCliLauncherBinarySource, bunCliLauncherDestination, {
490
+ recursive: true,
491
+ dereference: true,
492
+ });
493
+
494
+ cpSync(PATHS.MAIN_JS, join(appBundleMacOSPath, 'main.js'));
495
+
496
+ // Bun runtime binary
497
+ // todo (yoav): this only works for the current architecture
498
+ const bunBinarySourcePath = PATHS.BUN_BINARY;
499
+ // Note: .bin/bun binary in node_modules is a symlink to the versioned one in another place
500
+ // in node_modules, so we have to dereference here to get the actual binary in the bundle.
501
+ const bunBinaryDestInBundlePath = join(appBundleMacOSPath, "bun") + binExt;
502
+ const destFolder2 = dirname(bunBinaryDestInBundlePath);
503
+ if (!existsSync(destFolder2)) {
504
+ // console.info('creating folder: ', destFolder);
505
+ mkdirSync(destFolder2, { recursive: true });
506
+ }
507
+ cpSync(bunBinarySourcePath, bunBinaryDestInBundlePath, { dereference: true });
508
+
509
+ // copy native wrapper dynamic library
510
+ if (OS === 'macos') {
511
+ const nativeWrapperMacosSource = PATHS.NATIVE_WRAPPER_MACOS;
512
+ const nativeWrapperMacosDestination = join(
513
+ appBundleMacOSPath,
514
+ "libNativeWrapper.dylib"
515
+ );
516
+ cpSync(nativeWrapperMacosSource, nativeWrapperMacosDestination, {
517
+ dereference: true,
518
+ });
519
+ } else if (OS === 'win') {
520
+ const nativeWrapperMacosSource = PATHS.NATIVE_WRAPPER_WIN;
521
+ const nativeWrapperMacosDestination = join(
522
+ appBundleMacOSPath,
523
+ "libNativeWrapper.dll"
524
+ );
525
+ cpSync(nativeWrapperMacosSource, nativeWrapperMacosDestination, {
526
+ dereference: true,
527
+ });
528
+
529
+ const webview2LibSource = PATHS.WEBVIEW2LOADER_WIN;
530
+ const webview2LibDestination = join(
531
+ appBundleMacOSPath,
532
+ "WebView2Loader.dll"
533
+ ); ;
534
+ // copy webview2 system webview library
535
+ cpSync(webview2LibSource, webview2LibDestination);
536
+
537
+ } else if (OS === 'linux') {
538
+ const nativeWrapperLinuxSource = PATHS.NATIVE_WRAPPER_LINUX;
539
+ const nativeWrapperLinuxDestination = join(
540
+ appBundleMacOSPath,
541
+ "libNativeWrapper.so"
542
+ );
543
+ if (existsSync(nativeWrapperLinuxSource)) {
544
+ cpSync(nativeWrapperLinuxSource, nativeWrapperLinuxDestination, {
545
+ dereference: true,
546
+ });
547
+ }
548
+ }
549
+
550
+ // Ensure core binaries are available
551
+ await ensureCoreDependencies();
552
+
553
+ // Download CEF binaries if needed when bundleCEF is enabled
554
+ if ((OS === 'macos' && config.build.mac?.bundleCEF) ||
555
+ (OS === 'win' && config.build.win?.bundleCEF) ||
556
+ (OS === 'linux' && config.build.linux?.bundleCEF)) {
557
+
558
+ await ensureCEFDependencies();
559
+ if (OS === 'macos') {
560
+ const cefFrameworkSource = PATHS.CEF_FRAMEWORK_MACOS;
561
+ const cefFrameworkDestination = join(
562
+ appBundleFolderFrameworksPath,
563
+ "Chromium Embedded Framework.framework"
564
+ );
565
+
566
+ cpSync(cefFrameworkSource, cefFrameworkDestination, {
567
+ recursive: true,
568
+ dereference: true,
569
+ });
570
+
571
+
572
+ // cef helpers
573
+ const cefHelperNames = [
574
+ "bun Helper",
575
+ "bun Helper (Alerts)",
576
+ "bun Helper (GPU)",
577
+ "bun Helper (Plugin)",
578
+ "bun Helper (Renderer)",
579
+ ];
580
+
581
+ const helperSourcePath = PATHS.CEF_HELPER_MACOS;
582
+ cefHelperNames.forEach((helperName) => {
583
+ const destinationPath = join(
584
+ appBundleFolderFrameworksPath,
585
+ `${helperName}.app`,
586
+ `Contents`,
587
+ `MacOS`,
588
+ `${helperName}`
589
+ );
590
+
591
+ const destFolder4 = dirname(destinationPath);
592
+ if (!existsSync(destFolder4)) {
593
+ // console.info('creating folder: ', destFolder4);
594
+ mkdirSync(destFolder4, { recursive: true });
595
+ }
596
+ cpSync(helperSourcePath, destinationPath, {
597
+ recursive: true,
598
+ dereference: true,
599
+ });
600
+ });
601
+ } else if (OS === 'win') {
602
+ // Copy CEF DLLs from dist/cef/ to the main executable directory
603
+ const electrobunDistPath = join(ELECTROBUN_DEP_PATH, "dist");
604
+ const cefSourcePath = join(electrobunDistPath, "cef");
605
+ const cefDllFiles = [
606
+ 'libcef.dll',
607
+ 'chrome_elf.dll',
608
+ 'd3dcompiler_47.dll',
609
+ 'libEGL.dll',
610
+ 'libGLESv2.dll',
611
+ 'vk_swiftshader.dll',
612
+ 'vulkan-1.dll'
613
+ ];
614
+
615
+ cefDllFiles.forEach(dllFile => {
616
+ const sourcePath = join(cefSourcePath, dllFile);
617
+ const destPath = join(appBundleMacOSPath, dllFile);
618
+ if (existsSync(sourcePath)) {
619
+ cpSync(sourcePath, destPath);
620
+ }
621
+ });
622
+
623
+ // Copy icudtl.dat to MacOS root (same folder as libcef.dll) - required for CEF initialization
624
+ const icuDataSource = join(cefSourcePath, 'icudtl.dat');
625
+ const icuDataDest = join(appBundleMacOSPath, 'icudtl.dat');
626
+ if (existsSync(icuDataSource)) {
627
+ cpSync(icuDataSource, icuDataDest);
628
+ }
629
+
630
+ // Copy essential CEF pak files to MacOS root (same folder as libcef.dll) - required for CEF resources
631
+ const essentialPakFiles = ['chrome_100_percent.pak', 'resources.pak', 'v8_context_snapshot.bin'];
632
+ essentialPakFiles.forEach(pakFile => {
633
+ const sourcePath = join(cefSourcePath, pakFile);
634
+ const destPath = join(appBundleMacOSPath, pakFile);
635
+
636
+ if (existsSync(sourcePath)) {
637
+ cpSync(sourcePath, destPath);
638
+ } else {
639
+ console.log(`WARNING: Missing CEF file: ${sourcePath}`);
640
+ }
641
+ });
642
+
643
+ // Copy CEF resources to MacOS/cef/ subdirectory for other resources like locales
644
+ const cefResourcesSource = join(electrobunDistPath, 'cef');
645
+ const cefResourcesDestination = join(appBundleMacOSPath, 'cef');
646
+
647
+ if (existsSync(cefResourcesSource)) {
648
+ cpSync(cefResourcesSource, cefResourcesDestination, {
649
+ recursive: true,
650
+ dereference: true,
651
+ });
652
+ }
653
+
654
+ // Copy CEF helper processes with different names
655
+ const cefHelperNames = [
656
+ "bun Helper",
657
+ "bun Helper (Alerts)",
658
+ "bun Helper (GPU)",
659
+ "bun Helper (Plugin)",
660
+ "bun Helper (Renderer)",
661
+ ];
662
+
663
+ const helperSourcePath = PATHS.CEF_HELPER_WIN;
664
+ if (existsSync(helperSourcePath)) {
665
+ cefHelperNames.forEach((helperName) => {
666
+ const destinationPath = join(appBundleMacOSPath, `${helperName}.exe`);
667
+ cpSync(helperSourcePath, destinationPath);
668
+
669
+ });
670
+ } else {
671
+ console.log(`WARNING: Missing CEF helper: ${helperSourcePath}`);
672
+ }
673
+ } else if (OS === 'linux') {
674
+ // Copy CEF shared libraries from dist/cef/ to the main executable directory
675
+ const electrobunDistPath = join(ELECTROBUN_DEP_PATH, "dist");
676
+ const cefSourcePath = join(electrobunDistPath, "cef");
677
+
678
+ if (existsSync(cefSourcePath)) {
679
+ const cefSoFiles = [
680
+ 'libcef.so',
681
+ 'libEGL.so',
682
+ 'libGLESv2.so',
683
+ 'libvk_swiftshader.so',
684
+ 'libvulkan.so.1'
685
+ ];
686
+
687
+ cefSoFiles.forEach(soFile => {
688
+ const sourcePath = join(cefSourcePath, soFile);
689
+ const destPath = join(appBundleMacOSPath, soFile);
690
+ if (existsSync(sourcePath)) {
691
+ cpSync(sourcePath, destPath);
692
+ }
693
+ });
694
+
695
+ // Copy icudtl.dat to MacOS root (same folder as libcef.so) - required for CEF initialization
696
+ const icuDataSource = join(cefSourcePath, 'icudtl.dat');
697
+ const icuDataDest = join(appBundleMacOSPath, 'icudtl.dat');
698
+ if (existsSync(icuDataSource)) {
699
+ cpSync(icuDataSource, icuDataDest);
700
+ }
701
+
702
+ // Copy .pak files and other CEF resources to the main executable directory
703
+ const pakFiles = [
704
+ 'icudtl.dat',
705
+ 'v8_context_snapshot.bin',
706
+ 'snapshot_blob.bin',
707
+ 'resources.pak',
708
+ 'chrome_100_percent.pak',
709
+ 'chrome_200_percent.pak',
710
+ 'locales',
711
+ 'chrome-sandbox',
712
+ 'vk_swiftshader_icd.json'
713
+ ];
714
+ pakFiles.forEach(pakFile => {
715
+ const sourcePath = join(cefSourcePath, pakFile);
716
+ const destPath = join(appBundleMacOSPath, pakFile);
717
+ if (existsSync(sourcePath)) {
718
+ cpSync(sourcePath, destPath, { recursive: true });
719
+ }
720
+ });
721
+
722
+ // Copy locales to cef subdirectory
723
+ const cefResourcesDestination = join(appBundleMacOSPath, 'cef');
724
+ if (!existsSync(cefResourcesDestination)) {
725
+ mkdirSync(cefResourcesDestination, { recursive: true });
726
+ }
727
+
728
+ // Copy all CEF shared libraries to cef subdirectory as well (for RPATH $ORIGIN/cef)
729
+ cefSoFiles.forEach(soFile => {
730
+ const sourcePath = join(cefSourcePath, soFile);
731
+ const destPath = join(cefResourcesDestination, soFile);
732
+ if (existsSync(sourcePath)) {
733
+ cpSync(sourcePath, destPath);
734
+ console.log(`Copied CEF library to cef subdirectory: ${soFile}`);
735
+ } else {
736
+ console.log(`WARNING: Missing CEF library: ${sourcePath}`);
737
+ }
738
+ });
739
+
740
+ // Copy essential CEF files to cef subdirectory as well (for RPATH $ORIGIN/cef)
741
+ const cefEssentialFiles = ['vk_swiftshader_icd.json'];
742
+ cefEssentialFiles.forEach(cefFile => {
743
+ const sourcePath = join(cefSourcePath, cefFile);
744
+ const destPath = join(cefResourcesDestination, cefFile);
745
+ if (existsSync(sourcePath)) {
746
+ cpSync(sourcePath, destPath);
747
+ console.log(`Copied CEF essential file to cef subdirectory: ${cefFile}`);
748
+ } else {
749
+ console.log(`WARNING: Missing CEF essential file: ${sourcePath}`);
750
+ }
751
+ });
752
+
753
+ // Copy CEF helper processes with different names
754
+ const cefHelperNames = [
755
+ "bun Helper",
756
+ "bun Helper (Alerts)",
757
+ "bun Helper (GPU)",
758
+ "bun Helper (Plugin)",
759
+ "bun Helper (Renderer)",
760
+ ];
761
+
762
+ const helperSourcePath = PATHS.CEF_HELPER_LINUX;
763
+ if (existsSync(helperSourcePath)) {
764
+ cefHelperNames.forEach((helperName) => {
765
+ const destinationPath = join(appBundleMacOSPath, helperName);
766
+ cpSync(helperSourcePath, destinationPath);
767
+ console.log(`Copied CEF helper: ${helperName}`);
768
+ });
769
+ } else {
770
+ console.log(`WARNING: Missing CEF helper: ${helperSourcePath}`);
771
+ }
772
+ }
773
+ }
774
+ }
775
+
776
+
777
+ // copy native bindings
778
+ const bsPatchSource = PATHS.BSPATCH;
779
+ const bsPatchDestination = join(appBundleMacOSPath, "bspatch") + binExt;
780
+ const bsPatchDestFolder = dirname(bsPatchDestination);
781
+ if (!existsSync(bsPatchDestFolder)) {
782
+ mkdirSync(bsPatchDestFolder, { recursive: true });
783
+ }
784
+
785
+ cpSync(bsPatchSource, bsPatchDestination, {
786
+ recursive: true,
787
+ dereference: true,
788
+ });
789
+
790
+ // transpile developer's bun code
791
+ const bunDestFolder = join(appBundleAppCodePath, "bun");
792
+ // Build bun-javascript ts files
793
+ const buildResult = await Bun.build({
794
+ entrypoints: [bunSource],
795
+ outdir: bunDestFolder,
796
+ external: bunConfig.external || [],
797
+ // minify: true, // todo (yoav): add minify in canary and prod builds
798
+ target: "bun",
799
+ });
800
+
801
+ if (!buildResult.success) {
802
+ console.error("failed to build", bunSource, buildResult.logs);
803
+ process.exit(1);
804
+ }
805
+
806
+ // transpile developer's view code
807
+ // Build webview-javascript ts files
808
+ // bundle all the bundles
809
+ for (const viewName in config.build.views) {
810
+ const viewConfig = config.build.views[viewName];
811
+
812
+ const viewSource = join(projectRoot, viewConfig.entrypoint);
813
+ if (!existsSync(viewSource)) {
814
+ console.error(`failed to bundle ${viewSource} because it doesn't exist.`);
815
+ continue;
816
+ }
817
+
818
+ const viewDestFolder = join(appBundleAppCodePath, "views", viewName);
819
+
820
+ if (!existsSync(viewDestFolder)) {
821
+ // console.info('creating folder: ', viewDestFolder);
822
+ mkdirSync(viewDestFolder, { recursive: true });
823
+ } else {
824
+ console.error(
825
+ "continuing, but ",
826
+ viewDestFolder,
827
+ "unexpectedly already exists in the build folder"
828
+ );
829
+ }
830
+
831
+ // console.info(`bundling ${viewSource} to ${viewDestFolder} with config: `, viewConfig);
832
+
833
+ const buildResult = await Bun.build({
834
+ entrypoints: [viewSource],
835
+ outdir: viewDestFolder,
836
+ external: viewConfig.external || [],
837
+ target: "browser",
838
+ });
839
+
840
+ if (!buildResult.success) {
841
+ console.error("failed to build", viewSource, buildResult.logs);
842
+ continue;
843
+ }
844
+ }
845
+ // Copy assets like html, css, images, and other files
846
+ for (const relSource in config.build.copy) {
847
+ const source = join(projectRoot, relSource);
848
+ if (!existsSync(source)) {
849
+ console.error(`failed to copy ${source} because it doesn't exist.`);
850
+ continue;
851
+ }
852
+
853
+ const destination = join(
854
+ appBundleAppCodePath,
855
+ config.build.copy[relSource]
856
+ );
857
+ const destFolder = dirname(destination);
858
+
859
+ if (!existsSync(destFolder)) {
860
+ // console.info('creating folder: ', destFolder);
861
+ mkdirSync(destFolder, { recursive: true });
862
+ }
863
+
864
+ // todo (yoav): add ability to swap out BUILD VARS
865
+ cpSync(source, destination, { recursive: true, dereference: true });
866
+ }
867
+
868
+
869
+ buildIcons(appBundleFolderResourcesPath);
870
+
871
+
872
+ // Run postBuild script
873
+ if (config.scripts.postBuild) {
874
+
875
+ Bun.spawnSync([bunBinarySourcePath, config.scripts.postBuild], {
876
+ stdio: ["ignore", "inherit", "inherit"],
877
+ env: {
878
+ ...process.env,
879
+ ELECTROBUN_BUILD_ENV: buildEnvironment,
880
+ },
881
+ });
882
+ }
883
+ // All the unique files are in the bundle now. Create an initial temporary tar file
884
+ // for hashing the contents
885
+ // tar the signed and notarized app bundle
886
+ const tmpTarPath = `${appBundleFolderPath}-temp.tar`;
887
+ await tar.c(
888
+ {
889
+ gzip: false,
890
+ file: tmpTarPath,
891
+ cwd: buildFolder,
892
+ },
893
+ [basename(appBundleFolderPath)]
894
+ );
895
+ const tmpTarball = Bun.file(tmpTarPath);
896
+ const tmpTarBuffer = await tmpTarball.arrayBuffer();
897
+ // Note: wyhash is the default in Bun.hash but that may change in the future
898
+ // so we're being explicit here.
899
+ const hash = Bun.hash.wyhash(tmpTarBuffer, 43770n).toString(36);
900
+
901
+ unlinkSync(tmpTarPath);
902
+ // const bunVersion = execSync(`${bunBinarySourcePath} --version`).toString().trim();
903
+
904
+ // version.json inside the app bundle
905
+ const versionJsonContent = JSON.stringify({
906
+ version: config.app.version,
907
+ // The first tar file does not include this, it gets hashed,
908
+ // then the hash is included in another tar file. That later one
909
+ // then gets used for patching and updating.
910
+ hash: hash,
911
+ channel: buildEnvironment,
912
+ bucketUrl: config.release.bucketUrl,
913
+ name: appFileName,
914
+ identifier: config.app.identifier,
915
+ });
916
+
917
+ await Bun.write(
918
+ join(appBundleFolderResourcesPath, "version.json"),
919
+ versionJsonContent
920
+ );
921
+
922
+ // todo (yoav): add these to config
923
+ const shouldCodesign =
924
+ buildEnvironment !== "dev" && config.build.mac.codesign;
925
+ const shouldNotarize = shouldCodesign && config.build.mac.notarize;
926
+
927
+ if (shouldCodesign) {
928
+ codesignAppBundle(
929
+ appBundleFolderPath,
930
+ join(buildFolder, "entitlements.plist")
931
+ );
932
+ } else {
933
+ console.log("skipping codesign");
934
+ }
935
+
936
+ // codesign
937
+ // NOTE: Codesigning fails in dev mode (when using a single-file-executable bun cli as the launcher)
938
+ // see https://github.com/oven-sh/bun/issues/7208
939
+ if (shouldNotarize) {
940
+ notarizeAndStaple(appBundleFolderPath);
941
+ } else {
942
+ console.log("skipping notarization");
943
+ }
944
+ if (buildEnvironment !== "dev") {
945
+ const artifactsToUpload = [];
946
+ // zstd wasm https://github.com/OneIdentity/zstd-js
947
+ // tar https://github.com/isaacs/node-tar
948
+
949
+ // steps:
950
+ // 1. [done] build the app bundle, code sign, notarize, staple.
951
+ // 2. tar and zstd the app bundle (two separate files)
952
+ // 3. build another app bundle for the self-extracting app bundle with the zstd in Resources
953
+ // 4. code sign and notarize the self-extracting app bundle
954
+ // 5. while waiting for that notarization, download the prev app bundle, extract the tar, and generate a bsdiff patch
955
+ // 6. when notarization is complete, generate a dmg of the self-extracting app bundle
956
+ // 6.5. code sign and notarize the dmg
957
+ // 7. copy artifacts to directory [self-extractor dmg, zstd app bundle, bsdiff patch, update.json]
958
+
959
+ const tarPath = `${appBundleFolderPath}.tar`;
960
+
961
+ // tar the signed and notarized app bundle
962
+ await tar.c(
963
+ {
964
+ gzip: false,
965
+ file: tarPath,
966
+ cwd: buildFolder,
967
+ },
968
+ [basename(appBundleFolderPath)]
969
+ );
970
+
971
+ const tarball = Bun.file(tarPath);
972
+ const tarBuffer = await tarball.arrayBuffer();
973
+
974
+ // Note: The playground app bundle is around 48MB.
975
+ // compression on m1 max with 64GB ram:
976
+ // brotli: 1min 38s, 48MB -> 11.1MB
977
+ // zstd: 15s, 48MB -> 12.1MB
978
+ // zstd is the clear winner here. dev iteration speed gain of 1min 15s per build is much more valubale
979
+ // than saving 1 more MB of space/bandwidth.
980
+
981
+ const compressedTarPath = `${tarPath}.zst`;
982
+ artifactsToUpload.push(compressedTarPath);
983
+
984
+ // zstd compress tarball
985
+ // todo (yoav): consider using c bindings for zstd for speed instead of wasm
986
+ // we already have it in the bsdiff binary
987
+ console.log("compressing tarball...");
988
+ await ZstdInit().then(async ({ ZstdSimple, ZstdStream }) => {
989
+ // Note: Simple is much faster than stream, but stream is better for large files
990
+ // todo (yoav): consider a file size cutoff to switch to stream instead of simple.
991
+ if (tarball.size > 0) {
992
+ // Uint8 array filestream of the tar file
993
+
994
+ const data = new Uint8Array(tarBuffer);
995
+ const compressionLevel = 22;
996
+ const compressedData = ZstdSimple.compress(data, compressionLevel);
997
+
998
+ console.log(
999
+ "compressed",
1000
+ compressedData.length,
1001
+ "bytes",
1002
+ "from",
1003
+ data.length,
1004
+ "bytes"
1005
+ );
1006
+
1007
+ await Bun.write(compressedTarPath, compressedData);
1008
+ }
1009
+ });
1010
+
1011
+ // we can delete the original app bundle since we've tarred and zstd it. We need to create the self-extracting app bundle
1012
+ // now and it needs the same name as the original app bundle.
1013
+ rmdirSync(appBundleFolderPath, { recursive: true });
1014
+
1015
+ const selfExtractingBundle = createAppBundle(appFileName, buildFolder);
1016
+ const compressedTarballInExtractingBundlePath = join(
1017
+ selfExtractingBundle.appBundleFolderResourcesPath,
1018
+ `${hash}.tar.zst`
1019
+ );
1020
+
1021
+ // copy the zstd tarball to the self-extracting app bundle
1022
+ cpSync(compressedTarPath, compressedTarballInExtractingBundlePath);
1023
+
1024
+ const selfExtractorBinSourcePath = PATHS.EXTRACTOR;
1025
+ const selfExtractorBinDestinationPath = join(
1026
+ selfExtractingBundle.appBundleMacOSPath,
1027
+ "launcher"
1028
+ );
1029
+
1030
+ cpSync(selfExtractorBinSourcePath, selfExtractorBinDestinationPath, {
1031
+ dereference: true,
1032
+ });
1033
+
1034
+ buildIcons(appBundleFolderResourcesPath);
1035
+ await Bun.write(
1036
+ join(selfExtractingBundle.appBundleFolderContentsPath, "Info.plist"),
1037
+ InfoPlistContents
1038
+ );
1039
+
1040
+ if (shouldCodesign) {
1041
+ codesignAppBundle(
1042
+ selfExtractingBundle.appBundleFolderPath,
1043
+ join(buildFolder, "entitlements.plist")
1044
+ );
1045
+ } else {
1046
+ console.log("skipping codesign");
1047
+ }
1048
+
1049
+ // Note: we need to notarize the original app bundle, the self-extracting app bundle, and the dmg
1050
+ if (shouldNotarize) {
1051
+ notarizeAndStaple(selfExtractingBundle.appBundleFolderPath);
1052
+ } else {
1053
+ console.log("skipping notarization");
1054
+ }
1055
+
1056
+ console.log("creating dmg...");
1057
+ // make a dmg
1058
+ const dmgPath = join(buildFolder, `${appFileName}.dmg`);
1059
+ artifactsToUpload.push(dmgPath);
1060
+ // hdiutil create -volname "YourAppName" -srcfolder /path/to/YourApp.app -ov -format UDZO YourAppName.dmg
1061
+ // Note: use UDBZ for better compression vs. UDZO
1062
+ execSync(
1063
+ `hdiutil create -volname "${appFileName}" -srcfolder ${escapePathForTerminal(
1064
+ appBundleFolderPath
1065
+ )} -ov -format UDBZ ${escapePathForTerminal(dmgPath)}`
1066
+ );
1067
+
1068
+ if (shouldCodesign) {
1069
+ codesignAppBundle(dmgPath);
1070
+ } else {
1071
+ console.log("skipping codesign");
1072
+ }
1073
+
1074
+ if (shouldNotarize) {
1075
+ notarizeAndStaple(dmgPath);
1076
+ } else {
1077
+ console.log("skipping notarization");
1078
+ }
1079
+
1080
+ // refresh artifacts folder
1081
+ console.log("creating artifacts folder...");
1082
+ if (existsSync(artifactFolder)) {
1083
+ console.info("deleting artifact folder: ", artifactFolder);
1084
+ rmdirSync(artifactFolder, { recursive: true });
1085
+ }
1086
+
1087
+ mkdirSync(artifactFolder, { recursive: true });
1088
+
1089
+ console.log("creating update.json...");
1090
+ // update.json for the channel in that channel's build folder
1091
+ const updateJsonContent = JSON.stringify({
1092
+ // The version isn't really used for updating, but it's nice to have for
1093
+ // the download button or display on your marketing site or in the app.
1094
+ version: config.app.version,
1095
+ hash: hash.toString(),
1096
+ // channel: buildEnvironment,
1097
+ // bucketUrl: config.release.bucketUrl
1098
+ });
1099
+
1100
+ await Bun.write(join(artifactFolder, "update.json"), updateJsonContent);
1101
+
1102
+ // generate bsdiff
1103
+ // https://storage.googleapis.com/eggbun-static/electrobun-playground/canary/ElectrobunPlayground-canary.app.tar.zst
1104
+ console.log("bucketUrl: ", config.release.bucketUrl);
1105
+
1106
+ console.log("generating a patch from the previous version...");
1107
+ const urlToPrevUpdateJson = join(
1108
+ config.release.bucketUrl,
1109
+ buildEnvironment,
1110
+ `update.json`
1111
+ );
1112
+ const cacheBuster = Math.random().toString(36).substring(7);
1113
+ const updateJsonResponse = await fetch(
1114
+ urlToPrevUpdateJson + `?${cacheBuster}`
1115
+ ).catch((err) => {
1116
+ console.log("bucketURL not found: ", err);
1117
+ });
1118
+
1119
+ const urlToLatestTarball = join(
1120
+ config.release.bucketUrl,
1121
+ buildEnvironment,
1122
+ `${appFileName}.app.tar.zst`
1123
+ );
1124
+
1125
+
1126
+ // attempt to get the previous version to create a patch file
1127
+ if (updateJsonResponse.ok) {
1128
+ const prevUpdateJson = await updateJsonResponse.json();
1129
+
1130
+ const prevHash = prevUpdateJson.hash;
1131
+ console.log("PREVIOUS HASH", prevHash);
1132
+
1133
+ // todo (yoav): should be able to stream and decompress in the same step
1134
+
1135
+ const response = await fetch(urlToLatestTarball + `?${cacheBuster}`);
1136
+ const prevVersionCompressedTarballPath = join(
1137
+ buildFolder,
1138
+ "prev.tar.zst"
1139
+ );
1140
+
1141
+ if (response.ok && response.body) {
1142
+ const reader = response.body.getReader();
1143
+
1144
+ const writer = Bun.file(prevVersionCompressedTarballPath).writer();
1145
+
1146
+ while (true) {
1147
+ const { done, value } = await reader.read();
1148
+ if (done) break;
1149
+ await writer.write(value);
1150
+ }
1151
+ await writer.flush();
1152
+ writer.end();
1153
+
1154
+ console.log("decompress prev funn bundle...");
1155
+ const prevTarballPath = join(buildFolder, "prev.tar");
1156
+ await ZstdInit().then(async ({ ZstdSimple }) => {
1157
+ const data = new Uint8Array(
1158
+ await Bun.file(prevVersionCompressedTarballPath).arrayBuffer()
1159
+ );
1160
+ const uncompressedData = ZstdSimple.decompress(data);
1161
+ await Bun.write(prevTarballPath, uncompressedData);
1162
+ });
1163
+
1164
+ console.log("diff previous and new tarballs...");
1165
+ // Run it as a separate process to leverage multi-threadedness
1166
+ // especially for creating multiple diffs in parallel
1167
+ const bsdiffpath = PATHS.BSDIFF;
1168
+ const patchFilePath = join(buildFolder, `${prevHash}.patch`);
1169
+ artifactsToUpload.push(patchFilePath);
1170
+ const result = Bun.spawnSync(
1171
+ [bsdiffpath, prevTarballPath, tarPath, patchFilePath, "--use-zstd"],
1172
+ { cwd: buildFolder }
1173
+ );
1174
+ console.log(
1175
+ "bsdiff result: ",
1176
+ result.stdout.toString(),
1177
+ result.stderr.toString()
1178
+ );
1179
+ }
1180
+ } else {
1181
+ console.log("prevoius version not found at: ", urlToLatestTarball);
1182
+ console.log("skipping diff generation");
1183
+ }
1184
+
1185
+ // compress all the upload files
1186
+ console.log("copying artifacts...");
1187
+
1188
+ artifactsToUpload.forEach((filePath) => {
1189
+ const filename = basename(filePath);
1190
+ cpSync(filePath, join(artifactFolder, filename));
1191
+ });
1192
+
1193
+ // todo: now just upload the artifacts to your bucket replacing the ones that exist
1194
+ // you'll end up with a sequence of patch files that will
1195
+ }
1196
+
1197
+ // NOTE: verify codesign
1198
+ // codesign --verify --deep --strict --verbose=2 <app path>
1199
+
1200
+ // Note: verify notarization
1201
+ // spctl --assess --type execute --verbose <app path>
1202
+
1203
+ // Note: for .dmg spctl --assess will respond with "rejected (*the code is valid* but does not seem to be an app)" which is valid
1204
+ // an actual failed response for a dmg is "source=no usable signature"
1205
+ // for a dmg.
1206
+ // can also use stapler validate -v to validate the dmg and look for teamId, signingId, and the response signedTicket
1207
+ // stapler validate -v <app path>
1208
+ } else if (commandArg === "dev") {
1209
+ // todo (yoav): rename to start
1210
+
1211
+ // run the project in dev mode
1212
+ // this runs the cli in debug mode, on macos executes the app bundle,
1213
+ // there is another copy of the cli in the app bundle that will execute the app
1214
+ // the two cli processes communicate via named pipes and together manage the dev
1215
+ // lifecycle and debug functionality
1216
+
1217
+ // Note: this cli will be a bun single-file-executable
1218
+ // Note: we want to use the version of bun that's packaged with electrobun
1219
+ // const bunPath = join(projectRoot, 'node_modules', '.bin', 'bun');
1220
+ // const mainPath = join(buildFolder, 'bun', 'index.js');
1221
+ // const mainPath = join(buildFolder, bundleFileName);
1222
+ // console.log('running ', bunPath, mainPath);
1223
+
1224
+ // Note: open will open the app bundle as a completely different process
1225
+ // This is critical to fully test the app (including plist configuration, etc.)
1226
+ // but also to get proper cmd+tab and dock behaviour and not run the windowed app
1227
+ // as a child of the terminal process which steels keyboard focus from any descendant nswindows.
1228
+ // Bun.spawn(["open", mainPath], {
1229
+ // env: {},
1230
+ // });
1231
+
1232
+ let mainProc;
1233
+ let bundleExecPath: string;
1234
+
1235
+ if (OS === 'macos') {
1236
+ bundleExecPath = join(buildFolder, bundleFileName, "Contents", 'MacOS');
1237
+ } else if (OS === 'linux' || OS === 'win') {
1238
+ bundleExecPath = join(buildFolder, bundleFileName, "bin");
1239
+ } else {
1240
+ throw new Error(`Unsupported OS: ${OS}`);
1241
+ }
1242
+
1243
+ if (OS === 'macos') {
1244
+
1245
+ mainProc = Bun.spawn([join(bundleExecPath,'bun'), join(bundleExecPath, 'main.js')], {
1246
+ stdio: ['inherit', 'inherit', 'inherit'],
1247
+ cwd: bundleExecPath
1248
+ })
1249
+ } else if (OS === 'win') {
1250
+ // Try the main process
1251
+ mainProc = Bun.spawn(['./bun.exe', './main.js'], {
1252
+ stdio: ['inherit', 'inherit', 'inherit'],
1253
+ cwd: bundleExecPath,
1254
+ onExit: (proc, exitCode, signalCode, error) => {
1255
+ console.log('Bun process exited:', { exitCode, signalCode, error });
1256
+ }
1257
+ })
1258
+ } else if (OS === 'linux') {
1259
+ let env = { ...process.env };
1260
+
1261
+ // Add LD_PRELOAD for CEF libraries to fix static TLS allocation issues
1262
+ if (config.build.linux?.bundleCEF) {
1263
+ const cefLibs = ['./libcef.so', './libvk_swiftshader.so'];
1264
+ const existingCefLibs = cefLibs.filter(lib => existsSync(join(bundleExecPath, lib)));
1265
+
1266
+ if (existingCefLibs.length > 0) {
1267
+ env['LD_PRELOAD'] = existingCefLibs.join(':');
1268
+ console.log(`Using LD_PRELOAD for CEF: ${env['LD_PRELOAD']}`);
1269
+ }
1270
+ }
1271
+
1272
+ mainProc = Bun.spawn([join(bundleExecPath, 'bun'), join(bundleExecPath, 'main.js')], {
1273
+ stdio: ['inherit', 'inherit', 'inherit'],
1274
+ cwd: bundleExecPath,
1275
+ env
1276
+ })
1277
+ }
1278
+
1279
+ process.on("SIGINT", () => {
1280
+ console.log('exit command')
1281
+ // toLauncherPipe.write("exit command\n");
1282
+ mainProc.kill();
1283
+ process.exit();
1284
+ });
1285
+
1286
+ }
1287
+
1288
+ function getConfig() {
1289
+ let loadedConfig = {};
1290
+ if (existsSync(configPath)) {
1291
+ const configFileContents = readFileSync(configPath, "utf8");
1292
+ // Note: we want this to hard fail if there's a syntax error
1293
+ loadedConfig = JSON.parse(configFileContents);
1294
+ }
1295
+
1296
+ // todo (yoav): write a deep clone fn
1297
+ return {
1298
+ ...defaultConfig,
1299
+ ...loadedConfig,
1300
+ app: {
1301
+ ...defaultConfig.app,
1302
+ ...(loadedConfig?.app || {}),
1303
+ },
1304
+ build: {
1305
+ ...defaultConfig.build,
1306
+ ...(loadedConfig?.build || {}),
1307
+ mac: {
1308
+ ...defaultConfig.build.mac,
1309
+ ...(loadedConfig?.build?.mac || {}),
1310
+ entitlements: {
1311
+ ...defaultConfig.build.mac.entitlements,
1312
+ ...(loadedConfig?.build?.mac?.entitlements || {}),
1313
+ },
1314
+ },
1315
+ },
1316
+ scripts: {
1317
+ ...defaultConfig.scripts,
1318
+ ...(loadedConfig?.scripts || {}),
1319
+ },
1320
+ release: {
1321
+ ...defaultConfig.release,
1322
+ ...(loadedConfig?.release || {}),
1323
+ },
1324
+ };
1325
+ }
1326
+
1327
+ function buildEntitlementsFile(entitlements) {
1328
+ return `<?xml version="1.0" encoding="UTF-8"?>
1329
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1330
+ <plist version="1.0">
1331
+ <dict>
1332
+ ${Object.keys(entitlements)
1333
+ .map((key) => {
1334
+ return `<key>${key}</key>\n${getEntitlementValue(entitlements[key])}`;
1335
+ })
1336
+ .join("\n")}
1337
+ </dict>
1338
+ </plist>
1339
+ `;
1340
+ }
1341
+
1342
+ function getEntitlementValue(value: boolean | string) {
1343
+ if (typeof value === "boolean") {
1344
+ return `<${value.toString()}/>`;
1345
+ } else {
1346
+ return value;
1347
+ }
1348
+ }
1349
+
1350
+ function codesignAppBundle(
1351
+ appBundleOrDmgPath: string,
1352
+ entitlementsFilePath?: string
1353
+ ) {
1354
+ console.log("code signing...");
1355
+ if (OS !== 'macos' || !config.build.mac.codesign) {
1356
+ return;
1357
+ }
1358
+
1359
+ const ELECTROBUN_DEVELOPER_ID = process.env["ELECTROBUN_DEVELOPER_ID"];
1360
+
1361
+ if (!ELECTROBUN_DEVELOPER_ID) {
1362
+ console.error("Env var ELECTROBUN_DEVELOPER_ID is required to codesign");
1363
+ process.exit(1);
1364
+ }
1365
+
1366
+ // list of entitlements https://developer.apple.com/documentation/security/hardened_runtime?language=objc
1367
+ // todo (yoav): consider allowing separate entitlements config for each binary
1368
+ // const entitlementsFilePath = join(buildFolder, 'entitlements.plist');
1369
+
1370
+ // codesign --deep --force --verbose --timestamp --sign "ELECTROBUN_DEVELOPER_ID" --options runtime --entitlements entitlementsFilePath appBundleOrDmgPath`
1371
+
1372
+ if (entitlementsFilePath) {
1373
+ const entitlementsFileContents = buildEntitlementsFile(
1374
+ config.build.mac.entitlements
1375
+ );
1376
+ Bun.write(entitlementsFilePath, entitlementsFileContents);
1377
+
1378
+ execSync(
1379
+ `codesign --deep --force --verbose --timestamp --sign "${ELECTROBUN_DEVELOPER_ID}" --options runtime --entitlements ${entitlementsFilePath} ${escapePathForTerminal(
1380
+ appBundleOrDmgPath
1381
+ )}`
1382
+ );
1383
+ } else {
1384
+ execSync(
1385
+ `codesign --deep --force --verbose --timestamp --sign "${ELECTROBUN_DEVELOPER_ID}" ${escapePathForTerminal(
1386
+ appBundleOrDmgPath
1387
+ )}`
1388
+ );
1389
+ }
1390
+ }
1391
+
1392
+ function notarizeAndStaple(appOrDmgPath: string) {
1393
+ if (OS !== 'macos' || !config.build.mac.notarize) {
1394
+ return;
1395
+ }
1396
+
1397
+ let fileToNotarize = appOrDmgPath;
1398
+ // codesign
1399
+ // NOTE: Codesigning fails in dev mode (when using a single-file-executable bun cli as the launcher)
1400
+ // see https://github.com/oven-sh/bun/issues/7208
1401
+ // if (shouldNotarize) {
1402
+ console.log("notarizing...");
1403
+ const zipPath = appOrDmgPath + ".zip";
1404
+ // if (appOrDmgPath.endsWith('.app')) {
1405
+ const appBundleFileName = basename(appOrDmgPath);
1406
+ // if we're codesigning the .app we have to zip it first
1407
+ execSync(
1408
+ `zip -y -r -9 ${escapePathForTerminal(zipPath)} ${escapePathForTerminal(
1409
+ appBundleFileName
1410
+ )}`,
1411
+ {
1412
+ cwd: dirname(appOrDmgPath),
1413
+ }
1414
+ );
1415
+ fileToNotarize = zipPath;
1416
+ // }
1417
+
1418
+ const ELECTROBUN_APPLEID = process.env["ELECTROBUN_APPLEID"];
1419
+
1420
+ if (!ELECTROBUN_APPLEID) {
1421
+ console.error("Env var ELECTROBUN_APPLEID is required to notarize");
1422
+ process.exit(1);
1423
+ }
1424
+
1425
+ const ELECTROBUN_APPLEIDPASS = process.env["ELECTROBUN_APPLEIDPASS"];
1426
+
1427
+ if (!ELECTROBUN_APPLEIDPASS) {
1428
+ console.error("Env var ELECTROBUN_APPLEIDPASS is required to notarize");
1429
+ process.exit(1);
1430
+ }
1431
+
1432
+ const ELECTROBUN_TEAMID = process.env["ELECTROBUN_TEAMID"];
1433
+
1434
+ if (!ELECTROBUN_TEAMID) {
1435
+ console.error("Env var ELECTROBUN_TEAMID is required to notarize");
1436
+ process.exit(1);
1437
+ }
1438
+
1439
+ // notarize
1440
+ // todo (yoav): follow up on options here like --s3-acceleration and --webhook
1441
+ // todo (yoav): don't use execSync since it's blocking and we'll only see the output at the end
1442
+ const statusInfo = execSync(
1443
+ `xcrun notarytool submit --apple-id "${ELECTROBUN_APPLEID}" --password "${ELECTROBUN_APPLEIDPASS}" --team-id "${ELECTROBUN_TEAMID}" --wait ${escapePathForTerminal(
1444
+ fileToNotarize
1445
+ )}`
1446
+ ).toString();
1447
+ const uuid = statusInfo.match(/id: ([^\n]+)/)?.[1];
1448
+ console.log("statusInfo", statusInfo);
1449
+ console.log("uuid", uuid);
1450
+
1451
+ if (statusInfo.match("Current status: Invalid")) {
1452
+ console.error("notarization failed", statusInfo);
1453
+ const log = execSync(
1454
+ `xcrun notarytool log --apple-id "${ELECTROBUN_APPLEID}" --password "${ELECTROBUN_APPLEIDPASS}" --team-id "${ELECTROBUN_TEAMID}" ${uuid}`
1455
+ ).toString();
1456
+ console.log("log", log);
1457
+ process.exit(1);
1458
+ }
1459
+ // check notarization
1460
+ // todo (yoav): actually check result
1461
+ // use `notarytool info` or some other request thing to check separately from the wait above
1462
+
1463
+ // stable notarization
1464
+ console.log("stapling...");
1465
+ execSync(`xcrun stapler staple ${escapePathForTerminal(appOrDmgPath)}`);
1466
+
1467
+ if (existsSync(zipPath)) {
1468
+ unlinkSync(zipPath);
1469
+ }
1470
+ }
1471
+
1472
+ // Note: supposedly the app bundle name is relevant to code sign/notarization so we need to make the app bundle and the self-extracting wrapper app bundle
1473
+ // have the same name but different subfolders in our build directory. or I guess delete the first one after tar/compression and then create the other one.
1474
+ // either way you can pass in the parent folder here for that flexibility.
1475
+ // for intel/arm builds on mac we'll probably have separate subfolders as well and build them in parallel.
1476
+ function createAppBundle(bundleName: string, parentFolder: string) {
1477
+ if (OS === 'macos') {
1478
+ // macOS bundle structure
1479
+ const bundleFileName = `${bundleName}.app`;
1480
+ const appBundleFolderPath = join(parentFolder, bundleFileName);
1481
+ const appBundleFolderContentsPath = join(appBundleFolderPath, "Contents");
1482
+ const appBundleMacOSPath = join(appBundleFolderContentsPath, "MacOS");
1483
+ const appBundleFolderResourcesPath = join(
1484
+ appBundleFolderContentsPath,
1485
+ "Resources"
1486
+ );
1487
+ const appBundleFolderFrameworksPath = join(
1488
+ appBundleFolderContentsPath,
1489
+ "Frameworks"
1490
+ );
1491
+
1492
+ // we don't have to make all the folders, just the deepest ones
1493
+ mkdirSync(appBundleMacOSPath, { recursive: true });
1494
+ mkdirSync(appBundleFolderResourcesPath, { recursive: true });
1495
+ mkdirSync(appBundleFolderFrameworksPath, { recursive: true });
1496
+
1497
+ return {
1498
+ appBundleFolderPath,
1499
+ appBundleFolderContentsPath,
1500
+ appBundleMacOSPath,
1501
+ appBundleFolderResourcesPath,
1502
+ appBundleFolderFrameworksPath,
1503
+ };
1504
+ } else if (OS === 'linux' || OS === 'win') {
1505
+ // Linux/Windows simpler structure
1506
+ const appBundleFolderPath = join(parentFolder, bundleName);
1507
+ const appBundleFolderContentsPath = appBundleFolderPath; // No Contents folder needed
1508
+ const appBundleMacOSPath = join(appBundleFolderPath, "bin"); // Use bin instead of MacOS
1509
+ const appBundleFolderResourcesPath = join(appBundleFolderPath, "Resources");
1510
+ const appBundleFolderFrameworksPath = join(appBundleFolderPath, "lib"); // Use lib instead of Frameworks
1511
+
1512
+ // Create directories
1513
+ mkdirSync(appBundleMacOSPath, { recursive: true });
1514
+ mkdirSync(appBundleFolderResourcesPath, { recursive: true });
1515
+ mkdirSync(appBundleFolderFrameworksPath, { recursive: true });
1516
+
1517
+ return {
1518
+ appBundleFolderPath,
1519
+ appBundleFolderContentsPath,
1520
+ appBundleMacOSPath,
1521
+ appBundleFolderResourcesPath,
1522
+ appBundleFolderFrameworksPath,
1523
+ };
1524
+ } else {
1525
+ throw new Error(`Unsupported OS: ${OS}`);
1526
+ }
1527
+ }