Files
Web2App/electron/builder.js

260 lines
8.6 KiB
JavaScript

const fs = require('fs')
const path = require('path')
const { exec, 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 runAsync(cmd, args, options) {
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) => { stdout += d.toString() })
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(fs.readFileSync(getConfigPath(projectPath), 'utf-8'))
const srcDir = getSrcDir(projectPath)
const tempDir = path.join(projectPath, '.web2app', 'temp-build')
onProgress({ stage: 'prepare', message: '准备打包环境...', percent: 5 })
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true })
}
if (isCancelled) return { success: false, cancelled: true }
onProgress({ stage: 'prepare', message: '复制模板文件...', percent: 10 })
copyTemplateSync(tempDir)
if (isCancelled) return { success: false, cancelled: true }
onProgress({ stage: 'copy-files', message: '复制前端资源...', percent: 20 })
const frontendDest = path.join(tempDir, 'frontend')
fs.mkdirSync(frontendDest, { recursive: true })
copyDirContentsSync(srcDir, frontendDest)
if (isCancelled) return { success: false, cancelled: true }
onProgress({ stage: 'configure', message: '配置应用信息...', percent: 35 })
const templatePackagePath = path.join(tempDir, 'package.json')
const templatePackage = JSON.parse(fs.readFileSync(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
}
}
fs.writeFileSync(templatePackagePath, JSON.stringify(templatePackage, null, 2))
if (isCancelled) return { success: false, cancelled: true }
onProgress({ stage: 'install', message: '正在安装依赖...', percent: 45 })
try {
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'
onProgress({ stage: 'install', message: '正在安装依赖 (首次可能需要几分钟)...', percent: 50 })
await runAsync(npmCmd, ['install', '--production'], { cwd: tempDir, timeout: 300000 })
} 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 })
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()
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.trim(), percent: 55 })
} else if (msg.includes('building')) {
onProgress({ stage: 'build', message: msg.trim(), percent: 70 })
} else {
const line = msg.trim()
if (line) {
onProgress({ stage: 'build', message: line, percent: 65 })
}
}
})
buildingProcess.stderr.on('data', (data) => {
buildOutput += data.toString()
})
buildingProcess.on('close', (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 })
const distDir = path.join(tempDir, 'dist')
let installerPath = null
if (fs.existsSync(distDir)) {
const files = fs.readdirSync(distDir)
.filter(f => f.endsWith('.exe') || f.endsWith('.dmg') || f.endsWith('.AppImage') || f.endsWith('.deb'))
.sort((a, b) => fs.statSync(path.join(distDir, b)).mtimeMs - fs.statSync(path.join(distDir, a)).mtimeMs)
if (files.length > 0) {
installerPath = path.join(distDir, files[0])
if (outputPath) {
try {
fs.copyFileSync(installerPath, outputPath)
installerPath = outputPath
} catch (e) {
}
}
}
}
resolve({ success: true, outputPath: installerPath })
})
buildingProcess.on('error', (err) => {
buildingProcess = null
resolve({ success: false, error: err.message })
})
})
}
function copyTemplateSync(destDir) {
const templateDir = path.join(__dirname, '..', 'template')
const entries = fs.readdirSync(templateDir, { withFileTypes: true })
for (const entry of entries) {
if (entry.name.startsWith('.')) continue
const srcPath = path.join(templateDir, entry.name)
const destPath = path.join(destDir, entry.name)
if (entry.isDirectory()) {
copyDirSync(srcPath, destPath)
} else {
fs.mkdirSync(path.dirname(destPath), { recursive: true })
fs.copyFileSync(srcPath, destPath)
}
}
fs.mkdirSync(path.join(destDir, 'frontend'), { recursive: true })
fs.mkdirSync(path.join(destDir, 'backend'), { recursive: true })
}
function copyDirSync(src, dest) {
fs.mkdirSync(dest, { recursive: true })
const entries = fs.readdirSync(src, { withFileTypes: true })
for (const entry of entries) {
if (entry.name.startsWith('.')) continue
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
if (entry.isDirectory()) {
copyDirSync(srcPath, destPath)
} else {
fs.copyFileSync(srcPath, destPath)
}
}
}
function copyDirContentsSync(src, dest) {
if (!fs.existsSync(src)) return
fs.mkdirSync(dest, { recursive: true })
const entries = fs.readdirSync(src, { withFileTypes: true })
for (const entry of entries) {
if (entry.name.startsWith('.')) continue
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
if (entry.isDirectory()) {
copyDirSync(srcPath, destPath)
} else {
fs.copyFileSync(srcPath, destPath)
}
}
}
module.exports = { startPack, cancelPack }