// 前端 OTA 封装 // - Tauri 客户端: 直接用 tauri-plugin-hotswap-api (checkUpdate / applyUpdate / ...) // - Web 浏览器: 只走我们自己的 /api/ota/manifest, 提示用户手动刷新 (Vite 资源指纹会失效) // // 设计原则: // - Web 端只能"查 + 提示", 不能真下载应用, 避免误导 // - Tauri 端走 hotswap 插件的官方 API, 签名校验/回滚/资源替换全交给插件 import { getApiBase } from './client.js' import { APP_VERSION, BUILD_ID } from '../version.js' // 判断是否在 Tauri WebView 内 (有 __TAURI_INTERNALS__ 即 Tauri 环境) 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)) } // --------------------------------------------------------------------------- // 判新模式 (V0.2) // 「开发版本」开关 (我的页 OTA 组内, 默认关闭), 值持久化在设备端 localStorage: // 开关 关闭 (默认 / 生产口径): 用**版本号**判新 —— 取服务端 manifest 的 version, // 与当前运行版本按数字字段逐段比较大小 (0.2.0 > 0.1.0)。 // 这样即使某个旧版本分支被重新构建 (sequence 变大但版本号更小), 也不会把新设备拉回旧版。 // 开关 打开 (开发口径): 用 **build sequence** 判新 —— 直接走插件 checkUpdate(), // 只要服务端 sequence 比本地大就算新, 每次提交都能推到设备上, 便于联调。 // --------------------------------------------------------------------------- const OTA_DEV_MODE_KEY = 'iboard_ota_dev_mode' export function getOtaDevMode() { try { return localStorage.getItem(OTA_DEV_MODE_KEY) === '1' } catch { /* ignore */ } return false } export function setOtaDevMode(v) { try { localStorage.setItem(OTA_DEV_MODE_KEY, v ? '1' : '0') } catch { /* ignore */ } } // 版本号 → 数字字段数组 / 比较函数 (纯函数放在 utils, 便于单测) import { compareVersion, parseVersionFields } from '../utils/version.js' export { compareVersion, parseVersionFields } // 拉服务端最新 manifest (不分平台) // /api/ota/check/0: current_sequence 传 0, 只要有 OTA 包就一定返回 200 + manifest export async function fetchOtaManifest() { try { const res = await fetch(resolveUrl('/api/ota/check/0'), { method: 'GET' }) if (res.status === 204) return null if (!res.ok) return null return await res.json() } catch (e) { console.warn('[ota] manifest 拉取失败:', e) return null } } // 检查更新 // 返回统一结构, 调用方不关心是 Tauri 还是 web: // { // available: boolean, // reason: 'no_package' | 'same_version' | 'ota_disabled' | 'unsupported' | 'ok', // info: { version, sequence, notes, mandatory, bundle_size } // } // opts.devMode: true=按 build sequence 判新, false=按版本号判新 (默认) export async function checkOtaUpdate(opts) { const devMode = !!(opts && opts.devMode) if (!inTauri) { return checkOtaUpdateWeb(devMode) } return checkOtaUpdateTauri(devMode) } // 取"当前运行版本": 已应用 OTA 覆盖包则用覆盖包版本, 否则用内置包版本 async function currentRunningVersion() { try { const v = await getOtaVersionInfo() if (v?.active && v.version) return v.version } catch { /* ignore */ } return APP_VERSION } // 版本号口径判新 (Tauri + Web 共用): 服务端 version 比当前运行版本大才算新 async function checkByVersion() { const [manifest, current] = await Promise.all([fetchOtaManifest(), currentRunningVersion()]) if (!manifest || !manifest.version) { return { available: false, reason: 'no_package', info: null } } if (compareVersion(manifest.version, current) <= 0) { return { available: false, reason: 'same_version', info: manifest } } return { available: true, reason: 'ok', info: { version: manifest.version, sequence: manifest.sequence, notes: manifest.notes || '', mandatory: !!manifest.mandatory, bundle_size: manifest.bundle_size || 0, }, } } async function checkOtaUpdateTauri(devMode) { // 开发口径: sequence 判新, 直接走插件 (插件内部用当前 sequence 请求 /api/ota/check/:seq) if (devMode) { 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, }, } } // 生产口径: 版本号判新 return checkByVersion() } async function checkOtaUpdateWeb(devMode) { // 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 } // Web 端拿不到本地 sequence: // 开发口径 (devMode) → 只要服务端版本与本地不同就算新 (近似 sequence 语义, 含回退) // 生产口径 → 严格比较版本号大小, 只有更高版本才算新 const cmp = compareVersion(data.version, APP_VERSION) if (devMode ? cmp === 0 : cmp <= 0) { 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 let unlistenLifecycle = null if (onProgress) { try { unlisten = await mod.onDownloadProgress((p) => onProgress(p.downloaded, p.total)) } catch (e) { console.warn('[ota] onDownloadProgress 监听失败:', e) } } // 监听 lifecycle 事件, 捕获失败详情 (download-error / apply 失败时 error 字段带具体原因) // applyUpdate() 抛出的错误往往只有泛化信息, 细节藏在 lifecycle 事件里 let lifecycleError = '' try { unlistenLifecycle = await mod.onLifecycle((ev) => { if (ev.event === 'download-error' || ev.event === 'apply' || ev.event === 'check-error') { if (ev.error) { lifecycleError = ev.error console.error('[ota] lifecycle', ev.event, 'error:', ev.error) } } }) } catch (e) { console.warn('[ota] onLifecycle 监听失败:', e) } try { const newVersion = await mod.applyUpdate() return newVersion } catch (e) { // 把 lifecycle 里的详细错误拼进抛出的异常, 方便前端展示真实原因 const base = e?.message || '升级失败' const detail = lifecycleError ? ` (${lifecycleError})` : '' const err = new Error(base + detail) err.cause = e throw err } finally { if (unlisten) { try { unlisten() } catch { /* ignore */ } } if (unlistenLifecycle) { try { unlistenLifecycle() } 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() }