chore(dev): 新增 Windows 本地实时预览脚本 win:dev (不打包)
Docker Build and Push / build-image (push) Successful in 2m31s
Docker Build and Push / build-image (push) Successful in 2m31s
改完前端 UI 想立刻在 Windows 外壳里看效果, 原本要 tauri build 打包。实际外壳 用 tauri-plugin-hotswap, 启动时优先读 %APPDATA%\<identifier>\hotswap\ 下的覆盖 包, 没有才回退 exe 内置 dist —— 与线上 OTA 同一条路径。因此只构建前端并写进 覆盖目录 + 重启外壳即可, 不编译 Rust、不打 NSIS/便携包、不重装。 npm run win:dev 构建(shell 模式) + 挂载 + 重启 npm run win:dev -- --no-build 复用现有 dist, 只挂载+重启 (~2s) npm run win:dev -- --clear 卸载覆盖, 回到内置前端 npm run win:dev -- --no-launch 只改覆盖目录, 不碰外壳进程 读插件源码得到的两个硬约束 (写错会被静默丢弃或启动即回滚), 脚本已处理: - min_binary_version 必须 == 外壳版本: 默认 DiscardOnUpgrade 在 binary > min 时 丢弃缓存, 而安全线又要求 binary >= min, 只能取等号; - confirmed 必须为 true: 默认 SingleLaunch 对未确认版本启动即回滚。 另: 目录名须为 seq-<数字>; sequence 取 Date.now() 让 OTA 检查认为已是最新, 避免线上更新顶掉本地版本。 README 补「Windows 本地实时预览(不打包)」章节: 用法/原理/约束表/注意事项。
This commit is contained in:
@@ -274,3 +274,47 @@ Web 端不会自动下载包,也不会替换前端代码。
|
||||
- 由于只有 `master` 会部署,**迁移真正作用到生产库的唯一时机就是 master 发布**;
|
||||
开发分支上的迁移不会被自动应用,请在合并到 `master` 前确认可回滚。
|
||||
- 本地改了 `prisma/schema.prisma` 后:写迁移 SQL → `npx prisma migrate deploy` 验证 → 提交迁移目录。
|
||||
|
||||
---
|
||||
|
||||
## Windows 本地实时预览(不打包)
|
||||
|
||||
改完前端 UI 想立刻在 **Windows 外壳**里看效果,不必 `tauri build`、不必打 NSIS/便携包、也不必重装。
|
||||
|
||||
```bash
|
||||
npm run win:dev # 构建(shell 模式) + 挂载 + 重启外壳
|
||||
npm run win:dev -- --no-build # 复用现有 dist, 只挂载+重启 (最快, 约 2 秒)
|
||||
npm run win:dev -- --no-launch # 只挂载, 自己手动启动外壳
|
||||
npm run win:dev -- --exe "D:\path\iBoard.exe"
|
||||
npm run win:dev -- --clear # 卸载覆盖, 外壳回到内置前端
|
||||
```
|
||||
|
||||
### 原理
|
||||
|
||||
外壳用 `tauri-plugin-hotswap`,启动时**先看覆盖目录有没有生效的前端包,没有才回退到 exe 内置的 `dist`**。
|
||||
这和线上 OTA 走的是同一条路径,所以只要把新构建的 `dist` 写进覆盖目录再重启外壳即可:
|
||||
|
||||
```
|
||||
%APPDATA%\com.iboard.app\hotswap\
|
||||
current ← 文本文件, 内容为生效的目录名 (如 seq-1793068631298)
|
||||
seq-1793068631298\ ← 本次前端 (dist 内容) + hotswap-meta.json
|
||||
```
|
||||
|
||||
`hotswap-meta.json` 里有两个字段是**硬约束**,改错会导致覆盖包被丢弃或立刻回滚:
|
||||
|
||||
| 字段 | 取值 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| `min_binary_version` | **必须等于**外壳版本(当前 `0.2.1`) | 默认策略 `DiscardOnUpgrade` 在 `binary > min` 时丢弃缓存;安全线又要求 `binary >= min`,故只能取等号 |
|
||||
| `confirmed` | **必须为 `true`** | 默认策略 `SingleLaunch` 对未确认版本会在启动时立即回滚 |
|
||||
|
||||
另外 `sequence` 取 `Date.now()`(大值),让 OTA 检查认为"已是最新",避免线上更新把本地版本顶掉。
|
||||
|
||||
### 注意
|
||||
|
||||
- 覆盖目录就是外壳**真实的 OTA 落地目录**,挂载期间"我的"页会显示本地版本(`0.2.1-local`);
|
||||
测完建议 `npm run win:dev -- --clear` 复原,避免影响后续正常 OTA。
|
||||
- 脚本按 `src-tauri/tauri.conf.json` 的 `identifier` 推导覆盖目录,换 identifier 后无需改脚本。
|
||||
- 这只适用于**前端**改动。改了 `src-tauri/`(Rust/原生层、权限、CSP)必须重新打包外壳。
|
||||
- 想要带 HMR 的完整开发循环(改代码即时刷新、免手动重启)用 `npm run tauri:dev`,
|
||||
代价是首次要编译一次 debug 版 Rust 二进制。
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"build:shell": "vite build --mode shell",
|
||||
"build:win": "node scripts/build-windows.mjs",
|
||||
"build:win:debug": "node scripts/build-windows.mjs --debug",
|
||||
"win:dev": "node scripts/dev-windows-live.mjs",
|
||||
"build:ota": "node scripts/build-ota.mjs",
|
||||
"preview": "vite preview",
|
||||
"start": "node --experimental-strip-types --no-warnings server/index.js",
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env node
|
||||
// Windows 外壳「本地实时预览」: 不打包, 直接把新前端挂到已安装外壳上跑
|
||||
//
|
||||
// 原理: 外壳用 tauri-plugin-hotswap, 启动时先看 %APPDATA%\<identifier>\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`')
|
||||
Reference in New Issue
Block a user