dsh-dock 0.7.1 → 0.9.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.
@@ -1,56 +1,257 @@
1
1
  // dsh-dock · 功能模块【手机接力】· 宿主半部
2
2
  //
3
- // 不复制或迁移 DSH 会话:手机与电脑都连接同一个 DSH 宿主时,会话本来就是同一份。
4
- // 本模块只负责短时配对、在线状态、实时任务摘要和接力备注,避免把任务状态再做成一套
5
- // 容易分叉的副本。所有配对凭据均只存内存,宿主重启后自动失效。
6
- import { randomBytes } from 'node:crypto'
7
- import { readBody, sendJson } from '../../src/host-core.js'
3
+ // 「服务器模式」(单实例架构):所有设备访问同一个 DSH 进程,会话、任务进度、
4
+ // 设置、功能坞完全一致——这就是"dsh 装在服务器上,任何设备打开都一样"的形态。
5
+ //
6
+ // - 开启:插件把 `- id: webserver / config: {host:'0.0.0.0', port}` 写进用户的
7
+ // profile 补丁层(~/.dsh/profiles/web/cordis.patch.yml,DSH 官方补丁语义),
8
+ // 重启 `dsh web` 后主实例直接绑定 0.0.0.0。绑定后 DSH 自身派生局域网信任
9
+ // (connection 行 trustedHosts = 局域网 IPv4),目录选择器自动切浏览模式;
10
+ // 手机接力的网关上游也直接指向主实例——不再有第二个进程,自然不存在
11
+ // 任务状态在不同实例间不同步的问题。
12
+ // - 兼容兜底:局域网 http 非安全上下文,部分浏览器缺 crypto.randomUUID(DSH
13
+ // 客户端生成消息/RPC ID 直接调用)。通过 webServer.tapIndex 在 index.html
14
+ // 内联注入 ES5 兜底;回环/安全上下文下脚本自我短路零副作用。
15
+ //
16
+ // 配对状态、在线设备与接力备注只存内存;会话数据始终只有 ~/.dsh 这一份。
17
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
18
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
19
+ import { homedir } from 'node:os'
20
+ import { dirname, join } from 'node:path'
21
+ import yaml from 'js-yaml'
22
+ import { DOCK_NS, readBody, sendJson } from '../../src/host-core.js'
8
23
  import { lanAddresses, startProtectedLanGateway } from './gateway.js'
