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 +14 -0
- package/lib/index.mjs +40 -11
- package/lib/relay-connector.mjs +33 -1
- package/package.json +2 -2
- package/relay/agent-connector.js +19 -0
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
|
-
|
|
2868
|
-
|
|
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
|
-
|
|
3976
|
-
|
|
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()
|
package/lib/relay-connector.mjs
CHANGED
|
@@ -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.
|
|
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",
|
package/relay/agent-connector.js
CHANGED
|
@@ -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';
|