diff --git a/README.md b/README.md index 0829686..9b0f087 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ npm run build:app Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查海外 PDA 的独立更新通道,员工也可在“个人中心 → 检查更新”手动触发;H5 仅展示 App 操作提示,不执行更新。更新服务接收 `appid`、`platform`、`appVersion`、`appVersionCode`、`wgtVersion` 查询参数,无更新返回 `{ "update": false }`,有更新返回: +HBuilderX 真机调试及标准基座使用当前工程 `src/manifest.json` 中的 AppID、版本名和版本码模拟更新检查,避免把 HBuilderX 基座版本作为业务版本上报;正式 APK 仍使用实际安装包版本码及独立的 WGT 资源版本。调试检查同样只读取后台已发布版本,上传草稿不会触发更新;调试基座不能代替正式 APK 的覆盖安装与签名验收。 + ```json { "update": true, diff --git a/src/utils/app-update.js b/src/utils/app-update.js index fdb464d..b7b3839 100644 --- a/src/utils/app-update.js +++ b/src/utils/app-update.js @@ -1,3 +1,5 @@ +import appManifest from '@/manifest.json' + const updateEndpoint = String(import.meta.env.VITE_PDA_UPDATE_URL || '').trim() let checking = false @@ -15,20 +17,40 @@ function getRuntimeInfo() { return } + // 标准真机基座的版本属于 HBuilderX;调试更新按当前工程版本模拟。 + const debugBase = import.meta.env.DEV || uni.getAppBaseInfo().packageName === 'io.dcloud.HBuilder' + const runtime = { + appid: String((debugBase ? appManifest.appid : plus.runtime.appid) || ''), + appVersion: String((debugBase ? appManifest.versionName : plus.runtime.version) || ''), + appVersionCode: Number(debugBase ? appManifest.versionCode : plus.runtime.versionCode) + } + if (!runtime.appid || !runtime.appVersion || !Number.isInteger(runtime.appVersionCode) || runtime.appVersionCode < 1) { + reject(new Error('无法读取当前应用版本,请重新运行 App')) + return + } + if (debugBase) { + resolve({ ...runtime, wgtVersion: runtime.appVersion }) + return + } + + // 正式 APK 始终保留原生基座版本码,WGT 升级只改变资源版本。 plus.runtime.getProperty(plus.runtime.appid, (properties) => { - resolve({ - appid: plus.runtime.appid, - appVersion: String(plus.runtime.version || ''), - appVersionCode: Number(plus.runtime.versionCode || 0), - wgtVersion: String(properties?.version || plus.runtime.version || '') - }) + resolve({ ...runtime, wgtVersion: String(properties?.version || runtime.appVersion) }) }) }) } function normalizeUpdate(payload, runtime) { const value = payload && typeof payload === 'object' ? payload : {} - if (value.update !== true) return null + if ('code' in value && String(value.code) !== '200') { + const error = new Error(String(value.message || '检查更新失败,请稍后重试')) + error.code = value.code + error.requestId = value.requestId + error.traceId = value.traceId + throw error + } + if (typeof value.update !== 'boolean') throw new Error('更新服务返回了无效的版本信息') + if (!value.update) return null if (value.appid !== runtime.appid) throw new Error('更新包与当前应用不匹配') const packageType = String(value.packageType || '').toLowerCase() @@ -110,20 +132,45 @@ function downloadUpdate(update) { }) } +function prepareUpdateFile(filePath, update) { + return new Promise((resolve, reject) => { + const fail = (cause) => { + const error = new Error('更新包文件准备失败,请重新下载') + error.stage = 'prepare' + error.code = cause?.code + reject(error) + } + // 下载接口以 /package 结尾,临时文件可能没有安装器要求的扩展名。 + const filename = `overseas-pda-${update.versionName}-${Date.now()}.${update.packageType}` + plus.io.resolveLocalFileSystemURL(filePath, (entry) => { + entry.getParent((parent) => { + entry.moveTo(parent, filename, (file) => resolve(file.toLocalURL()), fail) + }, fail) + }, fail) + }) +} + function installUpdate(filePath) { return new Promise((resolve, reject) => { plus.runtime.install( filePath, { force: false }, resolve, - () => reject(new Error('更新包安装失败,请重新下载')) + (cause) => { + const code = cause?.code == null ? '' : `(${cause.code})` + const reason = String(cause?.message || '请重新下载后重试') + const error = new Error(`更新包安装失败${code}:${reason}`) + error.stage = 'install' + error.code = cause?.code + reject(error) + } ) }) } function showFailure(error, mandatory) { const message = error?.message || '检查更新失败,请稍后重试' - if (!mandatory) { + if (!mandatory && error?.stage !== 'install' && error?.stage !== 'prepare') { uni.showToast({ title: message, icon: 'none', duration: 3000 }) return Promise.resolve(false) } @@ -132,9 +179,9 @@ function showFailure(error, mandatory) { uni.showModal({ title: '更新失败', content: message, - confirmText: '重新尝试', + confirmText: mandatory ? '重新尝试' : '知道了', showCancel: false, - complete: () => resolve(true) + complete: () => resolve(mandatory) }) }) } @@ -145,7 +192,8 @@ async function runUpdate(update) { try { const filePath = await downloadUpdate(update) - await installUpdate(filePath) + const installPath = await prepareUpdateFile(filePath, update) + await installUpdate(installPath) if (update.packageType === 'wgt') { uni.showModal({ title: '更新完成', @@ -184,7 +232,7 @@ export async function checkForAppUpdate({ manual = false } = {}) { }) if (response.statusCode !== 200) throw new Error('更新服务暂时不可用') - const update = normalizeUpdate(response.data?.data ?? response.data, runtime) + const update = normalizeUpdate(response.data, runtime) if (update && isNewerUpdate(update, runtime)) { await runUpdate(update) } else if (manual) { diff --git a/tests/app-update.test.mjs b/tests/app-update.test.mjs index 7f56fd1..48942f9 100644 --- a/tests/app-update.test.mjs +++ b/tests/app-update.test.mjs @@ -1,9 +1,82 @@ import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import test from 'node:test' +import { runInNewContext } from 'node:vm' const read = (path) => readFile(new URL(path, import.meta.url), 'utf8') +async function createUpdater({ development = true, packageName = 'io.dcloud.HBuilder', + manifest = { appid: '__UNI__936D079', versionName: '0.0.4', versionCode: 4 }, + runtime = {}, resourceVersion = '0.0.4', response = { update: false }, acceptUpdate = false, + downloadResult = { statusCode: 200, tempFilePath: '_doc/temp/download/package' }, + fileErrorAt, installError } = {}) { + const calls = { requests: [], modals: [], toasts: [], downloads: [], moves: [], installs: [], restarts: 0 } + const source = (await read('../src/utils/app-update.js')) + .replace(/^import .*$/gm, '') + .replaceAll('import.meta.env.VITE_PDA_UPDATE_URL', 'updateUrl') + .replaceAll('import.meta.env.DEV', 'development') + .replace('export async function checkForAppUpdate', 'async function checkForAppUpdate') + const updater = runInNewContext(`${source}\n;({ getRuntimeInfo, checkForAppUpdate })`, { + appManifest: manifest, development, updateUrl: 'https://updates.example.test/pda/overseas/latest', + plus: { + io: { + resolveLocalFileSystemURL(path, success, fail) { + const fileError = { code: 10, message: 'file operation failed' } + if (fileErrorAt === 'resolve') return fail(fileError) + const parent = {} + success({ + getParent(success, fail) { fileErrorAt === 'parent' ? fail(fileError) : success(parent) }, + moveTo(destination, name, success, fail) { + assert.equal(destination, parent) + calls.moves.push({ path, name }) + if (fileErrorAt === 'move') return fail(fileError) + success({ toLocalURL: () => `_doc/temp/download/${name}` }) + } + }) + } + }, + runtime: { appid: 'HBuilder', version: '5.24.0', versionCode: 52400, + getProperty(_appid, callback) { callback({ version: resourceVersion }) }, + install(path, options, success, fail) { + calls.installs.push({ path, options }) + if (installError) return fail(installError) + if (!/\.(wgt|apk)$/.test(path)) return fail({ code: -1201, message: 'WGT/WGTU文件格式错误' }) + success() + }, + restart() { calls.restarts += 1 }, ...runtime + } + }, + uni: { + getAppBaseInfo: () => ({ packageName }), + request(options) { + calls.requests.push(options) + options.success({ statusCode: 200, data: typeof response === 'function' ? response(options.data) : response }) + }, + downloadFile(options) { + calls.downloads.push(options) + options.success(downloadResult) + options.complete() + return { onProgressUpdate() {} } + }, + showLoading() {}, hideLoading() {}, + showModal(options) { + calls.modals.push(options) + options.success?.({ confirm: acceptUpdate && options.confirmText === '立即更新' }) + options.complete?.() + }, + showToast(options) { calls.toasts.push(options) } + } + }) + return { ...updater, calls } +} + +const publishedApk = { update: true, appid: '__UNI__936D079', packageType: 'apk', + versionName: '0.0.5', versionCode: 5, minAppVersionCode: 5, + downloadUrl: 'https://updates.example.test/overseas-0.0.5.apk', mandatory: false } + +const publishedWgt = { ...publishedApk, packageType: 'wgt', minAppVersionCode: 4, + downloadUrl: 'https://updates.example.test/overseas-0.0.5.wgt', releaseNotes: '更新测试' } + test('App-Plus checks the independent overseas update channel after launch', async () => { const [app, updater, env] = await Promise.all([ read('../src/App.vue'), @@ -53,3 +126,148 @@ test('overseas profile exposes manual App update checking and the runtime versio assert.match(profile, /checkForAppUpdate\(\{ manual: true \}\)/) assert.match(profile, /#ifndef APP-PLUS[\s\S]*?请在 PDA App 中检查更新/) }) + +test('HBuilderX 0.0.4 checks the project version and offers a published 0.0.5 APK', async () => { + const updater = await createUpdater({ response: query => query.appVersionCode < 5 ? publishedApk : { update: false } }) + await updater.checkForAppUpdate({ manual: true }) + const query = updater.calls.requests[0].data + assert.equal(query.appid, '__UNI__936D079') + assert.equal(query.appVersion, '0.0.4') + assert.equal(query.appVersionCode, 4) + assert.equal(query.wgtVersion, '0.0.4') + assert.equal(updater.calls.modals[0]?.title, '发现更新 0.0.5') + assert.equal(updater.calls.toasts.length, 0) +}) + +test('standard HBuilder base uses project versions when running production WGT resources', async () => { + const updater = await createUpdater({ development: false }) + const info = await updater.getRuntimeInfo() + assert.equal(info.appid, '__UNI__936D079') + assert.equal(info.appVersionCode, 4) + assert.equal(info.appVersion, '0.0.4') +}) + +test('released APK keeps its native base version after a WGT version increase', async () => { + const updater = await createUpdater({ development: false, packageName: 'com.baiyuelogistics.pda.overseas', + manifest: { appid: '__UNI__936D079', versionName: '0.0.5', versionCode: 5 }, + runtime: { appid: '__UNI__936D079', version: '0.0.4', versionCode: 4 }, resourceVersion: '0.0.5' }) + const info = await updater.getRuntimeInfo() + assert.equal(info.appVersionCode, 4) + assert.equal(info.appVersion, '0.0.4') + assert.equal(info.wgtVersion, '0.0.5') +}) + +test('HBuilderX 0.0.4 can detect a compatible published 0.0.5 WGT', async () => { + const updater = await createUpdater({ response: publishedWgt }) + await updater.checkForAppUpdate({ manual: true }) + assert.equal(updater.calls.requests[0].data.appVersionCode, 4) + assert.equal(updater.calls.requests[0].data.wgtVersion, '0.0.4') + assert.equal(updater.calls.modals[0]?.title, '发现更新 0.0.5') + assert.equal(updater.calls.modals[0]?.content, '更新测试') +}) + +test('released APK 0.0.4 shows the published 0.0.5 WGT release notes', async () => { + const updater = await createUpdater({ development: false, packageName: 'com.baiyuelogistics.pda.overseas', + runtime: { appid: '__UNI__936D079', version: '0.0.4', versionCode: 4 }, response: publishedWgt }) + await updater.checkForAppUpdate({ manual: true }) + assert.equal(updater.calls.modals[0]?.title, '发现更新 0.0.5') + assert.equal(updater.calls.modals[0]?.content, '更新测试') +}) + +test('an APK 0.0.4 with WGT 0.0.5 does not offer that WGT again', async () => { + const updater = await createUpdater({ development: false, packageName: 'com.baiyuelogistics.pda.overseas', + runtime: { appid: '__UNI__936D079', version: '0.0.4', versionCode: 4 }, + resourceVersion: '0.0.5', response: publishedWgt }) + await updater.checkForAppUpdate({ manual: true }) + assert.equal(updater.calls.modals.length, 0) + assert.equal(updater.calls.toasts[0]?.title, '当前已是最新版本') +}) + +test('a WGT requiring APK 5 is rejected on a released APK 4 after resource updates', async () => { + const updater = await createUpdater({ development: false, packageName: 'com.baiyuelogistics.pda.overseas', + manifest: { appid: '__UNI__936D079', versionName: '0.0.5', versionCode: 5 }, + runtime: { appid: '__UNI__936D079', version: '0.0.4', versionCode: 4 }, + resourceVersion: '0.0.5', response: { ...publishedWgt, versionName: '0.0.6', minAppVersionCode: 5 } }) + await updater.checkForAppUpdate({ manual: true }) + assert.equal(updater.calls.modals.length, 0) + assert.equal(updater.calls.toasts[0]?.title, '当前安装包不支持此热更新,请安装新版 APK') +}) + +test('an unreadable native APK version is reported instead of claiming the latest version', async () => { + const updater = await createUpdater({ development: false, packageName: 'com.baiyuelogistics.pda.overseas', + runtime: { appid: '__UNI__936D079', version: '0.0.4', versionCode: 0 } }) + await updater.checkForAppUpdate({ manual: true }) + assert.equal(updater.calls.requests.length, 0) + assert.equal(updater.calls.toasts[0]?.title, '无法读取当前应用版本,请重新运行 App') +}) + +test('update service business errors are shown instead of claiming the latest version', async () => { + const updater = await createUpdater({ response: { code: 'S0503', message: 'PDA 发布通道未初始化', data: null } }) + await updater.checkForAppUpdate({ manual: true }) + assert.equal(updater.calls.toasts[0]?.title, 'PDA 发布通道未初始化') + assert.equal(updater.calls.modals.length, 0) +}) + +test('invalid update responses are rejected instead of claiming the latest version', async () => { + const updater = await createUpdater({ response: {} }) + await updater.checkForAppUpdate({ manual: true }) + assert.equal(updater.calls.toasts[0]?.title, '更新服务返回了无效的版本信息') +}) + +test('unpublished versions remain unavailable to the device', async () => { + const updater = await createUpdater({ response: { update: false } }) + await updater.checkForAppUpdate({ manual: true }) + assert.equal(updater.calls.modals.length, 0) + assert.equal(updater.calls.toasts[0]?.title, '当前已是最新版本') +}) + +test('a downloaded extensionless WGT is renamed before install and restarts on success', async () => { + const updater = await createUpdater({ response: publishedWgt, acceptUpdate: true }) + await updater.checkForAppUpdate({ manual: true }) + assert.equal(updater.calls.downloads[0]?.url, publishedWgt.downloadUrl) + assert.equal(updater.calls.moves[0]?.path, '_doc/temp/download/package') + assert.match(updater.calls.moves[0]?.name, /^overseas-pda-0\.0\.5-\d+\.wgt$/) + assert.equal(updater.calls.installs[0]?.path, `_doc/temp/download/${updater.calls.moves[0].name}`) + assert.equal(updater.calls.installs[0]?.options.force, false) + assert.equal(updater.calls.modals[1]?.title, '更新完成') + assert.equal(updater.calls.restarts, 1) +}) + +test('an extensionless APK receives the APK suffix and does not restart as a WGT', async () => { + const updater = await createUpdater({ response: publishedApk, acceptUpdate: true }) + await updater.checkForAppUpdate({ manual: true }) + assert.match(updater.calls.installs[0]?.path, /\.apk$/) + assert.equal(updater.calls.installs[0]?.options.force, false) + assert.equal(updater.calls.restarts, 0) +}) + +test('native installation errors remain visible with their actual reason and code', async () => { + const updater = await createUpdater({ response: publishedWgt, acceptUpdate: true, + installError: { code: -1205, message: 'WGT安装包中manifest.json文件的version版本不匹配' } }) + await updater.checkForAppUpdate({ manual: true }) + assert.equal(updater.calls.modals[1]?.title, '更新失败') + assert.match(updater.calls.modals[1]?.content, /-1205/) + assert.match(updater.calls.modals[1]?.content, /version版本不匹配/) + assert.equal(updater.calls.restarts, 0) + assert.equal(updater.calls.installs.length, 1) +}) + +test('unreadable or unrenamable downloads never reach the native installer', async () => { + for (const fileErrorAt of ['resolve', 'parent', 'move']) { + const updater = await createUpdater({ response: publishedWgt, acceptUpdate: true, fileErrorAt }) + await updater.checkForAppUpdate({ manual: true }) + assert.equal(updater.calls.installs.length, 0, fileErrorAt) + assert.equal(updater.calls.restarts, 0, fileErrorAt) + assert.equal(updater.calls.modals[1]?.title, '更新失败', fileErrorAt) + assert.match(updater.calls.modals[1]?.content, /更新包文件准备失败/, fileErrorAt) + } +}) + +test('an unsuccessful HTTP download never prepares or installs an update', async () => { + const updater = await createUpdater({ response: publishedWgt, acceptUpdate: true, + downloadResult: { statusCode: 404, tempFilePath: '_doc/temp/download/package' } }) + await updater.checkForAppUpdate({ manual: true }) + assert.equal(updater.calls.moves.length, 0) + assert.equal(updater.calls.installs.length, 0) + assert.match(updater.calls.toasts[0]?.title, /下载失败(404)/) +}) diff --git a/tests/h5-deployment.test.mjs b/tests/h5-deployment.test.mjs index 101b376..c244aff 100644 --- a/tests/h5-deployment.test.mjs +++ b/tests/h5-deployment.test.mjs @@ -1,7 +1,11 @@ import test from 'node:test' import { runInNewContext } from 'node:vm' import assert from 'node:assert/strict' -import { readFile, readdir } from 'node:fs/promises' +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { loadEnv } from 'vite' const read = path => readFile(new URL(path, import.meta.url), 'utf8') test('PDA H5 has no container publishing entry', async () => { const files = await readdir(new URL('../deploy/', import.meta.url)) @@ -28,10 +32,12 @@ test('host nginx serves uploaded files and proxies only the API prefix', async ( test('only H5 production overrides the toolchain asset base', async () => { const source = (await read('../vite.config.js')) .replace(/^import .*$/gm, '') + .replaceAll('import.meta.url', 'configUrl') .replace('export default ', '') for (const platform of ['h5', 'app', 'app-plus', 'app-harmony', undefined]) { for (const mode of ['production', 'development']) { const factory = runInNewContext(source, { + URL, fileURLToPath, configUrl: new URL('../vite.config.js', import.meta.url).href, defineConfig: value => value, loadEnv: () => ({}), uni: () => ({}), @@ -45,8 +51,10 @@ test('only H5 production overrides the toolchain asset base', async () => { }) test('H5 proxy uses the shared gateway prefix and preserves API suffixes', async () => { - const source = (await read('../vite.config.js')).replace(/^import .*$/gm, '').replace('export default ', '') + const source = (await read('../vite.config.js')).replace(/^import .*$/gm, '') + .replaceAll('import.meta.url', 'configUrl').replace('export default ', '') const factory = runInNewContext(source, { + URL, fileURLToPath, configUrl: new URL('../vite.config.js', import.meta.url).href, defineConfig: value => value, loadEnv: () => ({ API_PROXY_TARGET: 'https://gateway.example.com' }), uni: () => ({}), process: { env: { UNI_PLATFORM: 'h5' }, cwd: () => '/project' } }) @@ -57,3 +65,24 @@ test('H5 proxy uses the shared gateway prefix and preserves API suffixes', async assert.equal(proxy['/gateway-api'].rewrite(`/gateway-api${suffix}`), `/api${suffix}`) } }) + +test('App update configuration loads from the project when HBuilderX starts elsewhere', async t => { + const fixturePrefix = join(tmpdir(), 'overseas-pda-env-') + const fixtureDir = await mkdtemp(fixturePrefix) + assert.ok(resolve(fixtureDir).startsWith(resolve(fixturePrefix))) + t.after(() => rm(fixtureDir, { recursive: true, force: true })) + const endpoint = 'https://updates.example.test/pda/overseas/latest' + await writeFile(join(fixtureDir, '.env.local'), `VITE_PDA_UPDATE_URL=${endpoint}\n`) + const source = (await read('../vite.config.js')).replace(/^import .*$/gm, '') + .replaceAll('import.meta.url', 'configUrl').replace('export default ', '') + const factory = runInNewContext(source, { + URL, fileURLToPath, configUrl: pathToFileURL(join(fixtureDir, 'vite.config.js')).href, + defineConfig: value => value, loadEnv, uni: () => ({}), + process: { env: { UNI_PLATFORM: 'app' }, cwd: () => join(fixtureDir, 'HBuilderX') } + }) + for (const mode of ['development', 'production']) { + const config = factory({ mode }) + assert.equal(JSON.parse(config.define['import.meta.env.VITE_PDA_UPDATE_URL']), endpoint) + assert.equal(resolve(config.envDir), resolve(fixtureDir)) + } +}) diff --git a/vite.config.js b/vite.config.js index fd7cc0c..e735232 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,5 +1,8 @@ import { defineConfig, loadEnv } from 'vite' import uni from '@dcloudio/vite-plugin-uni' +import { fileURLToPath } from 'node:url' + +const projectDir = fileURLToPath(new URL('.', import.meta.url)) function loggedProxy(name, options) { return { @@ -24,9 +27,10 @@ function loggedProxy(name, options) { } export default defineConfig(({ mode }) => { - const env = loadEnv(mode, process.cwd(), '') + const env = loadEnv(mode, projectDir, '') const apiTarget = env.API_PROXY_TARGET || 'http://127.0.0.1:9000' return { + envDir: projectDir, base: process.env.UNI_PLATFORM === 'h5' && mode === 'production' ? '/' : undefined, plugins: [uni()], define: {