mpUpdate.js 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. /**
  2. * 微信小程序正式版更新 + 版本/状态可见性。
  3. * 新包就绪后强制重启;「我的」页可展示「已是最新 / 有新版本…」。
  4. *
  5. * @see docs/superpowers/specs/2026-09-26-mp-weixin-force-update-design.md
  6. * @see docs/superpowers/specs/2026-09-26-mp-version-status-design.md
  7. */
  8. const SHOW_DEBOUNCE_MS = 30 * 1000
  9. const USER_CHECK_FALLBACK_MS = 2800
  10. const ENV_LABEL = {
  11. release: '正式版',
  12. trial: '体验版',
  13. develop: '开发版'
  14. }
  15. let listenersBound = false
  16. let readyModalShown = false
  17. let failedModalShown = false
  18. let lastCheckAt = 0
  19. let cachedUpdateManager = null
  20. let userToastUnsub = null
  21. let userFallbackTimer = null
  22. let snapshot = {
  23. status: 'idle',
  24. version: '',
  25. envVersion: '',
  26. envLabel: '',
  27. isRelease: false,
  28. hasUpdate: false
  29. }
  30. const listeners = new Set()
  31. function notify() {
  32. const copy = { ...snapshot }
  33. listeners.forEach((fn) => {
  34. try {
  35. fn(copy)
  36. } catch (e) {
  37. console.warn('[mpUpdate] listener error', e)
  38. }
  39. })
  40. }
  41. function patchSnapshot(partial) {
  42. snapshot = { ...snapshot, ...partial }
  43. notify()
  44. }
  45. /**
  46. * @returns {{ version: string, envVersion: string, envLabel: string, isRelease: boolean }}
  47. */
  48. export function getMpAccountInfo() {
  49. // #ifdef MP-WEIXIN
  50. try {
  51. if (typeof uni.getAccountInfoSync === 'function') {
  52. const mini = (uni.getAccountInfoSync() || {}).miniProgram || {}
  53. const envVersion = mini.envVersion || ''
  54. return {
  55. version: mini.version || '',
  56. envVersion,
  57. envLabel: ENV_LABEL[envVersion] || envVersion || '未知',
  58. isRelease: envVersion === 'release'
  59. }
  60. }
  61. } catch (e) {
  62. console.warn('[mpUpdate] getAccountInfoSync failed', e)
  63. }
  64. // #endif
  65. return {
  66. version: '',
  67. envVersion: '',
  68. envLabel: '',
  69. isRelease: false
  70. }
  71. }
  72. export function getMpUpdateSnapshot() {
  73. return { ...snapshot }
  74. }
  75. /**
  76. * @param {(s: typeof snapshot) => void} listener
  77. * @returns {() => void}
  78. */
  79. export function subscribeMpUpdate(listener) {
  80. listeners.add(listener)
  81. listener({ ...snapshot })
  82. return () => listeners.delete(listener)
  83. }
  84. function refreshAccountIntoSnapshot(statusOverride) {
  85. const account = getMpAccountInfo()
  86. const next = {
  87. version: account.version,
  88. envVersion: account.envVersion,
  89. envLabel: account.envLabel,
  90. isRelease: account.isRelease,
  91. hasUpdate: snapshot.hasUpdate,
  92. status: snapshot.status
  93. }
  94. if (!account.isRelease) {
  95. next.status = account.envVersion ? 'unsupported' : snapshot.status
  96. next.hasUpdate = false
  97. } else if (statusOverride) {
  98. next.status = statusOverride
  99. } else if (snapshot.status === 'unsupported') {
  100. next.status = 'idle'
  101. }
  102. patchSnapshot(next)
  103. }
  104. function showReadyModal(updateManager) {
  105. if (readyModalShown) return
  106. readyModalShown = true
  107. uni.showModal({
  108. title: '更新提示',
  109. content: '新版本已准备好,需要重启以使用最新功能',
  110. showCancel: false,
  111. confirmText: '立即重启',
  112. success: (res) => {
  113. if (res.confirm) {
  114. updateManager.applyUpdate()
  115. }
  116. }
  117. })
  118. }
  119. function clearUserFeedbackHooks() {
  120. if (userToastUnsub) {
  121. userToastUnsub()
  122. userToastUnsub = null
  123. }
  124. if (userFallbackTimer) {
  125. clearTimeout(userFallbackTimer)
  126. userFallbackTimer = null
  127. }
  128. }
  129. function toastForStatus(status) {
  130. if (status === 'latest') {
  131. uni.showToast({ title: '已是最新', icon: 'none' })
  132. } else if (status === 'downloading') {
  133. uni.showToast({ title: '发现新版本,正在下载', icon: 'none' })
  134. } else if (status === 'failed') {
  135. uni.showToast({ title: '更新失败,请稍后重试', icon: 'none' })
  136. }
  137. }
  138. function feedbackFromKnownStatus() {
  139. const { status } = snapshot
  140. if (status === 'ready' && cachedUpdateManager) {
  141. readyModalShown = false
  142. showReadyModal(cachedUpdateManager)
  143. return true
  144. }
  145. if (status === 'latest' || status === 'downloading' || status === 'failed') {
  146. toastForStatus(status)
  147. return true
  148. }
  149. return false
  150. }
  151. function armUserCheckFeedback() {
  152. clearUserFeedbackHooks()
  153. const startedStatus = snapshot.status
  154. userToastUnsub = subscribeMpUpdate((s) => {
  155. if (s.status === startedStatus && s.status === 'checking') return
  156. if (s.status === 'latest' || s.status === 'downloading' || s.status === 'failed') {
  157. toastForStatus(s.status)
  158. clearUserFeedbackHooks()
  159. } else if (s.status === 'ready') {
  160. clearUserFeedbackHooks()
  161. }
  162. })
  163. userFallbackTimer = setTimeout(() => {
  164. userFallbackTimer = null
  165. if (snapshot.status === 'checking') {
  166. // 微信未必再次回调:无 hasUpdate 信号时按「已是最新」收口
  167. patchSnapshot({ status: 'latest', hasUpdate: false })
  168. uni.showToast({ title: '已是最新', icon: 'none' })
  169. }
  170. clearUserFeedbackHooks()
  171. }, USER_CHECK_FALLBACK_MS)
  172. }
  173. function bindUpdateListeners(updateManager) {
  174. if (listenersBound) return
  175. listenersBound = true
  176. updateManager.onCheckForUpdate((res) => {
  177. const hasUpdate = !!(res && res.hasUpdate)
  178. console.log('[mpUpdate] onCheckForUpdate hasUpdate=', hasUpdate)
  179. if (!snapshot.isRelease) {
  180. patchSnapshot({ status: 'unsupported', hasUpdate: false })
  181. return
  182. }
  183. if (hasUpdate) {
  184. if (snapshot.status !== 'ready') {
  185. patchSnapshot({ status: 'downloading', hasUpdate: true })
  186. } else {
  187. patchSnapshot({ hasUpdate: true })
  188. }
  189. } else {
  190. patchSnapshot({ status: 'latest', hasUpdate: false })
  191. }
  192. })
  193. updateManager.onUpdateReady(() => {
  194. console.log('[mpUpdate] onUpdateReady')
  195. patchSnapshot({ status: 'ready', hasUpdate: true })
  196. showReadyModal(updateManager)
  197. })
  198. updateManager.onUpdateFailed(() => {
  199. console.log('[mpUpdate] onUpdateFailed')
  200. patchSnapshot({ status: 'failed', hasUpdate: snapshot.hasUpdate })
  201. if (failedModalShown) return
  202. failedModalShown = true
  203. uni.showModal({
  204. title: '更新失败',
  205. content: '新版本下载失败,请删除小程序后重新搜索打开,或检查网络后重试',
  206. showCancel: false
  207. })
  208. })
  209. }
  210. /**
  211. * @param {{ force?: boolean, fromUser?: boolean }} [options]
  212. */
  213. export function checkMpUpdate(options = {}) {
  214. // #ifdef MP-WEIXIN
  215. const force = !!options.force
  216. const fromUser = !!options.fromUser
  217. refreshAccountIntoSnapshot()
  218. if (!snapshot.isRelease) {
  219. if (fromUser) {
  220. uni.showToast({
  221. title: '开发/体验版无正式更新流',
  222. icon: 'none'
  223. })
  224. }
  225. return
  226. }
  227. if (fromUser && feedbackFromKnownStatus()) {
  228. return
  229. }
  230. const now = Date.now()
  231. if (!force && lastCheckAt && now - lastCheckAt < SHOW_DEBOUNCE_MS) {
  232. return
  233. }
  234. lastCheckAt = now
  235. if (typeof uni.canIUse !== 'function' || !uni.canIUse('getUpdateManager')) {
  236. if (fromUser) {
  237. uni.showToast({ title: '当前基础库不支持检查更新', icon: 'none' })
  238. }
  239. return
  240. }
  241. if (fromUser || snapshot.status === 'idle') {
  242. patchSnapshot({ status: 'checking' })
  243. }
  244. const updateManager = uni.getUpdateManager()
  245. cachedUpdateManager = updateManager
  246. bindUpdateListeners(updateManager)
  247. if (fromUser) {
  248. armUserCheckFeedback()
  249. }
  250. // #endif
  251. }
  252. /**
  253. * @param {ReturnType<typeof getMpUpdateSnapshot>} [s]
  254. */
  255. export function formatMpUpdateLine(s) {
  256. const state = s || getMpUpdateSnapshot()
  257. if (state.status === 'unsupported' || (!state.isRelease && state.envVersion)) {
  258. const env = state.envLabel || '非正式版'
  259. return `${env} · 无正式更新流`
  260. }
  261. const ver = state.version ? `版本 ${state.version}` : '版本 —'
  262. const env = state.envLabel || '正式版'
  263. let tip = '点此检查更新'
  264. switch (state.status) {
  265. case 'checking':
  266. tip = '检查中…'
  267. break
  268. case 'latest':
  269. tip = '已是最新'
  270. break
  271. case 'downloading':
  272. tip = '有新版本(下载中)'
  273. break
  274. case 'ready':
  275. tip = '有新版本,点此更新'
  276. break
  277. case 'failed':
  278. tip = '更新失败'
  279. break
  280. default:
  281. tip = '点此检查更新'
  282. }
  283. return `${ver} · ${env} · ${tip}`
  284. }
  285. /** 是否需要用户关注更新(下载中 / 待重启 / 失败) */
  286. export function isMpUpdateUrgent(s) {
  287. const status = (s || getMpUpdateSnapshot()).status
  288. return status === 'downloading' || status === 'ready' || status === 'failed'
  289. }
  290. refreshAccountIntoSnapshot()