dsh-mobile-plugin 0.5.0 → 0.5.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.
@@ -19,7 +19,8 @@
19
19
  * - **Windows**:默认入站全拒,所以「有允许规则」才算放行,没有就是被挡;还要看那条
20
20
  * 规则管的是哪个网络类别 —— 笔记本换个 WiFi、系统把那个网判成「公用」时,只放行
21
21
  * 「专用」的老规则就不再生效。这就是「出去一趟就连不上、看一眼防火墙又好了」的
22
- * 真身,所以当前接入的网络类别必须一起查。
22
+ * 真身,所以当前接入的网络类别必须一起查。开关与规则都按**生效策略**(`ActiveStore`)
23
+ * 查,不按本地静态库 —— 见 `WINDOWS_SCRIPT` 上面那条。
23
24
  * - **Linux**:只能读到「防火墙开着没」(`firewall-cmd --state` 不要 root),
24
25
  * 「放行了没」要 root 才读得到 —— 那条我们不碰,一律回 `null`。
25
26
  *
@@ -139,19 +140,24 @@ function decideWindowsAllows(report) {
139
140
  /**
140
141
  * Windows 那份 PowerShell。
141
142
  *
142
- * 两个要点:
143
+ * 三个要点:
143
144
  * 1. `Get-NetFirewallApplicationFilter -Program <path>` 是**服务端过滤**,只回这个程序
144
145
  * 的过滤条件;换成枚举全部规则再逐条 join 会有几百次 CIM 查询,第一次跑要好几秒。
145
146
  * 2. 输出必须是 JSON 且全 ASCII —— `netsh` 的文字输出会跟着系统语言变,中文 Windows
146
147
  * 上没有办法稳定匹配。
148
+ * 3. 每一条查询都写死 `-PolicyStore ActiveStore`。不带这个参数时 PowerShell 读的是
149
+ * 本地静态库(PersistentStore),而它**不含组策略下发的开关与规则**:组策略管着的
150
+ * 机器上,系统界面显示的防火墙开着,按本地库读出来三个 profile 可能全是「关」
151
+ * —— 用户看到的「实际防火墙开着、自检说关着」就是这一条;反过来,组策略下发的
152
+ * 放行规则也会被漏掉、报成「挡住」。
147
153
  */
148
154
  const WINDOWS_SCRIPT = [
149
155
  '$path = $env:DSH_FIREWALL_PROGRAM;',
150
- '$profiles = @(Get-NetFirewallProfile | ForEach-Object { @{ name = "$($_.Name)"; enabled = [bool]$_.Enabled } });',
156
+ '$profiles = @(Get-NetFirewallProfile -PolicyStore ActiveStore | ForEach-Object { @{ name = "$($_.Name)"; enabled = [bool]$_.Enabled } });',
151
157
  '$active = @(Get-NetConnectionProfile | ForEach-Object { $c = "$($_.NetworkCategory)"; if ($c -eq "DomainAuthenticated") { "Domain" } else { $c } });',
152
158
  '$apps = @();',
153
- 'foreach ($f in @(Get-NetFirewallApplicationFilter -Program $path)) {',
154
- ' $r = Get-NetFirewallRule -Name $f.Name;',
159
+ 'foreach ($f in @(Get-NetFirewallApplicationFilter -PolicyStore ActiveStore -Program $path)) {',
160
+ ' $r = Get-NetFirewallRule -Name $f.Name -PolicyStore ActiveStore;',
155
161
  ' if ($r) { $apps += @{ program = "$($f.Program)"; action = "$($r.Action)"; enabled = ("$($r.Enabled)" -eq "True"); profile = "$($r.Profile)" } }',
156
162
  '}',
157
163
  '@{ profiles = $profiles; apps = $apps; activeProfiles = $active } | ConvertTo-Json -Depth 6 -Compress',
@@ -17,6 +17,7 @@ const { orderCandidates, withManualCandidate } = require('../relay/lan-network.c
17
17
  const sessions = require('../mobile/sessions.js');
18
18
  const sessionModelOptions = require('../mobile/session-model-options.js');
19
19
  const sessionFile = require('../mobile/session-file.js');
20
+ const sessionLocalImage = require('../mobile/session-local-image.js');
20
21
  const sessionStep = require('../mobile/session-step.js');
21
22
  const fileRead = require('../mobile/file-read.js');
22
23
  const stats = require('../mobile/stats.js');
@@ -29,6 +30,7 @@ const workspaces = require('../mobile/workspaces.js');
29
30
  const directoryListing = require('../mobile/directory-listing.js');
30
31
  const archiveClean = require('../mobile/archive-clean.js');
31
32
  const sessionLog = require('../mobile/session-log.js');
33
+ const debugTrace = require('../mobile/debug-trace.js');
32
34
  const hostSettings = require('../mobile/host-settings.js');
33
35
  const channels = require('../mobile/channels.js');
34
36
  const deepSeekAccount = require('../mobile/deepseek-account.js');
@@ -229,6 +231,8 @@ function registerMobileRoutes(deps) {
229
231
  // 一步的过程详情:清单只带一行摘要,点开某一步才来这里取参数 / 输出 / diff / 思考全文。
230
232
  route('/api/mobile/v1/session/step', sessionStep.handleSessionStep)
231
233
  route('/api/mobile/v1/session/attachment', sessions.handleSessionAttachment)
234
+ // 正文图的字节:按登记过的编号取,不是按路径读文件(见 mobile/local-images.js 的文件头)。
235
+ route('/api/mobile/v1/session/local-image', sessionLocalImage.handleSessionLocalImage)
232
236
  // 会话文件:允许本会话 present 声明的路径及会话工作区内的文件(见 mobile/session-file.js)。
233
237
  route('/api/mobile/v1/session/file', sessionFile.handleSessionFile)
234
238
  // 文件浏览器按绝对路径取字节,范围是宿主已登记的工作区及当前会话的项目目录
@@ -267,6 +271,9 @@ function registerMobileRoutes(deps) {
267
271
  route('/api/mobile/v1/agent-presets/read', agentPresets.handleAgentPresetRead)
268
272
  route('/api/mobile/v1/agent-presets/copy', agentPresets.handleAgentPresetCopy)
269
273
  route('/api/mobile/v1/agent-presets/remove', agentPresets.handleAgentPresetRemove)
274
+ // 把某一档设成「新会话默认吃的那一档」:写宿主 settings 的 agent-preset-registry,
275
+ // 与桌面端预设页那颗「设为默认」同一格(不是预设自己的属性)。
276
+ route('/api/mobile/v1/agent-presets/default', agentPresets.handleAgentPresetDefault)
270
277
  route('/api/mobile/v1/session/new', sessions.handleNew)
271
278
  route('/api/mobile/v1/session/stream', stream.handleStream)
272
279
  route('/api/mobile/v1/events/stream', events.handleDeviceStream)
@@ -326,6 +333,10 @@ function registerMobileRoutes(deps) {
326
333
  // 的 127.0.0.1 上手机直连不到 —— 所以由插件代取(mobile/session-log.js),App 侧
327
334
  // 再落盘。
328
335
  route('/api/mobile/v1/session-log', sessionLog.handleSessionLog)
336
+ // 手机端调试轨迹(开发包专用,见 mobile/debug-trace.js):App 录滑动过程,边录边把 JSONL
337
+ // 打到这里,插件写进 <DSH_HOME>/mobile-debug/<capture>.jsonl,维护者在电脑上直接读。
338
+ // 只追加、轨迹名走白名单、单份 8MB 封顶 —— 不是「任意文件写入」。
339
+ route('/api/mobile/v1/debug/trace', debugTrace.handleDebugTrace)
329
340
  // 宿主设置与模型提供方(宿主 settings.describe / llm.providers 的移动端只读代理)。
330
341
  // 桌面端「设置」里有默认模型、提供方配置、凭据是否已设;手机端此前完全没有这一块,
331
342
  // 只能看到 App 自己的偏好项。两个宿主接口本身已脱敏(secrets 只给 path 与 set),
@@ -0,0 +1,73 @@
1
+ /**
2
+ * 宿主半边的自检口:`/api/relay/host-half` 与它的 `/reload`。
3
+ *
4
+ * 这两条**必须由外壳注册**,不能放进实现:实现装载失败时整张 `/api/relay/*` 都不在,
5
+ * 而「它为什么没起来」只有外壳答得出来(外壳自己活得好好的是刻意的故障边界)。
6
+ * 现场的样子是设置页一句「插件宿主侧没有加载」,原因只打在宿主 stderr 里、桌面端不落盘,
7
+ * 用户看不见、我们也查不出来 —— 这两条就是那个原因唯一的出口。
8
+ *
9
+ * 放在 `lib/shell/` 而不是 `lib/index.js` 里,是因为外壳的守卫钉着「只该有装载逻辑」。
10
+ * 这里只干两件小事:把 state 拍成响应体(`half-view.js`),以及把「重新装载」转给
11
+ * runtime 那套换代秩序(串行、先停后起、失败回滚)。
12
+ *
13
+ * 两条都走 `localOnly`:拒绝中转/局域网转发来的请求、拒绝跨站、钉住 method。它们能读到的
14
+ * 是这台机器的插件装载状态,不值得让别处看见。
15
+ */
16
+
17
+ import { createRequire } from 'node:module'
18
+ import { hostHalfView } from './half-view.js'
19
+
20
+ const require = createRequire(import.meta.url)
21
+ const { localOnly } = require('../http/routing.js')
22
+ const { sendJson } = require('../shared/http-util.js')
23
+
24
+ export const HOST_HALF_PATH = '/api/relay/host-half'
25
+ export const HOST_HALF_RELOAD_PATH = '/api/relay/host-half/reload'
26
+
27
+ /**
28
+ * 挂上两条路由,返回清理函数(给 `ctx.effect` 当返回值用)。
29
+ *
30
+ * @param {object} ctx 外壳那份 context(只用它的 `webServer`)。
31
+ * @param {{status: () => object, reload: () => Promise<unknown>}} runtime 代际运行时。
32
+ * @param {{installedVersion?: () => string|null, installedShellHash?: () => string|null,
33
+ * runningShellHash?: string|null}} [deps] 盘上那一份的坐标,只为「要不要重启」这一格。
34
+ */
35
+ export function installHostHalf(ctx, runtime, deps = {}) {
36
+ const server = ctx?.webServer
37
+ if (typeof server?.register !== 'function') return () => {}
38
+ const installedVersion = typeof deps.installedVersion === 'function' ? deps.installedVersion : () => null
39
+ const installedShellHash = typeof deps.installedShellHash === 'function' ? deps.installedShellHash : () => null
40
+ const runningShellHash = typeof deps.runningShellHash === 'string' ? deps.runningShellHash : null
41
+ const view = () => hostHalfView(runtime.status(), {
42
+ installedVersion: installedVersion(),
43
+ shellChanged: installedShellHash() !== null && installedShellHash() !== runningShellHash,
44
+ })
45
+ const offs = [
46
+ server.register({
47
+ kind: 'exact',
48
+ path: HOST_HALF_PATH,
49
+ handler: localOnly('GET', async (_req, res) => sendJson(res, 200, view())),
50
+ }),
51
+ server.register({
52
+ kind: 'exact',
53
+ path: HOST_HALF_RELOAD_PATH,
54
+ handler: localOnly('POST', async (_req, res) => {
55
+ try {
56
+ await runtime.reload()
57
+ return sendJson(res, 200, view())
58
+ } catch (error) {
59
+ // 装载失败时外壳已经按老规矩回滚过了,这里只把原因与回滚后的状态一起交出去:
60
+ // 面板要能原样显示「还是不行,因为……」。
61
+ return sendJson(res, 200, {
62
+ ...view(),
63
+ ok: false,
64
+ error: { code: 'reload-failed', message: String((error && error.message) || error) },
65
+ })
66
+ }
67
+ }),
68
+ }),
69
+ ].filter(off => typeof off === 'function')
70
+ return () => {
71
+ for (const off of offs) off()
72
+ }
73
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 宿主半边自检的响应体:把 `runtime.status()` 收成设置页能读的那几格。
3
+ *
4
+ * 为什么单独一个纯函数:拼响应体的地方在外壳里,而外壳不好在测试里跑起来(它一 apply
5
+ * 就真的去装载实现)。这里只做取值与白名单,测试直接喂一份 status 就能钉住形状。
6
+ *
7
+ * **`lastError` 是这一条接口存在的理由**:实现装载失败时外壳仍然活着,于是路由 404、
8
+ * 设置页只显示一句「插件宿主侧没有加载」,而真正的原因(等哪个服务超时、哪一代装不上)
9
+ * 只打在宿主 stderr 里,桌面端不落盘、用户看不见、我们也就查不出来。这条接口把它原样
10
+ * 交给设置页。
11
+ */
12
+
13
+ /**
14
+ * @param {object} status `runtime.status()` 的快照。
15
+ * @param {{installedVersion?: string|null, shellChanged?: boolean}} [extra]
16
+ * 盘上那一份的坐标(市场装完立刻变,和跑着的对照就看得出要不要重启)。
17
+ */
18
+ export function hostHalfView(status, extra = {}) {
19
+ const s = status && typeof status === 'object' ? status : {};
20
+ const info = extra && typeof extra === 'object' ? extra : {};
21
+ return {
22
+ ok: true,
23
+ half: {
24
+ version: typeof s.version === 'string' ? s.version : null,
25
+ source: typeof s.source === 'string' ? s.source : null,
26
+ previousVersion: typeof s.previousVersion === 'string' ? s.previousVersion : null,
27
+ activatedAt: typeof s.activatedAt === 'string' ? s.activatedAt : null,
28
+ lastError: typeof s.lastError === 'string' ? s.lastError : null,
29
+ /** 启动时那几次没成、现在仍在后台按长间隔重试 —— 面板据此劝人「再等等」而不是重装。 */
30
+ retrying: s.retrying === true,
31
+ retryAttempts: Number.isFinite(s.retryAttempts) ? s.retryAttempts : 0,
32
+ nextRetryAt: typeof s.nextRetryAt === 'string' ? s.nextRetryAt : null,
33
+ installedVersion: typeof info.installedVersion === 'string' ? info.installedVersion : null,
34
+ shellChanged: info.shellChanged === true,
35
+ },
36
+ };
37
+ }
@@ -16,10 +16,26 @@ import { checkGenerationAlive } from './self-check-after-swap.js'
16
16
 
17
17
  /** 换代前等在飞请求归零的上限。 */
18
18
  const DRAIN_TIMEOUT_MS = 2000
19
- /** 等一代装载完成的上限。装载卡住(依赖永远不来)时不能把整个外壳拖死。 */
20
- const ACTIVATE_TIMEOUT_MS = 20000
19
+ /**
20
+ * 等一代装载完成的上限。装载卡住(依赖永远不来)时不能把整个外壳拖死。
21
+ *
22
+ * 20s 在慢机器上不够:失败的首要原因就是注入的服务(typertGateway / apiProxy)比本插件
23
+ * 晚就位,而 Windows 冷启动、装了一堆插件的 DSH 要几十秒才轮到那条服务。真实现场是
24
+ * 「重启还是不行」—— 设置页一句 404,日志里什么都没有。
25
+ */
26
+ const ACTIVATE_TIMEOUT_MS = 60000
21
27
  /** 首次装载全失败后的重试间隔与次数(重试理由见 `start()`)。 */
22
28
  const START_RETRY_LIMIT = 6
29
+ /**
30
+ * 启动那一轮没成之后,后台继续按这个节奏重试多久。
31
+ *
32
+ * 失败代价是**整张控制面路由表都不在**,而根因多半是「服务晚到」这种会自己好的事。
33
+ * 只重试启动那六次(约五分钟)然后就躺平,等于把一个瞬态问题固定成故障,用户唯一的
34
+ * 出路是重启 —— 而重启正好又落回同一个时间窗里。这里按长间隔再试一小时,并让设置页
35
+ * 看得到「还在试」。
36
+ */
37
+ const RETRY_BACKLOG_MS = 120000
38
+ const RETRY_BACKLOG_LIMIT = 30
23
39
  /** 间隔可用环境变量压低,只给测试(与 `DSH_MOBILE_PROFILE_NODE_MODULES` 同款逃生口)。 */
24
40
  const START_RETRY_MS = Number(process.env.DSH_MOBILE_START_RETRY_MS) > 0
25
41
  ? Number(process.env.DSH_MOBILE_START_RETRY_MS)
@@ -68,6 +84,15 @@ export function createRuntime() {
68
84
  * 读到这条已经发生过的事实(`lastApply.rolledBack` 就是从它来的)。
69
85
  */
70
86
  let lastRollback = null
87
+ /**
88
+ * 后台补齐重试的账:启动那一轮失败后接着试,试到成功或额度用完。
89
+ *
90
+ * 这三格同时也是说给用户听的:`retrying` 决定面板劝人「再等等」还是「重装」,
91
+ * `nextRetryAt` 让人知道下一次是几分钟后。
92
+ */
93
+ let retryTimer = null
94
+ let retryAttempts = 0
95
+ let nextRetryAt = null
71
96
 
72
97
  /** 对外状态快照。控制面接口直接把它交给客户端。 */
73
98
  function status() {
@@ -80,6 +105,9 @@ export function createRuntime() {
80
105
  inFlight,
81
106
  swapping: swapping !== null,
82
107
  lastRollback,
108
+ retrying: retryTimer !== null,
109
+ retryAttempts,
110
+ nextRetryAt,
83
111
  }
84
112
  }
85
113
 
@@ -261,6 +289,7 @@ export function createRuntime() {
261
289
  throw error
262
290
  }
263
291
  }
292
+ clearBacklogRetry()
264
293
  return status()
265
294
  } catch (error) {
266
295
  // 自检那条已经在上面退回过一次了,别退第二遍(第二遍会把刚装回去的旧代再卸一次)。
@@ -286,37 +315,103 @@ export function createRuntime() {
286
315
  * (typertGateway / apiProxy)比本插件晚就位、撞上 20s 装载上限,或者第一次装载撞上
287
316
  * IO/时序抖动。一次失败就永久躺平到下次重启,等于把一个瞬态问题固定成故障。
288
317
  */
318
+ /**
319
+ * 把当前可用的每一代都试一遍:成功就装成当前代并返回 `{ok:true}`,全失败返回
320
+ * `{ok:false, failures}`。`start()` 与后台补齐重试共用这一份,别各写一遍。
321
+ *
322
+ * 候选**现算**而不是启动时抓一份快照:市场换过代、OTA 落过一版之后,这里要能看见新目录。
323
+ */
324
+ async function attemptAll() {
325
+ const failures = []
326
+ for (const target of resolveGenerations()) {
327
+ if (detached) throw new Error('插件已经卸载,不再重试装载')
328
+ try {
329
+ const { loaded, fiber: startedFiber } = await activate(target)
330
+ fiber = startedFiber
331
+ loadedCurrent = loaded
332
+ current = describe(loaded, target.dir)
333
+ activatedAt = new Date().toISOString()
334
+ lastError = failures.length
335
+ ? `${failures.join(';')};已回退到 ${target.version}(${target.source})`
336
+ : null
337
+ return { ok: true, version: target.version }
338
+ } catch (error) {
339
+ failures.push(`装载 ${target.version}(${target.source})失败:${error?.message || error}`)
340
+ }
341
+ }
342
+ return { ok: false, failures }
343
+ }
344
+
345
+ /** 停掉后台补齐重试(装成功后调用:再试一次只会撞同一批路由)。 */
346
+ function clearBacklogRetry() {
347
+ if (retryTimer !== null) {
348
+ clearTimeout(retryTimer)
349
+ retryTimer = null
350
+ }
351
+ retryAttempts = 0
352
+ nextRetryAt = null
353
+ }
354
+
355
+ /**
356
+ * 启动那一轮没成之后接着试:长间隔、有额度,成功即停。
357
+ *
358
+ * 它是 `lastError` 之外的第二次机会,也是「用户什么都不做也能自己好」的那条路。
359
+ */
360
+ function scheduleBacklogRetry(log) {
361
+ if (detached || retryTimer !== null || retryAttempts >= RETRY_BACKLOG_LIMIT) return
362
+ retryAttempts += 1
363
+ nextRetryAt = new Date(Date.now() + RETRY_BACKLOG_MS).toISOString()
364
+ retryTimer = setTimeout(() => {
365
+ retryTimer = null
366
+ nextRetryAt = null
367
+ if (detached) return
368
+ void attemptAll().then(result => {
369
+ if (result.ok) {
370
+ retryAttempts = 0
371
+ log('info', 'dsh-mobile-plugin: 后台补齐成功,已装载 %s', result.version)
372
+ return
373
+ }
374
+ lastError = `所有可用插件实现均装载失败:${result.failures.join(';')}`
375
+ log('warn', 'dsh-mobile-plugin: 后台补齐仍未成功:%s', lastError)
376
+ scheduleBacklogRetry(log)
377
+ }).catch(error => {
378
+ lastError = `后台补齐装载出错:${error?.message || error}`
379
+ scheduleBacklogRetry(log)
380
+ })
381
+ }, RETRY_BACKLOG_MS)
382
+ if (typeof retryTimer.unref === 'function') retryTimer.unref()
383
+ }
384
+
385
+ /**
386
+ * 第一次激活;最高代装载失败时按优先级尝试保留的回退代;全失败则按固定间隔重试有限次,
387
+ * 之后交给后台补齐接着试。
388
+ *
389
+ * 为什么要重试:一次失败 = **整张控制面路由表都不在**,而设置页
390
+ * 的「移动端」照样画得出来(客户端半边按包扫描,不看宿主 fiber 活没活),于是现场只表现成
391
+ * 「读取宿主状态失败: HTTP 404」。失败最常见的原因恰好是**可能自己好**的:注入的服务
392
+ * (typertGateway / apiProxy)比本插件晚就位、撞上装载上限,或者第一次装载撞上 IO/时序抖动。
393
+ * 一次失败就永久躺平到下次重启,等于把一个瞬态问题固定成故障。
394
+ */
289
395
  async function start(context, options = {}) {
290
396
  ctx = context
291
397
  const log = typeof options.log === 'function' ? options.log : () => {}
292
- const candidates = resolveGenerations()
293
- if (!candidates.length) throw new Error('找不到任何一代实现:包内的 lib/impl.js 缺失')
398
+ if (!resolveGenerations().length) throw new Error('找不到任何一代实现:包内的 lib/impl.js 缺失')
294
399
  let lastFailure = ''
295
400
  for (let attempt = 1; attempt <= START_RETRY_LIMIT; attempt += 1) {
296
- const failures = []
297
- for (const target of candidates) {
298
- if (detached) throw new Error('插件已经卸载,不再重试装载')
299
- try {
300
- const { loaded, fiber: startedFiber } = await activate(target)
301
- fiber = startedFiber
302
- loadedCurrent = loaded
303
- current = describe(loaded, target.dir)
304
- activatedAt = new Date().toISOString()
305
- lastError = failures.length
306
- ? `${failures.join(';')};已回退到 ${target.version}(${target.source})`
307
- : null
308
- return status()
309
- } catch (error) {
310
- failures.push(`装载 ${target.version}(${target.source})失败:${error?.message || error}`)
311
- }
401
+ const result = await attemptAll()
402
+ if (result.ok) {
403
+ clearBacklogRetry()
404
+ return status()
312
405
  }
313
- lastFailure = `所有可用插件实现均装载失败:${failures.join(';')}`
406
+ lastFailure = `所有可用插件实现均装载失败:${result.failures.join(';')}`
314
407
  if (attempt >= START_RETRY_LIMIT) break
315
408
  lastError = `${lastFailure}(第 ${attempt} 次,${START_RETRY_MS / 1000}s 后重试)`
316
409
  log('warn', 'dsh-mobile-plugin: 装载失败,%d 秒后重试:%s', START_RETRY_MS / 1000, lastFailure)
317
410
  await sleep(START_RETRY_MS)
318
411
  }
319
412
  lastError = lastFailure
413
+ // 抛出去是为了让外壳那行日志照旧留下;后台补齐另起一条线,两条不冲突。
414
+ scheduleBacklogRetry(log)
320
415
  throw new Error(lastFailure)
321
416
  }
322
417
 
@@ -331,6 +426,7 @@ export function createRuntime() {
331
426
  /** 外壳被卸载时调用:停当前代,之后不再接受换代。 */
332
427
  async function detach() {
333
428
  detached = true
429
+ clearBacklogRetry()
334
430
  const oldFiber = fiber
335
431
  fiber = null
336
432
  current = null
@@ -43,6 +43,11 @@ function createSidecarLifecycle(deps) {
43
43
  // 那样重启就一定有一半调用点还握着旧实例。
44
44
  // The child-process bridge is used by the sidecar transport; Cordis services remain injected read-only.
45
45
  let sidecarState = 'starting'
46
+ // 「这个 error 态是不是健康轮询自己写上去的」。轮询失败只是这一次回环请求被掐了
47
+ // (worker 正在被换掉、socket 抖一下),下一次轮询通了就必须自己抹回 healthy;
48
+ // 不下发配置、不重启,那一行就会一直挂着旧错误等人来点重启。反过来,下发配置 /
49
+ // 重启失败留下的 error **不许**被轮询抹掉 —— worker 活着但配置没生效,那还是坏的。
50
+ let stateFromPoll = false
46
51
  let sidecarSnapshot = { tunnel: { state: 'off', error: null }, lan: { state: 'off', error: null } }
47
52
  // worker 意外退出时的唯一出口:把状态打回「不健康」,而不是让它冻在最后一帧。
48
53
  // 冻住的后果是 /api/relay/status 继续报 tunnel: connected / lan: listening,
@@ -53,6 +58,7 @@ function createSidecarLifecycle(deps) {
53
58
  // 时要把 markSidecarDown 交进去,箭头函数还没初始化就被引用会直接 TDZ 报错。
54
59
  const markSidecarDown = (error) => {
55
60
  const message = error instanceof Error ? error.message : String(error)
61
+ stateFromPoll = true
56
62
  sidecarState = `error: ${message}`
57
63
  sidecarSnapshot = { tunnel: { state: 'off', error: message }, lan: { state: 'off', error: message } }
58
64
  }
@@ -119,12 +125,14 @@ function createSidecarLifecycle(deps) {
119
125
  }
120
126
  applyLedger.recordSuccess()
121
127
  sidecarState = 'healthy'
128
+ stateFromPoll = false
122
129
  return sidecarSnapshot
123
130
  }
124
131
  const syncSidecar = () => {
125
132
  if (syncInFlight) { syncQueued = true; return syncInFlight }
126
133
  syncInFlight = applySidecarConfig()
127
134
  .catch(error => {
135
+ stateFromPoll = false
128
136
  sidecarState = `error: ${error instanceof Error ? error.message : String(error)}`
129
137
  throw error
130
138
  })
@@ -176,12 +184,14 @@ function createSidecarLifecycle(deps) {
176
184
  await sidecar.start()
177
185
  await syncSidecar()
178
186
  sidecarState = 'healthy'
187
+ stateFromPoll = false
179
188
  sidecarRestarts += 1
180
189
  sidecarRestartedAt = Date.now()
181
190
  sidecarRestartError = null
182
191
  return { ok: true, before, after: { tunnel: sidecarSnapshot.tunnel, lan: sidecarSnapshot.lan, state: sidecarState }, purgedModules: purged }
183
192
  } catch (error) {
184
193
  sidecarRestartError = error instanceof Error ? error.message : String(error)
194
+ stateFromPoll = false
185
195
  sidecarState = `error: ${sidecarRestartError}`
186
196
  // 起不来就把状态如实留在 error,而不是假装成功。宿主侧后续的 /tunnel/send
187
197
  // 会因为没有 worker 而明确报 'sidecar facade is not running'。
@@ -203,7 +213,11 @@ function createSidecarLifecycle(deps) {
203
213
  if (disposed) return
204
214
  await syncSidecar()
205
215
  sidecarState = 'healthy'
206
- }).catch(error => { sidecarState = `error: ${error instanceof Error ? error.message : String(error)}` })
216
+ stateFromPoll = false
217
+ }).catch(error => {
218
+ stateFromPoll = false
219
+ sidecarState = `error: ${error instanceof Error ? error.message : String(error)}`
220
+ })
207
221
  const healthTimer = setInterval(async () => {
208
222
  try {
209
223
  const st = await sidecar.request('/status')
@@ -212,6 +226,9 @@ function createSidecarLifecycle(deps) {
212
226
  // 'connecting'。原来这次轮询只用来判断隧道连接跳变,结果被直接丢弃,于是
213
227
  // /api/relay/status 永远谎报局域网"没起来",尽管 0.0.0.0:lanPort 早在监听。
214
228
  if (st) sidecarSnapshot = st
229
+ // 轮询通了就等于「worker 就在那儿、这一下请求也成了」:若上一格的 error 是轮询自己
230
+ // 写上去的,这里必须抹回 healthy —— 抖一下不该永久挂一行红字,等用户来点重启。
231
+ if (stateFromPoll) { sidecarState = 'healthy'; stateFromPoll = false }
215
232
  // 边沿判定与即时上报共用(noteTunnelConnected):这里只是权威兜底,
216
233
  // 隧道在两次轮询之间断又连时补上那一次全量补推。
217
234
  noteTunnelConnected(st?.tunnel?.state === 'connected')
@@ -9,7 +9,7 @@
9
9
  * 不自己实现包管理,只驱动它:profile 的 pnpm-workspace.yaml 已经写好 node-linker、peer 等
10
10
  * 关键设置,这里只「cd 到 profile 跑一句 pnpm add <包名>@<版本>」,不传布局类开关。必要的
11
11
  * 开关有两层:auto-install-peers=false(peer 是运行时注入的,npm 上没有)、minimum-release-age=0
12
- * (2026-10-26 用户要求一检测到新版就立刻装,不等「新发布先晾一会儿」;那道策略查的是整份
12
+ * (一检测到新版就立刻装,不等「新发布先晾一会儿」;那道策略查的是整份
13
13
  * lockfile,别的插件刚发也会连累我们),再加上 CI=1(无终端)。命令行带一份、环境变量再带一份:
14
14
  * pnpm 12 的原生 CLI 会静默忽略这两个 --config 参数,环境变量那一份还认。
15
15
  *
@@ -19,7 +19,7 @@
19
19
  *
20
20
  * 包管理器优先用宿主发布的那个(profile 是宿主自带 pnpm 装的,PATH 上版本不同会
21
21
  * ERR_PNPM_UNEXPECTED_STORE),找不到再退回 PATH。装之前先看有没有 agent 在跑;force 与 auto
22
- * 都跳过这道闸(2026-10-26 用户要求自动更新一检测到新版就立刻装,与面板上那颗「强制更新」
22
+ * 都跳过这道闸(自动更新一检测到新版就装,与面板上那颗「强制更新」
23
23
  * 同一个口径),两者的区别只剩「没新版时装不装」。顺序上先答「有没有新版」,没东西可装时
24
24
  * 不该拿「有对话在跑」挡在前面。
25
25
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.5.0",
3
+ "version": "0.5.3",
4
4
  "description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (dsh 0.2.x, peers resolved by the host)",
5
5
  "license": "MIT",
6
6
  "icon": "./icon.png",