electrobun 0.13.0-beta.8 → 1.0.2-beta.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.
@@ -228,10 +228,10 @@ export interface ElectrobunConfig {
228
228
  */
229
229
  release?: {
230
230
  /**
231
- * Base URL for artifact distribution (e.g., S3 bucket URL)
231
+ * Base URL for artifact distribution (e.g., S3 bucket, GitHub Releases)
232
232
  * Used for auto-updates and patch generation
233
233
  */
234
- bucketUrl?: string;
234
+ baseUrl?: string;
235
235
  };
236
236
  }
237
237
 
@@ -5,8 +5,31 @@ import { execSync } from "child_process";
5
5
  import tar from "tar";
6
6
  import { ZstdInit } from "@oneidentity/zstd-js/wasm";
7
7
  import { OS as currentOS, ARCH as currentArch } from '../../shared/platform';
8
- import { getPlatformFolder, getTarballFileName } from '../../shared/naming';
8
+ import { getPlatformPrefix, getTarballFileName } from '../../shared/naming';
9
9
  import { native } from '../proc/native';
10
+ import { quit } from './Utils';
11
+
12
+ // Helper to join URL paths without breaking the protocol (path.join mangles https:// to https:/)
13
+ function urlJoin(...parts: string[]): string {
14
+ if (parts.length === 0) return '';
15
+
16
+ // Start with the first part (base URL)
17
+ let result = parts[0];
18
+
19
+ // Join remaining parts
20
+ for (let i = 1; i < parts.length; i++) {
21
+ const part = parts[i];
22
+ if (!part) continue;
23
+
24
+ // Remove trailing slash from result and leading slash from part
25
+ const cleanResult = result.endsWith('/') ? result.slice(0, -1) : result;
26
+ const cleanPart = part.startsWith('/') ? part.slice(1) : part;
27
+
28
+ result = `${cleanResult}/${cleanPart}`;
29
+ }
30
+
31
+ return result;
32
+ }
10
33
 
11
34
  // setTimeout(async () => {
12
35
  // console.log('killing')
