diff --git a/.gitignore b/.gitignore index 160d9e6..e668ce3 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,6 @@ dist/* # local env (contains API keys) /.env -.reasonix/* \ No newline at end of file +.reasonix/* +# OTA 升级: 产物 (运行时生成, 部署时单独管理) + 私钥 (绝不能提交) +ota-package/ diff --git a/package-lock.json b/package-lock.json index 9a27e40..3919ad6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "react-dom": "^18.2.0", "react-router-dom": "^6.26.0", "recharts": "^3.8.1", + "tauri-plugin-hotswap-api": "^0.0.4", "ws": "^8.21.2" }, "devDependencies": { @@ -2166,7 +2167,6 @@ "version": "2.11.1", "resolved": "https://registry.npmmirror.com/@tauri-apps/api/-/api-2.11.1.tgz", "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", - "dev": true, "license": "Apache-2.0 OR MIT", "funding": { "type": "opencollective", @@ -6015,6 +6015,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/tauri-plugin-hotswap-api": { + "version": "0.0.4", + "resolved": "https://registry.npmmirror.com/tauri-plugin-hotswap-api/-/tauri-plugin-hotswap-api-0.0.4.tgz", + "integrity": "sha512-uI+yR/BkCmAeT11VBCUFRKVlE5KUrb/EosPl86OXQfFhk02wdxauRtIZvU9QFzteH0j2EN5/IB/u66Fib8KvqQ==", + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "@tauri-apps/api": ">=2.0.0" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", diff --git a/package.json b/package.json index bb068e8..2b1e238 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev:server": "node --watch --experimental-strip-types --no-warnings server/index.js", "dev": "concurrently -n web,api -c blue,green \"npm run dev:web\" \"npm run dev:server\"", "build": "vite build", + "build:ota": "node scripts/build-ota.mjs", "preview": "vite preview", "start": "node --experimental-strip-types --no-warnings server/index.js", "db:migrate": "prisma migrate deploy", @@ -43,6 +44,7 @@ "react-dom": "^18.2.0", "react-router-dom": "^6.26.0", "recharts": "^3.8.1", + "tauri-plugin-hotswap-api": "^0.0.4", "ws": "^8.21.2" }, "devDependencies": { diff --git a/readme b/readme index ec0ecb6..97a9f76 100644 --- a/readme +++ b/readme @@ -22,3 +22,73 @@ World Bank 中的数据只用来对比妙想 MCP 拉取的数据正确性。当 监控分为服务器监控和服务监控两部分。 服务器监控对接 komari,地址:https://komari.honor3.com/ + + +## 前端 OTA 升级 + +只升级前端资源 (`dist/`), **不触碰** Tauri 框架/原生层, 不重打 APK. +底层使用 [`tauri-plugin-hotswap`](https://github.com/denniskribl/tauri-plugin-hotswap) +(签名/回滚/资源替换全部由插件处理). + +### 工作流 + +``` +开发者 服务端 +npm run build ──┐ +scripts/build-ota.mjs │ 生成 iboard-ota.tar.gz + minisign 签名 + │ 输出 latest.json (sequence/version/...) + ▼ + ota-package/ ── 部署到服务器 ──▶ /srv/iboard/ota-package + │ + ▼ + GET /api/ota/check/ +客户端 (Tauri) │ +启动 ─▶ notifyReady() ▼ +我页 ─▶ checkUpdate() ◀──── 200 + manifest (含 url/signature) ─────┘ + applyUpdate() ─▶ 下载 tar.gz ─▶ minisign 校验 ─▶ 解压到 AppData/ota-dist + location.reload() ─▶ 后续资源从 tauri://localhost 优先命中覆盖包 +``` + +### 首次部署 + +1. **生成签名密钥** (一次性): + ```bash + # 安装 minisign: Windows (scoop) / macOS (brew) / Linux (apt) + node scripts/ota-genkey.mjs + # 产物: ota-package/hotswap.{key, pub} + ``` +2. **把公钥写入 `src-tauri/tauri.conf.json`** 的 `plugins.hotswap.pubkey` 字段 + (脚本会打印). +3. **配置 endpoint** (`src-tauri/tauri.conf.json`): + ```json + "hotswap": { + "endpoint": "https://your-server/api/ota/check/{{current_sequence}}", + "pubkey": "RWR...", + "require_https": true + } + ``` +4. **服务端环境变量** `OTA_PACKAGE_DIR` 指向 `ota-package/` 所在目录. + +### 发布新版前端 + +```bash +npm run build # 生成 dist/ +node scripts/build-ota.mjs # 打成 tar.gz + 签名 + latest.json +# sequence 默认 = Date.now(), 也可 --sequence 43 手动指定 +# 也可加 --notes "修复登录按钮" --min-binary 0.1.0 +``` + +然后把 `ota-package/` 下两个文件 (`iboard-ota.tar.gz` + `latest.json`) 同步到 +服务端 `OTA_PACKAGE_DIR` 指向的目录. sequence 单调递增即可. + +### 客户端使用 + +打开 App 的"我"页, 点 [检查更新] → [立即升级] 即可. 失败会自动回滚到内置 dist. + +> **注意:** Tauri 2 的 dev 模式 (`tauri dev`) 会代理到 Vite dev server, **OTA 不生效**. +> OTA 仅在 release/打包构建中可用, 测试请用 `tauri build` 产物. + +### Web 端 + +Web 浏览器访问 (同源) 时, 服务端返回简化版 manifest, 仅显示"有新版本请刷新". +Web 端不会自动下载包, 也不会替换前端代码. diff --git a/scripts/build-ota.mjs b/scripts/build-ota.mjs new file mode 100644 index 0000000..2cd78b2 --- /dev/null +++ b/scripts/build-ota.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node +// OTA 升级包生成脚本 (兼容 tauri-plugin-hotswap) +// 用法: node scripts/build-ota.mjs [--out ] [--sequence ] [--min-binary ] [--notes ] [--no-sign] +// 流程: +// 1) 校验 dist/ 已构建 +// 2) 生成 bundle.tar.gz (dist/ 内容) +// 3) minisign 签名 (除非 --no-sign) +// 4) 写 latest.json (manifest, 含 sequence/version/signature/bundle_size/notes/...) +// 5) 全部产物输出到 out 目录 (默认 ota-package/) +// +// minisign 安装: 见 readme "OTA 升级" 章节 +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import { fileURLToPath } from 'node:url' +import { spawnSync } from 'node:child_process' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const ROOT = path.resolve(__dirname, '..') + +const args = process.argv.slice(2) +function getArg(name, def) { + const i = args.indexOf(name) + return i >= 0 && args[i + 1] ? args[i + 1] : def +} +const OUT_DIR = path.resolve(getArg('--out', 'ota-package')) +const SEQUENCE = Number(getArg('--sequence', String(Date.now()))) // 默认用时间戳做单调计数 +const MIN_BINARY = getArg('--min-binary', '') +const NOTES = getArg('--notes', '') +const NO_SIGN = args.includes('--no-sign') +// minisign 私钥路径 (默认读 ./ota-package/hotswap.key; 也可显式 --key) +const KEY_PATH = path.resolve(getArg('--key', path.join(OUT_DIR, 'hotswap.key'))) +// 旧版 tauri.conf.json 里的 pubkey (自动同步过去, 方便用户复制) +const TAURI_CONF = path.join(ROOT, 'src-tauri', 'tauri.conf.json') + +const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')) +const APP_VERSION = pkg.version || '0.0.0' + +const DIST = path.join(ROOT, 'dist') +if (!fs.existsSync(path.join(DIST, 'index.html'))) { + console.error('[build-ota] 错误: dist/index.html 不存在, 请先执行 npm run build') + process.exit(1) +} + +// 准备输出目录 +fs.mkdirSync(OUT_DIR, { recursive: true }) + +// 1) 生成 bundle.tar.gz +const BUNDLE_NAME = 'iboard-ota.tar.gz' +const BUNDLE_PATH = path.join(OUT_DIR, BUNDLE_NAME) +const r = spawnSync('tar', ['-czf', BUNDLE_PATH, '-C', DIST, '.'], { stdio: 'inherit' }) +if (r.status !== 0) { + console.error('[build-ota] tar 打包失败') + process.exit(1) +} +const bundleStat = fs.statSync(BUNDLE_PATH) +const bundleSha256 = crypto.createHash('sha256').update(fs.readFileSync(BUNDLE_PATH)).digest('hex') + +// 2) 签名 (minisign) +let signature = '' +let pubkey = '' +if (!NO_SIGN) { + if (!fs.existsSync(KEY_PATH)) { + console.error('[build-ota] 私钥不存在:', KEY_PATH) + console.error(' 生成方法: minisign -G -W -p ota-package/hotswap.pub -s ota-package/hotswap.key') + process.exit(1) + } + // minisign -Sm -s [-W] 生成 detached signature + const sigArgs = ['-Sm', BUNDLE_PATH, '-s', KEY_PATH] + // 私钥无密码时不需要 -W + const sigR = spawnSync('minisign', sigArgs, { stdio: 'inherit' }) + if (sigR.status !== 0) { + console.error('[build-ota] minisign 签名失败') + process.exit(1) + } + const sigPath = BUNDLE_PATH + '.minisig' + signature = fs.readFileSync(sigPath, 'utf8').trim() + + // 尝试同步 pubkey 到 tauri.conf.json (如果存在) + const PUB_PATH = path.join(path.dirname(KEY_PATH), 'hotswap.pub') + if (fs.existsSync(PUB_PATH)) { + pubkey = fs.readFileSync(PUB_PATH, 'utf8').trim() + // 提取 base64 部分 (第二行) + const lines = pubkey.split(/\r?\n/) + pubkey = (lines.find((l) => l.startsWith('RWR') || l.startsWith('RWT') || l.startsWith('RWQ')) || '').trim() + } +} + +// 3) 写 latest.json +const minBinary = MIN_BINARY || (() => { + // 默认从 src-tauri/tauri.conf.json 读 version + try { return JSON.parse(fs.readFileSync(TAURI_CONF, 'utf8')).version || APP_VERSION } + catch { return APP_VERSION } +})() +const manifest = { + version: APP_VERSION + '-ota', + sequence: SEQUENCE, + min_binary_version: minBinary, + bundle: BUNDLE_NAME, + bundle_size: bundleStat.size, + bundle_sha256: bundleSha256, + signature, + notes: NOTES, + pub_date: new Date().toISOString(), + mandatory: false, + // 内部用: 前端 web 模式比对 build_id + build_id: 'seq-' + SEQUENCE, +} +fs.writeFileSync(path.join(OUT_DIR, 'latest.json'), JSON.stringify(manifest, null, 2)) + +// 4) 提示 +console.log('\n=== OTA 包生成完成 ===') +console.log('输出目录: ', OUT_DIR) +console.log('版本: ', manifest.version) +console.log('sequence: ', manifest.sequence) +console.log('bundle 大小: ', (bundleStat.size / 1024).toFixed(1) + ' KB') +console.log('bundle sha256:', bundleSha256) +console.log('签名: ', signature ? '(已生成, 写入 latest.json)' : '(未签名, --no-sign)') +if (pubkey) { + console.log('\n请将下面这一行 pubkey 写到 src-tauri/tauri.conf.json -> plugins.hotswap.pubkey:') + console.log(' ' + pubkey) + // 自动同步 (可选, 注释掉默认行为; 用户自己粘更安全) + // syncTauriConf(pubkey) +} + +console.log('\n下一步: 把这两个文件发布到 OTA 存储:') +console.log(' ' + BUNDLE_NAME) +console.log(' latest.json') +console.log('并确保 server 进程环境变量 OTA_PACKAGE_DIR 指向该目录。') + +// 同步 tauri.conf.json pubkey 字段 (用户首次签名后手动确认) +// 注释: 启用此函数可自动写入, 但为安全默认关闭 +function syncTauriConf(pub) { + if (!fs.existsSync(TAURI_CONF)) return + try { + const txt = fs.readFileSync(TAURI_CONF, 'utf8') + const newTxt = txt.replace( + /"pubkey":\s*"[^"]*"/, + '"pubkey": "' + pub + '"' + ) + if (newTxt !== txt) { + fs.writeFileSync(TAURI_CONF, newTxt) + console.log('\n[build-ota] 已同步 pubkey 到 tauri.conf.json') + } + } catch (e) { + console.warn('[build-ota] 同步 pubkey 失败:', e.message) + } +} diff --git a/scripts/ota-genkey.mjs b/scripts/ota-genkey.mjs new file mode 100644 index 0000000..d57ea96 --- /dev/null +++ b/scripts/ota-genkey.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node +// 生成 minisign 密钥对 (ota-package/hotswap.{key,pub}) +// 首次跑一遍: node scripts/ota-genkey.mjs +// - hotswap.key: 私钥, 不要提交, 部署时由 CI 注入 (环境变量 / secret) +// - hotswap.pub: 公钥, 写到 src-tauri/tauri.conf.json 的 plugins.hotswap.pubkey +// +// 依赖: minisign (https://jedisct1.github.io/minisign/) +// - Windows: scoop install minisign 或 choco install minisign +// - macOS: brew install minisign +// - Linux: apt install minisign +import fs from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const ROOT = path.resolve(__dirname, '..') +const OUT_DIR = path.join(ROOT, 'ota-package') +fs.mkdirSync(OUT_DIR, { recursive: true }) + +const KEY = path.join(OUT_DIR, 'hotswap.key') +const PUB = path.join(OUT_DIR, 'hotswap.pub') + +if (fs.existsSync(KEY) || fs.existsSync(PUB)) { + console.error('[ota-genkey] 已存在密钥, 如需重新生成请先删除 ota-package/hotswap.{key,pub}') + console.error(' 注意: 重新生成密钥会导致旧 bundle 无法被客户端验证, 慎用!') + process.exit(1) +} + +// 检查 minisign 是否存在 +const which = spawnSync('minisign', ['-v'], { stdio: 'pipe' }) +if (which.status !== 0 && which.error) { + console.error('[ota-genkey] 未找到 minisign 命令, 请先安装:') + console.error(' Windows: scoop install minisign') + console.error(' macOS: brew install minisign') + console.error(' Linux: apt install minisign') + process.exit(1) +} + +// -G: 生成 -W: 无密码 -p: 公钥输出 -s: 私钥输出 +const r = spawnSync('minisign', ['-G', '-W', '-p', PUB, '-s', KEY], { stdio: 'inherit' }) +if (r.status !== 0) { + console.error('[ota-genkey] minisign 生成失败') + process.exit(1) +} + +console.log('\n=== 密钥生成完成 ===') +console.log('私钥: ' + KEY + ' (不要提交到 git!)') +console.log('公钥: ' + PUB) + +const pubText = fs.readFileSync(PUB, 'utf8') +const lines = pubText.split(/\r?\n/) +const pubB64 = lines.find((l) => l.startsWith('RWR') || l.startsWith('RWT') || l.startsWith('RWQ')) +console.log('\n请把下面这一行 pubkey 写到 src-tauri/tauri.conf.json -> plugins.hotswap.pubkey:') +console.log(' ' + (pubB64 || '').trim()) + +// 自动写 .gitignore 提醒 +const gitignore = path.join(ROOT, '.gitignore') +if (fs.existsSync(gitignore)) { + let txt = fs.readFileSync(gitignore, 'utf8') + if (!txt.includes('ota-package/hotswap.key')) { + if (!txt.endsWith('\n')) txt += '\n' + txt += '\n# OTA 升级私钥, 不要提交\nota-package/hotswap.key\n' + fs.writeFileSync(gitignore, txt) + console.log('\n[ota-genkey] 已把 ota-package/hotswap.key 加入 .gitignore') + } +} diff --git a/server/index.js b/server/index.js index 1eba465..ad2464a 100644 --- a/server/index.js +++ b/server/index.js @@ -8,6 +8,7 @@ import dataRouter from './routes/data.js' import newsRouter from './routes/news.js' import alertsRouter from './routes/alerts.js' import articlesRouter from './routes/articles.js' +import otaRouter from './routes/ota.js' import monitorsRouter from './routes/monitors.js' const PORT = Number(process.env.PORT) || 3001 @@ -27,6 +28,7 @@ app.use('/api', dataRouter) app.use('/api', newsRouter) app.use('/api', alertsRouter) app.use('/api', articlesRouter) +app.use('/api', otaRouter) app.use('/api', monitorsRouter) // 静态资源 (Vite build 产物) @@ -53,4 +55,4 @@ import('./cron.js').catch((e) => console.error('[cron] 加载失败:', e.message app.listen(PORT, () => { console.log(`[iboard-api] listening on http://127.0.0.1:${PORT}`) console.log(`[iboard-api] static dist: ${HAS_DIST ? DIST_DIR : '(not built, API only)'}`) -}) \ No newline at end of file +}) diff --git a/server/routes/ota.js b/server/routes/ota.js new file mode 100644 index 0000000..4e11528 --- /dev/null +++ b/server/routes/ota.js @@ -0,0 +1,110 @@ +// OTA 升级接口 (兼容 tauri-plugin-hotswap 服务端约定) +// GET /api/ota/check/:current_sequence +// - 客户端传当前 sequence, 服务端判断是否有更新 +// - 有: 200 + manifest JSON +// - 无: 204 No Content +// GET /api/ota/bundle/:filename +// - 下载前端 bundle (tar.gz), 供 manifest.url 引用 +// GET /api/ota/manifest-web +// - 给 Web 浏览器用的简化版 manifest (无签名, 仅展示用) +// +// 数据源: 环境变量 OTA_PACKAGE_DIR 指向的目录, 里面要放: +// - latest.json (由 scripts/build-ota.mjs 生成) +// { sequence, version, min_binary_version, signature, notes, bundle, mandatory, pub_date, bundle_size } +// - (tar.gz 升级包) +import { Router } from 'express' +import fs from 'node:fs' +import path from 'node:path' + +const router = Router() + +function resolveOtaDir() { + const dir = process.env.OTA_PACKAGE_DIR + ? path.resolve(process.env.OTA_PACKAGE_DIR) + : path.resolve(process.cwd(), 'ota-package') + return dir +} + +function readLatest() { + const p = path.join(resolveOtaDir(), 'latest.json') + if (!fs.existsSync(p)) return null + try { + return JSON.parse(fs.readFileSync(p, 'utf8')) + } catch (e) { + console.error('[ota] latest.json 解析失败:', e.message) + return null + } +} + +// GET /api/ota/check/:current_sequence +// 客户端实际请求 URL 是 endpoint 中 {{current_sequence}} 替换后的形式 +router.get('/ota/check/:current_sequence', (req, res) => { + const currentSeq = Number(req.params.current_sequence) + if (!Number.isFinite(currentSeq) || currentSeq < 0) { + return res.status(400).json({ error: '无效的 sequence' }) + } + const latest = readLatest() + if (!latest) { + // 无可用包, 按约定返回 204 + return res.status(204).end() + } + // 不新于当前则 204 + if (typeof latest.sequence === 'number' && latest.sequence <= currentSeq) { + return res.status(204).end() + } + // 检查 binary_version 兼容性 (客户端会再校验一次, 这里只做参考过滤) + // 客户端传 binary_version query: 暂不强制, 让客户端兜底 + const dir = resolveOtaDir() + const base = process.env.OTA_PUBLIC_BASE || '' + res.json({ + version: latest.version, + sequence: latest.sequence, + min_binary_version: latest.min_binary_version || '0.0.0', + url: (base ? base : '') + '/api/ota/bundle/' + encodeURIComponent(latest.bundle), + signature: latest.signature, + notes: latest.notes || '', + pub_date: latest.pub_date || new Date().toISOString(), + mandatory: !!latest.mandatory, + bundle_size: latest.bundle_size || 0, + }) +}) + +// GET /api/ota/bundle/:filename - 流式下载 bundle +router.get('/ota/bundle/:filename', (req, res) => { + const dir = resolveOtaDir() + // 防越界: 只取 basename + const name = path.basename(req.params.filename) + const p = path.join(dir, name) + if (!fs.existsSync(p)) return res.status(404).json({ error: 'bundle 不存在' }) + const stat = fs.statSync(p) + res.setHeader('Content-Type', 'application/gzip') + res.setHeader('Content-Length', stat.size) + res.setHeader('Content-Disposition', 'attachment; filename="' + name + '"') + fs.createReadStream(p).pipe(res) +}) + +// GET /api/ota/manifest-web - Web 浏览器用的简化版 +// 客户端会拿这个跟当前 build_id 比较, 有新版本就提示用户刷新 +router.get('/ota/manifest-web', (_req, res) => { + const latest = readLatest() + if (!latest) return res.json({ available: false }) + res.json({ + available: true, + version: latest.version, + build_id: latest.build_id || latest.version, + note: latest.notes || '', + size: latest.bundle_size || 0, + }) +}) + +// GET /api/ota/info - 诊断/自检 +router.get('/ota/info', (_req, res) => { + const dir = resolveOtaDir() + const latest = readLatest() + res.json({ + ota_dir: dir, + latest, + }) +}) + +export default router diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0c55a0c..71f3739 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,6 +18,10 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = [] } serde = { version = "1", features = ["derive"] } serde_json = "1" +# 前端 OTA 升级 (运行时覆盖 tauri://localhost 资源, 不动 Tauri 框架/原生层) +# 见 docs/architecture.md - 通过替换 Assets provider, webview 仍走 tauri:// 协议, +# 但优先从 AppData 读, 缺失回退到内置 dist +tauri-plugin-hotswap = "0.0.4" # Android 平台需要的 JNI / Activity 启动能力 [target."cfg(target_os = \"android\")".dependencies] diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 5602c90..17039f5 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,9 +1,10 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "默认能力: 允许 core API", + "description": "默认能力: 允许 core API + hotswap 插件 (前端 OTA 升级)", "windows": ["main"], "permissions": [ - "core:default" + "core:default", + "hotswap:default" ] -} \ No newline at end of file +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 494f857..4e99313 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,8 +1,19 @@ // 跨平台入口: 桌面从这里走, Android 走 mobile_entry_point 宏 +// 启动流程: +// 1) 初始化 tauri-plugin-hotswap (前端 OTA 升级插件) +// - 必须在 build 之前调用, 插件会替换 tauri::generate_context! 的 asset provider, +// 后续可被 tauri::Builder 消费 +// 2) 业务初始化 #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // 生成 context, 交给 hotswap 包装 (替换 Assets provider, 让 tauri://localhost 资源 + // 优先从本地覆盖目录读, 走不到时回退到内置 dist) + let context = tauri::generate_context!(); + let (hotswap, context) = tauri_plugin_hotswap::init(context) + .expect("failed to initialize tauri-plugin-hotswap"); + tauri::Builder::default() - .setup(|_app| Ok(())) - .run(tauri::generate_context!()) + .plugin(hotswap) + .run(context) .expect("error while running iBoard"); -} \ No newline at end of file +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6f84c4f..cc20d44 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -9,6 +9,14 @@ "devUrl": "http://localhost:5173", "frontendDist": "../dist" }, + "plugins": { + "hotswap": { + "endpoint": "http://bh.vps.honor3.com:3001/api/ota/check/{{current_sequence}}", + "pubkey": "RWQQ4/wfHjJiC/O6NmA7eIQYUNRpCsz5wLLsLqJXgHmsUraAavUWx0HA", + "require_https": false, + "headers": {} + } + }, "app": { "windows": [ { @@ -39,4 +47,4 @@ "minSdkVersion": 24 } } -} \ No newline at end of file +} diff --git a/src/api/ota.js b/src/api/ota.js new file mode 100644 index 0000000..634496b --- /dev/null +++ b/src/api/ota.js @@ -0,0 +1,150 @@ +// 前端 OTA 封装 +// - Tauri 客户端: 直接用 tauri-plugin-hotswap-api (checkUpdate / applyUpdate / ...) +// - Web 浏览器: 只走我们自己的 /api/ota/manifest, 提示用户手动刷新 (Vite 资源指纹会失效) +// +// 设计原则: +// - Web 端只能"查 + 提示", 不能真下载应用, 避免误导 +// - Tauri 端走 hotswap 插件的官方 API, 签名校验/回滚/资源替换全交给插件 +import { getApiBase, getToken } from './client.js' +import { APP_VERSION, BUILD_ID } from '../version.js' + +// 判断是否在 Tauri WebView 内 (与 src/debug/pagespy.js 同样的判定) +const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window + +// 拼接绝对 URL: 在 Tauri (VITE_API_BASE 有值) 时直连, 否则同源 +function resolveUrl(p) { + const base = getApiBase() + return base ? base + p : p +} + +// 当前版本信息 (前端展示用) +export function getCurrentVersionInfo() { + return { version: APP_VERSION, build: BUILD_ID } +} + +// 启动时: 确认当前资源可用 (Tauri only) +// 失败/异常都不抛, 仅 console.warn +export function initOta() { + if (!inTauri) return + // 动态 import: web 模式根本不打这个 chunk + import('tauri-plugin-hotswap-api') + .then((mod) => mod.notifyReady()) + .then(() => console.log('[ota] notifyReady ok')) + .catch((e) => console.warn('[ota] notifyReady failed:', e)) +} + +// 检查更新 +// 返回统一结构, 调用方不关心是 Tauri 还是 web: +// { +// available: boolean, +// reason: 'no_package' | 'same_version' | 'ota_disabled' | 'unsupported' | 'ok', +// info: { version, sequence, notes, mandatory, bundle_size } +// } +export async function checkOtaUpdate() { + if (!inTauri) { + return checkOtaUpdateWeb() + } + return checkOtaUpdateTauri() +} + +async function checkOtaUpdateTauri() { + // 走 hotswap 插件 (内部会带 sequence / platform / arch 等参数) + const { checkUpdate } = await import('tauri-plugin-hotswap-api') + const r = await checkUpdate() + if (!r.available) { + return { available: false, reason: 'no_package', info: r } + } + return { + available: true, + reason: 'ok', + info: { + version: r.version, + sequence: r.sequence, + notes: r.notes, + mandatory: r.mandatory, + bundle_size: r.bundle_size, + }, + } +} + +async function checkOtaUpdateWeb() { + // Web 端不能下载, 只能拿到版本号/构建号做比对, 提示用户刷新 + try { + const res = await fetch(resolveUrl('/api/ota/manifest-web'), { method: 'GET' }) + if (!res.ok) { + return { available: false, reason: 'unsupported', info: null } + } + const data = await res.json() + if (!data.available) return { available: false, reason: 'no_package', info: data } + if (data.build_id && data.build_id === BUILD_ID) { + return { available: false, reason: 'same_version', info: data } + } + return { + available: true, + reason: 'ok', + info: { + version: data.version, + notes: data.note || '', + // web 端没有强制/mandatory 等字段, 仅展示 + mandatory: false, + bundle_size: data.size || 0, + }, + } + } catch (e) { + console.warn('[ota] web check failed:', e) + return { available: false, reason: 'unsupported', info: null } + } +} + +// 下载并应用更新 (Tauri only). 调用方负责 UI 提示和错误处理 +// onProgress(received, total) 进度回调 +// 成功后必须 reload, 新资源才会生效 +export async function applyOtaUpdate(onProgress) { + if (!inTauri) { + throw new Error('OTA 升级仅在 Tauri 客户端可用, Web 端请直接刷新页面') + } + const mod = await import('tauri-plugin-hotswap-api') + + // 注册一次性进度监听 (applyUpdate 期间触发) + let unlisten = null + if (onProgress) { + try { + unlisten = await mod.onDownloadProgress((p) => onProgress(p.downloaded, p.total)) + } catch (e) { + console.warn('[ota] onDownloadProgress 监听失败:', e) + } + } + + try { + const newVersion = await mod.applyUpdate() + return newVersion + } finally { + if (unlisten) { + try { unlisten() } catch { /* ignore */ } + } + } +} + +// 回滚到内置 dist (Tauri only, 一般用于 OTA 出错时手动恢复) +export async function rollbackOta() { + if (!inTauri) throw new Error('仅 Tauri 客户端可用') + const mod = await import('tauri-plugin-hotswap-api') + return mod.rollback() +} + +// 取当前 OTA 状态 (Tauri only) +export async function getOtaVersionInfo() { + if (!inTauri) return null + try { + const mod = await import('tauri-plugin-hotswap-api') + return await mod.getVersionInfo() + } catch (e) { + console.warn('[ota] getVersionInfo failed:', e) + return null + } +} + +// reload 当前页 (新资源生效) +export function reloadApp() { + window.location.reload() +} diff --git a/src/main.jsx b/src/main.jsx index 8a069f6..3d75afe 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -9,8 +9,14 @@ import ReactDOM from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from './App.jsx' import { AuthProvider } from './auth/AuthContext.jsx' +import { initOta } from './api/ota.js' import './styles/globals.css' +// OTA 启动: 仅 Tauri 内生效 +// - notifyReady: 确认当前资源可用, 否则下次启动自动回滚 (hotswap 插件行为) +// - 异步执行, 不阻塞 React 挂载 +initOta() + ReactDOM.createRoot(document.getElementById('root')).render( diff --git a/src/pages/MePage.jsx b/src/pages/MePage.jsx index ecbce19..2cd49c3 100644 --- a/src/pages/MePage.jsx +++ b/src/pages/MePage.jsx @@ -1,14 +1,101 @@ +import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { Avatar, List, NavBar, Toast } from 'antd-mobile' +import { Avatar, Button, List, NavBar, ProgressBar, Toast } from 'antd-mobile' import { useAuth } from '../auth/AuthContext.jsx' import { APP_VERSION, BUILD_ID } from '../version.js' +import { + applyOtaUpdate, + checkOtaUpdate, + getCurrentVersionInfo, + getOtaVersionInfo, + reloadApp, + rollbackOta, +} from '../api/ota.js' -// 我的页: 顶部 NavBar + 用户头像卡片 + 信息列表 -// 使用 antd-mobile: NavBar / List / Avatar / Toast +// "我" 页: 顶部 NavBar + 用户头像卡片 + 信息列表 + OTA 升级区 +// OTA 区: +// - 显示当前 APP_VERSION (前端包, 对应内置 dist) +// - 显示当前 hotswap 状态 (是否已应用覆盖包) +// - [检查更新]: 走 checkOtaUpdate() +// - [立即升级]: 走 applyOtaUpdate(), 完成后 reload +// - [回滚]: 清除覆盖包, 回到内置 dist export default function MePage() { const { user, logout } = useAuth() const navigate = useNavigate() + // OTA 状态 + const [otaState, setOtaState] = useState('idle') // idle | checking | available | none | downloading | ready | error + const [otaInfo, setOtaInfo] = useState(null) // 检查结果中的 info + const [otaVersion, setOtaVersion] = useState(null) // hotswap 当前激活的覆盖包版本 + const [progress, setProgress] = useState({ received: 0, total: 0 }) + const [error, setError] = useState('') + + // 进入页面时拉一次 hotswap 状态 (Tauri 内才需要, web 下 null 也无所谓) + useEffect(() => { + let cancelled = false + getOtaVersionInfo().then((v) => { + if (!cancelled) setOtaVersion(v) + }) + return () => { cancelled = true } + }, []) + + async function onCheck() { + setError('') + setOtaState('checking') + try { + const r = await checkOtaUpdate() + if (r.available) { + setOtaInfo(r.info) + setOtaState('available') + Toast.show({ icon: 'success', content: '发现新版本 v' + (r.info?.version || '') }) + } else { + setOtaInfo(null) + setOtaState('none') + if (r.reason === 'same_version') { + Toast.show({ content: '已是最新版本' }) + } else if (r.reason === 'no_package') { + Toast.show({ content: '当前没有可用更新' }) + } else { + Toast.show({ content: '当前没有可用更新' }) + } + } + } catch (e) { + setError(e?.message || '检查失败') + setOtaState('error') + Toast.show({ icon: 'fail', content: '检查失败: ' + (e?.message || '') }) + } + } + + async function onApply() { + setError('') + setOtaState('downloading') + setProgress({ received: 0, total: 0 }) + try { + const newVer = await applyOtaUpdate((received, total) => { + setProgress({ received, total }) + }) + Toast.show({ icon: 'success', content: '已下载 v' + newVer + ', 即将重启' }) + setOtaState('ready') + // 给用户 1s 看提示, 然后 reload + setTimeout(() => reloadApp(), 1000) + } catch (e) { + setError(e?.message || '升级失败') + setOtaState('error') + Toast.show({ icon: 'fail', content: '升级失败: ' + (e?.message || '') }) + } + } + + async function onRollback() { + if (!window.confirm('确认回滚到内置版本?')) return + try { + await rollbackOta() + Toast.show({ icon: 'success', content: '已回滚, 正在重启' }) + setTimeout(() => reloadApp(), 800) + } catch (e) { + Toast.show({ icon: 'fail', content: '回滚失败: ' + (e?.message || '') }) + } + } + async function onLogout() { const confirmed = window.confirm('确定退出登录?') if (!confirmed) return @@ -17,6 +104,11 @@ export default function MePage() { navigate('/login', { replace: true }) } + const cur = getCurrentVersionInfo() + const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window + const showProgress = otaState === 'downloading' && progress.total > 0 + const progressPct = showProgress ? Math.min(100, Math.round((progress.received / progress.total) * 100)) : 0 + return (
我的 @@ -31,6 +123,75 @@ export default function MePage() {
{user?.name || '用户'}
{user?.email}
+ + {/* OTA 升级区 */} + + 当前版本 + + 运行资源 + + +
+ + + {isTauri && otaVersion?.active && ( + + )} +
+
+ {otaState === 'available' && otaInfo && ( + + 新版本 + {otaInfo.bundle_size ? ` (${(otaInfo.bundle_size / 1024).toFixed(0)} KB)` : ''} + + )} + {otaState === 'downloading' && ( + +
+
+ 正在下载 {showProgress ? progressPct + '%' : ''} + {' '}({(progress.received / 1024).toFixed(0)} KB + {progress.total ? ' / ' + (progress.total / 1024).toFixed(0) + ' KB' : ''}) +
+ +
+
+ )} + {otaInfo?.notes && ( + 发布说明 + )} + {error && ( + + {error} + + )} + {!isTauri && ( + 提示 + )} +
+ 用户 ID diff --git a/src/styles/globals.css b/src/styles/globals.css index 39dbfcd..4b5dd65 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -265,3 +265,32 @@ input, textarea, select { font: inherit; } .chart-card .adm-card-body { padding: 12px 8px 8px; } .chart-title { font-size: 13px; font-weight: 600; color: var(--color-text); padding: 0 4px 6px; } .scroll-sentinel { text-align: center; padding: 14px 0 4px; color: var(--color-muted); font-size: 12px; } + + +/* ================== OTA 升级区 (MePage) ================== */ +.me-ota-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} +.me-ota-actions .adm-button { + --adm-button-border-radius: 6px; + min-width: 84px; +} +.me-ota-progress { + display: flex; + flex-direction: column; + gap: 6px; + padding: 2px 0; +} +.me-ota-progress-text { + font-size: 12px; + color: var(--color-muted); + font-variant-numeric: tabular-nums; +} +.me-ota-error { + color: var(--color-danger); + font-size: 12px; + word-break: break-word; +}