#!/usr/bin/env node // Windows 外壳「本地实时预览」: 不打包, 直接把新前端挂到已安装外壳上跑 // // 原理: 外壳用 tauri-plugin-hotswap, 启动时先看 %APPDATA%\\hotswap\ // 是否有生效的覆盖包, 有就从该目录读前端资源, 没有才回退到 exe 内置的 dist。 // 所以只要构建 dist 并写进覆盖目录, 重启外壳即可看到改动 —— 无需 tauri build, // 无需重打 NSIS/APK, 也不用重装。 // // 用法: // npm run win:dev # 构建(shell 模式) + 挂载 + 重启外壳 // npm run win:dev -- --no-build # 复用现有 dist, 只挂载+重启 (最快) // npm run win:dev -- --no-launch # 只挂载, 不重启 // npm run win:dev -- --exe "D:\path\iBoard.exe" // npm run win:dev -- --clear # 卸载覆盖, 外壳回到内置版本 // // 覆盖目录里生成的东西 (全部可删): // hotswap/seq-<时间戳>/ 本次前端 (dist 内容) + hotswap-meta.json // hotswap/current 指向 seq-<时间戳> 的指针文件 // // 注意: 覆盖目录是外壳的真实 OTA 落地目录, 挂载期间「我的」页会显示本地版本; // --clear 会清掉本脚本产生的覆盖, 恢复正常 OTA 行为。 import { spawnSync } from 'node:child_process' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const args = process.argv.slice(2) const has = (f) => args.includes(f) const val = (f, d) => { const i = args.indexOf(f); return i >= 0 && args[i + 1] ? args[i + 1] : d } const CLEAR = has('--clear') const NO_BUILD = has('--no-build') const NO_LAUNCH = has('--no-launch') const conf = JSON.parse(fs.readFileSync(path.join(root, 'src-tauri', 'tauri.conf.json'), 'utf8')) const APP_ID = conf.identifier // 覆盖目录名 = 外壳 identifier const BINARY_VERSION = conf.version // 外壳版本: min_binary_version 必须与它相等 const APP_VERSION = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version const HOTSWAP_DIR = path.join(os.homedir(), 'AppData', 'Roaming', APP_ID, 'hotswap') function log(msg) { console.log(`[win:dev] ${msg}`) } function die(msg) { console.error(`[win:dev] 错误: ${msg}`); process.exit(1) } /** 找已安装的外壳 exe */ function findExe() { const explicit = val('--exe', '') if (explicit) return explicit const candidates = [ 'C:\\Program Files\\iboard\\iBoard.exe', 'C:\\Program Files\\iBoard\\iBoard.exe', path.join(root, 'src-tauri', 'target', 'release', 'iBoard.exe'), path.join(root, 'src-tauri', 'target', 'debug', 'iBoard.exe'), ] return candidates.find((p) => fs.existsSync(p)) || '' } /** 清掉覆盖目录里本脚本(或上次 OTA)留下的生效版本 */ function clearOverride() { if (!fs.existsSync(HOTSWAP_DIR)) { log('覆盖目录不存在, 无需清理'); return } const current = path.join(HOTSWAP_DIR, 'current') if (fs.existsSync(current)) { fs.rmSync(current); log('已移除 current 指针') } let n = 0 for (const e of fs.readdirSync(HOTSWAP_DIR)) { if (/^seq-\d+$/.test(e)) { fs.rmSync(path.join(HOTSWAP_DIR, e), { recursive: true, force: true }); n++ } } log(`已移除 ${n} 个覆盖版本目录 -> 外壳将回退到内置前端`) } /** 构建前端 (shell 模式: VITE_API_BASE 指向远端后端, 与打包口径一致) */ function buildFrontend() { const vite = path.join(root, 'node_modules', 'vite', 'bin', 'vite.js') if (!fs.existsSync(vite)) die('未找到 vite, 请先 npm install') // esbuild 需要可写的临时目录; 指向仓库内, 避免系统 TEMP 权限问题 const tmp = path.join(root, '.esbuild-tmp') fs.mkdirSync(tmp, { recursive: true }) log('构建前端 (vite build --mode shell) ...') const r = spawnSync(process.execPath, [vite, 'build', '--mode', 'shell'], { cwd: root, stdio: 'inherit', env: { ...process.env, TEMP: tmp, TMP: tmp }, }) if (r.status !== 0) die('前端构建失败') } /** 把 dist 挂成生效的 hotswap 覆盖包 */ function installOverride() { const dist = path.join(root, 'dist') if (!fs.existsSync(path.join(dist, 'index.html'))) die('dist/index.html 不存在, 请先构建') fs.mkdirSync(HOTSWAP_DIR, { recursive: true }) const seq = Date.now() // 单调递增; 取大值可让 OTA 检查认为"已是最新", 不会覆盖本次本地版本 const dirName = `seq-${seq}` const dir = path.join(HOTSWAP_DIR, dirName) fs.rmSync(dir, { recursive: true, force: true }) fs.mkdirSync(dir, { recursive: true }) fs.cpSync(dist, dir, { recursive: true }) // 元数据字段见 tauri-plugin-hotswap/src/manifest.rs // min_binary_version 必须 == 外壳版本: 默认 DiscardOnUpgrade 策略在 // binary > min 时会丢弃缓存; 而安全线又要求 binary >= min, 故只能取等号 // confirmed 必须为 true: 默认 SingleLaunch 策略对未确认版本会在启动时立刻回滚 fs.writeFileSync(path.join(dir, 'hotswap-meta.json'), JSON.stringify({ version: `${APP_VERSION}-local`, sequence: seq, min_binary_version: BINARY_VERSION, confirmed: true, unconfirmed_launch_count: 0, }, null, 2)) fs.writeFileSync(path.join(HOTSWAP_DIR, 'current'), dirName) // 保留策略只留最近 2 个, 顺手清掉更早的本地版本 const olds = fs.readdirSync(HOTSWAP_DIR) .filter((e) => /^seq-\d+$/.test(e) && e !== dirName) .sort() .slice(0, -1) for (const o of olds) fs.rmSync(path.join(HOTSWAP_DIR, o), { recursive: true, force: true }) log(`已挂载覆盖包: ${dirName}`) log(` 版本 ${APP_VERSION}-local (外壳 ${BINARY_VERSION})`) return dirName } /** 结束正在运行的外壳 (需要重启才会读取覆盖包) */ function stopRunning(exe) { const r = spawnSync('tasklist', ['/FI', 'IMAGENAME eq iBoard.exe', '/NH'], { encoding: 'utf8' }) const out = r.stdout || '' if (!/iBoard\.exe/i.test(out)) return false log('检测到外壳正在运行, 结束它以便重启 ...') spawnSync('taskkill', ['/IM', 'iBoard.exe', '/F'], { stdio: 'inherit' }) return true } function launch(exe) { if (!exe) { log('未找到外壳 exe, 跳过启动。可用 --exe 指定路径, 或手动启动后再验证。') return } log(`启动: ${exe}`) // 用 Start-Process 让外壳脱离本进程, 脚本退出后应用继续运行 const ps = `Start-Process -FilePath '${exe.replace(/'/g, "''")}'` const r = spawnSync('powershell.exe', ['-NoProfile', '-Command', ps], { stdio: 'inherit' }) if (r.status !== 0) log('启动命令返回非 0, 请手动启动外壳') } // ---------- main ---------- // --no-launch 表示"不碰外壳进程": 既不结束也不启动, 只改覆盖目录 if (CLEAR) { if (!NO_LAUNCH) stopRunning() clearOverride() if (!NO_LAUNCH) launch(findExe()) else log('已清理。外壳下次启动即回到内置前端 (当前若在运行, 重启后生效)。') process.exit(0) } if (!NO_BUILD) buildFrontend() installOverride() const exe = findExe() if (!NO_LAUNCH) { stopRunning(exe) launch(exe) } else { log('已挂载。外壳下次启动生效 (当前若在运行, 重启后生效)。') } log('完成。改完 UI 后重跑 `npm run win:dev` 即可看到新版本 (想更快可加 --no-build 复用 dist)。') log('恢复到内置版本: `npm run win:dev -- --clear`')