electrobun 0.13.0-beta.21 → 0.13.0-beta.22

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.
@@ -8,6 +8,28 @@ import { OS as currentOS, ARCH as currentArch } from '../../shared/platform';
8
8
  import { getPlatformFolder, getTarballFileName } from '../../shared/naming';
9
9
  import { native } from '../proc/native';
10
10
 
11
+ // Helper to join URL paths without breaking the protocol (path.join mangles https:// to https:/)
12
+ function urlJoin(...parts: string[]): string {
13
+ if (parts.length === 0) return '';
14
+
15
+ // Start with the first part (base URL)
16
+ let result = parts[0];
17
+
18
+ // Join remaining parts
19
+ for (let i = 1; i < parts.length; i++) {
20
+ const part = parts[i];
21
+ if (!part) continue;
22
+
23
+ // Remove trailing slash from result and leading slash from part
24
+ const cleanResult = result.endsWith('/') ? result.slice(0, -1) : result;
25
+ const cleanPart = part.startsWith('/') ? part.slice(1) : part;
26
+
27
+ result = `${cleanResult}/${cleanPart}`;
28
+ }
29
+
30
+ return result;
31
+ }
32
+
11
33
  // setTimeout(async () => {
12
34
  // console.log('killing')
13
35
  // const { native } = await import('../proc/native');
