dsh-dock 0.11.2 → 0.11.3
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/LICENSE +18 -18
- package/README.md +242 -396
- package/client.js +173 -9
- package/cordis.patch.yml +2 -2
- package/features/animation/host.js +119 -119
- package/features/animation/view.jsx +1662 -1662
- package/features/balance/host.js +494 -494
- package/features/balance/view.js +351 -351
- package/features/games/games/breakout.jsx +227 -227
- package/features/games/games/gomoku.jsx +244 -244
- package/features/games/games/racer.jsx +169 -169
- package/features/games/games/shared.jsx +118 -118
- package/features/games/games/snake.jsx +144 -144
- package/features/games/games/sokoban.jsx +180 -180
- package/features/games/games/tank.jsx +128 -128
- package/features/games/games/tetris.jsx +308 -308
- package/features/games/games/xiangqi.jsx +277 -277
- package/features/games/tank-core.js +152 -152
- package/features/games/view.jsx +639 -639
- package/features/games/xiangqi-core.js +299 -299
- package/features/heartbeat/view.js +35 -35
- package/features/mobile-relay/gateway.js +472 -472
- package/features/mobile-relay/host.js +570 -570
- package/features/mobile-relay/view.jsx +300 -300
- package/features/modelconfig/host.js +406 -406
- package/features/modelconfig/view.js +490 -490
- package/features/notify/host.js +374 -374
- package/features/notify/view.jsx +751 -751
- package/features/theme/view.js +61 -61
- package/features/tokenlog/host.js +882 -883
- package/features/tokenlog/view.jsx +1117 -932
- package/features/visionproxy/host.js +315 -315
- package/index.js +195 -195
- package/package.json +66 -64
- package/src/client.jsx +677 -677
- package/src/host-core.js +207 -207
- package/src/shared.js +192 -192
- package/src/task-track.js +354 -359
|
@@ -1,570 +1,570 @@
|
|
|
1
|
-
// dsh-dock · 功能模块【手机接力】· 宿主半部
|
|
2
|
-
//
|
|
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 { stat } from 'node:fs/promises'
|
|
20
|
-
import { homedir } from 'node:os'
|
|
21
|
-
import { dirname, join } from 'node:path'
|
|
22
|
-
import yaml from 'js-yaml'
|
|
23
|
-
import { DOCK_NS, readBody, sendJson } from '../../src/host-core.js'
|
|
24
|
-
import { lanAddresses, startProtectedLanGateway } from './gateway.js'
|
|
25
|
-
|
|
26
|
-
function dshHome() { return process.env.DSH_HOME || join(homedir(), '.dsh') }
|
|
27
|
-
/** 远程访问开关所在的用户补丁层(web profile 专属;测试可用环境变量指到临时文件)。 */
|
|
28
|
-
function serverPatchFile() {
|
|
29
|
-
return process.env.DSH_DOCK_PATCH_FILE || join(dshHome(), 'profiles', 'web', 'cordis.patch.yml')
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// 与 DSH include 插件同款的 !!js 表达式标签:用户补丁层可能含 !!js 表达式,
|
|
33
|
-
// 读写必须无损往返,否则一次开关操作就会破坏用户手工配置。
|
|
34
|
-
const JsExprType = new yaml.Type('tag:yaml.org,2002:js', {
|
|
35
|
-
kind: 'scalar',
|
|
36
|
-
resolve: (data) => typeof data === 'string',
|
|
37
|
-
construct: (data) => ({ __jsExpr: data }),
|
|
38
|
-
represent: (data) => data['__jsExpr'],
|
|
39
|
-
})
|
|
40
|
-
const patchYamlSchema = yaml.JSON_SCHEMA.extend(JsExprType)
|
|
41
|
-
|
|
42
|
-
function readPatchList(file) {
|
|
43
|
-
let text
|
|
44
|
-
try {
|
|
45
|
-
text = readFileSync(file, 'utf8')
|
|
46
|
-
} catch (error) {
|
|
47
|
-
if (error && error.code === 'ENOENT') return null
|
|
48
|
-
const err = new Error('读取局域网服务补丁失败:' + ((error && error.message) || String(error)))
|
|
49
|
-
err.statusCode = 500
|
|
50
|
-
throw err
|
|
51
|
-
}
|
|
52
|
-
try {
|
|
53
|
-
const data = yaml.load(text, { schema: patchYamlSchema })
|
|
54
|
-
return Array.isArray(data) ? data : []
|
|
55
|
-
} catch (error) {
|
|
56
|
-
const err = new Error('局域网服务补丁不是合法的补丁列表:' + ((error && error.message) || String(error)))
|
|
57
|
-
err.statusCode = 500
|
|
58
|
-
throw err
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
function writePatchList(file, list) {
|
|
62
|
-
try {
|
|
63
|
-
mkdirSync(dirname(file), { recursive: true })
|
|
64
|
-
writeFileSync(file, yaml.dump(list, { schema: patchYamlSchema }), 'utf8')
|
|
65
|
-
} catch (error) {
|
|
66
|
-
const err = new Error('写入局域网服务补丁失败:' + ((error && error.message) || String(error)))
|
|
67
|
-
err.statusCode = 500
|
|
68
|
-
throw err
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
// 补丁层的扁平行视图:insert 块({insert:[…]})展开成行,其余原样。
|
|
72
|
-
// DSH 补丁语义里,非 insert 行只能改已存在 id 的 config/disabled——想新增行必须包
|
|
73
|
-
// insert(applyEntryPatches 对未知 id 的普通行只告警后跳过,对 name 不一致的行同样
|
|
74
|
-
// 整行跳过)。展开后统一按行检测/清理,兼容旧版写坏的平铺文件。
|
|
75
|
-
function patchRows(list) {
|
|
76
|
-
if (!Array.isArray(list)) return []
|
|
77
|
-
return list.flatMap((row) => (row && Array.isArray(row.insert)) ? row.insert : [row])
|
|
78
|
-
}
|
|
79
|
-
function serverPatchApplied(list) {
|
|
80
|
-
// 远程访问补丁:目录选择器钉死为浏览模式(远程设备不能弹本机对话框)。
|
|
81
|
-
return patchRows(list).some((row) => row && row.id === 'directory-picker-browse')
|
|
82
|
-
}
|
|
83
|
-
function legacyWebserverRowPresent(list) {
|
|
84
|
-
// 旧「服务器模式」残留:0.0.0.0 的 webserver 覆盖行。账号认证架构下主实例必须回到仅本机,
|
|
85
|
-
// 否则局域网设备可以绕过登录网关直连。
|
|
86
|
-
return patchRows(list).some((row) => row && row.id === 'webserver'
|
|
87
|
-
&& row.config && row.config.host === '0.0.0.0')
|
|
88
|
-
}
|
|
89
|
-
function stripRemoteRows(list) {
|
|
90
|
-
// 清掉全部远程访问相关行:平铺的 browse 行、webserver 行、auto 钉死行,以及
|
|
91
|
-
// insert 块里的 browse 行(块清空则整块移除,块里混有用户自己的行则只摘除 browse)。
|
|
92
|
-
return list
|
|
93
|
-
.map((row) => {
|
|
94
|
-
if (!(row && Array.isArray(row.insert))) return row
|
|
95
|
-
const rest = row.insert.filter((item) => !(item && (item.id === 'directory-picker-browse' || item.id === 'ui-directory-picker-browse')))
|
|
96
|
-
return rest.length ? { insert: rest } : null
|
|
97
|
-
})
|
|
98
|
-
.filter((row) => row && !(row.id === 'webserver'
|
|
99
|
-
|| row.id === 'directory-picker' || row.id === 'directory-picker-browse' || row.id === 'ui-directory-picker-browse'))
|
|
100
|
-
}
|
|
101
|
-
function upsertRemotePatches(list) {
|
|
102
|
-
const cleaned = stripRemoteRows(list)
|
|
103
|
-
cleaned.push(
|
|
104
|
-
{ id: 'directory-picker', disabled: true },
|
|
105
|
-
{ insert: [
|
|
106
|
-
{ id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' },
|
|
107
|
-
{ id: 'ui-directory-picker-browse', name: '@deepseek-ai/dsh-client-ui-directory-picker-browse' },
|
|
108
|
-
] },
|
|
109
|
-
)
|
|
110
|
-
return cleaned
|
|
111
|
-
}
|
|
112
|
-
function removeRemotePatches(list) {
|
|
113
|
-
return stripRemoteRows(list)
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// LAN HTTP 不是浏览器安全上下文:部分浏览器在 http://<局域网IP> 下只暴露
|
|
117
|
-
// crypto.getRandomValues() 而省略 crypto.randomUUID(),而 DSH 的 connection /
|
|
118
|
-
// conversation 客户端直接调用 randomUUID() 生成消息 ID 与 RPC ID——缺失时整个
|
|
119
|
-
// 客户端引导失败(会话列表、工作区等全部不可用)。下面这段与手机接力网关同款、
|
|
120
|
-
// 局域网 http 不是浏览器安全上下文:部分浏览器(老内核/加固内核)在 http://<局域网IP>
|
|
121
|
-
// 下缺失 crypto.randomUUID(DSH 的 connection/conversation 客户端生成消息与 RPC ID
|
|
122
|
-
// 直接调用它,缺失则整个客户端瘫痪——会话列表、设置目录、工作区全不可用)。
|
|
123
|
-
// 这段 ES5 兜底无前置条件:randomUUID 与 getRandomValues 都补齐(实例属性 +
|
|
124
|
-
// Crypto.prototype 双路径);crypto 被整体冻结的极端浏览器则替换整个对象。
|
|
125
|
-
// 回环/安全上下文下 randomUUID 原生存在,脚本自我短路零副作用。
|
|
126
|
-
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){}}}})()"
|
|
127
|
-
const LAN_COMPAT_MARKER = 'data-dsh-lan-compat'
|
|
128
|
-
// 手机端排版补丁(窄屏媒体查询护栏,桌面零影响):
|
|
129
|
-
// 1) 设置弹窗官方只有"左导航+右内容"两栏,窄屏内容栏被压到不足百像素(一字一行);
|
|
130
|
-
// 改纵向堆叠:导航横排在上、内容占满。
|
|
131
|
-
// 2) 侧边栏展开是 grid 栅格挤占会话区(窄屏会话区只剩 ~110px);改为浮层覆盖。
|
|
132
|
-
// 3) 侧栏栅格归零后,原生「打开侧边栏」按钮随窄轨一起被裁掉——补贴左边缘的抽屉把手
|
|
133
|
-
// (.dsh-mobile-drawer-btn,行为脚本创建,点击转发原生 toggle,位置在趣味游戏
|
|
134
|
-
// 浮标正上方、兜底左侧中部)与抽屉遮罩(.dsh-mobile-scrim,点击即收起,阻断滚动穿透)。
|
|
135
|
-
// 类名用语义后缀匹配(哈希前缀随 DSH 版本会变,后缀稳定);DSH 升级改了结构时
|
|
136
|
-
// 选择器自然失效,不影响其他功能。
|
|
137
|
-
const MOBILE_LAYOUT_CSS = [
|
|
138
|
-
'@media (max-width:700px){',
|
|
139
|
-
' [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}',
|
|
140
|
-
' [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)}',
|
|
141
|
-
' [role="dialog"][class*="panel"] [class*="navList"]{flex-direction:row;overflow-x:auto;gap:2px}',
|
|
142
|
-
' [role="dialog"][class*="panel"] [class*="navTitle"]{flex:none;white-space:nowrap;margin-right:6px}',
|
|
143
|
-
' [role="dialog"][class*="panel"]>[class*="nav"] [class*="navCell"]{flex:none}',
|
|
144
|
-
' [role="dialog"][class*="panel"]>[class*="content"]{flex:1 1 auto!important;width:auto!important;min-width:0!important;max-width:none!important}',
|
|
145
|
-
' [class$="frame"]{grid-template-columns:0px minmax(0,1fr) 0px!important}',
|
|
146
|
-
' [class$="_handle"]{display:none!important}',
|
|
147
|
-
' [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)}',
|
|
148
|
-
' [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)}',
|
|
149
|
-
' [class*="overlayLayer"]{z-index:90!important}',
|
|
150
|
-
// 抽屉把手:贴左边缘(同趣味游戏浮标的边缘吸附语言),top 由 place() 动态设定
|
|
151
|
-
// (趣味游戏浮标正上方,兜底左侧中部);z 序 70 < 遮罩 75 < 侧栏 80 < overlayLayer 90,
|
|
152
|
-
// 面板/弹窗打开时自然被盖,无需额外隐藏逻辑。
|
|
153
|
-
' .dsh-mobile-drawer-btn{position:fixed;left:0;top:38vh;z-index:70;box-sizing:border-box;width:30px;height:48px;padding:0;display:flex;align-items:center;justify-content:center;border:1px solid var(--dsw-alias-border-l1,rgba(127,139,161,.35));border-left:none;border-radius:0 12px 12px 0;background:color-mix(in srgb,var(--dsw-alias-bg-layer-2,#1c2230) 88%,transparent);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);color:var(--dsw-alias-label-primary,#e6eaf2);box-shadow:4px 0 18px rgba(0,0,0,.28);cursor:pointer;touch-action:manipulation;-webkit-tap-highlight-color:transparent}',
|
|
154
|
-
' .dsh-mobile-drawer-btn:active{transform:scale(.94)}',
|
|
155
|
-
' .dsh-mobile-drawer-btn svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}',
|
|
156
|
-
' .dsh-mobile-scrim{position:fixed;inset:0;z-index:75;background:rgba(8,10,14,.45);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);touch-action:none}',
|
|
157
|
-
'}',
|
|
158
|
-
].join('\n')
|
|
159
|
-
const MOBILE_LAYOUT_MARKER = 'data-dsh-mobile-layout'
|
|
160
|
-
// 窄屏行为补丁:侧边栏浮层化后,原生的"保持展开"会一直挡住半屏——
|
|
161
|
-
// 点会话行/新会话后自动收起;点侧栏外的页面区域也收起(浮层语义)。
|
|
162
|
-
// 侧栏内的其他点击(工作区折叠、搜索、功能坞入口)保持原生行为不收。
|
|
163
|
-
// 另创建抽屉按钮(窄屏 + 侧栏收起时才显示,点击转发原生「打开侧边栏」toggle;
|
|
164
|
-
// 侧栏展开或设置弹窗打开时自动隐藏)与抽屉遮罩(点击经全局捕获 handler 收起,
|
|
165
|
-
// 单一路径防止同一事件两次 toggle)。注入点在 <head> 后,body 尚未解析,
|
|
166
|
-
// DOM 创建一律推迟到 DOMContentLoaded;显隐同步走 MutationObserver(属性翻转即时)
|
|
167
|
-
// + 低速轮询(覆盖路由重渲等一切边角)。
|
|
168
|
-
const MOBILE_BEHAVIOR_JS = [
|
|
169
|
-
';(function(){',
|
|
170
|
-
'var mq=window.matchMedia?window.matchMedia("(max-width:700px)"):null;',
|
|
171
|
-
'if(!mq)return;',
|
|
172
|
-
// 与 dsh-dock 客户端 Overlay 版(features/mobile-relay/view.jsx,刷新即生效)互斥:
|
|
173
|
-
// 本脚本在 <head> 先执行,存在即接管;客户端版看到旗标自动让位。
|
|
174
|
-
'if(window.__dshDockMobileDrawer)return;',
|
|
175
|
-
'window.__dshDockMobileDrawer="host";',
|
|
176
|
-
'function narrow(){return mq.matches}',
|
|
177
|
-
'function frameEl(){return document.querySelector(\'[class$="frame"]\')}',
|
|
178
|
-
'function sidebarCollapsed(){var f=frameEl();return !!f&&f.hasAttribute("data-sidebar-collapsed")}',
|
|
179
|
-
'function dialogOpen(){return !!document.querySelector(\'[role="dialog"][class*="panel"]\')}',
|
|
180
|
-
'function collapse(){var b=document.querySelector(\'button[aria-label="收起侧边栏"],button[aria-label="Collapse sidebar"]\');if(b)b.click()}',
|
|
181
|
-
'function expand(){var b=document.querySelector(\'button[aria-label="打开侧边栏"],button[aria-label="Open sidebar"]\');if(b)b.click()}',
|
|
182
|
-
'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()}',
|
|
183
|
-
'var fab=null,scrim=null;',
|
|
184
|
-
'function ensureChrome(){',
|
|
185
|
-
' if(!fab){fab=document.createElement("button");fab.type="button";fab.className="dsh-mobile-drawer-btn";fab.setAttribute("aria-label","打开会话列表");fab.innerHTML=\'<svg viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h10"/></svg>\';fab.addEventListener("click",function(){expand()});document.body.appendChild(fab)}',
|
|
186
|
-
' if(!scrim){scrim=document.createElement("div");scrim.className="dsh-mobile-scrim";document.body.appendChild(scrim)}',
|
|
187
|
-
'}',
|
|
188
|
-
// 把手定位:贴着趣味游戏浮标(.dgfab,可拖拽)正上方;浮标太靠上时改放它下面
|
|
189
|
-
// (避开顶栏标题),被拖走/隐藏/不存在时兜底左侧中部(38% 视高)。
|
|
190
|
-
'function place(){',
|
|
191
|
-
' if(!fab)return;',
|
|
192
|
-
' var vh=window.innerHeight,top=null;',
|
|
193
|
-
' var g=document.querySelector(".dgfab");',
|
|
194
|
-
' if(g&&!g.classList.contains("dgfab-hide")&&g.getBoundingClientRect){',
|
|
195
|
-
' var r=g.getBoundingClientRect();',
|
|
196
|
-
' if(r&&r.height>0&&r.top>0){',
|
|
197
|
-
' top=r.top-fab.offsetHeight-8;',
|
|
198
|
-
' if(top<64)top=r.bottom+8;',
|
|
199
|
-
' if(top+fab.offsetHeight>vh-16)top=null;',
|
|
200
|
-
' }',
|
|
201
|
-
' }',
|
|
202
|
-
' if(top==null)top=Math.round(vh*.38);',
|
|
203
|
-
' fab.style.top=top+"px";',
|
|
204
|
-
'}',
|
|
205
|
-
'function syncChrome(){',
|
|
206
|
-
' if(!fab||!scrim)return;',
|
|
207
|
-
' var on=narrow()&&!!frameEl()&&!dialogOpen();',
|
|
208
|
-
' var col=sidebarCollapsed();',
|
|
209
|
-
' var showFab=on&&col;',
|
|
210
|
-
' fab.style.display=showFab?"":"none";',
|
|
211
|
-
' if(showFab)place();',
|
|
212
|
-
' scrim.style.display=on&&!col?"":"none";',
|
|
213
|
-
'}',
|
|
214
|
-
'function boot(){',
|
|
215
|
-
' ensureChrome();syncChrome();',
|
|
216
|
-
' if(typeof MutationObserver!=="undefined"){new MutationObserver(function(){syncChrome()}).observe(document.documentElement,{attributes:true,attributeFilter:["data-sidebar-collapsed"],subtree:true})}',
|
|
217
|
-
' setInterval(syncChrome,1500);',
|
|
218
|
-
' var onMq=function(){syncChrome()};',
|
|
219
|
-
' if(mq.addEventListener)mq.addEventListener("change",onMq);else if(mq.addListener)mq.addListener(onMq);',
|
|
220
|
-
' window.addEventListener("resize",place);',
|
|
221
|
-
'}',
|
|
222
|
-
'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",boot);else boot();',
|
|
223
|
-
'document.addEventListener("click",function(e){',
|
|
224
|
-
' if(!narrow())return;',
|
|
225
|
-
' var t=e.target;',
|
|
226
|
-
' if(!t||!t.closest)return;',
|
|
227
|
-
' if(t.closest(".dsh-mobile-drawer-btn"))return;',
|
|
228
|
-
' var expanded=!!document.querySelector(\'button[aria-label="收起侧边栏"],button[aria-label="Collapse sidebar"]\');',
|
|
229
|
-
' var col=document.querySelector(\'[class*="sidebarCol"]\');',
|
|
230
|
-
' var inSidebar=col&&col.contains(t);',
|
|
231
|
-
' if(!expanded&&!inSidebar)return;',
|
|
232
|
-
' if(inSidebar){',
|
|
233
|
-
' var row=t.closest(\'[role="treeitem"]\');',
|
|
234
|
-
' var leaf=row&&!row.querySelector(\'[role="treeitem"]\')&&row.closest(\'[role="tree"]\');',
|
|
235
|
-
' var fresh=t.closest(\'[class*="newSession"]\');',
|
|
236
|
-
' if(leaf||fresh)setTimeout(function(){collapse();closeDetails()},300);',
|
|
237
|
-
' return',
|
|
238
|
-
' }',
|
|
239
|
-
' if(t.closest(\'[role="dialog"],[class*="dockm"],[class*="dgfab"],[class*="dgwin"],[class*="dgame"],[class*="detailsCol"]\'))return;',
|
|
240
|
-
' collapse()',
|
|
241
|
-
'},true)',
|
|
242
|
-
'})();',
|
|
243
|
-
].join('\n')
|
|
244
|
-
const MOBILE_BEHAVIOR_MARKER = 'data-dsh-mobile-behave'
|
|
245
|
-
/** 把兼容脚本、移动端排版样式与行为脚本内联注入 index.html 的 <head> 之后(幂等)。 */
|
|
246
|
-
function injectLanCompat(html) {
|
|
247
|
-
let out = html
|
|
248
|
-
const match = /<head(?:\s[^>]*)?>/i.exec(out)
|
|
249
|
-
const at = match ? match.index + match[0].length : 0
|
|
250
|
-
const parts = []
|
|
251
|
-
if (!out.includes(LAN_COMPAT_MARKER)) parts.push(`<script ${LAN_COMPAT_MARKER}>${LAN_COMPAT_JS}</script>`)
|
|
252
|
-
if (!out.includes(MOBILE_LAYOUT_MARKER)) parts.push(`<style ${MOBILE_LAYOUT_MARKER}>${MOBILE_LAYOUT_CSS}</style>`)
|
|
253
|
-
if (!out.includes(MOBILE_BEHAVIOR_MARKER)) parts.push(`<script ${MOBILE_BEHAVIOR_MARKER}>${MOBILE_BEHAVIOR_JS}</script>`)
|
|
254
|
-
if (!parts.length) return out
|
|
255
|
-
const injected = parts.join('')
|
|
256
|
-
return at > 0 ? out.slice(0, at) + injected + out.slice(at) : injected + out
|
|
257
|
-
}
|
|
258
|
-
function routeMethod(req) {
|
|
259
|
-
const url = new URL(req.url || '/', 'http://dsh.internal')
|
|
260
|
-
return url.pathname.replace(/^\/dsh-dock\/mobile-relay\/?/, '').replace(/\/+$/, '')
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
export const feature = {
|
|
264
|
-
id: 'mobile-relay',
|
|
265
|
-
name: '远程访问',
|
|
266
|
-
description: '账号密码登录的远程入口:局域网/虚拟网设备访问同一个 DSH,任务进度实时一致',
|
|
267
|
-
// 宿主半部必须随插件加载(网关路由/账号服务都在这里),techfunway-dsh 等部署
|
|
268
|
-
// 完全依赖它——保持默认开启。v0.9.6 起面板开关会同步到宿主:显式停用会连网关
|
|
269
|
-
// 一起关掉(并持久化,重启后仍停用),部署环境请勿在面板里关闭本功能。
|
|
270
|
-
defaultEnabled: true,
|
|
271
|
-
setup(ctx) {
|
|
272
|
-
const disposers = []
|
|
273
|
-
let gateway = null
|
|
274
|
-
let gatewayStarting = null
|
|
275
|
-
let settingsCtx = null
|
|
276
|
-
|
|
277
|
-
// 自愈:早期版本把 browse 行平铺写进补丁层——DSH 会把未知 id 的普通补丁行整行
|
|
278
|
-
// 跳过(且 auto 已被停用),结果 directoryPicker 服务无人提供,/api 整面 404,
|
|
279
|
-
// 重启时更会因条目未激活直接起不来。这里把旧形态无损改写成 insert 形态。
|
|
280
|
-
try {
|
|
281
|
-
const file = serverPatchFile()
|
|
282
|
-
const list = readPatchList(file)
|
|
283
|
-
if (list && list.some((row) => row && (row.id === 'directory-picker-browse' || row.id === 'ui-directory-picker-browse'))) {
|
|
284
|
-
writePatchList(file, upsertRemotePatches(list))
|
|
285
|
-
}
|
|
286
|
-
} catch { /* 补丁层不可读时交给 lanStatus 的错误呈现,不在启动路径上放大 */ }
|
|
287
|
-
|
|
288
|
-
disposers.push(ctx.inject(['settings'], (sctx) => { settingsCtx = sctx }))
|
|
289
|
-
|
|
290
|
-
// ── 浏览模式目录选择器的 Windows 跨盘补全 ──
|
|
291
|
-
// 官方 browse 后端从主目录起步,只能列出当前盘内的子目录,面包屑在主目录之上
|
|
292
|
-
// 全部折叠——Windows 开着远程访问时,选工作区根本到不了其他磁盘。
|
|
293
|
-
// 这里在运行时包装 browse 能力的 list():主目录层级注入全部盘符入口,盘根目录
|
|
294
|
-
// 注入其他盘符入口,远程浏览即可像资源管理器一样跨盘选工作区。非 Windows 或
|
|
295
|
-
// 非浏览模式(原生对话框本身可跨盘)零改动。
|
|
296
|
-
const DRIVE_SCAN_TTL = 5000
|
|
297
|
-
let driveScan = { at: 0, letters: null }
|
|
298
|
-
function sameWinPath(a, b) {
|
|
299
|
-
return a.replace(/[\\/]+/g, '\\').toLowerCase() === b.replace(/[\\/]+/g, '\\').toLowerCase()
|
|
300
|
-
}
|
|
301
|
-
function winDriveLetterOf(p) {
|
|
302
|
-
return /^[A-Za-z]:[\\/]?$/.test(p) ? p.slice(0, 1).toUpperCase() : null
|
|
303
|
-
}
|
|
304
|
-
async function windowsDrivePaths() {
|
|
305
|
-
if (driveScan.letters && Date.now() - driveScan.at < DRIVE_SCAN_TTL) return driveScan.letters
|
|
306
|
-
const probes = []
|
|
307
|
-
for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
|
|
308
|
-
probes.push(stat(letter + ':\\').then(() => letter, () => null))
|
|
309
|
-
}
|
|
310
|
-
const letters = (await Promise.all(probes)).filter(Boolean).sort()
|
|
311
|
-
driveScan = { at: Date.now(), letters }
|
|
312
|
-
return letters
|
|
313
|
-
}
|
|
314
|
-
disposers.push(ctx.inject(['directoryPicker'], (dpCtx) => {
|
|
315
|
-
if (process.platform !== 'win32') return
|
|
316
|
-
const picker = dpCtx.directoryPicker
|
|
317
|
-
if (!picker || typeof picker.capability !== 'function') return
|
|
318
|
-
let cap
|
|
319
|
-
try { cap = picker.capability() } catch { return }
|
|
320
|
-
if (!cap || cap.kind !== 'browse' || typeof cap.list !== 'function' || cap.__dshDockDrives) return
|
|
321
|
-
cap.__dshDockDrives = true
|
|
322
|
-
const origList = cap.list
|
|
323
|
-
cap.list = async (path, signal) => {
|
|
324
|
-
const listing = await origList(path, signal)
|
|
325
|
-
try {
|
|
326
|
-
const target = String((listing && listing.path) || '')
|
|
327
|
-
const atHome = sameWinPath(target, String(listing.home || '\u0000'))
|
|
328
|
-
const driveRoot = winDriveLetterOf(target)
|
|
329
|
-
// 只在主目录与盘根两级注入:这两级是跨盘的必经之地,其余层级保持官方原样
|
|
330
|
-
if (atHome || driveRoot) {
|
|
331
|
-
const letters = await windowsDrivePaths()
|
|
332
|
-
const rows = letters
|
|
333
|
-
.filter((letter) => !driveRoot || letter !== driveRoot)
|
|
334
|
-
.map((letter) => ({ name: letter + ':\\', path: letter + ':\\', hidden: false }))
|
|
335
|
-
if (rows.length) listing.entries = atHome ? rows.concat(listing.entries) : listing.entries.concat(rows)
|
|
336
|
-
}
|
|
337
|
-
} catch { /* 盘符探测失败保持原列表,不让补全拖垮选目录 */ }
|
|
338
|
-
return listing
|
|
339
|
-
}
|
|
340
|
-
}))
|
|
341
|
-
|
|
342
|
-
// ── 远程访问账号(settings 持久化;密码只存加盐哈希) ──
|
|
343
|
-
function hashPassword(salt, password) {
|
|
344
|
-
return createHash('sha256').update(String(salt) + '\u0000' + String(password), 'utf8').digest('hex')
|
|
345
|
-
}
|
|
346
|
-
function sameText(a, b) {
|
|
347
|
-
const ba = Buffer.from(String(a || ''))
|
|
348
|
-
const bb = Buffer.from(String(b || ''))
|
|
349
|
-
return ba.length > 0 && ba.length === bb.length && timingSafeEqual(ba, bb)
|
|
350
|
-
}
|
|
351
|
-
function getAuth() {
|
|
352
|
-
if (!settingsCtx) return null
|
|
353
|
-
const value = settingsCtx.settings.get(DOCK_NS)
|
|
354
|
-
const auth = value && value.remoteAuth
|
|
355
|
-
return auth && auth.username && auth.passwordHash && auth.salt ? auth : null
|
|
356
|
-
}
|
|
357
|
-
async function saveAuth(username, password) {
|
|
358
|
-
if (!settingsCtx || typeof settingsCtx.settings.mutate !== 'function') {
|
|
359
|
-
const error = new Error('settings 服务未就绪,无法保存账号')
|
|
360
|
-
error.statusCode = 500
|
|
361
|
-
throw error
|
|
362
|
-
}
|
|
363
|
-
const salt = randomBytes(16).toString('hex')
|
|
364
|
-
await settingsCtx.settings.mutate(DOCK_NS, [{
|
|
365
|
-
op: 'set',
|
|
366
|
-
path: ['remoteAuth'],
|
|
367
|
-
value: { username, salt, passwordHash: hashPassword(salt, password) },
|
|
368
|
-
}])
|
|
369
|
-
// 账号密码变更:作废所有已登录设备,强制重新登录。
|
|
370
|
-
if (gateway) gateway.revokeAllSessions()
|
|
371
|
-
}
|
|
372
|
-
async function verifyLogin(username, password) {
|
|
373
|
-
const auth = getAuth()
|
|
374
|
-
if (!auth) return false
|
|
375
|
-
return sameText(username, auth.username) && sameText(hashPassword(auth.salt, password), auth.passwordHash)
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// 上游会话自动兑换:dsh web 自身还有一层启动令牌认证(首次访问 /?token= 换
|
|
379
|
-
// 30 天会话 Cookie,绑定回环 authority)。网关与主实例同进程,直接取
|
|
380
|
-
// connection.authenticatedUrl 生成的带令牌地址,服务端完成兑换并返回 Cookie
|
|
381
|
-
// 对,由网关在代理时自动附带——远程浏览器只需网关账号登录,无需再手动访问
|
|
382
|
-
// 令牌地址。老版本 dsh 没有 connection 服务时返回空,网关退回官方行为。
|
|
383
|
-
async function mintUpstreamSession(mainPort) {
|
|
384
|
-
try {
|
|
385
|
-
const connection = ctx.get('connection')
|
|
386
|
-
if (!connection || typeof connection.authenticatedUrl !== 'function') return null
|
|
387
|
-
const url = connection.authenticatedUrl(`http://127.0.0.1:${mainPort}`)
|
|
388
|
-
const res = await fetch(url, { redirect: 'manual' })
|
|
389
|
-
const cookies = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [res.headers.get('set-cookie')].filter(Boolean)
|
|
390
|
-
for (const raw of cookies) {
|
|
391
|
-
const pair = String(raw).split(';')[0].trim()
|
|
392
|
-
if (pair.includes('=')) return pair
|
|
393
|
-
}
|
|
394
|
-
return null
|
|
395
|
-
} catch { return null }
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
/** 远程访问状态:网关、账号、浏览模式补丁、旧服务器模式残留。 */
|
|
399
|
-
function lanStatus(webServer) {
|
|
400
|
-
const file = serverPatchFile()
|
|
401
|
-
let applied = false
|
|
402
|
-
let webserverActive = false
|
|
403
|
-
try {
|
|
404
|
-
const list = readPatchList(file)
|
|
405
|
-
applied = serverPatchApplied(list)
|
|
406
|
-
webserverActive = legacyWebserverRowPresent(list)
|
|
407
|
-
} catch { applied = false }
|
|
408
|
-
const auth = getAuth()
|
|
409
|
-
return {
|
|
410
|
-
gatewayActive: Boolean(gateway),
|
|
411
|
-
gatewayPort: gateway ? gateway.port : null,
|
|
412
|
-
mainPort: webServer.port,
|
|
413
|
-
addresses: lanAddresses(),
|
|
414
|
-
patchApplied: applied,
|
|
415
|
-
webserverActive,
|
|
416
|
-
accountSet: Boolean(auth),
|
|
417
|
-
username: auth ? auth.username : '',
|
|
418
|
-
patchPath: file,
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
async function stopGateway() {
|
|
423
|
-
const current = gateway
|
|
424
|
-
gateway = null
|
|
425
|
-
gatewayStarting = null
|
|
426
|
-
if (current) await current.close()
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
/** 远程访问网关:上游即主实例;主实例保持仅本机,远程一律经账号登录进入。 */
|
|
430
|
-
async function ensureGateway(webServer, requestedPort, mintUpstreamCookie) {
|
|
431
|
-
// 默认端口跟主实例走(主端口+1):网关和主服务是同一台机器上的两个监听,
|
|
432
|
-
// 结构上不能同端口;跟随主端口让"哪个口是哪个"一目了然。
|
|
433
|
-
const port = Number(requestedPort === undefined || requestedPort === null || requestedPort === ''
|
|
434
|
-
? webServer.port + 1 : requestedPort)
|
|
435
|
-
if (!Number.isInteger(port) || port < 1024 || port > 65535) {
|
|
436
|
-
const error = new Error('局域网端口需为 1024 到 65535 之间的整数')
|
|
437
|
-
error.statusCode = 400
|
|
438
|
-
throw error
|
|
439
|
-
}
|
|
440
|
-
if (port === webServer.port) {
|
|
441
|
-
const error = new Error(`局域网端口不能与 DSH 主服务端口 ${webServer.port} 相同`)
|
|
442
|
-
error.statusCode = 400
|
|
443
|
-
throw error
|
|
444
|
-
}
|
|
445
|
-
if (gateway && gateway.port === port) return gateway
|
|
446
|
-
if (gateway) {
|
|
447
|
-
const error = new Error(`局域网网关已在 ${gateway.port} 端口运行,请先关闭当前手机连接`)
|
|
448
|
-
error.statusCode = 409
|
|
449
|
-
throw error
|
|
450
|
-
}
|
|
451
|
-
// 上游即主实例:单实例架构下所有设备看到的就是同一个进程,任务状态天然同步。
|
|
452
|
-
// 主实例保持仅本机 → 网关把 Host/Origin 改写成回环(回环恒被信任),
|
|
453
|
-
// 远程设备必须先经过账号登录,不存在绕过登录的直连路径。
|
|
454
|
-
if (!gatewayStarting) {
|
|
455
|
-
gatewayStarting = startProtectedLanGateway({
|
|
456
|
-
port,
|
|
457
|
-
upstreamHost: '127.0.0.1',
|
|
458
|
-
upstreamPort: webServer.port,
|
|
459
|
-
spoofLoopback: true,
|
|
460
|
-
verifyLogin,
|
|
461
|
-
mintUpstreamCookie: typeof mintUpstreamCookie === 'function' ? mintUpstreamCookie : undefined,
|
|
462
|
-
})
|
|
463
|
-
.then((started) => { gateway = started; gatewayStarting = null; return started })
|
|
464
|
-
.catch((error) => { gatewayStarting = null; throw error })
|
|
465
|
-
}
|
|
466
|
-
try { return await gatewayStarting }
|
|
467
|
-
catch (cause) {
|
|
468
|
-
const error = new Error(cause && cause.code === 'EADDRINUSE'
|
|
469
|
-
? `端口 ${port} 已被占用,请换一个端口`
|
|
470
|
-
: '无法开启局域网网关:' + ((cause && cause.message) || String(cause)))
|
|
471
|
-
error.statusCode = 400
|
|
472
|
-
throw error
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
disposers.push(ctx.inject(['webServer'], (wsCtx) => {
|
|
478
|
-
// 服务器模式下的主实例同样服务局域网浏览器:注入 crypto.randomUUID 兜底,
|
|
479
|
-
// 修复非安全上下文下 DSH 客户端引导失败。回环/安全上下文下脚本自我短路,
|
|
480
|
-
// 对本机使用零副作用。
|
|
481
|
-
if (typeof wsCtx.webServer.tapIndex === 'function') {
|
|
482
|
-
disposers.push(wsCtx.effect(() => wsCtx.webServer.tapIndex(injectLanCompat)))
|
|
483
|
-
}
|
|
484
|
-
wsCtx.effect(() => wsCtx.webServer.register({
|
|
485
|
-
kind: 'prefix',
|
|
486
|
-
path: '/dsh-dock/mobile-relay',
|
|
487
|
-
async handler(req, res) {
|
|
488
|
-
try {
|
|
489
|
-
const method = routeMethod(req)
|
|
490
|
-
const payload = await readBody(req)
|
|
491
|
-
if (method === 'lan') {
|
|
492
|
-
return sendJson(res, 200, { ok: true, data: lanStatus(wsCtx.webServer) })
|
|
493
|
-
}
|
|
494
|
-
if (method === 'auth/set') {
|
|
495
|
-
// 设置/修改远程访问账号密码;改完作废所有已登录会话。
|
|
496
|
-
const username = String((payload && payload.username) || '').trim()
|
|
497
|
-
const password = String((payload && payload.password) || '')
|
|
498
|
-
if (!username || username.length > 64) {
|
|
499
|
-
const error = new Error('账号需为 1 到 64 个字符')
|
|
500
|
-
error.statusCode = 400
|
|
501
|
-
throw error
|
|
502
|
-
}
|
|
503
|
-
if (password.length < 6 || password.length > 128) {
|
|
504
|
-
const error = new Error('密码至少 6 位、至多 128 位')
|
|
505
|
-
error.statusCode = 400
|
|
506
|
-
throw error
|
|
507
|
-
}
|
|
508
|
-
await saveAuth(username, password)
|
|
509
|
-
return sendJson(res, 200, { ok: true, data: { ...lanStatus(wsCtx.webServer) } })
|
|
510
|
-
}
|
|
511
|
-
if (method === 'lan/start') {
|
|
512
|
-
// 开启远程访问:网关监听 0.0.0.0(唯一远程入口,登录后才放行);
|
|
513
|
-
// 同时移除旧服务器模式行(主实例必须回到仅本机)并把目录选择器钉为浏览模式。
|
|
514
|
-
const username = String((payload && payload.username) || '').trim()
|
|
515
|
-
const password = String((payload && payload.password) || '')
|
|
516
|
-
if (username && password) {
|
|
517
|
-
if (!username || username.length > 64) {
|
|
518
|
-
const error = new Error('账号需为 1 到 64 个字符')
|
|
519
|
-
error.statusCode = 400
|
|
520
|
-
throw error
|
|
521
|
-
}
|
|
522
|
-
if (password.length < 6 || password.length > 128) {
|
|
523
|
-
const error = new Error('密码至少 6 位、至多 128 位')
|
|
524
|
-
error.statusCode = 400
|
|
525
|
-
throw error
|
|
526
|
-
}
|
|
527
|
-
await saveAuth(username, password)
|
|
528
|
-
}
|
|
529
|
-
if (!getAuth()) {
|
|
530
|
-
const error = new Error('请先设置远程访问的账号和密码')
|
|
531
|
-
error.statusCode = 400
|
|
532
|
-
throw error
|
|
533
|
-
}
|
|
534
|
-
const file = serverPatchFile()
|
|
535
|
-
const list = readPatchList(file) || []
|
|
536
|
-
const changed = !serverPatchApplied(list) || legacyWebserverRowPresent(list)
|
|
537
|
-
writePatchList(file, upsertRemotePatches(list))
|
|
538
|
-
await ensureGateway(wsCtx.webServer, payload && payload.port, () => mintUpstreamSession(wsCtx.webServer.port))
|
|
539
|
-
return sendJson(res, 200, { ok: true, data: { ...lanStatus(wsCtx.webServer), needsRestart: changed } })
|
|
540
|
-
}
|
|
541
|
-
if (method === 'lan/stop') {
|
|
542
|
-
const file = serverPatchFile()
|
|
543
|
-
const list = readPatchList(file)
|
|
544
|
-
await stopGateway()
|
|
545
|
-
if (list === null) {
|
|
546
|
-
return sendJson(res, 200, { ok: true, data: { ...lanStatus(wsCtx.webServer), needsRestart: false } })
|
|
547
|
-
}
|
|
548
|
-
const changed = serverPatchApplied(list) || legacyWebserverRowPresent(list)
|
|
549
|
-
if (changed) writePatchList(file, removeRemotePatches(list))
|
|
550
|
-
return sendJson(res, 200, { ok: true, data: { ...lanStatus(wsCtx.webServer), needsRestart: changed } })
|
|
551
|
-
}
|
|
552
|
-
return sendJson(res, 404, { ok: false, error: { code: 'method-not-found', message: 'unknown method: ' + method } })
|
|
553
|
-
} catch (error) {
|
|
554
|
-
const status = error && error.statusCode ? error.statusCode : 500
|
|
555
|
-
if (status >= 500) console.error('[dsh-dock] mobile relay HTTP error:', error && error.message)
|
|
556
|
-
return sendJson(res, status, { ok: false, error: { code: status >= 500 ? 'internal' : 'bad-request', message: (error && error.message) || String(error) } })
|
|
557
|
-
}
|
|
558
|
-
},
|
|
559
|
-
}), 'dsh-dock mobile relay: /dsh-dock/mobile-relay HTTP route')
|
|
560
|
-
}))
|
|
561
|
-
|
|
562
|
-
return () => {
|
|
563
|
-
while (disposers.length) {
|
|
564
|
-
const dispose = disposers.pop()
|
|
565
|
-
try { if (typeof dispose === 'function') dispose() } catch { /* ignore */ }
|
|
566
|
-
}
|
|
567
|
-
void stopGateway()
|
|
568
|
-
}
|
|
569
|
-
},
|
|
570
|
-
}
|
|
1
|
+
// dsh-dock · 功能模块【手机接力】· 宿主半部
|
|
2
|
+
//
|
|
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 { stat } from 'node:fs/promises'
|
|
20
|
+
import { homedir } from 'node:os'
|
|
21
|
+
import { dirname, join } from 'node:path'
|
|
22
|
+
import yaml from 'js-yaml'
|
|
23
|
+
import { DOCK_NS, readBody, sendJson } from '../../src/host-core.js'
|
|
24
|
+
import { lanAddresses, startProtectedLanGateway } from './gateway.js'
|
|
25
|
+
|
|
26
|
+
function dshHome() { return process.env.DSH_HOME || join(homedir(), '.dsh') }
|
|
27
|
+
/** 远程访问开关所在的用户补丁层(web profile 专属;测试可用环境变量指到临时文件)。 */
|
|
28
|
+
function serverPatchFile() {
|
|
29
|
+
return process.env.DSH_DOCK_PATCH_FILE || join(dshHome(), 'profiles', 'web', 'cordis.patch.yml')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 与 DSH include 插件同款的 !!js 表达式标签:用户补丁层可能含 !!js 表达式,
|
|
33
|
+
// 读写必须无损往返,否则一次开关操作就会破坏用户手工配置。
|
|
34
|
+
const JsExprType = new yaml.Type('tag:yaml.org,2002:js', {
|
|
35
|
+
kind: 'scalar',
|
|
36
|
+
resolve: (data) => typeof data === 'string',
|
|
37
|
+
construct: (data) => ({ __jsExpr: data }),
|
|
38
|
+
represent: (data) => data['__jsExpr'],
|
|
39
|
+
})
|
|
40
|
+
const patchYamlSchema = yaml.JSON_SCHEMA.extend(JsExprType)
|
|
41
|
+
|
|
42
|
+
function readPatchList(file) {
|
|
43
|
+
let text
|
|
44
|
+
try {
|
|
45
|
+
text = readFileSync(file, 'utf8')
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error && error.code === 'ENOENT') return null
|
|
48
|
+
const err = new Error('读取局域网服务补丁失败:' + ((error && error.message) || String(error)))
|
|
49
|
+
err.statusCode = 500
|
|
50
|
+
throw err
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const data = yaml.load(text, { schema: patchYamlSchema })
|
|
54
|
+
return Array.isArray(data) ? data : []
|
|
55
|
+
} catch (error) {
|
|
56
|
+
const err = new Error('局域网服务补丁不是合法的补丁列表:' + ((error && error.message) || String(error)))
|
|
57
|
+
err.statusCode = 500
|
|
58
|
+
throw err
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function writePatchList(file, list) {
|
|
62
|
+
try {
|
|
63
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
64
|
+
writeFileSync(file, yaml.dump(list, { schema: patchYamlSchema }), 'utf8')
|
|
65
|
+
} catch (error) {
|
|
66
|
+
const err = new Error('写入局域网服务补丁失败:' + ((error && error.message) || String(error)))
|
|
67
|
+
err.statusCode = 500
|
|
68
|
+
throw err
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// 补丁层的扁平行视图:insert 块({insert:[…]})展开成行,其余原样。
|
|
72
|
+
// DSH 补丁语义里,非 insert 行只能改已存在 id 的 config/disabled——想新增行必须包
|
|
73
|
+
// insert(applyEntryPatches 对未知 id 的普通行只告警后跳过,对 name 不一致的行同样
|
|
74
|
+
// 整行跳过)。展开后统一按行检测/清理,兼容旧版写坏的平铺文件。
|
|
75
|
+
function patchRows(list) {
|
|
76
|
+
if (!Array.isArray(list)) return []
|
|
77
|
+
return list.flatMap((row) => (row && Array.isArray(row.insert)) ? row.insert : [row])
|
|
78
|
+
}
|
|
79
|
+
function serverPatchApplied(list) {
|
|
80
|
+
// 远程访问补丁:目录选择器钉死为浏览模式(远程设备不能弹本机对话框)。
|
|
81
|
+
return patchRows(list).some((row) => row && row.id === 'directory-picker-browse')
|
|
82
|
+
}
|
|
83
|
+
function legacyWebserverRowPresent(list) {
|
|
84
|
+
// 旧「服务器模式」残留:0.0.0.0 的 webserver 覆盖行。账号认证架构下主实例必须回到仅本机,
|
|
85
|
+
// 否则局域网设备可以绕过登录网关直连。
|
|
86
|
+
return patchRows(list).some((row) => row && row.id === 'webserver'
|
|
87
|
+
&& row.config && row.config.host === '0.0.0.0')
|
|
88
|
+
}
|
|
89
|
+
function stripRemoteRows(list) {
|
|
90
|
+
// 清掉全部远程访问相关行:平铺的 browse 行、webserver 行、auto 钉死行,以及
|
|
91
|
+
// insert 块里的 browse 行(块清空则整块移除,块里混有用户自己的行则只摘除 browse)。
|
|
92
|
+
return list
|
|
93
|
+
.map((row) => {
|
|
94
|
+
if (!(row && Array.isArray(row.insert))) return row
|
|
95
|
+
const rest = row.insert.filter((item) => !(item && (item.id === 'directory-picker-browse' || item.id === 'ui-directory-picker-browse')))
|
|
96
|
+
return rest.length ? { insert: rest } : null
|
|
97
|
+
})
|
|
98
|
+
.filter((row) => row && !(row.id === 'webserver'
|
|
99
|
+
|| row.id === 'directory-picker' || row.id === 'directory-picker-browse' || row.id === 'ui-directory-picker-browse'))
|
|
100
|
+
}
|
|
101
|
+
function upsertRemotePatches(list) {
|
|
102
|
+
const cleaned = stripRemoteRows(list)
|
|
103
|
+
cleaned.push(
|
|
104
|
+
{ id: 'directory-picker', disabled: true },
|
|
105
|
+
{ insert: [
|
|
106
|
+
{ id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' },
|
|
107
|
+
{ id: 'ui-directory-picker-browse', name: '@deepseek-ai/dsh-client-ui-directory-picker-browse' },
|
|
108
|
+
] },
|
|
109
|
+
)
|
|
110
|
+
return cleaned
|
|
111
|
+
}
|
|
112
|
+
function removeRemotePatches(list) {
|
|
113
|
+
return stripRemoteRows(list)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// LAN HTTP 不是浏览器安全上下文:部分浏览器在 http://<局域网IP> 下只暴露
|
|
117
|
+
// crypto.getRandomValues() 而省略 crypto.randomUUID(),而 DSH 的 connection /
|
|
118
|
+
// conversation 客户端直接调用 randomUUID() 生成消息 ID 与 RPC ID——缺失时整个
|
|
119
|
+
// 客户端引导失败(会话列表、工作区等全部不可用)。下面这段与手机接力网关同款、
|
|
120
|
+
// 局域网 http 不是浏览器安全上下文:部分浏览器(老内核/加固内核)在 http://<局域网IP>
|
|
121
|
+
// 下缺失 crypto.randomUUID(DSH 的 connection/conversation 客户端生成消息与 RPC ID
|
|
122
|
+
// 直接调用它,缺失则整个客户端瘫痪——会话列表、设置目录、工作区全不可用)。
|
|
123
|
+
// 这段 ES5 兜底无前置条件:randomUUID 与 getRandomValues 都补齐(实例属性 +
|
|
124
|
+
// Crypto.prototype 双路径);crypto 被整体冻结的极端浏览器则替换整个对象。
|
|
125
|
+
// 回环/安全上下文下 randomUUID 原生存在,脚本自我短路零副作用。
|
|
126
|
+
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){}}}})()"
|
|
127
|
+
const LAN_COMPAT_MARKER = 'data-dsh-lan-compat'
|
|
128
|
+
// 手机端排版补丁(窄屏媒体查询护栏,桌面零影响):
|
|
129
|
+
// 1) 设置弹窗官方只有"左导航+右内容"两栏,窄屏内容栏被压到不足百像素(一字一行);
|
|
130
|
+
// 改纵向堆叠:导航横排在上、内容占满。
|
|
131
|
+
// 2) 侧边栏展开是 grid 栅格挤占会话区(窄屏会话区只剩 ~110px);改为浮层覆盖。
|
|
132
|
+
// 3) 侧栏栅格归零后,原生「打开侧边栏」按钮随窄轨一起被裁掉——补贴左边缘的抽屉把手
|
|
133
|
+
// (.dsh-mobile-drawer-btn,行为脚本创建,点击转发原生 toggle,位置在趣味游戏
|
|
134
|
+
// 浮标正上方、兜底左侧中部)与抽屉遮罩(.dsh-mobile-scrim,点击即收起,阻断滚动穿透)。
|
|
135
|
+
// 类名用语义后缀匹配(哈希前缀随 DSH 版本会变,后缀稳定);DSH 升级改了结构时
|
|
136
|
+
// 选择器自然失效,不影响其他功能。
|
|
137
|
+
const MOBILE_LAYOUT_CSS = [
|
|
138
|
+
'@media (max-width:700px){',
|
|
139
|
+
' [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}',
|
|
140
|
+
' [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)}',
|
|
141
|
+
' [role="dialog"][class*="panel"] [class*="navList"]{flex-direction:row;overflow-x:auto;gap:2px}',
|
|
142
|
+
' [role="dialog"][class*="panel"] [class*="navTitle"]{flex:none;white-space:nowrap;margin-right:6px}',
|
|
143
|
+
' [role="dialog"][class*="panel"]>[class*="nav"] [class*="navCell"]{flex:none}',
|
|
144
|
+
' [role="dialog"][class*="panel"]>[class*="content"]{flex:1 1 auto!important;width:auto!important;min-width:0!important;max-width:none!important}',
|
|
145
|
+
' [class$="frame"]{grid-template-columns:0px minmax(0,1fr) 0px!important}',
|
|
146
|
+
' [class$="_handle"]{display:none!important}',
|
|
147
|
+
' [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)}',
|
|
148
|
+
' [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)}',
|
|
149
|
+
' [class*="overlayLayer"]{z-index:90!important}',
|
|
150
|
+
// 抽屉把手:贴左边缘(同趣味游戏浮标的边缘吸附语言),top 由 place() 动态设定
|
|
151
|
+
// (趣味游戏浮标正上方,兜底左侧中部);z 序 70 < 遮罩 75 < 侧栏 80 < overlayLayer 90,
|
|
152
|
+
// 面板/弹窗打开时自然被盖,无需额外隐藏逻辑。
|
|
153
|
+
' .dsh-mobile-drawer-btn{position:fixed;left:0;top:38vh;z-index:70;box-sizing:border-box;width:30px;height:48px;padding:0;display:flex;align-items:center;justify-content:center;border:1px solid var(--dsw-alias-border-l1,rgba(127,139,161,.35));border-left:none;border-radius:0 12px 12px 0;background:color-mix(in srgb,var(--dsw-alias-bg-layer-2,#1c2230) 88%,transparent);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);color:var(--dsw-alias-label-primary,#e6eaf2);box-shadow:4px 0 18px rgba(0,0,0,.28);cursor:pointer;touch-action:manipulation;-webkit-tap-highlight-color:transparent}',
|
|
154
|
+
' .dsh-mobile-drawer-btn:active{transform:scale(.94)}',
|
|
155
|
+
' .dsh-mobile-drawer-btn svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}',
|
|
156
|
+
' .dsh-mobile-scrim{position:fixed;inset:0;z-index:75;background:rgba(8,10,14,.45);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);touch-action:none}',
|
|
157
|
+
'}',
|
|
158
|
+
].join('\n')
|
|
159
|
+
const MOBILE_LAYOUT_MARKER = 'data-dsh-mobile-layout'
|
|
160
|
+
// 窄屏行为补丁:侧边栏浮层化后,原生的"保持展开"会一直挡住半屏——
|
|
161
|
+
// 点会话行/新会话后自动收起;点侧栏外的页面区域也收起(浮层语义)。
|
|
162
|
+
// 侧栏内的其他点击(工作区折叠、搜索、功能坞入口)保持原生行为不收。
|
|
163
|
+
// 另创建抽屉按钮(窄屏 + 侧栏收起时才显示,点击转发原生「打开侧边栏」toggle;
|
|
164
|
+
// 侧栏展开或设置弹窗打开时自动隐藏)与抽屉遮罩(点击经全局捕获 handler 收起,
|
|
165
|
+
// 单一路径防止同一事件两次 toggle)。注入点在 <head> 后,body 尚未解析,
|
|
166
|
+
// DOM 创建一律推迟到 DOMContentLoaded;显隐同步走 MutationObserver(属性翻转即时)
|
|
167
|
+
// + 低速轮询(覆盖路由重渲等一切边角)。
|
|
168
|
+
const MOBILE_BEHAVIOR_JS = [
|
|
169
|
+
';(function(){',
|
|
170
|
+
'var mq=window.matchMedia?window.matchMedia("(max-width:700px)"):null;',
|
|
171
|
+
'if(!mq)return;',
|
|
172
|
+
// 与 dsh-dock 客户端 Overlay 版(features/mobile-relay/view.jsx,刷新即生效)互斥:
|
|
173
|
+
// 本脚本在 <head> 先执行,存在即接管;客户端版看到旗标自动让位。
|
|
174
|
+
'if(window.__dshDockMobileDrawer)return;',
|
|
175
|
+
'window.__dshDockMobileDrawer="host";',
|
|
176
|
+
'function narrow(){return mq.matches}',
|
|
177
|
+
'function frameEl(){return document.querySelector(\'[class$="frame"]\')}',
|
|
178
|
+
'function sidebarCollapsed(){var f=frameEl();return !!f&&f.hasAttribute("data-sidebar-collapsed")}',
|
|
179
|
+
'function dialogOpen(){return !!document.querySelector(\'[role="dialog"][class*="panel"]\')}',
|
|
180
|
+
'function collapse(){var b=document.querySelector(\'button[aria-label="收起侧边栏"],button[aria-label="Collapse sidebar"]\');if(b)b.click()}',
|
|
181
|
+
'function expand(){var b=document.querySelector(\'button[aria-label="打开侧边栏"],button[aria-label="Open sidebar"]\');if(b)b.click()}',
|
|
182
|
+
'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()}',
|
|
183
|
+
'var fab=null,scrim=null;',
|
|
184
|
+
'function ensureChrome(){',
|
|
185
|
+
' if(!fab){fab=document.createElement("button");fab.type="button";fab.className="dsh-mobile-drawer-btn";fab.setAttribute("aria-label","打开会话列表");fab.innerHTML=\'<svg viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h10"/></svg>\';fab.addEventListener("click",function(){expand()});document.body.appendChild(fab)}',
|
|
186
|
+
' if(!scrim){scrim=document.createElement("div");scrim.className="dsh-mobile-scrim";document.body.appendChild(scrim)}',
|
|
187
|
+
'}',
|
|
188
|
+
// 把手定位:贴着趣味游戏浮标(.dgfab,可拖拽)正上方;浮标太靠上时改放它下面
|
|
189
|
+
// (避开顶栏标题),被拖走/隐藏/不存在时兜底左侧中部(38% 视高)。
|
|
190
|
+
'function place(){',
|
|
191
|
+
' if(!fab)return;',
|
|
192
|
+
' var vh=window.innerHeight,top=null;',
|
|
193
|
+
' var g=document.querySelector(".dgfab");',
|
|
194
|
+
' if(g&&!g.classList.contains("dgfab-hide")&&g.getBoundingClientRect){',
|
|
195
|
+
' var r=g.getBoundingClientRect();',
|
|
196
|
+
' if(r&&r.height>0&&r.top>0){',
|
|
197
|
+
' top=r.top-fab.offsetHeight-8;',
|
|
198
|
+
' if(top<64)top=r.bottom+8;',
|
|
199
|
+
' if(top+fab.offsetHeight>vh-16)top=null;',
|
|
200
|
+
' }',
|
|
201
|
+
' }',
|
|
202
|
+
' if(top==null)top=Math.round(vh*.38);',
|
|
203
|
+
' fab.style.top=top+"px";',
|
|
204
|
+
'}',
|
|
205
|
+
'function syncChrome(){',
|
|
206
|
+
' if(!fab||!scrim)return;',
|
|
207
|
+
' var on=narrow()&&!!frameEl()&&!dialogOpen();',
|
|
208
|
+
' var col=sidebarCollapsed();',
|
|
209
|
+
' var showFab=on&&col;',
|
|
210
|
+
' fab.style.display=showFab?"":"none";',
|
|
211
|
+
' if(showFab)place();',
|
|
212
|
+
' scrim.style.display=on&&!col?"":"none";',
|
|
213
|
+
'}',
|
|
214
|
+
'function boot(){',
|
|
215
|
+
' ensureChrome();syncChrome();',
|
|
216
|
+
' if(typeof MutationObserver!=="undefined"){new MutationObserver(function(){syncChrome()}).observe(document.documentElement,{attributes:true,attributeFilter:["data-sidebar-collapsed"],subtree:true})}',
|
|
217
|
+
' setInterval(syncChrome,1500);',
|
|
218
|
+
' var onMq=function(){syncChrome()};',
|
|
219
|
+
' if(mq.addEventListener)mq.addEventListener("change",onMq);else if(mq.addListener)mq.addListener(onMq);',
|
|
220
|
+
' window.addEventListener("resize",place);',
|
|
221
|
+
'}',
|
|
222
|
+
'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",boot);else boot();',
|
|
223
|
+
'document.addEventListener("click",function(e){',
|
|
224
|
+
' if(!narrow())return;',
|
|
225
|
+
' var t=e.target;',
|
|
226
|
+
' if(!t||!t.closest)return;',
|
|
227
|
+
' if(t.closest(".dsh-mobile-drawer-btn"))return;',
|
|
228
|
+
' var expanded=!!document.querySelector(\'button[aria-label="收起侧边栏"],button[aria-label="Collapse sidebar"]\');',
|
|
229
|
+
' var col=document.querySelector(\'[class*="sidebarCol"]\');',
|
|
230
|
+
' var inSidebar=col&&col.contains(t);',
|
|
231
|
+
' if(!expanded&&!inSidebar)return;',
|
|
232
|
+
' if(inSidebar){',
|
|
233
|
+
' var row=t.closest(\'[role="treeitem"]\');',
|
|
234
|
+
' var leaf=row&&!row.querySelector(\'[role="treeitem"]\')&&row.closest(\'[role="tree"]\');',
|
|
235
|
+
' var fresh=t.closest(\'[class*="newSession"]\');',
|
|
236
|
+
' if(leaf||fresh)setTimeout(function(){collapse();closeDetails()},300);',
|
|
237
|
+
' return',
|
|
238
|
+
' }',
|
|
239
|
+
' if(t.closest(\'[role="dialog"],[class*="dockm"],[class*="dgfab"],[class*="dgwin"],[class*="dgame"],[class*="detailsCol"]\'))return;',
|
|
240
|
+
' collapse()',
|
|
241
|
+
'},true)',
|
|
242
|
+
'})();',
|
|
243
|
+
].join('\n')
|
|
244
|
+
const MOBILE_BEHAVIOR_MARKER = 'data-dsh-mobile-behave'
|
|
245
|
+
/** 把兼容脚本、移动端排版样式与行为脚本内联注入 index.html 的 <head> 之后(幂等)。 */
|
|
246
|
+
function injectLanCompat(html) {
|
|
247
|
+
let out = html
|
|
248
|
+
const match = /<head(?:\s[^>]*)?>/i.exec(out)
|
|
249
|
+
const at = match ? match.index + match[0].length : 0
|
|
250
|
+
const parts = []
|
|
251
|
+
if (!out.includes(LAN_COMPAT_MARKER)) parts.push(`<script ${LAN_COMPAT_MARKER}>${LAN_COMPAT_JS}</script>`)
|
|
252
|
+
if (!out.includes(MOBILE_LAYOUT_MARKER)) parts.push(`<style ${MOBILE_LAYOUT_MARKER}>${MOBILE_LAYOUT_CSS}</style>`)
|
|
253
|
+
if (!out.includes(MOBILE_BEHAVIOR_MARKER)) parts.push(`<script ${MOBILE_BEHAVIOR_MARKER}>${MOBILE_BEHAVIOR_JS}</script>`)
|
|
254
|
+
if (!parts.length) return out
|
|
255
|
+
const injected = parts.join('')
|
|
256
|
+
return at > 0 ? out.slice(0, at) + injected + out.slice(at) : injected + out
|
|
257
|
+
}
|
|
258
|
+
function routeMethod(req) {
|
|
259
|
+
const url = new URL(req.url || '/', 'http://dsh.internal')
|
|
260
|
+
return url.pathname.replace(/^\/dsh-dock\/mobile-relay\/?/, '').replace(/\/+$/, '')
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export const feature = {
|
|
264
|
+
id: 'mobile-relay',
|
|
265
|
+
name: '远程访问',
|
|
266
|
+
description: '账号密码登录的远程入口:局域网/虚拟网设备访问同一个 DSH,任务进度实时一致',
|
|
267
|
+
// 宿主半部必须随插件加载(网关路由/账号服务都在这里),techfunway-dsh 等部署
|
|
268
|
+
// 完全依赖它——保持默认开启。v0.9.6 起面板开关会同步到宿主:显式停用会连网关
|
|
269
|
+
// 一起关掉(并持久化,重启后仍停用),部署环境请勿在面板里关闭本功能。
|
|
270
|
+
defaultEnabled: true,
|
|
271
|
+
setup(ctx) {
|
|
272
|
+
const disposers = []
|
|
273
|
+
let gateway = null
|
|
274
|
+
let gatewayStarting = null
|
|
275
|
+
let settingsCtx = null
|
|
276
|
+
|
|
277
|
+
// 自愈:早期版本把 browse 行平铺写进补丁层——DSH 会把未知 id 的普通补丁行整行
|
|
278
|
+
// 跳过(且 auto 已被停用),结果 directoryPicker 服务无人提供,/api 整面 404,
|
|
279
|
+
// 重启时更会因条目未激活直接起不来。这里把旧形态无损改写成 insert 形态。
|
|
280
|
+
try {
|
|
281
|
+
const file = serverPatchFile()
|
|
282
|
+
const list = readPatchList(file)
|
|
283
|
+
if (list && list.some((row) => row && (row.id === 'directory-picker-browse' || row.id === 'ui-directory-picker-browse'))) {
|
|
284
|
+
writePatchList(file, upsertRemotePatches(list))
|
|
285
|
+
}
|
|
286
|
+
} catch { /* 补丁层不可读时交给 lanStatus 的错误呈现,不在启动路径上放大 */ }
|
|
287
|
+
|
|
288
|
+
disposers.push(ctx.inject(['settings'], (sctx) => { settingsCtx = sctx }))
|
|
289
|
+
|
|
290
|
+
// ── 浏览模式目录选择器的 Windows 跨盘补全 ──
|
|
291
|
+
// 官方 browse 后端从主目录起步,只能列出当前盘内的子目录,面包屑在主目录之上
|
|
292
|
+
// 全部折叠——Windows 开着远程访问时,选工作区根本到不了其他磁盘。
|
|
293
|
+
// 这里在运行时包装 browse 能力的 list():主目录层级注入全部盘符入口,盘根目录
|
|
294
|
+
// 注入其他盘符入口,远程浏览即可像资源管理器一样跨盘选工作区。非 Windows 或
|
|
295
|
+
// 非浏览模式(原生对话框本身可跨盘)零改动。
|
|
296
|
+
const DRIVE_SCAN_TTL = 5000
|
|
297
|
+
let driveScan = { at: 0, letters: null }
|
|
298
|
+
function sameWinPath(a, b) {
|
|
299
|
+
return a.replace(/[\\/]+/g, '\\').toLowerCase() === b.replace(/[\\/]+/g, '\\').toLowerCase()
|
|
300
|
+
}
|
|
301
|
+
function winDriveLetterOf(p) {
|
|
302
|
+
return /^[A-Za-z]:[\\/]?$/.test(p) ? p.slice(0, 1).toUpperCase() : null
|
|
303
|
+
}
|
|
304
|
+
async function windowsDrivePaths() {
|
|
305
|
+
if (driveScan.letters && Date.now() - driveScan.at < DRIVE_SCAN_TTL) return driveScan.letters
|
|
306
|
+
const probes = []
|
|
307
|
+
for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
|
|
308
|
+
probes.push(stat(letter + ':\\').then(() => letter, () => null))
|
|
309
|
+
}
|
|
310
|
+
const letters = (await Promise.all(probes)).filter(Boolean).sort()
|
|
311
|
+
driveScan = { at: Date.now(), letters }
|
|
312
|
+
return letters
|
|
313
|
+
}
|
|
314
|
+
disposers.push(ctx.inject(['directoryPicker'], (dpCtx) => {
|
|
315
|
+
if (process.platform !== 'win32') return
|
|
316
|
+
const picker = dpCtx.directoryPicker
|
|
317
|
+
if (!picker || typeof picker.capability !== 'function') return
|
|
318
|
+
let cap
|
|
319
|
+
try { cap = picker.capability() } catch { return }
|
|
320
|
+
if (!cap || cap.kind !== 'browse' || typeof cap.list !== 'function' || cap.__dshDockDrives) return
|
|
321
|
+
cap.__dshDockDrives = true
|
|
322
|
+
const origList = cap.list
|
|
323
|
+
cap.list = async (path, signal) => {
|
|
324
|
+
const listing = await origList(path, signal)
|
|
325
|
+
try {
|
|
326
|
+
const target = String((listing && listing.path) || '')
|
|
327
|
+
const atHome = sameWinPath(target, String(listing.home || '\u0000'))
|
|
328
|
+
const driveRoot = winDriveLetterOf(target)
|
|
329
|
+
// 只在主目录与盘根两级注入:这两级是跨盘的必经之地,其余层级保持官方原样
|
|
330
|
+
if (atHome || driveRoot) {
|
|
331
|
+
const letters = await windowsDrivePaths()
|
|
332
|
+
const rows = letters
|
|
333
|
+
.filter((letter) => !driveRoot || letter !== driveRoot)
|
|
334
|
+
.map((letter) => ({ name: letter + ':\\', path: letter + ':\\', hidden: false }))
|
|
335
|
+
if (rows.length) listing.entries = atHome ? rows.concat(listing.entries) : listing.entries.concat(rows)
|
|
336
|
+
}
|
|
337
|
+
} catch { /* 盘符探测失败保持原列表,不让补全拖垮选目录 */ }
|
|
338
|
+
return listing
|
|
339
|
+
}
|
|
340
|
+
}))
|
|
341
|
+
|
|
342
|
+
// ── 远程访问账号(settings 持久化;密码只存加盐哈希) ──
|
|
343
|
+
function hashPassword(salt, password) {
|
|
344
|
+
return createHash('sha256').update(String(salt) + '\u0000' + String(password), 'utf8').digest('hex')
|
|
345
|
+
}
|
|
346
|
+
function sameText(a, b) {
|
|
347
|
+
const ba = Buffer.from(String(a || ''))
|
|
348
|
+
const bb = Buffer.from(String(b || ''))
|
|
349
|
+
return ba.length > 0 && ba.length === bb.length && timingSafeEqual(ba, bb)
|
|
350
|
+
}
|
|
351
|
+
function getAuth() {
|
|
352
|
+
if (!settingsCtx) return null
|
|
353
|
+
const value = settingsCtx.settings.get(DOCK_NS)
|
|
354
|
+
const auth = value && value.remoteAuth
|
|
355
|
+
return auth && auth.username && auth.passwordHash && auth.salt ? auth : null
|
|
356
|
+
}
|
|
357
|
+
async function saveAuth(username, password) {
|
|
358
|
+
if (!settingsCtx || typeof settingsCtx.settings.mutate !== 'function') {
|
|
359
|
+
const error = new Error('settings 服务未就绪,无法保存账号')
|
|
360
|
+
error.statusCode = 500
|
|
361
|
+
throw error
|
|
362
|
+
}
|
|
363
|
+
const salt = randomBytes(16).toString('hex')
|
|
364
|
+
await settingsCtx.settings.mutate(DOCK_NS, [{
|
|
365
|
+
op: 'set',
|
|
366
|
+
path: ['remoteAuth'],
|
|
367
|
+
value: { username, salt, passwordHash: hashPassword(salt, password) },
|
|
368
|
+
}])
|
|
369
|
+
// 账号密码变更:作废所有已登录设备,强制重新登录。
|
|
370
|
+
if (gateway) gateway.revokeAllSessions()
|
|
371
|
+
}
|
|
372
|
+
async function verifyLogin(username, password) {
|
|
373
|
+
const auth = getAuth()
|
|
374
|
+
if (!auth) return false
|
|
375
|
+
return sameText(username, auth.username) && sameText(hashPassword(auth.salt, password), auth.passwordHash)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// 上游会话自动兑换:dsh web 自身还有一层启动令牌认证(首次访问 /?token= 换
|
|
379
|
+
// 30 天会话 Cookie,绑定回环 authority)。网关与主实例同进程,直接取
|
|
380
|
+
// connection.authenticatedUrl 生成的带令牌地址,服务端完成兑换并返回 Cookie
|
|
381
|
+
// 对,由网关在代理时自动附带——远程浏览器只需网关账号登录,无需再手动访问
|
|
382
|
+
// 令牌地址。老版本 dsh 没有 connection 服务时返回空,网关退回官方行为。
|
|
383
|
+
async function mintUpstreamSession(mainPort) {
|
|
384
|
+
try {
|
|
385
|
+
const connection = ctx.get('connection')
|
|
386
|
+
if (!connection || typeof connection.authenticatedUrl !== 'function') return null
|
|
387
|
+
const url = connection.authenticatedUrl(`http://127.0.0.1:${mainPort}`)
|
|
388
|
+
const res = await fetch(url, { redirect: 'manual' })
|
|
389
|
+
const cookies = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [res.headers.get('set-cookie')].filter(Boolean)
|
|
390
|
+
for (const raw of cookies) {
|
|
391
|
+
const pair = String(raw).split(';')[0].trim()
|
|
392
|
+
if (pair.includes('=')) return pair
|
|
393
|
+
}
|
|
394
|
+
return null
|
|
395
|
+
} catch { return null }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** 远程访问状态:网关、账号、浏览模式补丁、旧服务器模式残留。 */
|
|
399
|
+
function lanStatus(webServer) {
|
|
400
|
+
const file = serverPatchFile()
|
|
401
|
+
let applied = false
|
|
402
|
+
let webserverActive = false
|
|
403
|
+
try {
|
|
404
|
+
const list = readPatchList(file)
|
|
405
|
+
applied = serverPatchApplied(list)
|
|
406
|
+
webserverActive = legacyWebserverRowPresent(list)
|
|
407
|
+
} catch { applied = false }
|
|
408
|
+
const auth = getAuth()
|
|
409
|
+
return {
|
|
410
|
+
gatewayActive: Boolean(gateway),
|
|
411
|
+
gatewayPort: gateway ? gateway.port : null,
|
|
412
|
+
mainPort: webServer.port,
|
|
413
|
+
addresses: lanAddresses(),
|
|
414
|
+
patchApplied: applied,
|
|
415
|
+
webserverActive,
|
|
416
|
+
accountSet: Boolean(auth),
|
|
417
|
+
username: auth ? auth.username : '',
|
|
418
|
+
patchPath: file,
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function stopGateway() {
|
|
423
|
+
const current = gateway
|
|
424
|
+
gateway = null
|
|
425
|
+
gatewayStarting = null
|
|
426
|
+
if (current) await current.close()
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** 远程访问网关:上游即主实例;主实例保持仅本机,远程一律经账号登录进入。 */
|
|
430
|
+
async function ensureGateway(webServer, requestedPort, mintUpstreamCookie) {
|
|
431
|
+
// 默认端口跟主实例走(主端口+1):网关和主服务是同一台机器上的两个监听,
|
|
432
|
+
// 结构上不能同端口;跟随主端口让"哪个口是哪个"一目了然。
|
|
433
|
+
const port = Number(requestedPort === undefined || requestedPort === null || requestedPort === ''
|
|
434
|
+
? webServer.port + 1 : requestedPort)
|
|
435
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535) {
|
|
436
|
+
const error = new Error('局域网端口需为 1024 到 65535 之间的整数')
|
|
437
|
+
error.statusCode = 400
|
|
438
|
+
throw error
|
|
439
|
+
}
|
|
440
|
+
if (port === webServer.port) {
|
|
441
|
+
const error = new Error(`局域网端口不能与 DSH 主服务端口 ${webServer.port} 相同`)
|
|
442
|
+
error.statusCode = 400
|
|
443
|
+
throw error
|
|
444
|
+
}
|
|
445
|
+
if (gateway && gateway.port === port) return gateway
|
|
446
|
+
if (gateway) {
|
|
447
|
+
const error = new Error(`局域网网关已在 ${gateway.port} 端口运行,请先关闭当前手机连接`)
|
|
448
|
+
error.statusCode = 409
|
|
449
|
+
throw error
|
|
450
|
+
}
|
|
451
|
+
// 上游即主实例:单实例架构下所有设备看到的就是同一个进程,任务状态天然同步。
|
|
452
|
+
// 主实例保持仅本机 → 网关把 Host/Origin 改写成回环(回环恒被信任),
|
|
453
|
+
// 远程设备必须先经过账号登录,不存在绕过登录的直连路径。
|
|
454
|
+
if (!gatewayStarting) {
|
|
455
|
+
gatewayStarting = startProtectedLanGateway({
|
|
456
|
+
port,
|
|
457
|
+
upstreamHost: '127.0.0.1',
|
|
458
|
+
upstreamPort: webServer.port,
|
|
459
|
+
spoofLoopback: true,
|
|
460
|
+
verifyLogin,
|
|
461
|
+
mintUpstreamCookie: typeof mintUpstreamCookie === 'function' ? mintUpstreamCookie : undefined,
|
|
462
|
+
})
|
|
463
|
+
.then((started) => { gateway = started; gatewayStarting = null; return started })
|
|
464
|
+
.catch((error) => { gatewayStarting = null; throw error })
|
|
465
|
+
}
|
|
466
|
+
try { return await gatewayStarting }
|
|
467
|
+
catch (cause) {
|
|
468
|
+
const error = new Error(cause && cause.code === 'EADDRINUSE'
|
|
469
|
+
? `端口 ${port} 已被占用,请换一个端口`
|
|
470
|
+
: '无法开启局域网网关:' + ((cause && cause.message) || String(cause)))
|
|
471
|
+
error.statusCode = 400
|
|
472
|
+
throw error
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
disposers.push(ctx.inject(['webServer'], (wsCtx) => {
|
|
478
|
+
// 服务器模式下的主实例同样服务局域网浏览器:注入 crypto.randomUUID 兜底,
|
|
479
|
+
// 修复非安全上下文下 DSH 客户端引导失败。回环/安全上下文下脚本自我短路,
|
|
480
|
+
// 对本机使用零副作用。
|
|
481
|
+
if (typeof wsCtx.webServer.tapIndex === 'function') {
|
|
482
|
+
disposers.push(wsCtx.effect(() => wsCtx.webServer.tapIndex(injectLanCompat)))
|
|
483
|
+
}
|
|
484
|
+
wsCtx.effect(() => wsCtx.webServer.register({
|
|
485
|
+
kind: 'prefix',
|
|
486
|
+
path: '/dsh-dock/mobile-relay',
|
|
487
|
+
async handler(req, res) {
|
|
488
|
+
try {
|
|
489
|
+
const method = routeMethod(req)
|
|
490
|
+
const payload = await readBody(req)
|
|
491
|
+
if (method === 'lan') {
|
|
492
|
+
return sendJson(res, 200, { ok: true, data: lanStatus(wsCtx.webServer) })
|
|
493
|
+
}
|
|
494
|
+
if (method === 'auth/set') {
|
|
495
|
+
// 设置/修改远程访问账号密码;改完作废所有已登录会话。
|
|
496
|
+
const username = String((payload && payload.username) || '').trim()
|
|
497
|
+
const password = String((payload && payload.password) || '')
|
|
498
|
+
if (!username || username.length > 64) {
|
|
499
|
+
const error = new Error('账号需为 1 到 64 个字符')
|
|
500
|
+
error.statusCode = 400
|
|
501
|
+
throw error
|
|
502
|
+
}
|
|
503
|
+
if (password.length < 6 || password.length > 128) {
|
|
504
|
+
const error = new Error('密码至少 6 位、至多 128 位')
|
|
505
|
+
error.statusCode = 400
|
|
506
|
+
throw error
|
|
507
|
+
}
|
|
508
|
+
await saveAuth(username, password)
|
|
509
|
+
return sendJson(res, 200, { ok: true, data: { ...lanStatus(wsCtx.webServer) } })
|
|
510
|
+
}
|
|
511
|
+
if (method === 'lan/start') {
|
|
512
|
+
// 开启远程访问:网关监听 0.0.0.0(唯一远程入口,登录后才放行);
|
|
513
|
+
// 同时移除旧服务器模式行(主实例必须回到仅本机)并把目录选择器钉为浏览模式。
|
|
514
|
+
const username = String((payload && payload.username) || '').trim()
|
|
515
|
+
const password = String((payload && payload.password) || '')
|
|
516
|
+
if (username && password) {
|
|
517
|
+
if (!username || username.length > 64) {
|
|
518
|
+
const error = new Error('账号需为 1 到 64 个字符')
|
|
519
|
+
error.statusCode = 400
|
|
520
|
+
throw error
|
|
521
|
+
}
|
|
522
|
+
if (password.length < 6 || password.length > 128) {
|
|
523
|
+
const error = new Error('密码至少 6 位、至多 128 位')
|
|
524
|
+
error.statusCode = 400
|
|
525
|
+
throw error
|
|
526
|
+
}
|
|
527
|
+
await saveAuth(username, password)
|
|
528
|
+
}
|
|
529
|
+
if (!getAuth()) {
|
|
530
|
+
const error = new Error('请先设置远程访问的账号和密码')
|
|
531
|
+
error.statusCode = 400
|
|
532
|
+
throw error
|
|
533
|
+
}
|
|
534
|
+
const file = serverPatchFile()
|
|
535
|
+
const list = readPatchList(file) || []
|
|
536
|
+
const changed = !serverPatchApplied(list) || legacyWebserverRowPresent(list)
|
|
537
|
+
writePatchList(file, upsertRemotePatches(list))
|
|
538
|
+
await ensureGateway(wsCtx.webServer, payload && payload.port, () => mintUpstreamSession(wsCtx.webServer.port))
|
|
539
|
+
return sendJson(res, 200, { ok: true, data: { ...lanStatus(wsCtx.webServer), needsRestart: changed } })
|
|
540
|
+
}
|
|
541
|
+
if (method === 'lan/stop') {
|
|
542
|
+
const file = serverPatchFile()
|
|
543
|
+
const list = readPatchList(file)
|
|
544
|
+
await stopGateway()
|
|
545
|
+
if (list === null) {
|
|
546
|
+
return sendJson(res, 200, { ok: true, data: { ...lanStatus(wsCtx.webServer), needsRestart: false } })
|
|
547
|
+
}
|
|
548
|
+
const changed = serverPatchApplied(list) || legacyWebserverRowPresent(list)
|
|
549
|
+
if (changed) writePatchList(file, removeRemotePatches(list))
|
|
550
|
+
return sendJson(res, 200, { ok: true, data: { ...lanStatus(wsCtx.webServer), needsRestart: changed } })
|
|
551
|
+
}
|
|
552
|
+
return sendJson(res, 404, { ok: false, error: { code: 'method-not-found', message: 'unknown method: ' + method } })
|
|
553
|
+
} catch (error) {
|
|
554
|
+
const status = error && error.statusCode ? error.statusCode : 500
|
|
555
|
+
if (status >= 500) console.error('[dsh-dock] mobile relay HTTP error:', error && error.message)
|
|
556
|
+
return sendJson(res, status, { ok: false, error: { code: status >= 500 ? 'internal' : 'bad-request', message: (error && error.message) || String(error) } })
|
|
557
|
+
}
|
|
558
|
+
},
|
|
559
|
+
}), 'dsh-dock mobile relay: /dsh-dock/mobile-relay HTTP route')
|
|
560
|
+
}))
|
|
561
|
+
|
|
562
|
+
return () => {
|
|
563
|
+
while (disposers.length) {
|
|
564
|
+
const dispose = disposers.pop()
|
|
565
|
+
try { if (typeof dispose === 'function') dispose() } catch { /* ignore */ }
|
|
566
|
+
}
|
|
567
|
+
void stopGateway()
|
|
568
|
+
}
|
|
569
|
+
},
|
|
570
|
+
}
|