dsh-plugin-weapp-gateway 0.8.33 → 0.8.35

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/README.md CHANGED
@@ -6,11 +6,25 @@
6
6
 
7
7
  `dsh-plugin-weapp-gateway` · DeepSeek Harness 移动客户端连接插件
8
8
 
9
+ ### v0.8.35 · 修复 Windows 上的连接器测试
10
+
11
+ - 插件把 `spawnProcess` 选项转发给内置中继连接器,与 Cloudflare 隧道已有的注入缝一致;默认仍是真实 `spawn`,生产行为不变。测试可用它包住真实子进程并记录启动环境,无需依赖平台专有的进程检查工具。
12
+ - 修复 `relay-connector` 测试用 `URL.pathname` 生成连接器脚本路径:Windows 上会得到 `/D:/…`,交给 `spawn` 后变成 `D:\D:\…`,连接器无法启动。改用与生产代码一致的 `fileURLToPath`。
13
+ - 修复 `restart-reconnect` 测试用 POSIX 的 `ps eww` 读取连接器环境变量:改为通过注入缝记录真实的 `GATEWAY_URL`,在 Windows 与 POSIX 上都能验证重启后连接器拨向新端口。32 个测试文件全部通过。
14
+
9
15
  ### v0.8.32 · 修复 Windows 上网关完全没有日志
10
16
 
11
17
  - 修复默认日志路径写死成 POSIX 的 `/tmp/mobile-gateway.log`:Windows 上它会解析到「当前盘:\tmp」(一般不存在),`appendFileSync` 抛 ENOENT 又被 `log()` 的 catch 静默吞掉,运营者于是**完全看不到任何日志**——排查「手机为什么掉线」时没有任何线索。现在按平台取 `os.tmpdir()`,Windows 上落到 `%TEMP%\mobile-gateway.log`;Linux/macOS 行为不变。
12
18
  - 日志内容本身不变:连接/断开、配对、鉴权拒绝、设备撤销照旧记录。`logFile` 选项仍可覆盖,测试与并行安装仍写各自的文件。
13
19
 
20
+ ### v0.8.34 · 修复 DSH 重启后手机重连不上
21
+
22
+ - 内置中继连接器新增父进程守护:DSH 崩溃或强退后连接器不再继续存活占锁。此前幸存者会一直拨已死亡的 Web 端口,之后每次 DSH 重启都只会委派给它,走中继的手机永久重连不上(连接器随插件进程在 5 秒内退出并释放 pid 锁;手动运行的 systemd/nohup 连接器不注入该变量,行为不变)。
23
+ - 监督者在委派给其他连接器后会复查锁状态:持有者消失时自动接管并拉起新连接器,无需再重启一次 DSH。
24
+ - 「临时开启」的等待超时不再把「已关闭」写回状态文件:超时只是运行时保险,运营者保存的是临时开启;落盘 disabled 会让下次重启直接关闭并拒绝所有手机重连,直到有人手动重新开启。现在重启后按保存的模式重新计时。
25
+ - 局域网监听在绑定失败(EADDRINUSE,重启时旧进程尚未释放 3081)后自动重试约一分钟,而不是永久放弃导致局域网手机 ECONNREFUSED。
26
+ - 新增 `test/restart-reconnect.test.mjs`:完整模拟 DSH 重启(dispose + 更换端口重新 apply),覆盖持久模式恢复、长期 token 重连、连接器用新端口重拉、临时模式语义与局域网端口竞态;连接器生命周期回归进入 `test/relay-connector.test.mjs`。
27
+
14
28
  ### v0.8.33 · 可信设备连接线路徽标
15
29
 
16
30
  - 「设置 → 鲸尾移动网关 → 可信设备」为每台在线设备添加连接线路徽标:**局域网**(手机与 DSH 同网直连)、**公网**(公网服务器、反向代理或鲸尾中继)、**Cloudflare 外网**(命名 / Quick Tunnel)。徽标跟随手机当前实际连接实时变化,面板 3 秒轮询自动刷新,手机从 Wi-Fi 切到蜂窝即换线路。