@@ -144,7 +166,7 @@ const Updater = {
144
166
  const channelBucketUrl = await Updater.channelBucketUrl();
145
167
  const cacheBuster = Math.random().toString(36).substring(7);
146
168
  const platformFolder = getPlatformFolder(localInfo.channel, currentOS, currentArch);
147
- const updateInfoUrl = join(localInfo.bucketUrl, platformFolder, `update.json?${cacheBuster}`);
169
+ const updateInfoUrl = urlJoin(localInfo.bucketUrl, platformFolder, `update.json?${cacheBuster}`);
148
170
 
149
171
  try {
150
172
  const updateInfoResponse = await fetch(updateInfoUrl);
@@ -213,7 +235,7 @@ const Updater = {
213
235
  // check if there's a patch file for it
214
236
  const platformFolder = getPlatformFolder(localInfo.channel, currentOS, currentArch);
215
237
  const patchResponse = await fetch(
216
- join(localInfo.bucketUrl, platformFolder, `${currentHash}.patch`)
238
+ urlJoin(localInfo.bucketUrl, platformFolder, `${currentHash}.patch`)
217
239
  );
218
240
 
219
241
  if (!patchResponse.ok) {
@@ -332,7 +354,7 @@ const Updater = {
332
354
  const cacheBuster = Math.random().toString(36).substring(7);
333
355
  const platformFolder = getPlatformFolder(localInfo.channel, currentOS, currentArch);
334
356
  const tarballName = getTarballFileName(appFileName, currentOS);
335
- const urlToLatestTarball = join(
357
+ const urlToLatestTarball = urlJoin(
336
358
  localInfo.bucketUrl,
337
359
  platformFolder,
338
360
  tarballName
@@ -471,10 +493,12 @@ const Updater = {
471
493
  // Platform-specific path handling
472
494
  let newAppBundlePath: string;
473
495
  if (currentOS === 'linux') {
474
- // On Linux, look for the .AppImage file in the extraction directory
496
+ // On Linux, the tarball contains a directory named {appFileName}, and inside it is the AppImage
497
+ // Structure: extractionDir/{appFileName}/{appFileName}.AppImage
498
+ const innerDirName = localInfo.name.replace(/ /g, "");
475
499
  const appImageName = `${localInfo.name.replace(/ /g, "").replace(/\./g, "-")}.AppImage`;
476
- newAppBundlePath = join(extractionDir, appImageName);
477
-
500
+ newAppBundlePath = join(extractionDir, innerDirName, appImageName);
501
+
478
502
  // Verify the AppImage exists
479
503
  if (!statSync(newAppBundlePath, { throwIfNoEntry: false })) {
480
504
  console.error(`AppImage not found at: ${newAppBundlePath}`);
@@ -483,6 +507,14 @@ const Updater = {
483
507
  const files = readdirSync(extractionDir);
484
508
  for (const file of files) {
485
509
  console.log(` - ${file}`);
510
+ // Also list contents of subdirectories
511
+ const subPath = join(extractionDir, file);
512
+ if (statSync(subPath).isDirectory()) {
513
+ const subFiles = readdirSync(subPath);
514
+ for (const subFile of subFiles) {
515
+ console.log(` - ${subFile}`);
516
+ }
517
+ }
486
518
  }
487
519
  } catch (e) {
488
520
  console.log("Could not list directory contents:", e);
@@ -530,9 +562,8 @@ const Updater = {
530
562
  const appImageName = `${localInfo.name.replace(/ /g, "").replace(/\./g, "-")}.AppImage`;
531
563
  runningAppBundlePath = join(appDataFolder, appImageName);
532
564
  } else {
533
- // On Windows, use versioned app folders
534
- const currentHash = (await Updater.getLocallocalInfo()).hash;
535
- runningAppBundlePath = join(appDataFolder, `app-${currentHash}`);
565
+ // On Windows, use fixed 'app' folder to match extractor
566
+ runningAppBundlePath = join(appDataFolder, 'app');
536
567
  }
537
568
  }
538
569
  // Platform-specific backup handling
@@ -588,101 +619,92 @@ const Updater = {
588
619
  // Create/update launcher script that points to the AppImage
589
620
  await createLinuxAppImageLauncherScript(runningAppBundlePath);
590
621
  } else {
591
- // On Windows, use versioned app folders
622
+ // On Windows, files are locked while in use, so we need a helper script
623
+ // that runs after the app exits to do the replacement
592
624
  const parentDir = dirname(runningAppBundlePath);
593
- const newVersionDir = join(parentDir, `app-${latestHash}`);
594
-
595
- // Create the versioned directory
596
- mkdirSync(newVersionDir, { recursive: true });
597
-
598
- // Copy all contents from the extracted app to the versioned directory
599
- const files = readdirSync(newAppBundlePath);
600
- for (const file of files) {
601
- const srcPath = join(newAppBundlePath, file);
602
- const destPath = join(newVersionDir, file);
603
- const stats = statSync(srcPath);
604
-
605
- if (stats.isDirectory()) {
606
- // Recursively copy directories
607
- cpSync(srcPath, destPath, { recursive: true });
608
- } else {
609
- // Copy files
610
- cpSync(srcPath, destPath);
611
- }
612
- }
613
-
614
- // Clean up the temporary extraction directory on Windows
615
- if (currentOS === 'win') {
616
- rmdirSync(extractionDir, { recursive: true });
617
- }
618
-
619
- // Create/update the launcher batch file
620
- const launcherPath = join(parentDir, "run.bat");
621
- const launcherContent = `@echo off
622
- :: Electrobun App Launcher
623
- :: This file launches the current version
624
-
625
- :: Set current version
626
- set CURRENT_HASH=${latestHash}
627
- set APP_DIR=%~dp0app-%CURRENT_HASH%
628
-
629
- :: TODO: Implement proper cleanup mechanism that checks for running processes
630
- :: For now, old versions are kept to avoid race conditions during updates
631
- :: :: Clean up old app versions (keep current and one backup)
632
- :: for /d %%D in ("%~dp0app-*") do (
633
- :: if not "%%~nxD"=="app-%CURRENT_HASH%" (
634
- :: echo Removing old version: %%~nxD
635
- :: rmdir /s /q "%%D" 2>nul
636
- :: )
637
- :: )
638
-
639
- :: Launch the app
640
- cd /d "%APP_DIR%\\bin"
641
- start "" launcher.exe
625
+ const updateScriptPath = join(parentDir, 'update.bat');
626
+ const backupDir = join(parentDir, 'app-backup');
627
+
628
+ // Create a batch script that will:
629
+ // 1. Wait for the current app to exit
630
+ // 2. Remove old backup
631
+ // 3. Move current app to backup
632
+ // 4. Move new app to current location
633
+ // 5. Launch the new app
634
+ // 6. Clean up
635
+ const updateScript = `@echo off
636
+ setlocal
637
+
638
+ :: Wait for the app to fully exit (check if launcher.exe is still running)
639
+ :waitloop
640
+ tasklist /FI "IMAGENAME eq launcher.exe" 2>NUL | find /I /N "launcher.exe">NUL
641
+ if "%ERRORLEVEL%"=="0" (
642
+ timeout /t 1 /nobreak >nul
643
+ goto waitloop
644
+ )
645
+
646
+ :: Small extra delay to ensure all file handles are released
647
+ timeout /t 1 /nobreak >nul
648
+
649
+ :: Remove old backup if exists
650
+ if exist "${backupDir.replace(/\//g, '\\')}" (
651
+ rmdir /s /q "${backupDir.replace(/\//g, '\\')}"
652
+ )
653
+
654
+ :: Backup current app folder
655
+ if exist "${runningAppBundlePath.replace(/\//g, '\\')}" (
656
+ move "${runningAppBundlePath.replace(/\//g, '\\')}" "${backupDir.replace(/\//g, '\\')}"
657
+ )
658
+
659
+ :: Move new app to current location
660
+ move "${newAppBundlePath.replace(/\//g, '\\')}" "${runningAppBundlePath.replace(/\//g, '\\')}"
661
+
662
+ :: Clean up extraction directory
663
+ rmdir /s /q "${extractionDir.replace(/\//g, '\\')}" 2>nul
664
+
665
+ :: Launch the new app
666
+ start "" /d "${join(runningAppBundlePath, 'bin').replace(/\//g, '\\')}" launcher.exe
667
+
668
+ :: Delete this update script
669
+ del "%~f0"
642
670
  `;
643
-
644
- await Bun.write(launcherPath, launcherContent);
645
-
646
- // Update desktop shortcuts to point to run.bat
647
- // This is handled by the running app, not the updater
648
-
649
- runningAppBundlePath = newVersionDir;
671
+
672
+ await Bun.write(updateScriptPath, updateScript);
673
+
674
+ // Launch the update script detached - it will wait for us to exit
675
+ await Bun.spawn(["cmd", "/c", "start", "", "/min", updateScriptPath], { detached: true });
676
+
677
+ // Small delay to ensure the script starts
678
+ await new Promise(resolve => setTimeout(resolve, 200));
679
+
680
+ // Now exit - the script will take over
681
+ try {
682
+ native.symbols.killApp();
683
+ process.exit(0);
684
+ } catch (e) {
685
+ process.exit(0);
686
+ }
687
+ return; // Won't reach here, but for clarity
650
688
  }
651
689
  } catch (error) {
652
690
  console.error("Failed to replace app with new version", error);
653
691
  return;
654
692
  }
655
693
 
656
- // Cross-platform app launch
657
- switch (currentOS) {
658
- case 'macos':
659
- // Use a detached shell so relaunch survives after killApp terminates the current process
660
- await Bun.spawn(
661
- [
662
- "sh",
663
- "-c",
664
- `open "${runningAppBundlePath}" &`,
665
- ],
666
- { detached: true }
667
- );
668
- break;
669
- case 'win':
670
- // On Windows, launch the run.bat file which handles versioning
671
- const parentDir = dirname(runningAppBundlePath);
672
- const runBatPath = join(parentDir, "run.bat");
673
-
674
- // Use start command to launch detached process that survives parent termination
675
- await Bun.spawn(["cmd", "/c", "start", "", "/d", parentDir, "run.bat"], { detached: true });
676
- break;
677
- case 'linux':
678
- // On Linux, launch the AppImage directly
679
- Bun.spawn(["sh", "-c", `"${runningAppBundlePath}" &`], { detached: true});
680
- break;
681
- }
682
-
683
- // Small delay on Windows to ensure new process starts before we terminate
684
- if (currentOS === 'win') {
685
- await new Promise(resolve => setTimeout(resolve, 500));
694
+ // Cross-platform app launch (Windows is handled above with its own update script)
695
+ if (currentOS === 'macos') {
696
+ // Use a detached shell so relaunch survives after killApp terminates the current process
697
+ await Bun.spawn(
698
+ [
699
+ "sh",
700
+ "-c",
701
+ `open "${runningAppBundlePath}" &`,
702
+ ],
703
+ { detached: true }
704
+ );
705
+ } else if (currentOS === 'linux') {
706
+ // On Linux, launch the AppImage directly
707
+ Bun.spawn(["sh", "-c", `"${runningAppBundlePath}" &`], { detached: true});
686
708
  }
687
709
 
688
710
  // Use native killApp to properly clean up all resources
@@ -702,7 +724,7 @@ start "" launcher.exe
702
724
  channelBucketUrl: async () => {
703
725
  await Updater.getLocallocalInfo();
704
726
  const platformFolder = getPlatformFolder(localInfo.channel, currentOS, currentArch);
705
- return join(localInfo.bucketUrl, platformFolder);
727
+ return urlJoin(localInfo.bucketUrl, platformFolder);
706
728
  },
707
729
 
708
730
  appDataFolder: async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "electrobun",
3
- "version": "0.13.0-beta.21",
3
+ "version": "0.13.0-beta.22",
4
4
  "description": "Build ultra fast, tiny, and cross-platform desktop apps with Typescript.",
5
5
  "license": "MIT",
6
6
  "author": "Blackboard Technologies Inc.",
package/src/cli/index.ts CHANGED
@@ -2098,20 +2098,21 @@ if (commandArg === "init") {
2098
2098
  // Compress with Zstandard
2099
2099
  console.log(`Compressing tar with zstd...`);
2100
2100
  const uncompressedTarData = readFileSync(appImageTarPath);
2101
+ const compressedTarPath = `${appImageTarPath}.zst`;
2101
2102
  await ZstdInit().then(async ({ ZstdSimple }) => {
2102
2103
  const data = new Uint8Array(uncompressedTarData);
2103
2104
  const compressionLevel = 22;
2104
2105
  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)`);
2106
+ writeFileSync(compressedTarPath, compressedData);
2107
+ console.log(`✓ Created compressed tar: ${compressedTarPath} (${(compressedData.length / 1024 / 1024).toFixed(2)} MB)`);
2108
2108
  });
2109
-
2109
+
2110
2110
  // Remove uncompressed tar
2111
2111
  unlinkSync(appImageTarPath);
2112
-
2113
- // Add AppImage to artifacts for distribution (for direct download)
2114
- artifactsToUpload.push(appImagePath);
2112
+
2113
+ // Add tar.zst for Updater API (delta updates)
2114
+ // Note: raw AppImage is NOT added - only the Setup.AppImage (zipped) is distributed
2115
+ artifactsToUpload.push(compressedTarPath);
2115
2116
  }
2116
2117
  }
2117
2118
 
@@ -2338,8 +2339,10 @@ if (commandArg === "init") {
2338
2339
  buildEnvironment,
2339
2340
  hash
2340
2341
  );
2341
-
2342
- artifactsToUpload.push(selfExtractingAppImagePath);
2342
+
2343
+ // Wrap the Setup.AppImage in a tar.gz to preserve executable permissions
2344
+ const archivedAppImagePath = await wrapInArchive(selfExtractingAppImagePath, buildFolder, 'tar.gz');
2345
+ artifactsToUpload.push(archivedAppImagePath);
2343
2346
  }
2344
2347
  }
2345
2348
 
@@ -2918,11 +2921,14 @@ async function wrapInArchive(filePath: string, buildFolder: string, archiveType:
2918
2921
  const fileDir = dirname(filePath);
2919
2922
 
2920
2923
  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
+ // Output filename: Setup.exe -> Setup.exe.tar.gz, Setup.AppImage -> Setup.tar.gz
2925
+ // For AppImage files, strip the .AppImage extension so archive extracts to .AppImage
2926
+ const archivePath = fileName.endsWith('.AppImage')
2927
+ ? filePath.replace(/\.AppImage$/, '.tar.gz')
2928
+ : filePath + '.tar.gz';
2929
+
2924
2930
  // For Linux files, ensure they have executable permissions before archiving
2925
- if (fileName.endsWith('.run')) {
2931
+ if (fileName.endsWith('.run') || fileName.endsWith('.AppImage')) {
2926
2932
  try {
2927
2933
  // Try to set executable permissions (will only work on Unix-like systems)
2928
2934
  execSync(`chmod +x ${escapePathForTerminal(filePath)}`, { stdio: 'ignore' });