hot-update.js 21 KB

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