Files
2026-07-13 13:20:22 +08:00

917 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 (!/<div\s+id=["']root["']/.test(indexHtml)) {
problems.push('Renderer build output is incomplete: index.html is missing #root');
}
if (!/<script\b[^>]*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;
}
}
}