| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511 |
- /**
- * 仅 Android:APK 整包更新(静默下载后提示安装)。
- * iOS / H5 / 小程序不支持应用内自动更新,相关导出在非 App 端为空实现。
- * 冷启动检一次;之后每至少 5 小时回到前台再检(本地存储时间戳)。
- */
- const ANDROID_UPDATE_MANIFEST_URL =
- 'https://api.hnyunzhu.com:9004/app-updates/1773848909/android/update/latest.json'
- const LAST_CHECK_STORAGE_KEY = 'lastAndroidApkUpdateCheckAt'
- const CHECK_INTERVAL_MS = 5 * 60 * 60 * 1000
- // #ifndef APP-PLUS
- export function scheduleAndroidApkUpdateCheck() {}
- export function maybeCheckAndroidApkUpdateByInterval() {}
- export function manualCheckAndroidApkUpdate() {
- uni.showToast({ title: '请使用 App 客户端', icon: 'none' })
- }
- // #endif
- // #ifdef APP-PLUS
- let checkRunning = false
- function formatError(err) {
- if (err == null) return '未知错误'
- if (typeof err === 'string') return err
- if (err instanceof Error && err.message) return err.message
- if (typeof err.errMsg === 'string' && err.errMsg) return err.errMsg
- if (typeof err.message === 'string' && err.message) return err.message
- try {
- return JSON.stringify(err)
- } catch (e) {
- return String(err)
- }
- }
- function showCheckFailedModal(detail) {
- uni.showModal({
- title: '检查更新失败',
- content: detail || '未知错误',
- showCancel: false,
- confirmText: '知道了'
- })
- }
- /** 手动检测:确认版本说明后再下载 */
- function confirmNewVersionManually(versionName, changelog) {
- return new Promise((resolve) => {
- uni.showModal({
- title: '发现新版本',
- content: `版本号:${versionName}\n\n更新内容:\n${changelog || '(暂无说明)'}`,
- cancelText: '取消',
- confirmText: '下载更新',
- success: (r) => resolve(!!r.confirm)
- })
- })
- }
- function touchLastCheckTime() {
- try {
- uni.setStorageSync(LAST_CHECK_STORAGE_KEY, Date.now())
- } catch (e) {
- console.warn('[appUpgrade] storage', e)
- }
- }
- function getPendingApkPath(versionCode) {
- const env = typeof uni !== 'undefined' && uni.env ? uni.env : {}
- let base = String(env.USER_DATA_PATH || '').replace(/\/+$/, '')
- if (!base) {
- base = '_doc'
- }
- return `${base}/app-update-${versionCode}.apk`
- }
- /** 供 plus.io.resolveLocalFileSystemURL 使用的路径 */
- function normalizePlusPath(p) {
- if (p == null) return ''
- const s = String(p).trim()
- if (!s) return s
- if (/^file:\/\//i.test(s)) return s
- if (/^_(doc|downloads|www)/i.test(s)) return s
- if (/^\//.test(s)) return `file://${s}`
- return s
- }
- /** 尝试多种写法,避免 uni 落盘路径与自拼 destPath 不一致导致 resolve 失败 */
- function buildPathResolveCandidates(filePath) {
- if (filePath == null || filePath === '') return []
- const raw = String(filePath).trim()
- const set = new Set()
- const add = (x) => {
- if (x && typeof x === 'string' && x.trim()) set.add(x.trim())
- }
- add(normalizePlusPath(raw))
- add(raw)
- if (typeof plus !== 'undefined' && plus.io && typeof plus.io.convertLocalFileSystemURL === 'function') {
- try {
- if (/^_(doc|downloads|www)/i.test(raw)) {
- add(plus.io.convertLocalFileSystemURL(raw))
- }
- const noProto = raw.replace(/^file:\/\//i, '')
- if (noProto !== raw && /^(?:\/|_)/.test(noProto)) {
- add(plus.io.convertLocalFileSystemURL(noProto))
- }
- } catch (e) {
- console.warn('[appUpgrade] convertLocalFileSystemURL', e)
- }
- }
- return Array.from(set)
- }
- function pathExists(filePath) {
- return new Promise((resolve) => {
- if (typeof plus === 'undefined' || !plus.io) {
- resolve(false)
- return
- }
- const candidates = buildPathResolveCandidates(filePath)
- if (candidates.length === 0) {
- resolve(false)
- return
- }
- let i = 0
- const tryNext = () => {
- if (i >= candidates.length) {
- resolve(false)
- return
- }
- const url = candidates[i++]
- plus.io.resolveLocalFileSystemURL(
- url,
- (entry) => {
- resolve(!!(entry && entry.isFile))
- },
- () => tryNext()
- )
- }
- tryNext()
- })
- }
- function unlinkIfExists(filePath) {
- return new Promise((resolve) => {
- if (typeof plus === 'undefined' || !plus.io) {
- resolve()
- return
- }
- const url = normalizePlusPath(filePath)
- plus.io.resolveLocalFileSystemURL(
- url,
- (entry) => {
- if (entry.isFile) {
- entry.remove(
- () => resolve(),
- () => resolve()
- )
- } else {
- resolve()
- }
- },
- () => resolve()
- )
- })
- }
- /**
- * 使用 5+ FileEntry.copyTo(uni-app JS 引擎版 App 无 uni.getFileSystemManager)
- */
- function copyFilePlus(srcPath, destPath) {
- return new Promise((resolve, reject) => {
- if (typeof plus === 'undefined' || !plus.io) {
- reject(new Error('plus.io 不可用'))
- return
- }
- const srcUrl = normalizePlusPath(srcPath)
- const lastSlash = destPath.lastIndexOf('/')
- const destDirPath = lastSlash <= 0 ? '_doc' : destPath.substring(0, lastSlash)
- const destFileName =
- lastSlash >= 0 ? destPath.substring(lastSlash + 1) : destPath
- plus.io.resolveLocalFileSystemURL(
- srcUrl,
- (srcEntry) => {
- if (!srcEntry.isFile) {
- reject(new Error('临时文件无效'))
- return
- }
- plus.io.resolveLocalFileSystemURL(
- normalizePlusPath(destDirPath),
- (dirEntry) => {
- srcEntry.copyTo(
- dirEntry,
- destFileName,
- () => resolve(),
- (e) => reject(new Error(formatError(e) || '复制安装包失败'))
- )
- },
- (e) => reject(new Error(formatError(e) || '无法访问目标目录'))
- )
- },
- (e) => reject(new Error(formatError(e) || '无法访问临时文件'))
- )
- })
- }
- function getLocalVersionCode() {
- return new Promise((resolve) => {
- if (typeof plus === 'undefined' || !plus.runtime) {
- resolve(0)
- return
- }
- plus.runtime.getProperty(plus.runtime.appid, (inf) => {
- const code = Number(inf?.versionCode)
- resolve(Number.isFinite(code) ? code : 0)
- })
- })
- }
- function fetchLatestManifest() {
- return new Promise((resolve, reject) => {
- uni.request({
- url: ANDROID_UPDATE_MANIFEST_URL,
- method: 'GET',
- timeout: 15000,
- success: (res) => {
- if (res.statusCode !== 200 || res.data == null) {
- reject(new Error(`拉取更新配置失败(HTTP ${res.statusCode ?? '未知'})`))
- return
- }
- let d = res.data
- if (typeof d === 'string') {
- try {
- d = JSON.parse(d)
- } catch {
- reject(new Error('更新配置不是合法 JSON'))
- return
- }
- }
- resolve(d)
- },
- fail: (err) => {
- reject(new Error(formatError(err) || '网络请求失败'))
- }
- })
- })
- }
- /**
- * 静默下载 APK。返回:安装时应使用的本地路径(优先 uni 回调中的真实路径,避免与自拼 destPath 不一致)。
- * @returns {Promise<string>}
- */
- function silentDownloadApk(apkUrl, destPath) {
- return new Promise((resolve, reject) => {
- const tryCopyFromTemp = (tempFilePath) => {
- unlinkIfExists(destPath)
- .then(() => copyFilePlus(tempFilePath, destPath))
- .then(() => resolve(destPath))
- .catch((err) => reject(err))
- }
- uni.downloadFile({
- url: apkUrl,
- filePath: destPath,
- success: (res) => {
- if (res.statusCode !== 200) {
- reject(new Error(`下载失败(HTTP ${res.statusCode ?? '未知'})`))
- return
- }
- const actual =
- res.tempFilePath ||
- res.apFilePath ||
- (typeof res.filePath === 'string' ? res.filePath : '') ||
- destPath
- resolve(String(actual))
- },
- fail: () => {
- uni.downloadFile({
- url: apkUrl,
- success: (res) => {
- if (res.statusCode !== 200 || !res.tempFilePath) {
- reject(new Error(`下载失败(HTTP ${res.statusCode ?? '未知'})`))
- return
- }
- tryCopyFromTemp(res.tempFilePath)
- },
- fail: (e) => reject(new Error(formatError(e) || '下载失败'))
- })
- }
- })
- })
- }
- function openLocalApk(localPath, apkUrlFallback) {
- try {
- plus.runtime.openFile(
- localPath,
- () => {
- if (typeof plus !== 'undefined' && plus.runtime.openURL && apkUrlFallback) {
- plus.runtime.openURL(apkUrlFallback)
- } else {
- uni.showToast({ title: '无法打开安装包', icon: 'none' })
- }
- }
- )
- } catch (e) {
- console.warn('[appUpgrade] openFile', e)
- if (apkUrlFallback && plus.runtime.openURL) {
- plus.runtime.openURL(apkUrlFallback)
- }
- }
- }
- function showDownloadFailedModal(apkUrl, errDetail) {
- const extra = errDetail ? `${formatError(errDetail)}\n\n` : ''
- uni.showModal({
- title: '下载失败',
- content: `${extra}可复制链接在浏览器下载`,
- showCancel: false,
- confirmText: '复制链接',
- success: (r) => {
- if (r.confirm) {
- uni.setClipboardData({ data: apkUrl })
- }
- }
- })
- }
- function showInstallReadyModal(localPath, apkUrl, force, versionName, changelog) {
- const parts = [`版本号:${versionName}`]
- if (changelog) {
- parts.push(`更新内容:${changelog}`)
- }
- parts.push('安装包已下载完成,是否立即安装?')
- const content = parts.join('\n\n')
- uni.showModal({
- title: '准备安装',
- content,
- showCancel: !force,
- confirmText: '立即安装',
- cancelText: '稍后',
- success: (r) => {
- if (!r.confirm) {
- if (force && typeof plus !== 'undefined' && plus.runtime.quit) {
- plus.runtime.quit()
- }
- return
- }
- openLocalApk(localPath, apkUrl)
- }
- })
- }
- /**
- * 执行一次联网比对:有新版本则静默下载,完成后提示安装。
- * 成功拉到 manifest 后写入「上次检查时间」,网络失败则不写入。
- * @param {{ manual?: boolean }} options manual 为 true 时显示 Loading,无新版本时提示「已是最新版本」。
- */
- async function runAndroidApkUpdateCheckOnce(options = {}) {
- const manual = !!options.manual
- const sys = uni.getSystemInfoSync()
- if (sys.platform !== 'android') return
- if (checkRunning) {
- if (manual) {
- uni.showToast({ title: '正在检查中,请稍候', icon: 'none' })
- }
- return
- }
- checkRunning = true
- try {
- if (manual) {
- uni.showLoading({ title: '检查更新...', mask: true })
- }
- const localCode = await getLocalVersionCode()
- const remote = await fetchLatestManifest()
- touchLastCheckTime()
- const remoteCode = Number(remote.versionCode)
- if (!Number.isFinite(remoteCode) || remoteCode <= localCode) {
- if (manual) {
- uni.hideLoading()
- uni.showToast({ title: '已是最新版本', icon: 'none' })
- }
- return
- }
- const apkUrl = String(remote.apkUrl || '').trim()
- if (!/^https:\/\//i.test(apkUrl)) {
- console.warn('[appUpgrade] apkUrl must be https')
- if (manual) {
- uni.hideLoading()
- uni.showModal({
- title: '更新地址无效',
- content: `服务端返回的 apkUrl 须为 https 地址:\n${apkUrl || '(空)'}`,
- showCancel: false,
- confirmText: '知道了'
- })
- }
- return
- }
- const force = !!remote.forceUpdate
- const versionName = remote.versionName ? String(remote.versionName) : String(remoteCode)
- const changelog = remote.changelog ? String(remote.changelog) : ''
- if (manual) {
- uni.hideLoading()
- const confirmed = await confirmNewVersionManually(versionName, changelog)
- if (!confirmed) return
- uni.showLoading({ title: '正在下载新版本...', mask: true })
- }
- const destPath = getPendingApkPath(remoteCode)
- let installPath = destPath
- const cached = await pathExists(destPath)
- if (!cached) {
- try {
- await unlinkIfExists(destPath)
- installPath = await silentDownloadApk(apkUrl, destPath)
- } catch (e) {
- console.warn('[appUpgrade] download', e)
- if (manual) {
- uni.hideLoading()
- }
- showDownloadFailedModal(apkUrl, e)
- return
- }
- }
- let hasFile = await pathExists(installPath)
- if (!hasFile && installPath !== destPath) {
- hasFile = await pathExists(destPath)
- if (hasFile) {
- installPath = destPath
- }
- }
- if (!hasFile) {
- if (manual) {
- uni.hideLoading()
- }
- showDownloadFailedModal(
- apkUrl,
- new Error('本地未找到已下载的安装包(路径与校验不一致,请重试或浏览器下载)')
- )
- return
- }
- if (manual) {
- uni.hideLoading()
- }
- showInstallReadyModal(installPath, apkUrl, force, versionName, changelog)
- } catch (e) {
- console.warn('[appUpgrade]', e)
- if (manual) {
- uni.hideLoading()
- showCheckFailedModal(formatError(e))
- }
- } finally {
- checkRunning = false
- }
- }
- /**
- * 冷启动稍后检查更新(不阻塞登录逻辑)。仅 Android App。
- */
- export function scheduleAndroidApkUpdateCheck() {
- const sys = uni.getSystemInfoSync()
- if (sys.platform !== 'android') return
- setTimeout(() => {
- runAndroidApkUpdateCheckOnce()
- }, 800)
- }
- /**
- * 回到前台时:若距上次成功检查已满 5 小时则再检。首次检查仅走 scheduleAndroidApkUpdateCheck,避免与 onShow 重复。
- */
- export function maybeCheckAndroidApkUpdateByInterval() {
- const sys = uni.getSystemInfoSync()
- if (sys.platform !== 'android') return
- let last = 0
- try {
- last = Number(uni.getStorageSync(LAST_CHECK_STORAGE_KEY)) || 0
- } catch {
- return
- }
- if (!last) return
- if (Date.now() - last < CHECK_INTERVAL_MS) return
- runAndroidApkUpdateCheckOnce()
- }
- /**
- * 个人信息页等:用户主动检测更新(仅 Android App)。
- */
- export function manualCheckAndroidApkUpdate() {
- const sys = uni.getSystemInfoSync()
- if (sys.platform !== 'android') {
- uni.showToast({ title: '仅 Android 版支持', icon: 'none' })
- return
- }
- runAndroidApkUpdateCheckOnce({ manual: true })
- }
- // #endif
|