package/lib/index.mjs CHANGED
@@ -2757,6 +2757,9 @@ const plugin = {
2757
2757
  gatewayName: gatewayIdentity.gatewayName,
2758
2758
  mgmtUrl: `http://127.0.0.1:${webServer.port}`,
2759
2759
  stateDir: resolveStateDir(),
2760
+ // 注入缝:与 cloudflared 一致。测试用它包住真正的 spawn,
2761
+ // 既拿到连接器的环境变量(GATEWAY_URL 等)又不妨碍它真的跑起来。
2762
+ spawnProcess: options.spawnProcess,
2760
2763
  log,
2761
2764
  })
2762
2765
  let relayEnsureTimer = null
@@ -2819,6 +2822,7 @@ const plugin = {
2819
2822
  let lanListening = false
2820
2823
  let lanListenError = null
2821
2824
  let lanBoundPort = options.lanPort
2825
+ let lanDisposeLanRetry = null
2822
2826
  let tunnel = null
2823
2827
  const configuredPublicUrl = () => {
2824
2828
  if (typeof options.publicUrl === 'string' && options.publicUrl.trim()) return options.publicUrl.trim()
@@ -2864,9 +2868,12 @@ const plugin = {
2864
2868
  gatewayEnabled = false
2865
2869
  gatewayMode = 'disabled'
2866
2870
  waitExpiresAt = null
2867
- try { gatewayState.setMode('disabled') } catch (error) {
2868
- log(`failed to persist automatic gateway disable: ${error.message}`)
2869
- }
2871
+ // Deliberately NOT persisted: the operator's saved choice is
2872
+ // 'temporary', and the panel documents that a restart re-counts
2873
+ // the window. Writing 'disabled' here would silently turn their
2874
+ // choice into a permanent off switch, so every phone reconnect
2875
+ // after the next restart would be rejected with 503 until
2876
+ // somebody noticed and re-enabled the gateway by hand.
2870
2877
  log('mobile gateway automatically disabled: no device connected before timeout')
2871
2878
  void tunnel?.reconcile()
2872
2879
  }, options.gatewayWaitTimeoutMs)
@@ -3967,19 +3974,40 @@ const plugin = {
3967
3974
  }
3968
3975
  handleMobileUpgrade(req, socket, head, { lan: true, port: lanBoundPort })
3969
3976
  })
3977
+ // A restart can overlap the previous process still holding the LAN
3978
+ // port (app relaunch handoff, lingering socket). Giving up on the
3979
+ // first EADDRINUSE leaves every LAN phone with ECONNREFUSED until
3980
+ // somebody restarts again, so retry with backoff for about a minute.
3981
+ let lanRetryTimer = null
3982
+ let lanRetries = 0
3983
+ const clearLanRetry = () => {
3984
+ if (lanRetryTimer === null) return
3985
+ clearTimeout(lanRetryTimer)
3986
+ lanRetryTimer = null
3987
+ }
3988
+ const listenLan = () => {
3989
+ clearLanRetry()
3990
+ lanServer.listen(options.lanPort, options.lanHost, () => {
3991
+ lanRetries = 0
3992
+ lanListening = true
3993
+ lanListenError = null
3994
+ const address = lanServer.address()
3995
+ if (address && typeof address === 'object') lanBoundPort = address.port
3996
+ const urls = lanWebSocketUrls(options, wsPath, lanBoundPort)
3997
+ log(`LAN listener ready: ${options.lanHost}:${lanBoundPort}${wsPath}${urls.length ? ` (${urls.join(', ')})` : ''}; authentication forced`)
3998
+ })
3999
+ }
3970
4000
  lanServer.on('error', (error) => {
3971
4001
  lanListening = false
3972
4002
  lanListenError = error && error.message ? error.message : String(error)
3973
4003
  log(`LAN listener failed: ${lanListenError}`)
4004
+ lanRetries += 1
4005
+ if (lanRetries > 30 || lanRetryTimer !== null) return
4006
+ lanRetryTimer = setTimeout(listenLan, 2_000)
4007
+ lanRetryTimer.unref?.()
3974
4008
  })
3975
- lanServer.listen(options.lanPort, options.lanHost, () => {
3976
- lanListening = true
3977
- lanListenError = null
3978
- const address = lanServer.address()
3979
- if (address && typeof address === 'object') lanBoundPort = address.port
3980
- const urls = lanWebSocketUrls(options, wsPath, lanBoundPort)
3981
- log(`LAN listener ready: ${options.lanHost}:${lanBoundPort}${wsPath}${urls.length ? ` (${urls.join(', ')})` : ''}; authentication forced`)
3982
- })
4009
+ lanDisposeLanRetry = clearLanRetry
4010
+ listenLan()
3983
4011
  }
