917 lines
34 KiB
JavaScript
917 lines
34 KiB
JavaScript
#!/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;
|
||
}
|
||
}
|
||
}
|