dsh-mobile-plugin 0.1.60 → 0.1.62
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.
- package/lib/index.js +47 -31
- package/lib/panel/index.html +1 -1
- package/lib/routes/control-plane.js +126 -15
- package/lib/shell/updater.js +78 -9
- package/lib/update-install.cjs +438 -0
- package/lib/update-registry.cjs +142 -0
- package/package.json +1 -1
|
@@ -27,6 +27,10 @@ const readReceipt = require('../read-receipt.cjs');
|
|
|
27
27
|
// 客户端半边的活体台账:两条绿点同步通道都是「单向轮询 + 无回执」,不记账就永远分不开
|
|
28
28
|
// 「那半边根本没在跑」和「跑了但没清掉」(详见 lib/desktop-half-stats.cjs 的文件头)。
|
|
29
29
|
const desktopHalf = require('../desktop-half-stats.cjs');
|
|
30
|
+
// 「查有没有新版」与「装新版」这两格都归插件自己:查直接问 npm 注册表,装在 profile 里跑
|
|
31
|
+
// 它自己的包管理器(见 `../update-registry.cjs` / `../update-install.cjs`)。不再借道市场。
|
|
32
|
+
const { checkForUpdate } = require('../update-registry.cjs');
|
|
33
|
+
const { installLatestIfNeeded, declaredSource } = require('../update-install.cjs');
|
|
30
34
|
const { clearCompletionForAllDevices, invalidateSessionList } = require('../mobile/session-cache.js');
|
|
31
35
|
|
|
32
36
|
/** 一次 desktop-read 上报最多带多少个会话 id(防灌爆;客户端半边一次最多也就几个)。 */
|
|
@@ -47,6 +51,22 @@ const PANEL_FILE = path.join(__dirname, '..', 'panel', 'index.html');
|
|
|
47
51
|
/** 插件包根(`<pkg>/lib/routes` 往上两级):诊断时读它的 `package.json` 与 `React/`。 */
|
|
48
52
|
const PACKAGE_DIR = path.join(__dirname, '..', '..');
|
|
49
53
|
|
|
54
|
+
/**
|
|
55
|
+
* 这一代(`lib/` 所在的那份包)自己的版本号。
|
|
56
|
+
*
|
|
57
|
+
* 「检查更新」在**没有外壳**的宿主上(无头部署、或那一版没装外壳)也要能答,而盘上版本
|
|
58
|
+
* 是外壳的 `manager.js` 才知道的事;拿不到就退回这一代的版本号 —— 它至少是「这台机器上
|
|
59
|
+
* 正在跑的实现」的真实版本,比空着或回一句「问不了」有用。
|
|
60
|
+
*/
|
|
61
|
+
function generationVersion() {
|
|
62
|
+
try {
|
|
63
|
+
const raw = JSON.parse(fs.readFileSync(path.join(PACKAGE_DIR, 'package.json'), 'utf8'));
|
|
64
|
+
return typeof raw.version === 'string' && raw.version ? raw.version : null;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
50
70
|
/** 按 mtime 缓存:文件被换掉之后内容变了,缓存自然失效,不必手工清。 */
|
|
51
71
|
let panelCache = { file: '', mtime: -1, html: '' };
|
|
52
72
|
|
|
@@ -362,12 +382,12 @@ function registerControlPlaneRoutes(deps) {
|
|
|
362
382
|
}) })
|
|
363
383
|
// ── 插件更新(2026-10 搬回自更新之后重新有这四条)──
|
|
364
384
|
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
// 免得「宿主能装、面板说看不见」这种两边写法不一致的偏差。
|
|
385
|
+
// 分工(2026-10-11 起):**查归插件自己**(问 npm 注册表,`../update-registry.cjs`)、
|
|
386
|
+
// **装也归插件自己**(在 profile 里跑它自己的包管理器,`../update-install.cjs`),
|
|
387
|
+
// **换代码留给外壳**(`lib/index.js` 的 runtime)。市场不再是这条路上的任何一环。
|
|
369
388
|
//
|
|
370
|
-
// 四条都走 localOnly
|
|
389
|
+
// 四条都走 localOnly:`check` 读的是版本情况,另外三条会改盘上的插件(`apply` 直接动
|
|
390
|
+
// profile 的依赖树),全是本机运维动作,不该有局域网 / 中转来的调用者。
|
|
371
391
|
const pluginRuntime = () => {
|
|
372
392
|
try {
|
|
373
393
|
return getPluginRuntime() || null
|
|
@@ -375,23 +395,58 @@ function registerControlPlaneRoutes(deps) {
|
|
|
375
395
|
return null
|
|
376
396
|
}
|
|
377
397
|
}
|
|
378
|
-
|
|
398
|
+
/**
|
|
399
|
+
* 取一个宿主服务,拿不到就当没有。
|
|
400
|
+
*
|
|
401
|
+
* 更新这条路要用两个:`profileContext`(宿主发布的包管理器调用方式,装的时候必须优先用它)
|
|
402
|
+
* 与 `agents`(「有对话在跑就别换插件文件」那道闸的判据)。宿主没提供某个服务时如实降级,
|
|
403
|
+
* **不许让路由因此抛错** —— 服务不存在时 `ctx.get` 本身可能就不在。
|
|
404
|
+
*/
|
|
405
|
+
const hostService = key => {
|
|
406
|
+
try {
|
|
407
|
+
return ctx && typeof ctx.get === 'function' ? ctx.get(key) ?? null : null
|
|
408
|
+
} catch {
|
|
409
|
+
return null
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const runtimeUnavailable = { ok: false, error: { code: 'update_unavailable', message: '这一版没有可换代的外壳(插件是被直接挂载的),装完没法就地生效,所以这里不装:无头部署请 rsync 源码后重启' } }
|
|
379
413
|
registerRoute({ kind: 'exact', path: '/api/relay/update/status', handler: localOnly('GET', async (_req, res) => {
|
|
380
414
|
const runtime = pluginRuntime()
|
|
381
415
|
if (!runtime) return sendJson(res, 200, { ok: true, data: { available: false, runningVersion: null, installedVersion: null } })
|
|
382
416
|
return sendJson(res, 200, { ok: true, data: { available: true, ...runtime.status() } })
|
|
383
417
|
}) })
|
|
418
|
+
// 「检查更新」:插件自己问 npm 注册表,**不经过市场、也没有经过外壳**。
|
|
419
|
+
//
|
|
420
|
+
// 刻意不要求外壳在场(所以这一条**不是** `runtimeUnavailable`):查不需要换代能力,
|
|
421
|
+
// 装才需要。从前这条转调 `runtime.check()` → 外壳 → 市场,于是市场没装、太老或者坏了,
|
|
422
|
+
// 「有没有新版」就整个答不出来 —— 一个插件得另一个插件在场才能告诉你自己的版本情况。
|
|
423
|
+
// 盘上版本仍然优先取外壳那份(市场装完立刻变,和运行中版本对照才看得出「要不要换」),
|
|
424
|
+
// 拿不到(无头部署 / 没有外壳的那一版)就退回这一代自己的版本号。
|
|
384
425
|
registerRoute({ kind: 'exact', path: '/api/relay/update/check', handler: localOnly('POST', async (_req, res) => {
|
|
385
426
|
const runtime = pluginRuntime()
|
|
386
|
-
|
|
427
|
+
const snapshot = runtime ? runtime.status() : null
|
|
387
428
|
try {
|
|
388
|
-
const
|
|
389
|
-
|
|
429
|
+
const update = await checkForUpdate({
|
|
430
|
+
installedVersion: snapshot?.installedVersion ?? snapshot?.version ?? generationVersion(),
|
|
431
|
+
// 「安装来源」也自己看:profile 的依赖声明里写的是什么(npm / file: / link:)。读不到
|
|
432
|
+
// 才退回外壳上次问市场的记忆 —— 这条路上不为它去敲市场。
|
|
433
|
+
source: declaredSource(snapshot?.profileNodeModules ? path.dirname(snapshot.profileNodeModules) : null) ?? snapshot?.marketResult?.source ?? null,
|
|
434
|
+
})
|
|
435
|
+
return sendJson(res, 200, { ok: true, data: { update, status: snapshot } })
|
|
390
436
|
} catch (error) {
|
|
391
|
-
return sendJson(res, 500, {
|
|
437
|
+
return sendJson(res, 500, {
|
|
438
|
+
ok: false,
|
|
439
|
+
error: { code: 'update_check_failed', message: String(error?.message || error) },
|
|
440
|
+
data: { update: null, status: snapshot },
|
|
441
|
+
})
|
|
392
442
|
}
|
|
393
443
|
}) })
|
|
394
|
-
//
|
|
444
|
+
// 「更新并立即生效」:插件**自己**去 profile 里装新版(`../update-install.cjs`),装完请外壳
|
|
445
|
+
// 就地换代。不再问市场 —— 装这一半也是插件自己的事了(查在同一条路上的 `check`)。
|
|
446
|
+
//
|
|
447
|
+
// 只在有外壳时才装:盘上换了版本、运行中的实现换不掉,等于「装了个用不上的东西」,不如
|
|
448
|
+
// 直说办不了。装失败一律回 200 + 如实的结果(面板照着 `failure` 说人话),只有我们这边
|
|
449
|
+
// 出了意料之外的错才回 500。
|
|
395
450
|
registerRoute({ kind: 'exact', path: '/api/relay/update/apply', handler: localOnly('POST', async (req, res) => {
|
|
396
451
|
const runtime = pluginRuntime()
|
|
397
452
|
if (!runtime) return sendJson(res, 409, runtimeUnavailable)
|
|
@@ -401,14 +456,70 @@ function registerControlPlaneRoutes(deps) {
|
|
|
401
456
|
} catch {
|
|
402
457
|
body = {}
|
|
403
458
|
}
|
|
459
|
+
const force = body.force === true
|
|
460
|
+
const snapshot = runtime.status()
|
|
461
|
+
const beforeVersion = snapshot.installedVersion ?? snapshot.version ?? generationVersion()
|
|
462
|
+
const outcome = {
|
|
463
|
+
installed: false,
|
|
464
|
+
skipped: false,
|
|
465
|
+
state: 'failed',
|
|
466
|
+
beforeVersion,
|
|
467
|
+
installedVersion: beforeVersion,
|
|
468
|
+
swapped: false,
|
|
469
|
+
// 更新后绝不重启 DSH(外壳就地换代),这两格只为如实上报。
|
|
470
|
+
restartRequired: false,
|
|
471
|
+
refreshRequired: false,
|
|
472
|
+
failure: null,
|
|
473
|
+
}
|
|
474
|
+
const answer = (extra = {}) => Object.assign(outcome, extra)
|
|
475
|
+
|
|
476
|
+
const profileNodeModules = snapshot.profileNodeModules ?? null
|
|
477
|
+
if (!profileNodeModules) {
|
|
478
|
+
answer({ failure: { code: 'profile-unknown', message: '宿主没给出 profile 的 node_modules 路径,装不了' } })
|
|
479
|
+
return sendJson(res, 500, { ok: false, error: { code: 'update_apply_failed', message: outcome.failure.message }, data: { ...outcome, status: snapshot } })
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// 查一次(有没有新版)、可不可能被「有 agent 在跑」挡住、装不装,全在 `installLatestIfNeeded`
|
|
483
|
+
// 里一起判:面板那颗按钮与外壳的自动更新必须走同一套判定,否则会出现「手动说有新版、
|
|
484
|
+
// 自动说没有」这种最难查的偏差。
|
|
485
|
+
let result
|
|
486
|
+
try {
|
|
487
|
+
result = await installLatestIfNeeded({
|
|
488
|
+
profileNodeModules,
|
|
489
|
+
force,
|
|
490
|
+
// 宿主发布的包管理器调用方式(`{command, args, env?}`)。**必须优先用它**:PATH 上
|
|
491
|
+
// 那个 pnpm 的版本可能和 profile 的 store 对不上(实测 10.x 装 11.x 的 profile 直接
|
|
492
|
+
// `ERR_PNPM_UNEXPECTED_STORE`)。市场也是这么取的(`profileContext.packageManager`)。
|
|
493
|
+
packageManager: hostService('profileContext')?.packageManager ?? null,
|
|
494
|
+
// 「有对话在跑就先别换插件文件」那道闸的判据。force 由用户点按钮时显式带上。
|
|
495
|
+
agents: hostService('agents'),
|
|
496
|
+
})
|
|
497
|
+
} catch (error) {
|
|
498
|
+
result = { installed: false, installedVersion: beforeVersion, command: null, output: '', failure: { code: 'install_failed', message: String(error?.message || error) } }
|
|
499
|
+
}
|
|
500
|
+
outcome.installedVersion = result.installedVersion ?? beforeVersion
|
|
501
|
+
outcome.failure = result.failure ?? null
|
|
502
|
+
outcome.command = result.command ?? null
|
|
503
|
+
outcome.output = result.output ?? ''
|
|
504
|
+
outcome.retryable = result.retryable === true
|
|
505
|
+
// `skipped` 也要如实带出去:已经是最新那一条**不是失败**,面板照着它说「没有改动任何文件」。
|
|
506
|
+
outcome.skipped = result.skipped === true
|
|
507
|
+
outcome.state = result.installed || result.skipped ? 'succeeded' : 'failed'
|
|
508
|
+
if (!result.installed) {
|
|
509
|
+
return sendJson(res, 200, { ok: result.skipped === true, data: { ...outcome, check: result.check ?? null, status: runtime.status() } })
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// 装上了才换代:换代码是外壳的事,失败要如实报(回滚由外壳负责,见 shell/runtime.js)。
|
|
404
513
|
try {
|
|
405
|
-
const
|
|
406
|
-
|
|
514
|
+
const status = await runtime.reload()
|
|
515
|
+
answer({ installed: true, state: 'succeeded', swapped: true, installedVersion: status?.version ?? outcome.installedVersion, refreshRequired: status?.shellChanged === true })
|
|
407
516
|
} catch (error) {
|
|
408
|
-
|
|
517
|
+
answer({ installed: true, state: 'installed-not-swapped', failure: { code: 'swap_failed', message: `新版已经装到盘上,但换代码没成:${String(error?.message || error)}` } })
|
|
409
518
|
}
|
|
519
|
+
return sendJson(res, 200, { ok: true, data: { ...outcome, status: runtime.status() } })
|
|
410
520
|
}) })
|
|
411
|
-
//
|
|
521
|
+
// 自动更新开关(默认关)。开=外壳每 10 分钟问一次有没有新版,有就自己装并就地切换
|
|
522
|
+
// (轮询住在外壳里,因为「没人看着也要发生」;装与换代用的就是上面这两条同一套实现)。
|
|
412
523
|
registerRoute({ kind: 'exact', path: '/api/relay/update/auto', handler: localOnly('POST', async (req, res) => {
|
|
413
524
|
const runtime = pluginRuntime()
|
|
414
525
|
if (!runtime) return sendJson(res, 409, runtimeUnavailable)
|
package/lib/shell/updater.js
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 更新编排:**查与装都归实现自己**,装完**就地换代**(不重启 DSH)。
|
|
3
3
|
*
|
|
4
4
|
* ## 为什么这件事归外壳做,而不是归面板(浏览器)
|
|
5
5
|
*
|
|
6
6
|
* 面板里那颗「更新」按钮只能覆盖「用户正开着页面点一下」这一种情形;自动更新的定义是
|
|
7
7
|
* **没人看着也要发生**。所以轮询和「装完切换」都必须在宿主进程里做,面板只是给它一个开关。
|
|
8
8
|
*
|
|
9
|
-
* ##
|
|
9
|
+
* ## 装法住哪儿(2026-10-11 改)
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* 从前「查」和「装」都是请 DSH 插件市场代劳的(打它的 HTTP 接口)。现在两件都归实现自己:
|
|
12
|
+
* 查问 npm 注册表、装在 profile 里跑它自己的包管理器(`lib/update-registry.cjs` /
|
|
13
|
+
* `lib/update-install.cjs`)。外壳**只从盘上那一代**把自装模块取进来跑(`installedInstaller`),
|
|
14
|
+
* 所以换装法、换判定都不用等重启 —— 盘上换了,下一次自动更新就走新的。
|
|
14
15
|
*
|
|
15
|
-
*
|
|
16
|
+
* 下面那套市场实现是**兜底**,只在「盘上那一代还没有自装模块」时才走到(老一代、刚回滚到
|
|
17
|
+
* 旧代)。自动更新宁可暂时还走老路,也不能直接坏掉;市场装完会报 `restartRequired: true`,
|
|
18
|
+
* 而本插件有外壳可以自己换代,所以那条路里**故意不调它的重启**。
|
|
19
|
+
*
|
|
20
|
+
* 装完新一代是**新目录**(包管理器换链接),外壳照着新 realpath 重新 import 一代即可,见
|
|
16
21
|
* `lib/shell/manager.js` 与 `lib/shell/runtime.js`。
|
|
17
22
|
*
|
|
18
|
-
* ##
|
|
23
|
+
* ## 兜底那条路不做的事
|
|
19
24
|
*
|
|
20
25
|
* 不下载、不解包、不校验、不写 profile —— 全是市场的职责。它只回答四个问题:有没有新版、
|
|
21
26
|
* 请它装、装完了没、盘上现在是什么版本。
|
|
@@ -25,12 +30,46 @@ import fs from 'node:fs'
|
|
|
25
30
|
import path from 'node:path'
|
|
26
31
|
import { createHash } from 'node:crypto'
|
|
27
32
|
import { createRequire } from 'node:module'
|
|
28
|
-
import {
|
|
33
|
+
import { pathToFileURL } from 'node:url'
|
|
34
|
+
import { installedPackageDir, installedVersion, profileNodeModules } from './manager.js'
|
|
29
35
|
|
|
30
36
|
const require_ = createRequire(import.meta.url)
|
|
31
37
|
const { dshHome } = require_('../shared/paths.js')
|
|
32
38
|
|
|
33
39
|
|
|
40
|
+
/**
|
|
41
|
+
* 盘上那一代的「自己装」模块(`lib/update-install.cjs`)。
|
|
42
|
+
*
|
|
43
|
+
* 「装」这件事 2026-10-11 起归**实现**自己:在 profile 里跑它自己的包管理器,不再借道 DSH
|
|
44
|
+
* 插件市场。外壳只负责把盘上那一代的模块取进来跑 —— 从**盘上**(不是外壳自己的目录)取,
|
|
45
|
+
* 是为了「装法改了不用等重启」:市场以外的方式换掉那一代之后,下一次自动更新就走新装法。
|
|
46
|
+
*
|
|
47
|
+
* 取不到(老一代没这个文件、安装目录被判了不存在、模块加载失败)就返回 null,调用方退回
|
|
48
|
+
* 下面那套市场实现 —— 自动更新宁可暂时还走老路,也不能直接坏掉。
|
|
49
|
+
*/
|
|
50
|
+
async function installedInstaller() {
|
|
51
|
+
const dir = installedPackageDirSafe()
|
|
52
|
+
if (!dir) return null
|
|
53
|
+
const file = path.join(dir, 'lib', 'update-install.cjs')
|
|
54
|
+
if (!fs.existsSync(file)) return null
|
|
55
|
+
try {
|
|
56
|
+
const mod = await import(`${pathToFileURL(file).href}?v=${Date.now()}`)
|
|
57
|
+
const fn = mod.installLatestIfNeeded ?? mod.default?.installLatestIfNeeded
|
|
58
|
+
return typeof fn === 'function' ? fn : null
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `installedPackageDir()` 只是读个路径,读不到不该把自动更新整个抛掉。 */
|
|
65
|
+
function installedPackageDirSafe() {
|
|
66
|
+
try {
|
|
67
|
+
return installedPackageDir()
|
|
68
|
+
} catch {
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
34
73
|
/**
|
|
35
74
|
* 外壳自己的指纹(`lib/index.js` + `lib/shell/*.js` 的内容哈希)。
|
|
36
75
|
*
|
|
@@ -188,8 +227,38 @@ const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'rolled-back'])
|
|
|
188
227
|
* 装完了但盘上版本没变(市场报成功、文件没落),这里如实返回 `installed: false`,不让调用方
|
|
189
228
|
* 以为换过代码 —— 「状态说成功、代码没换」是这个仓库吃过最贵的一次亏。
|
|
190
229
|
*/
|
|
191
|
-
export async function installIfNeeded(base, { name = PACKAGE_NAME, force = false, onEvent = () => {}, now = Date.now } = {}) {
|
|
230
|
+
export async function installIfNeeded(base, { name = PACKAGE_NAME, force = false, onEvent = () => {}, packageManager = () => null, agents = () => null, now = Date.now } = {}) {
|
|
192
231
|
const before = installedVersion()
|
|
232
|
+
// 装归实现自己(2026-10-11 起):盘上那一代里有 `update-install.cjs` 就走它。判定(有没有
|
|
233
|
+
// 新版、`link:` 来源不碰、有 agent 在跑就先别换文件)也一并交给它,别在这里抄第二份 ——
|
|
234
|
+
// 外壳的自动更新与面板那颗按钮必须是同一套判定,否则会出现「手动说有新版、自动说没有」。
|
|
235
|
+
const selfInstall = await installedInstaller()
|
|
236
|
+
if (selfInstall) {
|
|
237
|
+
const outcome = await selfInstall({
|
|
238
|
+
profileNodeModules: profileNodeModules(),
|
|
239
|
+
force,
|
|
240
|
+
onEvent,
|
|
241
|
+
// 宿主发布的包管理器(优先用它装)与 agents 服务(那道闸的判据)都从外壳这一层透下去:
|
|
242
|
+
// 实现在代里,它没有宿主 ctx,只有外壳有。
|
|
243
|
+
packageManager: packageManager(),
|
|
244
|
+
agents: agents(),
|
|
245
|
+
})
|
|
246
|
+
return {
|
|
247
|
+
installed: outcome.installed === true,
|
|
248
|
+
skipped: outcome.skipped === true,
|
|
249
|
+
state: outcome.installed || outcome.skipped ? 'succeeded' : 'failed',
|
|
250
|
+
beforeVersion: outcome.beforeVersion ?? before,
|
|
251
|
+
installedVersion: outcome.installedVersion ?? before,
|
|
252
|
+
// 外壳就地换代,市场那套「装完要重启」在本项目一律不照做。只为如实上报。
|
|
253
|
+
restartRequired: false,
|
|
254
|
+
refreshRequired: false,
|
|
255
|
+
failure: outcome.failure ?? null,
|
|
256
|
+
// 「有 agent 在跑」这类是「现在不行」,下个 tick 再试,不是坏了。
|
|
257
|
+
retryable: outcome.retryable === true,
|
|
258
|
+
command: outcome.command ?? null,
|
|
259
|
+
check: outcome.check ?? null,
|
|
260
|
+
}
|
|
261
|
+
}
|
|
193
262
|
const check = await checkUpdate(base, name, { force })
|
|
194
263
|
if (check.source !== null && !updatableSource(check.source)) {
|
|
195
264
|
// 本地 link / file: 装的:市场不会碰它(见 updatableSource)。如实返回,别去试。
|