dsh-mobile-plugin 0.1.64 → 0.1.66

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.
@@ -503,6 +503,13 @@ document.getElementById('f').addEventListener('submit', async (e) => {
503
503
  res.end('ok');
504
504
  return;
505
505
  }
506
+ // 地址体检要和 /healthz 一样在鉴权之前放行:手机在配对之前就要问「这个地址上
507
+ // 是宿主还是中转」,那时候它没有任何 token(上游那一条也没有 guarded)。
508
+ // 它只回服务名与版本,不是能力面。
509
+ if (url.pathname === '/api/mobile/v1/health') {
510
+ proxyToUpstream(req, res);
511
+ return;
512
+ }
506
513
  if (!authorized(req)) {
507
514
  // API 请求必须回 JSON 401(见 isApiRequest 注释);只有浏览器导航才给配对页。
508
515
  if (isApiRequest(req, url)) sendUnauthorized(res);
@@ -47,11 +47,26 @@ function freePort(host) {
47
47
 
48
48
  /**
49
49
  * 解析出本次真正要监听的端口。
50
- * @returns {Promise<{port: number, adjusted: boolean, requested: number}>}
51
- * `adjusted=true` 表示首选端口被占、换了一个。
50
+ *
51
+ * @param {number} requested 首选端口(不合法就回落 18787)。
52
+ * @param {string} [host] 试绑地址。
53
+ * @param {object} [options]
54
+ * @param {number} [options.ours] 我们**自己占着**的端口(= 最近一次下发给局域网代理的那个号),
55
+ * 没有给 0。试绑一个自己占着的端口必然失败,但那不是「被别人占了」——调用方给这个值时,
56
+ * 等于告诉我们「这个号是我的」,于是原样保留、连试绑都不做。
57
+ * **这一条不认,只读动作也会改落盘状态**:插件面板上「看一眼二维码」那条路由也会解析
58
+ * 一次端口,代理已经在那个号上之后每次解析都会判它「被占」、换一个内核随手给的空闲端口
59
+ * 回传,而调用方会把它写回 relay.json——于是状态里记的端口与真实监听分叉,平时看不出来,
60
+ * **下次重启按漂过的值去绑,手机里存的地址就废了**。判据怎么来的见 sidecar-lifecycle 的
61
+ * getOwnLanPort:**不能**用「快照说它在 listening」,那个在重绑窗口里还是上一个号。
62
+ * @returns {Promise<{port: number, adjusted: boolean, requested: number, ours?: boolean}>}
63
+ * `adjusted=true` 表示首选端口被占、换了一个;`ours=true` 表示这个号本来就是自己的。
52
64
  */
53
- async function resolvePort(requested, host = '0.0.0.0') {
65
+ async function resolvePort(requested, host = '0.0.0.0', options = {}) {
54
66
  const wanted = Number.isInteger(requested) && requested >= 1024 && requested <= 65535 ? requested : 18787
67
+ if (Number.isInteger(options.ours) && options.ours === wanted) {
68
+ return { port: wanted, adjusted: false, requested: wanted, ours: true }
69
+ }
55
70
  if (await canListen(wanted, host)) return { port: wanted, adjusted: false, requested: wanted }
56
71
  const port = await freePort(host)
57
72
  return { port: port > 0 ? port : wanted, adjusted: true, requested: wanted }
@@ -13,6 +13,9 @@
13
13
  * 取值器/函数注入,不捕获对象——闭包里那些 `let`(sidecarSnapshot、lanPortAdjusted、
14
14
  * sidecarState…)会被整体替换或重新赋值。`getSidecar` 尤其重要:sidecar 会被自重启换掉,
15
15
  * 拿到实例本身就等于拿到一份指向旧 facade 的快照。
16
+ *
17
+ * 插件更新那两条:`check` 自己问 npm 注册表(不要求外壳在场),`apply` 只委托外壳的
18
+ * `apply`(判定 / 装 / 换代 / 落账同一层)——各自的取舍写在路由头上。
16
19
  */
17
20
 
18
21
  const fs = require('node:fs');
@@ -36,7 +39,7 @@ const desktopHalf = require('../desktop-half-stats.cjs');
36
39
  // 「查有没有新版」与「装新版」这两格都归插件自己:查直接问 npm 注册表,装在 profile 里跑
37
40
  // 它自己的包管理器(见 `../update-registry.cjs` / `../update-install.cjs`)。不再借道市场。
38
41
  const { checkForUpdate } = require('../update-registry.cjs');
39
- const { installLatestIfNeeded, declaredSource } = require('../update-install.cjs');
42
+ const { declaredSource } = require('../update-install.cjs');
40
43
  const { clearCompletionForAllDevices, invalidateSessionList } = require('../mobile/session-cache.js');
41
44
 
42
45
  /** 一次 desktop-read 上报最多带多少个会话 id(防灌爆;客户端半边一次最多也就几个)。 */
@@ -440,20 +443,6 @@ function registerControlPlaneRoutes(deps) {
440
443
  return null
441
444
  }
442
445
  }
443
- /**
444
- * 取一个宿主服务,拿不到就当没有。
445
- *
446
- * 更新这条路要用两个:`profileContext`(宿主发布的包管理器调用方式,装的时候必须优先用它)
447
- * 与 `agents`(「有对话在跑就别换插件文件」那道闸的判据)。宿主没提供某个服务时如实降级,
448
- * **不许让路由因此抛错** —— 服务不存在时 `ctx.get` 本身可能就不在。
449
- */
450
- const hostService = key => {
451
- try {
452
- return ctx && typeof ctx.get === 'function' ? ctx.get(key) ?? null : null
453
- } catch {
454
- return null
455
- }
456
- }
457
446
  const runtimeUnavailable = { ok: false, error: { code: 'update_unavailable', message: '这一版没有可换代的外壳(插件是被直接挂载的),装完没法就地生效,所以这里不装:无头部署请 rsync 源码后重启' } }
458
447
  registerRoute({ kind: 'exact', path: '/api/relay/update/status', handler: localOnly('GET', async (_req, res) => {
459
448
  const runtime = pluginRuntime()
@@ -469,7 +458,10 @@ function registerControlPlaneRoutes(deps) {
469
458
  // 拿不到(无头部署 / 没有外壳的那一版)就退回这一代自己的版本号。
470
459
  registerRoute({ kind: 'exact', path: '/api/relay/update/check', handler: localOnly('POST', async (_req, res) => {
471
460
  const runtime = pluginRuntime()
472
- const snapshot = runtime ? runtime.status() : null
461
+ // 形状必须和上面那条 `update/status` **一模一样**(含 `available`)。2026-10-11 的真机现场:
462
+ // 这条只回 `runtime.status()`,面板拿它 `setHost()` 覆盖掉好状态,`available` 变成 undefined,
463
+ // 卡片当场从「宿主模式」掉进取降级那一半 —— 用户看到的现象是「有新版本却没有『立即更新』」。
464
+ const snapshot = runtime ? { available: true, ...runtime.status() } : null
473
465
  try {
474
466
  const update = await checkForUpdate({
475
467
  installedVersion: snapshot?.installedVersion ?? snapshot?.version ?? generationVersion(),
@@ -486,15 +478,21 @@ function registerControlPlaneRoutes(deps) {
486
478
  })
487
479
  }
488
480
  }) })
489
- // 「更新并立即生效」:插件**自己**去 profile 里装新版(`../update-install.cjs`),装完请外壳
490
- // 就地换代。不再问市场 —— 装这一半也是插件自己的事了(查在同一条路上的 `check`)。
481
+ // 「更新并立即生效」:**交给外壳去做**(`runtime.apply`)。它在 profile 里跑插件自己的包
482
+ // 装器装新版、装完就地换代,并把这次结果写进它那本 `lastApply` 账 —— 面板「最近一次更新」
483
+ // 与那颗「强制更新」按钮的「上次没装成」判据读的都是这本账。
484
+ //
485
+ // 为什么这条路由不再自己去装(2026-10-12 改):从前它自己调 `installLatestIfNeeded` 再
486
+ // `reload`,判定确实和自动更新是同一套(外壳那条路最后也走盘上那一代的同一个函数),
487
+ // **但结果没人落账** —— 手动(含强制)更新装完,外壳的账一动不动,面板那一行永远停在上一次
488
+ // 自动更新的结果上。判定与记账必须是同一个动作,所以这里只负责传参、把外壳记完账的状态
489
+ // 原样回给面板(`status` 必须是 apply **之后**的快照,拿 apply 之前那份等于没落账)。
491
490
  //
492
- // 只在有外壳时才装:盘上换了版本、运行中的实现换不掉,等于「装了个用不上的东西」,不如
493
- // 直说办不了。装失败一律回 200 + 如实的结果(面板照着 `failure` 说人话),只有我们这边
494
- // 出了意料之外的错才回 500。
491
+ // 只在有外壳时才装(没外壳回 409):盘上换了版本、运行中的实现换不掉,等于白装。装没装成
492
+ // 一律回 200 + 如实的结果(面板照着 `failure` 说人话);只有外壳**抛错**才是意料之外,回 500。
495
493
  registerRoute({ kind: 'exact', path: '/api/relay/update/apply', handler: localOnly('POST', async (req, res) => {
496
494
  const runtime = pluginRuntime()
497
- if (!runtime) return sendJson(res, 409, runtimeUnavailable)
495
+ if (!runtime || typeof runtime.apply !== 'function') return sendJson(res, 409, runtimeUnavailable)
498
496
  let body = {}
499
497
  try {
500
498
  body = (await readJson(req)) || {}
@@ -504,64 +502,56 @@ function registerControlPlaneRoutes(deps) {
504
502
  const force = body.force === true
505
503
  const snapshot = runtime.status()
506
504
  const beforeVersion = snapshot.installedVersion ?? snapshot.version ?? generationVersion()
507
- const outcome = {
508
- installed: false,
509
- skipped: false,
510
- state: 'failed',
511
- beforeVersion,
512
- installedVersion: beforeVersion,
513
- swapped: false,
514
- // 更新后绝不重启 DSH(外壳就地换代),这两格只为如实上报。
515
- restartRequired: false,
516
- refreshRequired: false,
517
- failure: null,
518
- }
519
- const answer = (extra = {}) => Object.assign(outcome, extra)
505
+ const beforeOnDisk = snapshot.installedVersion ?? null
520
506
 
521
- const profileNodeModules = snapshot.profileNodeModules ?? null
522
- if (!profileNodeModules) {
523
- answer({ failure: { code: 'profile-unknown', message: '宿主没给出 profile 的 node_modules 路径,装不了' } })
524
- return sendJson(res, 500, { ok: false, error: { code: 'update_apply_failed', message: outcome.failure.message }, data: { ...outcome, status: snapshot } })
525
- }
526
-
527
- // 查一次(有没有新版)、可不可能被「有 agent 在跑」挡住、装不装,全在 `installLatestIfNeeded`
528
- // 里一起判:面板那颗按钮与外壳的自动更新必须走同一套判定,否则会出现「手动说有新版、
529
- // 自动说没有」这种最难查的偏差。
530
- let result
507
+ let applied
531
508
  try {
532
- result = await installLatestIfNeeded({
533
- profileNodeModules,
534
- force,
535
- // 宿主发布的包管理器调用方式(`{command, args, env?}`)。**必须优先用它**:PATH 上
536
- // 那个 pnpm 的版本可能和 profile 的 store 对不上(实测 10.x 装 11.x 的 profile 直接
537
- // `ERR_PNPM_UNEXPECTED_STORE`)。市场也是这么取的(`profileContext.packageManager`)。
538
- packageManager: hostService('profileContext')?.packageManager ?? null,
539
- // 「有对话在跑就先别换插件文件」那道闸的判据。force 由用户点按钮时显式带上。
540
- agents: hostService('agents'),
541
- })
509
+ applied = await runtime.apply({ force })
542
510
  } catch (error) {
543
- result = { installed: false, installedVersion: beforeVersion, command: null, output: '', failure: { code: 'install_failed', message: String(error?.message || error) } }
544
- }
545
- outcome.installedVersion = result.installedVersion ?? beforeVersion
546
- outcome.failure = result.failure ?? null
547
- outcome.command = result.command ?? null
548
- outcome.output = result.output ?? ''
549
- outcome.retryable = result.retryable === true
550
- // `skipped` 也要如实带出去:已经是最新那一条**不是失败**,面板照着它说「没有改动任何文件」。
551
- outcome.skipped = result.skipped === true
552
- outcome.state = result.installed || result.skipped ? 'succeeded' : 'failed'
553
- if (!result.installed) {
554
- return sendJson(res, 200, { ok: result.skipped === true, data: { ...outcome, check: result.check ?? null, status: runtime.status() } })
511
+ // 外壳抛错只有两类:装的那步就炸了(网络 / 权限 / 注册表),或者装上了、换代码那步炸了。
512
+ // 必须分开说 —— 后者盘上的版本已经变了,回一句「装上了但没换过来」比笼统报失败有用得多。
513
+ const after = runtime.status()
514
+ const landed = beforeOnDisk !== null && (after.installedVersion ?? null) !== beforeOnDisk
515
+ const failure = landed
516
+ ? { code: 'swap_failed', message: `新版已经装到盘上(${after.installedVersion}),但换代码没成:${String(error?.message || error)}` }
517
+ : { code: 'install_failed', message: String(error?.message || error) }
518
+ return sendJson(res, 500, {
519
+ ok: false,
520
+ error: { code: 'update_apply_failed', message: failure.message },
521
+ data: {
522
+ installed: landed,
523
+ skipped: false,
524
+ state: landed ? 'installed-not-swapped' : 'failed',
525
+ beforeVersion,
526
+ installedVersion: after.installedVersion ?? beforeVersion,
527
+ swapped: false,
528
+ // 更新后绝不重启 DSH(外壳就地换代),这两格只为如实上报。
529
+ restartRequired: false,
530
+ refreshRequired: false,
531
+ failure,
532
+ status: after,
533
+ },
534
+ })
555
535
  }
556
536
 
557
- // 装上了才换代:换代码是外壳的事,失败要如实报(回滚由外壳负责,见 shell/runtime.js)。
558
- try {
559
- const status = await runtime.reload()
560
- answer({ installed: true, state: 'succeeded', swapped: true, installedVersion: status?.version ?? outcome.installedVersion, refreshRequired: status?.shellChanged === true })
561
- } catch (error) {
562
- answer({ installed: true, state: 'installed-not-swapped', failure: { code: 'swap_failed', message: `新版已经装到盘上,但换代码没成:${String(error?.message || error)}` } })
537
+ // `ok` 只有「装上了」与「已经最新、什么都没动」算真;其余(含「有对话在跑」)都是没成。
538
+ const outcome = {
539
+ installed: applied.installed === true,
540
+ skipped: applied.skipped === true,
541
+ state: applied.state ?? (applied.installed || applied.skipped ? 'succeeded' : 'failed'),
542
+ beforeVersion: applied.beforeVersion ?? beforeVersion,
543
+ installedVersion: applied.installedVersion ?? beforeVersion,
544
+ swapped: applied.swapped === true,
545
+ restartRequired: applied.restartRequired === true,
546
+ refreshRequired: applied.refreshRequired === true,
547
+ failure: applied.failure ?? null,
548
+ retryable: applied.retryable === true,
549
+ command: applied.command ?? null,
550
+ check: applied.check ?? null,
551
+ // 外壳自己记完账后的状态(`lastApply` / `appliedAt` 就在这里面)。
552
+ status: applied.status ?? runtime.status(),
563
553
  }
564
- return sendJson(res, 200, { ok: true, data: { ...outcome, status: runtime.status() } })
554
+ return sendJson(res, 200, { ok: outcome.installed || outcome.skipped, data: outcome })
565
555
  }) })
566
556
  // 自动更新开关(默认关)。开=外壳每 10 分钟问一次有没有新版,有就自己装并就地切换
567
557
  // (轮询住在外壳里,因为「没人看着也要发生」;装与换代用的就是上面这两条同一套实现)。
@@ -34,6 +34,8 @@ const mobileCredentials = require('../mobile/credentials.js');
34
34
  const subagents = require('../mobile/subagents.js');
35
35
  const references = require('../mobile/references.js');
36
36
  const plugins = require('../mobile/plugins.js');
37
+ // 体检接口要自报身份:版本号只有一个来源,就是包自己的 package.json(与 relay/status.js 同)。
38
+ const pkg = require('../../package.json');
37
39
  const desktopView = require('../desktop-view.cjs');
38
40
 
39
41
  const API_VERSION = '1';
@@ -78,6 +80,20 @@ function registerMobileRoutes(deps) {
78
80
  ...hostMetadata(),
79
81
  })
80
82
  }) })
83
+ // 地址体检:手机在**配对之前**就要问「这个地址上到底是谁」,那时它手上没有任何
84
+ // 凭据,所以这条刻意不包 guarded(豁免与理由见 test/mobile-routes.test.cjs 的
85
+ // PUBLIC_ROUTES)。它能说的话只有服务名与版本——没有实例信息、没有任何能力面。
86
+ // 中转侧在 server.js 的同名路径上回 'dsh-mobile-relay',App 靠这个字段分辨
87
+ // 「填的是宿主还是中转」(消费方 app/src/services/addressProbe.ts)。
88
+ //
89
+ // ⚠️ 签名必须是 `(req, res)` **两个**参数:宿主 webServer 就是 `handler(req, res)` 调的,
90
+ // 上面那条 `guarded(...)` 之所以能收 `(ctx, req, res)`,是因为那三个参数由 guarded 自己补。
91
+ // 这里曾写成 `(_ctx, _req, res)`,于是 `res` 是 undefined,而 `sendJson` 的 catch 会把
92
+ // TypeError 当成「连接已关闭」吞掉——既没报错也没回包,请求**永久挂住**;手机 5s 超时后
93
+ // 退回探活接口拿到 ok,报「能连上但不是 DSH 移动端服务」。纯文本的路由表测试抓不到这类错。
94
+ registerRoute({ kind: 'exact', path: '/api/mobile/v1/health', handler: async (_req, res) => {
95
+ sendJson(res, 200, { ok: true, service: 'dsh-mobile-plugin', apiVersion: API_VERSION, version: pkg.version })
96
+ } })
81
97
  // 局域网/中转连接信息。公网配对时由中转在 `/api/mobile/v1/connection-info`
82
98
  // 代答宿主上报的镜像数据;但纯局域网配对(App 只扫到局域网二维码、完全不经
83
99
  // 中转)需要宿主自己回答同一个地址,否则 App 拿不到 lanCandidates,换网卡后
@@ -100,9 +100,13 @@ function createSidecarLifecycle(deps) {
100
100
  // 请求复用同一个在飞 Promise,并记录一次待重跑,确保最终下发的是最新配置。
101
101
  let syncInFlight = null
102
102
  let syncQueued = false
103
+ // 我们最近一次**下发给 worker** 的局域网端口(0 = 还没下发过)。它是 getOwnLanPort 的判据。
104
+ let configuredLanPort = 0
103
105
  const applySidecarConfig = async () => {
104
106
  await resolveLanPort()
105
- sidecarSnapshot = await sidecar.configure(buildConfig(lanPortInUse()))
107
+ const config = buildConfig(lanPortInUse())
108
+ configuredLanPort = Number.isInteger(config.lanPort) ? config.lanPort : 0
109
+ sidecarSnapshot = await sidecar.configure(config)
106
110
  sidecarState = 'healthy'
107
111
  return sidecarSnapshot
108
112
  }
@@ -239,6 +243,20 @@ function createSidecarLifecycle(deps) {
239
243
  if (Number.isInteger(reported) && reported > 0) return reported
240
244
  return lanPortInUse()
241
245
  },
246
+ /**
247
+ * 「这个局域网端口是我们自己占着的」——就是**最近一次下发给 worker 的那个号**;没下发过是 0。
248
+ *
249
+ * 为什么不用快照来判:`lan.port` 走的是 worker 上报的状态帧,是**另一条消息**。刚
250
+ * `configure` 完的那个窗口里快照还是上一个号(或还没绑完),此刻去试绑刚下发的号必然
251
+ * 失败——那是自己占着自己,不是被别人占了。只认快照的话,紧接着的第二次同步就会把这个
252
+ * 号判成「被占」、换个内核随手给的端口写回 relay.json,监听也跟着挪走(`/api/relay/config`
253
+ * 改端口那条路由**一次会同步两遍**,第二遍正好落在这个窗口里;2026-10-11 真机实测,
254
+ * 落盘端口 18787→22779→33091→17079→28223 一路漂,手机里存的地址就是这么废的)。
255
+ *
256
+ * 代价照实说:真的被别的程序占着时,这里也认这个号。那没关系——worker 自己 bind 失败会
257
+ * 退到内核给的空闲端口并按**实绑值**上报,对外报的地址(getAdvertisedLanPort)跟着它走。
258
+ */
259
+ getOwnLanPort: () => configuredLanPort,
242
260
  markSidecarDown,
243
261
  }
244
262
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.1.64",
3
+ "version": "0.1.66",
4
4
  "description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (0.2.0-rc.2 compatible, peers resolved by the host)",
5
5
  "license": "MIT",
6
6
  "type": "module",