electrobun 0.11.0-beta.3 → 0.13.0-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.
package/src/cli/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { join, dirname, basename, relative } from "path";
2
+ import * as path from "path";
2
3
  import {
3
4
  existsSync,
4
5
  readFileSync,
@@ -531,7 +532,7 @@ const commandDefaults = {
531
532
  },
532
533
  };
533
534
 
534
- // todo (yoav): add types for config
535
+ // TODO (yoav): add types for config
535
536
  const defaultConfig = {
536
537
  app: {
537
538
  name: "MyApp",
@@ -569,7 +570,10 @@ const defaultConfig = {
569
570
  },
570
571
  },
571
572
  scripts: {
573
+ preBuild: "",
572
574
  postBuild: "",
575
+ postWrap: "",
576
+ postPackage: "",
573
577
  },
574
578
  release: {
575
579
  bucketUrl: "",
@@ -606,6 +610,255 @@ function escapeXml(str: string): string {
606
610
  .replace(/'/g, ''');
607
611
  }
608
612
 
613
+ // Helper functions
614
+ function escapePathForTerminal(path: string): string {
615
+ return `"${path.replace(/"/g, '\\"')}"`;
616
+ }
617
+
618
+ // AppImage tooling functions
619
+ async function ensureAppImageTooling(): Promise<void> {
620
+ // First check if FUSE2 is available
621
+ try {
622
+ execSync('ls /usr/lib/*/libfuse.so.2 || ls /lib/*/libfuse.so.2', { stdio: 'ignore' });
623
+ } catch (error) {
624
+ console.log('');
625
+ console.log('═══════════════════════════════════════════════════════════════');
626
+ console.log('🚨 FUSE2 DEPENDENCY MISSING');
627
+ console.log('═══════════════════════════════════════════════════════════════');
628
+ console.log('AppImage creation requires libfuse2, but it was not found on your system.');
629
+ console.log('');
630
+ console.log('Please install it using:');
631
+ console.log(' sudo apt update && sudo apt install -y libfuse2');
632
+ console.log('');
633
+ console.log('Without libfuse2, AppImage creation will fail with FUSE errors.');
634
+ console.log('═══════════════════════════════════════════════════════════════');
635
+ console.log('');
636
+ throw new Error('libfuse2 is required for AppImage creation but not found. Please install it first.');
637
+ }
638
+
639
+ try {
640
+ // Check if appimagetool is available
641
+ execSync('which appimagetool', { stdio: 'ignore' });
642
+ console.log('✓ appimagetool found');
643
+ return;
644
+ } catch (error) {
645
+ // appimagetool not found, download it automatically
646
+ console.log('📥 appimagetool not found, downloading...');
647
+
648
+ try {
649
+ // Determine architecture-specific download URL
650
+ const downloadUrl = ARCH === 'arm64'
651
+ ? 'https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-aarch64.AppImage'
652
+ : 'https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage';
653
+
654
+ // Download appimagetool
655
+ console.log(`Downloading appimagetool from ${downloadUrl}...`);
656
+ execSync(`wget -q "${downloadUrl}" -O /tmp/appimagetool.AppImage`, { stdio: 'inherit' });
657
+
658
+ // Make it executable
659
+ execSync('chmod +x /tmp/appimagetool.AppImage', { stdio: 'inherit' });
660
+
661
+ // Try to move to /usr/local/bin (with sudo)
662
+ try {
663
+ execSync('sudo mv /tmp/appimagetool.AppImage /usr/local/bin/appimagetool', { stdio: 'inherit' });
664
+ console.log('✓ appimagetool installed to /usr/local/bin/appimagetool');
665
+ } catch (sudoError) {
666
+ // Fallback: extract and place in user's local bin
667
+ console.log('sudo not available, installing to ~/.local/bin/...');
668
+ execSync('mkdir -p ~/.local/bin', { stdio: 'inherit' });
669
+
670
+ // Extract the AppImage to get the binary
671
+ execSync('cd /tmp && ./appimagetool.AppImage --appimage-extract >/dev/null 2>&1', { stdio: 'inherit' });
672
+ execSync('cp /tmp/squashfs-root/usr/bin/appimagetool ~/.local/bin/appimagetool', { stdio: 'inherit' });
673
+ execSync('chmod +x ~/.local/bin/appimagetool', { stdio: 'inherit' });
674
+
675
+ // Set up symlink for mksquashfs dependency
676
+ execSync('mkdir -p ~/.local/lib/appimagekit', { stdio: 'inherit' });
677
+ execSync('ln -sf /usr/bin/mksquashfs ~/.local/lib/appimagekit/mksquashfs', { stdio: 'inherit' });
678
+
679
+ // Clean up
680
+ execSync('rm -rf /tmp/appimagetool.AppImage /tmp/squashfs-root', { stdio: 'inherit' });
681
+
682
+ console.log('✓ appimagetool installed to ~/.local/bin/appimagetool');
683
+ console.log('Note: Make sure ~/.local/bin is in your PATH for future use');
684
+ }
685
+
686
+ } catch (downloadError) {
687
+ console.error('Failed to download appimagetool:', downloadError);
688
+ throw new Error('Failed to install appimagetool automatically. Please install it manually.');
689
+ }
690
+ }
691
+ }
692
+
693
+ async function createAppImage(
694
+ appBundlePath: string,
695
+ appFileName: string,
696
+ config: any,
697
+ buildFolder: string
698
+ ): Promise<string> {
699
+ console.log(`🚀 CREATING APPIMAGE WITH PATH: ${appBundlePath}`);
700
+ console.log(`DEBUG: createAppImage called with:`);
701
+ console.log(` appBundlePath: ${appBundlePath}`);
702
+ console.log(` appFileName: ${appFileName}`);
703
+ console.log(` buildFolder: ${buildFolder}`);
704
+ console.log(` current working directory: ${process.cwd()}`);
705
+
706
+ // Ensure appBundlePath is absolute - fix for when it's passed as basename only
707
+ let resolvedAppBundlePath = appBundlePath;
708
+ if (!path.isAbsolute(appBundlePath)) {
709
+ resolvedAppBundlePath = join(buildFolder, appBundlePath);
710
+ console.log(`DEBUG: Converted relative path to absolute: ${resolvedAppBundlePath}`);
711
+ }
712
+
713
+ // Create AppDir structure
714
+ const appDirPath = join(buildFolder, `${appFileName}.AppDir`);
715
+ if (existsSync(appDirPath)) {
716
+ rmSync(appDirPath, { recursive: true, force: true });
717
+ }
718
+ mkdirSync(appDirPath, { recursive: true });
719
+
720
+ // Copy the entire app bundle to AppDir/usr/bin/
721
+ const usrBinPath = join(appDirPath, 'usr', 'bin');
722
+ mkdirSync(usrBinPath, { recursive: true });
723
+
724
+ console.log(`DEBUG: Attempting to copy from: ${resolvedAppBundlePath}`);
725
+ console.log(`DEBUG: Does source exist? ${existsSync(resolvedAppBundlePath)}`);
726
+ console.log(`DEBUG: To destination: ${join(usrBinPath, basename(resolvedAppBundlePath))}`);
727
+
728
+ if (!existsSync(resolvedAppBundlePath)) {
729
+ throw new Error(`Source bundle does not exist: ${resolvedAppBundlePath}`);
730
+ }
731
+
732
+ console.log(`DEBUG: About to copy with cpSync:`);
733
+ console.log(` from: ${resolvedAppBundlePath} (exists: ${existsSync(resolvedAppBundlePath)})`);
734
+ console.log(` to: ${join(usrBinPath, basename(resolvedAppBundlePath))}`);
735
+
736
+ cpSync(resolvedAppBundlePath, join(usrBinPath, basename(resolvedAppBundlePath)), {
737
+ recursive: true,
738
+ dereference: true
739
+ });
740
+
741
+ // Create AppRun script (the entry point)
742
+ const appBundleBasename = basename(resolvedAppBundlePath);
743
+ const appRunContent = `#!/bin/bash
744
+ # AppRun script for ${appFileName}
745
+ HERE="$(dirname "$(readlink -f "\${0}")")"
746
+ EXEC="\${HERE}/usr/bin/${appBundleBasename}/bin/launcher"
747
+
748
+ # Set up library path for CEF
749
+ export LD_LIBRARY_PATH="\${HERE}/usr/bin/${appBundleBasename}/bin:\${HERE}/usr/bin/${appBundleBasename}/lib:\${LD_LIBRARY_PATH}"
750
+
751
+ # Execute the application
752
+ exec "\${EXEC}" "\$@"
753
+ `;
754
+
755
+ const appRunPath = join(appDirPath, 'AppRun');
756
+ writeFileSync(appRunPath, appRunContent);
757
+ execSync(`chmod +x ${escapePathForTerminal(appRunPath)}`);
758
+
759
+ // Create .desktop file in AppDir root
760
+ const desktopContent = `[Desktop Entry]
761
+ Version=1.0
762
+ Type=Application
763
+ Name=${config.app.name}
764
+ Comment=${config.app.description || ''}
765
+ Exec=${appFileName}
766
+ Icon=${appFileName}
767
+ Terminal=false
768
+ StartupWMClass=${appFileName}
769
+ Categories=Utility;
770
+ `;
771
+
772
+ const desktopPath = join(appDirPath, `${appFileName}.desktop`);
773
+ writeFileSync(desktopPath, desktopContent);
774
+
775
+ // Copy icon if available
776
+ if (config.build.linux?.icon && existsSync(join(projectRoot, config.build.linux.icon))) {
777
+ const iconSourcePath = join(projectRoot, config.build.linux.icon);
778
+ const iconDestPath = join(appDirPath, `${appFileName}.png`);
779
+ const dirIconPath = join(appDirPath, '.DirIcon');
780
+
781
+ cpSync(iconSourcePath, iconDestPath, { dereference: true });
782
+ cpSync(iconSourcePath, dirIconPath, { dereference: true });
783
+
784
+ console.log(`Copied icon for AppImage: ${iconSourcePath} -> ${iconDestPath}`);
785
+ console.log(`Created .DirIcon: ${iconSourcePath} -> ${dirIconPath}`);
786
+ }
787
+
788
+ // Generate the AppImage using appimagetool
789
+ const appImagePath = join(buildFolder, `${appFileName}.AppImage`);
790
+ if (existsSync(appImagePath)) {
791
+ unlinkSync(appImagePath);
792
+ }
793
+
794
+ console.log(`DEBUG: AppDir path: ${appDirPath}`);
795
+ console.log(`DEBUG: Does AppDir exist? ${existsSync(appDirPath)}`);
796
+ console.log(`Generating AppImage: ${appImagePath}`);
797
+ const appImageArch = ARCH === 'arm64' ? 'aarch64' : 'x86_64';
798
+
799
+ // Use full path to appimagetool if not in PATH
800
+ let appimagetoolCmd = 'appimagetool';
801
+ try {
802
+ execSync('which appimagetool', { stdio: 'ignore' });
803
+ } catch {
804
+ // Try ~/.local/bin/appimagetool
805
+ const localBinPath = join(process.env['HOME'] || '', '.local', 'bin', 'appimagetool');
806
+ if (existsSync(localBinPath)) {
807
+ appimagetoolCmd = localBinPath;
808
+ }
809
+ }
810
+
811
+ try {
812
+ // First try with --no-appstream flag to avoid some FUSE-related issues
813
+ execSync(`ARCH=${appImageArch} ${appimagetoolCmd} --no-appstream ${escapePathForTerminal(appDirPath)} ${escapePathForTerminal(appImagePath)}`, {
814
+ stdio: 'inherit',
815
+ env: { ...process.env, ARCH: appImageArch }
816
+ });
817
+ } catch (error) {
818
+ console.error('Failed to create AppImage:', error);
819
+ console.log('Note: If you see FUSE errors, you may need to install libfuse2:');
820
+ console.log(' sudo apt update && sudo apt install -y libfuse2');
821
+ throw error;
822
+ }
823
+
824
+ // Verify the AppImage was created
825
+ if (!existsSync(appImagePath)) {
826
+ throw new Error(`AppImage was not created at expected path: ${appImagePath}`);
827
+ }
828
+
829
+ // Extract and copy icon for desktop shortcut
830
+ const iconExtractPath = join(buildFolder, `${appFileName}.png`);
831
+ if (config.build.linux?.icon && existsSync(join(projectRoot, config.build.linux.icon))) {
832
+ const iconSourcePath = join(projectRoot, config.build.linux.icon);
833
+ cpSync(iconSourcePath, iconExtractPath, { dereference: true });
834
+ console.log(`✓ Icon extracted for desktop shortcut: ${iconExtractPath}`);
835
+ }
836
+
837
+ // Create desktop shortcut alongside the AppImage
838
+ const desktopShortcutPath = join(buildFolder, `${appFileName}.desktop`);
839
+ const desktopShortcutContent = `[Desktop Entry]
840
+ Version=1.0
841
+ Type=Application
842
+ Name=${config.app.name}
843
+ Comment=${config.app.description || ''}
844
+ Exec=${appImagePath}
845
+ Icon=${iconExtractPath}
846
+ Terminal=false
847
+ StartupWMClass=${appFileName}
848
+ Categories=Utility;
849
+ `;
850
+
851
+ writeFileSync(desktopShortcutPath, desktopShortcutContent);
852
+ execSync(`chmod +x ${escapePathForTerminal(desktopShortcutPath)}`);
853
+ console.log(`✓ Desktop shortcut created: ${desktopShortcutPath}`);
854
+
855
+ // Clean up AppDir
856
+ rmSync(appDirPath, { recursive: true, force: true });
857
+
858
+ console.log(`✓ AppImage created: ${appImagePath}`);
859
+ return appImagePath;
860
+ }
861
+
609
862
  // Helper function to generate usage description entries for Info.plist
610
863
  function generateUsageDescriptions(entitlements: Record<string, boolean | string>): string {
611
864
  const usageEntries: string[] = [];
@@ -648,286 +901,8 @@ ${schemesXml}
648
901
  </array>`;
649
902
  }
650
903
 
651
- const command = commandDefaults[commandArg];
652
-
653
- if (!command) {
654
- console.error("Invalid command: ", commandArg);
655
- process.exit(1);
656
- }
657
-
658
- // Main execution function
659
- async function main() {
660
- const config = await getConfig();
661
-
662
- const envArg =
663
- process.argv.find((arg) => arg.startsWith("--env="))?.split("=")[1] || "";
664
-
665
- const targetsArg =
666
- process.argv.find((arg) => arg.startsWith("--targets="))?.split("=")[1] || "";
667
-
668
- const validEnvironments = ["dev", "canary", "stable"];
669
-
670
- // todo (yoav): dev, canary, and stable;
671
- const buildEnvironment: "dev" | "canary" | "stable" =
672
- validEnvironments.includes(envArg || "dev") ? (envArg || "dev") : "dev";
673
-
674
- // Determine build targets
675
- type BuildTarget = { os: 'macos' | 'win' | 'linux', arch: 'arm64' | 'x64' };
676
-
677
- function parseBuildTargets(): BuildTarget[] {
678
- // If explicit targets provided via CLI
679
- if (targetsArg) {
680
- if (targetsArg === 'current') {
681
- return [{ os: OS, arch: ARCH }];
682
- } else if (targetsArg === 'all') {
683
- return parseConfigTargets();
684
- } else {
685
- // Parse comma-separated targets like "macos-arm64,win-x64"
686
- return targetsArg.split(',').map(target => {
687
- const [os, arch] = target.trim().split('-') as [string, string];
688
- if (!['macos', 'win', 'linux'].includes(os) || !['arm64', 'x64'].includes(arch)) {
689
- console.error(`Invalid target: ${target}. Format should be: os-arch (e.g., macos-arm64)`);
690
- process.exit(1);
691
- }
692
- return { os, arch } as BuildTarget;
693
- });
694
- }
695
- }
696
-
697
- // Default behavior: always build for current platform only
698
- // This ensures predictable, fast builds unless explicitly requesting multi-platform
699
- return [{ os: OS, arch: ARCH }];
700
- }
701
-
702
- function parseConfigTargets(): BuildTarget[] {
703
- // If config has targets, use them
704
- if (config.build.targets && config.build.targets.length > 0) {
705
- return config.build.targets.map(target => {
706
- if (target === 'current') {
707
- return { os: OS, arch: ARCH };
708
- }
709
- const [os, arch] = target.split('-') as [string, string];
710
- if (!['macos', 'win', 'linux'].includes(os) || !['arm64', 'x64'].includes(arch)) {
711
- console.error(`Invalid target in config: ${target}. Format should be: os-arch (e.g., macos-arm64)`);
712
- process.exit(1);
713
- }
714
- return { os, arch } as BuildTarget;
715
- });
716
- }
717
-
718
- // If no config targets and --targets=all, use all available platforms
719
- if (targetsArg === 'all') {
720
- console.log('No targets specified in config, using all available platforms');
721
- return [
722
- { os: 'macos', arch: 'arm64' },
723
- { os: 'macos', arch: 'x64' },
724
- { os: 'win', arch: 'x64' },
725
- { os: 'linux', arch: 'x64' },
726
- { os: 'linux', arch: 'arm64' }
727
- ];
728
- }
729
-
730
- // Default to current platform
731
- return [{ os: OS, arch: ARCH }];
732
- }
733
-
734
- const buildTargets = parseBuildTargets();
735
-
736
- // Show build targets to user
737
- if (buildTargets.length === 1) {
738
- console.log(`Building for ${buildTargets[0].os}-${buildTargets[0].arch} (${buildEnvironment})`);
739
- } else {
740
- const targetList = buildTargets.map(t => `${t.os}-${t.arch}`).join(', ');
741
- console.log(`Building for multiple targets: ${targetList} (${buildEnvironment})`);
742
- console.log(`Running ${buildTargets.length} parallel builds...`);
743
-
744
- // Spawn parallel build processes
745
- const buildPromises = buildTargets.map(async (target) => {
746
- const targetString = `${target.os}-${target.arch}`;
747
- const prefix = `[${targetString}]`;
748
-
749
- try {
750
- // Try to find the electrobun binary in node_modules/.bin or use bunx
751
- const electrobunBin = join(projectRoot, 'node_modules', '.bin', 'electrobun');
752
- let command: string[];
753
-
754
- if (existsSync(electrobunBin)) {
755
- command = [electrobunBin, 'build', `--env=${buildEnvironment}`, `--targets=${targetString}`];
756
- } else {
757
- // Fallback to bunx which should resolve node_modules binaries
758
- command = ['bunx', 'electrobun', 'build', `--env=${buildEnvironment}`, `--targets=${targetString}`];
759
- }
760
-
761
- console.log(`${prefix} Running:`, command.join(' '));
762
-
763
- const result = await Bun.spawn(command, {
764
- stdio: ['inherit', 'pipe', 'pipe'],
765
- env: process.env,
766
- cwd: projectRoot // Ensure we're in the right directory
767
- });
768
-
769
- // Pipe output with prefix
770
- if (result.stdout) {
771
- const reader = result.stdout.getReader();
772
- while (true) {
773
- const { done, value } = await reader.read();
774
- if (done) break;
775
- const text = new TextDecoder().decode(value);
776
- // Add prefix to each line
777
- const prefixedText = text.split('\n').map(line =>
778
- line ? `${prefix} ${line}` : line
779
- ).join('\n');
780
- process.stdout.write(prefixedText);
781
- }
782
- }
783
-
784
- if (result.stderr) {
785
- const reader = result.stderr.getReader();
786
- while (true) {
787
- const { done, value } = await reader.read();
788
- if (done) break;
789
- const text = new TextDecoder().decode(value);
790
- const prefixedText = text.split('\n').map(line =>
791
- line ? `${prefix} ${line}` : line
792
- ).join('\n');
793
- process.stderr.write(prefixedText);
794
- }
795
- }
796
-
797
- const exitCode = await result.exited;
798
- return { target, exitCode, success: exitCode === 0 };
799
-
800
- } catch (error) {
801
- console.error(`${prefix} Failed to start build:`, error);
802
- return { target, exitCode: 1, success: false, error };
803
- }
804
- });
805
-
806
- // Wait for all builds to complete
807
- const results = await Promise.allSettled(buildPromises);
808
-
809
- // Report final results
810
- console.log('\n=== Build Results ===');
811
- let allSucceeded = true;
812
-
813
- for (const result of results) {
814
- if (result.status === 'fulfilled') {
815
- const { target, success, exitCode } = result.value;
816
- const status = success ? '✅ SUCCESS' : '❌ FAILED';
817
- console.log(`${target.os}-${target.arch}: ${status} (exit code: ${exitCode})`);
818
- if (!success) allSucceeded = false;
819
- } else {
820
- console.log(`Build rejected: ${result.reason}`);
821
- allSucceeded = false;
822
- }
823
- }
824
-
825
- if (!allSucceeded) {
826
- console.log('\nSome builds failed. Check the output above for details.');
827
- process.exit(1);
828
- } else {
829
- console.log('\nAll builds completed successfully! 🎉');
830
- }
831
-
832
- process.exit(0);
833
- }
834
-
835
- // todo (yoav): dev builds should include the branch name, and/or allow configuration via external config
836
- // For now, assume single target build (we'll refactor for multi-target later)
837
- const currentTarget = buildTargets[0];
838
- const buildSubFolder = `${buildEnvironment}-${currentTarget.os}-${currentTarget.arch}`;
839
-
840
- // Use target OS/ARCH for build logic (instead of current machine's OS/ARCH)
841
- const targetOS = currentTarget.os;
842
- const targetARCH = currentTarget.arch;
843
- const targetBinExt = targetOS === 'win' ? '.exe' : '';
844
-
845
- const buildFolder = join(projectRoot, config.build.buildFolder, buildSubFolder);
846
-
847
- const artifactFolder = join(
848
- projectRoot,
849
- config.build.artifactFolder,
850
- buildSubFolder
851
- );
852
-
853
- const buildIcons = (appBundleFolderResourcesPath: string) => {
854
- if (OS === 'macos' && config.build.mac.icons) {
855
- const iconSourceFolder = join(projectRoot, config.build.mac.icons);
856
- const iconDestPath = join(appBundleFolderResourcesPath, "AppIcon.icns");
857
- if (existsSync(iconSourceFolder)) {
858
- Bun.spawnSync(
859
- ["iconutil", "-c", "icns", "-o", iconDestPath, iconSourceFolder],
860
- {
861
- cwd: appBundleFolderResourcesPath,
862
- stdio: ["ignore", "inherit", "inherit"],
863
- env: {
864
- ...process.env,
865
- ELECTROBUN_BUILD_ENV: buildEnvironment,
866
- },
867
- }
868
- );
869
- }
870
- }
871
- };
872
-
873
- function escapePathForTerminal(filePath: string) {
874
- // List of special characters to escape
875
- const specialChars = [
876
- " ",
877
- "(",
878
- ")",
879
- "&",
880
- "|",
881
- ";",
882
- "<",
883
- ">",
884
- "`",
885
- "\\",
886
- '"',
887
- "'",
888
- "$",
889
- "*",
890
- "?",
891
- "[",
892
- "]",
893
- "#",
894
- ];
895
-
896
- let escapedPath = "";
897
- for (const char of filePath) {
898
- if (specialChars.includes(char)) {
899
- escapedPath += `\\${char}`;
900
- } else {
901
- escapedPath += char;
902
- }
903
- }
904
-
905
- return escapedPath;
906
- }
907
-
908
- function sanitizeVolumeNameForHdiutil(volumeName: string) {
909
- // Remove or replace characters that cause issues with hdiutil volume mounting
910
- // Parentheses and other special characters can cause "Operation not permitted" errors
911
- return volumeName.replace(/[()]/g, '');
912
- }
913
-
914
- // MyApp
915
-
916
- // const appName = config.app.name.replace(/\s/g, '-').toLowerCase();
917
-
918
- const appFileName = (
919
- buildEnvironment === "stable"
920
- ? config.app.name
921
- : `${config.app.name}-${buildEnvironment}`
922
- )
923
- .replace(/\s/g, "")
924
- .replace(/\./g, "-");
925
- const bundleFileName = targetOS === 'macos' ? `${appFileName}.app` : appFileName;
926
-
927
- // const logPath = `/Library/Logs/Electrobun/ExampleApp/dev/out.log`;
928
-
929
- let proc = null;
930
-
904
+ // Execute command handling
905
+ (async () => {
931
906
  if (commandArg === "init") {
932
907
  await (async () => {
933
908
  const secondArg = process.argv[indexOfElectrobun + 2];
@@ -1041,13 +1016,116 @@ if (commandArg === "init") {
1041
1016
  console.log("🎉 Happy building with Electrobun!");
1042
1017
  })();
1043
1018
  } else if (commandArg === "build") {
1019
+ // Get config
1020
+ const config = await getConfig();
1021
+
1022
+ // Get environment
1023
+ const envArg = process.argv.find((arg) => arg.startsWith("--env="))?.split("=")[1] || "";
1024
+ const buildEnvironment = ["dev", "canary", "stable"].includes(envArg) ? envArg : "dev";
1025
+
1026
+ // Determine current platform as default target
1027
+ const currentTarget = { os: OS, arch: ARCH };
1028
+
1029
+ // Set up build variables
1030
+ const targetOS = currentTarget.os;
1031
+ const targetARCH = currentTarget.arch;
1032
+ const targetBinExt = targetOS === 'win' ? '.exe' : '';
1033
+ const appFileName = `${config.app.name.replace(/ /g, "")}-${buildEnvironment}`;
1034
+ const buildSubFolder = `${buildEnvironment}-${currentTarget.os}-${currentTarget.arch}`;
1035
+ const buildFolder = join(projectRoot, config.build.buildFolder, buildSubFolder);
1036
+ const bundleFileName = targetOS === 'macos' ? `${appFileName}.app` : appFileName;
1037
+ const artifactFolder = join(projectRoot, config.build.artifactFolder, buildSubFolder);
1038
+
1044
1039
  // Ensure core binaries are available for the target platform before starting build
1045
1040
  await ensureCoreDependencies(currentTarget.os, currentTarget.arch);
1046
1041
 
1047
1042
  // Get platform-specific paths for the current target
1048
1043
  const targetPaths = getPlatformPaths(currentTarget.os, currentTarget.arch);
1049
1044
 
1050
- // refresh build folder
1045
+ // Helper functions
1046
+ const sanitizeVolumeNameForHdiutil = (name: string) => {
1047
+ return name.replace(/[^a-zA-Z0-9 ]/g, '').trim();
1048
+ };
1049
+
1050
+ // Helper to run lifecycle hook scripts
1051
+ const runHook = (hookName: keyof typeof config.scripts, extraEnv: Record<string, string> = {}) => {
1052
+ const hookScript = config.scripts[hookName];
1053
+ if (!hookScript) return;
1054
+
1055
+ console.log(`Running ${hookName} script:`, hookScript);
1056
+ // Use host platform's bun binary for running scripts, not target platform's
1057
+ const hostPaths = getPlatformPaths(OS, ARCH);
1058
+
1059
+ const result = Bun.spawnSync([hostPaths.BUN_BINARY, hookScript], {
1060
+ stdio: ["ignore", "inherit", "inherit"],
1061
+ cwd: projectRoot,
1062
+ env: {
1063
+ ...process.env,
1064
+ ELECTROBUN_BUILD_ENV: buildEnvironment,
1065
+ ELECTROBUN_OS: targetOS,
1066
+ ELECTROBUN_ARCH: targetARCH,
1067
+ ELECTROBUN_BUILD_DIR: buildFolder,
1068
+ ELECTROBUN_APP_NAME: appFileName,
1069
+ ELECTROBUN_APP_VERSION: config.app.version,
1070
+ ELECTROBUN_APP_IDENTIFIER: config.app.identifier,
1071
+ ELECTROBUN_ARTIFACT_DIR: artifactFolder,
1072
+ ...extraEnv,
1073
+ },
1074
+ });
1075
+
1076
+ if (result.exitCode !== 0) {
1077
+ console.error(`${hookName} script failed with exit code:`, result.exitCode);
1078
+ if (result.stderr) {
1079
+ console.error("stderr:", result.stderr.toString());
1080
+ }
1081
+ console.error("Tried to run with bun at:", hostPaths.BUN_BINARY);
1082
+ console.error("Script path:", hookScript);
1083
+ console.error("Working directory:", projectRoot);
1084
+ process.exit(1);
1085
+ }
1086
+ };
1087
+
1088
+ const buildIcons = (appBundleFolderResourcesPath: string) => {
1089
+ // Platform-specific icon handling
1090
+ if (targetOS === 'macos' && config.build.mac?.icon) {
1091
+ const iconPath = join(projectRoot, config.build.mac.icon);
1092
+ if (existsSync(iconPath)) {
1093
+ const targetIconPath = join(appBundleFolderResourcesPath, "AppIcon.icns");
1094
+ cpSync(iconPath, targetIconPath, { dereference: true });
1095
+ }
1096
+ } else if (targetOS === 'linux' && config.build.linux?.icon) {
1097
+ const iconSourcePath = join(projectRoot, config.build.linux.icon);
1098
+ if (existsSync(iconSourcePath)) {
1099
+ const standardIconPath = join(appBundleFolderResourcesPath, 'appIcon.png');
1100
+
1101
+ // Ensure Resources directory exists
1102
+ mkdirSync(appBundleFolderResourcesPath, { recursive: true });
1103
+
1104
+ // Copy the icon to standard location
1105
+ cpSync(iconSourcePath, standardIconPath, { dereference: true });
1106
+ console.log(`Copied Linux icon from ${iconSourcePath} to ${standardIconPath}`);
1107
+
1108
+ // Also copy icon for the extractor (expects it in Resources/app/icon.png before ASAR packaging)
1109
+ const extractorIconPath = join(appBundleFolderResourcesPath, 'app', 'icon.png');
1110
+ mkdirSync(join(appBundleFolderResourcesPath, 'app'), { recursive: true });
1111
+ cpSync(iconSourcePath, extractorIconPath, { dereference: true });
1112
+ console.log(`Copied Linux icon for extractor from ${iconSourcePath} to ${extractorIconPath}`);
1113
+ } else {
1114
+ console.log(`WARNING: Linux icon not found: ${iconSourcePath}`);
1115
+ }
1116
+ } else if (targetOS === 'win' && config.build.win?.icon) {
1117
+ const iconPath = join(projectRoot, config.build.win.icon);
1118
+ if (existsSync(iconPath)) {
1119
+ const targetIconPath = join(appBundleFolderResourcesPath, "app.ico");
1120
+ cpSync(iconPath, targetIconPath, { dereference: true });
1121
+ }
1122
+ }
1123
+ };
1124
+
1125
+ // Run preBuild hook before anything starts
1126
+ runHook('preBuild');
1127
+
1128
+ // refresh build folder
1051
1129
  if (existsSync(buildFolder)) {
1052
1130
  rmdirSync(buildFolder, { recursive: true });
1053
1131
  }
@@ -1158,6 +1236,59 @@ if (commandArg === "init") {
1158
1236
  dereference: true,
1159
1237
  });
1160
1238
 
1239
+ // On Windows, ensure launcher has .exe extension
1240
+ // Bun's cpSync on Windows may create files without .exe despite the destination path having it
1241
+ if (targetOS === 'win') {
1242
+ const launcherWithoutExt = join(appBundleMacOSPath, "launcher");
1243
+
1244
+ // Use PowerShell to force rename and add .exe extension
1245
+ // This bypasses Bun's PATHEXT behavior that treats launcher and launcher.exe as the same
1246
+ try {
1247
+ execSync(`powershell -Command "if (Test-Path '${launcherWithoutExt}') { Rename-Item -Path '${launcherWithoutExt}' -NewName 'launcher.exe' -Force }"`, { stdio: 'pipe' });
1248
+ console.log(`Ensured launcher has .exe extension on Windows`);
1249
+ } catch (error) {
1250
+ console.warn(`Warning: Could not rename launcher to launcher.exe: ${error}`);
1251
+ }
1252
+ }
1253
+
1254
+ // Embed icon into launcher.exe on Windows
1255
+ if (targetOS === 'win' && config.build.win?.icon) {
1256
+ const iconSourcePath = config.build.win.icon.startsWith('/') || config.build.win.icon.match(/^[a-zA-Z]:/)
1257
+ ? config.build.win.icon
1258
+ : join(projectRoot, config.build.win.icon);
1259
+
1260
+ if (existsSync(iconSourcePath)) {
1261
+ console.log(`Embedding icon into launcher.exe: ${iconSourcePath}`);
1262
+ try {
1263
+ let iconPath = iconSourcePath;
1264
+
1265
+ // Convert PNG to ICO if needed
1266
+ if (iconSourcePath.toLowerCase().endsWith('.png')) {
1267
+ const pngToIco = (await import('png-to-ico')).default;
1268
+ const tempIcoPath = join(buildFolder, 'temp-launcher-icon.ico');
1269
+ const icoBuffer = await pngToIco(iconSourcePath);
1270
+ writeFileSync(tempIcoPath, icoBuffer);
1271
+ iconPath = tempIcoPath;
1272
+ console.log(`Converted PNG to ICO format for launcher: ${tempIcoPath}`);
1273
+ }
1274
+
1275
+ // Use rcedit to embed the icon into launcher.exe
1276
+ const rcedit = (await import('rcedit')).default;
1277
+ await rcedit(bunCliLauncherDestination, {
1278
+ icon: iconPath
1279
+ });
1280
+ console.log(`Successfully embedded icon into launcher.exe`);
1281
+
1282
+ // Clean up temp ICO file
1283
+ if (iconPath !== iconSourcePath && existsSync(iconPath)) {
1284
+ unlinkSync(iconPath);
1285
+ }
1286
+ } catch (error) {
1287
+ console.warn(`Warning: Failed to embed icon into launcher.exe: ${error}`);
1288
+ }
1289
+ }
1290
+ }
1291
+
1161
1292
  cpSync(targetPaths.MAIN_JS, join(appBundleFolderResourcesPath, 'main.js'), { dereference: true });
1162
1293
 
1163
1294
  // Bun runtime binary
@@ -1173,6 +1304,44 @@ if (commandArg === "init") {
1173
1304
  }
1174
1305
  cpSync(bunBinarySourcePath, bunBinaryDestInBundlePath, { dereference: true });
1175
1306
 
1307
+ // Embed icon into bun.exe on Windows
1308
+ if (targetOS === 'win' && config.build.win?.icon) {
1309
+ const iconSourcePath = config.build.win.icon.startsWith('/') || config.build.win.icon.match(/^[a-zA-Z]:/)
1310
+ ? config.build.win.icon
1311
+ : join(projectRoot, config.build.win.icon);
1312
+
1313
+ if (existsSync(iconSourcePath)) {
1314
+ console.log(`Embedding icon into bun.exe: ${iconSourcePath}`);
1315
+ try {
1316
+ let iconPath = iconSourcePath;
1317
+
1318
+ // Convert PNG to ICO if needed
1319
+ if (iconSourcePath.toLowerCase().endsWith('.png')) {
1320
+ const pngToIco = (await import('png-to-ico')).default;
1321
+ const tempIcoPath = join(buildFolder, 'temp-bun-icon.ico');
1322
+ const icoBuffer = await pngToIco(iconSourcePath);
1323
+ writeFileSync(tempIcoPath, icoBuffer);
1324
+ iconPath = tempIcoPath;
1325
+ console.log(`Converted PNG to ICO format for bun.exe: ${tempIcoPath}`);
1326
+ }
1327
+
1328
+ // Use rcedit to embed the icon into bun.exe
1329
+ const rcedit = (await import('rcedit')).default;
1330
+ await rcedit(bunBinaryDestInBundlePath, {
1331
+ icon: iconPath
1332
+ });
1333
+ console.log(`Successfully embedded icon into bun.exe`);
1334
+
1335
+ // Clean up temp ICO file
1336
+ if (iconPath !== iconSourcePath && existsSync(iconPath)) {
1337
+ unlinkSync(iconPath);
1338
+ }
1339
+ } catch (error) {
1340
+ console.warn(`Warning: Failed to embed icon into bun.exe: ${error}`);
1341
+ }
1342
+ }
1343
+ }
1344
+
1176
1345
  // copy native wrapper dynamic library
1177
1346
  if (targetOS === 'macos') {
1178
1347
  const nativeWrapperMacosSource = targetPaths.NATIVE_WRAPPER_MACOS;
@@ -1218,6 +1387,29 @@ if (commandArg === "init") {
1218
1387
  } else {
1219
1388
  throw new Error(`Native wrapper not found: ${nativeWrapperLinuxSource}`);
1220
1389
  }
1390
+
1391
+ // Copy icon if specified for Linux to a standard location
1392
+ if (config.build.linux?.icon) {
1393
+ const iconSourcePath = join(projectRoot, config.build.linux.icon);
1394
+ if (existsSync(iconSourcePath)) {
1395
+ const standardIconPath = join(appBundleFolderResourcesPath, 'appIcon.png');
1396
+
1397
+ // Ensure Resources directory exists
1398
+ mkdirSync(appBundleFolderResourcesPath, { recursive: true });
1399
+
1400
+ // Copy the icon to standard location
1401
+ cpSync(iconSourcePath, standardIconPath, { dereference: true });
1402
+ console.log(`Copied Linux icon from ${iconSourcePath} to ${standardIconPath}`);
1403
+
1404
+ // Also copy icon for the extractor (expects it in Resources/app/icon.png before ASAR packaging)
1405
+ const extractorIconPath = join(appBundleFolderResourcesPath, 'app', 'icon.png');
1406
+ mkdirSync(join(appBundleFolderResourcesPath, 'app'), { recursive: true });
1407
+ cpSync(iconSourcePath, extractorIconPath, { dereference: true });
1408
+ console.log(`Copied Linux icon for extractor from ${iconSourcePath} to ${extractorIconPath}`);
1409
+ } else {
1410
+ console.log(`WARNING: Linux icon not found: ${iconSourcePath}`);
1411
+ }
1412
+ }
1221
1413
  }
1222
1414
 
1223
1415
 
@@ -1577,6 +1769,7 @@ if (commandArg === "init") {
1577
1769
  continue;
1578
1770
  }
1579
1771
  }
1772
+
1580
1773
  // Copy assets like html, css, images, and other files
1581
1774
  for (const relSource in config.build.copy) {
1582
1775
  const source = join(projectRoot, relSource);
@@ -1605,39 +1798,7 @@ if (commandArg === "init") {
1605
1798
 
1606
1799
 
1607
1800
  // Run postBuild script
1608
- if (config.scripts.postBuild) {
1609
- console.log("Running postBuild script:", config.scripts.postBuild);
1610
- // Use host platform's bun binary for running scripts, not target platform's
1611
- const hostPaths = getPlatformPaths(OS, ARCH);
1612
-
1613
- const result = Bun.spawnSync([hostPaths.BUN_BINARY, config.scripts.postBuild], {
1614
- stdio: ["ignore", "inherit", "inherit"],
1615
- cwd: projectRoot, // Add cwd to ensure script runs from project root
1616
- env: {
1617
- ...process.env,
1618
- ELECTROBUN_BUILD_ENV: buildEnvironment,
1619
- ELECTROBUN_OS: targetOS, // Use target OS for environment variables
1620
- ELECTROBUN_ARCH: targetARCH, // Use target ARCH for environment variables
1621
- ELECTROBUN_BUILD_DIR: buildFolder,
1622
- ELECTROBUN_APP_NAME: appFileName,
1623
- ELECTROBUN_APP_VERSION: config.app.version,
1624
- ELECTROBUN_APP_IDENTIFIER: config.app.identifier,
1625
- ELECTROBUN_ARTIFACT_DIR: artifactFolder,
1626
- },
1627
- });
1628
-
1629
- if (result.exitCode !== 0) {
1630
- console.error("postBuild script failed with exit code:", result.exitCode);
1631
- if (result.stderr) {
1632
- console.error("stderr:", result.stderr.toString());
1633
- }
1634
- // Also log which bun binary we're trying to use
1635
- console.error("Tried to run with bun at:", hostPaths.BUN_BINARY);
1636
- console.error("Script path:", config.scripts.postBuild);
1637
- console.error("Working directory:", projectRoot);
1638
- process.exit(1);
1639
- }
1640
- }
1801
+ runHook('postBuild');
1641
1802
 
1642
1803
  // Pack app resources into ASAR archive if enabled
1643
1804
  if (config.build.useAsar) {
@@ -1778,6 +1939,23 @@ if (commandArg === "init") {
1778
1939
  versionJsonContent
1779
1940
  );
1780
1941
 
1942
+ // build.json inside the app bundle - runtime build configuration
1943
+ const platformConfig = targetOS === 'macos' ? config.build?.mac :
1944
+ targetOS === 'win' ? config.build?.win :
1945
+ config.build?.linux;
1946
+
1947
+ const bundlesCEF = platformConfig?.bundleCEF ?? false;
1948
+
1949
+ const buildJsonContent = JSON.stringify({
1950
+ defaultRenderer: platformConfig?.defaultRenderer ?? 'native',
1951
+ availableRenderers: bundlesCEF ? ['native', 'cef'] : ['native'],
1952
+ });
1953
+
1954
+ await Bun.write(
1955
+ join(appBundleFolderResourcesPath, "build.json"),
1956
+ buildJsonContent
1957
+ );
1958
+
1781
1959
  // todo (yoav): add these to config
1782
1960
  // Only codesign/notarize when building macOS targets on macOS host
1783
1961
  const shouldCodesign =
@@ -1801,8 +1979,113 @@ if (commandArg === "init") {
1801
1979
  } else {
1802
1980
  console.log("skipping notarization");
1803
1981
  }
1982
+
1983
+ const artifactsToUpload = [];
1984
+
1985
+ console.log(`DEBUG: Checking for Linux AppImage creation - targetOS: ${targetOS}, buildEnvironment: ${buildEnvironment}`);
1986
+
1987
+ // Linux AppImage creation (for all build environments including dev)
1988
+ if (targetOS === 'linux') {
1989
+ console.log("DEBUG: Creating Linux AppImage...");
1990
+ // Ensure AppImage tooling is available
1991
+ await ensureAppImageTooling();
1992
+
1993
+ // Create AppImage from the app bundle (for both dev and production builds)
1994
+ console.log(`🔍 CALLING createAppImage with appBundleFolderPath: ${appBundleFolderPath}`);
1995
+ console.log(`🔍 buildFolder: ${buildFolder}`);
1996
+ console.log(`🔍 appFileName: ${appFileName}`);
1997
+ const appImagePath = await createAppImage(
1998
+ appBundleFolderPath,
1999
+ appFileName,
2000
+ config,
2001
+ buildFolder
2002
+ );
2003
+
2004
+ console.log(`✓ Linux AppImage created at: ${appImagePath}`);
2005
+
2006
+ // Only create compressed tar for non-dev builds
2007
+ if (buildEnvironment !== "dev") {
2008
+ // For Linux, create a compressed tar containing:
2009
+ // 1. The AppImage
2010
+ // 2. Desktop shortcut file
2011
+ // 3. Icon file
2012
+ // 4. Metadata
2013
+
2014
+ const tempDirName = `${appFileName}-installer-contents`;
2015
+ const tempDirPath = join(buildFolder, tempDirName);
2016
+
2017
+ // Clean up any existing temp directory
2018
+ if (existsSync(tempDirPath)) {
2019
+ rmSync(tempDirPath, { recursive: true });
2020
+ }
2021
+
2022
+ // Create temp directory structure
2023
+ mkdirSync(tempDirPath, { recursive: true });
2024
+ const innerDirPath = join(tempDirPath, appFileName);
2025
+ mkdirSync(innerDirPath, { recursive: true });
2026
+
2027
+ // Copy AppImage
2028
+ const appImageDestPath = join(innerDirPath, `${appFileName}.AppImage`);
2029
+ cpSync(appImagePath, appImageDestPath, { dereference: true });
2030
+
2031
+ // Copy desktop shortcut and icon (they were created alongside the AppImage)
2032
+ const desktopPath = join(buildFolder, `${appFileName}.desktop`);
2033
+ const iconPath = join(buildFolder, `${appFileName}.png`);
2034
+
2035
+ if (existsSync(desktopPath)) {
2036
+ cpSync(desktopPath, join(innerDirPath, `${appFileName}.desktop`));
2037
+ }
2038
+
2039
+ if (existsSync(iconPath)) {
2040
+ cpSync(iconPath, join(innerDirPath, `${appFileName}.png`));
2041
+ }
2042
+
2043
+ // Create metadata file
2044
+ const metadata = {
2045
+ identifier: config.app.identifier,
2046
+ name: config.app.name,
2047
+ version: config.app.version,
2048
+ channel: buildEnvironment
2049
+ };
2050
+ writeFileSync(join(innerDirPath, 'metadata.json'), JSON.stringify(metadata, null, 2));
2051
+
2052
+ const appImageTarPath = join(buildFolder, `${appFileName}.tar`);
2053
+ console.log(`Creating tar of installer contents: ${appImageTarPath}`);
2054
+
2055
+ // Tar the inner directory
2056
+ await tar.create(
2057
+ {
2058
+ file: appImageTarPath,
2059
+ cwd: tempDirPath,
2060
+ gzip: false, // We'll compress with zstd after
2061
+ },
2062
+ [appFileName]
2063
+ );
2064
+
2065
+ // Clean up temp directory
2066
+ rmSync(tempDirPath, { recursive: true });
2067
+
2068
+ // Compress with Zstandard
2069
+ console.log(`Compressing tar with zstd...`);
2070
+ const uncompressedTarData = readFileSync(appImageTarPath);
2071
+ await ZstdInit().then(async ({ ZstdSimple }) => {
2072
+ const data = new Uint8Array(uncompressedTarData);
2073
+ const compressionLevel = 22;
2074
+ const compressedData = ZstdSimple.compress(data, compressionLevel);
2075
+ const compressedPath = `${appImageTarPath}.zst`;
2076
+ writeFileSync(compressedPath, compressedData);
2077
+ console.log(`✓ Created compressed tar: ${compressedPath} (${(compressedData.length / 1024 / 1024).toFixed(2)} MB)`);
2078
+ });
2079
+
2080
+ // Remove uncompressed tar
2081
+ unlinkSync(appImageTarPath);
2082
+
2083
+ // Add AppImage to artifacts for distribution (for direct download)
2084
+ artifactsToUpload.push(appImagePath);
2085
+ }
2086
+ }
2087
+
1804
2088
  if (buildEnvironment !== "dev") {
1805
- const artifactsToUpload = [];
1806
2089
  // zstd wasm https://github.com/OneIdentity/zstd-js
1807
2090
  // tar https://github.com/isaacs/node-tar
1808
2091
 
@@ -1820,61 +2103,73 @@ if (commandArg === "init") {
1820
2103
  const platformSuffix = `-${targetOS}-${targetARCH}`;
1821
2104
  const tarPath = `${appBundleFolderPath}.tar`;
1822
2105
 
1823
- // tar the signed and notarized app bundle
1824
- await tar.c(
1825
- {
1826
- gzip: false,
1827
- file: tarPath,
1828
- cwd: buildFolder,
1829
- },
1830
- [basename(appBundleFolderPath)]
1831
- );
1832
-
1833
- const tarball = Bun.file(tarPath);
1834
- const tarBuffer = await tarball.arrayBuffer();
1835
-
1836
- // Note: The playground app bundle is around 48MB.
1837
- // compression on m1 max with 64GB ram:
1838
- // brotli: 1min 38s, 48MB -> 11.1MB
1839
- // zstd: 15s, 48MB -> 12.1MB
1840
- // zstd is the clear winner here. dev iteration speed gain of 1min 15s per build is much more valubale
1841
- // than saving 1 more MB of space/bandwidth.
2106
+ // For Linux, we've already created the tar in the AppImage section above
2107
+ // For macOS/Windows, tar the signed and notarized app bundle
2108
+ if (targetOS !== 'linux') {
2109
+ await tar.c(
2110
+ {
2111
+ gzip: false,
2112
+ file: tarPath,
2113
+ cwd: buildFolder,
2114
+ },
2115
+ [basename(appBundleFolderPath)]
2116
+ );
2117
+ }
1842
2118
 
1843
2119
  let compressedTarPath = `${tarPath}.zst`;
1844
- artifactsToUpload.push(compressedTarPath);
1845
-
1846
- // zstd compress tarball
1847
- // todo (yoav): consider using c bindings for zstd for speed instead of wasm
1848
- // we already have it in the bsdiff binary
1849
- console.log("compressing tarball...");
1850
- await ZstdInit().then(async ({ ZstdSimple, ZstdStream }) => {
1851
- // Note: Simple is much faster than stream, but stream is better for large files
1852
- // todo (yoav): consider a file size cutoff to switch to stream instead of simple.
1853
- const useStream = tarball.size > 100 * 1024 * 1024;
1854
-
1855
- if (tarball.size > 0) {
1856
- // Uint8 array filestream of the tar file
1857
- const data = new Uint8Array(tarBuffer);
2120
+
2121
+ // For Linux, skip compression as we already have the compressed tar
2122
+ if (targetOS === 'linux') {
2123
+ console.log("Linux tar.zst already created, skipping general compression step");
2124
+ // compressedTarPath already points to the right file
2125
+ } else {
2126
+ const tarball = Bun.file(tarPath);
2127
+ const tarBuffer = await tarball.arrayBuffer();
2128
+
2129
+ // Note: The playground app bundle is around 48MB.
2130
+ // compression on m1 max with 64GB ram:
2131
+ // brotli: 1min 38s, 48MB -> 11.1MB
2132
+ // zstd: 15s, 48MB -> 12.1MB
2133
+ // zstd is the clear winner here. dev iteration speed gain of 1min 15s per build is much more valubale
2134
+ // than saving 1 more MB of space/bandwidth.
2135
+
2136
+ artifactsToUpload.push(compressedTarPath);
2137
+
2138
+ // zstd compress tarball
2139
+ // todo (yoav): consider using c bindings for zstd for speed instead of wasm
2140
+ // we already have it in the bsdiff binary
2141
+ console.log("compressing tarball...");
2142
+ await ZstdInit().then(async ({ ZstdSimple, ZstdStream }) => {
2143
+ // Note: Simple is much faster than stream, but stream is better for large files
2144
+ // todo (yoav): consider a file size cutoff to switch to stream instead of simple.
2145
+ const useStream = tarball.size > 100 * 1024 * 1024;
1858
2146
 
1859
- const compressionLevel = 22; // Maximum compression - now safe with stripped CEF libraries
1860
- const compressedData = ZstdSimple.compress(data, compressionLevel);
1861
-
1862
- console.log(
1863
- "compressed",
1864
- data.length,
1865
- "bytes",
1866
- "from",
1867
- tarBuffer.byteLength,
1868
- "bytes"
1869
- );
2147
+ if (tarball.size > 0) {
2148
+ // Uint8 array filestream of the tar file
2149
+ const data = new Uint8Array(tarBuffer);
2150
+
2151
+ const compressionLevel = 22; // Maximum compression - now safe with stripped CEF libraries
2152
+ const compressedData = ZstdSimple.compress(data, compressionLevel);
2153
+
2154
+ console.log(
2155
+ "compressed",
2156
+ data.length,
2157
+ "bytes",
2158
+ "from",
2159
+ tarBuffer.byteLength,
2160
+ "bytes"
2161
+ );
1870
2162
 
1871
- await Bun.write(compressedTarPath, compressedData);
1872
- }
1873
- });
2163
+ await Bun.write(compressedTarPath, compressedData);
2164
+ }
2165
+ });
2166
+ }
1874
2167
 
1875
- // we can delete the original app bundle since we've tarred and zstd it. We need to create the self-extracting app bundle
1876
- // now and it needs the same name as the original app bundle.
1877
- rmdirSync(appBundleFolderPath, { recursive: true });
2168
+ // For macOS/Windows, delete the original app bundle since we've tarred it
2169
+ // For Linux, the app bundle was already converted to AppImage, so the directory might not exist
2170
+ if (targetOS !== 'linux') {
2171
+ rmdirSync(appBundleFolderPath, { recursive: true });
2172
+ }
1878
2173
 
1879
2174
  const selfExtractingBundle = createAppBundle(appFileName, buildFolder, targetOS);
1880
2175
  const compressedTarballInExtractingBundlePath = join(
@@ -1901,6 +2196,10 @@ if (commandArg === "init") {
1901
2196
  InfoPlistContents
1902
2197
  );
1903
2198
 
2199
+ // Run postWrap hook after self-extracting bundle is created, before code signing
2200
+ // This is where you can add files to the wrapper (e.g., for liquid glass support)
2201
+ runHook('postWrap', { ELECTROBUN_WRAPPER_BUNDLE_PATH: selfExtractingBundle.appBundleFolderPath });
2202
+
1904
2203
  if (shouldCodesign) {
1905
2204
  codesignAppBundle(
1906
2205
  selfExtractingBundle.appBundleFolderPath,
@@ -1989,7 +2288,9 @@ if (commandArg === "init") {
1989
2288
  appFileName,
1990
2289
  targetPaths,
1991
2290
  buildEnvironment,
1992
- hash
2291
+ hash,
2292
+ config,
2293
+ projectRoot
1993
2294
  );
1994
2295
 
1995
2296
  // Wrap Windows installer files in zip for distribution
@@ -1999,48 +2300,19 @@ if (commandArg === "init") {
1999
2300
  // Also keep the raw exe for backwards compatibility (optional)
2000
2301
  // artifactsToUpload.push(selfExtractingExePath);
2001
2302
  } else if (targetOS === 'linux') {
2002
- // Create desktop file for Linux
2003
- const desktopFileContent = `[Desktop Entry]
2004
- Version=1.0
2005
- Type=Application
2006
- Name=${config.package?.name || config.app.name}
2007
- Comment=${config.package?.description || config.app.description || ''}
2008
- Exec=${appFileName}
2009
- Icon=${appFileName}
2010
- Terminal=false
2011
- StartupWMClass=${appFileName}
2012
- Categories=Application;
2013
- `;
2014
-
2015
- const desktopFilePath = join(appBundleFolderPath, `${appFileName}.desktop`);
2016
- writeFileSync(desktopFilePath, desktopFileContent);
2017
-
2018
- // Make desktop file executable
2019
- execSync(`chmod +x ${escapePathForTerminal(desktopFilePath)}`);
2020
-
2021
- // Note: No longer creating shell script wrapper - users can run bin/launcher directly
2022
- // or use the symlink created by the self-extractor
2023
-
2024
- // Create self-extracting Linux binary (similar to Windows approach)
2025
- const selfExtractingLinuxPath = await createLinuxSelfExtractingBinary(
2303
+ // On Linux, create a self-extracting AppImage with embedded archive
2304
+ // Use the Linux-specific compressed tar path
2305
+ const linuxCompressedTarPath = join(buildFolder, `${appFileName}.tar.zst`);
2306
+ const selfExtractingAppImagePath = await createLinuxSelfExtractingAppImage(
2026
2307
  buildFolder,
2027
- compressedTarPath,
2308
+ linuxCompressedTarPath,
2028
2309
  appFileName,
2029
- targetPaths,
2030
- buildEnvironment
2310
+ config,
2311
+ buildEnvironment,
2312
+ hash
2031
2313
  );
2032
2314
 
2033
- // Wrap Linux .run file in tar.gz to preserve permissions
2034
- const wrappedRunPath = await wrapInArchive(selfExtractingLinuxPath, buildFolder, 'tar.gz');
2035
- artifactsToUpload.push(wrappedRunPath);
2036
-
2037
- // Also keep the raw .run for backwards compatibility (optional)
2038
- // artifactsToUpload.push(selfExtractingLinuxPath);
2039
-
2040
- // On Linux, create a tar.gz of the bundle (for build process, not uploaded to artifacts)
2041
- const linuxTarPath = join(buildFolder, `${appFileName}.tar.gz`);
2042
- execSync(`tar -czf ${escapePathForTerminal(linuxTarPath)} -C ${escapePathForTerminal(buildFolder)} ${escapePathForTerminal(basename(appBundleFolderPath))}`);
2043
- // Note: Not adding to artifactsToUpload - we use .tar.zst for updates and .run.tar.gz for distribution
2315
+ artifactsToUpload.push(selfExtractingAppImagePath);
2044
2316
  }
2045
2317
  }
2046
2318
 
@@ -2173,6 +2445,9 @@ Categories=Application;
2173
2445
  // you'll end up with a sequence of patch files that will
2174
2446
  }
2175
2447
 
2448
+ // Run postPackage hook at the very end of the build process
2449
+ runHook('postPackage');
2450
+
2176
2451
  // NOTE: verify codesign
2177
2452
  // codesign --verify --deep --strict --verbose=2 <app path>
2178
2453
 
@@ -2190,6 +2465,17 @@ Categories=Application;
2190
2465
  // run the project in dev mode
2191
2466
  // this runs the bundled bun binary with main.js directly
2192
2467
 
2468
+ // Get config for dev mode
2469
+ const config = await getConfig();
2470
+
2471
+ // Set up dev build variables (similar to build mode)
2472
+ const buildEnvironment = "dev";
2473
+ const currentTarget = { os: OS, arch: ARCH };
2474
+ const appFileName = `${config.app.name.replace(/ /g, "")}-${buildEnvironment}`;
2475
+ const buildSubFolder = `${buildEnvironment}-${currentTarget.os}-${currentTarget.arch}`;
2476
+ const buildFolder = join(projectRoot, config.build.buildFolder, buildSubFolder);
2477
+ const bundleFileName = OS === 'macos' ? `${appFileName}.app` : appFileName;
2478
+
2193
2479
  // Note: this cli will be a bun single-file-executable
2194
2480
  // Note: we want to use the version of bun that's packaged with electrobun
2195
2481
  // const bunPath = join(projectRoot, 'node_modules', '.bin', 'bun');
@@ -2208,11 +2494,25 @@ Categories=Application;
2208
2494
  let mainProc;
2209
2495
  let bundleExecPath: string;
2210
2496
  let bundleResourcesPath: string;
2497
+ let isAppImage = false;
2211
2498
 
2212
2499
  if (OS === 'macos') {
2213
2500
  bundleExecPath = join(buildFolder, bundleFileName, "Contents", 'MacOS');
2214
2501
  bundleResourcesPath = join(buildFolder, bundleFileName, "Contents", 'Resources');
2215
- } else if (OS === 'linux' || OS === 'win') {
2502
+ } else if (OS === 'linux') {
2503
+ // Check if we have an AppImage or directory bundle
2504
+ const appImagePath = join(buildFolder, `${bundleFileName}.AppImage`);
2505
+ if (existsSync(appImagePath)) {
2506
+ // AppImage mode
2507
+ bundleExecPath = appImagePath;
2508
+ bundleResourcesPath = join(buildFolder, bundleFileName, "Resources"); // For compatibility
2509
+ isAppImage = true;
2510
+ } else {
2511
+ // Directory bundle mode (fallback)
2512
+ bundleExecPath = join(buildFolder, bundleFileName, "bin");
2513
+ bundleResourcesPath = join(buildFolder, bundleFileName, "Resources");
2514
+ }
2515
+ } else if (OS === 'win') {
2216
2516
  bundleExecPath = join(buildFolder, bundleFileName, "bin");
2217
2517
  bundleResourcesPath = join(buildFolder, bundleFileName, "Resources");
2218
2518
  } else {
@@ -2221,7 +2521,8 @@ Categories=Application;
2221
2521
 
2222
2522
  if (OS === 'macos' || OS === 'linux') {
2223
2523
  // For Linux dev mode, update libNativeWrapper.so based on bundleCEF setting
2224
- if (OS === 'linux') {
2524
+ if (OS === 'linux' && !isAppImage) {
2525
+ // Only update libNativeWrapper for directory bundle mode
2225
2526
  const currentLibPath = join(bundleExecPath, 'libNativeWrapper.so');
2226
2527
  const targetPaths = getPlatformPaths('linux', ARCH);
2227
2528
  const correctLibSource = config.build.linux?.bundleCEF
@@ -2238,12 +2539,21 @@ Categories=Application;
2238
2539
  }
2239
2540
  }
2240
2541
 
2241
- // Use the zig launcher for macOS and Linux
2242
- mainProc = Bun.spawn([join(bundleExecPath, 'launcher')], {
2243
- stdio: ['inherit', 'inherit', 'inherit'],
2244
- cwd: bundleExecPath
2245
- })
2246
- } else if (OS === 'win') {
2542
+ if (OS === 'linux' && isAppImage) {
2543
+ // For Linux AppImage mode, execute the AppImage directly
2544
+ console.log(`Running AppImage: ${bundleExecPath}`);
2545
+ mainProc = Bun.spawn([bundleExecPath], {
2546
+ stdio: ['inherit', 'inherit', 'inherit'],
2547
+ cwd: dirname(bundleExecPath)
2548
+ });
2549
+ } else {
2550
+ // Use the zig launcher for macOS and directory bundle Linux
2551
+ mainProc = Bun.spawn([join(bundleExecPath, 'launcher')], {
2552
+ stdio: ['inherit', 'inherit', 'inherit'],
2553
+ cwd: bundleExecPath
2554
+ });
2555
+ }
2556
+ } else if (OS === 'win') {
2247
2557
  // Windows: Use launcher if available, otherwise fallback to direct execution
2248
2558
  const launcherPath = join(bundleExecPath, 'launcher.exe');
2249
2559
  if (existsSync(launcherPath)) {
@@ -2284,7 +2594,9 @@ Categories=Application;
2284
2594
  }
2285
2595
  });
2286
2596
 
2287
- }
2597
+ }
2598
+
2599
+ // Helper functions
2288
2600
 
2289
2601
  async function getConfig() {
2290
2602
  let loadedConfig = {};
@@ -2384,21 +2696,63 @@ async function createWindowsSelfExtractingExe(
2384
2696
  appFileName: string,
2385
2697
  targetPaths: any,
2386
2698
  buildEnvironment: string,
2387
- hash: string
2699
+ hash: string,
2700
+ config: any,
2701
+ projectRoot: string
2388
2702
  ): Promise<string> {
2389
2703
  console.log("Creating Windows installer with separate archive...");
2390
-
2704
+
2391
2705
  // Format: MyApp-Setup.exe (stable) or MyApp-Setup-canary.exe (non-stable)
2392
- const setupFileName = buildEnvironment === "stable"
2706
+ const setupFileName = buildEnvironment === "stable"
2393
2707
  ? `${config.app.name}-Setup.exe`
2394
2708
  : `${config.app.name}-Setup-${buildEnvironment}.exe`;
2395
-
2709
+
2396
2710
  const outputExePath = join(buildFolder, setupFileName);
2397
-
2711
+
2398
2712
  // Copy the extractor exe
2399
2713
  const extractorExe = readFileSync(targetPaths.EXTRACTOR);
2400
2714
  writeFileSync(outputExePath, extractorExe);
2401
-
2715
+
2716
+ // Embed icon into the wrapper EXE if provided
2717
+ if (config.build.win?.icon) {
2718
+ const iconSourcePath = config.build.win.icon.startsWith('/') || config.build.win.icon.match(/^[a-zA-Z]:/)
2719
+ ? config.build.win.icon
2720
+ : join(projectRoot, config.build.win.icon);
2721
+
2722
+ if (existsSync(iconSourcePath)) {
2723
+ console.log(`Embedding icon into Windows installer: ${iconSourcePath}`);
2724
+ try {
2725
+ let iconPath = iconSourcePath;
2726
+
2727
+ // Convert PNG to ICO if needed
2728
+ if (iconSourcePath.toLowerCase().endsWith('.png')) {
2729
+ const pngToIco = (await import('png-to-ico')).default;
2730
+ const tempIcoPath = join(buildFolder, 'temp-icon.ico');
2731
+ const icoBuffer = await pngToIco(iconSourcePath);
2732
+ writeFileSync(tempIcoPath, icoBuffer);
2733
+ iconPath = tempIcoPath;
2734
+ console.log(`Converted PNG to ICO format: ${tempIcoPath}`);
2735
+ }
2736
+
2737
+ // Use rcedit to embed the icon
2738
+ const rcedit = (await import('rcedit')).default;
2739
+ await rcedit(outputExePath, {
2740
+ icon: iconPath
2741
+ });
2742
+ console.log(`Successfully embedded icon into ${setupFileName}`);
2743
+
2744
+ // Clean up temp ICO file
2745
+ if (iconPath !== iconSourcePath && existsSync(iconPath)) {
2746
+ unlinkSync(iconPath);
2747
+ }
2748
+ } catch (error) {
2749
+ console.warn(`Warning: Failed to embed icon into Windows installer: ${error}`);
2750
+ }
2751
+ } else {
2752
+ console.warn(`Warning: Windows icon not found at ${iconSourcePath}`);
2753
+ }
2754
+ }
2755
+
2402
2756
  // Create metadata JSON file
2403
2757
  const metadata = {
2404
2758
  identifier: config.app.identifier,
@@ -2410,27 +2764,27 @@ async function createWindowsSelfExtractingExe(
2410
2764
  const metadataFileName = setupFileName.replace('.exe', '.metadata.json');
2411
2765
  const metadataPath = join(buildFolder, metadataFileName);
2412
2766
  writeFileSync(metadataPath, metadataJson);
2413
-
2767
+
2414
2768
  // Copy the compressed archive with matching name
2415
2769
  const archiveFileName = setupFileName.replace('.exe', '.tar.zst');
2416
2770
  const archivePath = join(buildFolder, archiveFileName);
2417
2771
  copyFileSync(compressedTarPath, archivePath);
2418
-
2772
+
2419
2773
  // Make the exe executable (though Windows doesn't need chmod)
2420
2774
  if (OS !== 'win') {
2421
2775
  execSync(`chmod +x ${escapePathForTerminal(outputExePath)}`);
2422
2776
  }
2423
-
2777
+
2424
2778
  const exeSize = statSync(outputExePath).size;
2425
2779
  const archiveSize = statSync(archivePath).size;
2426
2780
  const totalSize = exeSize + archiveSize;
2427
-
2781
+
2428
2782
  console.log(`Created Windows installer:`);
2429
2783
  console.log(` - Extractor: ${outputExePath} (${(exeSize / 1024 / 1024).toFixed(2)} MB)`);
2430
2784
  console.log(` - Archive: ${archivePath} (${(archiveSize / 1024 / 1024).toFixed(2)} MB)`);
2431
2785
  console.log(` - Metadata: ${metadataPath}`);
2432
2786
  console.log(` - Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`);
2433
-
2787
+
2434
2788
  return outputExePath;
2435
2789
  }
2436
2790
 
@@ -2526,12 +2880,14 @@ async function wrapWindowsInstallerInZip(exePath: string, buildFolder: string):
2526
2880
  });
2527
2881
 
2528
2882
  archive.pipe(output);
2529
-
2530
- // Add all three files to the archive
2883
+
2884
+ // Add Setup.exe at the root level for easy access
2531
2885
  archive.file(exePath, { name: basename(exePath) });
2532
- archive.file(metadataPath, { name: basename(metadataPath) });
2533
- archive.file(archivePath, { name: basename(archivePath) });
2534
-
2886
+
2887
+ // Put metadata and archive in a subdirectory to discourage manual extraction
2888
+ archive.file(metadataPath, { name: `.installer/${basename(metadataPath)}` });
2889
+ archive.file(archivePath, { name: `.installer/${basename(archivePath)}` });
2890
+
2535
2891
  archive.finalize();
2536
2892
  });
2537
2893
  }
@@ -2600,81 +2956,159 @@ async function wrapInArchive(filePath: string, buildFolder: string, archiveType:
2600
2956
  }
2601
2957
  }
2602
2958
 
2603
- async function createAppImage(buildFolder: string, appBundlePath: string, appFileName: string, config: any): Promise<string | null> {
2604
- try {
2605
- console.log("Creating AppImage...");
2606
-
2607
- // Create AppDir structure
2608
- const appDirPath = join(buildFolder, `${appFileName}.AppDir`);
2609
- mkdirSync(appDirPath, { recursive: true });
2610
-
2611
- // Copy app bundle contents to AppDir
2612
- const appDirAppPath = join(appDirPath, "app");
2613
- cpSync(appBundlePath, appDirAppPath, { recursive: true, dereference: true });
2614
-
2615
- // Create AppRun script (main executable for AppImage)
2616
- const appRunContent = `#!/bin/bash
2959
+ async function createLinuxSelfExtractingAppImage(
2960
+ buildFolder: string,
2961
+ compressedTarPath: string,
2962
+ appFileName: string,
2963
+ config: any,
2964
+ buildEnvironment: string,
2965
+ hash: string
2966
+ ): Promise<string> {
2967
+ console.log('Creating Linux AppImage wrapper...');
2968
+
2969
+ // Create wrapper AppImage filename
2970
+ const wrapperName = buildEnvironment === 'stable'
2971
+ ? `${config.app.name}-Setup`
2972
+ : `${config.app.name}-Setup-${buildEnvironment}`;
2973
+
2974
+ const wrapperAppImagePath = join(buildFolder, `${wrapperName}.AppImage`);
2975
+ const wrapperAppDirPath = join(buildFolder, `${wrapperName}.AppDir`);
2976
+
2977
+ // Clean up any existing AppDir
2978
+ if (existsSync(wrapperAppDirPath)) {
2979
+ rmSync(wrapperAppDirPath, { recursive: true, force: true });
2980
+ }
2981
+ mkdirSync(wrapperAppDirPath, { recursive: true });
2982
+
2983
+ // Create usr/bin directory structure
2984
+ const usrBinPath = join(wrapperAppDirPath, 'usr', 'bin');
2985
+ mkdirSync(usrBinPath, { recursive: true });
2986
+
2987
+ // Create self-extracting binary with embedded archive (following magic markers pattern)
2988
+ const targetPaths = getPlatformPaths('linux', ARCH);
2989
+
2990
+ // Read the extractor binary
2991
+ const extractorBinary = readFileSync(targetPaths.EXTRACTOR);
2992
+
2993
+ // Read the compressed archive
2994
+ const compressedArchive = readFileSync(compressedTarPath);
2995
+
2996
+ // Create metadata JSON
2997
+ const metadata = {
2998
+ identifier: config.app.identifier,
2999
+ name: config.app.name,
3000
+ channel: buildEnvironment,
3001
+ hash: hash
3002
+ };
3003
+ const metadataJson = JSON.stringify(metadata);
3004
+ const metadataBuffer = Buffer.from(metadataJson, 'utf8');
3005
+
3006
+ // Create marker buffers
3007
+ const metadataMarker = Buffer.from('ELECTROBUN_METADATA_V1', 'utf8');
3008
+ const archiveMarker = Buffer.from('ELECTROBUN_ARCHIVE_V1', 'utf8');
3009
+
3010
+ // Combine extractor + metadata marker + metadata + archive marker + archive
3011
+ const combinedBuffer = Buffer.concat([
3012
+ extractorBinary,
3013
+ metadataMarker,
3014
+ metadataBuffer,
3015
+ archiveMarker,
3016
+ compressedArchive
3017
+ ]);
3018
+
3019
+ // Write the self-extracting binary to AppImage/usr/bin/
3020
+ const wrapperExtractorPath = join(usrBinPath, wrapperName);
3021
+ writeFileSync(wrapperExtractorPath, combinedBuffer, { mode: 0o755 });
3022
+ execSync(`chmod +x ${escapePathForTerminal(wrapperExtractorPath)}`);
3023
+
3024
+ // Create AppRun script
3025
+ const appRunContent = `#!/bin/bash
3026
+ # AppRun script for ${wrapperName}
2617
3027
  HERE="$(dirname "$(readlink -f "\${0}")")"
2618
- export APPDIR="\$HERE"
2619
- cd "\$HERE"
2620
- exec "\$HERE/app/bin/launcher" "\$@"
3028
+ EXEC="\${HERE}/usr/bin/${wrapperName}"
3029
+
3030
+ # Execute the wrapper extractor
3031
+ exec "\${EXEC}" "\$@"
2621
3032
  `;
2622
-
2623
- const appRunPath = join(appDirPath, "AppRun");
2624
- writeFileSync(appRunPath, appRunContent);
2625
- execSync(`chmod +x ${escapePathForTerminal(appRunPath)}`);
2626
-
2627
- // Create desktop file in AppDir root
2628
- const desktopContent = `[Desktop Entry]
3033
+
3034
+ const appRunPath = join(wrapperAppDirPath, 'AppRun');
3035
+ writeFileSync(appRunPath, appRunContent);
3036
+ execSync(`chmod +x ${escapePathForTerminal(appRunPath)}`);
3037
+
3038
+ // Create desktop file
3039
+ const desktopContent = `[Desktop Entry]
2629
3040
  Version=1.0
2630
3041
  Type=Application
2631
- Name=${config.package?.name || config.app.name}
2632
- Comment=${config.package?.description || config.app.description || ''}
2633
- Exec=AppRun
2634
- Icon=${appFileName}
3042
+ Name=${config.app.name} Installer
3043
+ Comment=Install ${config.app.name}
3044
+ Exec=${wrapperName}
3045
+ Icon=${wrapperName}
2635
3046
  Terminal=false
2636
- StartupWMClass=${appFileName}
2637
- Categories=Application;
3047
+ Categories=Utility;
2638
3048
  `;
3049
+
3050
+ const desktopPath = join(wrapperAppDirPath, `${wrapperName}.desktop`);
3051
+ writeFileSync(desktopPath, desktopContent);
3052
+
3053
+ // Copy icon if available
3054
+ if (config.build.linux?.icon && existsSync(join(projectRoot, config.build.linux.icon))) {
3055
+ const iconSourcePath = join(projectRoot, config.build.linux.icon);
3056
+ const iconDestPath = join(wrapperAppDirPath, `${wrapperName}.png`);
3057
+ const dirIconPath = join(wrapperAppDirPath, '.DirIcon');
2639
3058
 
2640
- const appDirDesktopPath = join(appDirPath, `${appFileName}.desktop`);
2641
- writeFileSync(appDirDesktopPath, desktopContent);
2642
-
2643
- // Copy icon if it exists
2644
- const iconPath = config.build.linux?.appImageIcon;
2645
- if (iconPath && existsSync(iconPath)) {
2646
- const iconDestPath = join(appDirPath, `${appFileName}.png`);
2647
- cpSync(iconPath, iconDestPath, { dereference: true });
2648
- }
2649
-
2650
- // Try to create AppImage using available tools
2651
- const appImagePath = join(buildFolder, `${appFileName}.AppImage`);
3059
+ cpSync(iconSourcePath, iconDestPath, { dereference: true });
3060
+ cpSync(iconSourcePath, dirIconPath, { dereference: true });
2652
3061
 
2653
- // Check for appimagetool
2654
- try {
2655
- execSync('which appimagetool', { stdio: 'pipe' });
2656
- console.log("Using appimagetool to create AppImage...");
2657
- execSync(`appimagetool ${escapePathForTerminal(appDirPath)} ${escapePathForTerminal(appImagePath)}`, { stdio: 'inherit' });
2658
- return appImagePath;
2659
- } catch {
2660
- // Check for Docker
2661
- try {
2662
- execSync('which docker', { stdio: 'pipe' });
2663
- console.log("Using Docker to create AppImage...");
2664
- execSync(`docker run --rm -v "${buildFolder}:/workspace" linuxserver/appimagetool "/workspace/${basename(appDirPath)}" "/workspace/${basename(appImagePath)}"`, { stdio: 'inherit' });
2665
- return appImagePath;
2666
- } catch {
2667
- console.warn("Neither appimagetool nor Docker found. AppImage creation skipped.");
2668
- console.warn("To create AppImages, install appimagetool or Docker.");
2669
- return null;
2670
- }
3062
+ console.log(`Copied icon for wrapper AppImage: ${iconSourcePath} -> ${iconDestPath}`);
3063
+ }
3064
+
3065
+ // Ensure appimagetool is available
3066
+ await ensureAppImageTooling();
3067
+
3068
+ // Generate the wrapper AppImage
3069
+ if (existsSync(wrapperAppImagePath)) {
3070
+ unlinkSync(wrapperAppImagePath);
3071
+ }
3072
+
3073
+ console.log(`Creating wrapper AppImage: ${wrapperAppImagePath}`);
3074
+ const appImageArch = ARCH === 'arm64' ? 'aarch64' : 'x86_64';
3075
+
3076
+ // Use appimagetool to create the wrapper AppImage
3077
+ let appimagetoolCmd = 'appimagetool';
3078
+ try {
3079
+ execSync('which appimagetool', { stdio: 'ignore' });
3080
+ } catch {
3081
+ const localBinPath = join(process.env['HOME'] || '', '.local', 'bin', 'appimagetool');
3082
+ if (existsSync(localBinPath)) {
3083
+ appimagetoolCmd = localBinPath;
2671
3084
  }
3085
+ }
3086
+
3087
+ try {
3088
+ execSync(`ARCH=${appImageArch} ${appimagetoolCmd} --no-appstream ${escapePathForTerminal(wrapperAppDirPath)} ${escapePathForTerminal(wrapperAppImagePath)}`, {
3089
+ stdio: 'inherit',
3090
+ env: { ...process.env, ARCH: appImageArch }
3091
+ });
2672
3092
  } catch (error) {
2673
- console.error("Failed to create AppImage:", error);
2674
- return null;
3093
+ console.error('Failed to create wrapper AppImage:', error);
3094
+ throw error;
2675
3095
  }
3096
+
3097
+ // Clean up AppDir
3098
+ rmSync(wrapperAppDirPath, { recursive: true, force: true });
3099
+
3100
+ // Verify the wrapper AppImage was created
3101
+ if (!existsSync(wrapperAppImagePath)) {
3102
+ throw new Error(`Wrapper AppImage was not created at expected path: ${wrapperAppImagePath}`);
3103
+ }
3104
+
3105
+ const stats = statSync(wrapperAppImagePath);
3106
+ console.log(`✓ Linux wrapper AppImage created: ${wrapperAppImagePath} (${(stats.size / 1024 / 1024).toFixed(2)} MB)`);
3107
+
3108
+ return wrapperAppImagePath;
2676
3109
  }
2677
3110
 
3111
+
2678
3112
  function codesignAppBundle(
2679
3113
  appBundleOrDmgPath: string,
2680
3114
  entitlementsFilePath?: string
@@ -3013,10 +3447,11 @@ function createAppBundle(bundleName: string, parentFolder: string, targetOS: 'ma
3013
3447
  }
3014
3448
  }
3015
3449
 
3016
- } // End of main() function
3450
+ // Close the command handling if/else chain
3017
3451
 
3018
- // Run the main function
3019
- main().catch((error) => {
3452
+ // Close and execute the async IIFE
3453
+ })().catch((error) => {
3020
3454
  console.error('Fatal error:', error);
3021
3455
  process.exit(1);
3022
3456
  });
3457
+