UNPKG

electrobun

Version:

Build ultra fast, tiny, and cross-platform desktop apps with Typescript.

1,339 lines (1,144 loc) 104 kB
import { join, dirname, basename, relative } from "path"; import { existsSync, readFileSync, writeFileSync, cpSync, rmdirSync, mkdirSync, createWriteStream, unlinkSync, readdirSync, rmSync, symlinkSync, statSync, copyFileSync, } from "fs"; import { execSync } from "child_process"; import * as readline from "readline"; import tar from "tar"; import archiver from "archiver"; import { ZstdInit } from "@oneidentity/zstd-js/wasm"; import { OS, ARCH } from '../shared/platform'; import { getTemplate, getTemplateNames } from './templates/embedded'; // import { loadBsdiff, loadBspatch } from 'bsdiff-wasm'; // MacOS named pipes hang at around 4KB const MAX_CHUNK_SIZE = 1024 * 2; // const binExt = OS === 'win' ? '.exe' : ''; // this when run as an npm script this will be where the folder where package.json is. const projectRoot = process.cwd(); // Find TypeScript ESM config file function findConfigFile(): string | null { const configFile = join(projectRoot, 'electrobun.config.ts'); return existsSync(configFile) ? configFile : null; } // Note: cli args can be called via npm bun /path/to/electorbun/binary arg1 arg2 const indexOfElectrobun = process.argv.findIndex((arg) => arg.includes("electrobun") ); const commandArg = process.argv[indexOfElectrobun + 1] || "build"; const ELECTROBUN_DEP_PATH = join(projectRoot, "node_modules", "electrobun"); // When debugging electrobun with the example app use the builds (dev or release) right from the source folder // For developers using electrobun cli via npm use the release versions in /dist // This lets us not have to commit src build folders to git and provide pre-built binaries // Function to get platform-specific paths function getPlatformPaths(targetOS: 'macos' | 'win' | 'linux', targetArch: 'arm64' | 'x64') { const binExt = targetOS === 'win' ? '.exe' : ''; const platformDistDir = join(ELECTROBUN_DEP_PATH, `dist-${targetOS}-${targetArch}`); const sharedDistDir = join(ELECTROBUN_DEP_PATH, "dist"); return { // Platform-specific binaries (from dist-OS-ARCH/) BUN_BINARY: join(platformDistDir, "bun") + binExt, LAUNCHER_DEV: join(platformDistDir, "electrobun") + binExt, LAUNCHER_RELEASE: join(platformDistDir, "launcher") + binExt, NATIVE_WRAPPER_MACOS: join(platformDistDir, "libNativeWrapper.dylib"), NATIVE_WRAPPER_WIN: join(platformDistDir, "libNativeWrapper.dll"), NATIVE_WRAPPER_LINUX: join(platformDistDir, "libNativeWrapper.so"), NATIVE_WRAPPER_LINUX_CEF: join(platformDistDir, "libNativeWrapper_cef.so"), WEBVIEW2LOADER_WIN: join(platformDistDir, "WebView2Loader.dll"), BSPATCH: join(platformDistDir, "bspatch") + binExt, EXTRACTOR: join(platformDistDir, "extractor") + binExt, BSDIFF: join(platformDistDir, "bsdiff") + binExt, CEF_FRAMEWORK_MACOS: join(platformDistDir, "cef", "Chromium Embedded Framework.framework"), CEF_HELPER_MACOS: join(platformDistDir, "cef", "process_helper"), CEF_HELPER_WIN: join(platformDistDir, "cef", "process_helper.exe"), CEF_HELPER_LINUX: join(platformDistDir, "cef", "process_helper"), CEF_DIR: join(platformDistDir, "cef"), // Shared platform-independent files (from dist/) // These work with existing package.json and development workflow MAIN_JS: join(sharedDistDir, "main.js"), API_DIR: join(sharedDistDir, "api"), }; } // Default PATHS for host platform (backward compatibility) const PATHS = getPlatformPaths(OS, ARCH); async function ensureCoreDependencies(targetOS?: 'macos' | 'win' | 'linux', targetArch?: 'arm64' | 'x64') { // Use provided target platform or default to host platform const platformOS = targetOS || OS; const platformArch = targetArch || ARCH; // Get platform-specific paths const platformPaths = getPlatformPaths(platformOS, platformArch); // Check platform-specific binaries const requiredBinaries = [ platformPaths.BUN_BINARY, platformPaths.LAUNCHER_RELEASE, // Platform-specific native wrapper platformOS === 'macos' ? platformPaths.NATIVE_WRAPPER_MACOS : platformOS === 'win' ? platformPaths.NATIVE_WRAPPER_WIN : platformPaths.NATIVE_WRAPPER_LINUX ]; // Check shared files (main.js should be in shared dist/) const requiredSharedFiles = [ platformPaths.MAIN_JS ]; const missingBinaries = requiredBinaries.filter(file => !existsSync(file)); const missingSharedFiles = requiredSharedFiles.filter(file => !existsSync(file)); // If only shared files are missing, that's expected in production (they come via npm) if (missingBinaries.length === 0 && missingSharedFiles.length > 0) { console.log(`Shared files missing (expected in production): ${missingSharedFiles.map(f => f.replace(ELECTROBUN_DEP_PATH, '.')).join(', ')}`); } // Only download if platform-specific binaries are missing if (missingBinaries.length === 0) { return; } // Show which binaries are missing console.log(`Core dependencies not found for ${platformOS}-${platformArch}. Missing files:`, missingBinaries.map(f => f.replace(ELECTROBUN_DEP_PATH, '.')).join(', ')); console.log(`Downloading core binaries for ${platformOS}-${platformArch}...`); // Get the current Electrobun version from package.json const packageJsonPath = join(ELECTROBUN_DEP_PATH, 'package.json'); let version = 'latest'; if (existsSync(packageJsonPath)) { try { const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); version = `v${packageJson.version}`; } catch (error) { console.warn('Could not read package version, using latest'); } } const platformName = platformOS === 'macos' ? 'darwin' : platformOS === 'win' ? 'win' : 'linux'; const archName = platformArch; const coreTarballUrl = `https://github.com/blackboardsh/electrobun/releases/download/${version}/electrobun-core-${platformName}-${archName}.tar.gz`; console.log(`Downloading core binaries from: ${coreTarballUrl}`); try { // Download core binaries tarball const response = await fetch(coreTarballUrl); if (!response.ok) { throw new Error(`Failed to download binaries: ${response.status} ${response.statusText}`); } // Create temp file const tempFile = join(ELECTROBUN_DEP_PATH, `core-${platformOS}-${platformArch}-temp.tar.gz`); const fileStream = createWriteStream(tempFile); // Write response to file if (response.body) { const reader = response.body.getReader(); let totalBytes = 0; while (true) { const { done, value } = await reader.read(); if (done) break; const buffer = Buffer.from(value); fileStream.write(buffer); totalBytes += buffer.length; } console.log(`Downloaded ${totalBytes} bytes for ${platformOS}-${platformArch}`); } // Ensure file is properly closed before proceeding await new Promise((resolve, reject) => { fileStream.end((err) => { if (err) reject(err); else resolve(null); }); }); // Verify the downloaded file exists and has content if (!existsSync(tempFile)) { throw new Error(`Downloaded file not found: ${tempFile}`); } const fileSize = require('fs').statSync(tempFile).size; if (fileSize === 0) { throw new Error(`Downloaded file is empty: ${tempFile}`); } console.log(`Verified download: ${tempFile} (${fileSize} bytes)`); // Extract to platform-specific dist directory console.log(`Extracting core dependencies for ${platformOS}-${platformArch}...`); const platformDistPath = join(ELECTROBUN_DEP_PATH, `dist-${platformOS}-${platformArch}`); mkdirSync(platformDistPath, { recursive: true }); // Use Windows native tar.exe on Windows due to npm tar library issues if (OS === 'win') { console.log('Using Windows native tar.exe for reliable extraction...'); const relativeTempFile = relative(platformDistPath, tempFile); execSync(`tar -xf "${relativeTempFile}"`, { stdio: 'inherit', cwd: platformDistPath }); } else { await tar.x({ file: tempFile, cwd: platformDistPath, preservePaths: false, strip: 0, }); } // NOTE: We no longer copy main.js from platform-specific downloads // Platform-specific downloads should only contain native binaries // main.js and api/ should be shipped via npm in the shared dist/ folder // Clean up temp file unlinkSync(tempFile); // Debug: List what was actually extracted try { const extractedFiles = readdirSync(platformDistPath); console.log(`Extracted files to ${platformDistPath}:`, extractedFiles); // Check if files are in subdirectories for (const file of extractedFiles) { const filePath = join(platformDistPath, file); const stat = require('fs').statSync(filePath); if (stat.isDirectory()) { const subFiles = readdirSync(filePath); console.log(` ${file}/: ${subFiles.join(', ')}`); } } } catch (e) { console.error('Could not list extracted files:', e); } // Verify extraction completed successfully - check platform-specific binaries only const requiredBinaries = [ platformPaths.BUN_BINARY, platformPaths.LAUNCHER_RELEASE, platformOS === 'macos' ? platformPaths.NATIVE_WRAPPER_MACOS : platformOS === 'win' ? platformPaths.NATIVE_WRAPPER_WIN : platformPaths.NATIVE_WRAPPER_LINUX ]; const missingBinaries = requiredBinaries.filter(file => !existsSync(file)); if (missingBinaries.length > 0) { console.error(`Missing binaries after extraction: ${missingBinaries.map(f => f.replace(ELECTROBUN_DEP_PATH, '.')).join(', ')}`); console.error('This suggests the tarball structure is different than expected'); } // Note: We no longer need to remove or re-add signatures from downloaded binaries // The CI-added adhoc signatures are actually required for macOS to run the binaries // For development: if main.js doesn't exist in shared dist/, copy from platform-specific download as fallback const sharedDistPath = join(ELECTROBUN_DEP_PATH, 'dist'); const extractedMainJs = join(platformDistPath, 'main.js'); const sharedMainJs = join(sharedDistPath, 'main.js'); if (existsSync(extractedMainJs) && !existsSync(sharedMainJs)) { console.log('Development fallback: copying main.js from platform-specific download to shared dist/'); mkdirSync(sharedDistPath, { recursive: true }); cpSync(extractedMainJs, sharedMainJs); } console.log(`Core dependencies for ${platformOS}-${platformArch} downloaded and cached successfully`); } catch (error: any) { console.error(`Failed to download core dependencies for ${platformOS}-${platformArch}:`, error.message); console.error('Please ensure you have an internet connection and the release exists.'); process.exit(1); } } async function ensureCEFDependencies(targetOS?: 'macos' | 'win' | 'linux', targetArch?: 'arm64' | 'x64') { // Use provided target platform or default to host platform const platformOS = targetOS || OS; const platformArch = targetArch || ARCH; // Get platform-specific paths const platformPaths = getPlatformPaths(platformOS, platformArch); // Check if CEF dependencies already exist if (existsSync(platformPaths.CEF_DIR)) { console.log(`CEF dependencies found for ${platformOS}-${platformArch}, using cached version`); return; } console.log(`CEF dependencies not found for ${platformOS}-${platformArch}, downloading...`); // Get the current Electrobun version from package.json const packageJsonPath = join(ELECTROBUN_DEP_PATH, 'package.json'); let version = 'latest'; if (existsSync(packageJsonPath)) { try { const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); version = `v${packageJson.version}`; } catch (error) { console.warn('Could not read package version, using latest'); } } const platformName = platformOS === 'macos' ? 'darwin' : platformOS === 'win' ? 'win' : 'linux'; const archName = platformArch; const cefTarballUrl = `https://github.com/blackboardsh/electrobun/releases/download/${version}/electrobun-cef-${platformName}-${archName}.tar.gz`; // Helper function to download with retry logic async function downloadWithRetry(url: string, filePath: string, maxRetries = 3): Promise<void> { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { console.log(`Downloading CEF (attempt ${attempt}/${maxRetries}) from: ${url}`); const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } // Get content length for progress tracking const contentLength = response.headers.get('content-length'); const totalSize = contentLength ? parseInt(contentLength, 10) : 0; // Create temp file with unique name to avoid conflicts const fileStream = createWriteStream(filePath); let downloadedSize = 0; let lastReportedPercent = -1; // Stream download with progress if (response.body) { const reader = response.body.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = Buffer.from(value); fileStream.write(chunk); downloadedSize += chunk.length; if (totalSize > 0) { const percent = Math.round((downloadedSize / totalSize) * 100); const percentTier = Math.floor(percent / 10) * 10; if (percentTier > lastReportedPercent && percentTier <= 100) { console.log(` Progress: ${percentTier}% (${Math.round(downloadedSize / 1024 / 1024)}MB/${Math.round(totalSize / 1024 / 1024)}MB)`); lastReportedPercent = percentTier; } } } } await new Promise((resolve, reject) => { fileStream.end((error: any) => { if (error) reject(error); else resolve(void 0); }); }); // Verify file size if content-length was provided if (totalSize > 0) { const actualSize = (await import('fs')).statSync(filePath).size; if (actualSize !== totalSize) { throw new Error(`Downloaded file size mismatch: expected ${totalSize}, got ${actualSize}`); } } console.log(`✓ Download completed successfully (${Math.round(downloadedSize / 1024 / 1024)}MB)`); return; // Success, exit retry loop } catch (error: any) { console.error(`Download attempt ${attempt} failed:`, error.message); // Clean up partial download if (existsSync(filePath)) { unlinkSync(filePath); } if (attempt === maxRetries) { throw new Error(`Failed to download after ${maxRetries} attempts: ${error.message}`); } // Wait before retrying (exponential backoff) const delay = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s... console.log(`Retrying in ${delay / 1000} seconds...`); await new Promise(resolve => setTimeout(resolve, delay)); } } } try { // Create temp file with unique name const tempFile = join(ELECTROBUN_DEP_PATH, `cef-${platformOS}-${platformArch}-${Date.now()}.tar.gz`); // Download with retry logic await downloadWithRetry(cefTarballUrl, tempFile); // Extract to platform-specific dist directory console.log(`Extracting CEF dependencies for ${platformOS}-${platformArch}...`); const platformDistPath = join(ELECTROBUN_DEP_PATH, `dist-${platformOS}-${platformArch}`); mkdirSync(platformDistPath, { recursive: true }); // Helper function to validate tar file before extraction async function validateTarFile(filePath: string): Promise<void> { try { // Quick validation - try to read the tar file header const fd = await import('fs').then(fs => fs.promises.readFile(filePath)); // Check if it's a gzip file (magic bytes: 1f 8b) if (fd.length < 2 || fd[0] !== 0x1f || fd[1] !== 0x8b) { throw new Error('Invalid gzip header - file may be corrupted'); } console.log(`✓ Tar file validation passed (${Math.round(fd.length / 1024 / 1024)}MB)`); } catch (error: any) { throw new Error(`Tar file validation failed: ${error.message}`); } } // Validate downloaded file before extraction await validateTarFile(tempFile); try { // Use Windows native tar.exe on Windows due to npm tar library issues if (OS === 'win') { console.log('Using Windows native tar.exe for reliable extraction...'); const relativeTempFile = relative(platformDistPath, tempFile); execSync(`tar -xf "${relativeTempFile}"`, { stdio: 'inherit', cwd: platformDistPath }); } else { await tar.x({ file: tempFile, cwd: platformDistPath, preservePaths: false, strip: 0, }); } console.log(`✓ Extraction completed successfully`); } catch (error: any) { // Check if CEF directory was created despite the error (partial extraction) const cefDir = join(platformDistPath, 'cef'); if (existsSync(cefDir)) { const cefFiles = readdirSync(cefDir); if (cefFiles.length > 0) { console.warn(`⚠️ Extraction warning: ${error.message}`); console.warn(` However, CEF files were extracted (${cefFiles.length} files found).`); console.warn(` Proceeding with partial extraction - this usually works fine.`); // Don't throw - continue with what we have } else { // No files extracted, this is a real failure throw new Error(`Extraction failed (no files extracted): ${error.message}`); } } else { // No CEF directory created, this is a real failure throw new Error(`Extraction failed (no CEF directory created): ${error.message}`); } } // Clean up temp file only after successful extraction try { unlinkSync(tempFile); } catch (cleanupError) { console.warn('Could not clean up temp file:', cleanupError); } // Debug: List what was actually extracted for CEF try { const extractedFiles = readdirSync(platformDistPath); console.log(`CEF extracted files to ${platformDistPath}:`, extractedFiles); // Check if CEF directory was created const cefDir = join(platformDistPath, 'cef'); if (existsSync(cefDir)) { const cefFiles = readdirSync(cefDir); console.log(`CEF directory contents: ${cefFiles.slice(0, 10).join(', ')}${cefFiles.length > 10 ? '...' : ''}`); } } catch (e) { console.error('Could not list CEF extracted files:', e); } console.log(`✓ CEF dependencies for ${platformOS}-${platformArch} downloaded and cached successfully`); } catch (error: any) { console.error(`Failed to download CEF dependencies for ${platformOS}-${platformArch}:`, error.message); // Provide helpful guidance based on the error if (error.message.includes('corrupted download') || error.message.includes('zlib') || error.message.includes('unexpected end')) { console.error('\n💡 This appears to be a download corruption issue. Suggestions:'); console.error(' • Check your internet connection stability'); console.error(' • Try running the command again (it will retry automatically)'); console.error(' • Clear the cache if the issue persists:'); console.error(` rm -rf "${ELECTROBUN_DEP_PATH}"`); } else if (error.message.includes('HTTP 404') || error.message.includes('Not Found')) { console.error('\n💡 The CEF release was not found. This could mean:'); console.error(' • The version specified doesn\'t have CEF binaries available'); console.error(' • You\'re using a development/unreleased version'); console.error(' • Try using a stable version instead'); } else { console.error('\nPlease ensure you have an internet connection and the release exists.'); console.error(`If the problem persists, try clearing the cache: rm -rf "${ELECTROBUN_DEP_PATH}"`); } process.exit(1); } } const commandDefaults = { init: { projectRoot, config: "electrobun.config", }, build: { projectRoot, config: "electrobun.config", }, dev: { projectRoot, config: "electrobun.config", }, }; // todo (yoav): add types for config const defaultConfig = { app: { name: "MyApp", identifier: "com.example.myapp", version: "0.1.0", }, build: { buildFolder: "build", artifactFolder: "artifacts", targets: undefined, // Will default to current platform if not specified mac: { codesign: false, notarize: false, bundleCEF: false, entitlements: { // This entitlement is required for Electrobun apps with a hardened runtime (required for notarization) to run on macos "com.apple.security.cs.allow-jit": true, // Required for bun runtime to work with dynamic code execution and JIT compilation when signed "com.apple.security.cs.allow-unsigned-executable-memory": true, "com.apple.security.cs.disable-library-validation": true, }, icons: "icon.iconset", }, win: { bundleCEF: false, }, linux: { bundleCEF: false, }, bun: { entrypoint: "src/bun/index.ts", external: [], }, }, scripts: { postBuild: "", }, release: { bucketUrl: "", }, }; // Mapping of entitlements to their corresponding Info.plist usage description keys const ENTITLEMENT_TO_PLIST_KEY: Record<string, string> = { "com.apple.security.device.camera": "NSCameraUsageDescription", "com.apple.security.device.microphone": "NSMicrophoneUsageDescription", "com.apple.security.device.audio-input": "NSMicrophoneUsageDescription", "com.apple.security.personal-information.location": "NSLocationUsageDescription", "com.apple.security.personal-information.location-when-in-use": "NSLocationWhenInUseUsageDescription", "com.apple.security.personal-information.contacts": "NSContactsUsageDescription", "com.apple.security.personal-information.calendars": "NSCalendarsUsageDescription", "com.apple.security.personal-information.reminders": "NSRemindersUsageDescription", "com.apple.security.personal-information.photos-library": "NSPhotoLibraryUsageDescription", "com.apple.security.personal-information.apple-music-library": "NSAppleMusicUsageDescription", "com.apple.security.personal-information.motion": "NSMotionUsageDescription", "com.apple.security.personal-information.speech-recognition": "NSSpeechRecognitionUsageDescription", "com.apple.security.device.bluetooth": "NSBluetoothAlwaysUsageDescription", "com.apple.security.files.user-selected.read-write": "NSDocumentsFolderUsageDescription", "com.apple.security.files.downloads.read-write": "NSDownloadsFolderUsageDescription", "com.apple.security.files.desktop.read-write": "NSDesktopFolderUsageDescription", }; // Helper function to escape XML special characters function escapeXml(str: string): string { return str .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&apos;'); } // Helper function to generate usage description entries for Info.plist function generateUsageDescriptions(entitlements: Record<string, boolean | string>): string { const usageEntries: string[] = []; for (const [entitlement, value] of Object.entries(entitlements)) { const plistKey = ENTITLEMENT_TO_PLIST_KEY[entitlement]; if (plistKey && value) { // Use the string value as description, or a default if it's just true const description = typeof value === "string" ? escapeXml(value) : `This app requires access for ${entitlement.split('.').pop()?.replace('-', ' ')}`; usageEntries.push(` <key>${plistKey}</key>\n <string>${description}</string>`); } } return usageEntries.join('\n'); } const command = commandDefaults[commandArg]; if (!command) { console.error("Invalid command: ", commandArg); process.exit(1); } // Main execution function async function main() { const config = await getConfig(); const envArg = process.argv.find((arg) => arg.startsWith("--env="))?.split("=")[1] || ""; const targetsArg = process.argv.find((arg) => arg.startsWith("--targets="))?.split("=")[1] || ""; const validEnvironments = ["dev", "canary", "stable"]; // todo (yoav): dev, canary, and stable; const buildEnvironment: "dev" | "canary" | "stable" = validEnvironments.includes(envArg || "dev") ? (envArg || "dev") : "dev"; // Determine build targets type BuildTarget = { os: 'macos' | 'win' | 'linux', arch: 'arm64' | 'x64' }; function parseBuildTargets(): BuildTarget[] { // If explicit targets provided via CLI if (targetsArg) { if (targetsArg === 'current') { return [{ os: OS, arch: ARCH }]; } else if (targetsArg === 'all') { return parseConfigTargets(); } else { // Parse comma-separated targets like "macos-arm64,win-x64" return targetsArg.split(',').map(target => { const [os, arch] = target.trim().split('-') as [string, string]; if (!['macos', 'win', 'linux'].includes(os) || !['arm64', 'x64'].includes(arch)) { console.error(`Invalid target: ${target}. Format should be: os-arch (e.g., macos-arm64)`); process.exit(1); } return { os, arch } as BuildTarget; }); } } // Default behavior: always build for current platform only // This ensures predictable, fast builds unless explicitly requesting multi-platform return [{ os: OS, arch: ARCH }]; } function parseConfigTargets(): BuildTarget[] { // If config has targets, use them if (config.build.targets && config.build.targets.length > 0) { return config.build.targets.map(target => { if (target === 'current') { return { os: OS, arch: ARCH }; } const [os, arch] = target.split('-') as [string, string]; if (!['macos', 'win', 'linux'].includes(os) || !['arm64', 'x64'].includes(arch)) { console.error(`Invalid target in config: ${target}. Format should be: os-arch (e.g., macos-arm64)`); process.exit(1); } return { os, arch } as BuildTarget; }); } // If no config targets and --targets=all, use all available platforms if (targetsArg === 'all') { console.log('No targets specified in config, using all available platforms'); return [ { os: 'macos', arch: 'arm64' }, { os: 'macos', arch: 'x64' }, { os: 'win', arch: 'x64' }, { os: 'linux', arch: 'x64' }, { os: 'linux', arch: 'arm64' } ]; } // Default to current platform return [{ os: OS, arch: ARCH }]; } const buildTargets = parseBuildTargets(); // Show build targets to user if (buildTargets.length === 1) { console.log(`Building for ${buildTargets[0].os}-${buildTargets[0].arch} (${buildEnvironment})`); } else { const targetList = buildTargets.map(t => `${t.os}-${t.arch}`).join(', '); console.log(`Building for multiple targets: ${targetList} (${buildEnvironment})`); console.log(`Running ${buildTargets.length} parallel builds...`); // Spawn parallel build processes const buildPromises = buildTargets.map(async (target) => { const targetString = `${target.os}-${target.arch}`; const prefix = `[${targetString}]`; try { // Try to find the electrobun binary in node_modules/.bin or use bunx const electrobunBin = join(projectRoot, 'node_modules', '.bin', 'electrobun'); let command: string[]; if (existsSync(electrobunBin)) { command = [electrobunBin, 'build', `--env=${buildEnvironment}`, `--targets=${targetString}`]; } else { // Fallback to bunx which should resolve node_modules binaries command = ['bunx', 'electrobun', 'build', `--env=${buildEnvironment}`, `--targets=${targetString}`]; } console.log(`${prefix} Running:`, command.join(' ')); const result = await Bun.spawn(command, { stdio: ['inherit', 'pipe', 'pipe'], env: process.env, cwd: projectRoot // Ensure we're in the right directory }); // Pipe output with prefix if (result.stdout) { const reader = result.stdout.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; const text = new TextDecoder().decode(value); // Add prefix to each line const prefixedText = text.split('\n').map(line => line ? `${prefix} ${line}` : line ).join('\n'); process.stdout.write(prefixedText); } } if (result.stderr) { const reader = result.stderr.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; const text = new TextDecoder().decode(value); const prefixedText = text.split('\n').map(line => line ? `${prefix} ${line}` : line ).join('\n'); process.stderr.write(prefixedText); } } const exitCode = await result.exited; return { target, exitCode, success: exitCode === 0 }; } catch (error) { console.error(`${prefix} Failed to start build:`, error); return { target, exitCode: 1, success: false, error }; } }); // Wait for all builds to complete const results = await Promise.allSettled(buildPromises); // Report final results console.log('\n=== Build Results ==='); let allSucceeded = true; for (const result of results) { if (result.status === 'fulfilled') { const { target, success, exitCode } = result.value; const status = success ? '✅ SUCCESS' : '❌ FAILED'; console.log(`${target.os}-${target.arch}: ${status} (exit code: ${exitCode})`); if (!success) allSucceeded = false; } else { console.log(`Build rejected: ${result.reason}`); allSucceeded = false; } } if (!allSucceeded) { console.log('\nSome builds failed. Check the output above for details.'); process.exit(1); } else { console.log('\nAll builds completed successfully! 🎉'); } process.exit(0); } // todo (yoav): dev builds should include the branch name, and/or allow configuration via external config // For now, assume single target build (we'll refactor for multi-target later) const currentTarget = buildTargets[0]; const buildSubFolder = `${buildEnvironment}-${currentTarget.os}-${currentTarget.arch}`; // Use target OS/ARCH for build logic (instead of current machine's OS/ARCH) const targetOS = currentTarget.os; const targetARCH = currentTarget.arch; const targetBinExt = targetOS === 'win' ? '.exe' : ''; const buildFolder = join(projectRoot, config.build.buildFolder, buildSubFolder); const artifactFolder = join( projectRoot, config.build.artifactFolder, buildSubFolder ); const buildIcons = (appBundleFolderResourcesPath: string) => { if (OS === 'macos' && config.build.mac.icons) { const iconSourceFolder = join(projectRoot, config.build.mac.icons); const iconDestPath = join(appBundleFolderResourcesPath, "AppIcon.icns"); if (existsSync(iconSourceFolder)) { Bun.spawnSync( ["iconutil", "-c", "icns", "-o", iconDestPath, iconSourceFolder], { cwd: appBundleFolderResourcesPath, stdio: ["ignore", "inherit", "inherit"], env: { ...process.env, ELECTROBUN_BUILD_ENV: buildEnvironment, }, } ); } } }; function escapePathForTerminal(filePath: string) { // List of special characters to escape const specialChars = [ " ", "(", ")", "&", "|", ";", "<", ">", "`", "\\", '"', "'", "$", "*", "?", "[", "]", "#", ]; let escapedPath = ""; for (const char of filePath) { if (specialChars.includes(char)) { escapedPath += `\\${char}`; } else { escapedPath += char; } } return escapedPath; } function sanitizeVolumeNameForHdiutil(volumeName: string) { // Remove or replace characters that cause issues with hdiutil volume mounting // Parentheses and other special characters can cause "Operation not permitted" errors return volumeName.replace(/[()]/g, ''); } // MyApp // const appName = config.app.name.replace(/\s/g, '-').toLowerCase(); const appFileName = ( buildEnvironment === "stable" ? config.app.name : `${config.app.name}-${buildEnvironment}` ) .replace(/\s/g, "") .replace(/\./g, "-"); const bundleFileName = targetOS === 'macos' ? `${appFileName}.app` : appFileName; // const logPath = `/Library/Logs/Electrobun/ExampleApp/dev/out.log`; let proc = null; if (commandArg === "init") { await (async () => { const secondArg = process.argv[indexOfElectrobun + 2]; const availableTemplates = getTemplateNames(); let projectName: string; let templateName: string; // Check if --template= flag is used const templateFlag = process.argv.find(arg => arg.startsWith("--template=")); if (templateFlag) { // Traditional usage: electrobun init my-project --template=photo-booth projectName = secondArg || "my-electrobun-app"; templateName = templateFlag.split("=")[1]; } else if (secondArg && availableTemplates.includes(secondArg)) { // New intuitive usage: electrobun init photo-booth projectName = secondArg; // Use template name as project name templateName = secondArg; } else { // Interactive menu when no template specified console.log("🚀 Welcome to Electrobun!"); console.log(""); console.log("Available templates:"); availableTemplates.forEach((template, index) => { console.log(` ${index + 1}. ${template}`); }); console.log(""); // Simple CLI selection using readline const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const choice = await new Promise<string>((resolve) => { rl.question('Select a template (enter number): ', (answer) => { rl.close(); resolve(answer.trim()); }); }); const templateIndex = parseInt(choice) - 1; if (templateIndex < 0 || templateIndex >= availableTemplates.length) { console.error(`❌ Invalid selection. Please enter a number between 1 and ${availableTemplates.length}.`); process.exit(1); } templateName = availableTemplates[templateIndex]; // Ask for project name const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout }); projectName = await new Promise<string>((resolve) => { rl2.question(`Enter project name (default: my-${templateName}-app): `, (answer) => { rl2.close(); resolve(answer.trim() || `my-${templateName}-app`); }); }); } console.log(`🚀 Initializing Electrobun project: ${projectName}`); console.log(`📋 Using template: ${templateName}`); // Validate template name if (!availableTemplates.includes(templateName)) { console.error(`❌ Template "${templateName}" not found.`); console.log(`Available templates: ${availableTemplates.join(", ")}`); process.exit(1); } const template = getTemplate(templateName); if (!template) { console.error(`❌ Could not load template "${templateName}"`); process.exit(1); } // Create project directory const projectPath = join(process.cwd(), projectName); if (existsSync(projectPath)) { console.error(`❌ Directory "${projectName}" already exists.`); process.exit(1); } mkdirSync(projectPath, { recursive: true }); // Extract template files let fileCount = 0; for (const [relativePath, content] of Object.entries(template.files)) { const fullPath = join(projectPath, relativePath); const dir = dirname(fullPath); // Create directory if it doesn't exist mkdirSync(dir, { recursive: true }); // Write file writeFileSync(fullPath, content, 'utf-8'); fileCount++; } console.log(`✅ Created ${fileCount} files from "${templateName}" template`); console.log(`📁 Project created at: ${projectPath}`); console.log(""); console.log("📦 Next steps:"); console.log(` cd ${projectName}`); console.log(" bun install"); console.log(" bun start"); console.log(""); console.log("🎉 Happy building with Electrobun!"); })(); } else if (commandArg === "build") { // Ensure core binaries are available for the target platform before starting build await ensureCoreDependencies(currentTarget.os, currentTarget.arch); // Get platform-specific paths for the current target const targetPaths = getPlatformPaths(currentTarget.os, currentTarget.arch); // refresh build folder if (existsSync(buildFolder)) { rmdirSync(buildFolder, { recursive: true }); } mkdirSync(buildFolder, { recursive: true }); // bundle bun to build/bun const bunConfig = config.build.bun; const bunSource = join(projectRoot, bunConfig.entrypoint); if (!existsSync(bunSource)) { console.error( `failed to bundle ${bunSource} because it doesn't exist.\n You need a config.build.bun.entrypoint source file to build.` ); process.exit(1); } // build macos bundle const { appBundleFolderPath, appBundleFolderContentsPath, appBundleMacOSPath, appBundleFolderResourcesPath, appBundleFolderFrameworksPath, } = createAppBundle(appFileName, buildFolder, targetOS); const appBundleAppCodePath = join(appBundleFolderResourcesPath, "app"); mkdirSync(appBundleAppCodePath, { recursive: true }); // const bundledBunPath = join(appBundleMacOSPath, 'bun'); // cpSync(bunPath, bundledBunPath); // Note: for sandboxed apps, MacOS will use the CFBundleIdentifier to create a unique container for the app, // mirroring folders like Application Support, Caches, etc. in the user's Library folder that the sandboxed app // gets access to. // We likely want to let users configure this for different environments (eg: dev, canary, stable) and/or // provide methods to help segment data in those folders based on channel/environment // Generate usage descriptions from entitlements const usageDescriptions = generateUsageDescriptions(config.build.mac.entitlements || {}); const InfoPlistContents = `<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>CFBundleExecutable</key> <string>launcher</string> <key>CFBundleIdentifier</key> <string>${config.app.identifier}</string> <key>CFBundleName</key> <string>${appFileName}</string> <key>CFBundleVersion</key> <string>${config.app.version}</string> <key>CFBundlePackageType</key> <string>APPL</string> <key>CFBundleIconFile</key> <string>AppIcon</string>${usageDescriptions ? '\n' + usageDescriptions : ''} </dict> </plist>`; await Bun.write( join(appBundleFolderContentsPath, "Info.plist"), InfoPlistContents ); // in dev builds the log file is a named pipe so we can stream it back to the terminal // in canary/stable builds it'll be a regular log file // const LauncherContents = `#!/bin/bash // # change directory from whatever open was or double clicking on the app to the dir of the bin in the app bundle // cd "$(dirname "$0")"/ // # Define the log file path // LOG_FILE="$HOME/${logPath}" // # Ensure the directory exists // mkdir -p "$(dirname "$LOG_FILE")" // if [[ ! -p $LOG_FILE ]]; then // mkfifo $LOG_FILE // fi // # Execute bun and redirect stdout and stderr to the log file // ./bun ../Resources/app/bun/index.js >"$LOG_FILE" 2>&1 // `; // // Launcher binary // // todo (yoav): This will likely be a zig compiled binary in the future // Bun.write(join(appBundleMacOSPath, 'MyApp'), LauncherContents); // chmodSync(join(appBundleMacOSPath, 'MyApp'), '755'); // const zigLauncherBinarySource = join(projectRoot, 'node_modules', 'electrobun', 'src', 'launcher', 'zig-out', 'bin', 'launcher'); // const zigLauncherDestination = join(appBundleMacOSPath, 'MyApp'); // const destLauncherFolder = dirname(zigLauncherDestination); // if (!existsSync(destLauncherFolder)) { // // console.info('creating folder: ', destFolder); // mkdirSync(destLauncherFolder, {recursive: true}); // } // cpSync(zigLauncherBinarySource, zigLauncherDestination, {recursive: true, dereference: true}); // Copy zig launcher for all builds (dev, canary, stable) const bunCliLauncherBinarySource = targetPaths.LAUNCHER_RELEASE; const bunCliLauncherDestination = join(appBundleMacOSPath, "launcher") + targetBinExt; const destLauncherFolder = dirname(bunCliLauncherDestination); if (!existsSync(destLauncherFolder)) { // console.info('creating folder: ', destFolder); mkdirSync(destLauncherFolder, { recursive: true }); } cpSync(bunCliLauncherBinarySource, bunCliLauncherDestination, { recursive: true, dereference: true, }); cpSync(targetPaths.MAIN_JS, join(appBundleFolderResourcesPath, 'main.js')); // Bun runtime binary // todo (yoav): this only works for the current architecture const bunBinarySourcePath = targetPaths.BUN_BINARY; // Note: .bin/bun binary in node_modules is a symlink to the versioned one in another place // in node_modules, so we have to dereference here to get the actual binary in the bundle. const bunBinaryDestInBundlePath = join(appBundleMacOSPath, "bun") + targetBinExt; const destFolder2 = dirname(bunBinaryDestInBundlePath); if (!existsSync(destFolder2)) { // console.info('creating folder: ', destFolder); mkdirSync(destFolder2, { recursive: true }); } cpSync(bunBinarySourcePath, bunBinaryDestInBundlePath, { dereference: true }); // copy native wrapper dynamic library if (targetOS === 'macos') { const nativeWrapperMacosSource = targetPaths.NATIVE_WRAPPER_MACOS; const nativeWrapperMacosDestination = join( appBundleMacOSPath, "libNativeWrapper.dylib" ); cpSync(nativeWrapperMacosSource, nativeWrapperMacosDestination, { dereference: true, }); } else if (targetOS === 'win') { const nativeWrapperMacosSource = targetPaths.NATIVE_WRAPPER_WIN; const nativeWrapperMacosDestination = join( appBundleMacOSPath, "libNativeWrapper.dll" ); cpSync(nativeWrapperMacosSource, nativeWrapperMacosDestination, { dereference: true, }); const webview2LibSource = targetPaths.WEBVIEW2LOADER_WIN; const webview2LibDestination = join( appBundleMacOSPath, "WebView2Loader.dll" ); ; // copy webview2 system webview library cpSync(webview2LibSource, webview2LibDestination); } else if (targetOS === 'linux') { // Choose the appropriate native wrapper based on bundleCEF setting const useCEF = config.build.linux?.bundleCEF; const nativeWrapperLinuxSource = useCEF ? targetPaths.NATIVE_WRAPPER_LINUX_CEF : targetPaths.NATIVE_WRAPPER_LINUX; const nativeWrapperLinuxDestination = join( appBundleMacOSPath, "libNativeWrapper.so" ); if (existsSync(nativeWrapperLinuxSource)) { cpSync(nativeWrapperLinuxSource, nativeWrapperLinuxDestination, { dereference: true, }); console.log(`Using ${useCEF ? 'CEF' : 'GTK'} native wrapper for Linux`); } else { throw new Error(`Native wrapper not found: ${nativeWrapperLinuxSource}`); } } // Download CEF binaries if needed when bundleCEF is enabled if ((targetOS === 'macos' && config.build.mac?.bundleCEF) || (targetOS === 'win' && config.build.win?.bundleCEF) || (targetOS === 'linux' && config.build.linux?.bundleCEF)) { await ensureCEFDependencies(currentTarget.os, currentTarget.arch); if (targetOS === 'macos') { const cefFrameworkSource = targetPaths.CEF_FRAMEWORK_MACOS; const cefFrameworkDestination = join( appBundleFolderFrameworksPath, "Chromium Embedded Framework.framework" ); cpSync(cefFrameworkSource, cefFrameworkDestination, { recursive: true, dereference: true, }); // cef helpers const cefHelperNames = [ "bun Helper", "bun Helper (Alerts)", "bun Helper (GPU)", "bun Helper (Plugin)", "bun Helper (Renderer)", ]; const helperSourcePath = targetPaths.CEF_HELPER_MACOS; cefHelperNames.forEach((helperName) => { const destinationPath = join( appBundleFolderFrameworksPath, `${helperName}.app`, `Contents`, `MacOS`, `${helperName}` ); const destFolder4 = dirname(destinationPath); if (!existsSync(destFolder4)) { // console.info('creating folder: ', destFolder4); mkdirSync(destFolder4, { recursive: true }); } cpSync(helperSourcePath, destinationPath, { recursive: true, dereference: true, }); }); } else if (targetOS === 'win') { // Copy CEF DLLs from platform-specific dist/cef/ to the main executable directory const cefSourcePath = targetPaths.CEF_DIR; const cefDllFiles = [ 'libcef.dll', 'chrome_elf.dll', 'd3dcompiler_47.dll', 'libEGL.dll', 'libGLESv2.dll', 'vk_swiftshader.dll', 'vulkan-1.dll' ]; cefDllFiles.forEach(dllFile => { const sourcePath = join(cefSourcePath, dllFile); const destPath = join(appBundleMacOSPath, dllFile); if (existsSync(sourcePath)) { cpSync(sourcePath, destPath); } }); // Copy icudtl.dat to MacOS root (same folder as libcef.dll) - required for CEF initialization const icuDataSource = join(cefSourcePath, 'icudtl.dat'); const icuDataDest = join(appBundleMacOSPath, 'icudtl.dat'); if (existsSync(icuDataSource)) { cpSync(icuDataSource, icuDataDest); } // Copy essential CEF pak files to MacOS root (same folder as libcef.dll) - required for CEF resources const essentialPakFiles = ['chrome_100_percent.pak', 'resources.pak', 'v8_context_snapshot.bin']; essentialPakFiles.forEach(pakFile => { const sourcePath = join(cefSourcePath, pakFile); const destPath = join(appBundleMacOSPath, pakFile); if (existsSync(sourcePath)) { cpSync(sourcePath, destPath); } else { console.log(`WARNING: Missing CEF file: ${sourcePath}`); } }); // Copy CEF resources to MacOS/cef/ subdirectory for other resources like locales const cefResourcesSource = targetPaths.CEF_DIR; const cefResourcesDestination = join(appBundleMacOSPath, 'cef'); if (existsSync(cefResourcesSource)) { cpSync(cefResourcesSource, cefResourcesDestination, { recursive: true, dereference: true, }); } // Copy CEF helper processes with different names const cefHelperNames = [ "bun Helper", "bun Helper (Alerts)", "bun Helper (GPU)", "bun Helper (Plugin)", "bun Helper (Renderer)", ]; const helperSourcePath = targetPaths.CEF_HELPER_WIN; if (existsSync(helperSourcePath)) { cefHelperNames.forEach((helperName) => { const destinationPath = join(appBundleMacOSPath, `${helperName}.exe`); cpSync(helperSourcePath, destinationPath); }); } else { console.log(`WARNING: Missing CEF helper: ${helperSourcePath}`); } } else if (targetOS === 'linux') { // Copy CEF shared libraries from platform-specific dist/cef/ to the main executable directory const cefSourcePath = targetPaths.CEF_DIR; if (existsSync(cefSourcePath)) { const cefSoFiles = [ 'libcef.so', 'libEGL.so', 'libGLESv2.so', 'libvk_swiftshader.so', 'libvulkan.so.1' ]; // Copy CEF .so files to main directory as symlinks to cef/ subdirectory cefSoFiles.forEach(soFile => { const sourcePath = join(cefSourcePath, soFile); const destPath = join(appBundleMacOSPath, soFile); if (existsSync(sourcePath)) { // We'll create the actual file in cef/ and symlink from main directory // This will be done after the cef/ directory is populated } }); // Copy icudtl.dat to MacOS root (same folder as libcef.so) - required for CEF initialization const icuDataSource = join(cefSourcePath, 'icudtl.dat'); const icuDataDest = join(appBundleMacOSPath, 'icudtl.dat'); if (existsSync(icuDataSource)) { cpSync(icuDataSource, icuDataDest); } // Copy .pak files and other CEF resources to the main executable directory const pakFiles = [