3984
4012
 
3985
4013
  tunnel = createCloudflareTunnel({
@@ -4334,6 +4362,7 @@ const plugin = {
4334
4362
  disposeQuestions()
4335
4363
  disposeApprovals()
4336
4364
  if (lanServer) lanServer.close()
4365
+ if (lanDisposeLanRetry) lanDisposeLanRetry()
4337
4366
  clearRelayEnsure()
4338
4367
  relayConnector.dispose()
4339
4368
  tunnel.dispose()
@@ -46,13 +46,14 @@ export function resolveStateDir(env = process.env, home = os.homedir(), legacyHo
46
46
 
47
47
  export function createRelayConnector({ relay, gatewayUrl, gatewayName, mgmtUrl,
48
48
  stateDir = resolveStateDir(), log = () => {}, spawnProcess = spawn, now = () => Date.now(),
49
- restartDelayMs = 5_000 } = {}) {
49
+ restartDelayMs = 5_000, delegationRecheckMs = 15_000 } = {}) {
50
50
  // Whale public service is configured internally, not a user-facing domain.
51
51
  const configuredRelay = String(relay || '').replace(/[/]$/, '')
52
52
  relay = ['', 'wss://relay.ahwe.top', 'wss://relay.ahwe.top/relay'].includes(configuredRelay)
53
53
  ? 'wss://dsh.ahwe.top/relay' : configuredRelay
54
54
  let child = null
55
55
  let restartTimer = null
56
+ let delegationTimer = null
56
57
  let stopped = false
57
58
  let restarts = 0
58
59
  let windowStart = now()
@@ -74,9 +75,31 @@ export function createRelayConnector({ relay, gatewayUrl, gatewayName, mgmtUrl,
74
75
  restartTimer = null
75
76
  }
76
77
 
78
+ const clearDelegationTimer = () => {
79
+ if (delegationTimer === null) return
80
+ clearTimeout(delegationTimer)
81
+ delegationTimer = null
82
+ }
83
+
84
+ // A holder can disappear after we deferred to it (a zombie from an
85
+ // unclean DSH exit that only now noticed, or an operator connector that
86
+ // died). Without a re-check the delegation would be permanent: relay-
87
+ // routed phones would keep failing against a bridge that dials a dead
88
+ // port, and no restart would help because the next one defers again.
89
+ const scheduleDelegationRecheck = () => {
90
+ if (delegationTimer !== null || stopped) return
91
+ delegationTimer = setTimeout(() => {
92
+ delegationTimer = null
93
+ if (stopped || child !== null) return
94
+ start()
95
+ }, delegationRecheckMs)
96
+ delegationTimer.unref?.()
97
+ }
98
+
77
99
  const stop = () => {
78
100
  stopped = true
79
101
  clearTimer()
102
+ clearDelegationTimer()
80
103
  const previous = child
81
104
  child = null
82
105
  if (previous === null || previous.exitCode !== null || previous.signalCode !== null) return
@@ -108,6 +131,7 @@ export function createRelayConnector({ relay, gatewayUrl, gatewayName, mgmtUrl,
108
131
  if (holder !== null) {
109
132
  delegatedTo = holder
110
133
  lastError = null
134
+ scheduleDelegationRecheck()
111
135
  return snapshot()
112
136
  }
113
137
  }
@@ -128,6 +152,13 @@ export function createRelayConnector({ relay, gatewayUrl, gatewayName, mgmtUrl,
128
152
  ...process.env,
129
153
  // Desktop runs under Electron; child scripts must execute as Node.
130
154
  ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}),
155
+ // The bundled connector must not outlive the DSH process that
156
+ // spawned it: after a crash or force-quit a survivor would keep
157
+ // the pid lock and keep dialling the dead web port, so every
158
+ // later restart would delegate to it and relay-routed phones
159
+ // could never reconnect. Manually run connectors
160
+ // (systemd/nohup) never receive this variable.
161
+ PARENT_PID: String(process.pid),
131
162
  RELAY_URL: relay,
132
163
  GATEWAY_URL: gatewayUrl,
133
164
  GATEWAY_NAME: gatewayName || '',
@@ -143,6 +174,7 @@ export function createRelayConnector({ relay, gatewayUrl, gatewayName, mgmtUrl,
143
174
  }
144
175
  child = spawned
145
176
  delegatedTo = null
177
+ clearDelegationTimer()
146
178
  log(`bundled connector starting: pid=${spawned.pid} relay=${relay} state=${stateDir}`)
147
179
  spawned.on('error', (error) => {
148
180
  if (child !== spawned) return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-weapp-gateway",
3
- "version": "0.8.33",
3
+ "version": "0.8.35",
4
4
  "description": "鲸尾移动网关:DeepSeek Harness 移动客户端连接与会话同步,支持安全配对、远程访问、上下文、编队、额度和语音输入",
5
5
  "main": "lib/index.mjs",
6
6
  "files": [
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "bin": "bin/setup-ip.mjs",
31
31
  "scripts": {
32
- "test": "node test/wire-json.test.mjs && node --test test/history-cache.test.mjs && node test/session-target.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/file-upload-adapter.test.mjs && node test/file-upload.test.mjs && node test/agent-team.test.mjs && node test/account-bridge.test.mjs && node test/context-bridge.test.mjs && node test/cost-meter.test.mjs && node --test test/cost-meter-dashboard.test.mjs && node test/clawbot-service.test.mjs && node test/speech-input.test.mjs && node test/speech-official.test.mjs && node test/plugin-management.test.mjs && node test/plugin-updates.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node test/completion-notifications.test.mjs && node --test test/workspace-changes.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/cloudflared-binary.test.mjs && node test/cloudflare-tunnel.test.mjs && node --test test/public-endpoint-check.test.mjs && node test/gateway-tools.test.mjs && node test/relay-connector.test.mjs && node test/cloudflare-linux.test.mjs && node test/multi-gateway.test.mjs"
32
+ "test": "node test/wire-json.test.mjs && node --test test/history-cache.test.mjs && node test/session-target.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/file-upload-adapter.test.mjs && node test/file-upload.test.mjs && node test/agent-team.test.mjs && node test/account-bridge.test.mjs && node test/context-bridge.test.mjs && node test/cost-meter.test.mjs && node --test test/cost-meter-dashboard.test.mjs && node test/clawbot-service.test.mjs && node test/speech-input.test.mjs && node test/speech-official.test.mjs && node test/plugin-management.test.mjs && node test/plugin-updates.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node test/completion-notifications.test.mjs && node --test test/workspace-changes.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/cloudflared-binary.test.mjs && node test/cloudflare-tunnel.test.mjs && node --test test/public-endpoint-check.test.mjs && node test/gateway-tools.test.mjs && node test/relay-connector.test.mjs && node test/restart-reconnect.test.mjs && node test/cloudflare-linux.test.mjs && node test/multi-gateway.test.mjs"
33
33
  },
34
34
  "exports": {
35
35
  ".": "./lib/index.mjs",
@@ -55,6 +55,25 @@ process.on('exit', () => {
55
55
  try { if (JSON.parse(fs.readFileSync(lockFile, 'utf8')).pid === process.pid) fs.unlinkSync(lockFile); } catch {}
56
56
  });
57
57
 
58
+ // 父进程守护(仅插件内置连接器):DSH 崩溃或强退时,子进程会被过继给 init 继续
59
+ // 存活——它占着 pid 锁、拨已死亡的端口,之后每次 DSH 重启都只会委派给这个僵尸,
60
+ // 手机的中继线路永久重连不上。插件启动时注入 PARENT_PID;手动运行
61
+ // (systemd / nohup)不设置该变量,行为完全不变。POSIX 上父进程死亡后 ppid 会被
62
+ // 改写,同时兜住 pid 复用。
63
+ const PARENT_PID = Number(process.env.PARENT_PID) || 0;
64
+ if (PARENT_PID > 0) {
65
+ const startupPpid = process.ppid;
66
+ const parentGone = () => {
67
+ if (process.ppid !== startupPpid) return true;
68
+ try { process.kill(PARENT_PID, 0); return false; } catch (e) { return e.code === 'ESRCH'; }
69
+ };
70
+ setInterval(() => {
71
+ if (!parentGone()) return;
72
+ console.log('[' + new Date().toISOString() + '] parent process ' + PARENT_PID + ' exited; connector shutting down');
73
+ process.exit(0);
74
+ }, 5000).unref?.();
75
+ }
76
+
58
77
  // ---------- 身份 ----------
59
78
 
60
79
  const PROTOCOL = 'dsh-weapp-e2ee-v1';