const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); const root = path.join(__dirname, '..'); const packageJson = require(path.join(root, 'package.json')); const buildDir = path.join(root, 'dist-release-final'); const unpackedDir = path.join(buildDir, 'win-unpacked'); const bundleDir = path.join(buildDir, 'resource-bundles'); const manifestPath = path.join(buildDir, 'resource-manifest.json'); const tempDir = path.join(root, 'build', 'oem-batch-temp'); function findIscc() { const candidates = [ process.env.INNO_SETUP_ISCC, 'C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe', 'C:\\Program Files\\Inno Setup 6\\ISCC.exe', 'ISCC.exe', ].filter(Boolean); for (const candidate of candidates) { if (candidate.includes('\\') || candidate.includes('/')) { if (fs.existsSync(candidate)) return candidate; continue; } const result = spawnSync(candidate, ['/Qp'], { encoding: 'utf8', windowsHide: true, }); if (result.status === 0 || result.status === 1) return candidate; } throw new Error('Inno Setup ISCC.exe not found.'); } function getInnoInstallDir(isccPath) { if (isccPath.includes('\\') || isccPath.includes('/')) { return path.dirname(isccPath); } const candidates = [ 'C:\\Program Files (x86)\\Inno Setup 6', 'C:\\Program Files\\Inno Setup 6', ]; return candidates.find(candidate => fs.existsSync(path.join(candidate, 'ISCC.exe'))) || ''; } function getLanguagesSection(isccPath) { const innoDir = getInnoInstallDir(isccPath); const chineseLanguageFile = innoDir ? path.join(innoDir, 'Languages', 'ChineseSimplified.isl') : ''; if (chineseLanguageFile && fs.existsSync(chineseLanguageFile)) { return '[Languages]\nName: "chinesesimp"; MessagesFile: "compiler:Languages\\\\ChineseSimplified.isl"'; } return ''; } function escapeInnoString(value) { return String(value).replace(/"/g, '""'); } function readJson(file, fallback) { if (!fs.existsSync(file)) return fallback; try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; } } function findAppExe() { if (!fs.existsSync(unpackedDir)) { throw new Error(`win-unpacked not found: ${unpackedDir}`); } const exeFiles = fs.readdirSync(unpackedDir) .filter(name => name.toLowerCase().endsWith('.exe')) .filter(name => !/^unins/i.test(name)); if (exeFiles.length === 0) { throw new Error(`No app exe found in ${unpackedDir}`); } exeFiles.sort((a, b) => { const aStat = fs.statSync(path.join(unpackedDir, a)); const bStat = fs.statSync(path.join(unpackedDir, b)); return bStat.size - aStat.size; }); return exeFiles[0]; } function getCurrentResourceArchives() { const manifest = readJson(manifestPath, { bundles: {} }); const archives = Object.values(manifest.bundles || {}) .map(bundle => bundle && bundle.archive) .filter(Boolean); const archivePaths = Array.from(new Set(archives)) .map(archive => path.join(bundleDir, archive)) .sort(); const missing = archivePaths.filter(archivePath => !fs.existsSync(archivePath)); if (archivePaths.length === 0) { throw new Error(`No resource archives found in manifest: ${manifestPath}`); } if (missing.length > 0) { throw new Error(`Missing resource archives:\n${missing.join('\n')}`); } return archivePaths; } function getCurrentResourceArchiveEntries() { const manifest = readJson(manifestPath, { bundles: {} }); return Object.entries(manifest.bundles || {}) .map(([name, bundle]) => ({ name, archive: bundle && bundle.archive, archivePath: bundle && bundle.archive ? path.join(bundleDir, bundle.archive) : '', })) .filter(entry => entry.archive && fs.existsSync(entry.archivePath)) .sort((a, b) => a.name.localeCompare(b.name)); } function ensureOfflineBundlesHydrated() { const scriptPath = path.join(__dirname, 'hydrate-oem-portable-bundles.cjs'); const result = spawnSync(process.execPath, [scriptPath], { cwd: root, encoding: 'utf8', stdio: 'inherit', windowsHide: true, }); if (result.status !== 0) { throw new Error(`hydrate-oem-portable-bundles failed with exit code ${result.status}`); } } function dirSizeBytes(dir) { let total = 0; const walk = current => { for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const full = path.join(current, entry.name); if (entry.isDirectory()) { walk(full); } else if (entry.isFile()) { total += fs.statSync(full).size; } } }; walk(dir); return total; } function main() { const appExe = findAppExe(); ensureOfflineBundlesHydrated(); const resourceArchives = getCurrentResourceArchives(); const appName = path.basename(appExe, '.exe'); const version = process.env.OEM_VERSION || process.env.APP_VERSION || packageJson.version || '1.0.0'; const outputBaseName = `${appName}-${version}-win-setup-x64`; const issPath = path.join(tempDir, 'oem-full-offline-installer.iss'); const iscc = findIscc(); const languagesSection = getLanguagesSection(iscc); fs.mkdirSync(tempDir, { recursive: true }); fs.mkdirSync(buildDir, { recursive: true }); const previousOutput = path.join(buildDir, `${outputBaseName}.exe`); if (fs.existsSync(previousOutput)) { fs.rmSync(previousOutput, { force: true }); } const iss = ` [Setup] AppId={{68912956-3e71-5e51-8eba-ab7cdc795c8c} AppName=${escapeInnoString(appName)} AppVersion=${escapeInnoString(version)} AppPublisher=ModStartLib DefaultDirName={autopf}\\${escapeInnoString(appName)} DefaultGroupName=${escapeInnoString(appName)} DisableDirPage=no DisableProgramGroupPage=yes UsePreviousAppDir=no OutputDir=${escapeInnoString(buildDir)} OutputBaseFilename=${escapeInnoString(outputBaseName)} SetupIconFile=${escapeInnoString(path.join(root, 'electron', 'resources', 'build', 'logo.ico'))} UninstallDisplayIcon={app}\\${escapeInnoString(appExe)} Compression=lzma2/max SolidCompression=no LZMAUseSeparateProcess=yes LZMADictionarySize=65536 DiskSpanning=no ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible WizardStyle=modern PrivilegesRequired=lowest CloseApplications=yes RestartApplications=no ${languagesSection} [Tasks] Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" [Files] Source: "${escapeInnoString(path.join(unpackedDir, '*'))}"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "${escapeInnoString(manifestPath)}"; DestDir: "{app}\\resources"; Flags: ignoreversion [Icons] Name: "{group}\\${escapeInnoString(appName)}"; Filename: "{app}\\${escapeInnoString(appExe)}"; WorkingDir: "{app}" Name: "{autodesktop}\\${escapeInnoString(appName)}"; Filename: "{app}\\${escapeInnoString(appExe)}"; WorkingDir: "{app}"; Tasks: desktopicon [Run] Filename: "{app}\\${escapeInnoString(appExe)}"; Description: "{cm:LaunchProgram,${escapeInnoString(appName)}}"; Flags: nowait postinstall skipifsilent `.trimStart(); fs.writeFileSync(issPath, iss, 'utf8'); const unpackedGb = dirSizeBytes(unpackedDir) / 1024 / 1024 / 1024; const archivesMb = resourceArchives.reduce((total, archivePath) => total + fs.statSync(archivePath).size, 0) / 1024 / 1024; console.log(`[inno] source: ${unpackedDir} (${unpackedGb.toFixed(2)} GB)`); console.log(`[inno] validated resource archives: ${resourceArchives.length} (${archivesMb.toFixed(2)} MB)`); console.log('[inno] resource payload mode: full offline resources copied from win-unpacked'); console.log(`[inno] output: ${previousOutput}`); const result = spawnSync(iscc, [issPath], { cwd: root, encoding: 'utf8', stdio: 'inherit', windowsHide: true, }); if (result.status !== 0) { throw new Error(`ISCC failed with exit code ${result.status}`); } const stat = fs.statSync(previousOutput); console.log(`[inno] built: ${previousOutput} (${(stat.size / 1024 / 1024).toFixed(2)} MB)`); } main();