dsh-dock 0.5.0 → 0.7.0
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 +26 -12
- package/client.js +3198 -365
- package/features/animation/host.js +150 -24
- package/features/animation/view.jsx +311 -131
- package/features/mobile-relay/gateway.js +279 -0
- package/features/mobile-relay/host.js +328 -0
- package/features/mobile-relay/view.jsx +207 -0
- package/features/modelconfig/view.js +55 -14
- package/features/theme/view.js +35 -10
- package/features/tokenlog/view.jsx +3 -1
- package/features/visionproxy/host.js +24 -10
- package/index.js +2 -0
- package/package.json +4 -3
- package/screenshots/dock-animation.png +0 -0
- package/screenshots/dock-modelconfig.png +0 -0
- package/src/client.jsx +44 -18
- package/src/host-core.js +3 -0
- package/screenshots/dock-robot.png +0 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// dsh-dock · 手机接力局域网安全网关
|
|
2
|
+
//
|
|
3
|
+
// DSH 0.1.1-rc.2 有意拒绝直接绑定 0.0.0.0:浏览器表层尚无登录认证,直接开放
|
|
4
|
+
// 会把代码执行能力交给同一网络里的任何人。本文件提供一个最小认证反向代理:主 DSH
|
|
5
|
+
// 继续只监听 127.0.0.1,手机凭 256-bit 一次性链接换取 HttpOnly 会话后才能访问。
|
|
6
|
+
import { createServer, request as httpRequest } from 'node:http'
|
|
7
|
+
import { networkInterfaces } from 'node:os'
|
|
8
|
+
import { randomBytes } from 'node:crypto'
|
|
9
|
+
|
|
10
|
+
const COOKIE_NAME = 'dsh_mobile_session'
|
|
11
|
+
const CONNECT_PATH = '/__dsh_mobile/connect'
|
|
12
|
+
const HEALTH_PATH = '/__dsh_mobile/health'
|
|
13
|
+
const COMPAT_PATH = '/__dsh_mobile/compat.js'
|
|
14
|
+
const SESSION_TTL_MS = 12 * 60 * 60 * 1000
|
|
15
|
+
const MAX_CONNECT_BODY = 4096
|
|
16
|
+
const MAX_HTML_BYTES = 2 * 1024 * 1024
|
|
17
|
+
|
|
18
|
+
// LAN HTTP is not a browser secure context. Some mobile browsers therefore expose
|
|
19
|
+
// crypto.getRandomValues() but omit crypto.randomUUID(), while the official DSH
|
|
20
|
+
// connection/workspace clients call randomUUID() directly. This tiny standards-compatible
|
|
21
|
+
// UUID v4 fallback is injected before every DSH bootstrap script through the proxy only.
|
|
22
|
+
const MOBILE_COMPAT_JS = `(()=>{const c=globalThis.crypto;if(!c||typeof c.randomUUID==='function'||typeof c.getRandomValues!=='function')return;const make=()=>{const b=new Uint8Array(16);c.getRandomValues(b);b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;const h=Array.from(b,x=>x.toString(16).padStart(2,'0')).join('');return h.slice(0,8)+'-'+h.slice(8,12)+'-'+h.slice(12,16)+'-'+h.slice(16,20)+'-'+h.slice(20)};try{Object.defineProperty(c,'randomUUID',{value:make,configurable:true})}catch{try{c.randomUUID=make}catch{}}})()`
|
|
23
|
+
|
|
24
|
+
function secret() { return randomBytes(32).toString('base64url') }
|
|
25
|
+
function now() { return Date.now() }
|
|
26
|
+
function isPrivateIpv4(ip) {
|
|
27
|
+
const p = String(ip).split('.').map(Number)
|
|
28
|
+
return p.length === 4 && (p[0] === 10 || (p[0] === 172 && p[1] >= 16 && p[1] <= 31) || (p[0] === 192 && p[1] === 168))
|
|
29
|
+
}
|
|
30
|
+
function interfacePriority(name) {
|
|
31
|
+
if (/^en0$/.test(name)) return 100
|
|
32
|
+
if (/^en\d+$/.test(name)) return 90
|
|
33
|
+
if (/^(eth|enp|wlan|wl)/.test(name)) return 80
|
|
34
|
+
if (/^bridge/.test(name)) return 20
|
|
35
|
+
if (/^(utun|tun|tap)/.test(name)) return 10
|
|
36
|
+
return 50
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 当前可用于手机连接的非回环 IPv4,私网地址优先。 */
|
|
40
|
+
export function lanAddresses() {
|
|
41
|
+
const rows = []
|
|
42
|
+
const table = networkInterfaces()
|
|
43
|
+
for (const [name, list] of Object.entries(table)) {
|
|
44
|
+
for (const item of list || []) {
|
|
45
|
+
if (!item || item.internal || item.family !== 'IPv4') continue
|
|
46
|
+
rows.push({ address: item.address, interface: name, private: isPrivateIpv4(item.address) })
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return rows.sort((a, b) => Number(b.private) - Number(a.private)
|
|
50
|
+
|| interfacePriority(b.interface) - interfacePriority(a.interface)
|
|
51
|
+
|| a.interface.localeCompare(b.interface))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseCookies(header) {
|
|
55
|
+
const result = {}
|
|
56
|
+
for (const part of String(header || '').split(';')) {
|
|
57
|
+
const at = part.indexOf('=')
|
|
58
|
+
if (at <= 0) continue
|
|
59
|
+
result[part.slice(0, at).trim()] = part.slice(at + 1).trim()
|
|
60
|
+
}
|
|
61
|
+
return result
|
|
62
|
+
}
|
|
63
|
+
function sendJson(res, status, value, headers = {}) {
|
|
64
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers })
|
|
65
|
+
res.end(JSON.stringify(value))
|
|
66
|
+
}
|
|
67
|
+
function readSmallJson(req) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
let size = 0
|
|
70
|
+
const chunks = []
|
|
71
|
+
req.on('data', (chunk) => {
|
|
72
|
+
size += chunk.length
|
|
73
|
+
if (size > MAX_CONNECT_BODY) { reject(new Error('请求过大')); req.destroy(); return }
|
|
74
|
+
chunks.push(chunk)
|
|
75
|
+
})
|
|
76
|
+
req.on('end', () => {
|
|
77
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')) }
|
|
78
|
+
catch { reject(new Error('请求格式错误')) }
|
|
79
|
+
})
|
|
80
|
+
req.on('error', reject)
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
function connectHtml() {
|
|
84
|
+
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="dark light"><title>连接 DSH</title><style>html{font-family:system-ui,-apple-system,sans-serif;color-scheme:dark light}body{margin:0;min-height:100dvh;display:grid;place-items:center;padding:env(safe-area-inset-top) 0 env(safe-area-inset-bottom);background:#111318;color:#f4f6fa}.card{box-sizing:border-box;width:min(420px,calc(100vw - 32px));padding:28px;border:1px solid #30343d;border-radius:18px;background:#1c1f26;box-shadow:0 18px 60px #0007;text-align:center}.icon{width:48px;height:48px;margin:0 auto 16px;display:grid;place-items:center;border-radius:15px;background:#38bdf822;color:#38bdf8}.icon svg{width:24px;height:24px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}h1{margin:0 0 8px;font-size:20px}p{margin:0;color:#abb1bd;line-height:1.6;font-size:14px}.error{margin-top:16px;padding:10px 12px;border-radius:10px;background:#ef44441a;color:#ff8b8b;text-align:left}[hidden]{display:none}@media(prefers-color-scheme:light){body{background:#f1f4f8;color:#16181d}.card{background:#fff;border-color:#d8dde6}.card p{color:#586173}}</style></head><body><main class="card"><div class="icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l2-2a5 5 0 0 0-7.07-7.07l-1.15 1.15"/><path d="M14 11a5 5 0 0 0-7.54-.54l-2 2a5 5 0 0 0 7.07 7.07l1.14-1.14"/></svg></div><h1>正在安全连接</h1><p id="status">验证一次性链接并进入 DSH…</p><div id="error" class="error" role="alert" hidden></div></main><script>(()=>{const status=document.getElementById('status'),error=document.getElementById('error'),token=decodeURIComponent(location.hash.slice(1));history.replaceState(null,'',location.pathname);if(!token){status.textContent='连接链接不完整';error.hidden=false;error.textContent='请回到电脑端重新复制手机接力链接。';return}fetch(${JSON.stringify(CONNECT_PATH)},{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({token})}).then(async r=>{const d=await r.json().catch(()=>({}));if(!r.ok||!d.ok)throw new Error(d.error||'连接验证失败');status.textContent='验证成功,正在打开任务…';location.replace('/#dsh-mobile-relay='+encodeURIComponent(d.launch))}).catch(e=>{status.textContent='无法连接';error.hidden=false;error.textContent=e&&e.message?e.message:String(e)})})()</script></body></html>`
|
|
85
|
+
}
|
|
86
|
+
function rewriteProxyHeaders(headers, upstreamAuthority) {
|
|
87
|
+
const next = { ...headers, host: upstreamAuthority }
|
|
88
|
+
delete next.cookie
|
|
89
|
+
// HTML must stay uncompressed so the proxy can inject the early compatibility script.
|
|
90
|
+
delete next['accept-encoding']
|
|
91
|
+
if (next.origin) next.origin = 'http://' + upstreamAuthority
|
|
92
|
+
return next
|
|
93
|
+
}
|
|
94
|
+
function injectMobileCompat(html) {
|
|
95
|
+
if (html.includes('data-dsh-mobile-compat')) return html
|
|
96
|
+
const match = /<head(?:\s[^>]*)?>/i.exec(html)
|
|
97
|
+
const script = `<script data-dsh-mobile-compat src="${COMPAT_PATH}"></script>`
|
|
98
|
+
if (!match) return script + html
|
|
99
|
+
const at = match.index + match[0].length
|
|
100
|
+
return html.slice(0, at) + script + html.slice(at)
|
|
101
|
+
}
|
|
102
|
+
function writeUpgradeHead(socket, response) {
|
|
103
|
+
const lines = [`HTTP/1.1 ${response.statusCode || 101} ${response.statusMessage || 'Switching Protocols'}`]
|
|
104
|
+
for (let i = 0; i < response.rawHeaders.length; i += 2) lines.push(response.rawHeaders[i] + ': ' + response.rawHeaders[i + 1])
|
|
105
|
+
socket.write(lines.join('\r\n') + '\r\n\r\n')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 开启一个需要一次性链接认证的局域网代理。返回的 issue() 生成只在 fragment 中传输的令牌。
|
|
110
|
+
*/
|
|
111
|
+
export function startProtectedLanGateway({ port, upstreamHost = '127.0.0.1', upstreamPort }) {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const pending = new Map() // gateway token -> { pairId, launch, expiresAt }
|
|
114
|
+
const sessions = new Map() // cookie secret -> { pairId, expiresAt, seenAt }
|
|
115
|
+
const upgradedSockets = new Map() // client socket -> { sessionId, pairId, upstreamSocket }
|
|
116
|
+
const authority = upstreamHost + ':' + upstreamPort
|
|
117
|
+
|
|
118
|
+
function purge() {
|
|
119
|
+
const stamp = now()
|
|
120
|
+
for (const [key, value] of pending) if (value.expiresAt <= stamp) pending.delete(key)
|
|
121
|
+
const expiredSessions = new Set()
|
|
122
|
+
for (const [key, value] of sessions) {
|
|
123
|
+
if (value.expiresAt <= stamp) { sessions.delete(key); expiredSessions.add(key) }
|
|
124
|
+
}
|
|
125
|
+
if (expiredSessions.size) {
|
|
126
|
+
for (const [socket, value] of upgradedSockets) {
|
|
127
|
+
if (expiredSessions.has(value.sessionId)) {
|
|
128
|
+
if (value.upstreamSocket) value.upstreamSocket.destroy()
|
|
129
|
+
socket.destroy()
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function authorize(req) {
|
|
135
|
+
purge()
|
|
136
|
+
const value = parseCookies(req.headers.cookie)[COOKIE_NAME]
|
|
137
|
+
const session = value ? sessions.get(value) : null
|
|
138
|
+
if (!session) return null
|
|
139
|
+
session.seenAt = now()
|
|
140
|
+
return { ...session, sessionId: value }
|
|
141
|
+
}
|
|
142
|
+
function proxyHttp(req, res) {
|
|
143
|
+
const upstream = httpRequest({
|
|
144
|
+
hostname: upstreamHost, port: upstreamPort, method: req.method, path: req.url,
|
|
145
|
+
headers: rewriteProxyHeaders(req.headers, authority),
|
|
146
|
+
}, (upstreamRes) => {
|
|
147
|
+
const contentType = String(upstreamRes.headers['content-type'] || '').toLowerCase()
|
|
148
|
+
if (!contentType.includes('text/html')) {
|
|
149
|
+
res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers)
|
|
150
|
+
upstreamRes.pipe(res)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
const chunks = []
|
|
154
|
+
let size = 0
|
|
155
|
+
upstreamRes.on('data', (chunk) => {
|
|
156
|
+
size += chunk.length
|
|
157
|
+
if (size <= MAX_HTML_BYTES) chunks.push(chunk)
|
|
158
|
+
})
|
|
159
|
+
upstreamRes.on('end', () => {
|
|
160
|
+
if (size > MAX_HTML_BYTES) {
|
|
161
|
+
if (!res.headersSent) sendJson(res, 502, { ok: false, error: 'DSH 页面过大,无法注入手机兼容层。' })
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
const headers = { ...upstreamRes.headers }
|
|
165
|
+
delete headers['content-length']
|
|
166
|
+
delete headers['content-encoding']
|
|
167
|
+
headers['cache-control'] = 'no-store'
|
|
168
|
+
res.writeHead(upstreamRes.statusCode || 502, headers)
|
|
169
|
+
res.end(injectMobileCompat(Buffer.concat(chunks).toString('utf8')))
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
upstream.on('error', (error) => {
|
|
173
|
+
if (!res.headersSent) sendJson(res, 502, { ok: false, error: 'DSH 主服务连接失败:' + error.message })
|
|
174
|
+
else res.destroy(error)
|
|
175
|
+
})
|
|
176
|
+
req.pipe(upstream)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const server = createServer(async (req, res) => {
|
|
180
|
+
const url = new URL(req.url || '/', 'http://dsh-mobile.internal')
|
|
181
|
+
if (url.pathname === HEALTH_PATH) return sendJson(res, 200, { ok: true })
|
|
182
|
+
if (url.pathname === CONNECT_PATH && req.method === 'GET') {
|
|
183
|
+
res.writeHead(200, {
|
|
184
|
+
'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store',
|
|
185
|
+
'content-security-policy': "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
186
|
+
'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer',
|
|
187
|
+
})
|
|
188
|
+
return res.end(connectHtml())
|
|
189
|
+
}
|
|
190
|
+
if (url.pathname === CONNECT_PATH && req.method === 'POST') {
|
|
191
|
+
try {
|
|
192
|
+
const body = await readSmallJson(req)
|
|
193
|
+
const token = String(body && body.token || '')
|
|
194
|
+
const grant = pending.get(token)
|
|
195
|
+
if (!grant || grant.expiresAt <= now()) return sendJson(res, 403, { ok: false, error: '一次性链接无效或已过期,请在电脑端重新开启。' })
|
|
196
|
+
pending.delete(token)
|
|
197
|
+
const sessionId = secret()
|
|
198
|
+
sessions.set(sessionId, { pairId: grant.pairId, expiresAt: now() + SESSION_TTL_MS, seenAt: now() })
|
|
199
|
+
return sendJson(res, 200, { ok: true, launch: grant.launch }, {
|
|
200
|
+
'set-cookie': `${COOKIE_NAME}=${sessionId}; HttpOnly; SameSite=Strict; Path=/`,
|
|
201
|
+
})
|
|
202
|
+
} catch (error) {
|
|
203
|
+
return sendJson(res, 400, { ok: false, error: error.message || String(error) })
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (!authorize(req)) return sendJson(res, 401, { ok: false, error: '手机连接未认证或已过期,请使用电脑端生成的接力链接重新进入。' })
|
|
207
|
+
if (url.pathname === COMPAT_PATH && req.method === 'GET') {
|
|
208
|
+
res.writeHead(200, {
|
|
209
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
210
|
+
'cache-control': 'no-store',
|
|
211
|
+
'x-content-type-options': 'nosniff',
|
|
212
|
+
})
|
|
213
|
+
return res.end(MOBILE_COMPAT_JS)
|
|
214
|
+
}
|
|
215
|
+
return proxyHttp(req, res)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
server.on('upgrade', (req, socket, head) => {
|
|
219
|
+
const authorized = authorize(req)
|
|
220
|
+
if (!authorized) { socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n'); return }
|
|
221
|
+
const connection = { sessionId: authorized.sessionId, pairId: authorized.pairId, upstreamSocket: null }
|
|
222
|
+
upgradedSockets.set(socket, connection)
|
|
223
|
+
const forgetSocket = () => upgradedSockets.delete(socket)
|
|
224
|
+
socket.once('close', forgetSocket)
|
|
225
|
+
const upstream = httpRequest({
|
|
226
|
+
hostname: upstreamHost, port: upstreamPort, method: req.method, path: req.url,
|
|
227
|
+
headers: rewriteProxyHeaders(req.headers, authority),
|
|
228
|
+
})
|
|
229
|
+
upstream.on('upgrade', (response, upstreamSocket, upstreamHead) => {
|
|
230
|
+
connection.upstreamSocket = upstreamSocket
|
|
231
|
+
writeUpgradeHead(socket, response)
|
|
232
|
+
if (head && head.length) upstreamSocket.write(head)
|
|
233
|
+
if (upstreamHead && upstreamHead.length) socket.write(upstreamHead)
|
|
234
|
+
socket.once('close', () => upstreamSocket.destroy())
|
|
235
|
+
upstreamSocket.once('close', () => socket.destroy())
|
|
236
|
+
socket.pipe(upstreamSocket).pipe(socket)
|
|
237
|
+
})
|
|
238
|
+
upstream.on('response', (response) => {
|
|
239
|
+
socket.end(`HTTP/1.1 ${response.statusCode || 502} ${response.statusMessage || 'Bad Gateway'}\r\nConnection: close\r\n\r\n`)
|
|
240
|
+
})
|
|
241
|
+
upstream.on('error', () => socket.destroy())
|
|
242
|
+
upstream.end()
|
|
243
|
+
})
|
|
244
|
+
server.once('error', reject)
|
|
245
|
+
server.listen(port, '0.0.0.0', () => {
|
|
246
|
+
server.removeListener('error', reject)
|
|
247
|
+
const listened = server.address()
|
|
248
|
+
const listenedPort = listened && typeof listened === 'object' ? listened.port : port
|
|
249
|
+
resolve({
|
|
250
|
+
port: listenedPort,
|
|
251
|
+
addresses: lanAddresses(),
|
|
252
|
+
issue(pairId, launch, expiresAt) {
|
|
253
|
+
const token = secret()
|
|
254
|
+
pending.set(token, { pairId, launch, expiresAt })
|
|
255
|
+
return token
|
|
256
|
+
},
|
|
257
|
+
revokePair(pairId) {
|
|
258
|
+
for (const [key, value] of pending) if (value.pairId === pairId) pending.delete(key)
|
|
259
|
+
for (const [key, value] of sessions) if (value.pairId === pairId) sessions.delete(key)
|
|
260
|
+
for (const [socket, value] of upgradedSockets) {
|
|
261
|
+
if (value.pairId !== pairId) continue
|
|
262
|
+
if (value.upstreamSocket) value.upstreamSocket.destroy()
|
|
263
|
+
socket.destroy()
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
purge,
|
|
267
|
+
close() {
|
|
268
|
+
pending.clear(); sessions.clear()
|
|
269
|
+
for (const [socket, value] of upgradedSockets) {
|
|
270
|
+
if (value.upstreamSocket) value.upstreamSocket.destroy()
|
|
271
|
+
socket.destroy()
|
|
272
|
+
}
|
|
273
|
+
upgradedSockets.clear()
|
|
274
|
+
return new Promise((done) => server.close(() => done()))
|
|
275
|
+
},
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
})
|
|
279
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// dsh-dock · 功能模块【手机接力】· 宿主半部
|
|
2
|
+
//
|
|
3
|
+
// 不复制或迁移 DSH 会话:手机与电脑都连接同一个 DSH 宿主时,会话本来就是同一份。
|
|
4
|
+
// 本模块只负责短时配对、在线状态、实时任务摘要和接力备注,避免把任务状态再做成一套
|
|
5
|
+
// 容易分叉的副本。所有配对凭据均只存内存,宿主重启后自动失效。
|
|
6
|
+
import { randomBytes } from 'node:crypto'
|
|
7
|
+
import { readBody, sendJson } from '../../src/host-core.js'
|
|
8
|
+
import { lanAddresses, startProtectedLanGateway } from './gateway.js'
|
|
9
|
+
|
|
10
|
+
const PAIR_TTL_MS = 10 * 60 * 1000
|
|
11
|
+
const PRESENCE_TTL_MS = 45 * 1000
|
|
12
|
+
const NOTE_MAX_LENGTH = 1200
|
|
13
|
+
const MAX_JOIN_ATTEMPTS = 8
|
|
14
|
+
const DEFAULT_GATEWAY_PORT = 3081
|
|
15
|
+
|
|
16
|
+
function now() { return Date.now() }
|
|
17
|
+
function cleanText(value, max) { return String(value || '').replace(/\u0000/g, '').trim().slice(0, max) }
|
|
18
|
+
function publicId() { return randomBytes(9).toString('base64url') }
|
|
19
|
+
function joinCode() { return randomBytes(5).toString('hex').toUpperCase() }
|
|
20
|
+
function phaseOf(event) {
|
|
21
|
+
if (event && event.type === 'assistant/chunk') {
|
|
22
|
+
const type = event.data && event.data.chunk && event.data.chunk.type
|
|
23
|
+
if (type === 'reasoning-delta') return 'think'
|
|
24
|
+
if (type === 'text-delta') return 'write'
|
|
25
|
+
}
|
|
26
|
+
if (event && event.type === 'tool/call') {
|
|
27
|
+
const name = String(event.data && event.data.name || '').toLowerCase()
|
|
28
|
+
return /web|search|fetch|grep|glob|find|read|ls$|^ls|tree|locate/.test(name) ? 'search' : 'code'
|
|
29
|
+
}
|
|
30
|
+
return ''
|
|
31
|
+
}
|
|
32
|
+
function extractText(content) {
|
|
33
|
+
if (!Array.isArray(content)) return ''
|
|
34
|
+
return content.filter((x) => x && x.type === 'text' && typeof x.text === 'string')
|
|
35
|
+
.map((x) => x.text).join(' ').replace(/\s+/g, ' ').trim()
|
|
36
|
+
}
|
|
37
|
+
function routeMethod(req) {
|
|
38
|
+
const url = new URL(req.url || '/', 'http://dsh.internal')
|
|
39
|
+
return url.pathname.replace(/^\/dsh-dock\/mobile-relay\/?/, '').split('/')[0] || ''
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const feature = {
|
|
43
|
+
id: 'mobile-relay',
|
|
44
|
+
name: '手机接力',
|
|
45
|
+
description: '安全反向代理与扫码接力,同步当前任务状态和备注',
|
|
46
|
+
defaultEnabled: true,
|
|
47
|
+
setup(ctx) {
|
|
48
|
+
const disposers = []
|
|
49
|
+
const pairs = new Map()
|
|
50
|
+
const tasks = new Map()
|
|
51
|
+
const joinAttempts = new Map()
|
|
52
|
+
let gateway = null
|
|
53
|
+
let gatewayStarting = null
|
|
54
|
+
|
|
55
|
+
async function stopGateway() {
|
|
56
|
+
const current = gateway
|
|
57
|
+
gateway = null
|
|
58
|
+
gatewayStarting = null
|
|
59
|
+
if (current) await current.close()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function ensureGateway(webServer, requestedPort) {
|
|
63
|
+
const port = Number(requestedPort === undefined ? DEFAULT_GATEWAY_PORT : requestedPort)
|
|
64
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535) {
|
|
65
|
+
const error = new Error('局域网端口需为 1024 到 65535 之间的整数')
|
|
66
|
+
error.statusCode = 400
|
|
67
|
+
throw error
|
|
68
|
+
}
|
|
69
|
+
if (port === webServer.port) {
|
|
70
|
+
const error = new Error(`局域网端口不能与 DSH 主服务端口 ${webServer.port} 相同`)
|
|
71
|
+
error.statusCode = 400
|
|
72
|
+
throw error
|
|
73
|
+
}
|
|
74
|
+
if (gateway && gateway.port === port) return gateway
|
|
75
|
+
if (gateway) {
|
|
76
|
+
const error = new Error(`局域网网关已在 ${gateway.port} 端口运行,请先关闭当前手机连接`)
|
|
77
|
+
error.statusCode = 409
|
|
78
|
+
throw error
|
|
79
|
+
}
|
|
80
|
+
if (!gatewayStarting) {
|
|
81
|
+
gatewayStarting = startProtectedLanGateway({ port, upstreamHost: '127.0.0.1', upstreamPort: webServer.port })
|
|
82
|
+
.then((started) => { gateway = started; gatewayStarting = null; return started })
|
|
83
|
+
.catch((error) => { gatewayStarting = null; throw error })
|
|
84
|
+
}
|
|
85
|
+
try { return await gatewayStarting }
|
|
86
|
+
catch (cause) {
|
|
87
|
+
const error = new Error(cause && cause.code === 'EADDRINUSE'
|
|
88
|
+
? `端口 ${port} 已被占用,请换一个端口`
|
|
89
|
+
: '无法开启局域网网关:' + ((cause && cause.message) || String(cause)))
|
|
90
|
+
error.statusCode = 400
|
|
91
|
+
throw error
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function purge() {
|
|
96
|
+
const stamp = now()
|
|
97
|
+
for (const [id, pair] of pairs) {
|
|
98
|
+
for (const [deviceId, device] of pair.devices) {
|
|
99
|
+
if (stamp - device.seenAt > PRESENCE_TTL_MS) pair.devices.delete(deviceId)
|
|
100
|
+
}
|
|
101
|
+
if (pair.joinExpiresAt <= stamp && pair.devices.size === 0) {
|
|
102
|
+
pairs.delete(id)
|
|
103
|
+
if (gateway) gateway.revokePair(id)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const [key, stampAt] of joinAttempts) if (stamp - stampAt.firstAt > PAIR_TTL_MS) joinAttempts.delete(key)
|
|
107
|
+
if (gateway) gateway.purge()
|
|
108
|
+
if (gateway && pairs.size === 0) void stopGateway()
|
|
109
|
+
}
|
|
110
|
+
const purgeTimer = setInterval(purge, 15 * 1000)
|
|
111
|
+
disposers.push(() => clearInterval(purgeTimer))
|
|
112
|
+
|
|
113
|
+
function taskSnapshot() {
|
|
114
|
+
const stamp = now()
|
|
115
|
+
return [...tasks.values()].filter((task) => task.running).map((task) => ({
|
|
116
|
+
sessionId: task.sessionId,
|
|
117
|
+
title: task.title || task.firstPrompt || '(等待任务标题)',
|
|
118
|
+
phase: task.phase || 'think',
|
|
119
|
+
startedAt: task.startedAt,
|
|
120
|
+
elapsed: stamp - task.startedAt,
|
|
121
|
+
lastActivityAt: task.lastActivityAt,
|
|
122
|
+
})).sort((a, b) => b.lastActivityAt - a.lastActivityAt)
|
|
123
|
+
}
|
|
124
|
+
function visiblePair(pair) {
|
|
125
|
+
const devices = [...pair.devices.values()].map((device) => ({
|
|
126
|
+
id: device.id,
|
|
127
|
+
role: device.role,
|
|
128
|
+
label: device.label,
|
|
129
|
+
seenAt: device.seenAt,
|
|
130
|
+
})).sort((a, b) => b.seenAt - a.seenAt)
|
|
131
|
+
return {
|
|
132
|
+
id: pair.id,
|
|
133
|
+
expiresAt: pair.joinExpiresAt,
|
|
134
|
+
note: pair.note,
|
|
135
|
+
noteAt: pair.noteAt,
|
|
136
|
+
noteFrom: pair.noteFrom,
|
|
137
|
+
devices,
|
|
138
|
+
tasks: taskSnapshot(),
|
|
139
|
+
now: now(),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function requirePair(payload) {
|
|
143
|
+
const pairId = cleanText(payload && payload.pairId, 48)
|
|
144
|
+
const deviceId = cleanText(payload && payload.deviceId, 80)
|
|
145
|
+
const secret = cleanText(payload && payload.secret, 160)
|
|
146
|
+
const pair = pairs.get(pairId)
|
|
147
|
+
if (!pair) {
|
|
148
|
+
const error = new Error('配对不存在,请在电脑端重新开启连接')
|
|
149
|
+
error.statusCode = 410
|
|
150
|
+
throw error
|
|
151
|
+
}
|
|
152
|
+
const device = pair.devices.get(deviceId)
|
|
153
|
+
if (!device || device.secret !== secret) {
|
|
154
|
+
const error = new Error('此设备未完成配对,请重新打开手机接力链接')
|
|
155
|
+
error.statusCode = 403
|
|
156
|
+
throw error
|
|
157
|
+
}
|
|
158
|
+
device.seenAt = now()
|
|
159
|
+
return { pair, device }
|
|
160
|
+
}
|
|
161
|
+
function joinRateKey(req) {
|
|
162
|
+
return (req.socket && req.socket.remoteAddress) || 'unknown'
|
|
163
|
+
}
|
|
164
|
+
function recordFailedJoin(req) {
|
|
165
|
+
const key = joinRateKey(req)
|
|
166
|
+
const item = joinAttempts.get(key) || { count: 0, firstAt: now() }
|
|
167
|
+
if (now() - item.firstAt > PAIR_TTL_MS) { item.count = 0; item.firstAt = now() }
|
|
168
|
+
item.count++
|
|
169
|
+
joinAttempts.set(key, item)
|
|
170
|
+
return item.count
|
|
171
|
+
}
|
|
172
|
+
function canJoin(req) {
|
|
173
|
+
const item = joinAttempts.get(joinRateKey(req))
|
|
174
|
+
return !item || now() - item.firstAt > PAIR_TTL_MS || item.count < MAX_JOIN_ATTEMPTS
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 任务状态只取最小必要字段。真正的会话内容仍由 DSH 的既有会话服务管理,
|
|
178
|
+
// 这样手机继续输入后不会和电脑产生两个任务副本。
|
|
179
|
+
disposers.push(ctx.inject(['sessionQuery'], (sqCtx) => {
|
|
180
|
+
const sessionQuery = sqCtx.sessionQuery
|
|
181
|
+
const titleFor = async (task) => {
|
|
182
|
+
try {
|
|
183
|
+
const snapshot = sessionQuery && typeof sessionQuery.readTitle === 'function'
|
|
184
|
+
? await sessionQuery.readTitle(task.sessionId) : null
|
|
185
|
+
if (snapshot && snapshot.title && tasks.get(task.sessionId) === task) task.title = snapshot.title
|
|
186
|
+
} catch { /* 标题缺失时以首条输入兜底 */ }
|
|
187
|
+
}
|
|
188
|
+
disposers.push(sqCtx.on('agent/status', (payload) => {
|
|
189
|
+
const agent = payload && payload.agent
|
|
190
|
+
const sid = agent && agent.id ? String(agent.id) : ''
|
|
191
|
+
if (!sid) return
|
|
192
|
+
if (payload.status === 'running') {
|
|
193
|
+
let task = tasks.get(sid)
|
|
194
|
+
if (!task) {
|
|
195
|
+
task = { sessionId: sid, title: '', firstPrompt: '', phase: 'think', startedAt: now(), lastActivityAt: now(), running: true }
|
|
196
|
+
tasks.set(sid, task)
|
|
197
|
+
titleFor(task)
|
|
198
|
+
}
|
|
199
|
+
task.running = true
|
|
200
|
+
task.lastActivityAt = now()
|
|
201
|
+
} else if (payload.status === 'idle') {
|
|
202
|
+
const task = tasks.get(sid)
|
|
203
|
+
if (task) { task.running = false; task.lastActivityAt = now() }
|
|
204
|
+
}
|
|
205
|
+
}))
|
|
206
|
+
disposers.push(sqCtx.on('agent/disposed', (payload) => {
|
|
207
|
+
const sid = payload && payload.agent && payload.agent.id ? String(payload.agent.id) : ''
|
|
208
|
+
const task = tasks.get(sid)
|
|
209
|
+
if (task) { task.running = false; task.lastActivityAt = now() }
|
|
210
|
+
}))
|
|
211
|
+
disposers.push(sqCtx.on('session/event', (session, event) => {
|
|
212
|
+
const sid = typeof session === 'object' && session ? String(session.id || '') : String(session || '')
|
|
213
|
+
const task = tasks.get(sid)
|
|
214
|
+
if (!task) return
|
|
215
|
+
const phase = phaseOf(event)
|
|
216
|
+
if (phase) task.phase = phase
|
|
217
|
+
if (!task.firstPrompt && event && event.type === 'user/message') {
|
|
218
|
+
const text = extractText(event.data && event.data.content)
|
|
219
|
+
if (text) task.firstPrompt = text.slice(0, 120)
|
|
220
|
+
}
|
|
221
|
+
task.lastActivityAt = (event && event.time) || now()
|
|
222
|
+
}))
|
|
223
|
+
}))
|
|
224
|
+
|
|
225
|
+
disposers.push(ctx.inject(['webServer'], (wsCtx) => {
|
|
226
|
+
wsCtx.effect(() => wsCtx.webServer.register({
|
|
227
|
+
kind: 'prefix',
|
|
228
|
+
path: '/dsh-dock/mobile-relay',
|
|
229
|
+
async handler(req, res) {
|
|
230
|
+
try {
|
|
231
|
+
const method = routeMethod(req)
|
|
232
|
+
const payload = await readBody(req)
|
|
233
|
+
purge()
|
|
234
|
+
if (method === 'network') {
|
|
235
|
+
return sendJson(res, 200, { ok: true, data: {
|
|
236
|
+
addresses: lanAddresses(),
|
|
237
|
+
defaultPort: DEFAULT_GATEWAY_PORT,
|
|
238
|
+
main: { host: wsCtx.webServer.host, port: wsCtx.webServer.port },
|
|
239
|
+
gateway: gateway ? { active: true, port: gateway.port, addresses: gateway.addresses } : { active: false },
|
|
240
|
+
} })
|
|
241
|
+
}
|
|
242
|
+
if (method === 'start') {
|
|
243
|
+
const activeGateway = await ensureGateway(wsCtx.webServer, payload && payload.port)
|
|
244
|
+
const id = publicId()
|
|
245
|
+
const code = joinCode()
|
|
246
|
+
const desktopId = cleanText(payload && payload.deviceId, 80) || publicId()
|
|
247
|
+
const desktopSecret = publicId() + publicId()
|
|
248
|
+
const pair = {
|
|
249
|
+
id, code, joinExpiresAt: now() + PAIR_TTL_MS, note: '', noteAt: 0, noteFrom: '', devices: new Map(),
|
|
250
|
+
}
|
|
251
|
+
pair.devices.set(desktopId, { id: desktopId, secret: desktopSecret, role: 'desktop', label: '电脑端', seenAt: now() })
|
|
252
|
+
pairs.set(id, pair)
|
|
253
|
+
const gatewayToken = activeGateway.issue(id, `${id}.${code}`, pair.joinExpiresAt)
|
|
254
|
+
return sendJson(res, 200, { ok: true, data: {
|
|
255
|
+
pairId: id, code, secret: desktopSecret, expiresAt: pair.joinExpiresAt, gatewayToken,
|
|
256
|
+
gateway: { active: true, port: activeGateway.port, addresses: activeGateway.addresses },
|
|
257
|
+
pair: visiblePair(pair),
|
|
258
|
+
} })
|
|
259
|
+
}
|
|
260
|
+
if (method === 'join') {
|
|
261
|
+
if (!canJoin(req)) {
|
|
262
|
+
const error = new Error('尝试次数过多,请稍后重试或在电脑端重新生成链接')
|
|
263
|
+
error.statusCode = 429
|
|
264
|
+
throw error
|
|
265
|
+
}
|
|
266
|
+
const pairId = cleanText(payload && payload.pairId, 48)
|
|
267
|
+
const code = cleanText(payload && payload.code, 32).toUpperCase()
|
|
268
|
+
const pair = pairs.get(pairId)
|
|
269
|
+
if (!pair || pair.joinExpiresAt <= now() || code !== pair.code) {
|
|
270
|
+
recordFailedJoin(req)
|
|
271
|
+
const error = new Error('连接码无效或已过期,请回到电脑端重新开启')
|
|
272
|
+
error.statusCode = 403
|
|
273
|
+
throw error
|
|
274
|
+
}
|
|
275
|
+
const deviceId = cleanText(payload && payload.deviceId, 80) || publicId()
|
|
276
|
+
const secret = publicId() + publicId()
|
|
277
|
+
pair.devices.set(deviceId, { id: deviceId, secret, role: 'mobile', label: cleanText(payload && payload.label, 32) || '手机端', seenAt: now() })
|
|
278
|
+
return sendJson(res, 200, { ok: true, data: { pairId, secret, expiresAt: pair.joinExpiresAt, pair: visiblePair(pair) } })
|
|
279
|
+
}
|
|
280
|
+
if (method === 'status') {
|
|
281
|
+
const { pair } = requirePair(payload)
|
|
282
|
+
return sendJson(res, 200, { ok: true, data: { pair: visiblePair(pair) } })
|
|
283
|
+
}
|
|
284
|
+
if (method === 'note') {
|
|
285
|
+
const { pair, device } = requirePair(payload)
|
|
286
|
+
const note = cleanText(payload && payload.note, NOTE_MAX_LENGTH)
|
|
287
|
+
if (!note) {
|
|
288
|
+
const error = new Error('请先写下接力内容')
|
|
289
|
+
error.statusCode = 400
|
|
290
|
+
throw error
|
|
291
|
+
}
|
|
292
|
+
pair.note = note
|
|
293
|
+
pair.noteAt = now()
|
|
294
|
+
pair.noteFrom = device.role === 'mobile' ? '手机端' : '电脑端'
|
|
295
|
+
return sendJson(res, 200, { ok: true, data: { pair: visiblePair(pair) } })
|
|
296
|
+
}
|
|
297
|
+
if (method === 'end') {
|
|
298
|
+
const { pair } = requirePair(payload)
|
|
299
|
+
pairs.delete(pair.id)
|
|
300
|
+
if (gateway) gateway.revokePair(pair.id)
|
|
301
|
+
const shouldStopGateway = pairs.size === 0
|
|
302
|
+
sendJson(res, 200, { ok: true, data: { ended: true } })
|
|
303
|
+
// 手机端的关闭请求本身也经过网关。先完成响应,再异步停止监听,
|
|
304
|
+
// 避免 server.close() 等待当前请求、当前请求又等待 close() 的死锁。
|
|
305
|
+
if (shouldStopGateway) setTimeout(() => { void stopGateway() }, 50)
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
return sendJson(res, 404, { ok: false, error: { code: 'method-not-found', message: 'unknown method: ' + method } })
|
|
309
|
+
} catch (error) {
|
|
310
|
+
const status = error && error.statusCode ? error.statusCode : 500
|
|
311
|
+
if (status >= 500) console.error('[dsh-dock] mobile relay HTTP error:', error && error.message)
|
|
312
|
+
return sendJson(res, status, { ok: false, error: { code: status >= 500 ? 'internal' : 'bad-request', message: (error && error.message) || String(error) } })
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
}), 'dsh-dock mobile relay: /dsh-dock/mobile-relay HTTP route')
|
|
316
|
+
}))
|
|
317
|
+
|
|
318
|
+
return () => {
|
|
319
|
+
while (disposers.length) {
|
|
320
|
+
const dispose = disposers.pop()
|
|
321
|
+
try { if (typeof dispose === 'function') dispose() } catch { /* ignore */ }
|
|
322
|
+
}
|
|
323
|
+
pairs.clear()
|
|
324
|
+
tasks.clear()
|
|
325
|
+
void stopGateway()
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
}
|