@marktrue/dsh-mobile-remote 3.2.4-alpha.3-app → 3.2.4-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. package/CHANGELOG.md +1439 -0
  2. package/CONTRIBUTING.md +59 -0
  3. package/FAQ.md +111 -0
  4. package/README.md +254 -6
  5. package/cordis.patch.yml +14 -0
  6. package/docs/00-/345/274/200/345/217/221/346/200/273/347/272/262.md +125 -0
  7. package/docs/01-PRD.md +148 -0
  8. package/docs/02-architecture.md +249 -0
  9. package/docs/03-api.md +1154 -0
  10. package/docs/04-security.md +100 -0
  11. package/docs/05-test-cases.md +405 -0
  12. package/docs/06-install-run.md +372 -0
  13. package/docs/07-user-manual.md +70 -0
  14. package/docs/08-/346/211/251/345/261/225/345/274/200/345/217/221/346/214/207/345/215/227.md +86 -0
  15. package/docs/09-compatibility.md +227 -0
  16. package/docs/10-release.md +1067 -0
  17. package/docs/screenshots/01-home.png +0 -0
  18. package/docs/screenshots/02-notifications.png +0 -0
  19. package/docs/screenshots/03-sessions.png +0 -0
  20. package/docs/screenshots/04-chat.png +0 -0
  21. package/docs/screenshots/05-settings.png +0 -0
  22. package/docs/screenshots/06-new-session.png +0 -0
  23. package/docs/screenshots/pgy-appstore.jpg +0 -0
  24. package/docs/screenshots/pgy-mobile-app.jpg +0 -0
  25. package/docs/screenshots/pgy-pc-client.jpg +0 -0
  26. package/lib/account-usage.js +145 -0
  27. package/lib/app-alignment.js +112 -0
  28. package/lib/app-carrier-store.js +1972 -0
  29. package/lib/app-carrier.js +609 -0
  30. package/lib/client.js +1174 -0
  31. package/lib/desktop-form.js +738 -0
  32. package/lib/endpoint-registry.js +167 -0
  33. package/lib/git-read-bundled-provider.js +450 -0
  34. package/lib/git-read-service.js +601 -0
  35. package/lib/index.js +9622 -0
  36. package/lib/npm-update-source.js +226 -0
  37. package/lib/plugin-content-digest.js +80 -0
  38. package/lib/plugin-self-update.js +997 -0
  39. package/lib/plugin-update-controller.js +274 -0
  40. package/lib/plugin-update-log.js +164 -0
  41. package/lib/plugin-update-transaction.js +322 -0
  42. package/lib/plugin-update.js +309 -0
  43. package/lib/runtime-dependency-snapshot.js +345 -0
  44. package/lib/session-title-cache.js +123 -0
  45. package/lib/session-title-refresh.js +261 -0
  46. package/lib/tool-process.js +20 -0
  47. package/lib/update-fetch.js +24 -0
  48. package/lib/update-lock-arbiter.js +170 -0
  49. package/lib/update-supervisor.js +346 -0
  50. package/node_modules/ansi-regex/index.d.ts +37 -0
  51. package/node_modules/ansi-regex/index.js +10 -0
  52. package/node_modules/ansi-regex/license +9 -0
  53. package/node_modules/ansi-regex/package.json +55 -0
  54. package/node_modules/ansi-regex/readme.md +78 -0
  55. package/node_modules/ansi-styles/index.d.ts +345 -0
  56. package/node_modules/ansi-styles/index.js +163 -0
  57. package/node_modules/ansi-styles/license +9 -0
  58. package/node_modules/ansi-styles/package.json +56 -0
  59. package/node_modules/ansi-styles/readme.md +152 -0
  60. package/node_modules/camelcase/index.d.ts +63 -0
  61. package/node_modules/camelcase/index.js +76 -0
  62. package/node_modules/camelcase/license +9 -0
  63. package/node_modules/camelcase/package.json +43 -0
  64. package/node_modules/camelcase/readme.md +99 -0
  65. package/node_modules/cliui/CHANGELOG.md +76 -0
  66. package/node_modules/cliui/LICENSE.txt +14 -0
  67. package/node_modules/cliui/README.md +115 -0
  68. package/node_modules/cliui/index.js +354 -0
  69. package/node_modules/cliui/package.json +65 -0
  70. package/node_modules/color-convert/CHANGELOG.md +54 -0
  71. package/node_modules/color-convert/LICENSE +21 -0
  72. package/node_modules/color-convert/README.md +68 -0
  73. package/node_modules/color-convert/conversions.js +839 -0
  74. package/node_modules/color-convert/index.js +81 -0
  75. package/node_modules/color-convert/package.json +48 -0
  76. package/node_modules/color-convert/route.js +97 -0
  77. package/node_modules/color-name/LICENSE +8 -0
  78. package/node_modules/color-name/README.md +11 -0
  79. package/node_modules/color-name/index.js +152 -0
  80. package/node_modules/color-name/package.json +28 -0
  81. package/node_modules/decamelize/index.js +13 -0
  82. package/node_modules/decamelize/license +21 -0
  83. package/node_modules/decamelize/package.json +38 -0
  84. package/node_modules/decamelize/readme.md +48 -0
  85. package/node_modules/dijkstrajs/.travis.yml +4 -0
  86. package/node_modules/dijkstrajs/CONTRIBUTING.md +8 -0
  87. package/node_modules/dijkstrajs/LICENSE.md +19 -0
  88. package/node_modules/dijkstrajs/README.md +18 -0
  89. package/node_modules/dijkstrajs/dijkstra.js +165 -0
  90. package/node_modules/dijkstrajs/package.json +60 -0
  91. package/node_modules/dijkstrajs/test/dijkstra.test.js +96 -0
  92. package/node_modules/emoji-regex/LICENSE-MIT.txt +20 -0
  93. package/node_modules/emoji-regex/README.md +73 -0
  94. package/node_modules/emoji-regex/es2015/index.js +6 -0
  95. package/node_modules/emoji-regex/es2015/text.js +6 -0
  96. package/node_modules/emoji-regex/index.d.ts +23 -0
  97. package/node_modules/emoji-regex/index.js +6 -0
  98. package/node_modules/emoji-regex/package.json +50 -0
  99. package/node_modules/emoji-regex/text.js +6 -0
  100. package/node_modules/find-up/index.d.ts +137 -0
  101. package/node_modules/find-up/index.js +89 -0
  102. package/node_modules/find-up/license +9 -0
  103. package/node_modules/find-up/package.json +53 -0
  104. package/node_modules/find-up/readme.md +156 -0
  105. package/node_modules/get-caller-file/LICENSE.md +6 -0
  106. package/node_modules/get-caller-file/README.md +41 -0
  107. package/node_modules/get-caller-file/index.d.ts +2 -0
  108. package/node_modules/get-caller-file/index.js +22 -0
  109. package/node_modules/get-caller-file/index.js.map +1 -0
  110. package/node_modules/get-caller-file/package.json +42 -0
  111. package/node_modules/is-fullwidth-code-point/index.d.ts +17 -0
  112. package/node_modules/is-fullwidth-code-point/index.js +50 -0
  113. package/node_modules/is-fullwidth-code-point/license +9 -0
  114. package/node_modules/is-fullwidth-code-point/package.json +42 -0
  115. package/node_modules/is-fullwidth-code-point/readme.md +39 -0
  116. package/node_modules/locate-path/index.d.ts +83 -0
  117. package/node_modules/locate-path/index.js +65 -0
  118. package/node_modules/locate-path/license +9 -0
  119. package/node_modules/locate-path/package.json +45 -0
  120. package/node_modules/locate-path/readme.md +122 -0
  121. package/node_modules/p-limit/index.d.ts +38 -0
  122. package/node_modules/p-limit/index.js +57 -0
  123. package/node_modules/p-limit/license +9 -0
  124. package/node_modules/p-limit/package.json +52 -0
  125. package/node_modules/p-limit/readme.md +101 -0
  126. package/node_modules/p-locate/index.d.ts +64 -0
  127. package/node_modules/p-locate/index.js +52 -0
  128. package/node_modules/p-locate/license +9 -0
  129. package/node_modules/p-locate/package.json +53 -0
  130. package/node_modules/p-locate/readme.md +90 -0
  131. package/node_modules/p-try/index.d.ts +39 -0
  132. package/node_modules/p-try/index.js +9 -0
  133. package/node_modules/p-try/license +9 -0
  134. package/node_modules/p-try/package.json +42 -0
  135. package/node_modules/p-try/readme.md +58 -0
  136. package/node_modules/path-exists/index.d.ts +28 -0
  137. package/node_modules/path-exists/index.js +23 -0
  138. package/node_modules/path-exists/license +9 -0
  139. package/node_modules/path-exists/package.json +39 -0
  140. package/node_modules/path-exists/readme.md +52 -0
  141. package/node_modules/pngjs/.eslintignore +2 -0
  142. package/node_modules/pngjs/.eslintrc.json +19 -0
  143. package/node_modules/pngjs/.prettierignore +3 -0
  144. package/node_modules/pngjs/LICENSE +20 -0
  145. package/node_modules/pngjs/README.md +429 -0
  146. package/node_modules/pngjs/browser.js +16096 -0
  147. package/node_modules/pngjs/coverage/lcov-report/base.css +354 -0
  148. package/node_modules/pngjs/coverage/lcov-report/bitmapper.js.html +881 -0
  149. package/node_modules/pngjs/coverage/lcov-report/bitpacker.js.html +554 -0
  150. package/node_modules/pngjs/coverage/lcov-report/block-navigation.js +77 -0
  151. package/node_modules/pngjs/coverage/lcov-report/chunkstream.js.html +647 -0
  152. package/node_modules/pngjs/coverage/lcov-report/constants.js.html +176 -0
  153. package/node_modules/pngjs/coverage/lcov-report/crc.js.html +200 -0
  154. package/node_modules/pngjs/coverage/lcov-report/favicon.png +0 -0
  155. package/node_modules/pngjs/coverage/lcov-report/filter-pack.js.html +593 -0
  156. package/node_modules/pngjs/coverage/lcov-report/filter-parse-async.js.html +152 -0
  157. package/node_modules/pngjs/coverage/lcov-report/filter-parse-sync.js.html +143 -0
  158. package/node_modules/pngjs/coverage/lcov-report/filter-parse.js.html +611 -0
  159. package/node_modules/pngjs/coverage/lcov-report/format-normaliser.js.html +359 -0
  160. package/node_modules/pngjs/coverage/lcov-report/index.html +426 -0
  161. package/node_modules/pngjs/coverage/lcov-report/interlace.js.html +365 -0
  162. package/node_modules/pngjs/coverage/lcov-report/packer-async.js.html +230 -0
  163. package/node_modules/pngjs/coverage/lcov-report/packer-sync.js.html +248 -0
  164. package/node_modules/pngjs/coverage/lcov-report/packer.js.html +467 -0
  165. package/node_modules/pngjs/coverage/lcov-report/paeth-predictor.js.html +128 -0
  166. package/node_modules/pngjs/coverage/lcov-report/parser-async.js.html +575 -0
  167. package/node_modules/pngjs/coverage/lcov-report/parser-sync.js.html +404 -0
  168. package/node_modules/pngjs/coverage/lcov-report/parser.js.html +950 -0
  169. package/node_modules/pngjs/coverage/lcov-report/png-sync.js.html +116 -0
  170. package/node_modules/pngjs/coverage/lcov-report/png.js.html +662 -0
  171. package/node_modules/pngjs/coverage/lcov-report/prettify.css +101 -0
  172. package/node_modules/pngjs/coverage/lcov-report/prettify.js +1007 -0
  173. package/node_modules/pngjs/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  174. package/node_modules/pngjs/coverage/lcov-report/sorter.js +169 -0
  175. package/node_modules/pngjs/coverage/lcov-report/sync-inflate.js.html +584 -0
  176. package/node_modules/pngjs/coverage/lcov-report/sync-reader.js.html +215 -0
  177. package/node_modules/pngjs/coverage/lcov.info +2177 -0
  178. package/node_modules/pngjs/lib/bitmapper.js +267 -0
  179. package/node_modules/pngjs/lib/bitpacker.js +158 -0
  180. package/node_modules/pngjs/lib/chunkstream.js +189 -0
  181. package/node_modules/pngjs/lib/constants.js +32 -0
  182. package/node_modules/pngjs/lib/crc.js +40 -0
  183. package/node_modules/pngjs/lib/filter-pack.js +171 -0
  184. package/node_modules/pngjs/lib/filter-parse-async.js +24 -0
  185. package/node_modules/pngjs/lib/filter-parse-sync.js +21 -0
  186. package/node_modules/pngjs/lib/filter-parse.js +177 -0
  187. package/node_modules/pngjs/lib/format-normaliser.js +93 -0
  188. package/node_modules/pngjs/lib/interlace.js +95 -0
  189. package/node_modules/pngjs/lib/packer-async.js +50 -0
  190. package/node_modules/pngjs/lib/packer-sync.js +56 -0
  191. package/node_modules/pngjs/lib/packer.js +129 -0
  192. package/node_modules/pngjs/lib/paeth-predictor.js +16 -0
  193. package/node_modules/pngjs/lib/parser-async.js +165 -0
  194. package/node_modules/pngjs/lib/parser-sync.js +108 -0
  195. package/node_modules/pngjs/lib/parser.js +290 -0
  196. package/node_modules/pngjs/lib/png-sync.js +12 -0
  197. package/node_modules/pngjs/lib/png.js +194 -0
  198. package/node_modules/pngjs/lib/sync-inflate.js +168 -0
  199. package/node_modules/pngjs/lib/sync-reader.js +45 -0
  200. package/node_modules/pngjs/package.json +73 -0
  201. package/node_modules/qrcode/README.md +770 -0
  202. package/node_modules/qrcode/bin/qrcode +159 -0
  203. package/node_modules/qrcode/helper/to-sjis-browser.js +2 -0
  204. package/node_modules/qrcode/helper/to-sjis.js +105 -0
  205. package/node_modules/qrcode/lib/browser.js +76 -0
  206. package/node_modules/qrcode/lib/can-promise.js +7 -0
  207. package/node_modules/qrcode/lib/core/alignment-pattern.js +83 -0
  208. package/node_modules/qrcode/lib/core/alphanumeric-data.js +59 -0
  209. package/node_modules/qrcode/lib/core/bit-buffer.js +37 -0
  210. package/node_modules/qrcode/lib/core/bit-matrix.js +65 -0
  211. package/node_modules/qrcode/lib/core/byte-data.js +30 -0
  212. package/node_modules/qrcode/lib/core/error-correction-code.js +135 -0
  213. package/node_modules/qrcode/lib/core/error-correction-level.js +50 -0
  214. package/node_modules/qrcode/lib/core/finder-pattern.js +22 -0
  215. package/node_modules/qrcode/lib/core/format-info.js +29 -0
  216. package/node_modules/qrcode/lib/core/galois-field.js +69 -0
  217. package/node_modules/qrcode/lib/core/kanji-data.js +54 -0
  218. package/node_modules/qrcode/lib/core/mask-pattern.js +234 -0
  219. package/node_modules/qrcode/lib/core/mode.js +167 -0
  220. package/node_modules/qrcode/lib/core/numeric-data.js +43 -0
  221. package/node_modules/qrcode/lib/core/polynomial.js +62 -0
  222. package/node_modules/qrcode/lib/core/qrcode.js +495 -0
  223. package/node_modules/qrcode/lib/core/reed-solomon-encoder.js +56 -0
  224. package/node_modules/qrcode/lib/core/regex.js +31 -0
  225. package/node_modules/qrcode/lib/core/segments.js +330 -0
  226. package/node_modules/qrcode/lib/core/utils.js +63 -0
  227. package/node_modules/qrcode/lib/core/version-check.js +9 -0
  228. package/node_modules/qrcode/lib/core/version.js +163 -0
  229. package/node_modules/qrcode/lib/index.js +12 -0
  230. package/node_modules/qrcode/lib/renderer/canvas.js +63 -0
  231. package/node_modules/qrcode/lib/renderer/png.js +78 -0
  232. package/node_modules/qrcode/lib/renderer/svg-tag.js +81 -0
  233. package/node_modules/qrcode/lib/renderer/svg.js +19 -0
  234. package/node_modules/qrcode/lib/renderer/terminal/terminal-small.js +85 -0
  235. package/node_modules/qrcode/lib/renderer/terminal/terminal.js +49 -0
  236. package/node_modules/qrcode/lib/renderer/terminal.js +9 -0
  237. package/node_modules/qrcode/lib/renderer/utf8.js +71 -0
  238. package/node_modules/qrcode/lib/renderer/utils.js +99 -0
  239. package/node_modules/qrcode/lib/server.js +138 -0
  240. package/node_modules/qrcode/license +10 -0
  241. package/node_modules/qrcode/package.json +77 -0
  242. package/node_modules/require-directory/.jshintrc +67 -0
  243. package/node_modules/require-directory/.npmignore +1 -0
  244. package/node_modules/require-directory/.travis.yml +3 -0
  245. package/node_modules/require-directory/LICENSE +22 -0
  246. package/node_modules/require-directory/README.markdown +184 -0
  247. package/node_modules/require-directory/index.js +86 -0
  248. package/node_modules/require-directory/package.json +40 -0
  249. package/node_modules/require-main-filename/CHANGELOG.md +26 -0
  250. package/node_modules/require-main-filename/LICENSE.txt +14 -0
  251. package/node_modules/require-main-filename/README.md +26 -0
  252. package/node_modules/require-main-filename/index.js +18 -0
  253. package/node_modules/require-main-filename/package.json +35 -0
  254. package/node_modules/set-blocking/CHANGELOG.md +26 -0
  255. package/node_modules/set-blocking/LICENSE.txt +14 -0
  256. package/node_modules/set-blocking/README.md +31 -0
  257. package/node_modules/set-blocking/index.js +7 -0
  258. package/node_modules/set-blocking/package.json +42 -0
  259. package/node_modules/string-width/index.d.ts +29 -0
  260. package/node_modules/string-width/index.js +47 -0
  261. package/node_modules/string-width/license +9 -0
  262. package/node_modules/string-width/package.json +56 -0
  263. package/node_modules/string-width/readme.md +50 -0
  264. package/node_modules/strip-ansi/index.d.ts +17 -0
  265. package/node_modules/strip-ansi/index.js +4 -0
  266. package/node_modules/strip-ansi/license +9 -0
  267. package/node_modules/strip-ansi/package.json +54 -0
  268. package/node_modules/strip-ansi/readme.md +46 -0
  269. package/node_modules/which-module/LICENSE +13 -0
  270. package/node_modules/which-module/README.md +58 -0
  271. package/node_modules/which-module/index.js +9 -0
  272. package/node_modules/which-module/package.json +41 -0
  273. package/node_modules/wrap-ansi/index.js +186 -0
  274. package/node_modules/wrap-ansi/license +9 -0
  275. package/node_modules/wrap-ansi/package.json +61 -0
  276. package/node_modules/wrap-ansi/readme.md +97 -0
  277. package/node_modules/y18n/CHANGELOG.md +35 -0
  278. package/node_modules/y18n/LICENSE +13 -0
  279. package/node_modules/y18n/README.md +109 -0
  280. package/node_modules/y18n/index.js +188 -0
  281. package/node_modules/y18n/package.json +39 -0
  282. package/node_modules/yargs/CHANGELOG.md +420 -0
  283. package/node_modules/yargs/LICENSE +21 -0
  284. package/node_modules/yargs/README.md +140 -0
  285. package/node_modules/yargs/build/lib/apply-extends.d.ts +2 -0
  286. package/node_modules/yargs/build/lib/apply-extends.js +65 -0
  287. package/node_modules/yargs/build/lib/argsert.d.ts +2 -0
  288. package/node_modules/yargs/build/lib/argsert.js +65 -0
  289. package/node_modules/yargs/build/lib/command.d.ts +64 -0
  290. package/node_modules/yargs/build/lib/command.js +416 -0
  291. package/node_modules/yargs/build/lib/common-types.d.ts +36 -0
  292. package/node_modules/yargs/build/lib/common-types.js +25 -0
  293. package/node_modules/yargs/build/lib/completion-templates.d.ts +2 -0
  294. package/node_modules/yargs/build/lib/completion-templates.js +50 -0
  295. package/node_modules/yargs/build/lib/completion.d.ts +21 -0
  296. package/node_modules/yargs/build/lib/completion.js +135 -0
  297. package/node_modules/yargs/build/lib/is-promise.d.ts +1 -0
  298. package/node_modules/yargs/build/lib/is-promise.js +9 -0
  299. package/node_modules/yargs/build/lib/levenshtein.d.ts +1 -0
  300. package/node_modules/yargs/build/lib/levenshtein.js +58 -0
  301. package/node_modules/yargs/build/lib/middleware.d.ts +10 -0
  302. package/node_modules/yargs/build/lib/middleware.js +57 -0
  303. package/node_modules/yargs/build/lib/obj-filter.d.ts +1 -0
  304. package/node_modules/yargs/build/lib/obj-filter.js +14 -0
  305. package/node_modules/yargs/build/lib/parse-command.d.ts +11 -0
  306. package/node_modules/yargs/build/lib/parse-command.js +36 -0
  307. package/node_modules/yargs/build/lib/process-argv.d.ts +2 -0
  308. package/node_modules/yargs/build/lib/process-argv.js +31 -0
  309. package/node_modules/yargs/build/lib/usage.d.ts +49 -0
  310. package/node_modules/yargs/build/lib/usage.js +540 -0
  311. package/node_modules/yargs/build/lib/validation.d.ts +34 -0
  312. package/node_modules/yargs/build/lib/validation.js +330 -0
  313. package/node_modules/yargs/build/lib/yargs.d.ts +274 -0
  314. package/node_modules/yargs/build/lib/yargs.js +1190 -0
  315. package/node_modules/yargs/build/lib/yerror.d.ts +4 -0
  316. package/node_modules/yargs/build/lib/yerror.js +11 -0
  317. package/node_modules/yargs/index.js +40 -0
  318. package/node_modules/yargs/locales/be.json +46 -0
  319. package/node_modules/yargs/locales/de.json +46 -0
  320. package/node_modules/yargs/locales/en.json +51 -0
  321. package/node_modules/yargs/locales/es.json +46 -0
  322. package/node_modules/yargs/locales/fi.json +49 -0
  323. package/node_modules/yargs/locales/fr.json +53 -0
  324. package/node_modules/yargs/locales/hi.json +49 -0
  325. package/node_modules/yargs/locales/hu.json +46 -0
  326. package/node_modules/yargs/locales/id.json +50 -0
  327. package/node_modules/yargs/locales/it.json +46 -0
  328. package/node_modules/yargs/locales/ja.json +51 -0
  329. package/node_modules/yargs/locales/ko.json +49 -0
  330. package/node_modules/yargs/locales/nb.json +44 -0
  331. package/node_modules/yargs/locales/nl.json +49 -0
  332. package/node_modules/yargs/locales/nn.json +44 -0
  333. package/node_modules/yargs/locales/pirate.json +13 -0
  334. package/node_modules/yargs/locales/pl.json +49 -0
  335. package/node_modules/yargs/locales/pt.json +45 -0
  336. package/node_modules/yargs/locales/pt_BR.json +48 -0
  337. package/node_modules/yargs/locales/ru.json +46 -0
  338. package/node_modules/yargs/locales/th.json +46 -0
  339. package/node_modules/yargs/locales/tr.json +48 -0
  340. package/node_modules/yargs/locales/zh_CN.json +48 -0
  341. package/node_modules/yargs/locales/zh_TW.json +47 -0
  342. package/node_modules/yargs/package.json +92 -0
  343. package/node_modules/yargs/yargs.js +14 -0
  344. package/node_modules/yargs-parser/CHANGELOG.md +601 -0
  345. package/node_modules/yargs-parser/LICENSE.txt +14 -0
  346. package/node_modules/yargs-parser/README.md +449 -0
  347. package/node_modules/yargs-parser/index.js +1032 -0
  348. package/node_modules/yargs-parser/lib/tokenize-arg-string.js +40 -0
  349. package/node_modules/yargs-parser/package.json +46 -0
  350. package/package.json +65 -12
  351. package/DSH-Remote-v3.2.4.apk +0 -4
  352. package/manifest.json +0 -21
