const { execSync, spawn } = require('child_process') const path = require('path') const fs = require('fs') const crypto = require('crypto') const projectRoot = path.resolve(__dirname, '..', '..') const buildDir = path.join(projectRoot, 'package', 'build') /** 所有构建资源直接引用 package/x64 下的本地目录,不下载 */ const x64Dir = path.join(projectRoot, 'package', 'x64') /** Node 目录(源码执行与打包均使用),需先下载 Node 到 nodejs/node */ const NODE_PORTABLE_DIR = path.join(projectRoot, 'nodejs', 'node') const ELECTRON_MIRROR = process.env.ELECTRON_MIRROR /** 构建前校验:所需资源必须全部存在于 package/x64,缺一不可。不通过则直接退出,绝不下载。 */ function checkLocalResourcesOrExit() { const version = getElectronVersion() const missing = [] if (!version) { missing.push('无法读取 Electron 版本(node_modules/electron/package.json)') } else { const electronDir = path.join(x64Dir, `electron-v${version}-win32-x64`) const electronZip = path.join(x64Dir, `electron-v${version}-win32-x64.zip`) if (!fs.existsSync(electronDir) && !fs.existsSync(electronZip)) { missing.push(`Electron: 需要目录或 zip → ${path.basename(electronDir)} 或 ${path.basename(electronZip)}`) } } const nsisDir = path.join(x64Dir, 'nsis-3.0.4.1') if (!fs.existsSync(nsisDir) || !fs.statSync(nsisDir).isDirectory()) { missing.push('NSIS: 需要目录 → package/x64/nsis-3.0.4.1') } const winCodeSignDir = path.join(x64Dir, 'winCodeSign') if (!fs.existsSync(winCodeSignDir) || !fs.statSync(winCodeSignDir).isDirectory()) { missing.push('winCodeSign: 需要目录 → package/x64/winCodeSign') } if (process.platform === 'win32') { const portableExe = path.join(NODE_PORTABLE_DIR, 'node.exe') if (!fs.existsSync(portableExe)) { missing.push('Node: 需要 nodejs/node/node.exe,请先下载 Node 到 nodejs/node') } } if (missing.length === 0) return console.error('\n[错误] 本地资源不完整,禁止下载,已停止打包。请将以下资源放入 package/x64 后重试:') missing.forEach((m) => console.error(' - ' + m)) process.exit(1) } /** 确保 package/x64/winCodeSign 存在(app-builder 通过 getBin("winCodeSign") 使用),缺则退出。 */ function ensureWinCodeSignInX64OrExit() { const targetDir = path.join(x64Dir, 'winCodeSign') if (fs.existsSync(targetDir) && fs.statSync(targetDir).isDirectory()) return console.error('\n[错误] 缺少 package/x64/winCodeSign,已停止打包(禁止下载)。') process.exit(1) } /** 取当前 Electron 版本 */ function getElectronVersion() { try { return require(path.join(projectRoot, 'node_modules', 'electron', 'package.json')).version } catch (e) { return null } } /** 若存在已解压目录 package/x64/electron-vX.Y.Z-win32-x64,返回其绝对路径,供 electronDist 直接引用 */ function getUnpackedElectronDir() { const version = getElectronVersion() if (!version) return null const dir = path.join(x64Dir, `electron-v${version}-win32-x64`) return fs.existsSync(dir) ? dir : null } /** 与 @electron/get 的 Cache.getCacheDirectory 一致:用 URL 目录部分的 sha256 作为缓存子目录 */ function getElectronCacheSubdir(version) { const base = ELECTRON_MIRROR.replace(/\/$/, '') const strippedUrl = `${base}/v${version}` return crypto.createHash('sha256').update(strippedUrl).digest('hex') } /** 若未使用解压目录,则确保 package/x64 下的 Electron zip 已放入 @electron/get 使用的缓存路径 */ function ensureElectronZipInCache() { const unpacked = getUnpackedElectronDir() if (unpacked) return const version = getElectronVersion() if (!version) return const zipName = `electron-v${version}-win32-x64.zip` const zipInX64 = path.join(x64Dir, zipName) const cacheSubdir = getElectronCacheSubdir(version) const cacheDir = path.join(x64Dir, cacheSubdir) const zipInCache = path.join(cacheDir, zipName) if (!fs.existsSync(zipInX64)) return if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true }) if (fs.existsSync(zipInCache)) return try { fs.copyFileSync(zipInX64, zipInCache) } catch (_) {} } function getBuilderEnv() { ensureElectronZipInCache() ensureWinCodeSignInX64OrExit() const electronCacheRoot = x64Dir + path.sep const builderCache = path.resolve(x64Dir) process.env.ELECTRON_BUILDER_CACHE = builderCache return { ...process.env, ELECTRON_MIRROR, ELECTRON_CACHE: electronCacheRoot, electron_config_cache: electronCacheRoot, /** 直接引用 package/x64:nsis-3.0.4.1、winCodeSign 等均从本目录读取,禁止下载 */ ELECTRON_BUILDER_CACHE: builderCache, } } /** 输出到 package/build */ const X64_OUTPUT_DIR = 'package/build' const X64_UNPACKED_DIR = 'win-unpacked' /** Windows:结束可能占用输出目录的进程,避免 EBUSY。 */ function killProcessesLockingOutput() { if (process.platform !== 'win32') return const exeNames = ['AndroidRemoteController.exe', 'electron-react-vite-app.exe'] for (const name of exeNames) { try { execSync(`taskkill /F /IM "${name}" 2>nul`, { stdio: 'ignore', windowsHide: true }) } catch (_) {} } } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)) } /** 生成临时配置:仅打 dir、输出到 package/build、用本地 electronDist、不签名不 rcedit;显式包含 files/asarUnpack 确保 nodejs 打进包 */ function getElectronDistConfigPath() { const unpacked = getUnpackedElectronDir() const config = { electronDist: unpacked || undefined, directories: { output: X64_OUTPUT_DIR }, files: [ 'electron/**', 'configs/**', 'nodejs/**', '!nodejs/node/**', 'python/scripts/**', 'lib/**' ], asarUnpack: ['nodejs/**', 'configs/**', 'lib/**', 'python/scripts/**'], win: { target: [{ target: 'dir', arch: ['x64'] }], signAndEditExecutable: false } } if (!config.electronDist) delete config.electronDist const configPath = path.join(x64Dir, 'electron-builder-x64-tmp.json') fs.writeFileSync(configPath, JSON.stringify(config, null, 0), 'utf8') return configPath } function runElectronBuilder(cwd, env) { const cliPath = path.join(cwd, 'node_modules', 'electron-builder', 'out', 'cli', 'cli.js') if (!fs.existsSync(cliPath)) { return Promise.reject(new Error('未找到 electron-builder,请先在项目根目录执行 npm install。')) } const args = ['--win', '--x64', '--config', getElectronDistConfigPath()] const builderCache = path.resolve(x64Dir) const envCopy = { ...process.env, ...env } envCopy.ELECTRON_BUILDER_CACHE = builderCache if (process.platform === 'win32') { const quoted = (s) => (s.includes(' ') || s.includes('"') ? `"${String(s).replace(/"/g, '""')}"` : s) const cmdLine = `set "ELECTRON_BUILDER_CACHE=${builderCache}" && ${quoted(process.execPath)} ${quoted(cliPath)} ${args.map(quoted).join(' ')}` const opts = { cwd, stdio: ['inherit', 'inherit', 'inherit'], env: envCopy, windowsHide: false, shell: false } return new Promise((resolve, reject) => { const child = spawn('cmd', ['/c', cmdLine], opts) child.on('close', (code) => { const tmpConfig = path.join(x64Dir, 'electron-builder-x64-tmp.json') if (fs.existsSync(tmpConfig)) try { fs.unlinkSync(tmpConfig) } catch (_) {} if (code !== 0) { reject(new Error(`electron-builder 退出码: ${code}`)) } else resolve() }) child.on('error', (err) => reject(err)) }) } const opts = { cwd, stdio: ['inherit', 'inherit', 'inherit'], env: envCopy, windowsHide: false, shell: false } return new Promise((resolve, reject) => { const child = spawn(process.execPath, [cliPath, ...args], opts) child.on('close', (code) => { const tmpConfig = path.join(x64Dir, 'electron-builder-x64-tmp.json') if (fs.existsSync(tmpConfig)) try { fs.unlinkSync(tmpConfig) } catch (_) {} if (code !== 0) reject(new Error(`electron-builder 退出码: ${code}`)) else resolve() }) child.on('error', (err) => reject(err)) }) } function cleanDist(outputDir, keepDir) { const dir = path.join(projectRoot, outputDir) if (!fs.existsSync(dir)) return for (const name of fs.readdirSync(dir)) { if (name === keepDir) continue const full = path.join(dir, name) try { if (fs.statSync(full).isDirectory()) fs.rmSync(full, { recursive: true }) else fs.unlinkSync(full) } catch (e) { console.warn('clean:', e.message) } } } async function main() { killProcessesLockingOutput() await sleep(400) checkLocalResourcesOrExit() try { console.log('[1/3] Vite build...') execSync('npm run build', { cwd: projectRoot, stdio: 'inherit' }) } catch (e) { console.error('\n[错误] 第 1 步 Vite build 失败,已停止打包。') console.error(e.message || e) process.exit(1) } try { console.log('[2/3] Electron builder win x64...') await runElectronBuilder(projectRoot, getBuilderEnv()) } catch (e) { console.error('\n[错误] 第 2 步 electron-builder 失败。') console.error(e.message || e) process.exit(1) } try { const unpackedDir = path.join(projectRoot, X64_OUTPUT_DIR, X64_UNPACKED_DIR) const cp = require('child_process') // 先复制 web 到 win-unpacked/dist,再清理,避免删掉 package/build/web const webSrc = path.join(buildDir, 'web') const webDest = path.join(unpackedDir, 'dist') if (fs.existsSync(webSrc) && fs.statSync(webSrc).isDirectory()) { try { if (!fs.existsSync(webDest)) fs.mkdirSync(webDest, { recursive: true }) if (process.platform === 'win32') { cp.execSync(`xcopy /E /I /Y "${webSrc}\\*" "${webDest}"`, { stdio: 'ignore' }) } else { cp.execSync(`cp -R "${webSrc}"/* "${webDest}"`, { stdio: 'ignore' }) } } catch (_) {} } const configsSrc = path.join(projectRoot, 'configs') const configsDest = path.join(unpackedDir, 'configs') if (fs.existsSync(configsSrc) && fs.statSync(configsSrc).isDirectory()) { try { if (process.platform === 'win32') { cp.execSync(`xcopy /E /I /Y "${configsSrc}" "${configsDest}"`, { stdio: 'ignore' }) } else { cp.execSync(`cp -R "${configsSrc}" "${configsDest}"`, { stdio: 'ignore' }) } } catch (_) {} } console.log('[3/3] 清理 ' + X64_OUTPUT_DIR + '...') cleanDist(X64_OUTPUT_DIR, X64_UNPACKED_DIR) // 将 nodejs/node 打包进输出,复制到 win-unpacked/node/ 供运行时使用 const nodeDir = path.join(unpackedDir, 'node') const nodeExeName = process.platform === 'win32' ? 'node.exe' : 'node' const portableNodeExe = path.join(NODE_PORTABLE_DIR, nodeExeName) if (fs.existsSync(portableNodeExe)) { try { if (!fs.existsSync(nodeDir)) fs.mkdirSync(nodeDir, { recursive: true }) if (process.platform === 'win32') { cp.execSync(`xcopy /E /I /Y "${NODE_PORTABLE_DIR}\\*" "${nodeDir}"`, { stdio: 'ignore' }) } else { cp.execSync(`cp -R "${NODE_PORTABLE_DIR}"/* "${nodeDir}"`, { stdio: 'ignore' }) } console.log('[3/3] 已打包 nodejs/node → ' + X64_UNPACKED_DIR + '/node/') } catch (e) { console.warn('[3/3] 复制 nodejs/node 失败:', e.message) } } // adb:拷贝 lib/scrcpy-adb 到打包文件夹下,config 里配置为 projectRoot/scrcpy-adb/adb.exe const scrcpyAdbSrc = path.join(projectRoot, 'lib', 'scrcpy-adb') const scrcpyAdbDest = path.join(unpackedDir, 'scrcpy-adb') if (fs.existsSync(scrcpyAdbSrc) && fs.statSync(scrcpyAdbSrc).isDirectory()) { try { if (!fs.existsSync(scrcpyAdbDest)) fs.mkdirSync(scrcpyAdbDest, { recursive: true }) if (process.platform === 'win32') { cp.execSync(`xcopy /E /I /Y "${scrcpyAdbSrc}\\*" "${scrcpyAdbDest}"`, { stdio: 'ignore' }) } else { cp.execSync(`cp -R "${scrcpyAdbSrc}"/* "${scrcpyAdbDest}"`, { stdio: 'ignore' }) } console.log('[3/3] 已打包 lib/scrcpy-adb → ' + X64_UNPACKED_DIR + '/scrcpy-adb/') } catch (e) { console.warn('[3/3] 复制 scrcpy-adb 失败:', e.message) } } const staticSrc = path.join(x64Dir, 'static') const staticDest = path.join(unpackedDir, 'static') if (fs.existsSync(staticSrc) && fs.statSync(staticSrc).isDirectory()) { try { if (process.platform === 'win32') { cp.execSync(`xcopy /E /I /Y "${staticSrc}\\*" "${staticDest}"`, { stdio: 'ignore' }) } else { if (!fs.existsSync(staticDest)) fs.mkdirSync(staticDest, { recursive: true }) cp.execSync(`cp -R "${staticSrc}"/* "${staticDest}"`, { stdio: 'ignore' }) } console.log('[3/3] 已打包 package/x64/static → ' + X64_UNPACKED_DIR + '/static/') } catch (e) { console.warn('[3/3] 复制 static 失败:', e.message) } } // Python 整个目录拷贝到 win-unpacked/python/(含 scripts、x64),供 image-match.py、img-crop.py 等使用 const pythonSrcRoot = path.join(projectRoot, 'python') const pythonDestRoot = path.join(unpackedDir, 'python') if (fs.existsSync(pythonSrcRoot) && fs.statSync(pythonSrcRoot).isDirectory()) { try { if (!fs.existsSync(pythonDestRoot)) fs.mkdirSync(pythonDestRoot, { recursive: true }) if (process.platform === 'win32') { cp.execSync(`xcopy /E /I /Y "${pythonSrcRoot}\\*" "${pythonDestRoot}"`, { stdio: 'ignore' }) } else { cp.execSync(`cp -R "${pythonSrcRoot}"/* "${pythonDestRoot}"`, { stdio: 'ignore' }) } console.log('[3/3] 已打包 python(含 scripts、x64)→ ' + X64_UNPACKED_DIR + '/python/') } catch (e) { console.warn('[3/3] 复制 python 失败:', e.message) } } // 写入打包用 config:python 路径为 exe 同目录 python/x64,别人电脑无需安装 Python const packagedConfigPath = path.join(unpackedDir, 'configs', 'config.js') if (fs.existsSync(path.join(unpackedDir, 'configs'))) { const packagedConfig = `// 打包后使用:python 在 exe 同目录 python/x64,不依赖系统 Python const path = require('path') const projectRoot = path.dirname(process.execPath) const pythonDir = 'x64' const pythonVenvPath = path.join(projectRoot, 'python', pythonDir, 'env') module.exports = { window: { width: 800, height: 600, autoHideMenuBar: true }, devTools: { enabled: false }, vite: { port: 9527, host: 'localhost' }, pythonPath: { path: path.join(projectRoot, 'python', pythonDir) }, pythonVenvPath, adbPath: { path: path.join(projectRoot, 'scrcpy-adb', process.platform === 'win32' ? 'adb.exe' : 'adb') }, nodejsPath: 'node/' + (process.platform === 'win32' ? 'node.exe' : 'node') } ` try { fs.writeFileSync(packagedConfigPath, packagedConfig, 'utf8') console.log('[3/3] 已写入 ' + X64_UNPACKED_DIR + '/configs/config.js(python 路径: python/x64)') } catch (e) { console.warn('[3/3] 写入 configs/config.js 失败:', e.message) } } const readmePath = path.join(unpackedDir, '使用说明.txt') const readme = [ 'AndroidRemoteController - 开包即用', '', '使用:直接双击运行 AndroidRemoteController.exe', '首次运行会在本目录自动创建 static 文件夹(用于存放运行时数据)。', '', '分发:可将本「win-unpacked」整个文件夹复制到任意 Windows 电脑使用,无需安装。', '请勿单独只复制 .exe,必须复制整个文件夹。', '' ].join('\r\n') try { fs.writeFileSync(readmePath, readme, 'utf8') } catch (_) {} const runJs = `/** * 在本目录启动 exe,若有报错则写入 启动报错.txt * 用法:node run.js 或双击 run.bat */ const path = require('path') const fs = require('fs') const { spawn } = require('child_process') const dir = __dirname const errorLogPath = path.join(dir, '启动报错.txt') const exeNames = ['AndroidRemoteController.exe', 'electron-react-vite-app.exe'] let exePath = null for (const name of exeNames) { const p = path.join(dir, name) if (fs.existsSync(p)) { exePath = p break } } if (!exePath) { const msg = \`[\${new Date().toISOString()}] 未找到 exe(\${exeNames.join(' / ')})\n\` fs.writeFileSync(errorLogPath, msg, 'utf8') console.error(msg.trim()) process.exit(1) } const chunks = [] function writeErrorLog(extra) { const header = \`[\${new Date().toISOString()}] 运行 \${path.basename(exePath)} 异常\n\` const body = chunks.length ? Buffer.concat(chunks).toString('utf8') : '' const tail = extra ? \`\n\${extra}\` : '' fs.writeFileSync(errorLogPath, header + body + tail, 'utf8') } const child = spawn(exePath, [], { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: false }) child.stdout.on('data', (data) => { process.stdout.write(data) }) child.stderr.on('data', (data) => { chunks.push(data) process.stderr.write(data) }) child.on('error', (err) => { writeErrorLog(\`进程启动失败: \${err.message}\`) console.error(err) process.exit(1) }) child.on('exit', (code, signal) => { if (code !== 0 && code != null) { writeErrorLog(\`退出码: \${code}\${signal ? \` 信号: \${signal}\` : ''}\`) } }) ` // 使用同目录下 node/node.exe,不依赖系统 PATH 的 node(别人电脑未装 Node 也能用) const runBat = '@echo off\r\ncd /d "%~dp0"\r\n"%~dp0node\\node.exe" run.js\r\npause\r\n' try { fs.writeFileSync(path.join(unpackedDir, 'run.js'), runJs, 'utf8') fs.writeFileSync(path.join(unpackedDir, 'run.bat'), runBat, 'utf8') } catch (_) {} // 若 package/x64 下存在 run.js、run.bat、config.js,直接拷贝到打包文件夹,覆盖上面生成的内容 const x64RunJs = path.join(x64Dir, 'run.js') const x64RunBat = path.join(x64Dir, 'run.bat') const x64ConfigJs = path.join(x64Dir, 'config.js') if (fs.existsSync(x64RunJs)) { try { fs.copyFileSync(x64RunJs, path.join(unpackedDir, 'run.js')) console.log('[3/3] 已拷贝 package/x64/run.js → ' + X64_UNPACKED_DIR + '/run.js') } catch (e) { console.warn('[3/3] 拷贝 run.js 失败:', e.message) } } if (fs.existsSync(x64RunBat)) { try { fs.copyFileSync(x64RunBat, path.join(unpackedDir, 'run.bat')) console.log('[3/3] 已拷贝 package/x64/run.bat → ' + X64_UNPACKED_DIR + '/run.bat') } catch (e) { console.warn('[3/3] 拷贝 run.bat 失败:', e.message) } } if (fs.existsSync(x64ConfigJs) && fs.existsSync(path.join(unpackedDir, 'configs'))) { try { fs.copyFileSync(x64ConfigJs, path.join(unpackedDir, 'configs', 'config.js')) console.log('[3/3] 已拷贝 package/x64/config.js → ' + X64_UNPACKED_DIR + '/configs/config.js') } catch (e) { console.warn('[3/3] 拷贝 config.js 失败:', e.message) } } console.log('[完成] ' + X64_OUTPUT_DIR + '\\' + X64_UNPACKED_DIR) } catch (e) { console.error('\n[错误] 第 3 步 清理 ' + X64_OUTPUT_DIR + ' 失败。') console.error(e.message || e) process.exit(1) } } if (require.main === module) { main().catch((e) => { console.error(e) process.exit(1) }) } else { module.exports = { cleanDist } }