271 lines
8.5 KiB
JavaScript
271 lines
8.5 KiB
JavaScript
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 }
|