From 999723508a05e380a04f1d5fd040c2f8b068b4ab Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 15 Aug 2026 15:53:33 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96android=20=E7=83=AD=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 ++++--- web/capacitor.config.ts | 7 +++++ web/scripts/package-app-update.mjs | 29 ++++++++++++++----- web/src/services/appUpdates.ts | 46 +++++++++++++++++++++++++----- 4 files changed, 74 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index e8d06ad..852d754 100644 --- a/README.md +++ b/README.md @@ -79,16 +79,18 @@ App 内置自建 OTA 更新器。它只更新 `dist` 中的 HTML、JavaScript、 ```powershell Set-Location web npm ci -npm run ota:package -- --version 1.0.1 +npm run ota:package ``` -ZIP 会生成到 `.artifacts/app-updates`,根目录直接包含 `index.html`。使用 +ZIP 会生成到 `.artifacts/app-updates`,根目录直接包含 `index.html`,其版本固定取自 +`versions.props` 的 `JiaowuFrontendVersion`(传入 `--version` 时也必须一致)。使用 SuperAdmin 进入“运维与审计 → App 前端热更新”,上传 ZIP,填写目标平台、通道和 兼容的原生版本后先保存为草稿,再执行发布。当前 Android 工程的 `versionName` 为 `1.0`,因此对应更新包的“兼容原生版本”应填写 `1.0`。 -App 启动后向 `/api/app-updates/latest` 检查版本,在后台下载并校验服务端提供的 -SHA-256,下次启动时切换。新资源若未能成功启动,原生更新器会自动回滚。再次发布 +App 启动、以及从后台回到前台超过 5 分钟后,会向 `/api/app-updates/latest` 检查版本, +在后台下载并校验服务端提供的 SHA-256;切到后台或下次启动时切换,并保留当前页面地址。 +新资源若未能成功启动,原生更新器会自动回滚。再次发布 已归档版本即可回滚正式通道;不同原生版本、Android/iOS、测试/正式通道彼此隔离。 更新版本元数据和 ZIP 保存在数据库中,部署新服务端版本前必须先执行 `--migrate-only`。 diff --git a/web/capacitor.config.ts b/web/capacitor.config.ts index a8d47cf..de3b832 100644 --- a/web/capacitor.config.ts +++ b/web/capacitor.config.ts @@ -13,6 +13,13 @@ const config: CapacitorConfig = { autoUpdate: 'off', version: '1.0.0', appReadyTimeout: 10000, + // A 30 MB OTA package can take longer than the plugin default on a + // mobile network. Manual checking keeps the UI responsive while this + // timeout only governs the native request/download pipeline. + responseTimeout: 60, + autoDeleteFailed: true, + autoDeletePrevious: true, + keepUrlPathAfterReload: true, }, }, } diff --git a/web/scripts/package-app-update.mjs b/web/scripts/package-app-update.mjs index a584e86..124ceec 100644 --- a/web/scripts/package-app-update.mjs +++ b/web/scripts/package-app-update.mjs @@ -8,15 +8,30 @@ import { ZipArchive } from 'archiver' const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)) const webDirectory = path.resolve(scriptDirectory, '..') const distDirectory = path.join(webDirectory, 'dist') -const packageJson = JSON.parse( - await readFile(path.join(webDirectory, 'package.json'), 'utf8'), -) const versionIndex = process.argv.indexOf('--version') -const version = - versionIndex >= 0 ? process.argv[versionIndex + 1] : packageJson.version +const requestedVersion = + versionIndex >= 0 ? process.argv[versionIndex + 1] : undefined +const versionProps = await readFile(path.join(webDirectory, '..', 'versions.props'), 'utf8') +const version = /([^<]+)<\/JiaowuFrontendVersion>/.exec( + versionProps, + )?.[1]?.trim() -if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/.test(version ?? '')) { - throw new Error('请使用 --version 1.0.1 指定语义版本。') +if (!version) { + throw new Error('versions.props 中缺少 JiaowuFrontendVersion。') +} + +if (versionIndex >= 0 && !requestedVersion) { + throw new Error('请在 --version 后提供版本号。') +} + +if (requestedVersion && requestedVersion !== version) { + throw new Error( + `更新包版本必须与 JiaowuFrontendVersion 一致(当前为 ${version})。`, + ) +} + +if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/.test(version)) { + throw new Error('JiaowuFrontendVersion 必须使用语义版本,例如 1.0.1。') } await stat(path.join(distDirectory, 'index.html')).catch(() => { diff --git a/web/src/services/appUpdates.ts b/web/src/services/appUpdates.ts index 858b174..eb1740d 100644 --- a/web/src/services/appUpdates.ts +++ b/web/src/services/appUpdates.ts @@ -18,6 +18,10 @@ interface AppUpdateCheckResponse { const updateChannel = import.meta.env.VITE_APP_UPDATE_CHANNEL?.trim() || 'production' +const foregroundCheckInterval = 5 * 60 * 1000 + +let checkInProgress: Promise | undefined +let lastCheckedAt = 0 function absoluteApiUrl(relativePath: string) { const configuredBase = String(http.defaults.baseURL ?? '/api') @@ -30,14 +34,14 @@ function absoluteApiUrl(relativePath: string) { return new URL(relativePath, apiBase).toString() } -export async function initializeAppUpdates() { - if (!Capacitor.isNativePlatform()) return +async function checkForAppUpdate() { + if (checkInProgress) return checkInProgress + if (!navigator.onLine || Date.now() - lastCheckedAt < foregroundCheckInterval) { + return + } + checkInProgress = (async () => { try { - // Only confirm the bundle after Vue has mounted successfully. If a newly - // installed bundle cannot reach this point, the native plugin rolls back. - await CapacitorUpdater.notifyAppReady() - const platform = Capacitor.getPlatform() if (platform !== 'android' && platform !== 'ios') return @@ -68,6 +72,7 @@ export async function initializeAppUpdates() { !data.version || !data.downloadUrl || !data.sha256 || + data.version === currentVersion || next?.version === data.version ) { return @@ -82,12 +87,39 @@ export async function initializeAppUpdates() { ElNotification({ title: `新版本 ${data.version} 已就绪`, - message: data.releaseNotes || '更新将在下次启动应用时自动生效。', + message: + data.releaseNotes || '切换到后台或下次启动应用时将自动启用更新。', type: 'success', duration: 6000, }) } catch (error) { // OTA failure must never stop users from entering the bundled application. console.warn('[app-update] update check failed', error) + } finally { + lastCheckedAt = Date.now() + checkInProgress = undefined } + })() + + return checkInProgress +} + +export async function initializeAppUpdates() { + if (!Capacitor.isNativePlatform()) return + + try { + // Confirm the bundle before any network request. If a newly installed + // bundle cannot reach this point, the native plugin rolls it back. + await CapacitorUpdater.notifyAppReady() + } catch (error) { + // Do not let a native-plugin error prevent the bundled app from starting. + console.warn('[app-update] bundle readiness confirmation failed', error) + } + + void checkForAppUpdate() + window.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + void checkForAppUpdate() + } + }) }