9
24
 
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
25
+ function dshHome() { return process.env.DSH_HOME || join(homedir(), '.dsh') }
26
+ /** 远程访问开关所在的用户补丁层(web profile 专属;测试可用环境变量指到临时文件)。 */
27
+ function serverPatchFile() {
28
+ return process.env.DSH_DOCK_PATCH_FILE || join(dshHome(), 'profiles', 'web', 'cordis.patch.yml')
29
+ }
30
+
31
+ // 与 DSH include 插件同款的 !!js 表达式标签:用户补丁层可能含 !!js 表达式,
32
+ // 读写必须无损往返,否则一次开关操作就会破坏用户手工配置。
33
+ const JsExprType = new yaml.Type('tag:yaml.org,2002:js', {
34
+ kind: 'scalar',
35
+ resolve: (data) => typeof data === 'string',
36
+ construct: (data) => ({ __jsExpr: data }),
37
+ represent: (data) => data['__jsExpr'],
38
+ })
39
+ const patchYamlSchema = yaml.JSON_SCHEMA.extend(JsExprType)
15
40
 
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'
41
+ function readPatchList(file) {
42
+ let text
43
+ try {
44
+ text = readFileSync(file, 'utf8')
45
+ } catch (error) {
46
+ if (error && error.code === 'ENOENT') return null
47
+ const err = new Error('读取局域网服务补丁失败:' + ((error && error.message) || String(error)))
48
+ err.statusCode = 500
49
+ throw err
25
50
  }
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'
51
+ try {
52
+ const data = yaml.load(text, { schema: patchYamlSchema })
53
+ return Array.isArray(data) ? data : []
54
+ } catch (error) {
55
+ const err = new Error('局域网服务补丁不是合法的补丁列表:' + ((error && error.message) || String(error)))
56
+ err.statusCode = 500
57
+ throw err
29
58
  }
30
- return ''
31
59
  }
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()
60
+ function writePatchList(file, list) {
61
+ try {
62
+ mkdirSync(dirname(file), { recursive: true })
63
+ writeFileSync(file, yaml.dump(list, { schema: patchYamlSchema }), 'utf8')
64
+ } catch (error) {
65
+ const err = new Error('写入局域网服务补丁失败:' + ((error && error.message) || String(error)))
66
+ err.statusCode = 500
67
+ throw err
68
+ }
69
+ }
70
+ // 补丁层的扁平行视图:insert 块({insert:[…]})展开成行,其余原样。
71
+ // DSH 补丁语义里,非 insert 行只能改已存在 id 的 config/disabled——想新增行必须包
72
+ // insert(applyEntryPatches 对未知 id 的普通行只告警后跳过,对 name 不一致的行同样
73
+ // 整行跳过)。展开后统一按行检测/清理,兼容旧版写坏的平铺文件。
74
+ function patchRows(list) {
75
+ if (!Array.isArray(list)) return []
76
+ return list.flatMap((row) => (row && Array.isArray(row.insert)) ? row.insert : [row])
77
+ }
78
+ function serverPatchApplied(list) {
79
+ // 远程访问补丁:目录选择器钉死为浏览模式(远程设备不能弹本机对话框)。
80
+ return patchRows(list).some((row) => row && row.id === 'directory-picker-browse')
81
+ }
82
+ function legacyWebserverRowPresent(list) {
83
+ // 旧「服务器模式」残留:0.0.0.0 的 webserver 覆盖行。账号认证架构下主实例必须回到仅本机,
84
+ // 否则局域网设备可以绕过登录网关直连。
85
+ return patchRows(list).some((row) => row && row.id === 'webserver'
86
+ && row.config && row.config.host === '0.0.0.0')
87
+ }
88
+ function stripRemoteRows(list) {
89
+ // 清掉全部远程访问相关行:平铺的 browse 行、webserver 行、auto 钉死行,以及
90
+ // insert 块里的 browse 行(块清空则整块移除,块里混有用户自己的行则只摘除 browse)。
91
+ return list
92
+ .map((row) => {
93
+ if (!(row && Array.isArray(row.insert))) return row
94
+ const rest = row.insert.filter((item) => !(item && (item.id === 'directory-picker-browse' || item.id === 'ui-directory-picker-browse')))
95
+ return rest.length ? { insert: rest } : null
96
+ })
97
+ .filter((row) => row && !(row.id === 'webserver'
98
+ || row.id === 'directory-picker' || row.id === 'directory-picker-browse' || row.id === 'ui-directory-picker-browse'))
99
+ }
100
+ function upsertRemotePatches(list) {
101
+ const cleaned = stripRemoteRows(list)
102
+ cleaned.push(
103
+ { id: 'directory-picker', disabled: true },
104
+ { insert: [
105
+ { id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' },
106
+ { id: 'ui-directory-picker-browse', name: '@deepseek-ai/dsh-client-ui-directory-picker-browse' },
107
+ ] },
108
+ )
109
+ return cleaned
110
+ }
111
+ function removeRemotePatches(list) {
112
+ return stripRemoteRows(list)
113
+ }
114
+
115
+ // LAN HTTP 不是浏览器安全上下文:部分浏览器在 http://<局域网IP> 下只暴露
116
+ // crypto.getRandomValues() 而省略 crypto.randomUUID(),而 DSH 的 connection /
117
+ // conversation 客户端直接调用 randomUUID() 生成消息 ID 与 RPC ID——缺失时整个
118
+ // 客户端引导失败(会话列表、工作区等全部不可用)。下面这段与手机接力网关同款、
119
+ // 局域网 http 不是浏览器安全上下文:部分浏览器(老内核/加固内核)在 http://<局域网IP>
120
+ // 下缺失 crypto.randomUUID(DSH 的 connection/conversation 客户端生成消息与 RPC ID
121
+ // 直接调用它,缺失则整个客户端瘫痪——会话列表、设置目录、工作区全不可用)。
122
+ // 这段 ES5 兜底无前置条件:randomUUID 与 getRandomValues 都补齐(实例属性 +
123
+ // Crypto.prototype 双路径);crypto 被整体冻结的极端浏览器则替换整个对象。
124
+ // 回环/安全上下文下 randomUUID 原生存在,脚本自我短路零副作用。
125
+ const LAN_COMPAT_JS = "(function(){var g=typeof globalThis!=='undefined'?globalThis:(typeof window!=='undefined'?window:(typeof self!=='undefined'?self:undefined));if(!g)return;if(!g.crypto){try{g.crypto={}}catch(e){return}}var c=g.crypto;var nativeRng=typeof c.getRandomValues==='function'?c.getRandomValues.bind(c):null;function rng(bytes){if(nativeRng){nativeRng(bytes);return bytes}for(var i=0;i<bytes.length;i++)bytes[i]=Math.floor(Math.random()*256)&255;return bytes}function uuid(){var b=rng(new Uint8Array(16));b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;var h=[];for(var i=0;i<16;i++)h.push((b[i]+256).toString(16).slice(1));return h.slice(0,4).join('')+'-'+h.slice(4,6).join('')+'-'+h.slice(6,8).join('')+'-'+h.slice(8,10).join('')+'-'+h.slice(10).join('')}function fill(array){rng(array);return array}function define(obj,name,value){if(!obj||typeof obj[name]==='function')return;try{Object.defineProperty(obj,name,{value:value,configurable:true})}catch(e){try{obj[name]=value}catch(e2){}}}define(c,'randomUUID',uuid);if(!nativeRng)define(c,'getRandomValues',fill);define(g.Crypto&&g.Crypto.prototype||null,'randomUUID',uuid);if(!nativeRng)define(g.Crypto&&g.Crypto.prototype||null,'getRandomValues',fill);if(typeof c.randomUUID!=='function'){var fresh={getRandomValues:fill,randomUUID:uuid};if(c.subtle)fresh.subtle=c.subtle;var keyOrigin=Object.create(null);for(var k in c){try{keyOrigin[k]=c[k]}catch(e3){}}for(var k2 in keyOrigin){if(typeof fresh[k2]==='undefined')fresh[k2]=keyOrigin[k2]}try{Object.defineProperty(g,'crypto',{value:fresh,configurable:true})}catch(e4){try{g.crypto=fresh}catch(e5){}}}})()"
126
+ const LAN_COMPAT_MARKER = 'data-dsh-lan-compat'
127
+ // 手机端排版补丁(窄屏媒体查询护栏,桌面零影响):
128
+ // 1) 设置弹窗官方只有"左导航+右内容"两栏,窄屏内容栏被压到不足百像素(一字一行);
129
+ // 改纵向堆叠:导航横排在上、内容占满。
130
+ // 2) 侧边栏展开是 grid 栅格挤占会话区(窄屏会话区只剩 ~110px);改为浮层覆盖。
131
+ // 类名用语义后缀匹配(哈希前缀随 DSH 版本会变,后缀稳定);DSH 升级改了结构时
132
+ // 选择器自然失效,不影响其他功能。
133
+ const MOBILE_LAYOUT_CSS = [
134
+ '@media (max-width:700px){',
135
+ ' [role="dialog"][class*="panel"]{flex-direction:column;width:100vw!important;max-width:100vw!important;height:100vh!important;height:100dvh!important;max-height:none!important;border-radius:0!important}',
136
+ ' [role="dialog"][class*="panel"]>[class*="nav"]{flex:none!important;width:auto!important;height:auto!important;flex-direction:row;align-items:center;gap:2px;padding:6px 8px;overflow-x:auto;border-bottom:1px solid color-mix(in srgb,gray 25%,transparent)}',
137
+ ' [role="dialog"][class*="panel"] [class*="navList"]{flex-direction:row;overflow-x:auto;gap:2px}',
138
+ ' [role="dialog"][class*="panel"] [class*="navTitle"]{flex:none;white-space:nowrap;margin-right:6px}',
139
+ ' [role="dialog"][class*="panel"]>[class*="nav"] [class*="navCell"]{flex:none}',
140
+ ' [role="dialog"][class*="panel"]>[class*="content"]{flex:1 1 auto!important;width:auto!important;min-width:0!important;max-width:none!important}',
141
+ ' [class$="frame"]{grid-template-columns:0px minmax(0,1fr) 0px!important}',
142
+ ' [class$="_handle"]{display:none!important}',
143
+ ' [class$="frame"]:not([data-sidebar-collapsed]) [class*="sidebarCol"]{position:fixed!important;top:0!important;bottom:0!important;left:0!important;width:min(85vw,320px)!important;z-index:80!important;box-shadow:0 12px 48px rgba(0,0,0,.45)}',
144
+ ' [class$="frame"]:not([data-details-collapsed="true"]) [class*="detailsCol"]{position:fixed!important;top:0!important;bottom:0!important;right:0!important;left:auto!important;width:min(85vw,320px)!important;z-index:85!important;box-shadow:-12px 0 48px rgba(0,0,0,.45)}',
145
+ ' [class*="overlayLayer"]{z-index:90!important}',
146
+ '}',
147
+ ].join('\n')
148
+ const MOBILE_LAYOUT_MARKER = 'data-dsh-mobile-layout'
149
+ // 窄屏行为补丁:侧边栏浮层化后,原生的"保持展开"会一直挡住半屏——
150
+ // 点会话行/新会话后自动收起;点侧栏外的页面区域也收起(浮层语义)。
151
+ // 侧栏内的其他点击(工作区折叠、搜索、功能坞入口)保持原生行为不收。
152
+ const MOBILE_BEHAVIOR_JS = ";(function(){var mq=window.matchMedia?window.matchMedia('(max-width:700px)'):null;if(!mq)return;function narrow(){return mq.matches}function collapse(){var b=document.querySelector('button[aria-label=\"收起侧边栏\"],button[aria-label=\"Collapse sidebar\"]');if(b)b.click()}function closeDetails(){var d=document.querySelector('[class*=\"detailsCol\"]');if(!d||!d.getBoundingClientRect().width)return;var c=d.querySelector('button[aria-label=\"关闭详情\"]');if(c)c.click()}document.addEventListener('click',function(e){if(!narrow())return;var t=e.target;if(!t||!t.closest)return;var expanded=!!document.querySelector('button[aria-label=\"收起侧边栏\"],button[aria-label=\"Collapse sidebar\"]');var col=document.querySelector('[class*=\"sidebarCol\"]');var inSidebar=col&&col.contains(t);if(!expanded&&!inSidebar)return;if(inSidebar){var row=t.closest('[role=\"treeitem\"]');var leaf=row&&!row.querySelector('[role=\"treeitem\"]')&&row.closest('[role=\"tree\"]');var fresh=t.closest('[class*=\"newSession\"]');if(leaf||fresh)setTimeout(function(){collapse();closeDetails()},300);return}if(t.closest('[role=\"dialog\"],[class*=\"dockm\"],[class*=\"dgfab\"],[class*=\"dgwin\"],[class*=\"dgame\"],[class*=\"detailsCol\"]'))return;collapse()},true)})();"
153
+ const MOBILE_BEHAVIOR_MARKER = 'data-dsh-mobile-behave'
154
+ /** 把兼容脚本、移动端排版样式与行为脚本内联注入 index.html 的 <head> 之后(幂等)。 */
155
+ function injectLanCompat(html) {
156
+ let out = html
157
+ const match = /<head(?:\s[^>]*)?>/i.exec(out)
158
+ const at = match ? match.index + match[0].length : 0
159
+ const parts = []
160
+ if (!out.includes(LAN_COMPAT_MARKER)) parts.push(`<script ${LAN_COMPAT_MARKER}>${LAN_COMPAT_JS}</script>`)
161
+ if (!out.includes(MOBILE_LAYOUT_MARKER)) parts.push(`<style ${MOBILE_LAYOUT_MARKER}>${MOBILE_LAYOUT_CSS}</style>`)
162
+ if (!out.includes(MOBILE_BEHAVIOR_MARKER)) parts.push(`<script ${MOBILE_BEHAVIOR_MARKER}>${MOBILE_BEHAVIOR_JS}</script>`)
163
+ if (!parts.length) return out
164
+ const injected = parts.join('')
165
+ return at > 0 ? out.slice(0, at) + injected + out.slice(at) : injected + out
36
166
  }
37
167
  function routeMethod(req) {
38
168
  const url = new URL(req.url || '/', 'http://dsh.internal')
39
- return url.pathname.replace(/^\/dsh-dock\/mobile-relay\/?/, '').split('/')[0] || ''
169
+ return url.pathname.replace(/^\/dsh-dock\/mobile-relay\/?/, '').replace(/\/+$/, '')
40
170
  }
41
171
 
42
172
  export const feature = {
43
173
  id: 'mobile-relay',
44
- name: '手机接力',
45
- description: '安全反向代理与扫码接力,同步当前任务状态和备注',
174
+ name: '远程访问',
175
+ description: '账号密码登录的远程入口:局域网/虚拟网设备访问同一个 DSH,任务进度实时一致',
46
176
  defaultEnabled: true,
47
177
  setup(ctx) {
48
178
  const disposers = []
49
- const pairs = new Map()
50
- const tasks = new Map()
51
- const joinAttempts = new Map()
52
179
  let gateway = null
53
180
  let gatewayStarting = null
181
+ let settingsCtx = null
182
+
183
+ // 自愈:早期版本把 browse 行平铺写进补丁层——DSH 会把未知 id 的普通补丁行整行
184
+ // 跳过(且 auto 已被停用),结果 directoryPicker 服务无人提供,/api 整面 404,
185
+ // 重启时更会因条目未激活直接起不来。这里把旧形态无损改写成 insert 形态。
186
+ try {
187
+ const file = serverPatchFile()
188
+ const list = readPatchList(file)
189
+ if (list && list.some((row) => row && (row.id === 'directory-picker-browse' || row.id === 'ui-directory-picker-browse'))) {
190
+ writePatchList(file, upsertRemotePatches(list))
191
+ }
192
+ } catch { /* 补丁层不可读时交给 lanStatus 的错误呈现,不在启动路径上放大 */ }
193
+
194
+ disposers.push(ctx.inject(['settings'], (sctx) => { settingsCtx = sctx }))
195
+
196
+ // ── 远程访问账号(settings 持久化;密码只存加盐哈希) ──
197
+ function hashPassword(salt, password) {
198
+ return createHash('sha256').update(String(salt) + '\u0000' + String(password), 'utf8').digest('hex')
199
+ }
200
+ function sameText(a, b) {
201
+ const ba = Buffer.from(String(a || ''))
202
+ const bb = Buffer.from(String(b || ''))
203
+ return ba.length > 0 && ba.length === bb.length && timingSafeEqual(ba, bb)
204
+ }
205
+ function getAuth() {
206
+ if (!settingsCtx) return null
207
+ const value = settingsCtx.settings.get(DOCK_NS)
208
+ const auth = value && value.remoteAuth
209
+ return auth && auth.username && auth.passwordHash && auth.salt ? auth : null
210
+ }
211
+ async function saveAuth(username, password) {
212
+ if (!settingsCtx || typeof settingsCtx.settings.mutate !== 'function') {
213
+ const error = new Error('settings 服务未就绪,无法保存账号')
214
+ error.statusCode = 500
215
+ throw error
216
+ }
217
+ const salt = randomBytes(16).toString('hex')
218
+ await settingsCtx.settings.mutate(DOCK_NS, [{
219
+ op: 'set',
220
+ path: ['remoteAuth'],
221
+ value: { username, salt, passwordHash: hashPassword(salt, password) },
222
+ }])
223
+ // 账号密码变更:作废所有已登录设备,强制重新登录。
224
+ if (gateway) gateway.revokeAllSessions()
225
+ }
226
+ async function verifyLogin(username, password) {
227
+ const auth = getAuth()
228
+ if (!auth) return false
229
+ return sameText(username, auth.username) && sameText(hashPassword(auth.salt, password), auth.passwordHash)
230
+ }
231
+
232
+ /** 远程访问状态:网关、账号、浏览模式补丁、旧服务器模式残留。 */
233
+ function lanStatus(webServer) {
234
+ const file = serverPatchFile()
235
+ let applied = false
236
+ let webserverActive = false
237
+ try {
238
+ const list = readPatchList(file)
239
+ applied = serverPatchApplied(list)
240
+ webserverActive = legacyWebserverRowPresent(list)
241
+ } catch { applied = false }
242
+ const auth = getAuth()
243
+ return {
244
+ gatewayActive: Boolean(gateway),
245
+ gatewayPort: gateway ? gateway.port : null,
246
+ mainPort: webServer.port,
247
+ addresses: lanAddresses(),
248
+ patchApplied: applied,
249
+ webserverActive,
250
+ accountSet: Boolean(auth),
251
+ username: auth ? auth.username : '',
252
+ patchPath: file,
253
+ }
254
+ }
54
255
 
55
256
  async function stopGateway() {
56
257
  const current = gateway
@@ -59,8 +260,12 @@ export const feature = {
59
260
  if (current) await current.close()
60
261
  }
61
262
 
263
+ /** 远程访问网关:上游即主实例;主实例保持仅本机,远程一律经账号登录进入。 */
62
264
  async function ensureGateway(webServer, requestedPort) {
63
- const port = Number(requestedPort === undefined ? DEFAULT_GATEWAY_PORT : requestedPort)
265
+ // 默认端口跟主实例走(主端口+1):网关和主服务是同一台机器上的两个监听,
266
+ // 结构上不能同端口;跟随主端口让"哪个口是哪个"一目了然。
267
+ const port = Number(requestedPort === undefined || requestedPort === null || requestedPort === ''
268
+ ? webServer.port + 1 : requestedPort)
64
269
  if (!Number.isInteger(port) || port < 1024 || port > 65535) {
65
270
  const error = new Error('局域网端口需为 1024 到 65535 之间的整数')
66
271
  error.statusCode = 400
@@ -77,8 +282,17 @@ export const feature = {
77
282
  error.statusCode = 409
78
283
  throw error
79
284
  }
285
+ // 上游即主实例:单实例架构下所有设备看到的就是同一个进程,任务状态天然同步。
286
+ // 主实例保持仅本机 → 网关把 Host/Origin 改写成回环(回环恒被信任),
287
+ // 远程设备必须先经过账号登录,不存在绕过登录的直连路径。
80
288
  if (!gatewayStarting) {
81
- gatewayStarting = startProtectedLanGateway({ port, upstreamHost: '127.0.0.1', upstreamPort: webServer.port })
289
+ gatewayStarting = startProtectedLanGateway({
290
+ port,
291
+ upstreamHost: '127.0.0.1',
292
+ upstreamPort: webServer.port,
293
+ spoofLoopback: true,
294
+ verifyLogin,
295
+ })
82
296
  .then((started) => { gateway = started; gatewayStarting = null; return started })
83
297
  .catch((error) => { gatewayStarting = null; throw error })
84
298
  }
@@ -92,137 +306,14 @@ export const feature = {
92
306
  }
93
307
  }
94
308
 
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
309
 
225
310
  disposers.push(ctx.inject(['webServer'], (wsCtx) => {
311
+ // 服务器模式下的主实例同样服务局域网浏览器:注入 crypto.randomUUID 兜底,
312
+ // 修复非安全上下文下 DSH 客户端引导失败。回环/安全上下文下脚本自我短路,
313
+ // 对本机使用零副作用。
314
+ if (typeof wsCtx.webServer.tapIndex === 'function') {
315
+ disposers.push(wsCtx.effect(() => wsCtx.webServer.tapIndex(injectLanCompat)))
316
+ }
226
317
  wsCtx.effect(() => wsCtx.webServer.register({
227
318
  kind: 'prefix',
228
319
  path: '/dsh-dock/mobile-relay',
@@ -230,80 +321,66 @@ export const feature = {
230
321
  try {
231
322
  const method = routeMethod(req)
232
323
  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
- } })
324
+ if (method === 'lan') {
325
+ return sendJson(res, 200, { ok: true, data: lanStatus(wsCtx.webServer) })
241
326
  }
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
327
+ if (method === 'auth/set') {
328
+ // 设置/修改远程访问账号密码;改完作废所有已登录会话。
329
+ const username = String((payload && payload.username) || '').trim()
330
+ const password = String((payload && payload.password) || '')
331
+ if (!username || username.length > 64) {
332
+ const error = new Error('账号需为 1 到 64 个字符')
333
+ error.statusCode = 400
264
334
  throw error
265
335
  }
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
336
+ if (password.length < 6 || password.length > 128) {
337
+ const error = new Error('密码至少 6 位、至多 128 位')
338
+ error.statusCode = 400
273
339
  throw error
274
340
  }
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) } })
341
+ await saveAuth(username, password)
342
+ return sendJson(res, 200, { ok: true, data: { ...lanStatus(wsCtx.webServer) } })
279
343
  }
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('请先写下接力内容')
344
+ if (method === 'lan/start') {
345
+ // 开启远程访问:网关监听 0.0.0.0(唯一远程入口,登录后才放行);
346
+ // 同时移除旧服务器模式行(主实例必须回到仅本机)并把目录选择器钉为浏览模式。
347
+ const username = String((payload && payload.username) || '').trim()
348
+ const password = String((payload && payload.password) || '')
349
+ if (username && password) {
350
+ if (!username || username.length > 64) {
351
+ const error = new Error('账号需为 1 到 64 个字符')
352
+ error.statusCode = 400
353
+ throw error
354
+ }
355
+ if (password.length < 6 || password.length > 128) {
356
+ const error = new Error('密码至少 6 位、至多 128 位')
357
+ error.statusCode = 400
358
+ throw error
359
+ }
360
+ await saveAuth(username, password)
361
+ }
362
+ if (!getAuth()) {
363
+ const error = new Error('请先设置远程访问的账号和密码')
289
364
  error.statusCode = 400
290
365
  throw error
291
366
  }
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) } })
367
+ const file = serverPatchFile()
368
+ const list = readPatchList(file) || []
369
+ const changed = !serverPatchApplied(list) || legacyWebserverRowPresent(list)
370
+ writePatchList(file, upsertRemotePatches(list))
371
+ await ensureGateway(wsCtx.webServer, payload && payload.port)
372
+ return sendJson(res, 200, { ok: true, data: { ...lanStatus(wsCtx.webServer), needsRestart: changed } })
296
373
  }
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
374
+ if (method === 'lan/stop') {
375
+ const file = serverPatchFile()
376
+ const list = readPatchList(file)
377
+ await stopGateway()
378
+ if (list === null) {
379
+ return sendJson(res, 200, { ok: true, data: { ...lanStatus(wsCtx.webServer), needsRestart: false } })
380
+ }
381
+ const changed = serverPatchApplied(list) || legacyWebserverRowPresent(list)
382
+ if (changed) writePatchList(file, removeRemotePatches(list))
383
+ return sendJson(res, 200, { ok: true, data: { ...lanStatus(wsCtx.webServer), needsRestart: changed } })
307
384
  }
308
385
  return sendJson(res, 404, { ok: false, error: { code: 'method-not-found', message: 'unknown method: ' + method } })
309
386
  } catch (error) {
@@ -320,8 +397,6 @@ export const feature = {
320
397
  const dispose = disposers.pop()
321
398
  try { if (typeof dispose === 'function') dispose() } catch { /* ignore */ }
322
399
  }
323
- pairs.clear()
324
- tasks.clear()
325
400
  void stopGateway()
326
401
  }
327
402
  },