hot-update.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785
  1. import config from "@/utils/config";
  2. import { runAppUpgrade } from "@/utils/upgrade";
  3. /**
  4. * 旧版本使用过的持久化锁 key,仅用于启动时清理遗留值。
  5. * 当前版本的互斥状态由模块内 hotUpdatePromise 管理,不会跨进程形成死锁。
  6. */
  7. export const HOT_UPDATE_LOCK_KEY = "__APP_WGT_HOT_UPDATE_RUNNING__";
  8. export const HOT_UPDATE_FALLBACK_APP_CHECK_EVENT =
  9. "__APP_WGT_HOT_UPDATE_FALLBACK_APP_CHECK__";
  10. const HOT_UPDATE_FALLBACK_PENDING_KEY =
  11. "__APP_WGT_HOT_UPDATE_FALLBACK_PENDING__";
  12. /**
  13. * 页面 onShow、App onShow 和路由切换可能在很短时间内连续触发。
  14. * 默认三十秒只向服务端检查一次;显式传入 forceCheck 时可用于用户手动检查。
  15. */
  16. export const HOT_UPDATE_CHECK_INTERVAL = 30 * 1000;
  17. let hotUpdatePromise = null;
  18. let lastCheckStartedAt = 0;
  19. /**
  20. * 当用户点击“稍后再说”时,我们把当前版本记下来。
  21. *
  22. * 这样做的原因:
  23. * 1. 热更新会在冷启动、回到前台和页面显示时检查。
  24. * 2. 如果用户已经明确拒绝了某个版本,后续页面切换继续弹同一个版本,体验会比较差。
  25. * 3. 只记录“被跳过的目标版本”,新版本来了仍然会重新提示。
  26. */
  27. const HOT_UPDATE_SKIP_VERSION_KEY = "__APP_WGT_HOT_UPDATE_SKIP_VERSION__";
  28. /**
  29. * 这里单独声明接口地址,而不是直接写死在 uni.request 里。
  30. *
  31. * 这样做的原因:
  32. * 1. 现有项目已经有 config 环境配置,说明项目本身就是按环境切换接口域名的。
  33. * 2. 热更新检查应该沿用现有配置习惯,避免你上线后再去改很多地方。
  34. * 3. 这个路径是本次方案“约定”的服务端接口,你可以直接让后端按这个接口实现,
  35. * 也可以只改这里一处,把路径替换成你们实际的地址。
  36. */
  37. const HOT_UPDATE_API_URL =
  38. config.default.apiUrl + config.default.apiUrlSuffix + "/rq/iot-app/newWgt";
  39. const APP_UPGRADE_API_URL =
  40. config.default.apiUrl + config.default.apiUrlSuffix + "/rq/iot-app/new";
  41. /**
  42. * 读取当前进程的热更新运行状态。
  43. *
  44. * 对页面组件的意义:
  45. * 旧的整包升级逻辑挂在页面里,页面 mounted 时如果发现热更新还在跑,
  46. * 就应该先让路,避免同时弹两个升级框。
  47. */
  48. export const isHotUpdateRunning = () => {
  49. return Boolean(hotUpdatePromise);
  50. };
  51. /**
  52. * 旧版本把运行锁写进了持久化 storage。如果 App 在下载或安装过程中被系统
  53. * 强杀,这个值不会进入 finally 清理,后续每次启动都会永久跳过更新。
  54. * 新实现使用模块内 Promise 作为进程锁;这里仅清理由旧版本遗留的死锁。
  55. */
  56. const clearLegacyHotUpdateLock = () => {
  57. if (uni.getStorageSync(HOT_UPDATE_LOCK_KEY)) {
  58. uni.removeStorageSync(HOT_UPDATE_LOCK_KEY);
  59. }
  60. };
  61. /**
  62. * 整包回退事件可能早于 upgrade 组件创建。额外保存一次待处理标记,组件挂载后
  63. * 仍能消费它,避免事件丢失。
  64. */
  65. export const consumePendingAppUpgradeCheck = () => {
  66. const pending = uni.getStorageSync(HOT_UPDATE_FALLBACK_PENDING_KEY) === "1";
  67. if (pending) {
  68. uni.removeStorageSync(HOT_UPDATE_FALLBACK_PENDING_KEY);
  69. }
  70. return pending;
  71. };
  72. /**
  73. * 等待 plus 对象可用。
  74. *
  75. * 为什么即便在 APP-PLUS 里也还要做这一步:
  76. * 1. 你要求热更新接在启动阶段,启动阶段最容易踩到“plus 尚未完全就绪”的时序问题。
  77. * 2. 现有 App.vue 的 onLaunch 里已经直接用了 plus.globalEvent,这说明项目默认运行在 App-Plus 环境。
  78. * 3. 但热更新比普通日志更敏感,因为它要马上调用 plus.runtime.getProperty / install / restart,
  79. * 所以这里显式兜底一次,会比“直接假设 plus 一定存在”更稳。
  80. *
  81. * 成功分支:
  82. * - plus 已就绪,resolve,继续后续热更新流程。
  83. *
  84. * 失败分支:
  85. * - 理论上这里没有主动 reject,超时场景也继续等待;
  86. * 如果 plus 始终不可用,后续逻辑不会开始,也就不会影响原有启动逻辑。
  87. */
  88. const waitForPlusReady = () => {
  89. return new Promise((resolve) => {
  90. if (typeof plus !== "undefined") {
  91. resolve();
  92. return;
  93. }
  94. document.addEventListener(
  95. "plusready",
  96. () => {
  97. resolve();
  98. },
  99. { once: true }
  100. );
  101. });
  102. };
  103. /**
  104. * 使用 plus.runtime.getProperty 读取当前资源包信息。
  105. *
  106. * 这是这次方案里最关键的一步,也是你特别强调不能替换掉的地方。
  107. *
  108. * 为什么必须用它:
  109. * 1. 你要做的是 .wgt 资源热更新,比较的是“当前资源包版本”和“服务端资源包版本”。
  110. * 2. plus.runtime.getProperty(plus.runtime.appid) 返回的是当前应用资源信息,
  111. * 其中 wgtinfo.version 才是最贴近“资源版本”的值。
  112. * 3. plus.runtime.version 更偏运行时/客户端信息,uni.getSystemInfo 也不是这个场景最合适的来源,
  113. * 所以这里严格按你的要求使用 getProperty。
  114. *
  115. * 输出:
  116. * - resolve(wgtInfo)
  117. * 其中最常用的字段包括:
  118. * - appid: 当前应用的 DCloud appid
  119. * - version: 当前资源版本
  120. * - name: 当前应用名称
  121. */
  122. export const getCurrentWgtInfo = () => {
  123. return new Promise((resolve, reject) => {
  124. try {
  125. plus.runtime.getProperty(plus.runtime.appid, (wgtInfo) => {
  126. if (!wgtInfo || !wgtInfo.version) {
  127. reject(new Error("未能获取当前应用资源版本信息"));
  128. return;
  129. }
  130. resolve(wgtInfo);
  131. });
  132. } catch (error) {
  133. reject(error);
  134. }
  135. });
  136. };
  137. /**
  138. * 一个简单的版本比较函数。
  139. *
  140. * 为什么这里仍然保留本地比较,而不是完全信任服务端的 update 字段:
  141. * 1. 服务端当然应该负责判断版本,但客户端再做一次兜底,可以避免后端配置错误导致重复更新。
  142. * 2. 这对“启动期自动更新”尤其重要,因为一旦后端误配,用户会在每次启动都被弹框打扰。
  143. *
  144. * 比较规则:
  145. * - a > b 返回 1
  146. * - a = b 返回 0
  147. * - a < b 返回 -1
  148. */
  149. const compareVersion = (a = "", b = "") => {
  150. const aList = String(a)
  151. .split(".")
  152. .map((item) => Number(item || 0));
  153. const bList = String(b)
  154. .split(".")
  155. .map((item) => Number(item || 0));
  156. const length = Math.max(aList.length, bList.length);
  157. for (let i = 0; i < length; i += 1) {
  158. const aNum = aList[i] || 0;
  159. const bNum = bList[i] || 0;
  160. if (aNum > bNum) return 1;
  161. if (aNum < bNum) return -1;
  162. }
  163. return 0;
  164. };
  165. /**
  166. * 判断服务端返回的地址是否真的是 .wgt 文件。
  167. *
  168. * 这里额外去掉 query/hash,是为了兼容这类地址:
  169. * - https://example.com/app/update.wgt?sign=xxx
  170. * - https://example.com/app/update.wgt#download
  171. */
  172. const isWgtPackageUrl = (url = "") => {
  173. const normalizedUrl = String(url)
  174. .trim()
  175. .split("#")[0]
  176. .split("?")[0]
  177. .toLowerCase();
  178. return normalizedUrl.endsWith(".wgt");
  179. };
  180. const isApkPackageUrl = (url = "") => {
  181. const normalizedUrl = String(url)
  182. .trim()
  183. .split("#")[0]
  184. .split("?")[0]
  185. .toLowerCase();
  186. return normalizedUrl.endsWith(".apk");
  187. };
  188. /**
  189. * 启动时先检查整包更新。只有 Android 端存在比当前原生版本更新的 APK 时,
  190. * 才中止 WGT 流程并执行全局整包更新。
  191. */
  192. const requestAppUpgradeInfo = () => {
  193. return new Promise((resolve, reject) => {
  194. uni.request({
  195. url: APP_UPGRADE_API_URL,
  196. method: "GET",
  197. header: {
  198. "Content-Type": "application/json",
  199. "tenant-id": "1",
  200. },
  201. timeout: 10000,
  202. success: (response) => {
  203. if (response.statusCode !== 200) {
  204. reject(
  205. new Error(
  206. `整包更新检查接口请求失败,HTTP 状态码:${response.statusCode}`
  207. )
  208. );
  209. return;
  210. }
  211. resolve(response.data || {});
  212. },
  213. fail: (error) => {
  214. reject(new Error(error?.errMsg || "整包更新检查接口请求失败"));
  215. },
  216. });
  217. });
  218. };
  219. const shouldPrioritizeAppUpgrade = (response, currentAppVersion) => {
  220. const data = response?.code === 0 ? response?.data : null;
  221. if (!data?.appVersion || !isApkPackageUrl(data.url)) {
  222. return false;
  223. }
  224. return compareVersion(data.appVersion, currentAppVersion) > 0;
  225. };
  226. /**
  227. * 请求服务端检查是否存在新的 .wgt 资源包。
  228. *
  229. * 为什么这里直接使用 uni.request,而不是复用现有 utils/request.js:
  230. * 1. 现有 request 封装默认会带 token、tenant-id、loading、401 刷新令牌等业务逻辑。
  231. * 2. 热更新检查发生在 App 启动最早阶段,不应该依赖登录态,也不应该因为 token 过期影响更新检查。
  232. * 3. 启动期代码越“去业务化”,越不容易被后续鉴权改造牵连。
  233. *
  234. * 请求参数说明:
  235. * - appid: 用来确保服务端下发的是当前应用对应的更新包。
  236. * - version: 当前资源版本,服务端据此判断是否需要更新。
  237. * - platform: 当前平台,便于服务端做 Android / iOS 区分。
  238. * - name: 可选,便于后端日志排查。
  239. *
  240. * 服务端返回格式约定:
  241. * {
  242. * code: 0,
  243. * data: {
  244. * update: true,
  245. * packageType: "wgt",
  246. * version: "1.3.6",
  247. * wgtUrl: "https://example.com/app/1.3.6/update.wgt",
  248. * note: "1. 修复巡检表单保存异常\\n2. 优化首页加载速度",
  249. * force: false
  250. * },
  251. * message: "success"
  252. * }
  253. *
  254. * 字段用途:
  255. * - code: 业务状态码,0 表示接口业务成功。
  256. * - data.update: 服务端是否认为当前客户端需要更新。
  257. * - data.packageType: 更新类型。这里约定 wgt 表示资源热更新,native 表示整包更新。
  258. * - data.version: 服务端最新资源版本。
  259. * - data.wgtUrl: .wgt 下载地址。
  260. * - data.note: 更新说明,弹窗里给用户看。
  261. * - data.force: 是否强制更新。true 时不展示取消按钮。
  262. * - message: 服务端通用提示。
  263. */
  264. const requestHotUpdateInfo = () => {
  265. console.log("11", 11);
  266. return new Promise((resolve, reject) => {
  267. uni.request({
  268. url: HOT_UPDATE_API_URL,
  269. method: "GET",
  270. header: {
  271. "Content-Type": "application/json",
  272. "tenant-id": "1",
  273. },
  274. timeout: 10000,
  275. success: (response) => {
  276. if (response.statusCode !== 200) {
  277. reject(
  278. new Error(
  279. `热更新检查接口请求失败,HTTP 状态码:${response.statusCode}`
  280. )
  281. );
  282. return;
  283. }
  284. resolve(response.data || {});
  285. },
  286. fail: (error) => {
  287. reject(
  288. new Error(
  289. error?.errMsg || "热更新检查接口请求失败,请检查网络或服务端状态"
  290. )
  291. );
  292. },
  293. complete: () => {
  294. /**
  295. * 这里故意不做 UI 处理,只保留 complete 生命周期位置。
  296. *
  297. * 原因:
  298. * 启动期的检查更新是“后台检查”,不是用户手动点按钮触发的请求。
  299. * 如果这里也弹 loading,会和现有登录页/首页首屏体验打架。
  300. */
  301. },
  302. });
  303. });
  304. };
  305. /**
  306. * 规范化服务端返回,统一后续分支判断。
  307. *
  308. * 为什么要单独做这一层:
  309. * 1. 启动流程最怕到处写 if/else,后面维护时很难看出到底在哪个条件提前返回。
  310. * 2. 统一成“shouldUpdate + reason + payload”的结构,后面 orchestration 会清晰很多。
  311. *
  312. * 成功输出示例:
  313. * {
  314. * shouldUpdate: true,
  315. * reason: "has-update",
  316. * payload: { ...服务端 data... }
  317. * }
  318. */
  319. const normalizeUpdateResult = (response, currentVersion) => {
  320. console.log("response", response);
  321. const code = response?.code;
  322. const message = response?.message || response?.msg || "";
  323. const data = response?.data || {};
  324. if (code !== 0) {
  325. return {
  326. shouldUpdate: false,
  327. reason: "server-error",
  328. message: message || "热更新接口返回了业务错误",
  329. };
  330. }
  331. // if (!data.update) {
  332. // return {
  333. // shouldUpdate: false,
  334. // reason: "no-update",
  335. // message: "服务端确认当前版本无需更新",
  336. // };
  337. // }
  338. // if (data.packageType && data.packageType !== "wgt") {
  339. // return {
  340. // shouldUpdate: false,
  341. // reason: "not-wgt",
  342. // message: `服务端返回的是 ${data.packageType} 更新,不属于本次 .wgt 热更新流程`,
  343. // payload: data,
  344. // };
  345. // }
  346. if (data.wgtUrl && !isWgtPackageUrl(data.wgtUrl)) {
  347. return {
  348. shouldUpdate: false,
  349. reason: "fallback-app-check-version",
  350. message:
  351. "服务端返回的 wgtUrl 不是 .wgt 文件地址,回退到原有整包升级检查流程",
  352. payload: data,
  353. };
  354. }
  355. if (!data.version || !data.wgtUrl) {
  356. return {
  357. shouldUpdate: false,
  358. reason: "invalid-data",
  359. message: "服务端返回缺少 version 或 wgtUrl,无法继续热更新",
  360. };
  361. }
  362. if (compareVersion(data.version, currentVersion) <= 0) {
  363. return {
  364. shouldUpdate: false,
  365. reason: "not-newer",
  366. message: "服务端返回的版本不高于当前版本,跳过本次热更新",
  367. payload: data,
  368. };
  369. }
  370. const skippedVersion = uni.getStorageSync(HOT_UPDATE_SKIP_VERSION_KEY);
  371. if (!data.force && skippedVersion === data.version) {
  372. return {
  373. shouldUpdate: false,
  374. reason: "skipped-same-version",
  375. message: "用户之前已经跳过该版本,本次启动不再重复提示",
  376. payload: data,
  377. };
  378. }
  379. return {
  380. shouldUpdate: true,
  381. reason: "has-update",
  382. payload: data,
  383. };
  384. };
  385. /**
  386. * 组装展示给用户的更新文案。
  387. *
  388. * 这里没有引入 i18n,也没有复用页面组件里的 UI。
  389. *
  390. * 原因:
  391. * 1. 热更新发生在 App 启动阶段,应该尽量减少对页面层、全局实例、组件状态的依赖。
  392. * 2. 现有项目的旧升级逻辑是在组件里拿 $t,这条链路不适合直接照搬到启动期。
  393. * 3. 启动期用系统级的 uni.showModal 更稳,也更符合“最小侵入式改造”。
  394. */
  395. const buildUpdateContent = (payload) => {
  396. const note = payload.note
  397. ? String(payload.note)
  398. : "修复已知问题,优化使用体验";
  399. const content = [`发现新的资源版本:${payload.version}`, "", note];
  400. if (payload.force) {
  401. content.push("", "该更新为强制更新,请完成安装后继续使用。");
  402. }
  403. return content.join("\n");
  404. };
  405. /**
  406. * 提示用户是否开始热更新。
  407. *
  408. * 成功分支:
  409. * - 用户点击确认,resolve(true),继续下载。
  410. *
  411. * 失败/取消分支:
  412. * - 非强更时,用户点击取消,记录本次跳过的版本,resolve(false)。
  413. * - 强更时,没有取消按钮,只能确认。
  414. */
  415. const confirmHotUpdate = (payload) => {
  416. return new Promise((resolve) => {
  417. uni.showModal({
  418. title: payload.force ? "发现重要更新" : "发现新版本",
  419. content: buildUpdateContent(payload),
  420. showCancel: !payload.force,
  421. confirmText: "立即更新",
  422. cancelText: "稍后再说",
  423. success: (result) => {
  424. if (result.confirm) {
  425. uni.removeStorageSync(HOT_UPDATE_SKIP_VERSION_KEY);
  426. resolve(true);
  427. return;
  428. }
  429. if (!payload.force && result.cancel) {
  430. uni.setStorageSync(HOT_UPDATE_SKIP_VERSION_KEY, payload.version);
  431. }
  432. resolve(false);
  433. },
  434. fail: () => {
  435. resolve(false);
  436. },
  437. });
  438. });
  439. };
  440. /**
  441. * 下载 .wgt 文件。
  442. *
  443. * 为什么这里要单独拆出来:
  444. * 1. 下载是一个明显独立的异步阶段,最需要看清楚成功和失败分支。
  445. * 2. 下载是启动期流程里耗时较长的一步,单独封装后更方便控制提示方式。
  446. *
  447. * 用户可见表现:
  448. * - 下载中:顶部展示固定 loading 提示,不展示实时百分比。
  449. * - 下载成功:loading 自动关闭,进入安装阶段。
  450. * - 下载失败:loading 关闭,并在上层统一提示“热更新失败”。
  451. *
  452. * 输出:
  453. * - resolve(tempFilePath) 返回下载到本地后的临时文件路径。
  454. */
  455. const downloadWgtPackage = (wgtUrl) => {
  456. console.log("wgtUrl :>> ", wgtUrl);
  457. return new Promise((resolve, reject) => {
  458. uni.showLoading({
  459. title: "下载更新中",
  460. mask: true,
  461. });
  462. uni.downloadFile({
  463. url: wgtUrl,
  464. timeout: 600000,
  465. success: (response) => {
  466. if (response.statusCode === 200 && response.tempFilePath) {
  467. resolve(response.tempFilePath);
  468. return;
  469. }
  470. reject(
  471. new Error(
  472. `热更新包下载失败,HTTP 状态码:${response.statusCode || "unknown"}`
  473. )
  474. );
  475. },
  476. fail: (error) => {
  477. reject(new Error(error?.errMsg || "热更新包下载失败"));
  478. },
  479. complete: () => {
  480. uni.hideLoading();
  481. },
  482. });
  483. });
  484. };
  485. /**
  486. * 安装下载完成后的 .wgt 包。
  487. *
  488. * 为什么安装时要带 appid:
  489. * 1. 官方文档说明可以传入 appid 做校验。
  490. * 2. 这可以避免服务端配置错误时,把不属于当前应用的资源包安装进来。
  491. *
  492. * 为什么 force 设为 false:
  493. * 1. force=true 会跳过版本校验,风险更高。
  494. * 2. 现在我们已经在服务端和客户端各做了一层版本判断,没有必要强制覆盖旧包。
  495. */
  496. const installWgtPackage = (filePath, appid) => {
  497. return new Promise((resolve, reject) => {
  498. try {
  499. plus.runtime.install(
  500. filePath,
  501. {
  502. appid,
  503. force: false,
  504. },
  505. () => {
  506. resolve();
  507. },
  508. (error) => {
  509. reject(
  510. new Error(
  511. error?.message || `热更新安装失败,错误码:${error?.code || ""}`
  512. )
  513. );
  514. }
  515. );
  516. } catch (error) {
  517. reject(error);
  518. }
  519. });
  520. };
  521. /**
  522. * 安装完成后提示用户并重启应用。
  523. *
  524. * 为什么这里显式调用 plus.runtime.restart:
  525. * 1. .wgt 安装完成只是把新资源放到了运行环境,想让用户真正跑到新代码,还需要重启应用。
  526. * 2. 这一步和旧的页面级整包升级不同,旧逻辑 Android 是直接安装 apk。
  527. * 3. 热更新的目标是“尽快让新前端资源生效”,因此安装完成后立即重启是最直接的方案。
  528. *
  529. * 运行锁现在是进程内 Promise;应用重启会自然释放,不再依赖持久化 storage 清锁。
  530. */
  531. const restartAppAfterInstall = (targetVersion) => {
  532. return new Promise((resolve) => {
  533. uni.showModal({
  534. title: "更新完成",
  535. content: `资源版本 ${targetVersion} 已安装完成,点击确定后将立即重启应用使更新生效。`,
  536. showCancel: false,
  537. success: () => {
  538. plus.runtime.restart();
  539. resolve();
  540. },
  541. fail: () => {
  542. /**
  543. * 即便弹窗失败,我们也仍然执行重启。
  544. *
  545. * 原因:
  546. * - 到这里说明安装已经完成。
  547. * - 如果不重启,用户还会继续运行旧资源,和“安装成功”的状态不一致。
  548. */
  549. plus.runtime.restart();
  550. resolve();
  551. },
  552. });
  553. });
  554. };
  555. /**
  556. * 统一展示“热更新失败”提示。
  557. *
  558. * 为什么不把所有失败都提示给用户:
  559. * 1. 启动阶段的“检查接口失败 / 无网络”很常见,如果每次启动都弹错,会严重影响体验。
  560. * 2. 所以我们只对“用户已经明确点击开始更新之后”的失败给出弹窗提示。
  561. * 3. 纯检查阶段失败,记录日志后继续进入原有流程即可。
  562. */
  563. const showHotUpdateError = (error, title = "更新失败") => {
  564. uni.showModal({
  565. title,
  566. content: error?.message || "热更新执行失败,请稍后重试",
  567. showCancel: false,
  568. });
  569. };
  570. /**
  571. * 内部热更新执行流程,由公开入口负责并发合并和调用频率控制。
  572. *
  573. * 这是你后续在 App.vue onLaunch 里真正调用的函数。
  574. *
  575. * 整体执行顺序:
  576. * 1. 等待 plus 就绪
  577. * 2. Android 端先检查是否存在新的 APK
  578. * 3. 没有 APK 更新时获取当前资源版本
  579. * 4. 请求服务端检查 WGT 更新
  580. * 5. 判断是否存在新的 .wgt 包
  581. * 6. 提示用户确认
  582. * 7. 下载 .wgt
  583. * 8. 安装 .wgt
  584. * 9. 重启应用
  585. *
  586. * 返回值:
  587. * - Promise<{ status: string, ... }>
  588. * 主要用于日志或后续扩展,目前 App.vue 不需要依赖它的返回值。
  589. */
  590. const executeWgtHotUpdate = async () => {
  591. // #ifdef APP-PLUS
  592. let currentStage = "prepare";
  593. let finalResult = null;
  594. try {
  595. currentStage = "plus-ready";
  596. await waitForPlusReady();
  597. if (uni.getSystemInfoSync().platform === "android") {
  598. currentStage = "request-app-upgrade";
  599. try {
  600. const appUpgradeResponse = await requestAppUpgradeInfo();
  601. if (
  602. shouldPrioritizeAppUpgrade(
  603. appUpgradeResponse,
  604. plus.runtime.version
  605. )
  606. ) {
  607. currentStage = "app-upgrade";
  608. const appUpgradeResult = await runAppUpgrade(appUpgradeResponse.data);
  609. finalResult = {
  610. status: appUpgradeResult.status,
  611. message: "检测到新的 APK,已进入整包更新流程",
  612. data: appUpgradeResponse.data,
  613. };
  614. return finalResult;
  615. }
  616. } catch (error) {
  617. // APK 检查失败时不阻断原有 WGT 更新能力。
  618. console.warn("[hot-update] APK 更新检查失败,继续检查 WGT", error);
  619. }
  620. }
  621. currentStage = "read-local-version";
  622. const wgtInfo = await getCurrentWgtInfo();
  623. currentStage = "request-server";
  624. const response = await requestHotUpdateInfo();
  625. const updateResult = normalizeUpdateResult(response, wgtInfo.version);
  626. if (!updateResult.shouldUpdate) {
  627. finalResult = {
  628. status: updateResult.reason,
  629. message: updateResult.message,
  630. data: updateResult.payload || null,
  631. };
  632. return finalResult;
  633. }
  634. currentStage = "confirm";
  635. const confirmed = await confirmHotUpdate(updateResult.payload);
  636. if (!confirmed) {
  637. finalResult = {
  638. status: "cancelled",
  639. message: "用户取消本次热更新",
  640. };
  641. return finalResult;
  642. }
  643. currentStage = "download";
  644. const filePath = await downloadWgtPackage(updateResult.payload.wgtUrl);
  645. currentStage = "install";
  646. await installWgtPackage(filePath, wgtInfo.appid);
  647. currentStage = "restart";
  648. await restartAppAfterInstall(updateResult.payload.version);
  649. finalResult = {
  650. status: "restarted",
  651. message: "热更新安装完成,应用已触发重启",
  652. };
  653. return finalResult;
  654. } catch (error) {
  655. console.error("[hot-update] 执行失败:", currentStage, error);
  656. if (currentStage === "download") {
  657. showHotUpdateError(error, "下载失败");
  658. } else if (currentStage === "install" || currentStage === "restart") {
  659. showHotUpdateError(error, "安装失败");
  660. } else {
  661. /**
  662. * 检查阶段失败只打日志,不主动打扰用户。
  663. *
  664. * 为什么这么处理:
  665. * - 当前项目 onLaunch 里还有数据库初始化、url scheme 参数接收等逻辑。
  666. * - 热更新检查只是“加分项”,不应该因为检查失败阻断原有启动流程。
  667. * - 这样最符合“最小侵入式改造”的目标。
  668. */
  669. }
  670. finalResult = {
  671. status: "error",
  672. stage: currentStage,
  673. error,
  674. };
  675. return finalResult;
  676. } finally {
  677. if (finalResult?.status === "fallback-app-check-version") {
  678. uni.setStorageSync(HOT_UPDATE_FALLBACK_PENDING_KEY, "1");
  679. uni.$emit(HOT_UPDATE_FALLBACK_APP_CHECK_EVENT, finalResult);
  680. }
  681. }
  682. // #endif
  683. return {
  684. status: "skip-non-app-plus",
  685. message: "当前不是 APP-PLUS 环境,跳过 .wgt 热更新检查",
  686. };
  687. };
  688. /**
  689. * 在任意页面均可调用的热更新入口。
  690. *
  691. * - 同一时刻只执行一个检查/下载/安装流程。
  692. * - 自动触发默认受三十秒检查间隔限制。
  693. * - 用户主动点击“检查更新”时传 { forceCheck: true } 可忽略时间间隔。
  694. */
  695. export const runWgtHotUpdate = ({ forceCheck = false } = {}) => {
  696. // #ifdef APP-PLUS
  697. clearLegacyHotUpdateLock();
  698. if (hotUpdatePromise) {
  699. return hotUpdatePromise;
  700. }
  701. const now = Date.now();
  702. if (!forceCheck && now - lastCheckStartedAt < HOT_UPDATE_CHECK_INTERVAL) {
  703. return Promise.resolve({
  704. status: "throttled",
  705. message: "距离上次热更新检查时间较短,跳过重复检查",
  706. });
  707. }
  708. lastCheckStartedAt = now;
  709. hotUpdatePromise = executeWgtHotUpdate().finally(() => {
  710. hotUpdatePromise = null;
  711. });
  712. return hotUpdatePromise;
  713. // #endif
  714. return Promise.resolve({
  715. status: "skip-non-app-plus",
  716. message: "当前不是 APP-PLUS 环境,跳过 .wgt 热更新检查",
  717. });
  718. };