@@ -107,7 +130,7 @@ function getAppDataDir(): string {
107
130
  let localInfo: {
108
131
  version: string;
109
132
  hash: string;
110
- bucketUrl: string;
133
+ baseUrl: string;
111
134
  channel: string;
112
135
  name: string;
113
136
  identifier: string;
@@ -141,17 +164,36 @@ const Updater = {
141
164
  };
142
165
  }
143
166
 
144
- const channelBucketUrl = await Updater.channelBucketUrl();
145
167
  const cacheBuster = Math.random().toString(36).substring(7);
146
- const platformFolder = getPlatformFolder(localInfo.channel, currentOS, currentArch);
147
- const updateInfoUrl = join(localInfo.bucketUrl, platformFolder, `update.json?${cacheBuster}`);
168
+ const platformPrefix = getPlatformPrefix(localInfo.channel, currentOS, currentArch);
169
+ const updateInfoUrl = `${localInfo.baseUrl}/${platformPrefix}-update.json?${cacheBuster}`;
148
170
 
149
171
  try {
150
172
  const updateInfoResponse = await fetch(updateInfoUrl);
151
173
 
152
174
  if (updateInfoResponse.ok) {
153
- // todo: this seems brittle
154
- updateInfo = await updateInfoResponse.json();
175
+ const responseText = await updateInfoResponse.text();
176
+ try {
177
+ updateInfo = JSON.parse(responseText);
178
+ } catch {
179
+ return {
180
+ version: "",
181
+ hash: "",
182
+ updateAvailable: false,
183
+ updateReady: false,
184
+ error: `Invalid update.json: failed to parse JSON`,
185
+ };
186
+ }
187
+
188
+ if (!updateInfo.hash) {
189
+ return {
190
+ version: "",
191
+ hash: "",
192
+ updateAvailable: false,
193
+ updateReady: false,
194
+ error: `Invalid update.json: missing hash`,
195
+ };
196
+ }
155
197
 
156
198
  if (updateInfo.hash !== localInfo.hash) {
157
199
  updateInfo.updateAvailable = true;
@@ -211,9 +253,9 @@ const Updater = {
211
253
  }
212
254
 
213
255
  // check if there's a patch file for it
214
- const platformFolder = getPlatformFolder(localInfo.channel, currentOS, currentArch);
256
+ const platformPrefix = getPlatformPrefix(localInfo.channel, currentOS, currentArch);
215
257
  const patchResponse = await fetch(
216
- join(localInfo.bucketUrl, platformFolder, `${currentHash}.patch`)
258
+ `${localInfo.baseUrl}/${platformPrefix}-${currentHash}.patch`
217
259
  );
218
260
 
219
261
  if (!patchResponse.ok) {
@@ -330,13 +372,9 @@ const Updater = {
330
372
  // then just download it and unpack it
331
373
  if (currentHash !== latestHash) {
332
374
  const cacheBuster = Math.random().toString(36).substring(7);
333
- const platformFolder = getPlatformFolder(localInfo.channel, currentOS, currentArch);
375
+ const platformPrefix = getPlatformPrefix(localInfo.channel, currentOS, currentArch);
334
376
  const tarballName = getTarballFileName(appFileName, currentOS);
335
- const urlToLatestTarball = join(
336
- localInfo.bucketUrl,
337
- platformFolder,
338
- tarballName
339
- );
377
+ const urlToLatestTarball = `${localInfo.baseUrl}/${platformPrefix}-${tarballName}`;
340
378
  const prevVersionCompressedTarballPath = join(
341
379
  appDataFolder,
342
380
  "self-extraction",
@@ -471,10 +509,12 @@ const Updater = {
471
509
  // Platform-specific path handling
472
510
  let newAppBundlePath: string;
473
511
  if (currentOS === 'linux') {
474
- // On Linux, look for the .AppImage file in the extraction directory
512
+ // On Linux, the tarball contains a directory named {appFileName}, and inside it is the AppImage
513
+ // Structure: extractionDir/{appFileName}/{appFileName}.AppImage
514
+ const innerDirName = localInfo.name.replace(/ /g, "");
475
515
  const appImageName = `${localInfo.name.replace(/ /g, "").replace(/\./g, "-")}.AppImage`;
476
- newAppBundlePath = join(extractionDir, appImageName);
477
-
516
+ newAppBundlePath = join(extractionDir, innerDirName, appImageName);
517
+
478
518
  // Verify the AppImage exists
479
519
  if (!statSync(newAppBundlePath, { throwIfNoEntry: false })) {
480
520
  console.error(`AppImage not found at: ${newAppBundlePath}`);
@@ -483,6 +523,14 @@ const Updater = {
483
523
  const files = readdirSync(extractionDir);
484
524
  for (const file of files) {
485
525
  console.log(` - ${file}`);
526
+ // Also list contents of subdirectories
527
+ const subPath = join(extractionDir, file);
528
+ if (statSync(subPath).isDirectory()) {
529
+ const subFiles = readdirSync(subPath);
530
+ for (const subFile of subFiles) {
531
+ console.log(` - ${subFile}`);
532
+ }
533
+ }
486
534
  }
487
535
  } catch (e) {
488
536
  console.log("Could not list directory contents:", e);
@@ -530,9 +578,8 @@ const Updater = {
530
578
  const appImageName = `${localInfo.name.replace(/ /g, "").replace(/\./g, "-")}.AppImage`;
531
579
  runningAppBundlePath = join(appDataFolder, appImageName);
532
580
  } else {
533
- // On Windows, use versioned app folders
534
- const currentHash = (await Updater.getLocallocalInfo()).hash;
535
- runningAppBundlePath = join(appDataFolder, `app-${currentHash}`);
581
+ // On Windows, use fixed 'app' folder to match extractor
582
+ runningAppBundlePath = join(appDataFolder, 'app');
536
583
  }
537
584
  }
538
585
  // Platform-specific backup handling
@@ -555,9 +602,18 @@ const Updater = {
555
602
 
556
603
  // Move current running app to backup
557
604
  renameSync(runningAppBundlePath, backupPath);
558
-
605
+
559
606
  // Move new app to running location
560
607
  renameSync(newAppBundlePath, runningAppBundlePath);
608
+
609
+ // Remove quarantine extended attributes to prevent "damaged" error
610
+ // The inner bundle is already signed/notarized, but macOS applies
611
+ // quarantine attributes when extracting from a downloaded archive
612
+ try {
613
+ execSync(`xattr -r -d com.apple.quarantine "${runningAppBundlePath}"`, { stdio: 'ignore' });
614
+ } catch (e) {
615
+ // Ignore errors - attribute may not exist
616
+ }
561
617
  } else if (currentOS === 'linux') {
562
618
  // On Linux, backup and replace AppImage file
563
619
  // Remove existing backup if it exists
@@ -579,115 +635,117 @@ const Updater = {
579
635
  // Create/update launcher script that points to the AppImage
580
636
  await createLinuxAppImageLauncherScript(runningAppBundlePath);
581
637
  } else {
582
- // On Windows, use versioned app folders
638
+ // On Windows, files are locked while in use, so we need a helper script
639
+ // that runs after the app exits to do the replacement
583
640
  const parentDir = dirname(runningAppBundlePath);
584
- const newVersionDir = join(parentDir, `app-${latestHash}`);
585
-
586
- // Create the versioned directory
587
- mkdirSync(newVersionDir, { recursive: true });
588
-
589
- // Copy all contents from the extracted app to the versioned directory
590
- const files = readdirSync(newAppBundlePath);
591
- for (const file of files) {
592
- const srcPath = join(newAppBundlePath, file);
593
- const destPath = join(newVersionDir, file);
594
- const stats = statSync(srcPath);
595
-
596
- if (stats.isDirectory()) {
597
- // Recursively copy directories
598
- cpSync(srcPath, destPath, { recursive: true });
599
- } else {
600
- // Copy files
601
- cpSync(srcPath, destPath);
602
- }
603
- }
604
-
605
- // Clean up the temporary extraction directory on Windows
606
- if (currentOS === 'win') {
607
- rmdirSync(extractionDir, { recursive: true });
608
- }
609
-
610
- // Create/update the launcher batch file
611
- const launcherPath = join(parentDir, "run.bat");
612
- const launcherContent = `@echo off
613
- :: Electrobun App Launcher
614
- :: This file launches the current version
615
-
616
- :: Set current version
617
- set CURRENT_HASH=${latestHash}
618
- set APP_DIR=%~dp0app-%CURRENT_HASH%
619
-
620
- :: TODO: Implement proper cleanup mechanism that checks for running processes
621
- :: For now, old versions are kept to avoid race conditions during updates
622
- :: :: Clean up old app versions (keep current and one backup)
623
- :: for /d %%D in ("%~dp0app-*") do (
624
- :: if not "%%~nxD"=="app-%CURRENT_HASH%" (
625
- :: echo Removing old version: %%~nxD
626
- :: rmdir /s /q "%%D" 2>nul
627
- :: )
628
- :: )
629
-
630
- :: Launch the app
631
- cd /d "%APP_DIR%\\bin"
632
- start "" launcher.exe
641
+ const updateScriptPath = join(parentDir, 'update.bat');
642
+ const backupDir = join(parentDir, 'app-backup');
643
+ const launcherPath = join(runningAppBundlePath, 'bin', 'launcher.exe');
644
+
645
+ // Convert paths to Windows format
646
+ const backupDirWin = backupDir.replace(/\//g, '\\');
647
+ const runningAppWin = runningAppBundlePath.replace(/\//g, '\\');
648
+ const newAppWin = newAppBundlePath.replace(/\//g, '\\');
649
+ const extractionDirWin = extractionDir.replace(/\//g, '\\');
650
+ const launcherPathWin = launcherPath.replace(/\//g, '\\');
651
+
652
+ // Create a batch script that will:
653
+ // 1. Wait for the current app to exit
654
+ // 2. Remove old backup
655
+ // 3. Move current app to backup
656
+ // 4. Move new app to current location
657
+ // 5. Launch the new app
658
+ // 6. Clean up
659
+ const updateScript = `@echo off
660
+ setlocal
661
+
662
+ :: Wait for the app to fully exit (check if launcher.exe is still running)
663
+ :waitloop
664
+ tasklist /FI "IMAGENAME eq launcher.exe" 2>NUL | find /I /N "launcher.exe">NUL
665
+ if "%ERRORLEVEL%"=="0" (
666
+ timeout /t 1 /nobreak >nul
667
+ goto waitloop
668
+ )
669
+
670
+ :: Small extra delay to ensure all file handles are released
671
+ timeout /t 2 /nobreak >nul
672
+
673
+ :: Remove old backup if exists
674
+ if exist "${backupDirWin}" (
675
+ rmdir /s /q "${backupDirWin}"
676
+ )
677
+
678
+ :: Backup current app folder
679
+ if exist "${runningAppWin}" (
680
+ move "${runningAppWin}" "${backupDirWin}"
681
+ )
682
+
683
+ :: Move new app to current location
684
+ move "${newAppWin}" "${runningAppWin}"
685
+
686
+ :: Clean up extraction directory
687
+ rmdir /s /q "${extractionDirWin}" 2>nul
688
+
689
+ :: Launch the new app
690
+ start "" "${launcherPathWin}"
691
+
692
+ :: Clean up scheduled tasks starting with ElectrobunUpdate_
693
+ for /f "tokens=1" %%t in ('schtasks /query /fo list ^| findstr /i "ElectrobunUpdate_"') do (
694
+ schtasks /delete /tn "%%t" /f >nul 2>&1
695
+ )
696
+
697
+ :: Delete this update script after a short delay
698
+ ping -n 2 127.0.0.1 >nul
699
+ del "%~f0"
633
700
  `;
634
-
635
- await Bun.write(launcherPath, launcherContent);
636
-
637
- // Update desktop shortcuts to point to run.bat
638
- // This is handled by the running app, not the updater
639
-
640
- runningAppBundlePath = newVersionDir;
701
+
702
+ await Bun.write(updateScriptPath, updateScript);
703
+
704
+ // Use Windows Task Scheduler to run the update script independently
705
+ // This ensures the script runs even after the app exits
706
+ const scriptPathWin = updateScriptPath.replace(/\//g, '\\');
707
+ const taskName = `ElectrobunUpdate_${Date.now()}`;
708
+
709
+ // Create a scheduled task that runs immediately and deletes itself
710
+ execSync(`schtasks /create /tn "${taskName}" /tr "cmd /c \\"${scriptPathWin}\\"" /sc once /st 00:00 /f`, { stdio: 'ignore' });
711
+ execSync(`schtasks /run /tn "${taskName}"`, { stdio: 'ignore' });
712
+ // The task will be cleaned up by Windows after it runs, or we delete it in the batch script
713
+
714
+ // Use quit() for graceful shutdown - this closes all windows and processes
715
+ quit();
641
716
  }
642
717
  } catch (error) {
643
718
  console.error("Failed to replace app with new version", error);
644
719
  return;
645
720
  }
646
721
 
647
- // Cross-platform app launch
648
- switch (currentOS) {
649
- case 'macos':
650
- // Use a detached shell so relaunch survives after killApp terminates the current process
651
- await Bun.spawn(
652
- [
653
- "sh",
654
- "-c",
655
- `open "${runningAppBundlePath}" &`,
656
- ],
657
- { detached: true }
658
- );
659
- break;
660
- case 'win':
661
- // On Windows, launch the run.bat file which handles versioning
662
- const parentDir = dirname(runningAppBundlePath);
663
- const runBatPath = join(parentDir, "run.bat");
664
-
665
-
666
- await Bun.spawn(["cmd", "/c", runBatPath], { detached: true });
667
- break;
668
- case 'linux':
669
- // On Linux, launch the AppImage directly
670
- Bun.spawn(["sh", "-c", `"${runningAppBundlePath}" &`], { detached: true});
671
- break;
722
+ // Cross-platform app launch (Windows is handled above with its own update script)
723
+ if (currentOS === 'macos') {
724
+ // Use a detached shell so relaunch survives after killApp terminates the current process
725
+ await Bun.spawn(
726
+ [
727
+ "sh",
728
+ "-c",
729
+ `open "${runningAppBundlePath}" &`,
730
+ ],
731
+ { detached: true }
732
+ );
733
+ } else if (currentOS === 'linux') {
734
+ // On Linux, launch the AppImage directly
735
+ Bun.spawn(["sh", "-c", `"${runningAppBundlePath}" &`], { detached: true});
672
736
  }
673
- // Use native killApp to properly clean up all resources
674
- try {
675
- native.symbols.killApp();
676
- // Still call process.exit as a fallback
677
- process.exit(0);
678
- } catch (e) {
679
- // Fallback if native binding fails
680
- console.error('Failed to call native killApp:', e);
681
- process.exit(0);
682
- }
737
+
738
+ // Use quit() for graceful shutdown
739
+ quit();
683
740
  }
684
741
  }
685
742
  },
686
743
 
687
744
  channelBucketUrl: async () => {
688
745
  await Updater.getLocallocalInfo();
689
- const platformFolder = getPlatformFolder(localInfo.channel, currentOS, currentArch);
690
- return join(localInfo.bucketUrl, platformFolder);
746
+ // With flat prefix-based naming, channelBucketUrl is just the baseUrl
747
+ // Users can also use Updater.localInfo.baseUrl() directly
748
+ return localInfo.baseUrl;
691
749
  },
692
750
 
693
751
  appDataFolder: async () => {
@@ -712,8 +770,8 @@ start "" launcher.exe
712
770
  channel: async () => {
713
771
  return (await Updater.getLocallocalInfo()).channel;
714
772
  },
715
- bucketUrl: async () => {
716
- return (await Updater.getLocallocalInfo()).bucketUrl;
773
+ baseUrl: async () => {
774
+ return (await Updater.getLocallocalInfo()).baseUrl;
717
775
  },
718
776
  },
719
777
 
@@ -3,10 +3,9 @@ import {
3
3
  sanitizeAppName,
4
4
  getAppFileName,
5
5
  getBundleFileName,
6
- getPlatformFolder,
6
+ getPlatformPrefix,
7
7
  getTarballFileName,
8
8
  getWindowsSetupFileName,
9
- getLinuxSetupFileName,
10
9
  getLinuxAppImageBaseName,
11
10
  getLinuxAppImageFileName,
12
11
  sanitizeVolumeNameForHdiutil,
@@ -82,13 +81,13 @@ describe('getBundleFileName', () => {
82
81
  });
83
82
  });
84
83
 
85
- describe('getPlatformFolder', () => {
86
- it('constructs correct folder format for all platform combinations', () => {
87
- expect(getPlatformFolder('stable', 'macos', 'arm64')).toBe('stable-macos-arm64');
88
- expect(getPlatformFolder('stable', 'macos', 'x64')).toBe('stable-macos-x64');
89
- expect(getPlatformFolder('canary', 'win', 'x64')).toBe('canary-win-x64');
90
- expect(getPlatformFolder('dev', 'linux', 'arm64')).toBe('dev-linux-arm64');
91
- expect(getPlatformFolder('dev', 'linux', 'x64')).toBe('dev-linux-x64');
84
+ describe('getPlatformPrefix', () => {
85
+ it('constructs correct prefix format for all platform combinations', () => {
86
+ expect(getPlatformPrefix('stable', 'macos', 'arm64')).toBe('stable-macos-arm64');
87
+ expect(getPlatformPrefix('stable', 'macos', 'x64')).toBe('stable-macos-x64');
88
+ expect(getPlatformPrefix('canary', 'win', 'x64')).toBe('canary-win-x64');
89
+ expect(getPlatformPrefix('dev', 'linux', 'arm64')).toBe('dev-linux-arm64');
90
+ expect(getPlatformPrefix('dev', 'linux', 'x64')).toBe('dev-linux-x64');
92
91
  });
93
92
  });
94
93
 
@@ -136,19 +135,10 @@ describe('getWindowsSetupFileName', () => {
136
135
  it('includes channel suffix for dev builds', () => {
137
136
  expect(getWindowsSetupFileName('MyApp', 'dev')).toBe('MyApp-Setup-dev.exe');
138
137
  });
139
- });
140
-
141
- describe('getLinuxSetupFileName', () => {
142
- it('returns AppName-Setup.run for stable builds', () => {
143
- expect(getLinuxSetupFileName('MyApp', 'stable')).toBe('MyApp-Setup.run');
144
- });
145
-
146
- it('includes channel suffix for canary builds', () => {
147
- expect(getLinuxSetupFileName('MyApp', 'canary')).toBe('MyApp-Setup-canary.run');
148
- });
149
138
 
150
- it('includes channel suffix for dev builds', () => {
151
- expect(getLinuxSetupFileName('MyApp', 'dev')).toBe('MyApp-Setup-dev.run');
139
+ it('preserves spaces in app name', () => {
140
+ expect(getWindowsSetupFileName('My App', 'stable')).toBe('My App-Setup.exe');
141
+ expect(getWindowsSetupFileName('My App', 'canary')).toBe('My App-Setup-canary.exe');
152
142
  });
153
143
  });
154
144
 
@@ -160,6 +150,11 @@ describe('getLinuxAppImageBaseName', () => {
160
150
  it('includes channel suffix for canary builds', () => {
161
151
  expect(getLinuxAppImageBaseName('MyApp', 'canary')).toBe('MyApp-Setup-canary');
162
152
  });
153
+
154
+ it('preserves spaces in app name', () => {
155
+ expect(getLinuxAppImageBaseName('My App', 'stable')).toBe('My App-Setup');
156
+ expect(getLinuxAppImageBaseName('My App', 'canary')).toBe('My App-Setup-canary');
157
+ });
163
158
  });
164
159
 
165
160
  describe('getLinuxAppImageFileName', () => {
@@ -170,6 +165,11 @@ describe('getLinuxAppImageFileName', () => {
170
165
  it('returns full filename with .AppImage extension for canary', () => {
171
166
  expect(getLinuxAppImageFileName('MyApp', 'canary')).toBe('MyApp-Setup-canary.AppImage');
172
167
  });
168
+
169
+ it('preserves spaces in app name', () => {
170
+ expect(getLinuxAppImageFileName('My App', 'stable')).toBe('My App-Setup.AppImage');
171
+ expect(getLinuxAppImageFileName('My App', 'canary')).toBe('My App-Setup-canary.AppImage');
172
+ });
173
173
  });
174
174
 
175
175
  describe('sanitizeVolumeNameForHdiutil', () => {
@@ -195,63 +195,64 @@ describe('sanitizeVolumeNameForHdiutil', () => {
195
195
  });
196
196
 
197
197
  describe('getDmgVolumeName', () => {
198
- it('adds -stable suffix for stable builds (to avoid CI volume conflicts)', () => {
199
- expect(getDmgVolumeName('MyApp', 'stable')).toBe('MyApp-stable');
198
+ it('returns plain name for stable builds', () => {
199
+ expect(getDmgVolumeName('MyApp', 'stable')).toBe('MyApp');
200
200
  });
201
201
 
202
- it('returns sanitized name for canary builds (already has suffix)', () => {
203
- expect(getDmgVolumeName('MyApp-canary', 'canary')).toBe('MyAppcanary');
202
+ it('adds channel suffix for non-stable builds', () => {
203
+ expect(getDmgVolumeName('MyApp', 'canary')).toBe('MyApp-canary');
204
204
  });
205
205
 
206
206
  it('sanitizes special characters', () => {
207
- expect(getDmgVolumeName('My-App', 'stable')).toBe('MyApp-stable');
207
+ expect(getDmgVolumeName('My-App', 'stable')).toBe('MyApp');
208
+ expect(getDmgVolumeName('My-App', 'canary')).toBe('MyApp-canary');
208
209
  });
209
210
  });
210
211
 
211
212
  describe('URL construction functions', () => {
212
- const bucketUrl = 'https://storage.example.com/releases';
213
- const platformFolder = 'canary-macos-arm64';
213
+ const baseUrl = 'https://storage.example.com/releases';
214
+ const platformPrefix = 'canary-macos-arm64';
214
215
 
215
216
  describe('getUpdateInfoUrl', () => {
216
- it('constructs correct URL', () => {
217
- expect(getUpdateInfoUrl(bucketUrl, platformFolder))
218
- .toBe('https://storage.example.com/releases/canary-macos-arm64/update.json');
217
+ it('constructs correct flat URL with prefix', () => {
218
+ expect(getUpdateInfoUrl(baseUrl, platformPrefix))
219
+ .toBe('https://storage.example.com/releases/canary-macos-arm64-update.json');
219
220
  });
220
221
 
221
222
  it('handles bucket URLs with trailing content', () => {
222
223
  expect(getUpdateInfoUrl('https://example.com/bucket', 'stable-win-x64'))
223
- .toBe('https://example.com/bucket/stable-win-x64/update.json');
224
+ .toBe('https://example.com/bucket/stable-win-x64-update.json');
224
225
  });
225
226
  });
226
227
 
227
228
  describe('getPatchFileUrl', () => {
228
- it('constructs correct URL with hash', () => {
229
- expect(getPatchFileUrl(bucketUrl, platformFolder, 'abc123def456'))
230
- .toBe('https://storage.example.com/releases/canary-macos-arm64/abc123def456.patch');
229
+ it('constructs correct flat URL with prefix and hash', () => {
230
+ expect(getPatchFileUrl(baseUrl, platformPrefix, 'abc123def456'))
231
+ .toBe('https://storage.example.com/releases/canary-macos-arm64-abc123def456.patch');
231
232
  });
232
233
  });
233
234
 
234
235
  describe('getTarballUrl', () => {
235
- it('constructs correct URL for macOS tarball', () => {
236
- expect(getTarballUrl(bucketUrl, platformFolder, 'MyApp.app.tar.zst'))
237
- .toBe('https://storage.example.com/releases/canary-macos-arm64/MyApp.app.tar.zst');
236
+ it('constructs correct flat URL for macOS tarball', () => {
237
+ expect(getTarballUrl(baseUrl, platformPrefix, 'MyApp.app.tar.zst'))
238
+ .toBe('https://storage.example.com/releases/canary-macos-arm64-MyApp.app.tar.zst');
238
239
  });
239
240
 
240
- it('constructs correct URL for Windows tarball', () => {
241
- expect(getTarballUrl(bucketUrl, 'stable-win-x64', 'MyApp.tar.zst'))
242
- .toBe('https://storage.example.com/releases/stable-win-x64/MyApp.tar.zst');
241
+ it('constructs correct flat URL for Windows tarball', () => {
242
+ expect(getTarballUrl(baseUrl, 'stable-win-x64', 'MyApp.tar.zst'))
243
+ .toBe('https://storage.example.com/releases/stable-win-x64-MyApp.tar.zst');
243
244
  });
244
245
  });
245
246
  });
246
247
 
247
248
  // Integration tests that verify CLI and Updater would produce matching values
248
249
  describe('CLI and Updater consistency', () => {
249
- it('produces matching platform folders', () => {
250
+ it('produces matching platform prefixes', () => {
250
251
  // CLI uses: `${buildEnvironment}-${currentTarget.os}-${currentTarget.arch}`
251
252
  // Updater uses: `${localInfo.channel}-${currentOS}-${currentArch}`
252
- // Both should use getPlatformFolder()
253
- const cliResult = getPlatformFolder('canary', 'macos', 'arm64');
254
- const updaterResult = getPlatformFolder('canary', 'macos', 'arm64');
253
+ // Both should use getPlatformPrefix()
254
+ const cliResult = getPlatformPrefix('canary', 'macos', 'arm64');
255
+ const updaterResult = getPlatformPrefix('canary', 'macos', 'arm64');
255
256
  expect(cliResult).toBe(updaterResult);
256
257
  expect(cliResult).toBe('canary-macos-arm64');
257
258
  });
@@ -280,6 +281,6 @@ describe('CLI and Updater consistency', () => {
280
281
  expect(getAppFileName('MyApp', 'stable')).not.toContain('-stable');
281
282
  expect(getTarballFileName(getAppFileName('MyApp', 'stable'), 'macos')).toBe('MyApp.app.tar.zst');
282
283
  expect(getWindowsSetupFileName('MyApp', 'stable')).toBe('MyApp-Setup.exe');
283
- expect(getLinuxSetupFileName('MyApp', 'stable')).toBe('MyApp-Setup.run');
284
+ expect(getLinuxAppImageFileName('MyApp', 'stable')).toBe('MyApp-Setup.AppImage');
284
285
  });
285
286
  });
@@ -27,6 +27,19 @@ export function getAppFileName(appName: string, buildEnvironment: BuildEnvironme
27
27
  return buildEnvironment === 'stable' ? sanitized : `${sanitized}-${buildEnvironment}`;
28
28
  }
29
29
 
30
+ /**
31
+ * Generates the macOS bundle display name (with spaces preserved).
32
+ * Used for the actual .app folder name on macOS.
33
+ * Format: "App Name" (stable) or "App Name-channel" (non-stable)
34
+ *
35
+ * @example
36
+ * getMacOSBundleDisplayName("My App", "stable") // "My App"
37
+ * getMacOSBundleDisplayName("My App", "canary") // "My App-canary"
38
+ */
39
+ export function getMacOSBundleDisplayName(appName: string, buildEnvironment: BuildEnvironment): string {
40
+ return buildEnvironment === 'stable' ? appName : `${appName}-${buildEnvironment}`;
41
+ }
42
+
30
43
  /**
31
44
  * Generates the bundle file name (with platform-specific extension).
32
45
  * macOS: "AppName.app" or "AppName-channel.app"
@@ -38,10 +51,11 @@ export function getBundleFileName(appName: string, buildEnvironment: BuildEnviro
38
51
  }
39
52
 
40
53
  /**
41
- * Generates the platform folder name for artifacts.
54
+ * Generates the platform prefix for artifacts.
42
55
  * Format: "channel-os-arch" (e.g., "stable-macos-arm64", "canary-win-x64")
56
+ * Used for flat file naming in artifact folders and bucket URLs.
43
57
  */
44
- export function getPlatformFolder(buildEnvironment: BuildEnvironment, os: SupportedOS, arch: SupportedArch): string {
58
+ export function getPlatformPrefix(buildEnvironment: BuildEnvironment, os: SupportedOS, arch: SupportedArch): string {
45
59
  return `${buildEnvironment}-${os}-${arch}`;
46
60
  }
47
61
 
@@ -56,7 +70,8 @@ export function getTarballFileName(appFileName: string, os: SupportedOS): string
56
70
 
57
71
  /**
58
72
  * Generates the Windows installer setup file name.
59
- * Format: "AppName-Setup.exe" (stable) or "AppName-Setup-channel.exe" (non-stable)
73
+ * Preserves spaces in app name for user-friendly display.
74
+ * Format: "App Name-Setup.exe" (stable) or "App Name-Setup-channel.exe" (non-stable)
60
75
  */
61
76
  export function getWindowsSetupFileName(appName: string, buildEnvironment: BuildEnvironment): string {
62
77
  return buildEnvironment === 'stable'
@@ -64,19 +79,10 @@ export function getWindowsSetupFileName(appName: string, buildEnvironment: Build
64
79
  : `${appName}-Setup-${buildEnvironment}.exe`;
65
80
  }
66
81
 
67
- /**
68
- * Generates the Linux self-extracting binary file name.
69
- * Format: "AppName-Setup.run" (stable) or "AppName-Setup-channel.run" (non-stable)
70
- */
71
- export function getLinuxSetupFileName(appName: string, buildEnvironment: BuildEnvironment): string {
72
- return buildEnvironment === 'stable'
73
- ? `${appName}-Setup.run`
74
- : `${appName}-Setup-${buildEnvironment}.run`;
75
- }
76
-
77
82
  /**
78
83
  * Generates the Linux AppImage wrapper name (without extension).
79
- * Format: "AppName-Setup" (stable) or "AppName-Setup-channel" (non-stable)
84
+ * Preserves spaces in app name for user-friendly display.
85
+ * Format: "App Name-Setup" (stable) or "App Name-Setup-channel" (non-stable)
80
86
  */
81
87
  export function getLinuxAppImageBaseName(appName: string, buildEnvironment: BuildEnvironment): string {
82
88
  return buildEnvironment === 'stable'
@@ -100,32 +106,35 @@ export function sanitizeVolumeNameForHdiutil(name: string): string {
100
106
  }
101
107
 
102
108
  /**
103
- * Generates the DMG volume name for macOS during creation.
104
- * Uses sanitized name with "-stable" suffix for stable builds to avoid
105
- * CI volume mounting conflicts. The DMG is renamed after creation.
109
+ * Generates the DMG volume name for macOS.
110
+ * Takes the original app name (with spaces) and preserves them for display.
111
+ * Format: "App Name" (stable) or "App Name-channel" (non-stable)
106
112
  */
107
- export function getDmgVolumeName(appFileName: string, buildEnvironment: BuildEnvironment): string {
108
- const baseName = sanitizeVolumeNameForHdiutil(appFileName);
109
- return buildEnvironment === 'stable' ? `${baseName}-stable` : baseName;
113
+ export function getDmgVolumeName(appName: string, buildEnvironment: BuildEnvironment): string {
114
+ const baseName = sanitizeVolumeNameForHdiutil(appName);
115
+ return buildEnvironment === 'stable' ? baseName : `${baseName}-${buildEnvironment}`;
110
116
  }
111
117
 
112
118
  /**
113
119
  * Constructs the full URL for the update.json file.
120
+ * Uses flat prefix-based naming for compatibility with GitHub Releases and other hosts.
114
121
  */
115
- export function getUpdateInfoUrl(bucketUrl: string, platformFolder: string): string {
116
- return `${bucketUrl}/${platformFolder}/update.json`;
122
+ export function getUpdateInfoUrl(baseUrl: string, platformPrefix: string): string {
123
+ return `${baseUrl}/${platformPrefix}-update.json`;
117
124
  }
118
125
 
119
126
  /**
120
127
  * Constructs the full URL for a patch file.
128
+ * Uses flat prefix-based naming for compatibility with GitHub Releases and other hosts.
121
129
  */
122
- export function getPatchFileUrl(bucketUrl: string, platformFolder: string, hash: string): string {
123
- return `${bucketUrl}/${platformFolder}/${hash}.patch`;
130
+ export function getPatchFileUrl(baseUrl: string, platformPrefix: string, hash: string): string {
131
+ return `${baseUrl}/${platformPrefix}-${hash}.patch`;
124
132
  }
125
133
 
126
134
  /**
127
135
  * Constructs the full URL for a tarball.
136
+ * Uses flat prefix-based naming for compatibility with GitHub Releases and other hosts.
128
137
  */
129
- export function getTarballUrl(bucketUrl: string, platformFolder: string, tarballFileName: string): string {
130
- return `${bucketUrl}/${platformFolder}/${tarballFileName}`;
138
+ export function getTarballUrl(baseUrl: string, platformPrefix: string, tarballFileName: string): string {
139
+ return `${baseUrl}/${platformPrefix}-${tarballFileName}`;
131
140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "electrobun",
3
- "version": "0.13.0-beta.8",
3
+ "version": "1.0.2-beta.0",
4
4
  "description": "Build ultra fast, tiny, and cross-platform desktop apps with Typescript.",
5
5
  "license": "MIT",
6
6
  "author": "Blackboard Technologies Inc.",
@@ -35,6 +35,7 @@
35
35
  "dev:clean": "cd ../kitchen && rm -rf node_modules && rm -rf vendors/cef && cd ../package && bun dev",
36
36
  "dev:rerun": "cd ../kitchen && bun start",
37
37
  "dev:canary": "bun install && bun build:release && bun build:cli && cd ../kitchen && bun install && bun build:canary",
38
+ "dev:stable": "bun install && bun build:release && bun build:cli && cd ../kitchen && bun install && bun build:stable",
38
39
  "run:template": "bun install && bun build:dev && bun build:cli && cd ../templates/interactive-playground && bun install && bun build:dev && bun start",
39
40
  "dev:docs": "cd ../documentation && bun start",
40
41
  "build:docs:release": "cd ../documentation && bun run build",
package/src/cli/index.ts CHANGED
@@ -25,13 +25,13 @@ import { OS, ARCH } from '../shared/platform';
25
25
  import {
26
26
  getAppFileName,
27
27
  getBundleFileName,
28
- getPlatformFolder,
28
+ getPlatformPrefix,
29
29
  getTarballFileName,
30
30
  getWindowsSetupFileName,
31
- getLinuxSetupFileName,
32
31
  getLinuxAppImageBaseName,
33
32
  sanitizeVolumeNameForHdiutil,
34
33
  getDmgVolumeName,
34
+ getMacOSBundleDisplayName,
35
35
  } from '../shared/naming';
36
36
  import { getTemplate, getTemplateNames } from './templates/embedded';
37
37
  // import { loadBsdiff, loadBspatch } from 'bsdiff-wasm';
@@ -107,7 +107,9 @@ async function ensureCoreDependencies(targetOS?: 'macos' | 'win' | 'linux', targ
107
107
 
108
108
  // Check platform-specific binaries
109
109
  const requiredBinaries = [
110
- platformPaths.BUN_BINARY
110
+ platformPaths.BUN_BINARY,
111
+ platformPaths.BSDIFF,
112
+ platformPaths.BSPATCH
111
113
  ];
112
114
  if (platformOS === 'macos') {
113
115
  requiredBinaries.push(
@@ -254,7 +256,11 @@ async function ensureCoreDependencies(targetOS?: 'macos' | 'win' | 'linux', targ
254
256
  }
255
257
 
256
258
  // Verify extraction completed successfully - check platform-specific binaries only
257
- const requiredBinaries = [platformPaths.BUN_BINARY];
259
+ const requiredBinaries = [
260
+ platformPaths.BUN_BINARY,
261
+ platformPaths.BSDIFF,
262
+ platformPaths.BSPATCH
263
+ ];
258
264
  if (platformOS === 'macos') {
259
265
  requiredBinaries.push(
260
266
  platformPaths.LAUNCHER_RELEASE,
@@ -587,7 +593,7 @@ const defaultConfig = {
587
593
  postPackage: "",
588
594
  },
589
595
  release: {
590
- bucketUrl: "",
596
+ baseUrl: "",
591
597
  },
592
598
  };
593
599
 
@@ -708,17 +714,17 @@ async function createAppImage(
708
714
  buildFolder: string
709
715
  ): Promise<string> {
710
716
  console.log(`🚀 CREATING APPIMAGE WITH PATH: ${appBundlePath}`);
711
- console.log(`DEBUG: createAppImage called with:`);
712
- console.log(` appBundlePath: ${appBundlePath}`);
713
- console.log(` appFileName: ${appFileName}`);
714
- console.log(` buildFolder: ${buildFolder}`);
715
- console.log(` current working directory: ${process.cwd()}`);
717
+ // console.log(`DEBUG: createAppImage called with:`);
718
+ // console.log(` appBundlePath: ${appBundlePath}`);
719
+ // console.log(` appFileName: ${appFileName}`);
720
+ // console.log(` buildFolder: ${buildFolder}`);
721
+ // console.log(` current working directory: ${process.cwd()}`);
716
722
 
717
723
  // Ensure appBundlePath is absolute - fix for when it's passed as basename only
718
724
  let resolvedAppBundlePath = appBundlePath;
719
725
  if (!path.isAbsolute(appBundlePath)) {
720
726
  resolvedAppBundlePath = join(buildFolder, appBundlePath);
721
- console.log(`DEBUG: Converted relative path to absolute: ${resolvedAppBundlePath}`);
727
+ // console.log(`DEBUG: Converted relative path to absolute: ${resolvedAppBundlePath}`);
722
728
  }
723
729
 
724
730
  // Create AppDir structure
@@ -732,17 +738,17 @@ async function createAppImage(
732
738
  const usrBinPath = join(appDirPath, 'usr', 'bin');
733
739
  mkdirSync(usrBinPath, { recursive: true });
734
740
 
735
- console.log(`DEBUG: Attempting to copy from: ${resolvedAppBundlePath}`);
736
- console.log(`DEBUG: Does source exist? ${existsSync(resolvedAppBundlePath)}`);
737
- console.log(`DEBUG: To destination: ${join(usrBinPath, basename(resolvedAppBundlePath))}`);
741
+ // console.log(`DEBUG: Attempting to copy from: ${resolvedAppBundlePath}`);
742
+ // console.log(`DEBUG: Does source exist? ${existsSync(resolvedAppBundlePath)}`);
743
+ // console.log(`DEBUG: To destination: ${join(usrBinPath, basename(resolvedAppBundlePath))}`);
738
744
 
739
745
  if (!existsSync(resolvedAppBundlePath)) {
740
746
  throw new Error(`Source bundle does not exist: ${resolvedAppBundlePath}`);
741
747
  }
742
748
 
743
- console.log(`DEBUG: About to copy with cpSync:`);
744
- console.log(` from: ${resolvedAppBundlePath} (exists: ${existsSync(resolvedAppBundlePath)})`);
745
- console.log(` to: ${join(usrBinPath, basename(resolvedAppBundlePath))}`);
749
+ // console.log(`DEBUG: About to copy with cpSync:`);
750
+ // console.log(` from: ${resolvedAppBundlePath} (exists: ${existsSync(resolvedAppBundlePath)})`);
751
+ // console.log(` to: ${join(usrBinPath, basename(resolvedAppBundlePath))}`);
746
752
 
747
753
  cpSync(resolvedAppBundlePath, join(usrBinPath, basename(resolvedAppBundlePath)), {
748
754
  recursive: true,
@@ -802,8 +808,8 @@ Categories=Utility;
802
808
  unlinkSync(appImagePath);
803
809
  }
804
810
 
805
- console.log(`DEBUG: AppDir path: ${appDirPath}`);
806
- console.log(`DEBUG: Does AppDir exist? ${existsSync(appDirPath)}`);
811
+ // console.log(`DEBUG: AppDir path: ${appDirPath}`);
812
+ // console.log(`DEBUG: Does AppDir exist? ${existsSync(appDirPath)}`);
807
813
  console.log(`Generating AppImage: ${appImagePath}`);
808
814
  const appImageArch = ARCH === 'arm64' ? 'aarch64' : 'x86_64';
809
815
 
@@ -1048,10 +1054,12 @@ if (commandArg === "init") {
1048
1054
  const targetARCH = currentTarget.arch;
1049
1055
  const targetBinExt = targetOS === 'win' ? '.exe' : '';
1050
1056
  const appFileName = getAppFileName(config.app.name, buildEnvironment);
1051
- const buildSubFolder = getPlatformFolder(buildEnvironment, currentTarget.os, currentTarget.arch);
1052
- const buildFolder = join(projectRoot, config.build.buildFolder, buildSubFolder);
1057
+ // macOS bundle display name preserves spaces for the actual .app folder
1058
+ const macOSBundleDisplayName = getMacOSBundleDisplayName(config.app.name, buildEnvironment);
1059
+ const platformPrefix = getPlatformPrefix(buildEnvironment, currentTarget.os, currentTarget.arch);
1060
+ const buildFolder = join(projectRoot, config.build.buildFolder, platformPrefix);
1053
1061
  const bundleFileName = getBundleFileName(config.app.name, buildEnvironment, targetOS);
1054
- const artifactFolder = join(projectRoot, config.build.artifactFolder, buildSubFolder);
1062
+ const artifactFolder = join(projectRoot, config.build.artifactFolder);
1055
1063
 
1056
1064
  // Ensure core binaries are available for the target platform before starting build
1057
1065
  await ensureCoreDependencies(currentTarget.os, currentTarget.arch);
@@ -1171,13 +1179,15 @@ if (commandArg === "init") {
1171
1179
  }
1172
1180
 
1173
1181
  // build macos bundle
1182
+ // Use display name (with spaces) for macOS bundle folders, sanitized name for other platforms
1183
+ const bundleName = targetOS === 'macos' ? macOSBundleDisplayName : appFileName;
1174
1184
  const {
1175
1185
  appBundleFolderPath,
1176
1186
  appBundleFolderContentsPath,
1177
1187
  appBundleMacOSPath,
1178
1188
  appBundleFolderResourcesPath,
1179
1189
  appBundleFolderFrameworksPath,
1180
- } = createAppBundle(appFileName, buildFolder, targetOS);
1190
+ } = createAppBundle(bundleName, buildFolder, targetOS);
1181
1191
 
1182
1192
  const appBundleAppCodePath = join(appBundleFolderResourcesPath, "app");
1183
1193
 
@@ -1206,7 +1216,7 @@ if (commandArg === "init") {
1206
1216
  <key>CFBundleIdentifier</key>
1207
1217
  <string>${config.app.identifier}</string>
1208
1218
  <key>CFBundleName</key>
1209
- <string>${appFileName}</string>
1219
+ <string>${bundleName}</string>
1210
1220
  <key>CFBundleVersion</key>
1211
1221
  <string>${config.app.version}</string>
1212
1222
  <key>CFBundlePackageType</key>
@@ -1932,7 +1942,8 @@ if (commandArg === "init") {
1932
1942
  // All the unique files are in the bundle now. Create an initial temporary tar file
1933
1943
  // for hashing the contents
1934
1944
  // tar the signed and notarized app bundle
1935
- const tmpTarPath = `${appBundleFolderPath}-temp.tar`;
1945
+ // Use sanitized appFileName for tarball paths (URL-safe), but tar content uses actual bundle folder
1946
+ const tmpTarPath = join(buildFolder, `${appFileName}${targetOS === 'macos' ? '.app' : ''}-temp.tar`);
1936
1947
  await tar.c(
1937
1948
  {
1938
1949
  gzip: false,
@@ -1958,7 +1969,7 @@ if (commandArg === "init") {
1958
1969
  // then gets used for patching and updating.
1959
1970
  hash: hash,
1960
1971
  channel: buildEnvironment,
1961
- bucketUrl: config.release.bucketUrl,
1972
+ baseUrl: config.release.baseUrl,
1962
1973
  name: appFileName,
1963
1974
  identifier: config.app.identifier,
1964
1975
  });
@@ -2011,12 +2022,9 @@ if (commandArg === "init") {
2011
2022
  }
2012
2023
 
2013
2024
  const artifactsToUpload = [];
2014
-
2015
- console.log(`DEBUG: Checking for Linux AppImage creation - targetOS: ${targetOS}, buildEnvironment: ${buildEnvironment}`);
2016
2025
 
2017
2026
  // Linux AppImage creation (for all build environments including dev)
2018
- if (targetOS === 'linux') {
2019
- console.log("DEBUG: Creating Linux AppImage...");
2027
+ if (targetOS === 'linux') {
2020
2028
  // Ensure AppImage tooling is available
2021
2029
  await ensureAppImageTooling();
2022
2030
 
@@ -2098,20 +2106,21 @@ if (commandArg === "init") {
2098
2106
  // Compress with Zstandard
2099
2107
  console.log(`Compressing tar with zstd...`);
2100
2108
  const uncompressedTarData = readFileSync(appImageTarPath);
2109
+ const compressedTarPath = `${appImageTarPath}.zst`;
2101
2110
  await ZstdInit().then(async ({ ZstdSimple }) => {
2102
2111
  const data = new Uint8Array(uncompressedTarData);
2103
2112
  const compressionLevel = 22;
2104
2113
  const compressedData = ZstdSimple.compress(data, compressionLevel);
2105
- const compressedPath = `${appImageTarPath}.zst`;
2106
- writeFileSync(compressedPath, compressedData);
2107
- console.log(`✓ Created compressed tar: ${compressedPath} (${(compressedData.length / 1024 / 1024).toFixed(2)} MB)`);
2114
+ writeFileSync(compressedTarPath, compressedData);
2115
+ console.log(`✓ Created compressed tar: ${compressedTarPath} (${(compressedData.length / 1024 / 1024).toFixed(2)} MB)`);
2108
2116
  });
2109
-
2110
- // Remove uncompressed tar
2111
- unlinkSync(appImageTarPath);
2112
-
2113
- // Add AppImage to artifacts for distribution (for direct download)
2114
- artifactsToUpload.push(appImagePath);
2117
+
2118
+ // Note: Don't delete uncompressed tar here - bsdiff needs it later for patch generation
2119
+ // It will be cleaned up after bsdiff runs
2120
+
2121
+ // Add tar.zst for Updater API (delta updates)
2122
+ // Note: raw AppImage is NOT added - only the Setup.AppImage (zipped) is distributed
2123
+ artifactsToUpload.push(compressedTarPath);
2115
2124
  }
2116
2125
  }
2117
2126
 
@@ -2131,7 +2140,8 @@ if (commandArg === "init") {
2131
2140
 
2132
2141
  // Platform suffix is only used for folder names, not file names
2133
2142
  const platformSuffix = `-${targetOS}-${targetARCH}`;
2134
- const tarPath = `${appBundleFolderPath}.tar`;
2143
+ // Use sanitized appFileName for tarball path (URL-safe), but tar content uses actual bundle folder name
2144
+ const tarPath = join(buildFolder, `${appFileName}${targetOS === 'macos' ? '.app' : ''}.tar`);
2135
2145
 
2136
2146
  // For Linux, we've already created the tar in the AppImage section above
2137
2147
  // For macOS/Windows, tar the signed and notarized app bundle
@@ -2201,7 +2211,7 @@ if (commandArg === "init") {
2201
2211
  rmdirSync(appBundleFolderPath, { recursive: true });
2202
2212
  }
2203
2213
 
2204
- const selfExtractingBundle = createAppBundle(appFileName, buildFolder, targetOS);
2214
+ const selfExtractingBundle = createAppBundle(bundleName, buildFolder, targetOS);
2205
2215
  const compressedTarballInExtractingBundlePath = join(
2206
2216
  selfExtractingBundle.appBundleFolderResourcesPath,
2207
2217
  `${hash}.tar.zst`
@@ -2260,7 +2270,7 @@ if (commandArg === "init") {
2260
2270
  buildEnvironment === "stable"
2261
2271
  ? join(buildFolder, `${appFileName}-stable.dmg`)
2262
2272
  : finalDmgPath;
2263
- const dmgVolumeName = getDmgVolumeName(appFileName, buildEnvironment);
2273
+ const dmgVolumeName = getDmgVolumeName(config.app.name, buildEnvironment);
2264
2274
 
2265
2275
  // Create a staging directory for DMG contents (app + Applications shortcut)
2266
2276
  const dmgStagingDir = join(buildFolder, '.dmg-staging');
@@ -2338,8 +2348,10 @@ if (commandArg === "init") {
2338
2348
  buildEnvironment,
2339
2349
  hash
2340
2350
  );
2341
-
2342
- artifactsToUpload.push(selfExtractingAppImagePath);
2351
+
2352
+ // Wrap the Setup.AppImage in a tar.gz to preserve executable permissions
2353
+ const archivedAppImagePath = await wrapInArchive(selfExtractingAppImagePath, buildFolder, 'tar.gz');
2354
+ artifactsToUpload.push(archivedAppImagePath);
2343
2355
  }
2344
2356
  }
2345
2357
 
@@ -2362,40 +2374,33 @@ if (commandArg === "init") {
2362
2374
  platform: OS,
2363
2375
  arch: ARCH,
2364
2376
  // channel: buildEnvironment,
2365
- // bucketUrl: config.release.bucketUrl
2377
+ // baseUrl: config.release.baseUrl
2366
2378
  });
2367
2379
 
2368
- // update.json (no platform suffix in filename, platform is in folder name)
2369
- await Bun.write(join(artifactFolder, 'update.json'), updateJsonContent);
2380
+ // update.json with platform prefix for flat naming structure
2381
+ await Bun.write(join(artifactFolder, `${platformPrefix}-update.json`), updateJsonContent);
2370
2382
 
2371
2383
  // generate bsdiff
2372
2384
  // https://storage.googleapis.com/eggbun-static/electrobun-playground/canary/ElectrobunPlayground-canary.app.tar.zst
2373
- console.log("bucketUrl: ", config.release.bucketUrl);
2385
+ console.log("baseUrl: ", config.release.baseUrl);
2374
2386
 
2375
2387
  console.log("generating a patch from the previous version...");
2376
-
2377
- // Skip patch generation if bucketUrl is not configured
2378
- if (!config.release.bucketUrl || config.release.bucketUrl.trim() === '') {
2379
- console.log("No bucketUrl configured, skipping patch generation");
2380
- console.log("To enable patch generation, configure bucketUrl in your electrobun.config");
2388
+
2389
+ // Skip patch generation if baseUrl is not configured
2390
+ if (!config.release.baseUrl || config.release.baseUrl.trim() === '') {
2391
+ console.log("No baseUrl configured, skipping patch generation");
2392
+ console.log("To enable patch generation, configure baseUrl in your electrobun.config");
2381
2393
  } else {
2382
- const urlToPrevUpdateJson = join(
2383
- config.release.bucketUrl,
2384
- buildSubFolder,
2385
- 'update.json'
2386
- );
2394
+ const urlToPrevUpdateJson = `${config.release.baseUrl}/${platformPrefix}-update.json`;
2387
2395
  const cacheBuster = Math.random().toString(36).substring(7);
2388
2396
  const updateJsonResponse = await fetch(
2389
2397
  urlToPrevUpdateJson + `?${cacheBuster}`
2390
2398
  ).catch((err) => {
2391
- console.log("bucketURL not found: ", err);
2399
+ console.log("baseUrl not found: ", err);
2392
2400
  });
2393
2401
 
2394
- const urlToLatestTarball = join(
2395
- config.release.bucketUrl,
2396
- buildSubFolder,
2397
- `${appFileName}.app.tar.zst`
2398
- );
2402
+ const tarballFileName = getTarballFileName(appFileName, OS);
2403
+ const urlToLatestTarball = `${config.release.baseUrl}/${platformPrefix}-${tarballFileName}`;
2399
2404
 
2400
2405
 
2401
2406
  // attempt to get the previous version to create a patch file
@@ -2441,7 +2446,6 @@ if (commandArg === "init") {
2441
2446
  // especially for creating multiple diffs in parallel
2442
2447
  const bsdiffpath = targetPaths.BSDIFF;
2443
2448
  const patchFilePath = join(buildFolder, `${prevHash}.patch`);
2444
- artifactsToUpload.push(patchFilePath);
2445
2449
  const result = Bun.spawnSync(
2446
2450
  [bsdiffpath, prevTarballPath, tarPath, patchFilePath, "--use-zstd"],
2447
2451
  {
@@ -2451,21 +2455,37 @@ if (commandArg === "init") {
2451
2455
  }
2452
2456
  );
2453
2457
  if (!result.success) {
2454
- throw new Error(`bsdiff failed with exit code ${result.exitCode}`);
2458
+ // Patch generation is non-critical - users will just download full updates instead of delta patches
2459
+ console.error("\n" + "=".repeat(80));
2460
+ console.error("WARNING: Patch generation failed (exit code " + result.exitCode + ")");
2461
+ console.error("Delta updates will not be available for this release.");
2462
+ console.error("Users will download the full update instead.");
2463
+ console.error("=".repeat(80) + "\n");
2464
+ } else {
2465
+ // Only add patch to artifacts if it was successfully created
2466
+ artifactsToUpload.push(patchFilePath);
2467
+ }
2468
+
2469
+ // Clean up uncompressed tars now that bsdiff is done
2470
+ if (existsSync(tarPath)) {
2471
+ unlinkSync(tarPath);
2472
+ }
2473
+ if (existsSync(prevTarballPath)) {
2474
+ unlinkSync(prevTarballPath);
2455
2475
  }
2456
2476
  }
2457
2477
  } else {
2458
2478
  console.log("prevoius version not found at: ", urlToLatestTarball);
2459
2479
  console.log("skipping diff generation");
2460
2480
  }
2461
- } // End of bucketUrl validation block
2481
+ } // End of baseUrl validation block
2462
2482
 
2463
2483
  // compress all the upload files
2464
2484
  console.log("copying artifacts...");
2465
2485
 
2466
2486
  artifactsToUpload.forEach((filePath) => {
2467
2487
  const filename = basename(filePath);
2468
- cpSync(filePath, join(artifactFolder, filename), { dereference: true });
2488
+ cpSync(filePath, join(artifactFolder, `${platformPrefix}-${filename}`), { dereference: true });
2469
2489
  });
2470
2490
 
2471
2491
  // todo: now just upload the artifacts to your bucket replacing the ones that exist
@@ -2811,59 +2831,6 @@ async function createWindowsSelfExtractingExe(
2811
2831
  return outputExePath;
2812
2832
  }
2813
2833
 
2814
- async function createLinuxSelfExtractingBinary(
2815
- buildFolder: string,
2816
- compressedTarPath: string,
2817
- appFileName: string,
2818
- targetPaths: any,
2819
- buildEnvironment: string,
2820
- config: Awaited<ReturnType<typeof getConfig>>
2821
- ): Promise<string> {
2822
- console.log("Creating self-extracting Linux binary...");
2823
-
2824
- const setupFileName = getLinuxSetupFileName(config.app.name, buildEnvironment);
2825
-
2826
- const outputPath = join(buildFolder, setupFileName);
2827
-
2828
- // Read the extractor binary
2829
- const extractorBinary = readFileSync(targetPaths.EXTRACTOR);
2830
-
2831
- // Read the compressed archive
2832
- const compressedArchive = readFileSync(compressedTarPath);
2833
-
2834
- // Create metadata JSON
2835
- const metadata = {
2836
- identifier: config.app.identifier,
2837
- name: config.app.name,
2838
- channel: buildEnvironment
2839
- };
2840
- const metadataJson = JSON.stringify(metadata);
2841
- const metadataBuffer = Buffer.from(metadataJson, 'utf8');
2842
-
2843
- // Create marker buffers
2844
- const metadataMarker = Buffer.from('ELECTROBUN_METADATA_V1', 'utf8');
2845
- const archiveMarker = Buffer.from('ELECTROBUN_ARCHIVE_V1', 'utf8');
2846
-
2847
- // Combine extractor + metadata marker + metadata + archive marker + archive
2848
- const combinedBuffer = Buffer.concat([
2849
- extractorBinary,
2850
- metadataMarker,
2851
- metadataBuffer,
2852
- archiveMarker,
2853
- compressedArchive
2854
- ]);
2855
-
2856
- // Write the self-extracting binary
2857
- writeFileSync(outputPath, combinedBuffer, { mode: 0o755 });
2858
-
2859
- // Ensure it's executable (redundant but explicit)
2860
- execSync(`chmod +x ${escapePathForTerminal(outputPath)}`);
2861
-
2862
- console.log(`Created self-extracting Linux binary: ${outputPath} (${(combinedBuffer.length / 1024 / 1024).toFixed(2)} MB)`);
2863
-
2864
- return outputPath;
2865
- }
2866
-
2867
2834
  async function wrapWindowsInstallerInZip(exePath: string, buildFolder: string): Promise<string> {
2868
2835
  const exeName = basename(exePath);
2869
2836
  const exeStem = exeName.replace('.exe', '');
@@ -2918,11 +2885,14 @@ async function wrapInArchive(filePath: string, buildFolder: string, archiveType:
2918
2885
  const fileDir = dirname(filePath);
2919
2886
 
2920
2887
  if (archiveType === 'tar.gz') {
2921
- // Output filename: Setup.exe -> Setup.exe.tar.gz or Setup.run -> Setup.run.tar.gz
2922
- const archivePath = filePath + '.tar.gz';
2923
-
2924
- // For Linux files, ensure they have executable permissions before archiving
2925
- if (fileName.endsWith('.run')) {
2888
+ // Output filename: Setup.exe -> Setup.exe.tar.gz, Setup.AppImage -> Setup.tar.gz
2889
+ // For AppImage files, strip the .AppImage extension so archive extracts to .AppImage
2890
+ const archivePath = fileName.endsWith('.AppImage')
2891
+ ? filePath.replace(/\.AppImage$/, '.tar.gz')
2892
+ : filePath + '.tar.gz';
2893
+
2894
+ // For Linux AppImage files, ensure they have executable permissions before archiving
2895
+ if (fileName.endsWith('.AppImage')) {
2926
2896
  try {
2927
2897
  // Try to set executable permissions (will only work on Unix-like systems)
2928
2898
  execSync(`chmod +x ${escapePathForTerminal(filePath)}`, { stdio: 'ignore' });