@@ -0,0 +1,997 @@
1
+ // 插件自更新(CONTEXT「插件自更新」「更新接续」)。
2
+ //
3
+ // 本模块负责**事务的推进**,副作用一律可注入;"重启后是否真的生效、失败怎么回滚"
4
+ // 交给独立的外部监督器(`lib/update-supervisor.js`)——因为回滚绝不能依赖新版插件
5
+ // 能否加载成功(评审 #32 note 1286 的 R1)。
6
+ //
7
+ // 关键约束(都来自那次评审):
8
+ // - 产物目录按**内容标识**命名且不可变,同版本不同内容不会互相覆盖(R2);
9
+ // - profile 目录靠**验证式查找**得到,不靠模块层级推断(R3);
10
+ // - 下载地址由 `resolvePluginUrl()` 解析后接入执行链(R4);
11
+ // - 期望内容标识以**解包后复算的值**为准(合成清单没有它也能正确收尾)(R5);
12
+ // - 安装/解包用**异步子进程**,不在宿主主线程上阻塞 HTTP/SSE/agent(R6)。
13
+ import { createReadStream, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
14
+ import { basename, dirname, join } from "node:path";
15
+ import { createHash } from "node:crypto";
16
+ import { spawn, spawnSync } from "node:child_process";
17
+ import { appCarrierAlias, discoverAppCarrier } from "./app-carrier.js";
18
+ import { pluginContentDigest } from "./plugin-content-digest.js";
19
+ import { fetchUpdateResource } from "./update-fetch.js";
20
+ import { decidePluginUpdate, normalizeReleaseManifest } from "./plugin-update.js";
21
+ import { NPM_CHECK_LOG_LIMIT, createNpmPackageView, resolveNpmUpdateCandidate } from "./npm-update-source.js";
22
+ import { quoteShellArgument, shellOptions } from "./tool-process.js";
23
+ import {
24
+ TRANSACTION_PHASE,
25
+ acquireLock,
26
+ assertUpdateLease,
27
+ serverOperationPath,
28
+ switchOwnedDependency,
29
+ validOperationId,
30
+ writeOwnedTransaction,
31
+ artifactDir,
32
+ artifactRoot,
33
+ convergeStaleTransaction,
34
+ defaultCopyDir,
35
+ dependencyKeyIn,
36
+ dependencyKeysFor,
37
+ LEGACY_DEPENDENCY_KEY,
38
+ dirReady,
39
+ newOperationId,
40
+ readTransaction,
41
+ releaseLock,
42
+ snapshotDir,
43
+ snapshotInstalled,
44
+ snapshotReady,
45
+ snapshotRoot,
46
+ transactionFile,
47
+ updateLockPath,
48
+ workRoot,
49
+ writeTransaction,
50
+ } from "./plugin-update-transaction.js";
51
+
52
+ export { TRANSACTION_PHASE };
53
+ export { transactionFile };
54
+ export { updateLockPath };
55
+ export const updateLockFile = updateLockPath;
56
+
57
+ /** 兼容旧名(状态文件即事务文件)。 */
58
+ export const updateStateFile = transactionFile;
59
+ export const readUpdateState = readTransaction;
60
+ export const writeUpdateState = writeTransaction;
61
+
62
+ /** 插件自更新的默认来源:上游仓库的 Release(清单优先,缺失则按 release 资产合成)。 */
63
+ export const UPSTREAM_REPO = "201222-L/dsh-mobile-remote";
64
+ export const UPSTREAM_MANIFEST_URL = `https://github.com/${UPSTREAM_REPO}/releases/latest/download/manifest.json`;
65
+ export const UPSTREAM_RELEASES_API = `https://api.github.com/repos/${UPSTREAM_REPO}/releases/latest`;
66
+
67
+ /** 更新来源的三种配置形态。 */
68
+ export const UPDATE_SOURCE_KIND = { NPM: "npm", UPSTREAM: "upstream", OFF: "off", CUSTOM: "custom", INVALID: "invalid" };
69
+
70
+ /** 检查结果(会随 bootstrap/diagnostics 下发,也是 PC 端提示的依据)。 */
71
+ export const UPDATE_CHECK_STATE = {
72
+ DISABLED: "disabled",
73
+ UNAVAILABLE: "unavailable",
74
+ UP_TO_DATE: "up-to-date",
75
+ UPDATE_AVAILABLE: "update-available",
76
+ LOCAL_AHEAD: "local-ahead",
77
+ UNKNOWN: "unknown",
78
+ };
79
+
80
+ /** 自更新过程阶段(进度上报用)。 */
81
+ export const UPDATE_STEP = {
82
+ DOWNLOAD: "download",
83
+ VERIFY: "verify",
84
+ EXTRACT: "extract",
85
+ SNAPSHOT: "snapshot",
86
+ SWITCH: "switch",
87
+ INSTALL: "install",
88
+ RESTART: "restart",
89
+ };
90
+
91
+ /** 回滚最多一次:避免"重启风暴"。 */
92
+ export const MAX_ROLLBACK_ATTEMPTS = 1;
93
+
94
+ /** 异步跑一个子进程;两个输出管道始终并行 drain,避免高输出安装被管道背压卡住。 */
95
+ export function runCommand(cmd, args, { cwd, timeoutMs = 10 * 60 * 1000, maxOutputBytes = 16 * 1024, onOutput, platform = process.platform } = {}) {
96
+ return new Promise((resolve) => {
97
+ let child;
98
+ try {
99
+ // Windows 上 npm/pnpm 是 .cmd,必须经 shell 才能解析(见 tool-process.js)。
100
+ child = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], ...shellOptions(platform) });
101
+ } catch (e) {
102
+ resolve({ ok: false, error: String(e?.message ?? e), exitCode: null, stdout: "", stderr: "" });
103
+ return;
104
+ }
105
+ const cap = Number.isSafeInteger(maxOutputBytes) && maxOutputBytes > 0 ? maxOutputBytes : 16 * 1024;
106
+ let stdout = Buffer.alloc(0), stderr = Buffer.alloc(0), timedOut = false, callbackFailed = false, settled = false;
107
+ const appendTail = (prior, chunk) => {
108
+ const next = Buffer.concat([prior, Buffer.from(chunk)]);
109
+ return next.length > cap ? next.subarray(next.length - cap) : next;
110
+ };
111
+ const onChunk = (stream, buffer) => {
112
+ if (stream === "stdout") stdout = appendTail(stdout, buffer);
113
+ else stderr = appendTail(stderr, buffer);
114
+ if (typeof onOutput === "function") {
115
+ try { onOutput(stream, Buffer.from(buffer).toString("utf8")); }
116
+ catch { callbackFailed = true; }
117
+ }
118
+ };
119
+ let timer;
120
+ const finish = (result) => {
121
+ if (settled) return;
122
+ settled = true;
123
+ clearTimeout(timer);
124
+ resolve({ ...result, stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8") });
125
+ };
126
+ timer = setTimeout(() => {
127
+ timedOut = true;
128
+ try { child.kill("SIGKILL"); } catch { /* already exited */ }
129
+ }, timeoutMs);
130
+ child.stdout?.on("data", chunk => onChunk("stdout", chunk));
131
+ child.stderr?.on("data", chunk => onChunk("stderr", chunk));
132
+ child.on("error", (e) => finish({ ok: false, error: String(e?.message ?? e), exitCode: null }));
133
+ child.on("close", (code) => {
134
+ if (timedOut) {
135
+ finish({ ok: false, error: `timeout-after-${Math.round(timeoutMs / 1000)}s`, exitCode: code });
136
+ return;
137
+ }
138
+ if (callbackFailed) {
139
+ finish({ ok: false, error: "output-capture-failed", exitCode: code });
140
+ return;
141
+ }
142
+ finish(code === 0
143
+ ? { ok: true, exitCode: 0 }
144
+ : { ok: false, error: `exit-${code ?? "unknown"}`, exitCode: code });
145
+ });
146
+ });
147
+ }
148
+
149
+ // ── 更新来源 ──────────────────────────────────────────────────────────────
150
+
151
+ /** 解析配置里的更新来源。默认 `upstream`;`off` 表示完全不检查;其余必须是 https 地址。 */
152
+ export function resolveManifestSource(configValue) {
153
+ const v = typeof configValue === "string" ? configValue.trim() : "";
154
+ if (v === "" || v === "off") return { kind: UPDATE_SOURCE_KIND.OFF };
155
+ if (v === "upstream") return { kind: UPDATE_SOURCE_KIND.UPSTREAM, url: UPSTREAM_MANIFEST_URL, api: UPSTREAM_RELEASES_API };
156
+ // 自建主机源就在同一台机器上:受保护端点走回环 http(凭据只发给这个 origin)。
157
+ // 外部地址仍只允许 https,不放宽。
158
+ if (/^https:\/\/\S+$/.test(v)) return { kind: UPDATE_SOURCE_KIND.CUSTOM, url: v };
159
+ try {
160
+ const u = new URL(v);
161
+ if (u.protocol === "http:" && (u.hostname === "127.0.0.1" || u.hostname === "localhost") && u.pathname.startsWith("/")) {
162
+ return { kind: UPDATE_SOURCE_KIND.CUSTOM, url: v, loopback: true };
163
+ }
164
+ } catch {
165
+ // 交给下面判非法
166
+ }
167
+ return { kind: UPDATE_SOURCE_KIND.INVALID, value: v };
168
+ }
169
+
170
+ /** Resolve the fork's fixed NPM source; legacy source values migrate to NPM, only off disables. */
171
+ export function resolvePluginUpdateSource(configValue) {
172
+ const value = typeof configValue === "string" ? configValue.trim() : "";
173
+ if (value === "off") return { kind: UPDATE_SOURCE_KIND.OFF };
174
+ if (value === "" || value === "npm" || value === "upstream" || /^https:\/\/\S+$/.test(value)) {
175
+ return { kind: UPDATE_SOURCE_KIND.NPM, migrated: value !== "" && value !== "npm" };
176
+ }
177
+ try {
178
+ const url = new URL(value);
179
+ if (url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost") && url.pathname.startsWith("/")) {
180
+ return { kind: UPDATE_SOURCE_KIND.NPM, migrated: true };
181
+ }
182
+ } catch {
183
+ // invalid values are rejected below
184
+ }
185
+ return { kind: UPDATE_SOURCE_KIND.INVALID, value };
186
+ }
187
+
188
+ async function fetchJson(url, fetchImpl, userAgent, auth = {}) {
189
+ try {
190
+ const { response: res, finalUrl } = await fetchUpdateResource(url, { fetchImpl, ...auth,
191
+ headers: { "user-agent": userAgent, accept: "application/json" }, signal: AbortSignal.timeout(10_000),
192
+ });
193
+ if (!res?.ok) return { ok: false, error: `manifest-http-${res?.status ?? "?"}` };
194
+ return { ok: true, json: await res.json(), finalUrl };
195
+ } catch (e) {
196
+ return { ok: false, error: `manifest-fetch-failed: ${e?.message ?? e}` };
197
+ }
198
+ }
199
+
200
+ /**
201
+ * 从 GitHub Release 资产**合成**一份清单。上游尚未把 manifest.json 作为发布资产时,
202
+ * 走这条路仍能拿到版本号与字节哈希(asset 自带的 digest),只是**没有内容标识**——
203
+ * 执行时以解包后复算的内容标识为准(R5),判定阶段则如实标注 synthesized。
204
+ */
205
+ export async function synthesizeFromGithubRelease(apiUrl, { fetchImpl = fetch, userAgent = "dsh-mobile-remote" } = {}) {
206
+ const r = await fetchJson(apiUrl, fetchImpl, userAgent);
207
+ if (!r.ok) return { ok: false, error: r.error };
208
+ const release = r.json ?? {};
209
+ const version = String(release.tag_name ?? "").replace(/^v/, "");
210
+ const asset = (Array.isArray(release.assets) ? release.assets : []).find((a) => typeof a?.name === "string" && a.name.endsWith(".tgz"));
211
+ if (!version || !asset) return { ok: false, error: "release-asset-missing" };
212
+ const digest = String(asset.digest ?? "");
213
+ const manifest = normalizeReleaseManifest({
214
+ schema: 1,
215
+ plugin: {
216
+ version,
217
+ file: asset.name,
218
+ url: asset.browser_download_url ?? "",
219
+ size: Number.isFinite(asset.size) ? asset.size : undefined,
220
+ sha256: digest.startsWith("sha256:") ? digest.slice("sha256:".length) : "",
221
+ },
222
+ });
223
+ if (!manifest.ok) return { ok: false, error: manifest.error };
224
+ return { ok: true, manifest, synthesized: true };
225
+ }
226
+
227
+ /** 主机源返回的是 `{ ok, manifest }` 信封;解包后再规范化(G1)。 */
228
+ export function unwrapManifestEnvelope(raw) {
229
+ if (raw && typeof raw === "object" && !Array.isArray(raw) && raw.manifest && typeof raw.manifest === "object" && !("schema" in raw)) {
230
+ return raw.manifest;
231
+ }
232
+ return raw;
233
+ }
234
+
235
+ /** 取回并规范化发布清单。upstream 优先取清单资产;取不到再按 release 资产合成。 */
236
+ export async function fetchReleaseManifest({ source, fetchImpl = fetch, userAgent = "dsh-mobile-remote", auth = {} } = {}) {
237
+ if (!source || source.kind === UPDATE_SOURCE_KIND.OFF) return { ok: false, error: "update-source-off" };
238
+ if (source.kind === UPDATE_SOURCE_KIND.INVALID) return { ok: false, error: "update-source-invalid" };
239
+ if (source.kind === UPDATE_SOURCE_KIND.UPSTREAM) {
240
+ const direct = await fetchJson(source.url, fetchImpl, userAgent, auth);
241
+ if (direct.ok) {
242
+ const manifest = normalizeReleaseManifest(unwrapManifestEnvelope(direct.json));
243
+ if (manifest.ok) return { ok: true, manifest, synthesized: false, manifestUrl: direct.finalUrl };
244
+ }
245
+ const synthesized = await synthesizeFromGithubRelease(source.api, { fetchImpl, userAgent });
246
+ return synthesized.ok ? { ...synthesized, manifestUrl: source.api } : synthesized;
247
+ }
248
+ const custom = await fetchJson(source.url, fetchImpl, userAgent, auth);
249
+ if (!custom.ok) return { ok: false, error: custom.error };
250
+ const manifest = normalizeReleaseManifest(unwrapManifestEnvelope(custom.json));
251
+ if (!manifest.ok) return { ok: false, error: manifest.error };
252
+ return { ok: true, manifest, synthesized: false, manifestUrl: custom.finalUrl };
253
+ }
254
+
255
+ /**
256
+ * 解析插件包的下载地址(G1):
257
+ * - 清单给了**绝对** url → 直接用;
258
+ * - 清单给了**相对** url → 相对**清单自身地址**解析(主机源就是这种:`/m/api/update/plugin`);
259
+ * - 只给了文件名 → 同样相对清单地址解析。
260
+ */
261
+ export function resolvePluginUrl(manifestUrl, plugin) {
262
+ const raw = typeof plugin?.url === "string" ? plugin.url.trim() : "";
263
+ const target = raw !== "" ? raw : (plugin?.file ?? "");
264
+ if (target === "") return "";
265
+ try {
266
+ // 绝对地址直接返回(校验交给下载器的协议白名单)
267
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target)) return target;
268
+ if (!manifestUrl) return "";
269
+ return new URL(target, manifestUrl).toString();
270
+ } catch {
271
+ return "";
272
+ }
273
+ }
274
+
275
+ /** 只允许 https(或本机回环 http,供自建主机源使用)。 */
276
+ export function isAllowedDownloadUrl(url) {
277
+ try {
278
+ const u = new URL(url);
279
+ if (u.protocol === "https:") return true;
280
+ if (u.protocol === "http:" && (u.hostname === "127.0.0.1" || u.hostname === "localhost")) return true;
281
+ return false;
282
+ } catch {
283
+ return false;
284
+ }
285
+ }
286
+
287
+ /**
288
+ * 该形态下如何更新;不能自更新时给出**与实际安装方式对应的那一条**降级指引(R11)。
289
+ * `canSelfUpdate` 由调用方按宿主运行形态判定后传入。
290
+ */
291
+ export function planForShape(shape, { specifier = "", dirPath = "", canSelfUpdate = true, reason = "" } = {}) {
292
+ const installCmd = "pnpm install";
293
+ const base = (() => {
294
+ switch (shape) {
295
+ case "registry":
296
+ return {
297
+ summary: "从 npm 源升级:改写 profile 依赖版本后重新安装",
298
+ steps: [`把 profile 的依赖版本改为新版(当前 ${specifier || "未知"})`, "重新安装依赖", "重启 DSH 让插件生效"],
299
+ commands: [installCmd],
300
+ };
301
+ case "github-tag":
302
+ return {
303
+ summary: "git 依赖升级:把 tag 改到新版本后重新安装",
304
+ steps: [`把 profile 依赖的 tag 改为 v<新版>(当前 ${specifier || "未知"})`, "重新安装依赖", "重启 DSH 让插件生效"],
305
+ commands: [installCmd],
306
+ };
307
+ case "file-clone":
308
+ return {
309
+ summary: "本地 clone 升级:拉取新代码后重新安装",
310
+ steps: [`在 clone 目录拉取新版本${dirPath ? `(${dirPath})` : ""}`, "重新安装依赖", "重启 DSH 让插件生效"],
311
+ commands: [dirPath ? `git -C ${dirPath} pull` : "git pull", installCmd],
312
+ };
313
+ case "file-dir":
314
+ return {
315
+ summary: "本地目录升级:用新版内容替换该目录后重新安装",
316
+ steps: [`用新版插件内容替换该目录${dirPath ? `(${dirPath})` : ""}`, "重新安装依赖", "重启 DSH 让插件生效"],
317
+ commands: [installCmd],
318
+ };
319
+ case "tgz-file":
320
+ return {
321
+ summary: "tgz 安装升级:换上新版 tgz 后重新安装",
322
+ steps: [`下载新版 tgz 并替换${dirPath ? `(${dirPath})` : "原包"}`, "重新安装依赖", "重启 DSH 让插件生效"],
323
+ commands: [installCmd],
324
+ };
325
+ default:
326
+ return {
327
+ summary: "无法识别本机插件安装方式,按 docs/06-install-run.md 的三种方式之一处理",
328
+ steps: ["确认插件是通过 clone、git tag 还是 tgz 安装的", "按对应方式更新到新版", "重启 DSH 让插件生效"],
329
+ commands: [],
330
+ };
331
+ }
332
+ })();
333
+ return { ...base, shape, mode: canSelfUpdate ? "auto" : "guidance", reason };
334
+ }
335
+
336
+ /** 检查是否有新版(**不下载、不改动任何东西**)。 */
337
+ export async function checkForUpdate({
338
+ configValue = "npm",
339
+ packageName = "",
340
+ npmPackageView = null,
341
+ npmCwd,
342
+ localVersion = "",
343
+ localContentSha256 = "",
344
+ fetchImpl = fetch,
345
+ userAgent = "dsh-mobile-remote",
346
+ capabilities = null,
347
+ auth = {},
348
+ } = {}) {
349
+ const withCaps = (r) => (capabilities ? { ...r, ...capabilities } : r);
350
+ // 检查日志(脱敏、限行长):和手机端日志一样可展开/复制,避免“只给一个错误码”的黑盒。
351
+ const checkLog = [];
352
+ const finish = (result) => withCaps({
353
+ ...result,
354
+ source: "npm",
355
+ checkedAt: new Date().toISOString(),
356
+ checkLog: checkLog.slice(-NPM_CHECK_LOG_LIMIT),
357
+ });
358
+ const source = resolvePluginUpdateSource(configValue);
359
+ if (source.kind === UPDATE_SOURCE_KIND.OFF) {
360
+ return finish({ state: UPDATE_CHECK_STATE.DISABLED, reason: "update-source-off" });
361
+ }
362
+ if (source.kind === UPDATE_SOURCE_KIND.INVALID) {
363
+ checkLog.push("更新来源配置无效(应固定为宿主 npm registry)");
364
+ return finish({ state: UPDATE_CHECK_STATE.UNAVAILABLE, reason: "update-source-invalid" });
365
+ }
366
+ const view = npmPackageView ?? createNpmPackageView({ packageName, cwd: npmCwd });
367
+ const resolved = await resolveNpmUpdateCandidate({ packageName, localVersion, packageView: view, log: checkLog });
368
+ if (!resolved.ok) {
369
+ checkLog.push(`检查失败:${resolved.reason}`);
370
+ return finish({ state: UPDATE_CHECK_STATE.UNAVAILABLE, reason: resolved.reason });
371
+ }
372
+ const remote = { version: resolved.plugin.version, contentSha256: resolved.plugin.contentSha256 || "" };
373
+ const decision = decidePluginUpdate({ localVersion, localContentSha256, remote });
374
+ // NPM versions are immutable. Never turn a same-version content mutation into an update.
375
+ if (decision.reason === "content-differs-same-version") {
376
+ checkLog.push(`检查失败:${remote.version} 内容标识与已安装内容不一致(不可变版本不得覆盖)`);
377
+ return finish({ state: UPDATE_CHECK_STATE.UNAVAILABLE, reason: "npm-version-content-mismatch" });
378
+ }
379
+ checkLog.push(`结论:${decision.action}(${decision.reason})`);
380
+ return finish({
381
+ state: decision.action,
382
+ reason: decision.reason,
383
+ candidate: resolved,
384
+ remote: {
385
+ version: resolved.plugin.version,
386
+ contentSha256: resolved.plugin.contentSha256 || "",
387
+ integrity: resolved.plugin.integrity,
388
+ carrierVersion: resolved.carrier.version,
389
+ appVersion: resolved.carrier.appVersion,
390
+ legacyCarrierMetadata: resolved.carrier.legacyMetadataMissing,
391
+ },
392
+ });
393
+ }
394
+
395
+ export function fixedReleaseSnapshot(manifest, manifestUrl = "") {
396
+ const app = manifest.app ? { ...manifest.app, url: resolvePluginUrl(manifestUrl, manifest.app) } : null;
397
+ return { schema: 1, manifestUrl, app, plugin: { ...manifest.plugin, url: resolvePluginUrl(manifestUrl, manifest.plugin) } };
398
+ }
399
+
400
+ // ── 下载 / 解包 ───────────────────────────────────────────────────────────
401
+
402
+ /**
403
+ * 下载到文件并校验。**先落盘到临时名再改名**,避免半成品被当成成品。
404
+ * `size`/`sha256` 任一不符即删除文件并失败——不做"先装上再说"。
405
+ */
406
+ export async function downloadVerified({ url, destPath, sha256, size = null, fetchImpl = fetch, onProgress, auth = {} } = {}) {
407
+ if (!url) return { ok: false, error: "download-url-missing" };
408
+ if (!isAllowedDownloadUrl(url)) return { ok: false, error: "download-url-not-allowed" };
409
+ let response;
410
+ try {
411
+ ({ response } = await fetchUpdateResource(url, { fetchImpl, ...auth, signal: AbortSignal.timeout(120_000) }));
412
+ } catch (e) {
413
+ return { ok: false, error: `download-failed: ${e?.message ?? e}` };
414
+ }
415
+ if (!response?.ok) return { ok: false, error: `download-http-${response?.status ?? "?"}` };
416
+
417
+ mkdirSync(dirname(destPath), { recursive: true });
418
+ const tmpPath = `${destPath}.part`;
419
+ try {
420
+ const chunks = [];
421
+ let bytes = 0;
422
+ for await (const chunk of response.body ?? []) {
423
+ chunks.push(Buffer.from(chunk));
424
+ bytes += chunk.length;
425
+ onProgress?.(bytes, size ?? response.headers?.get?.("content-length") ?? null);
426
+ }
427
+ const buf = Buffer.concat(chunks);
428
+ bytes = buf.length;
429
+ if (size !== null && bytes !== size) {
430
+ return { ok: false, error: `download-size-mismatch: ${bytes} != ${size}` };
431
+ }
432
+ const digest = createHash("sha256").update(buf).digest("hex");
433
+ if (sha256 && digest !== String(sha256).toLowerCase()) {
434
+ return { ok: false, error: `download-sha256-mismatch: ${digest}` };
435
+ }
436
+ writeFileSync(tmpPath, buf);
437
+ renameSync(tmpPath, destPath);
438
+ return { ok: true, bytes, sha256: digest };
439
+ } catch (e) {
440
+ try {
441
+ rmSync(tmpPath, { force: true });
442
+ } catch {
443
+ // 清理失败不掩盖原始错误
444
+ }
445
+ return { ok: false, error: `download-failed: ${e?.message ?? e}` };
446
+ }
447
+ }
448
+
449
+ /** 默认解包器:系统 tar(异步,不阻塞宿主)。 */
450
+ export function defaultRunTar(tgzPath, destDir) {
451
+ return runCommand("tar", ["-xzf", tgzPath, "-C", destDir]);
452
+ }
453
+
454
+ /** 默认安装器:包管理器(异步、带超时)。 */
455
+ export function defaultRunInstall(profileDir, { onOutput, platform = process.platform } = {}) {
456
+ return runCommand("pnpm", ["install"], { cwd: profileDir, onOutput, platform });
457
+ }
458
+
459
+ async function fileIntegrity(file, algorithm = "sha512") {
460
+ const digest = createHash(algorithm);
461
+ for await (const chunk of createReadStream(file)) digest.update(chunk);
462
+ return `${algorithm}-${digest.digest("base64")}`;
463
+ }
464
+
465
+ /** Pack an exact NPM version through the host npm config and verify its immutable SRI. */
466
+ export async function packNpmTarball({ packageName, version, expectedIntegrity, destDir, cwd, run = runCommand, platform = process.platform } = {}) {
467
+ if (!packageName || !version || !expectedIntegrity || !destDir) return { ok: false, error: "npm-pack-input-invalid" };
468
+ mkdirSync(destDir, { recursive: true });
469
+ // Windows shell 下含空白的临时目录必须引用,否则会被拆成多个参数。
470
+ const result = await run("npm", ["pack", "--json", "--ignore-scripts", "--pack-destination", quoteShellArgument(destDir, platform), `${packageName}@${version}`], {
471
+ cwd, timeoutMs: 120_000, maxOutputBytes: 256 * 1024, ...shellOptions(platform),
472
+ });
473
+ if (!result?.ok) return { ok: false, error: "npm-pack-failed" };
474
+ let entries;
475
+ try { entries = JSON.parse(result.stdout); } catch { return { ok: false, error: "npm-pack-json-invalid" }; }
476
+ const entry = Array.isArray(entries) && entries.length === 1 ? entries[0] : null;
477
+ if (!entry || entry.name !== packageName || entry.version !== version || entry.integrity !== expectedIntegrity) {
478
+ return { ok: false, error: "npm-pack-metadata-mismatch" };
479
+ }
480
+ const filename = typeof entry.filename === "string" ? basename(entry.filename) : "";
481
+ if (filename === "" || !filename.endsWith(".tgz")) return { ok: false, error: "npm-pack-filename-invalid" };
482
+ const file = join(destDir, filename);
483
+ try {
484
+ if (!statSync(file).isFile()) return { ok: false, error: "npm-pack-tarball-missing" };
485
+ } catch { return { ok: false, error: "npm-pack-tarball-missing" }; }
486
+ let actualIntegrity;
487
+ try { actualIntegrity = await fileIntegrity(file); } catch { return { ok: false, error: "npm-pack-tarball-unreadable" }; }
488
+ if (actualIntegrity !== expectedIntegrity) return { ok: false, error: "npm-pack-integrity-mismatch" };
489
+ return { ok: true, file, filename, integrity: actualIntegrity, size: statSync(file).size };
490
+ }
491
+
492
+ /**
493
+ * 解包并复核**内容标识**:把「发布侧声明的内容」与「真正落到磁盘上的内容」钉在一起。
494
+ * 返回值里的 `digest` 就是**复算出的真实内容标识**——事务以它作为期望值(R5)。
495
+ */
496
+ export async function extractVerified({ tgzPath, workDir, contentSha256, runTar = defaultRunTar } = {}) {
497
+ rmSync(workDir, { recursive: true, force: true });
498
+ mkdirSync(workDir, { recursive: true });
499
+ const r = await runTar(tgzPath, workDir);
500
+ if (!r?.ok) return { ok: false, error: `extract-failed: ${r?.error ?? "unknown"}` };
501
+ const packageDir = join(workDir, "package");
502
+ if (!existsSync(packageDir)) return { ok: false, error: "extract-missing-package-dir" };
503
+ const digest = pluginContentDigest(packageDir);
504
+ const expected = String(contentSha256 ?? "").toLowerCase();
505
+ if (expected && digest !== expected) {
506
+ return { ok: false, error: `content-sha256-mismatch: ${digest}`, digest, packageDir };
507
+ }
508
+ return { ok: true, digest, packageDir };
509
+ }
510
+
511
+ async function fileSha256(file) {
512
+ const digest = createHash("sha256");
513
+ for await (const chunk of createReadStream(file)) digest.update(chunk);
514
+ return digest.digest("hex");
515
+ }
516
+
517
+ async function safeTarListing(tarball, run = runCommand) {
518
+ const listing = await run("tar", ["-tzf", tarball], { timeoutMs: 30_000, maxOutputBytes: 256 * 1024 });
519
+ if (!listing?.ok) return { ok: false, error: "npm-tar-list-failed" };
520
+ const entries = listing.stdout.split(/\r?\n/).filter(Boolean);
521
+ if (entries.length === 0 || entries.length > 2048) return { ok: false, error: "npm-tar-list-invalid" };
522
+ for (const entry of entries) {
523
+ const normalized = entry.replace(/\\/g, "/");
524
+ if (normalized.startsWith("/") || normalized.split("/").includes("..") || (normalized !== "package" && !normalized.startsWith("package/"))) {
525
+ return { ok: false, error: "npm-tar-path-invalid" };
526
+ }
527
+ }
528
+ return { ok: true };
529
+ }
530
+
531
+ /** Download and validate the exact NPM pair before touching the profile. */
532
+ export async function prepareNpmArtifacts({ npmCandidate, packageName, profileDir, workDir, runCommandImpl = runCommand, runTar = defaultRunTar } = {}) {
533
+ const name = packageName || npmCandidate?.plugin?.name || "";
534
+ if (!npmCandidate?.plugin?.version || !npmCandidate?.carrier?.version || !name || !profileDir || !workDir) {
535
+ return { ok: false, error: "npm-candidate-incomplete" };
536
+ }
537
+ const pluginPackDir = join(workDir, "npm-plugin");
538
+ const carrierPackDir = join(workDir, "npm-carrier");
539
+ const plugin = await packNpmTarball({ packageName: name, version: npmCandidate.plugin.version,
540
+ expectedIntegrity: npmCandidate.plugin.integrity, destDir: pluginPackDir, cwd: profileDir, run: runCommandImpl });
541
+ if (!plugin.ok) return plugin;
542
+ const carrier = await packNpmTarball({ packageName: name, version: npmCandidate.carrier.version,
543
+ expectedIntegrity: npmCandidate.carrier.integrity, destDir: carrierPackDir, cwd: profileDir, run: runCommandImpl });
544
+ if (!carrier.ok) return carrier;
545
+
546
+ const pluginListing = await safeTarListing(plugin.file, runCommandImpl);
547
+ if (!pluginListing.ok) return pluginListing;
548
+ const carrierListing = await safeTarListing(carrier.file, runCommandImpl);
549
+ if (!carrierListing.ok) return carrierListing;
550
+ const pluginExtract = await extractVerified({ tgzPath: plugin.file, workDir: join(workDir, "plugin-extract"),
551
+ contentSha256: npmCandidate.plugin.contentSha256 || "", runTar });
552
+ if (!pluginExtract.ok) return { ok: false, error: `npm-plugin-${pluginExtract.error}` };
553
+ const carrierExtractDir = join(workDir, "carrier-extract");
554
+ rmSync(carrierExtractDir, { recursive: true, force: true });
555
+ mkdirSync(carrierExtractDir, { recursive: true });
556
+ const carrierUnpack = await runTar(carrier.file, carrierExtractDir);
557
+ if (!carrierUnpack?.ok) return { ok: false, error: "npm-carrier-extract-failed" };
558
+ const carrierPackageDir = join(carrierExtractDir, "package");
559
+ let manifest;
560
+ try { manifest = JSON.parse(readFileSync(join(carrierPackageDir, "manifest.json"), "utf8")); }
561
+ catch { return { ok: false, error: "npm-carrier-manifest-unreadable" }; }
562
+ // npm pack 会把 tarball 名规范化(加 scope 前缀、去掉发布产物里的 `-v`),
563
+ // 因此不能拿 manifest.plugin.file 与下载文件名逐字比较;身份由版本、大小与
564
+ // 后续 sha256/contentSha256 校验共同钉死。
565
+ if (manifest?.schema !== 1 || manifest?.plugin?.version !== npmCandidate.plugin.version
566
+ || manifest?.plugin?.size !== plugin.size) {
567
+ return { ok: false, error: "npm-carrier-plugin-identity-mismatch" };
568
+ }
569
+ let tarballSha256;
570
+ try { tarballSha256 = await fileSha256(plugin.file); } catch { return { ok: false, error: "npm-plugin-tarball-unreadable" }; }
571
+ if (manifest.plugin.sha256 !== tarballSha256) return { ok: false, error: "npm-plugin-tarball-sha256-mismatch" };
572
+ const pluginContentSha256 = pluginExtract.digest;
573
+ if (manifest.plugin.contentSha256 !== pluginContentSha256) return { ok: false, error: "npm-plugin-content-sha256-mismatch" };
574
+ if (npmCandidate.plugin.contentSha256 && npmCandidate.plugin.contentSha256 !== pluginContentSha256) {
575
+ return { ok: false, error: "npm-packument-content-sha256-mismatch" };
576
+ }
577
+ if (npmCandidate.carrier.appVersion && npmCandidate.carrier.appVersion !== manifest.app?.version) {
578
+ return { ok: false, error: "npm-packument-app-version-mismatch" };
579
+ }
580
+ if (npmCandidate.carrier.pluginContentSha256 && npmCandidate.carrier.pluginContentSha256 !== manifest.plugin.contentSha256) {
581
+ return { ok: false, error: "npm-packument-content-sha256-mismatch" };
582
+ }
583
+
584
+ const alias = appCarrierAlias(name);
585
+ const link = join(pluginExtract.packageDir, "node_modules", alias);
586
+ try {
587
+ mkdirSync(dirname(link), { recursive: true });
588
+ symlinkSync(carrierPackageDir, link, "dir");
589
+ } catch { return { ok: false, error: "npm-carrier-link-failed" }; }
590
+ const inspected = discoverAppCarrier({ pluginRoot: pluginExtract.packageDir, dshHome: join(workDir, "isolated-dsh") });
591
+ if (!inspected.ok || inspected.pluginContent?.matches !== true) {
592
+ return { ok: false, error: inspected.reason || "npm-carrier-validation-failed" };
593
+ }
594
+ return {
595
+ ok: true,
596
+ workDir,
597
+ pluginTgzPath: plugin.file,
598
+ carrierTgzPath: carrier.file,
599
+ pluginPackageDir: pluginExtract.packageDir,
600
+ carrierPackageDir,
601
+ pluginContentSha256,
602
+ pluginTarballSha256: tarballSha256,
603
+ carrierAppVersion: inspected.manifest.appVersion,
604
+ manifest,
605
+ };
606
+ }
607
+
608
+ /** 把解包好的版本目录记进 profile 的依赖声明(持久生效的关键,见 ADR 0019)。 */
609
+ export function switchDependencySpecifier({ profileDir, nextSpecifier, keys = [LEGACY_DEPENDENCY_KEY], readJson = readJsonFile, writeJson = writeJsonFile } = {}) {
610
+ const file = join(profileDir, "package.json");
611
+ let doc;
612
+ try {
613
+ doc = readJson(file);
614
+ } catch (e) {
615
+ return { ok: false, error: `profile-package-unreadable: ${e?.message ?? e}` };
616
+ }
617
+ const deps = doc?.dependencies;
618
+ if (!deps || typeof deps !== "object") return { ok: false, error: "profile-has-no-dependencies" };
619
+ // 键可能是旧包名,也可能是带 scope 的新包名:改**实际存在的那个**,不新增第二个依赖。
620
+ const key = dependencyKeyIn(doc, keys) || (keys.includes(LEGACY_DEPENDENCY_KEY) ? LEGACY_DEPENDENCY_KEY : keys[0]);
621
+ const previous = typeof deps[key] === "string" ? deps[key] : "";
622
+ if (previous === "") return { ok: false, error: "profile-dependency-missing" };
623
+ deps[key] = nextSpecifier;
624
+ try {
625
+ writeJson(file, doc);
626
+ } catch (e) {
627
+ return { ok: false, error: `profile-package-unwritable: ${e?.message ?? e}` };
628
+ }
629
+ return { ok: true, previous, next: nextSpecifier, file, key };
630
+ }
631
+
632
+ function readJsonFile(file) {
633
+ return JSON.parse(readFileSync(file, "utf8"));
634
+ }
635
+
636
+ function writeJsonFile(file, doc) {
637
+ writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
638
+ }
639
+
640
+ // ── 重启 ──────────────────────────────────────────────────────────────────
641
+
642
+ export function restartPlan({ form, pid = null, profile = "web", dshCommand = "dsh" } = {}) {
643
+ switch (form) {
644
+ case "web-systemd":
645
+ return {
646
+ mode: "supervised",
647
+ steps: ["终止当前宿主进程", "由 systemd 的 Restart=always 用原参数拉起"],
648
+ commands: pid ? [`kill -TERM ${pid}`] : ["systemctl --user restart dsh-web.service"],
649
+ };
650
+ case "web-foreground":
651
+ return {
652
+ mode: "respawn",
653
+ steps: ["终止当前宿主进程", "由分离脚本重新拉起 dsh web"],
654
+ commands: pid ? [`kill -TERM ${pid}`, `${dshCommand} web --profile ${profile}`] : [`${dshCommand} web --profile ${profile}`],
655
+ };
656
+ default:
657
+ return {
658
+ mode: "unsupported",
659
+ steps: ["当前形态不支持自动重启(DSH Desktop / macOS 需要人工重启)"],
660
+ commands: [],
661
+ };
662
+ }
663
+ }
664
+
665
+ /**
666
+ * 识别本机可自动重启的形态。
667
+ * **默认读真实 `process.env`**——生产调用漏传 env 会让前台 web 永远探测不到(评审 R12)。
668
+ */
669
+ export function detectRestartForm({ env = process.env, platform = process.platform, hasSystemdUnit = () => false } = {}) {
670
+ if (platform === "win32" || platform === "darwin") return "unsupported";
671
+ if (hasSystemdUnit()) return "web-systemd";
672
+ if (env?.DSH_PROFILE || env?.DSH_WEB_FOREGROUND === "1") return "web-foreground";
673
+ return "unsupported";
674
+ }
675
+
676
+ export function buildRestartScript({ plan, pid = null, delayMs = 3000 } = {}) {
677
+ const seconds = Math.max(0, Math.round(delayMs / 1000));
678
+ if (plan?.mode === "supervised") {
679
+ return pid ? `sleep ${seconds}; kill -TERM ${pid}` : "";
680
+ }
681
+ if (plan?.mode === "respawn") {
682
+ const respawn = plan.commands?.[plan.commands.length - 1] ?? "";
683
+ if (!respawn) return "";
684
+ const kill = pid ? `kill -TERM ${pid}; sleep 2; ` : "";
685
+ return `sleep ${seconds}; ${kill}nohup ${respawn} >/dev/null 2>&1 &`;
686
+ }
687
+ return "";
688
+ }
689
+
690
+ /** 执行重启:分离一个 shell 去跑脚本。 */
691
+ export function runRestart({ plan, pid = null, delayMs = 3000, spawnImpl = spawn, log = () => {} } = {}) {
692
+ const script = buildRestartScript({ plan, pid, delayMs });
693
+ if (script === "") return { ok: false, error: "restart-unsupported" };
694
+ try {
695
+ const child = spawnImpl("bash", ["-lc", script], { detached: true, stdio: "ignore" });
696
+ child.unref?.();
697
+ log(`mobile-remote: 已安排重启(${plan.mode}):${script}`);
698
+ return { ok: true, script, pid: child.pid ?? null };
699
+ } catch (e) {
700
+ return { ok: false, error: `restart-spawn-failed: ${e?.message ?? e}` };
701
+ }
702
+ }
703
+
704
+ /** 监督器冻结目录:启动前把自身代码复制到这里,不依赖之后可能被 pnpm 清理的模块目录。 */
705
+ export function supervisorRoot(home, id) {
706
+ // Only the server-generated UUID is a filesystem component, never the client request ID.
707
+ serverOperationPath(home, id);
708
+ return join(home, ".dsh", "mobile-remote", "supervisor", id);
709
+ }
710
+ export function freezeSupervisor({ pluginRoot, home, id, destDir = supervisorRoot(home, id), copyDir = defaultCopyDir } = {}) {
711
+ const expected = supervisorRoot(home, id);
712
+ if (destDir !== expected) return { ok: false, error: "supervisor-path-outside-root" };
713
+ if (existsSync(destDir)) return { ok: false, error: "supervisor-already-frozen" };
714
+ const src = join(pluginRoot, "lib");
715
+ if (!existsSync(src)) return { ok: false, error: "plugin-lib-missing" };
716
+ mkdirSync(destDir, { recursive: true });
717
+ const result = copyDir(src, join(destDir, "lib"));
718
+ if (!result?.ok) return { ok: false, error: "freeze-failed" };
719
+ writeFileSync(join(destDir, "package.json"), '{"type":"module"}\n');
720
+ const entry = join(destDir, "lib", "update-supervisor.js");
721
+ return existsSync(entry) ? { ok: true, entry } : { ok: false, error: "freeze-missing-entry" };
722
+ }
723
+
724
+ /**
725
+ * 启动监督器并**等它就绪回执**(F2)。
726
+ * systemd 托管时必须经 transient unit 逃逸出宿主 cgroup,否则停宿主会把它一起杀掉;
727
+ * 逃逸或就绪失败一律 fail-closed(调用方在**任何破坏性改动之前**据此中止)。
728
+ */
729
+ export function spawnSupervisorArmed({
730
+ frozenEntry,
731
+ stateFile,
732
+ readyFile,
733
+ opId,
734
+ generation,
735
+ needsEscape = false,
736
+ unitName = "",
737
+ nodePath = process.execPath,
738
+ timeoutMs = 15_000,
739
+ spawnImpl = spawn,
740
+ runImpl = null,
741
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
742
+ now = () => Date.now(),
743
+ log = () => {},
744
+ } = {}) {
745
+ const args = [frozenEntry, "--state", stateFile, "--op", opId, "--generation", generation, "--arm", "--ready", readyFile];
746
+ let mode = "detached";
747
+ if (needsEscape) {
748
+ const unit = unitName || `dsh-plugin-supervisor-${String(opId).slice(0, 8)}`;
749
+ const run = runImpl ?? ((cmd, argv) => spawnSync(cmd, argv, { encoding: "utf8", env: { ...process.env, XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR || `/run/user/${process.getuid()}` } }));
750
+ const r = run("systemd-run", ["--user", "--unit", unit, "--collect", "--quiet", "--", nodePath, ...args]);
751
+ if (r?.error) return { ok: false, error: `escape-failed: ${r.error.message ?? r.error}` };
752
+ if (typeof r?.status === "number" && r.status !== 0) {
753
+ return { ok: false, error: `escape-failed: exit ${r.status} ${String(r.stderr ?? "").trim()}` };
754
+ }
755
+ mode = "systemd-run";
756
+ } else {
757
+ try {
758
+ const child = spawnImpl(nodePath, args, { detached: true, stdio: "ignore" });
759
+ child.unref?.();
760
+ } catch (e) {
761
+ return { ok: false, error: `supervisor-spawn-failed: ${e?.message ?? e}` };
762
+ }
763
+ }
764
+ return { ok: true, mode, readyFile, args };
765
+ }
766
+
767
+ /** 等监督器写出就绪回执;超时即失败(调用方 fail-closed)。 */
768
+ export async function waitForSupervisorReady({
769
+ readyFile,
770
+ opId,
771
+ generation,
772
+ timeoutMs = 15_000,
773
+ pollMs = 200,
774
+ readFile = (f) => readFileSync(f, "utf8"),
775
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
776
+ now = () => Date.now(),
777
+ } = {}) {
778
+ const deadline = now() + timeoutMs;
779
+ while (now() < deadline) {
780
+ try {
781
+ const doc = JSON.parse(readFile(readyFile));
782
+ if (doc && Number.isFinite(doc.pid) && (!opId || doc.opId === opId) && (!generation || doc.generation === generation)) return { ok: true, ready: doc };
783
+ } catch {
784
+ // 还没写好
785
+ }
786
+ await sleep(pollMs);
787
+ }
788
+ return { ok: false, error: "supervisor-not-ready" };
789
+ }
790
+
791
+ // ── 事务推进 ──────────────────────────────────────────────────────────────
792
+
793
+ /**
794
+ * 推进一次自更新事务。
795
+ *
796
+ * 顺序即安全性:**先下载校验 → 再解包复核内容标识 → 快照当前内容 → 最后才动 profile**。
797
+ * 任何一步失败都可以直接放弃,此时还没有发生需要重启的变更。
798
+ *
799
+ * 返回 `{ok, step, error?, transaction}`;成功后事务处于 `restart-pending`,
800
+ * 由调用方启动外部监督器接管(重启 + 健康确认 + 失败回滚)。
801
+ */
802
+ export async function runSelfUpdate({
803
+ home, plugin, release = null, npmCandidate = null, packageName = "", manifestUrl = "", profileDir, restartForm = "unsupported", hostPid = process.pid,
804
+ port = 3080, basePath = "/m", token = "", profile = "web", dshCommand = "dsh", installedRoot = "", pluginRoot = "",
805
+ needsEscape = false, supervisorTimeoutMs = 15_000, freezeImpl = freezeSupervisor, spawnArmedImpl = spawnSupervisorArmed,
806
+ awaitReadyImpl = waitForSupervisorReady, opId = newOperationId(), fetchImpl = fetch, auth = {}, runTar = defaultRunTar,
807
+ runInstall = defaultRunInstall, onStep = () => {}, onOutput = () => {}, prepareNpmArtifactsImpl = prepareNpmArtifacts,
808
+ preparedNpmArtifacts = null, log = () => {}, now = () => Date.now(),
809
+ lease: admittedLease, initialTransaction, stateFile: operationStateFile, currentFile = transactionFile(home),
810
+ lockPath = updateLockPath(home),
811
+ } = {}) {
812
+ if (!validOperationId(opId)) return { ok: false, step: "input", error: "invalid-operation-id" };
813
+ if (!profileDir) return { ok: false, step: "profile", error: "profile-not-found" };
814
+ const grant = acquireLock(lockPath, { opId, lease: admittedLease, stateFile: currentFile });
815
+ if (!grant.ok) return { ok: false, step: "lock", error: grant.error, holder: grant.holder };
816
+ const lease = grant.lease, id = lease.generation;
817
+ const stateFile = operationStateFile || serverOperationPath(home, id);
818
+ let tx = initialTransaction ?? { opId, lease, id, phase: "staging", startedAt: new Date(now()).toISOString(),
819
+ profileDir, installedRoot, manifestUrl, release, npmCandidate, restartForm, hostPid, port, basePath, token, profile, dshCommand,
820
+ lockPath, currentFile, targetVersion: plugin?.version ?? "", targetContentSha256: plugin?.contentSha256 ?? "", attempts: 0 };
821
+ let destructive = false, step = "supervisor", npmWorkDir = "";
822
+ const save = (patch, expectedPhases) => { tx = writeOwnedTransaction(stateFile, { ...tx, ...patch }, { lockPath, lease, currentFile, expectedPhases }); return tx; };
823
+ const fail = error => {
824
+ if (npmCandidate && npmWorkDir) {
825
+ try { rmSync(npmWorkDir, { recursive: true, force: true }); } catch { /* recovery snapshot is independent of NPM scratch */ }
826
+ }
827
+ // 一旦可能动过 profile/安装字节,只有已 armed 的监督器能结束这个事务(C5)。
828
+ if (destructive) {
829
+ save({ phase: "rolling-back", rolledBack: true, result: { ok: false, reason: error }, recoveryCause: error }, ["installing"]);
830
+ return { ok: false, step, error, transaction: tx };
831
+ }
832
+ save({ phase: "failed", result: { ok: false, reason: error } }, ["staging"]);
833
+ releaseLock(lockPath, lease, { stateFile });
834
+ return { ok: false, step, error, transaction: tx };
835
+ };
836
+ try {
837
+ save({ release: release || tx.release, lockPath, currentFile }, ["staging"]);
838
+ const frozenDir = supervisorRoot(home, id);
839
+ const frozen = freezeImpl({ pluginRoot, home, id, destDir: frozenDir });
840
+ if (!frozen.ok) return fail(frozen.error);
841
+ const readyFile = join(frozenDir, "ready.json");
842
+ const armed = spawnArmedImpl({ frozenEntry: frozen.entry, stateFile, readyFile, opId, generation: lease.generation,
843
+ needsEscape, unitName: `dsh-plugin-supervisor-${id}`, log });
844
+ if (!armed.ok) return fail(armed.error);
845
+ const ready = await awaitReadyImpl({ readyFile, opId, generation: lease.generation, timeoutMs: supervisorTimeoutMs });
846
+ if (!ready.ok) return fail(ready.error);
847
+ assertUpdateLease(lockPath, lease);
848
+
849
+ const work = join(workRoot(home), id); mkdirSync(work, { recursive: true });
850
+ if (npmCandidate && !preparedNpmArtifacts) npmWorkDir = work;
851
+ let staged, npmPrepared = null;
852
+ step = UPDATE_STEP.DOWNLOAD; onStep(step);
853
+ if (npmCandidate) {
854
+ if (npmCandidate.carrier?.legacyMetadataMissing && !preparedNpmArtifacts) return fail("legacy-carrier-preparation-required");
855
+ npmPrepared = preparedNpmArtifacts ?? await prepareNpmArtifactsImpl({ npmCandidate, packageName: packageName || npmCandidate.plugin?.name,
856
+ profileDir, workDir: work, runCommandImpl: runCommand, runTar });
857
+ assertUpdateLease(lockPath, lease);
858
+ if (!npmPrepared?.ok) return fail(npmPrepared?.error ?? "npm-artifact-preparation-failed");
859
+ if (npmPrepared.manifest?.plugin?.version !== npmCandidate.plugin.version
860
+ || npmPrepared.manifest?.plugin?.contentSha256 !== npmPrepared.pluginContentSha256
861
+ || npmPrepared.manifest?.app?.version !== npmPrepared.carrierAppVersion) return fail("npm-prepared-candidate-mismatch");
862
+ staged = { ok: true, digest: npmPrepared.pluginContentSha256, packageDir: npmPrepared.pluginPackageDir };
863
+ } else {
864
+ const tgzPath = join(work, "plugin.tgz");
865
+ const downloaded = await downloadVerified({ url: resolvePluginUrl(manifestUrl, plugin), destPath: tgzPath,
866
+ sha256: plugin.sha256, size: plugin.size ?? null, fetchImpl, auth });
867
+ assertUpdateLease(lockPath, lease);
868
+ if (!downloaded.ok) return fail(downloaded.error);
869
+ onStep(UPDATE_STEP.VERIFY); step = UPDATE_STEP.EXTRACT; onStep(step);
870
+ staged = await extractVerified({ tgzPath, workDir: join(work, "extract"), contentSha256: plugin.contentSha256, runTar });
871
+ assertUpdateLease(lockPath, lease);
872
+ if (!staged.ok) return fail(staged.error);
873
+ }
874
+ if (!staged?.digest || !staged.packageDir) return fail("staged-plugin-invalid");
875
+ const destDir = artifactDir(artifactRoot(home), staged.digest);
876
+ if (existsSync(destDir)) {
877
+ if (pluginContentDigest(destDir) !== staged.digest) return fail("artifact-integrity-mismatch");
878
+ } else {
879
+ mkdirSync(dirname(destDir), { recursive: true });
880
+ if (npmCandidate) rmSync(join(staged.packageDir, "node_modules"), { recursive: true, force: true });
881
+ renameSync(staged.packageDir, destDir);
882
+ }
883
+
884
+ step = UPDATE_STEP.SNAPSHOT; onStep(step);
885
+ if (!installedRoot || !existsSync(installedRoot)) return fail("installed-root-missing");
886
+ const previousContent = pluginContentDigest(installedRoot);
887
+ const previousSnapshot = snapshotDir(snapshotRoot(home), previousContent);
888
+ if (!snapshotReady(previousSnapshot)) {
889
+ if (existsSync(previousSnapshot)) return fail("snapshot-incomplete-or-corrupt");
890
+ const snap = snapshotInstalled({ installedRoot, destDir: previousSnapshot });
891
+ if (!snap.ok) return fail(snap.error);
892
+ }
893
+ if (pluginContentDigest(previousSnapshot) !== previousContent) return fail("snapshot-content-mismatch");
894
+ const packageFile = readFileSync(join(profileDir, "package.json"), "utf8");
895
+ const pnpmLock = existsSync(join(profileDir, "pnpm-lock.yaml")) ? readFileSync(join(profileDir, "pnpm-lock.yaml"), "utf8") : null;
896
+ // 依赖键自描述(npm 正式渠道下包名带 scope,键随之改变)
897
+ const dependencyKeys = dependencyKeysFor(pluginRoot);
898
+ const dependencyKey = dependencyKeyIn(JSON.parse(packageFile), dependencyKeys) || LEGACY_DEPENDENCY_KEY;
899
+ const oldSpecifier = JSON.parse(packageFile).dependencies?.[dependencyKey];
900
+ const npmPackageName = npmCandidate?.plugin?.name || packageName;
901
+ const targetAppVersion = npmPrepared?.carrierAppVersion || npmPrepared?.manifest?.app?.version || npmCandidate?.carrier?.appVersion || null;
902
+ const resolvedNpmCandidate = npmCandidate ? {
903
+ ...npmCandidate,
904
+ plugin: { ...npmCandidate.plugin, contentSha256: staged.digest },
905
+ carrier: { ...npmCandidate.carrier, appVersion: targetAppVersion,
906
+ pluginContentSha256: staged.digest, legacyMetadataMissing: false },
907
+ } : null;
908
+ // Durable recovery metadata precedes the first destructive write, so crashes also recover.
909
+ save({ phase: "installing", artifactDir: destDir, targetContentSha256: staged.digest, targetAppVersion,
910
+ previousContentSha256: previousContent, previousSnapshotDir: previousSnapshot, previousSpecifier: oldSpecifier, dependencyKey,
911
+ profileSnapshot: { packageJson: packageFile, pnpmLock, dependencyKey }, npmCandidate: resolvedNpmCandidate,
912
+ release: tx.release ? { ...tx.release, plugin: { ...tx.release.plugin, contentSha256: staged.digest } } : null }, ["staging"]);
913
+ destructive = true; step = UPDATE_STEP.SWITCH; onStep(step);
914
+ const exactNpmSpecifier = npmCandidate
915
+ ? (dependencyKey === npmPackageName ? npmCandidate.plugin.version : `npm:${npmPackageName}@${npmCandidate.plugin.version}`)
916
+ : `file:${destDir}`;
917
+ const switched = switchOwnedDependency({ profileDir, nextSpecifier: exactNpmSpecifier, key: dependencyKey, lockPath, lease });
918
+ if (!switched.ok) return fail(switched.error);
919
+ step = UPDATE_STEP.INSTALL; onStep(step); assertUpdateLease(lockPath, lease);
920
+ const installed = await runInstall(profileDir, { onOutput });
921
+ assertUpdateLease(lockPath, lease);
922
+ if (!installed?.ok) return fail(installed?.error ?? "install-failed");
923
+ if (npmCandidate) {
924
+ const targetPath = join(profileDir, "node_modules", ...dependencyKey.split("/"));
925
+ let resolvedRoot;
926
+ try { resolvedRoot = realpathSync(targetPath); } catch { return fail("npm-installed-plugin-missing"); }
927
+ let installedPackage;
928
+ try { installedPackage = JSON.parse(readFileSync(join(resolvedRoot, "package.json"), "utf8")); }
929
+ catch { return fail("npm-installed-package-invalid"); }
930
+ if (installedPackage.name !== npmPackageName || installedPackage.version !== npmCandidate.plugin.version) return fail("npm-installed-package-identity-mismatch");
931
+ let installedDigest;
932
+ try { installedDigest = pluginContentDigest(resolvedRoot); } catch { return fail("npm-installed-content-unreadable"); }
933
+ if (installedDigest !== staged.digest) return fail("npm-installed-content-sha256-mismatch");
934
+ const installedCarrier = discoverAppCarrier({ pluginRoot: resolvedRoot, dshHome: join(home, ".dsh") });
935
+ if (!installedCarrier.ok || installedCarrier.pluginContent?.matches !== true) return fail(`npm-installed-carrier-invalid:${installedCarrier.reason ?? "content-mismatch"}`);
936
+ if (installedCarrier.carrier.version !== npmCandidate.carrier.version || installedCarrier.manifest.appVersion !== targetAppVersion) {
937
+ return fail("npm-installed-carrier-pairing-mismatch");
938
+ }
939
+ if (installedCarrier.manifest.pluginTgzSha256 !== npmPrepared.manifest.plugin.sha256
940
+ || installedCarrier.manifest.contentSha256 !== staged.digest
941
+ || installedCarrier.apk.sha256 !== npmPrepared.manifest.app?.sha256
942
+ || installedCarrier.apk.size !== npmPrepared.manifest.app?.size) {
943
+ return fail("npm-installed-carrier-integrity-mismatch");
944
+ }
945
+ }
946
+ if (npmCandidate) {
947
+ // The package-manager install is complete; discard its NPM tarball/extraction scratch before restart.
948
+ try { rmSync(work, { recursive: true, force: true }); } catch { /* cleanup cannot invalidate an installed package */ }
949
+ }
950
+ step = UPDATE_STEP.RESTART; onStep(step);
951
+ save({ phase: "restart-pending", installedAt: new Date(now()).toISOString(), result: null }, ["installing"]);
952
+ log(`mobile-remote: 已落位,监督器将确认内容 ${staged.digest.slice(0,12)}`);
953
+ return { ok: true, step, transaction: tx };
954
+ } catch(e) {
955
+ if (e.message === "lease-lost") return { ok: false, step, error: "lease-lost" };
956
+ try { return fail(e.message); } catch { return { ok: false, step, error: e.message }; }
957
+ }
958
+ }
959
+
960
+ /**
961
+ * 插件侧对事务的**只读报告/收敛**(F6):不再由新版插件自己回滚(那是监督器的职责),
962
+ * 但**任何非终态**(staging / installing / restart-pending)在持有者已死且超时后
963
+ * 都必须收敛为 FAILED,否则后续更新会永久 busy。
964
+ */
965
+ export function reportPendingUpdate({ stateFile, lockPath = "", localContentSha256 = "", supervisorAlive = false, timeoutMs = 10 * 60 * 1000, now = () => Date.now() } = {}) {
966
+ const tx = readTransaction(stateFile);
967
+ if (!tx) return { action: "none", reason: "no-transaction", transaction: null };
968
+ if (isTerminalPhase(tx.phase)) return { action: "none", reason: `phase-${tx.phase}`, transaction: tx };
969
+ if (tx.phase === TRANSACTION_PHASE.RESTART_PENDING) {
970
+ const target = String(tx.targetContentSha256 ?? "").toLowerCase();
971
+ if (target !== "" && String(localContentSha256 ?? "").toLowerCase() === target) {
972
+ return { action: "matched", reason: "loaded-target-content", transaction: tx };
973
+ }
974
+ }
975
+ if (!lockPath) return { action: "waiting", reason: "no-lock-path", transaction: tx };
976
+ const converged = convergeStaleTransaction({ stateFile, lockPath, now, staleMs: timeoutMs });
977
+ if (converged.action === "converged") {
978
+ return { action: "expired", reason: "holder-gone", transaction: converged.transaction };
979
+ }
980
+ if (converged.action === "active") {
981
+ return { action: "waiting", reason: supervisorAlive ? "supervisor-running" : "update-in-progress", transaction: tx };
982
+ }
983
+ return { action: "waiting", reason: `phase-${tx.phase}`, transaction: tx };
984
+ }
985
+
986
+ /** 事务是否已到终态(供 App 判断"结果待确认")。 */
987
+ export function isTerminalPhase(phase) {
988
+ return phase === TRANSACTION_PHASE.SETTLED || phase === TRANSACTION_PHASE.ROLLED_BACK || phase === TRANSACTION_PHASE.FAILED;
989
+ }
990
+
991
+ /** 回滚中间态:既不是"成功"也不是"终态"(App 不得据此推进,F7)。 */
992
+ export function isRollingBack(phase) {
993
+ return phase === TRANSACTION_PHASE.ROLLING_BACK;
994
+ }
995
+
996
+ /** 快照/产物目录是否可用(诊断用)。 */
997
+ export { dirReady };