#!/usr/bin/env node
/**
* Simplified build script for AionUi
* Coordinates electron-vite (bundling) and electron-builder (packaging)
*
* Features:
* - Incremental builds: use --skip-vite to skip Vite compilation if out/ exists
* - Skip native rebuild: use --skip-native to skip native module rebuilding
* - Packaging only: use --pack-only to skip electron-builder distributable creation
*/
const { execSync, spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
// DMG retry logic for macOS: detects DMG creation failures by checking artifacts
// (.app exists but .dmg missing) and retries only the DMG step using
// electron-builder --prepackaged with the .app path (not the parent directory).
// This preserves full DMG styling (window size, icon positions, background)
// Background: GitHub Actions macos-14 runners occasionally suffer from transient
// "Device not configured" hdiutil errors (electron-builder#8415, actions/runner-images#12323).
const DMG_RETRY_MAX = 3;
const DMG_RETRY_DELAY_SEC = 30;
// Incremental build: hash of source files to detect changes
const INCREMENTAL_CACHE_FILE = 'out/.build-hash';
const DEBUG_AUTO_UPDATE_CURRENT_VERSION_ENV = 'AIONUI_DEBUG_AUTO_UPDATE_CURRENT_VERSION';
function patchElectronBuilderNsisInstaller() {
const rootDir = path.resolve(__dirname, '..');
let appBuilderDir = '';
try {
appBuilderDir = path.dirname(require.resolve('app-builder-lib/package.json'));
} catch (error) {
const bunModulesDir = path.join(rootDir, 'node_modules', '.bun');
if (fs.existsSync(bunModulesDir)) {
const candidates = fs
.readdirSync(bunModulesDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith('app-builder-lib@'))
.map((entry) => path.join(bunModulesDir, entry.name, 'node_modules', 'app-builder-lib'))
.filter((candidate) => fs.existsSync(path.join(candidate, 'package.json')))
.sort();
appBuilderDir = candidates[0] || '';
}
if (!appBuilderDir) {
console.warn(`Warning: app-builder-lib is not resolvable; skipping NSIS template patch: ${error.message}`);
return;
}
}
const installUtilPath = path.join(appBuilderDir, 'templates', 'nsis', 'include', 'installUtil.nsh');
if (!fs.existsSync(installUtilPath)) {
console.warn(`Warning: electron-builder NSIS installUtil.nsh not found: ${installUtilPath}`);
return;
}
const original = fs.readFileSync(installUtilPath, 'utf8');
let patched = original;
const retryPrompt = [
' ${if} $R5 > 5',
' MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeClosed)" /SD IDCANCEL IDRETRY OneMoreAttempt',
' Return',
' ${endIf}',
].join('\n');
const retryHandoff = [
' ${if} $R5 > 5',
' DetailPrint `Previous uninstaller did not finish after retry limit; deferring to customUnInstallCheck.`',
' Return',
' ${endIf}',
].join('\n');
if (patched.includes(retryPrompt)) {
patched = patched.replace(retryPrompt, retryHandoff);
} else if (!patched.includes(retryHandoff)) {
throw new Error(
'electron-builder NSIS uninstall retry prompt template changed; update patchElectronBuilderNsisInstaller.'
);
}
const oneMoreAttemptLabel = ' OneMoreAttempt:\n';
if (patched.includes(oneMoreAttemptLabel)) {
patched = patched.replace(oneMoreAttemptLabel, '');
}
const copiedUninstallerExec = `ExecWait '"$uninstallerFileNameTemp" /S /KEEP_APP_DATA $0 _?=$installationDir' $R0`;
const copiedUninstallerExecWithLog = `ExecWait '"$uninstallerFileNameTemp" /S /KEEP_APP_DATA $0 --installer-log="$AionUiSessionLogPath" --installer-session="$AionUiSessionId" _?=$installationDir' $R0`;
if (patched.includes(copiedUninstallerExec)) {
patched = patched.replace(copiedUninstallerExec, copiedUninstallerExecWithLog);
} else if (
patched.includes(
`ExecWait '"$uninstallerFileNameTemp" /S /KEEP_APP_DATA $0 --installer-log="$AionUiSessionLogPath" _?=$installationDir' $R0`
)
) {
patched = patched.replace(
`ExecWait '"$uninstallerFileNameTemp" /S /KEEP_APP_DATA $0 --installer-log="$AionUiSessionLogPath" _?=$installationDir' $R0`,
copiedUninstallerExecWithLog
);
} else if (!patched.includes(copiedUninstallerExecWithLog)) {
throw new Error(
'electron-builder copied-uninstaller ExecWait template changed; update patchElectronBuilderNsisInstaller.'
);
}
const uninstallerCopySource = [
' StrCpy $uninstallerFileNameTemp "$PLUGINSDIR\\old-uninstaller.exe"',
' !insertmacro copyFile "$uninstallerFileName" "$uninstallerFileNameTemp"',
].join('\n');
const bundledUninstallerOverride = [
' ${if} ${FileExists} "$PLUGINSDIR\\AionUi-fixed-uninstaller.exe"',
' DetailPrint `AionUi-bundled-uninstaller override source.`',
' StrCpy $uninstallerFileName "$PLUGINSDIR\\AionUi-fixed-uninstaller.exe"',
' ${endIf}',
].join('\n');
const bundledUninstallerCopySource = [
bundledUninstallerOverride,
'',
' StrCpy $uninstallerFileNameTemp "$PLUGINSDIR\\old-uninstaller.exe"',
' !insertmacro copyFile "$uninstallerFileName" "$uninstallerFileNameTemp"',
].join('\n');
while (patched.includes(`${bundledUninstallerOverride}\n\n${bundledUninstallerOverride}`)) {
patched = patched.replace(
`${bundledUninstallerOverride}\n\n${bundledUninstallerOverride}`,
bundledUninstallerOverride
);
}
if (patched.includes(bundledUninstallerOverride)) {
// Already patched.
} else if (patched.includes(uninstallerCopySource)) {
patched = patched.replace(uninstallerCopySource, bundledUninstallerCopySource);
} else {
throw new Error(
'electron-builder old-uninstaller copy template changed; update patchElectronBuilderNsisInstaller.'
);
}
const inPlaceUninstallerExec = `ExecWait '"$uninstallerFileName" /S /KEEP_APP_DATA $0 _?=$installationDir' $R0`;
const inPlaceUninstallerExecWithLog = `ExecWait '"$uninstallerFileName" /S /KEEP_APP_DATA $0 --installer-log="$AionUiSessionLogPath" --installer-session="$AionUiSessionId" _?=$installationDir' $R0`;
if (patched.includes(inPlaceUninstallerExec)) {
patched = patched.replace(inPlaceUninstallerExec, inPlaceUninstallerExecWithLog);
} else if (
patched.includes(
`ExecWait '"$uninstallerFileName" /S /KEEP_APP_DATA $0 --installer-log="$AionUiSessionLogPath" _?=$installationDir' $R0`
)
) {
patched = patched.replace(
`ExecWait '"$uninstallerFileName" /S /KEEP_APP_DATA $0 --installer-log="$AionUiSessionLogPath" _?=$installationDir' $R0`,
inPlaceUninstallerExecWithLog
);
} else if (!patched.includes(inPlaceUninstallerExecWithLog)) {
throw new Error(
'electron-builder in-place uninstaller ExecWait template changed; update patchElectronBuilderNsisInstaller.'
);
}
if (patched !== original) {
fs.writeFileSync(installUtilPath, patched);
console.log('Patched electron-builder NSIS uninstall failure handoff.');
}
}
function walkFiles(dir, acc = []) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === 'out' || entry.name === '.git') continue;
walkFiles(fullPath, acc);
} else if (entry.isFile()) {
acc.push(fullPath);
}
}
return acc;
}
function computeSourceHash() {
const hash = crypto.createHash('md5');
const rootDir = path.resolve(__dirname, '..');
const filesToHash = [
'package.json',
'package-lock.json',
'bun.lock',
'tsconfig.json',
'packages/desktop/electron.vite.config.ts',
'packages/desktop/electron-builder.yml',
'justfile',
];
for (const file of filesToHash) {
const filePath = path.resolve(rootDir, file);
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath);
hash.update(file + ':');
hash.update(content);
}
}
const hashDirs = ['packages/desktop/src', 'packages', 'public', 'scripts'];
for (const dir of hashDirs) {
const dirPath = path.resolve(rootDir, dir);
if (!fs.existsSync(dirPath)) continue;
const files = walkFiles(dirPath)
.map((file) => path.relative(rootDir, file).replace(/\\/g, '/'))
.sort();
for (const relPath of files) {
const absolutePath = path.resolve(rootDir, relPath);
const stat = fs.statSync(absolutePath);
hash.update(relPath + ':');
hash.update(String(stat.size));
hash.update(String(stat.mtimeMs));
}
}
return hash.digest('hex');
}
function loadCachedHash() {
try {
const cacheFile = path.resolve(__dirname, '..', INCREMENTAL_CACHE_FILE);
if (fs.existsSync(cacheFile)) {
return fs.readFileSync(cacheFile, 'utf8').trim();
}
} catch {}
return null;
}
function saveCurrentHash(hash) {
try {
const cacheFile = path.resolve(__dirname, '..', INCREMENTAL_CACHE_FILE);
const viteDir = path.dirname(cacheFile);
if (!fs.existsSync(viteDir)) {
fs.mkdirSync(viteDir, { recursive: true });
}
fs.writeFileSync(cacheFile, hash);
} catch {}
}
function viteBuildExists() {
const outDir = path.resolve(__dirname, '../out');
const mainDir = path.join(outDir, 'main');
const rendererDir = path.join(outDir, 'renderer');
return (
fs.existsSync(path.join(mainDir, 'index.js')) &&
fs.existsSync(path.join(outDir, 'preload', 'index.js')) &&
validateRendererBuildOutput(rendererDir).valid
);
}
function collectHtmlAssetRefs(html, htmlDirRelative) {
const refs = [];
const attrRe = /\b(?:src|href)=["']([^"']+)["']/g;
for (const match of html.matchAll(attrRe)) {
const rawRef = match[1];
if (!rawRef || rawRef.startsWith('http:') || rawRef.startsWith('https:') || rawRef.startsWith('data:')) continue;
if (!rawRef.startsWith('./') && !rawRef.startsWith('../')) continue;
const normalized = path
.normalize(path.join(htmlDirRelative, rawRef.split(/[?#]/)[0]))
.replace(/\\/g, '/')
.replace(/^\.\//, '');
if (normalized.startsWith('assets/')) {
refs.push(normalized);
}
}
return refs;
}
function walkHtmlFiles(dir, baseDir = dir, acc = []) {
if (!fs.existsSync(dir)) return acc;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkHtmlFiles(fullPath, baseDir, acc);
} else if (entry.isFile() && entry.name.endsWith('.html')) {
acc.push({
fullPath,
relativePath: path.relative(baseDir, fullPath).replace(/\\/g, '/'),
});
}
}
return acc;
}
function validateRendererBuildOutput(rendererDir) {
const problems = [];
const indexHtmlPath = path.join(rendererDir, 'index.html');
if (!fs.existsSync(indexHtmlPath)) {
return { valid: false, problems: ['Renderer build output is incomplete: missing out/renderer/index.html'] };
}
const htmlFiles = walkHtmlFiles(rendererDir);
if (htmlFiles.length === 0) {
return { valid: false, problems: ['Renderer build output is incomplete: no HTML files under out/renderer'] };
}
const assetRefs = new Set();
for (const htmlFile of htmlFiles) {
const html = fs.readFileSync(htmlFile.fullPath, 'utf8');
if (/src=["'][^"']*\.tsx(?:[?#][^"']*)?["']/.test(html)) {
problems.push(`Renderer build output is incomplete: ${htmlFile.relativePath} still references TypeScript source`);
}
const htmlDirRelative = path.dirname(htmlFile.relativePath);
const baseRelative = htmlDirRelative === '.' ? '' : htmlDirRelative;
for (const ref of collectHtmlAssetRefs(html, baseRelative)) {
assetRefs.add(ref);
}
}
const indexHtml = fs.readFileSync(indexHtmlPath, 'utf8');
if (!/
]*type=["']module["'][^>]*\bsrc=["']\.\/assets\/[^"']+\.js["']/.test(indexHtml)) {
problems.push('Renderer build output is incomplete: index.html has no bundled module script');
}
if (assetRefs.size === 0) {
problems.push('Renderer build output is incomplete: no bundled renderer asset references found');
}
for (const ref of [...assetRefs].sort()) {
if (!fs.existsSync(path.join(rendererDir, ref))) {
problems.push(`Renderer build output is incomplete: missing referenced asset ${ref}`);
}
}
return { valid: problems.length === 0, problems };
}
function validateViteBuildOutput() {
const outDir = path.resolve(__dirname, '../out');
const problems = [];
for (const relPath of ['main/index.js', 'preload/index.js']) {
if (!fs.existsSync(path.join(outDir, relPath))) {
problems.push(`Vite build output is incomplete: missing out/${relPath}`);
}
}
const rendererValidation = validateRendererBuildOutput(path.join(outDir, 'renderer'));
problems.push(...rendererValidation.problems);
return { valid: problems.length === 0, problems };
}
function shouldSkipViteBuild(skipViteFlag, forceFlag) {
if (forceFlag) return false;
if (skipViteFlag) return true;
// Auto-detect: skip if build exists and hash matches
const currentHash = computeSourceHash();
const cachedHash = loadCachedHash();
if (cachedHash && currentHash === cachedHash && viteBuildExists()) {
console.log('📦 Incremental build: Vite output unchanged, skipping compilation');
return true;
}
if (cachedHash && currentHash === cachedHash) {
const validation = validateViteBuildOutput();
if (!validation.valid) {
console.warn('Incremental build cache matched but output is incomplete; rebuilding.');
for (const problem of validation.problems.slice(0, 5)) {
console.warn(` ${problem}`);
}
}
}
return false;
}
function cleanupDiskImages() {
try {
// Detach all mounted disk images that may block subsequent DMG creation:
// hdiutil info → grep device paths → force detach each
const result = spawnSync(
'sh',
[
'-c',
"hdiutil info 2>/dev/null | grep /dev/disk | awk '{print $1}' | xargs -I {} hdiutil detach {} -force 2>/dev/null",
],
{ stdio: 'ignore' }
);
if (result.status !== 0) {
console.log(` ℹ️ Disk image cleanup exit code: ${result.status}`);
}
return result.status === 0;
} catch (error) {
console.log(` ℹ️ Disk image cleanup failed: ${error.message}`);
return false;
}
}
// Find the .app directory from electron-builder output
function findAppDir(outDir) {
const candidates = ['mac', 'mac-arm64', 'mac-x64', 'mac-universal'];
for (const dir of candidates) {
const fullPath = path.join(outDir, dir);
if (fs.existsSync(fullPath)) {
const hasApp = fs.readdirSync(fullPath).some((f) => f.endsWith('.app'));
if (hasApp) return fullPath;
}
}
return null;
}
// Check if DMG exists in output directory
function dmgExists(outDir) {
try {
return fs.readdirSync(outDir).some((f) => f.endsWith('.dmg'));
} catch {
return false;
}
}
function tryRemoveDir(targetDir) {
if (!fs.existsSync(targetDir)) return true;
try {
fs.rmSync(targetDir, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 300,
});
return true;
} catch (error) {
console.log(`❌ Failed to remove ${targetDir}: ${error.message}`);
return false;
}
}
function isProcessRunningWindows(imageName) {
if (process.platform !== 'win32') return false;
try {
const result = execSync(`tasklist /FI "IMAGENAME eq ${imageName}"`, {
stdio: ['ignore', 'pipe', 'ignore'],
});
return result.toString().toLowerCase().includes(imageName.toLowerCase());
} catch {
return false;
}
}
function killWindowsProcesses(imageNames) {
if (process.platform !== 'win32') return;
for (const name of imageNames) {
try {
execSync(`taskkill /F /IM ${name}`, { stdio: 'ignore' });
} catch {}
}
}
function formatExecError(error) {
return [error?.message, error?.stdout?.toString?.(), error?.stderr?.toString?.()].filter(Boolean).join('\n').trim();
}
function escapeNsisDefineValue(value) {
return String(value ?? '')
.replace(/\\/g, '\\\\')
.replace(/"/g, '$\\"');
}
function writeGeneratedSentryDsnInclude(projectRoot) {
const generatedInclude = path.join(projectRoot, 'resources/windows/support/_sentry-dsn.generated.nsh');
fs.mkdirSync(path.dirname(generatedInclude), { recursive: true });
fs.writeFileSync(
generatedInclude,
`!define AIONUI_SENTRY_DSN "${escapeNsisDefineValue(process.env.SENTRY_DSN || '')}"\n`
);
}
function isValidPackageVersion(value) {
return /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(
value
);
}
function applyDebugAutoUpdateVersionOverride(packageJsonPath) {
const debugAutoUpdateCurrentVersion = process.env[DEBUG_AUTO_UPDATE_CURRENT_VERSION_ENV]?.trim();
if (!debugAutoUpdateCurrentVersion) {
return () => {};
}
if (!isValidPackageVersion(debugAutoUpdateCurrentVersion)) {
throw new Error(`${DEBUG_AUTO_UPDATE_CURRENT_VERSION_ENV} must be a valid semver version`);
}
const originalPackageJsonText = fs.readFileSync(packageJsonPath, 'utf8');
const packageJson = JSON.parse(originalPackageJsonText);
const originalPackageVersion = packageJson.version;
if (originalPackageVersion === debugAutoUpdateCurrentVersion) {
console.log(`Debug auto-update build version already set to ${debugAutoUpdateCurrentVersion}`);
return () => {};
}
packageJson.version = debugAutoUpdateCurrentVersion;
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
console.log(
`Debug auto-update build version override: ${originalPackageVersion} -> ${debugAutoUpdateCurrentVersion}`
);
return () => {
if (fs.readFileSync(packageJsonPath, 'utf8') !== originalPackageJsonText) {
fs.writeFileSync(packageJsonPath, originalPackageJsonText);
console.log(`Restored package.json version to ${originalPackageVersion}`);
}
};
}
// Create macOS distributables using electron-builder --prepackaged with .app path.
// This preserves DMG styling and still emits the zip required by MacUpdater.
function createMacArtifactsWithPrepackaged(appDir, targetArch) {
const appName = fs.readdirSync(appDir).find((f) => f.endsWith('.app'));
if (!appName) throw new Error(`No .app found in ${appDir}`);
const appPath = path.join(appDir, appName);
execSync(
`bunx electron-builder --config packages/desktop/electron-builder.yml --mac dmg zip --${targetArch} --prepackaged "${appPath}" --publish=never`,
{
stdio: 'inherit',
shell: process.platform === 'win32',
}
);
}
function buildWithDmgRetry(cmd, targetArch) {
const isMac = process.platform === 'darwin';
const outDir = path.resolve(__dirname, '../out');
try {
execSync(cmd, { stdio: 'inherit', shell: process.platform === 'win32' });
return;
} catch (error) {
// On non-macOS or if .app doesn't exist, just throw
const appDir = isMac ? findAppDir(outDir) : null;
if (!appDir || dmgExists(outDir)) throw error;
// .app exists but no .dmg → DMG creation failed
console.log('\n🔄 Build failed during DMG creation (.app exists, .dmg missing)');
console.log(' Retrying macOS distributable creation with --prepackaged...');
for (let attempt = 1; attempt <= DMG_RETRY_MAX; attempt++) {
cleanupDiskImages();
spawnSync('sleep', [String(DMG_RETRY_DELAY_SEC)]);
try {
console.log(`\n📀 DMG retry attempt ${attempt}/${DMG_RETRY_MAX}...`);
createMacArtifactsWithPrepackaged(appDir, targetArch);
console.log('✅ macOS distributables created successfully on retry');
return;
} catch (retryError) {
console.log(` ⚠️ DMG retry ${attempt}/${DMG_RETRY_MAX} failed`);
cleanupDiskImages();
if (attempt === DMG_RETRY_MAX) {
console.log(` ❌ DMG creation failed after ${DMG_RETRY_MAX} retries`);
throw retryError;
}
}
}
}
}
// Clean stale Windows packaging outputs from previous runs
function cleanupWindowsPackOutput() {
const outDir = path.resolve(__dirname, '../out');
if (!fs.existsSync(outDir)) return;
const removed = [];
const winUnpackedDirRe = /^win(?:-[a-z0-9]+)?-unpacked$/i;
const winArtifactFileRe = /-win-[^.]+\.(?:exe|msi|zip|7z)$/i;
for (const entry of fs.readdirSync(outDir, { withFileTypes: true })) {
const fullPath = path.join(outDir, entry.name);
if (entry.isDirectory() && winUnpackedDirRe.test(entry.name)) {
fs.rmSync(fullPath, { recursive: true, force: true });
removed.push(entry.name);
continue;
}
if (entry.isFile() && winArtifactFileRe.test(entry.name)) {
fs.rmSync(fullPath, { force: true });
removed.push(entry.name);
}
}
if (removed.length > 0) {
console.log(`🧹 Cleaned stale Windows outputs: ${removed.join(', ')}`);
}
}
// Parse command line arguments
const args = process.argv.slice(2);
const archList = ['x64', 'arm64', 'ia32', 'armv7l'];
// Check for special flags
const skipVite = args.includes('--skip-vite');
const skipNative = args.includes('--skip-native');
const packOnly = args.includes('--pack-only');
const forceBuild = args.includes('--force');
const builderArgs = args
.filter((arg) => {
// Filter out 'auto', architecture flags, and special flags
if (arg === 'auto') return false;
if (arg === '--skip-vite' || arg === '--skip-native' || arg === '--pack-only' || arg === '--force') return false;
if (archList.includes(arg)) return false;
if (arg.startsWith('--') && archList.includes(arg.slice(2))) return false;
return true;
})
.join(' ');
// Get target architecture from electron-builder.yml
function getTargetArchFromConfig(platform) {
try {
const configPath = path.resolve(__dirname, '../packages/desktop/electron-builder.yml');
const content = fs.readFileSync(configPath, 'utf8');
const platformRegex = new RegExp(`^${platform}:\\s*$`, 'm');
const platformMatch = content.match(platformRegex);
if (!platformMatch) return null;
const platformStartIndex = platformMatch.index;
const afterPlatform = content.slice(platformStartIndex + platformMatch[0].length);
const nextPlatformMatch = afterPlatform.match(/^[a-zA-Z][a-zA-Z0-9]*:/m);
const platformBlock = nextPlatformMatch
? content.slice(platformStartIndex, platformStartIndex + platformMatch[0].length + nextPlatformMatch.index)
: content.slice(platformStartIndex);
const archMatch = platformBlock.match(/arch:\s*\[\s*([a-z0-9_]+)/i);
return archMatch ? archMatch[1].trim() : null;
} catch (error) {
return null;
}
}
// Determine target architecture
const buildMachineArch = process.arch;
let targetArch;
let multiArch = false;
// Check if multiple architectures are specified (support both --x64 and x64 formats)
const rawArchArgs = args
.filter((arg) => {
if (archList.includes(arg)) return true;
if (arg.startsWith('--') && archList.includes(arg.slice(2))) return true;
return false;
})
.map((arg) => (arg.startsWith('--') ? arg.slice(2) : arg));
// Remove duplicates to avoid treating "x64 --x64" as multiple architectures
const archArgs = [...new Set(rawArchArgs)];
if (archArgs.length > 1) {
// Multiple unique architectures specified - let electron-builder handle it
multiArch = true;
targetArch = archArgs[0]; // Use first arch for webpack build
console.log(`🔨 Multi-architecture build detected: ${archArgs.join(', ')}`);
} else if (args[0] === 'auto') {
if (archArgs.length === 1) {
targetArch = archArgs[0];
} else {
// Auto mode: detect from electron-builder.yml
let detectedPlatform = null;
if (builderArgs.includes('--linux')) detectedPlatform = 'linux';
else if (builderArgs.includes('--mac')) detectedPlatform = 'mac';
else if (builderArgs.includes('--win')) detectedPlatform = 'win';
const configArch = detectedPlatform ? getTargetArchFromConfig(detectedPlatform) : null;
targetArch = configArch || buildMachineArch;
}
} else {
// Explicit architecture or default to build machine
targetArch = archArgs[0] || buildMachineArch;
}
console.log(`🔨 Building for architecture: ${targetArch}`);
console.log(`📋 Builder arguments: ${builderArgs || '(none)'}`);
if (skipVite) console.log('⚡ --skip-vite: Will skip Vite compilation if output exists');
if (skipNative) console.log('⚡ --skip-native: Will skip native module rebuilding');
if (packOnly) console.log('⚡ --pack-only: Will skip electron-builder distributable creation');
if (forceBuild) console.log('⚡ --force: Force full rebuild');
const packageJsonPath = path.resolve(__dirname, '../package.json');
let restorePackageVersionOverride = () => {};
let buildFailed = false;
try {
restorePackageVersionOverride = applyDebugAutoUpdateVersionOverride(packageJsonPath);
// 1. Ensure package.json main entry is correct for electron-vite
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
if (packageJson.main !== './out/main/index.js') {
packageJson.main = './out/main/index.js';
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
}
// 2. Check if we can skip Vite build (incremental build)
const skipViteBuild = shouldSkipViteBuild(skipVite, forceBuild);
if (!skipViteBuild) {
// Run electron-vite to build all bundles (main + preload + renderer)
console.log(`📦 Building ${targetArch}...`);
execSync(`bunx electron-vite build --config packages/desktop/electron.vite.config.ts`, {
stdio: 'inherit',
shell: process.platform === 'win32',
env: {
...process.env,
ELECTRON_BUILDER_ARCH: targetArch,
},
});
// Save hash after successful build
saveCurrentHash(computeSourceHash());
} else {
console.log('📦 Using cached Vite build output');
}
// Re-bundle builtin MCP server as a fully self-contained CJS bundle so it can
// be executed by an external `node` process (no Electron ASAR support available).
// electron-vite's externalizeDepsPlugin leaves npm packages as require() calls
// which the standalone node process cannot resolve from inside app.asar.unpacked.
// Uses a dedicated script (build-mcp-servers.js) to avoid shell-quoting issues
// with special characters in esbuild --define values.
console.log('📦 Bundling builtin MCP servers (self-contained)...');
execSync(`node "${path.join(__dirname, 'build-mcp-servers.js')}"`, {
stdio: 'inherit',
shell: process.platform === 'win32',
});
// 3. Verify electron-vite output
const outDir = path.resolve(__dirname, '../out');
if (!fs.existsSync(outDir)) {
throw new Error('electron-vite did not generate out/ directory');
}
// 4. Validate output structure. This must reject source-only renderer shells;
// otherwise local fast builds can package a white-screen app.
const viteOutputValidation = validateViteBuildOutput();
if (!viteOutputValidation.valid) {
throw new Error(`Vite build output is incomplete:\n${viteOutputValidation.problems.join('\n')}`);
}
// If --pack-only, skip electron-builder distributable creation
if (packOnly) {
console.log('✅ Package completed! (skipped distributable creation)');
return;
}
// 5. Prepare aioncore binary (for packaged runtime usage)
const { prepareAioncore } = require('../packages/shared-scripts/src/prepare-aioncore.js');
const { resolveAioncoreVersion } = require('./resolveAioncoreVersion.js');
const projectRoot = path.resolve(__dirname, '..');
writeGeneratedSentryDsnInclude(projectRoot);
prepareAioncore({
projectRoot,
platform: process.platform,
arch: targetArch,
version: resolveAioncoreVersion(projectRoot),
});
// 6. Prepare hub resources (index.json + extension zips for offline fallback)
execSync('node scripts/prepareHubResources.js', { stdio: 'inherit', env: process.env });
// 6. 运行 electron-builder 生成分发包(DMG/ZIP/EXE等)
// Run electron-builder to create distributables (DMG/ZIP/EXE, etc.)
// Always disable auto-publish to avoid electron-builder's implicit tag-based publishing
// Publishing is handled by a separate release job in CI
const publishArg = '--publish=never';
// Set compression level based on environment
// 7za -mx accepts numeric values: 0 (store) to 9 (ultra)
// CI builds use 9 (maximum) for smallest size
// Local builds use 7 (normal) for 30-50% faster ASAR packing
const isCI = process.env.CI === 'true';
if (!process.env.ELECTRON_BUILDER_COMPRESSION_LEVEL) {
process.env.ELECTRON_BUILDER_COMPRESSION_LEVEL = isCI ? '9' : '7';
}
console.log(
`📦 Compression level: ${process.env.ELECTRON_BUILDER_COMPRESSION_LEVEL} (${isCI ? 'CI build' : 'local build'})`
);
// 根据模式添加架构标志
// Add arch flags based on mode
let archFlag = '';
if (multiArch) {
// 多架构模式:将所有架构标志传递给 electron-builder
// Multi-arch mode: pass all arch flags to electron-builder
archFlag = archArgs.map((arch) => `--${arch}`).join(' ');
console.log(`🚀 Packaging for multiple architectures: ${archArgs.join(', ')}...`);
} else {
// 单架构模式:使用确定的目标架构
// Single arch mode: use the determined target arch
archFlag = `--${targetArch}`;
console.log(`🚀 Creating distributables for ${targetArch}...`);
}
// 为 Windows 构建添加架构检测脚本
// Add architecture detection scripts for Windows builds
// 使用 .onVerifyInstDir 避免与 electron-builder 冲突
// Use .onVerifyInstDir to avoid conflicts with electron-builder
let nsisInclude = '';
if (builderArgs.includes('--win') || builderArgs.includes('--all')) {
if (!multiArch) {
// 单架构构建:添加对应架构的检测脚本
// Single-arch build: Add architecture-specific detection script
if (targetArch === 'arm64') {
const arm64Script = 'resources/windows/windows-installer-arm64.nsh';
if (fs.existsSync(path.resolve(__dirname, '..', arm64Script))) {
nsisInclude += ` --config.nsis.include="${arm64Script}"`;
console.log(`📋 Including Windows ARM64 architecture check script`);
}
nsisInclude += ' --config.nsis.useZip=true';
console.log('📋 Using ZIP payload for Windows ARM64 NSIS installer');
} else if (targetArch === 'x64') {
const x64Script = 'resources/windows/windows-installer-x64.nsh';
if (fs.existsSync(path.resolve(__dirname, '..', x64Script))) {
nsisInclude += ` --config.nsis.include="${x64Script}"`;
console.log(`📋 Including Windows x64 architecture check script`);
}
}
}
// 多架构构建:暂不支持架构检测脚本
// Multi-arch builds: Architecture detection not supported yet
}
if (process.platform === 'win32' && builderArgs.includes('--win')) {
const winUnpackedDir = path.join(outDir, 'win-unpacked');
let cleaned = tryRemoveDir(winUnpackedDir);
if (!cleaned) {
const aionRunning = isProcessRunningWindows('AionUi.exe');
const electronRunning = isProcessRunningWindows('electron.exe');
if (aionRunning || electronRunning) {
console.log('⚠️ Detected running AionUi/Electron process. Attempting to close...');
killWindowsProcesses(['AionUi.exe', 'electron.exe']);
cleaned = tryRemoveDir(winUnpackedDir);
if (!cleaned) {
console.log('⚠️ Directory still locked. Please close any running AionUi/Electron processes and retry.');
}
}
}
}
const isWindowsBuild = builderArgs.includes('--win') || builderArgs.includes('--all');
if (isWindowsBuild) {
patchElectronBuilderNsisInstaller();
cleanupWindowsPackOutput();
}
const builderCommand = `bunx electron-builder --config packages/desktop/electron-builder.yml ${builderArgs} ${archFlag} ${nsisInclude} ${publishArg}`;
try {
buildWithDmgRetry(builderCommand, targetArch);
} catch (error) {
const winExePath = path.join(outDir, 'win-unpacked', 'AionUi.exe');
const firstError = formatExecError(error);
const canRetryWithoutExecutableEdit =
process.platform === 'win32' && isWindowsBuild && process.env.CI !== 'true' && fs.existsSync(winExePath);
if (!canRetryWithoutExecutableEdit) {
throw error;
}
console.log('⚠️ Windows local build failed after AionUi.exe was produced.');
if (firstError) {
console.log(' First failure summary:');
console.log(
firstError
.split(/\r?\n/)
.slice(0, 6)
.map((line) => ` ${line}`)
.join('\n')
);
}
console.log(' Retrying local build with win.signAndEditExecutable=false...');
console.log(' This fallback is intended for transient rcedit / file-lock failures on developer machines.');
killWindowsProcesses(['AionUi.exe', 'electron.exe']);
cleanupWindowsPackOutput();
try {
buildWithDmgRetry(`${builderCommand} --config.win.signAndEditExecutable=false`, targetArch);
} catch (retryError) {
const retryFailure = formatExecError(retryError);
throw new Error(
[
'Windows local retry with win.signAndEditExecutable=false also failed.',
'First failure:',
firstError || String(error),
'Retry failure:',
retryFailure || String(retryError),
].join('\n')
);
}
}
console.log('✅ Build completed!');
} catch (error) {
buildFailed = true;
console.error('❌ Build failed:', error.message);
process.exitCode = 1;
} finally {
try {
restorePackageVersionOverride();
} catch (restoreError) {
console.error('❌ Failed to restore package.json version:', restoreError.message);
if (!buildFailed) {
process.exitCode = 1;
}
}
}