appUpgrade.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. /**
  2. * 仅 Android:APK 整包更新(静默下载后提示安装)。
  3. * iOS / H5 / 小程序不支持应用内自动更新,相关导出在非 App 端为空实现。
  4. * 冷启动检一次;之后每至少 5 小时回到前台再检(本地存储时间戳)。
  5. */
  6. const ANDROID_UPDATE_MANIFEST_URL =
  7. 'https://api.hnyunzhu.com:9004/app-updates/1773848909/android/update/latest.json'
  8. const LAST_CHECK_STORAGE_KEY = 'lastAndroidApkUpdateCheckAt'
  9. const CHECK_INTERVAL_MS = 5 * 60 * 60 * 1000
  10. // #ifndef APP-PLUS
  11. export function scheduleAndroidApkUpdateCheck() {}
  12. export function maybeCheckAndroidApkUpdateByInterval() {}
  13. export function manualCheckAndroidApkUpdate() {
  14. uni.showToast({ title: '请使用 App 客户端', icon: 'none' })
  15. }
  16. // #endif
  17. // #ifdef APP-PLUS
  18. let checkRunning = false
  19. function formatError(err) {
  20. if (err == null) return '未知错误'
  21. if (typeof err === 'string') return err
  22. if (err instanceof Error && err.message) return err.message
  23. if (typeof err.errMsg === 'string' && err.errMsg) return err.errMsg
  24. if (typeof err.message === 'string' && err.message) return err.message
  25. try {
  26. return JSON.stringify(err)
  27. } catch (e) {
  28. return String(err)
  29. }
  30. }
  31. function showCheckFailedModal(detail) {
  32. uni.showModal({
  33. title: '检查更新失败',
  34. content: detail || '未知错误',
  35. showCancel: false,
  36. confirmText: '知道了'
  37. })
  38. }
  39. /** 手动检测:确认版本说明后再下载 */
  40. function confirmNewVersionManually(versionName, changelog) {
  41. return new Promise((resolve) => {
  42. uni.showModal({
  43. title: '发现新版本',
  44. content: `版本号:${versionName}\n\n更新内容:\n${changelog || '(暂无说明)'}`,
  45. cancelText: '取消',
  46. confirmText: '下载更新',
  47. success: (r) => resolve(!!r.confirm)
  48. })
  49. })
  50. }
  51. function touchLastCheckTime() {
  52. try {
  53. uni.setStorageSync(LAST_CHECK_STORAGE_KEY, Date.now())
  54. } catch (e) {
  55. console.warn('[appUpgrade] storage', e)
  56. }
  57. }
  58. function getPendingApkPath(versionCode) {
  59. const env = typeof uni !== 'undefined' && uni.env ? uni.env : {}
  60. let base = String(env.USER_DATA_PATH || '').replace(/\/+$/, '')
  61. if (!base) {
  62. base = '_doc'
  63. }
  64. return `${base}/app-update-${versionCode}.apk`
  65. }
  66. /** 供 plus.io.resolveLocalFileSystemURL 使用的路径 */
  67. function normalizePlusPath(p) {
  68. if (p == null) return ''
  69. const s = String(p).trim()
  70. if (!s) return s
  71. if (/^file:\/\//i.test(s)) return s
  72. if (/^_(doc|downloads|www)/i.test(s)) return s
  73. if (/^\//.test(s)) return `file://${s}`
  74. return s
  75. }
  76. /** 尝试多种写法,避免 uni 落盘路径与自拼 destPath 不一致导致 resolve 失败 */
  77. function buildPathResolveCandidates(filePath) {
  78. if (filePath == null || filePath === '') return []
  79. const raw = String(filePath).trim()
  80. const set = new Set()
  81. const add = (x) => {
  82. if (x && typeof x === 'string' && x.trim()) set.add(x.trim())
  83. }
  84. add(normalizePlusPath(raw))
  85. add(raw)
  86. if (typeof plus !== 'undefined' && plus.io && typeof plus.io.convertLocalFileSystemURL === 'function') {
  87. try {
  88. if (/^_(doc|downloads|www)/i.test(raw)) {
  89. add(plus.io.convertLocalFileSystemURL(raw))
  90. }
  91. const noProto = raw.replace(/^file:\/\//i, '')
  92. if (noProto !== raw && /^(?:\/|_)/.test(noProto)) {
  93. add(plus.io.convertLocalFileSystemURL(noProto))
  94. }
  95. } catch (e) {
  96. console.warn('[appUpgrade] convertLocalFileSystemURL', e)
  97. }
  98. }
  99. return Array.from(set)
  100. }
  101. function pathExists(filePath) {
  102. return new Promise((resolve) => {
  103. if (typeof plus === 'undefined' || !plus.io) {
  104. resolve(false)
  105. return
  106. }
  107. const candidates = buildPathResolveCandidates(filePath)
  108. if (candidates.length === 0) {
  109. resolve(false)
  110. return
  111. }
  112. let i = 0
  113. const tryNext = () => {
  114. if (i >= candidates.length) {
  115. resolve(false)
  116. return
  117. }
  118. const url = candidates[i++]
  119. plus.io.resolveLocalFileSystemURL(
  120. url,
  121. (entry) => {
  122. resolve(!!(entry && entry.isFile))
  123. },
  124. () => tryNext()
  125. )
  126. }
  127. tryNext()
  128. })
  129. }
  130. function unlinkIfExists(filePath) {
  131. return new Promise((resolve) => {
  132. if (typeof plus === 'undefined' || !plus.io) {
  133. resolve()
  134. return
  135. }
  136. const url = normalizePlusPath(filePath)
  137. plus.io.resolveLocalFileSystemURL(
  138. url,
  139. (entry) => {
  140. if (entry.isFile) {
  141. entry.remove(
  142. () => resolve(),
  143. () => resolve()
  144. )
  145. } else {
  146. resolve()
  147. }
  148. },
  149. () => resolve()
  150. )
  151. })
  152. }
  153. /**
  154. * 使用 5+ FileEntry.copyTo(uni-app JS 引擎版 App 无 uni.getFileSystemManager)
  155. */
  156. function copyFilePlus(srcPath, destPath) {
  157. return new Promise((resolve, reject) => {
  158. if (typeof plus === 'undefined' || !plus.io) {
  159. reject(new Error('plus.io 不可用'))
  160. return
  161. }
  162. const srcUrl = normalizePlusPath(srcPath)
  163. const lastSlash = destPath.lastIndexOf('/')
  164. const destDirPath = lastSlash <= 0 ? '_doc' : destPath.substring(0, lastSlash)
  165. const destFileName =
  166. lastSlash >= 0 ? destPath.substring(lastSlash + 1) : destPath
  167. plus.io.resolveLocalFileSystemURL(
  168. srcUrl,
  169. (srcEntry) => {
  170. if (!srcEntry.isFile) {
  171. reject(new Error('临时文件无效'))
  172. return
  173. }
  174. plus.io.resolveLocalFileSystemURL(
  175. normalizePlusPath(destDirPath),
  176. (dirEntry) => {
  177. srcEntry.copyTo(
  178. dirEntry,
  179. destFileName,
  180. () => resolve(),
  181. (e) => reject(new Error(formatError(e) || '复制安装包失败'))
  182. )
  183. },
  184. (e) => reject(new Error(formatError(e) || '无法访问目标目录'))
  185. )
  186. },
  187. (e) => reject(new Error(formatError(e) || '无法访问临时文件'))
  188. )
  189. })
  190. }
  191. function getLocalVersionCode() {
  192. return new Promise((resolve) => {
  193. if (typeof plus === 'undefined' || !plus.runtime) {
  194. resolve(0)
  195. return
  196. }
  197. plus.runtime.getProperty(plus.runtime.appid, (inf) => {
  198. const code = Number(inf?.versionCode)
  199. resolve(Number.isFinite(code) ? code : 0)
  200. })
  201. })
  202. }
  203. function fetchLatestManifest() {
  204. return new Promise((resolve, reject) => {
  205. uni.request({
  206. url: ANDROID_UPDATE_MANIFEST_URL,
  207. method: 'GET',
  208. timeout: 15000,
  209. success: (res) => {
  210. if (res.statusCode !== 200 || res.data == null) {
  211. reject(new Error(`拉取更新配置失败(HTTP ${res.statusCode ?? '未知'})`))
  212. return
  213. }
  214. let d = res.data
  215. if (typeof d === 'string') {
  216. try {
  217. d = JSON.parse(d)
  218. } catch {
  219. reject(new Error('更新配置不是合法 JSON'))
  220. return
  221. }
  222. }
  223. resolve(d)
  224. },
  225. fail: (err) => {
  226. reject(new Error(formatError(err) || '网络请求失败'))
  227. }
  228. })
  229. })
  230. }
  231. /**
  232. * 静默下载 APK。返回:安装时应使用的本地路径(优先 uni 回调中的真实路径,避免与自拼 destPath 不一致)。
  233. * @returns {Promise<string>}
  234. */
  235. function silentDownloadApk(apkUrl, destPath) {
  236. return new Promise((resolve, reject) => {
  237. const tryCopyFromTemp = (tempFilePath) => {
  238. unlinkIfExists(destPath)
  239. .then(() => copyFilePlus(tempFilePath, destPath))
  240. .then(() => resolve(destPath))
  241. .catch((err) => reject(err))
  242. }
  243. uni.downloadFile({
  244. url: apkUrl,
  245. filePath: destPath,
  246. success: (res) => {
  247. if (res.statusCode !== 200) {
  248. reject(new Error(`下载失败(HTTP ${res.statusCode ?? '未知'})`))
  249. return
  250. }
  251. const actual =
  252. res.tempFilePath ||
  253. res.apFilePath ||
  254. (typeof res.filePath === 'string' ? res.filePath : '') ||
  255. destPath
  256. resolve(String(actual))
  257. },
  258. fail: () => {
  259. uni.downloadFile({
  260. url: apkUrl,
  261. success: (res) => {
  262. if (res.statusCode !== 200 || !res.tempFilePath) {
  263. reject(new Error(`下载失败(HTTP ${res.statusCode ?? '未知'})`))
  264. return
  265. }
  266. tryCopyFromTemp(res.tempFilePath)
  267. },
  268. fail: (e) => reject(new Error(formatError(e) || '下载失败'))
  269. })
  270. }
  271. })
  272. })
  273. }
  274. function openLocalApk(localPath, apkUrlFallback) {
  275. try {
  276. plus.runtime.openFile(
  277. localPath,
  278. () => {
  279. if (typeof plus !== 'undefined' && plus.runtime.openURL && apkUrlFallback) {
  280. plus.runtime.openURL(apkUrlFallback)
  281. } else {
  282. uni.showToast({ title: '无法打开安装包', icon: 'none' })
  283. }
  284. }
  285. )
  286. } catch (e) {
  287. console.warn('[appUpgrade] openFile', e)
  288. if (apkUrlFallback && plus.runtime.openURL) {
  289. plus.runtime.openURL(apkUrlFallback)
  290. }
  291. }
  292. }
  293. function showDownloadFailedModal(apkUrl, errDetail) {
  294. const extra = errDetail ? `${formatError(errDetail)}\n\n` : ''
  295. uni.showModal({
  296. title: '下载失败',
  297. content: `${extra}可复制链接在浏览器下载`,
  298. showCancel: false,
  299. confirmText: '复制链接',
  300. success: (r) => {
  301. if (r.confirm) {
  302. uni.setClipboardData({ data: apkUrl })
  303. }
  304. }
  305. })
  306. }
  307. function showInstallReadyModal(localPath, apkUrl, force, versionName, changelog) {
  308. const parts = [`版本号:${versionName}`]
  309. if (changelog) {
  310. parts.push(`更新内容:${changelog}`)
  311. }
  312. parts.push('安装包已下载完成,是否立即安装?')
  313. const content = parts.join('\n\n')
  314. uni.showModal({
  315. title: '准备安装',
  316. content,
  317. showCancel: !force,
  318. confirmText: '立即安装',
  319. cancelText: '稍后',
  320. success: (r) => {
  321. if (!r.confirm) {
  322. if (force && typeof plus !== 'undefined' && plus.runtime.quit) {
  323. plus.runtime.quit()
  324. }
  325. return
  326. }
  327. openLocalApk(localPath, apkUrl)
  328. }
  329. })
  330. }
  331. /**
  332. * 执行一次联网比对:有新版本则静默下载,完成后提示安装。
  333. * 成功拉到 manifest 后写入「上次检查时间」,网络失败则不写入。
  334. * @param {{ manual?: boolean }} options manual 为 true 时显示 Loading,无新版本时提示「已是最新版本」。
  335. */
  336. async function runAndroidApkUpdateCheckOnce(options = {}) {
  337. const manual = !!options.manual
  338. const sys = uni.getSystemInfoSync()
  339. if (sys.platform !== 'android') return
  340. if (checkRunning) {
  341. if (manual) {
  342. uni.showToast({ title: '正在检查中,请稍候', icon: 'none' })
  343. }
  344. return
  345. }
  346. checkRunning = true
  347. try {
  348. if (manual) {
  349. uni.showLoading({ title: '检查更新...', mask: true })
  350. }
  351. const localCode = await getLocalVersionCode()
  352. const remote = await fetchLatestManifest()
  353. touchLastCheckTime()
  354. const remoteCode = Number(remote.versionCode)
  355. if (!Number.isFinite(remoteCode) || remoteCode <= localCode) {
  356. if (manual) {
  357. uni.hideLoading()
  358. uni.showToast({ title: '已是最新版本', icon: 'none' })
  359. }
  360. return
  361. }
  362. const apkUrl = String(remote.apkUrl || '').trim()
  363. if (!/^https:\/\//i.test(apkUrl)) {
  364. console.warn('[appUpgrade] apkUrl must be https')
  365. if (manual) {
  366. uni.hideLoading()
  367. uni.showModal({
  368. title: '更新地址无效',
  369. content: `服务端返回的 apkUrl 须为 https 地址:\n${apkUrl || '(空)'}`,
  370. showCancel: false,
  371. confirmText: '知道了'
  372. })
  373. }
  374. return
  375. }
  376. const force = !!remote.forceUpdate
  377. const versionName = remote.versionName ? String(remote.versionName) : String(remoteCode)
  378. const changelog = remote.changelog ? String(remote.changelog) : ''
  379. if (manual) {
  380. uni.hideLoading()
  381. const confirmed = await confirmNewVersionManually(versionName, changelog)
  382. if (!confirmed) return
  383. uni.showLoading({ title: '正在下载新版本...', mask: true })
  384. }
  385. const destPath = getPendingApkPath(remoteCode)
  386. let installPath = destPath
  387. const cached = await pathExists(destPath)
  388. if (!cached) {
  389. try {
  390. await unlinkIfExists(destPath)
  391. installPath = await silentDownloadApk(apkUrl, destPath)
  392. } catch (e) {
  393. console.warn('[appUpgrade] download', e)
  394. if (manual) {
  395. uni.hideLoading()
  396. }
  397. showDownloadFailedModal(apkUrl, e)
  398. return
  399. }
  400. }
  401. let hasFile = await pathExists(installPath)
  402. if (!hasFile && installPath !== destPath) {
  403. hasFile = await pathExists(destPath)
  404. if (hasFile) {
  405. installPath = destPath
  406. }
  407. }
  408. if (!hasFile) {
  409. if (manual) {
  410. uni.hideLoading()
  411. }
  412. showDownloadFailedModal(
  413. apkUrl,
  414. new Error('本地未找到已下载的安装包(路径与校验不一致,请重试或浏览器下载)')
  415. )
  416. return
  417. }
  418. if (manual) {
  419. uni.hideLoading()
  420. }
  421. showInstallReadyModal(installPath, apkUrl, force, versionName, changelog)
  422. } catch (e) {
  423. console.warn('[appUpgrade]', e)
  424. if (manual) {
  425. uni.hideLoading()
  426. showCheckFailedModal(formatError(e))
  427. }
  428. } finally {
  429. checkRunning = false
  430. }
  431. }
  432. /**
  433. * 冷启动稍后检查更新(不阻塞登录逻辑)。仅 Android App。
  434. */
  435. export function scheduleAndroidApkUpdateCheck() {
  436. const sys = uni.getSystemInfoSync()
  437. if (sys.platform !== 'android') return
  438. setTimeout(() => {
  439. runAndroidApkUpdateCheckOnce()
  440. }, 800)
  441. }
  442. /**
  443. * 回到前台时:若距上次成功检查已满 5 小时则再检。首次检查仅走 scheduleAndroidApkUpdateCheck,避免与 onShow 重复。
  444. */
  445. export function maybeCheckAndroidApkUpdateByInterval() {
  446. const sys = uni.getSystemInfoSync()
  447. if (sys.platform !== 'android') return
  448. let last = 0
  449. try {
  450. last = Number(uni.getStorageSync(LAST_CHECK_STORAGE_KEY)) || 0
  451. } catch {
  452. return
  453. }
  454. if (!last) return
  455. if (Date.now() - last < CHECK_INTERVAL_MS) return
  456. runAndroidApkUpdateCheckOnce()
  457. }
  458. /**
  459. * 个人信息页等:用户主动检测更新(仅 Android App)。
  460. */
  461. export function manualCheckAndroidApkUpdate() {
  462. const sys = uni.getSystemInfoSync()
  463. if (sys.platform !== 'android') {
  464. uni.showToast({ title: '仅 Android 版支持', icon: 'none' })
  465. return
  466. }
  467. runAndroidApkUpdateCheckOnce({ manual: true })
  468. }
  469. // #endif