const fs = require('fs') const fsp = fs.promises const path = require('path') const { spawn } = require('child_process') const { getConfigPath, getSrcDir } = require('./project') let isCancelled = false let buildingProcess = null function cancelPack() { isCancelled = true if (buildingProcess) { try { buildingProcess.kill() } catch (e) {} buildingProcess = null } } function yieldToEventLoop() { return new Promise(resolve => setImmediate(resolve)) } async function copyTree(src, dest) { await fsp.mkdir(dest, { recursive: true }) const entries = await fsp.readdir(src, { withFileTypes: true }) for (const entry of entries) { if (entry.name.startsWith('.')) continue const s = path.join(src, entry.name) const d = path.join(dest, entry.name) if (entry.isDirectory()) { await copyTree(s, d) } else { await fsp.copyFile(s, d) } } } async function copyContents(src, dest) { if (!fs.existsSync(src)) return await fsp.mkdir(dest, { recursive: true }) const entries = await fsp.readdir(src, { withFileTypes: true }) for (const entry of entries) { if (entry.name.startsWith('.')) continue const s = path.join(src, entry.name) const d = path.join(dest, entry.name) if (entry.isDirectory()) { await copyTree(s, d) } else { await fsp.copyFile(s, d) } } } function runWithProgress(cmd, args, options, onProgress) { return new Promise((resolve, reject) => { const child = spawn(cmd, args, { ...options, shell: true, stdio: ['pipe', 'pipe', 'pipe'] }) let stdout = '' let stderr = '' child.stdout.on('data', (d) => { const text = d.toString() stdout += text const line = text.trim() if (line) { onProgress({ stage: 'install', message: line, percent: 50 }) } }) child.stderr.on('data', (d) => { stderr += d.toString() }) child.on('error', reject) child.on('close', (code) => { if (code === 0) resolve(stdout) else reject(new Error(stderr || stdout || `进程退出码 ${code}`)) }) }) } async function startPack(projectPath, outputPath, onProgress) { isCancelled = false buildingProcess = null const config = JSON.parse(await fsp.readFile(getConfigPath(projectPath), 'utf-8')) const srcDir = getSrcDir(projectPath) const tempDir = path.join(projectPath, '.web2app', 'temp-build') if (isCancelled) return { success: false, cancelled: true } onProgress({ stage: 'prepare', message: '准备打包环境...', percent: 5 }) await yieldToEventLoop() if (fs.existsSync(tempDir)) { await fsp.rm(tempDir, { recursive: true, force: true }) } if (isCancelled) return { success: false, cancelled: true } onProgress({ stage: 'prepare', message: '复制模板文件...', percent: 10 }) await yieldToEventLoop() const templateDir = path.join(__dirname, '..', 'template') await copyTree(templateDir, tempDir) if (isCancelled) return { success: false, cancelled: true } onProgress({ stage: 'copy-files', message: '复制前端资源...', percent: 20 }) await yieldToEventLoop() const frontendDest = path.join(tempDir, 'frontend') await fsp.mkdir(frontendDest, { recursive: true }) await copyContents(srcDir, frontendDest) if (isCancelled) return { success: false, cancelled: true } onProgress({ stage: 'configure', message: '配置应用信息...', percent: 35 }) await yieldToEventLoop() const templatePackagePath = path.join(tempDir, 'package.json') const templatePackage = JSON.parse(await fsp.readFile(templatePackagePath, 'utf-8')) templatePackage.name = config.name.toLowerCase().replace(/\s+/g, '-') templatePackage.productName = config.name templatePackage.version = config.version templatePackage.description = config.description || `Generated by Web2App - ${config.name}` templatePackage.author = config.author || '' templatePackage.build = { appId: `com.web2app.${templatePackage.name}`, productName: config.name, artifactName: `${config.name}-Setup-\${version}.\${ext}`, directories: { output: 'dist' }, files: [ 'main.js', 'preload.js', 'frontend/**/*', 'backend/**/*', 'node_modules/**/*' ], extraResources: config.icon ? [ { from: path.join(frontendDest, config.icon), to: 'icon.png' } ] : [], win: { target: [ { target: 'nsis', arch: ['x64'] } ], icon: config.icon ? path.join(frontendDest, config.icon) : undefined }, nsis: { oneClick: false, perMachine: false, allowToChangeInstallationDirectory: true, createDesktopShortcut: true, shortcutName: config.name }, mac: { target: ['dmg'], icon: config.icon ? path.join(frontendDest, config.icon) : undefined }, linux: { target: ['AppImage'], icon: config.icon ? path.join(frontendDest, config.icon) : undefined } } await fsp.writeFile(templatePackagePath, JSON.stringify(templatePackage, null, 2)) if (isCancelled) return { success: false, cancelled: true } onProgress({ stage: 'install', message: '正在安装依赖...', percent: 45 }) await yieldToEventLoop() try { const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm' onProgress({ stage: 'install', message: '正在安装依赖 (首次可能需要几分钟)...', percent: 50 }) await yieldToEventLoop() await runWithProgress(npmCmd, ['install', '--production'], { cwd: tempDir, timeout: 300000 }, onProgress) } catch (err) { onProgress({ stage: 'error', message: `依赖安装失败: ${err.message}`, percent: 0 }) return { success: false, error: err.message } } if (isCancelled) return { success: false, cancelled: true } onProgress({ stage: 'build', message: '正在打包生成安装包...', percent: 65 }) await yieldToEventLoop() return new Promise((resolve) => { const builderPath = path.join(tempDir, 'node_modules', '.bin', 'electron-builder') const builderCmd = process.platform === 'win32' ? `${builderPath}.cmd` : builderPath buildingProcess = spawn(builderCmd, ['--config', templatePackagePath], { cwd: tempDir, shell: true, stdio: ['pipe', 'pipe', 'pipe'] }) let buildOutput = '' buildingProcess.stdout.on('data', (data) => { buildOutput += data.toString() const msg = data.toString().trim() if (msg) { if (msg.includes('packaging')) { onProgress({ stage: 'build', message: '正在打包...', percent: 75 }) } else if (msg.includes('signing')) { onProgress({ stage: 'sign', message: '正在签名...', percent: 85 }) } else if (msg.includes('downloading')) { onProgress({ stage: 'download', message: msg, percent: 55 }) } else if (msg.includes('building')) { onProgress({ stage: 'build', message: msg, percent: 70 }) } else { onProgress({ stage: 'build', message: msg, percent: 65 }) } } }) buildingProcess.stderr.on('data', (data) => { buildOutput += data.toString() }) buildingProcess.on('close', async (code) => { buildingProcess = null if (isCancelled) { resolve({ success: false, cancelled: true }) return } if (code !== 0) { const errMsg = buildOutput.split('\n').filter(l => l.includes('error')).join('\n') || '打包失败' onProgress({ stage: 'error', message: errMsg, percent: 0 }) resolve({ success: false, error: errMsg, output: buildOutput }) return } onProgress({ stage: 'complete', message: '打包完成!', percent: 100 }) await yieldToEventLoop() const distDir = path.join(tempDir, 'dist') let installerPath = null if (fs.existsSync(distDir)) { const files = (await fsp.readdir(distDir)) .filter(f => f.endsWith('.exe') || f.endsWith('.dmg') || f.endsWith('.AppImage') || f.endsWith('.deb')) .map(f => ({ name: f, mtime: fs.statSync(path.join(distDir, f)).mtimeMs })) .sort((a, b) => b.mtime - a.mtime) .map(f => f.name) if (files.length > 0) { installerPath = path.join(distDir, files[0]) if (outputPath) { try { await fsp.copyFile(installerPath, outputPath) installerPath = outputPath } catch (e) {} } } } resolve({ success: true, outputPath: installerPath }) }) buildingProcess.on('error', (err) => { buildingProcess = null resolve({ success: false, error: err.message }) }) }) } module.exports = { startPack, cancelPack }