electrobun 0.13.0-beta.21 → 0.13.0-beta.23

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.
@@ -7,6 +7,29 @@ import { ZstdInit } from "@oneidentity/zstd-js/wasm";
7
7
  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
+ 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')
@@ -144,7 +167,7 @@ const Updater = {
144
167
  const channelBucketUrl = await Updater.channelBucketUrl();
145
168
  const cacheBuster = Math.random().toString(36).substring(7);
146
169
  const platformFolder = getPlatformFolder(localInfo.channel, currentOS, currentArch);
147
- const updateInfoUrl = join(localInfo.bucketUrl, platformFolder, `update.json?${cacheBuster}`);
170
+ const updateInfoUrl = urlJoin(localInfo.bucketUrl, platformFolder, `update.json?${cacheBuster}`);
148
171
 
149
172
  try {
150
173
  const updateInfoResponse = await fetch(updateInfoUrl);
@@ -213,7 +236,7 @@ const Updater = {
213
236
  // check if there's a patch file for it
214
237
  const platformFolder = getPlatformFolder(localInfo.channel, currentOS, currentArch);
215
238
  const patchResponse = await fetch(
216
- join(localInfo.bucketUrl, platformFolder, `${currentHash}.patch`)
239
+ urlJoin(localInfo.bucketUrl, platformFolder, `${currentHash}.patch`)
217
240
  );
218
241
 
219
242
  if (!patchResponse.ok) {
@@ -332,7 +355,7 @@ const Updater = {
332
355
  const cacheBuster = Math.random().toString(36).substring(7);
333
356
  const platformFolder = getPlatformFolder(localInfo.channel, currentOS, currentArch);
334
357
  const tarballName = getTarballFileName(appFileName, currentOS);
335
- const urlToLatestTarball = join(
358
+ const urlToLatestTarball = urlJoin(
336
359
  localInfo.bucketUrl,
337
360
  platformFolder,
338
361
  tarballName
@@ -471,10 +494,12 @@ const Updater = {
471
494
  // Platform-specific path handling
472
495
  let newAppBundlePath: string;
473
496
  if (currentOS === 'linux') {
474
- // On Linux, look for the .AppImage file in the extraction directory
497
+ // On Linux, the tarball contains a directory named {appFileName}, and inside it is the AppImage
498
+ // Structure: extractionDir/{appFileName}/{appFileName}.AppImage
499
+ const innerDirName = localInfo.name.replace(/ /g, "");
475
500
  const appImageName = `${localInfo.name.replace(/ /g, "").replace(/\./g, "-")}.AppImage`;
476
- newAppBundlePath = join(extractionDir, appImageName);
477
-
501
+ newAppBundlePath = join(extractionDir, innerDirName, appImageName);
502
+
478
503
  // Verify the AppImage exists
479
504
  if (!statSync(newAppBundlePath, { throwIfNoEntry: false })) {
480
505
  console.error(`AppImage not found at: ${newAppBundlePath}`);
@@ -483,6 +508,14 @@ const Updater = {
483
508
  const files = readdirSync(extractionDir);
484
509
  for (const file of files) {
485
510
  console.log(` - ${file}`);
511
+ // Also list contents of subdirectories
512
+ const subPath = join(extractionDir, file);
513
+ if (statSync(subPath).isDirectory()) {
514
+ const subFiles = readdirSync(subPath);
515
+ for (const subFile of subFiles) {
516
+ console.log(` - ${subFile}`);
517
+ }
518
+ }
486
519
  }
487
520
  } catch (e) {
488
521
  console.log("Could not list directory contents:", e);
@@ -530,9 +563,8 @@ const Updater = {
530
563
  const appImageName = `${localInfo.name.replace(/ /g, "").replace(/\./g, "-")}.AppImage`;
531
564
  runningAppBundlePath = join(appDataFolder, appImageName);
532
565
  } else {
533
- // On Windows, use versioned app folders
534
- const currentHash = (await Updater.getLocallocalInfo()).hash;
535
- runningAppBundlePath = join(appDataFolder, `app-${currentHash}`);
566
+ // On Windows, use fixed 'app' folder to match extractor
567
+ runningAppBundlePath = join(appDataFolder, 'app');
536
568
  }
537
569
  }
538
570
  // Platform-specific backup handling
@@ -588,113 +620,108 @@ const Updater = {
588
620
  // Create/update launcher script that points to the AppImage
589
621
  await createLinuxAppImageLauncherScript(runningAppBundlePath);
590
622
  } else {
591
- // On Windows, use versioned app folders
623
+ // On Windows, files are locked while in use, so we need a helper script
624
+ // that runs after the app exits to do the replacement
592
625
  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
626
+ const updateScriptPath = join(parentDir, 'update.bat');
627
+ const backupDir = join(parentDir, 'app-backup');
628
+ const launcherPath = join(runningAppBundlePath, 'bin', 'launcher.exe');
629
+
630
+ // Convert paths to Windows format
631
+ const backupDirWin = backupDir.replace(/\//g, '\\');
632
+ const runningAppWin = runningAppBundlePath.replace(/\//g, '\\');
633
+ const newAppWin = newAppBundlePath.replace(/\//g, '\\');
634
+ const extractionDirWin = extractionDir.replace(/\//g, '\\');
635
+ const launcherPathWin = launcherPath.replace(/\//g, '\\');
636
+
637
+ // Create a batch script that will:
638
+ // 1. Wait for the current app to exit
639
+ // 2. Remove old backup
640
+ // 3. Move current app to backup
641
+ // 4. Move new app to current location
642
+ // 5. Launch the new app
643
+ // 6. Clean up
644
+ const updateScript = `@echo off
645
+ setlocal
646
+
647
+ :: Wait for the app to fully exit (check if launcher.exe is still running)
648
+ :waitloop
649
+ tasklist /FI "IMAGENAME eq launcher.exe" 2>NUL | find /I /N "launcher.exe">NUL
650
+ if "%ERRORLEVEL%"=="0" (
651
+ timeout /t 1 /nobreak >nul
652
+ goto waitloop
653
+ )
654
+
655
+ :: Small extra delay to ensure all file handles are released
656
+ timeout /t 2 /nobreak >nul
657
+
658
+ :: Remove old backup if exists
659
+ if exist "${backupDirWin}" (
660
+ rmdir /s /q "${backupDirWin}"
661
+ )
662
+
663
+ :: Backup current app folder
664
+ if exist "${runningAppWin}" (
665
+ move "${runningAppWin}" "${backupDirWin}"
666
+ )
667
+
668
+ :: Move new app to current location
669
+ move "${newAppWin}" "${runningAppWin}"
670
+
671
+ :: Clean up extraction directory
672
+ rmdir /s /q "${extractionDirWin}" 2>nul
673
+
674
+ :: Launch the new app
675
+ start "" "${launcherPathWin}"
676
+
677
+ :: Clean up scheduled tasks starting with ElectrobunUpdate_
678
+ for /f "tokens=1" %%t in ('schtasks /query /fo list ^| findstr /i "ElectrobunUpdate_"') do (
679
+ schtasks /delete /tn "%%t" /f >nul 2>&1
680
+ )
681
+
682
+ :: Delete this update script after a short delay
683
+ ping -n 2 127.0.0.1 >nul
684
+ del "%~f0"
642
685
  `;
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;
686
+
687
+ await Bun.write(updateScriptPath, updateScript);
688
+
689
+ // Use Windows Task Scheduler to run the update script independently
690
+ // This ensures the script runs even after the app exits
691
+ const scriptPathWin = updateScriptPath.replace(/\//g, '\\');
692
+ const taskName = `ElectrobunUpdate_${Date.now()}`;
693
+
694
+ // Create a scheduled task that runs immediately and deletes itself
695
+ execSync(`schtasks /create /tn "${taskName}" /tr "cmd /c \\"${scriptPathWin}\\"" /sc once /st 00:00 /f`, { stdio: 'ignore' });
696
+ execSync(`schtasks /run /tn "${taskName}"`, { stdio: 'ignore' });
697
+ // The task will be cleaned up by Windows after it runs, or we delete it in the batch script
698
+
699
+ // Use quit() for graceful shutdown - this closes all windows and processes
700
+ quit();
650
701
  }
651
702
  } catch (error) {
652
703
  console.error("Failed to replace app with new version", error);
653
704
  return;
654
705
  }
655
706
 
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));
707
+ // Cross-platform app launch (Windows is handled above with its own update script)
708
+ if (currentOS === 'macos') {
709
+ // Use a detached shell so relaunch survives after killApp terminates the current process
710
+ await Bun.spawn(
711
+ [
712
+ "sh",
713
+ "-c",
714
+ `open "${runningAppBundlePath}" &`,
715
+ ],
716
+ { detached: true }
717
+ );
718
+ } else if (currentOS === 'linux') {
719
+ // On Linux, launch the AppImage directly
720
+ Bun.spawn(["sh", "-c", `"${runningAppBundlePath}" &`], { detached: true});
686
721
  }
687
722
 
688
- // Use native killApp to properly clean up all resources
689
- try {
690
- native.symbols.killApp();
691
- // Still call process.exit as a fallback
692
- process.exit(0);
693
- } catch (e) {
694
- // Fallback if native binding fails
695
- console.error('Failed to call native killApp:', e);
696
- process.exit(0);
697
- }
723
+ // Use quit() for graceful shutdown
724
+ quit();
698
725
  }
699
726
  }
700
727
  },
@@ -702,7 +729,7 @@ start "" launcher.exe
702
729
  channelBucketUrl: async () => {
703
730
  await Updater.getLocallocalInfo();
704
731
  const platformFolder = getPlatformFolder(localInfo.channel, currentOS, currentArch);
705
- return join(localInfo.bucketUrl, platformFolder);
732
+ return urlJoin(localInfo.bucketUrl, platformFolder);
706
733
  },
707
734
 
708
735
  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.23",
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' });