dsh-dock 0.8.0 → 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.
- package/README.md +136 -0
- package/client.js +2522 -115
- package/features/games/view.jsx +1 -1
- package/features/mobile-relay/gateway.js +217 -81
- package/features/mobile-relay/host.js +305 -230
- package/features/mobile-relay/view.jsx +107 -140
- package/index.js +20 -1
- package/package.json +2 -1
- package/src/client.jsx +4 -3
- package/src/host-core.js +7 -1
|
@@ -1,56 +1,257 @@
|
|
|
1
1
|
// dsh-dock · 功能模块【手机接力】· 宿主半部
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
const
|
|
28
|
-
return
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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\/?/, '').
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
234
|
-
|
|
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 === '
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
276
|
-
|
|
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 === '
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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 === '
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if (
|
|
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
|
},
|