electrobun
Version:
Build ultra fast, tiny, and cross-platform desktop apps with Typescript.
1,339 lines (1,144 loc) • 104 kB
text/typescript
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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// 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 = [