dsh-dock 0.12.0 → 0.14.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.
@@ -6,17 +6,20 @@
6
6
  // 主实例:绑定回环时把 Host/Origin 改写为回环(回环恒被信任),已绑 0.0.0.0 时
7
7
  // 透传以走局域网信任派生。宿主可注入 mintUpstreamCookie:服务端兑换 dsh web 的
8
8
  // 启动令牌会话并在代理时自动附带(含 WebSocket 升级),远程浏览器只需账号密码。
9
- // HTML 响应注入远程标记(window.__DSH_REMOTE__,供面板显示退出按钮)与
10
- // crypto.randomUUID 兜底脚本引用。
9
+ // HTML 响应注入远程标记(window.__DSH_REMOTE__,供面板显示退出按钮)、
10
+ // crypto.randomUUID 兜底脚本引用、PWA 元信息(manifest/图标,手机「添加到主屏幕」
11
+ // 后全屏打开)与断线监视脚本(网关健康探测,断线时页面顶部出提示胶囊)。
11
12
  import { createServer, request as httpRequest } from 'node:http'
12
13
  import { networkInterfaces } from 'node:os'
13
14
  import { randomBytes } from 'node:crypto'
15
+ import { deflateSync } from 'node:zlib'
14
16
 
15
17
  const COOKIE_NAME = 'dsh_remote_session'
16
18
  const LOGIN_PATH = '/__dsh_auth/login'
17
19
  const LOGOUT_PATH = '/__dsh_auth/logout'
18
20
  const HEALTH_PATH = '/__dsh_auth/health'
19
21
  const COMPAT_PATH = '/__dsh_mobile/compat.js'
22
+ const MANIFEST_PATH = '/__dsh_auth/manifest.webmanifest'
20
23
  const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
21
24
  const MAX_LOGIN_BODY = 1024
22
25
  const MAX_HTML_BYTES = 2 * 1024 * 1024
@@ -27,9 +30,14 @@ const MAX_LOGIN_ATTEMPTS = 10
27
30
  // 构造 memory 镜像、永不读档,设置/模型页全部显示 unavailable)。而服务端信任栏
28
31
  // 只看 Host/Origin——经本网关(登录后)的请求早已映射为回环信任,特权接口实际
29
32
  // 全部放行。唯一拦住手机用户的只剩客户端这一处自我判定,所以对本 bundle 做定点
30
- // 改写(判定恒真)。上游升级改了片段则原样透传(退化为官方行为,不影响其他功能)。
33
+ // 改写(判定恒真)。候选片段按代际排列(dsh ≥0.1.7 在判定前面加了 ownsHost 短路,
34
+ // 旧片段在新产物里不再出现——0.1.7 上线时手机设置/模型页因此再次 unavailable);
35
+ // 全部匹配不上则原样透传(退化为官方行为,不影响其他功能)。
31
36
  const CONNECTION_BUNDLE_PREFIX = '/plugins/@deepseek-ai/dsh-client-connection/client.js'
32
- const CONNECTION_ISLOOPBACK_SNIPPET = 'isLoopback: pageLocation === void 0 || isLoopbackHostname(pageLocation.hostname)'
37
+ const CONNECTION_ISLOOPBACK_SNIPPETS = [
38
+ 'isLoopback: transport?.ownsHost === true || pageLocation === void 0 || isLoopbackHostname(pageLocation.hostname)',
39
+ 'isLoopback: pageLocation === void 0 || isLoopbackHostname(pageLocation.hostname)',
40
+ ]
33
41
  const CONNECTION_ISLOOPBACK_PATCHED = 'isLoopback: !0 /* dsh-dock remote gateway */'
34
42
  const MAX_ASSET_BYTES = 4 * 1024 * 1024
35
43
  const PATCH_CACHE_LIMIT = 16
@@ -41,6 +49,149 @@ const PATCH_CACHE_LIMIT = 16
41
49
  // property and on Crypto.prototype. Injected before every DSH bootstrap script.
42
50
  const MOBILE_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){}}}})()`
43
51
 
52
+ // ---- PWA:手机「添加到主屏幕」后全屏打开(无浏览器地址栏,像 App 一样使用)----
53
+ // head 标签注入进所有经网关的 HTML(登录页 + DSH 页面);manifest 与图标由网关
54
+ // 自行服务(登录前也要可取,否则添加主屏会拿不到清单)。
55
+ const PWA_HEAD_TAGS = [
56
+ '<meta name="theme-color" content="#0f1115"/>',
57
+ '<meta name="mobile-web-app-capable" content="yes"/>',
58
+ '<meta name="apple-mobile-web-app-capable" content="yes"/>',
59
+ '<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>',
60
+ '<meta name="apple-mobile-web-app-title" content="DSH"/>',
61
+ '<link rel="manifest" href="' + MANIFEST_PATH + '"/>',
62
+ '<link rel="apple-touch-icon" href="/__dsh_auth/icon-192.png"/>',
63
+ '<link rel="icon" type="image/png" href="/__dsh_auth/icon-192.png"/>',
64
+ ].join('')
65
+ // 远程标记并入监视脚本开头:面板的「退出登录」按钮靠它拿到注销地址(桌面本机访问
66
+ // 时没有这段脚本,按钮不显示——没有它手机上点了退出没反应)。
67
+ // 断线监视:手机换网(WiFi↔蜂窝)/锁屏久置后连接可能已死,页面本身不会提示——
68
+ // 顶部出「连接已断开,正在重连…」胶囊,恢复后自动消失。经网关健康端点探测
69
+ // (连续 2 次失败才提示,避免瞬时抖动闪现);离线/恢复事件立即响应。ES5,自含样式。
70
+ const REMOTE_WATCH_JS = "window.__DSH_REMOTE__={gateway:true,logout:'/__dsh_auth/logout'};(function(){var HEALTH='/__dsh_auth/health',pill=null,checking=false,fails=0;function css(){var s=document.createElement('style');s.setAttribute('data-dsh-remote-watch','');s.textContent='.dsh-remote-net{position:fixed;top:calc(env(safe-area-inset-top,0px) + 10px);left:50%;transform:translateX(-50%);z-index:2147483000;display:none;align-items:center;gap:8px;padding:8px 14px;border-radius:999px;background:linear-gradient(135deg,#f59e0b,#ef4444);color:#fff;font:600 12px/1.4 system-ui,-apple-system,sans-serif;box-shadow:0 10px 30px rgba(0,0,0,.35);pointer-events:none;white-space:nowrap}.dsh-remote-net .dot{width:8px;height:8px;border-radius:50%;background:#fff;opacity:.95;animation:dsh-remote-blink 1.1s ease-in-out infinite}@keyframes dsh-remote-blink{0%,100%{opacity:.95}50%{opacity:.25}}@media (prefers-reduced-motion:reduce){.dsh-remote-net .dot{animation:none}}';(document.head||document.documentElement).appendChild(s)}function ensure(){if(pill)return;css();pill=document.createElement('div');pill.className='dsh-remote-net';pill.setAttribute('role','status');pill.innerHTML='<span class=\"dot\"></span><span>连接已断开,正在重连…</span>';if(document.body)document.body.appendChild(pill);else{var t=setInterval(function(){if(document.body){clearInterval(t);document.body.appendChild(pill)}},200)}}function show(){ensure();pill.style.display='flex'}function hide(){if(pill)pill.style.display='none'}function finish(ok){checking=false;if(ok){fails=0;hide()}else if(++fails>=2){show()}}function ping(){if(checking||document.visibilityState==='hidden')return;checking=true;var done=false,t=setTimeout(function(){if(!done){done=true;finish(false)}},5000);fetch(HEALTH,{method:'POST',cache:'no-store'}).then(function(r){if(!done){done=true;clearTimeout(t);finish(r.ok||r.status===401||r.status===302)}}).catch(function(){if(!done){done=true;clearTimeout(t);finish(false)}})}window.addEventListener('offline',function(){show()});window.addEventListener('online',function(){fails=1;ping()});document.addEventListener('visibilitychange',function(){if(document.visibilityState==='visible')ping()});setInterval(ping,10000);ping()})()"
71
+
72
+ // ---- 零依赖 PNG 图标:远程访问 PWA 图标(品牌渐变圆角块 + 白色对话气泡)----
73
+ // 不引第三方图像依赖:像素逐点光栅化(SDF 抗锯齿),PNG 只需 IHDR/IDAT/IEND
74
+ // 三块(zlib deflate + CRC32)。maskable 变体用满幅方形(无圆角),系统裁切成
75
+ // 各种形状时内容都在安全区内。
76
+ const crcTable = (() => {
77
+ const table = new Int32Array(256)
78
+ for (let n = 0; n < 256; n++) {
79
+ let c = n
80
+ for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1)
81
+ table[n] = c
82
+ }
83
+ return table
84
+ })()
85
+ function crc32(buf) {
86
+ let c = -1
87
+ for (let i = 0; i < buf.length; i++) c = crcTable[(c ^ buf[i]) & 0xFF] ^ (c >>> 8)
88
+ return (c ^ -1) >>> 0
89
+ }
90
+ function pngChunk(type, data) {
91
+ const out = Buffer.alloc(8 + data.length + 4)
92
+ out.writeUInt32BE(data.length, 0)
93
+ out.write(type, 4, 'ascii')
94
+ data.copy(out, 8)
95
+ out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length)
96
+ return out
97
+ }
98
+ function segmentDist(px, py, ax, ay, bx, by) {
99
+ const abx = bx - ax, aby = by - ay
100
+ const t = Math.min(1, Math.max(0, ((px - ax) * abx + (py - ay) * aby) / (abx * abx + aby * aby)))
101
+ return Math.hypot(px - (ax + abx * t), py - (ay + aby * t))
102
+ }
103
+ function triangleSdf(px, py, tri) {
104
+ const ax = tri[0], ay = tri[1], bx = tri[2], by = tri[3], cx = tri[4], cy = tri[5]
105
+ const d = Math.min(
106
+ segmentDist(px, py, ax, ay, bx, by),
107
+ segmentDist(px, py, bx, by, cx, cy),
108
+ segmentDist(px, py, cx, cy, ax, ay),
109
+ )
110
+ const s1 = (bx - ax) * (py - ay) - (by - ay) * (px - ax)
111
+ const s2 = (cx - bx) * (py - by) - (cy - by) * (px - bx)
112
+ const s3 = (ax - cx) * (py - cy) - (ay - cy) * (px - cx)
113
+ const inside = (s1 >= 0 && s2 >= 0 && s3 >= 0) || (s1 <= 0 && s2 <= 0 && s3 <= 0)
114
+ return inside ? -d : d
115
+ }
116
+ function roundedRectSdf(x, y, x0, y0, x1, y1, r) {
117
+ const cx = Math.min(Math.max(x, x0 + r), x1 - r)
118
+ const cy = Math.min(Math.max(y, y0 + r), y1 - r)
119
+ return Math.hypot(x - cx, y - cy) - r
120
+ }
121
+ function renderIconPng(size, maskable) {
122
+ const rgba = Buffer.alloc(size * size * 4)
123
+ const c1 = [0x38, 0xbd, 0xf8] // #38bdf8(登录页品牌渐变起点)
124
+ const c2 = [0x63, 0x66, 0xf1] // #6366f1(终点)
125
+ const inset = maskable ? 0 : size * 0.04
126
+ const corner = maskable ? 0 : size * 0.22
127
+ const s = size
128
+ const bubble = [s * 0.26, s * 0.24, s * 0.74, s * 0.58, s * 0.07]
129
+ const tail = [s * 0.305, s * 0.565, s * 0.305, s * 0.72, s * 0.455, s * 0.565]
130
+ for (let y = 0; y < size; y++) {
131
+ for (let x = 0; x < size; x++) {
132
+ const at = (y * size + x) * 4
133
+ const px = x + 0.5, py = y + 0.5
134
+ const tile = roundedRectSdf(px, py, inset, inset, s - inset, s - inset, corner)
135
+ const tileA = Math.min(Math.max(0.5 - tile, 0), 1)
136
+ if (tileA <= 0) { rgba[at + 3] = 0; continue }
137
+ const t = (px + py) / (2 * s)
138
+ let r = c1[0] + (c2[0] - c1[0]) * t
139
+ let g = c1[1] + (c2[1] - c1[1]) * t
140
+ let b = c1[2] + (c2[2] - c1[2]) * t
141
+ const glyph = Math.min(
142
+ roundedRectSdf(px, py, bubble[0], bubble[1], bubble[2], bubble[3], bubble[4]),
143
+ triangleSdf(px, py, tail),
144
+ )
145
+ const glyphA = Math.min(Math.max(0.5 - glyph, 0), 1)
146
+ r += (255 - r) * glyphA
147
+ g += (255 - g) * glyphA
148
+ b += (255 - b) * glyphA
149
+ rgba[at] = Math.round(r)
150
+ rgba[at + 1] = Math.round(g)
151
+ rgba[at + 2] = Math.round(b)
152
+ rgba[at + 3] = Math.round(tileA * 255)
153
+ }
154
+ }
155
+ const ihdr = Buffer.alloc(13)
156
+ ihdr.writeUInt32BE(size, 0)
157
+ ihdr.writeUInt32BE(size, 4)
158
+ ihdr[8] = 8; ihdr[9] = 6
159
+ const raw = Buffer.alloc((size * 4 + 1) * size)
160
+ for (let y = 0; y < size; y++) {
161
+ raw[y * (size * 4 + 1)] = 0
162
+ rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4)
163
+ }
164
+ return Buffer.concat([
165
+ Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
166
+ pngChunk('IHDR', ihdr),
167
+ pngChunk('IDAT', deflateSync(raw, { level: 9 })),
168
+ pngChunk('IEND', Buffer.alloc(0)),
169
+ ])
170
+ }
171
+ const iconCache = new Map()
172
+ function iconPng(size, maskable) {
173
+ const key = (maskable ? 'm' : 'a') + size
174
+ if (!iconCache.has(key)) iconCache.set(key, renderIconPng(size, maskable))
175
+ return iconCache.get(key)
176
+ }
177
+ function manifestJson() {
178
+ return JSON.stringify({
179
+ name: 'DSH 远程访问',
180
+ short_name: 'DSH',
181
+ description: '登录后使用与本机完全一致的 DSH:会话、任务进度实时一致',
182
+ start_url: '/',
183
+ scope: '/',
184
+ display: 'standalone',
185
+ background_color: '#0f1115',
186
+ theme_color: '#0f1115',
187
+ icons: [
188
+ { src: '/__dsh_auth/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
189
+ { src: '/__dsh_auth/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
190
+ { src: '/__dsh_auth/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
191
+ ],
192
+ })
193
+ }
194
+
44
195
  function secret() { return randomBytes(32).toString('base64url') }
45
196
  function now() { return Date.now() }
46
197
  function isPrivateIpv4(ip) {
@@ -107,7 +258,9 @@ function loginPageHtml(withError) {
107
258
  const errorRow = withError
108
259
  ? `<div class="error" role="alert">账号或密码不正确,请重试。</div>`
109
260
  : `<div class="noerror"></div>`
110
- return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="dark light"><title>DSH 远程访问</title><style>${PAGE_STYLE}</style></head><body><main class="card"><form method="POST" action="/__dsh_auth/login"><div class="brand">${PAGE_ICON}<div><h1>DSH 远程访问</h1><small>登录后可使用完整功能</small></div></div>${errorRow}<label>账号<input name="username" autocomplete="username" autofocus required></label><label>密码<input type="password" name="password" autocomplete="current-password" required></label><button type="submit">登 录</button><p class="hint">仅限授权设备登录 · 会话 7 天内免重复登录</p></form></main></body></html>`
261
+ // 「显示密码」:手机上输错密码的成本高(键盘小、自动更正捣乱),明文切换是刚需。
262
+ // 提交后禁用按钮防重复提交(失败走 303 重载,不存在卡死)。
263
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="dark light">${PWA_HEAD_TAGS}<title>DSH 远程访问</title><style>${PAGE_STYLE}.pw{position:relative}.pw input{padding-right:64px}.pw .peek{position:absolute;right:8px;top:22px;width:auto;margin:0;padding:6px 10px;border:0;background:transparent;color:#8b93a3;font-size:12px;font-weight:600;cursor:pointer}</style></head><body><main class="card"><form method="POST" action="${LOGIN_PATH}" onsubmit="var b=this.querySelector('button[type=submit]');if(b)b.disabled=true"><div class="brand">${PAGE_ICON}<div><h1>DSH 远程访问</h1><small>登录后可使用完整功能</small></div></div>${errorRow}<label>账号<input name="username" autocomplete="username" autofocus required></label><label for="dsh-pw">密码</label><div class="pw"><input id="dsh-pw" type="password" name="password" autocomplete="current-password" required><button type="button" class="peek" onclick="var i=document.getElementById('dsh-pw');var s=i.type==='password';i.type=s?'text':'password';this.textContent=s?'隐藏':'显示'">显示</button></div><button type="submit">登 录</button><p class="hint">仅限授权设备登录 · 会话 7 天内免重复登录</p></form></main></body></html>`
111
264
  }
112
265
 
113
266
  function logoutPageHtml() {
@@ -138,8 +291,10 @@ function htmlInjectionHeaders(headers) {
138
291
  }
139
292
  function injectMobileCompat(html) {
140
293
  if (html.includes('data-dsh-mobile-compat')) return html
141
- const match = /<head(?:\s[^>]*)?>/i.exec(html)
142
- const script = `<script data-dsh-mobile-compat src="${COMPAT_PATH}"></script>`
294
+ const match = html.match(/<head(?:\s[^>]*)?>/i)
295
+ // DSH 页面 head 前置:crypto 兜底脚本引用 + PWA head 标签(PWA_HEAD_TAGS 为常量;
296
+ // 远程标记与断线监视在 compat.js 文件内,见 COMPAT_PATH 路由)。
297
+ const script = `<script data-dsh-mobile-compat src="${COMPAT_PATH}"></script>${PWA_HEAD_TAGS}`
143
298
  if (!match) return script + html
144
299
  const at = match.index + match[0].length
145
300
  return html.slice(0, at) + script + html.slice(at)
@@ -312,9 +467,10 @@ export function startProtectedLanGateway({ port, upstreamHost = '127.0.0.1', ups
312
467
  let body = Buffer.concat(chunks)
313
468
  if (isConnectionBundle) {
314
469
  const text = body.toString('utf8')
315
- const at = text.indexOf(CONNECTION_ISLOOPBACK_SNIPPET)
316
- if (at >= 0) {
317
- body = Buffer.from(text.slice(0, at) + CONNECTION_ISLOOPBACK_PATCHED + text.slice(at + CONNECTION_ISLOOPBACK_SNIPPET.length), 'utf8')
470
+ // 按代际找片段:新版先试(新版文本里不含旧片段,顺序无歧义)
471
+ const hit = CONNECTION_ISLOOPBACK_SNIPPETS.map((snippet) => ({ snippet, at: text.indexOf(snippet) })).find((c) => c.at >= 0)
472
+ if (hit) {
473
+ body = Buffer.from(text.slice(0, hit.at) + CONNECTION_ISLOOPBACK_PATCHED + text.slice(hit.at + hit.snippet.length), 'utf8')
318
474
  if (patchedBundles.size >= PATCH_CACHE_LIMIT) patchedBundles.clear()
319
475
  patchedBundles.set(req.url, body)
320
476
  }
@@ -344,6 +500,22 @@ export function startProtectedLanGateway({ port, upstreamHost = '127.0.0.1', ups
344
500
 
345
501
  if (url.pathname === HEALTH_PATH) return sendJson(res, 200, { ok: true })
346
502
 
503
+ // PWA 资源放行在登录之前:登录页也引用 manifest/图标,「添加到主屏幕」在
504
+ // 未登录时就能装好(装完后打开 / 未登录自然进登录页)。
505
+ if (url.pathname === MANIFEST_PATH && req.method === 'GET') {
506
+ res.writeHead(200, { 'content-type': 'application/manifest+json; charset=utf-8', 'cache-control': 'no-store' })
507
+ return res.end(manifestJson())
508
+ }
509
+ {
510
+ const icon = url.pathname.match(/^\/__dsh_auth\/icon-(192|512|maskable-512)\.png$/)
511
+ if (icon && req.method === 'GET') {
512
+ const maskable = icon[1] === 'maskable-512'
513
+ const size = maskable ? 512 : Number(icon[1])
514
+ res.writeHead(200, { 'content-type': 'image/png', 'cache-control': 'public, max-age=86400' })
515
+ return res.end(iconPng(size, maskable))
516
+ }
517
+ }
518
+
347
519
  if (url.pathname === LOGIN_PATH && req.method === 'GET') {
348
520
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
349
521
  return res.end(loginPageHtml(url.searchParams.get('e') === '1'))
@@ -402,7 +574,9 @@ export function startProtectedLanGateway({ port, upstreamHost = '127.0.0.1', ups
402
574
  'cache-control': 'no-store',
403
575
  'x-content-type-options': 'nosniff',
404
576
  })
405
- return res.end(MOBILE_COMPAT_JS)
577
+ // 兜底脚本之后接远程标记 + 断线监视:同一个外部脚本文件,页面加载早期执行
578
+ // (远程标记供面板「退出登录」用;监视脚本在断线时出顶部胶囊)。
579
+ return res.end(MOBILE_COMPAT_JS + '\n' + REMOTE_WATCH_JS)
406
580
  }
407
581
  return proxyHttp(req, res)
408
582
  })
@@ -448,6 +622,8 @@ export function startProtectedLanGateway({ port, upstreamHost = '127.0.0.1', ups
448
622
  resolve({
449
623
  port: listenedPort,
450
624
  addresses: lanAddresses(),
625
+ /** 当前有效的登录会话数(面板显示「已登录设备 N 台」)。 */
626
+ sessionCount() { return sessions.size },
451
627
  revokeAllSessions() {
452
628
  sessions.clear()
453
629
  for (const [socket, value] of upgradedSockets) {
@@ -125,184 +125,17 @@ function removeRemotePatches(list) {
125
125
  // 回环/安全上下文下 randomUUID 原生存在,脚本自我短路零副作用。
126
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
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
- // ---- 仿 ZCode 手机端:底部 Tab 栏 + 输入区吸附底部 ----
158
- // Tab 栏 z 序 65:低于遮罩 75 / 抽屉 80 / overlayLayer 90 / 功能坞面板 200,
159
- // 这些浮层打开时自然盖住 Tab 栏。功能坞面板打开时另由行为脚本整体隐藏(防露边)。
160
- ' .dsh-mobile-tabbar{position:fixed;left:0;right:0;bottom:0;z-index:65;display:flex;align-items:stretch;justify-content:space-around;padding:6px 8px calc(8px + env(safe-area-inset-bottom,0px));background:color-mix(in srgb,var(--dsw-alias-bg-layer-2,#1c2230) 92%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-top:1px solid var(--dsw-alias-border-l1,rgba(127,139,161,.25));touch-action:manipulation}',
161
- ' .dsh-mobile-tab{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;min-height:48px;padding:4px 0;border:0;background:transparent;color:var(--dsw-alias-label-tertiary,#8b95ab);font-size:11px;font-weight:600;line-height:1.2;font-family:inherit;cursor:pointer;-webkit-tap-highlight-color:transparent;border-radius:10px}',
162
- ' .dsh-mobile-tab svg{width:21px;height:21px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}',
163
- ' .dsh-mobile-tab.on{color:var(--dsw-alias-label-primary,#e6eaf2)}',
164
- ' .dsh-mobile-tab.on svg{color:#4d9fff}',
165
- ' .dsh-mobile-tab:active{transform:scale(.94)}',
166
- // 输入区吸附到 Tab 栏上方:宿主把欢迎内容 justify-content:center 垂直居中,
167
- // 手机视口下输入卡片悬在中部、下方留出大片空白。改为 flex-end 贴底,并给
168
- // 滚动体留出 Tab 栏高度的内边距,避免内容被 Tab 栏遮挡。
169
- ' [class*="scrollBody"]{justify-content:flex-end!important;padding-bottom:calc(72px + env(safe-area-inset-bottom,0px))!important}',
170
- ' [class*="scrollBody"]>[class*="composerSeat"]{padding-bottom:6px}',
171
- // 收起的侧栏轨道(collapsed rail)在窄屏只剩 1px 占位,但轨道里的按钮
172
- // 仍会溢出贴在左边缘(功能坞/进化/设置三枚露出半边的圆钮)。隐藏整列,
173
- // 这些入口已由底部 Tab 栏接管;展开抽屉(z 80)不受影响。
174
- ' [class*="sidebarCol"]:has([class*="collapsed"]){visibility:hidden}',
175
- '}',
176
- ].join('\n')
177
- const MOBILE_LAYOUT_MARKER = 'data-dsh-mobile-layout'
178
- // 窄屏行为补丁:侧边栏浮层化后,原生的"保持展开"会一直挡住半屏——
179
- // 点会话行/新会话后自动收起;点侧栏外的页面区域也收起(浮层语义)。
180
- // 侧栏内的其他点击(工作区折叠、搜索、功能坞入口)保持原生行为不收。
181
- // 另创建抽屉按钮(窄屏 + 侧栏收起时才显示,点击转发原生「打开侧边栏」toggle;
182
- // 侧栏展开或设置弹窗打开时自动隐藏)与抽屉遮罩(点击经全局捕获 handler 收起,
183
- // 单一路径防止同一事件两次 toggle)。注入点在 <head> 后,body 尚未解析,
184
- // DOM 创建一律推迟到 DOMContentLoaded;显隐同步走 MutationObserver(属性翻转即时)
185
- // + 低速轮询(覆盖路由重渲等一切边角)。
186
- const MOBILE_BEHAVIOR_JS = [
187
- ';(function(){',
188
- 'var mq=window.matchMedia?window.matchMedia("(max-width:700px)"):null;',
189
- 'if(!mq)return;',
190
- // 与 dsh-dock 客户端 Overlay 版(features/mobile-relay/view.jsx,刷新即生效)互斥:
191
- // 本脚本在 <head> 先执行,存在即接管;客户端版看到旗标自动让位。
192
- 'if(window.__dshDockMobileDrawer)return;',
193
- 'window.__dshDockMobileDrawer="host";',
194
- 'function narrow(){return mq.matches}',
195
- 'function frameEl(){return document.querySelector(\'[class$="frame"]\')}',
196
- 'function sidebarCollapsed(){var f=frameEl();return !!f&&f.hasAttribute("data-sidebar-collapsed")}',
197
- 'function dialogOpen(){return !!document.querySelector(\'[role="dialog"][class*="panel"]\')}',
198
- 'function collapse(){var b=document.querySelector(\'button[aria-label="收起侧边栏"],button[aria-label="Collapse sidebar"]\');if(b)b.click()}',
199
- 'function expand(){var b=document.querySelector(\'button[aria-label="打开侧边栏"],button[aria-label="Open sidebar"]\');if(b)b.click()}',
200
- '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()}',
201
- 'var fab=null,scrim=null,tabbar=null;',
202
- 'var TAB_DEFS=[',
203
- ' {id:"sessions",label:"会话",icon:\'<svg viewBox="0 0 24 24"><path d="M21 12a8 8 0 0 1-8 8H5l-2 2V12a8 8 0 0 1 8-8h2a8 8 0 0 1 8 8z"/></svg>\',act:function(){expand()}},',
204
- ' {id:"dock",label:"功能坞",icon:\'<svg viewBox="0 0 24 24"><rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/></svg>\',act:function(){var b=document.querySelector("button.docke2-rail,button[class*=\'docke2-btn\'][class*=\'docke2-rail\']");if(b){var col=b.closest(\'[class*="sidebarCol"]\');if(col)col.style.visibility="";b.click();if(col)setTimeout(function(){col.style.visibility=""},0)}}},',
205
- ' {id:"settings",label:"设置",icon:\'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3.2"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 9 19.4a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1 1.51 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.23.6.86 1 1.51 1H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1z"/></svg>\',act:function(){var bs=document.querySelectorAll("button");for(var i=0;i<bs.length;i++){if(bs[i].getAttribute("aria-label")==="设置"){bs[i].click();return}}}}',
206
- '];',
207
- 'function syncTabs(){',
208
- ' if(!tabbar)return;',
209
- ' var expanded=!sidebarCollapsed();',
210
- ' var settingsOpen=dialogOpen();',
211
- ' var dockOpen=!!document.querySelector(".dockm-backdrop");',
212
- ' var bs=tabbar.querySelectorAll(".dsh-mobile-tab");',
213
- ' for(var i=0;i<bs.length;i++){',
214
- ' var id=bs[i].dataset.tab;',
215
- ' var on=(id==="sessions"&&expanded)||(id==="dock"&&dockOpen)||(id==="settings"&&settingsOpen);',
216
- ' if(on)bs[i].classList.add("on");else bs[i].classList.remove("on");',
217
- ' }',
218
- '}',
219
- 'function ensureChrome(){',
220
- ' 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)}',
221
- ' if(!scrim){scrim=document.createElement("div");scrim.className="dsh-mobile-scrim";document.body.appendChild(scrim)}',
222
- ' if(!tabbar){',
223
- ' tabbar=document.createElement("nav");tabbar.className="dsh-mobile-tabbar";tabbar.setAttribute("aria-label","底部导航");',
224
- ' for(var i=0;i<TAB_DEFS.length;i++){',
225
- ' (function(def){',
226
- ' var b=document.createElement("button");b.type="button";b.className="dsh-mobile-tab";b.dataset.tab=def.id;',
227
- ' b.innerHTML=def.icon+"<span>"+def.label+"</span>";',
228
- ' b.addEventListener("click",function(){def.act()});',
229
- ' tabbar.appendChild(b);',
230
- ' })(TAB_DEFS[i]);',
231
- ' }',
232
- ' document.body.appendChild(tabbar);',
233
- ' }',
234
- '}',
235
- // 把手定位:贴着趣味游戏浮标(.dgfab,可拖拽)正上方;浮标太靠上时改放它下面
236
- // (避开顶栏标题),被拖走/隐藏/不存在时兜底左侧中部(38% 视高)。
237
- 'function place(){',
238
- ' if(!fab)return;',
239
- ' var vh=window.innerHeight,top=null;',
240
- ' var g=document.querySelector(".dgfab");',
241
- ' if(g&&!g.classList.contains("dgfab-hide")&&g.getBoundingClientRect){',
242
- ' var r=g.getBoundingClientRect();',
243
- ' if(r&&r.height>0&&r.top>0){',
244
- ' top=r.top-fab.offsetHeight-8;',
245
- ' if(top<64)top=r.bottom+8;',
246
- ' if(top+fab.offsetHeight>vh-16)top=null;',
247
- ' }',
248
- ' }',
249
- ' if(top==null)top=Math.round(vh*.38);',
250
- ' fab.style.top=top+"px";',
251
- '}',
252
- 'function syncChrome(){',
253
- ' if(!fab||!scrim)return;',
254
- ' var on=narrow()&&!!frameEl()&&!dialogOpen();',
255
- ' var col=sidebarCollapsed();',
256
- ' var dockOpen=!!document.querySelector(".dockm-backdrop");',
257
- ' var showFab=on&&col&&!dockOpen;',
258
- ' fab.style.display=showFab?"":"none";',
259
- ' if(showFab)place();',
260
- ' scrim.style.display=on&&!col?"":"none";',
261
- // Tab 栏:窄屏且页面骨架就绪后常驻;功能坞面板打开时整体隐藏(面板全屏
262
- // 模态,Tab 栏 z 序低于它仍可能从边缘露出);宿主设置弹窗 z 序更高,自然盖住。
263
- ' if(tabbar)tabbar.style.display=narrow()&&!!frameEl()&&!dockOpen?"":"none";',
264
- ' syncTabs();',
265
- '}',
266
- 'function boot(){',
267
- ' ensureChrome();syncChrome();',
268
- ' if(typeof MutationObserver!=="undefined"){new MutationObserver(function(){syncChrome()}).observe(document.documentElement,{attributes:true,attributeFilter:["data-sidebar-collapsed"],subtree:true})}',
269
- ' setInterval(syncChrome,1500);',
270
- ' var onMq=function(){syncChrome()};',
271
- ' if(mq.addEventListener)mq.addEventListener("change",onMq);else if(mq.addListener)mq.addListener(onMq);',
272
- ' window.addEventListener("resize",place);',
273
- '}',
274
- 'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",boot);else boot();',
275
- 'document.addEventListener("click",function(e){',
276
- ' if(!narrow())return;',
277
- ' var t=e.target;',
278
- ' if(!t||!t.closest)return;',
279
- ' if(t.closest(".dsh-mobile-drawer-btn")||t.closest(".dsh-mobile-tabbar"))return;',
280
- ' var expanded=!!document.querySelector(\'button[aria-label="收起侧边栏"],button[aria-label="Collapse sidebar"]\');',
281
- ' var col=document.querySelector(\'[class*="sidebarCol"]\');',
282
- ' var inSidebar=col&&col.contains(t);',
283
- ' if(!expanded&&!inSidebar)return;',
284
- ' if(inSidebar){',
285
- ' var row=t.closest(\'[role="treeitem"]\');',
286
- ' var leaf=row&&!row.querySelector(\'[role="treeitem"]\')&&row.closest(\'[role="tree"]\');',
287
- ' var fresh=t.closest(\'[class*="newSession"]\');',
288
- ' if(leaf||fresh)setTimeout(function(){collapse();closeDetails()},300);',
289
- ' return',
290
- ' }',
291
- ' if(t.closest(\'[role="dialog"],[class*="dockm"],[class*="dgfab"],[class*="dgwin"],[class*="dgame"],[class*="detailsCol"]\'))return;',
292
- ' collapse()',
293
- '},true)',
294
- '})();',
295
- ].join('\n')
296
- const MOBILE_BEHAVIOR_MARKER = 'data-dsh-mobile-behave'
297
- /** 把兼容脚本、移动端排版样式与行为脚本内联注入 index.html 的 <head> 之后(幂等)。 */
128
+ // 手机端排版/行为补丁已迁到【手机适配】模块(features/mobile/host.js)——
129
+ // 远程访问只保留「账号密码登录的远程入口」本身,窄屏排版与底部 Tab 栏归手机适配。
130
+ // 这里只留 LAN 非安全上下文的 crypto.randomUUID 兜底(远程设备经 http://<局域网IP>
131
+ // 访问时缺该 API,DSH 客户端引导会整片失败)。
132
+ /** 把 LAN 兼容脚本内联注入 index.html 的 <head> 之后(幂等)。 */
298
133
  function injectLanCompat(html) {
299
134
  let out = html
300
135
  const match = /<head(?:\s[^>]*)?>/i.exec(out)
301
136
  const at = match ? match.index + match[0].length : 0
302
137
  const parts = []
303
138
  if (!out.includes(LAN_COMPAT_MARKER)) parts.push(`<script ${LAN_COMPAT_MARKER}>${LAN_COMPAT_JS}</script>`)
304
- if (!out.includes(MOBILE_LAYOUT_MARKER)) parts.push(`<style ${MOBILE_LAYOUT_MARKER}>${MOBILE_LAYOUT_CSS}</style>`)
305
- if (!out.includes(MOBILE_BEHAVIOR_MARKER)) parts.push(`<script ${MOBILE_BEHAVIOR_MARKER}>${MOBILE_BEHAVIOR_JS}</script>`)
306
139
  if (!parts.length) return out
307
140
  const injected = parts.join('')
308
141
  return at > 0 ? out.slice(0, at) + injected + out.slice(at) : injected + out
@@ -457,11 +290,19 @@ export const feature = {
457
290
  webserverActive = legacyWebserverRowPresent(list)
458
291
  } catch { applied = false }
459
292
  const auth = getAuth()
293
+ let saved = null
294
+ try {
295
+ const root = readDockRoot(ctx)
296
+ saved = root && root.remoteGateway ? root.remoteGateway : null
297
+ } catch { saved = null }
460
298
  return {
461
299
  gatewayActive: Boolean(gateway),
462
300
  gatewayPort: gateway ? gateway.port : null,
301
+ gatewayEnabled: Boolean(saved && saved.enabled),
463
302
  mainPort: webServer.port,
464
303
  addresses: lanAddresses(),
304
+ /** 经网关登录的会话数(手机/平板各占一条;面板据此显示在线设备数)。 */
305
+ devices: gateway && typeof gateway.sessionCount === 'function' ? gateway.sessionCount() : 0,
465
306
  patchApplied: applied,
466
307
  webserverActive,
467
308
  accountSet: Boolean(auth),
@@ -470,6 +311,18 @@ export const feature = {
470
311
  }
471
312
  }
472
313
 
314
+ /** 持久化网关开关(remoteGateway 段):dsh web 重启后据此自动拉起。 */
315
+ async function persistGatewayState(enabled, port) {
316
+ try {
317
+ await mutateDockSection(ctx, ['remoteGateway'], {
318
+ enabled: Boolean(enabled),
319
+ port: Number.isInteger(port) && port > 0 ? port : 0,
320
+ })
321
+ } catch (error) {
322
+ console.warn('[dsh-dock] 远程访问开关状态保存失败:', (error && error.message) || String(error))
323
+ }
324
+ }
325
+
473
326
  async function stopGateway() {
474
327
  const current = gateway
475
328
  gateway = null
@@ -586,13 +439,17 @@ export const feature = {
586
439
  const list = readPatchList(file) || []
587
440
  const changed = !serverPatchApplied(list) || legacyWebserverRowPresent(list)
588
441
  writePatchList(file, upsertRemotePatches(list))
589
- await ensureGateway(wsCtx.webServer, payload && payload.port, () => mintUpstreamSession(wsCtx.webServer.port))
442
+ const started = await ensureGateway(wsCtx.webServer, payload && payload.port, () => mintUpstreamSession(wsCtx.webServer.port))
443
+ // 记住「开过」:重启 dsh web 后凭此自动拉起(账号存在才拉)。
444
+ await persistGatewayState(true, started.port)
590
445
  return sendJson(res, 200, { ok: true, data: { ...lanStatus(wsCtx.webServer), needsRestart: changed } })
591
446
  }
592
447
  if (method === 'lan/stop') {
593
448
  const file = serverPatchFile()
594
449
  const list = readPatchList(file)
450
+ const wasPort = gateway ? gateway.port : 0
595
451
  await stopGateway()
452
+ await persistGatewayState(false, wasPort)
596
453
  if (list === null) {
597
454
  return sendJson(res, 200, { ok: true, data: { ...lanStatus(wsCtx.webServer), needsRestart: false } })
598
455
  }
@@ -608,6 +465,34 @@ export const feature = {
608
465
  }
609
466
  },
610
467
  }), 'dsh-dock mobile relay: /dsh-dock/mobile-relay HTTP route')
468
+
469
+ // ── 网关自动拉起 ──
470
+ // 面板开过远程访问(remoteGateway.enabled=true)就持久记住:dsh web 重启后
471
+ // 只要账号还在,直接把网关重新监听起来,局域网设备不用等进面板再点一次。
472
+ // 门禁语义不变:未登录一律 302 到登录页;账号被清掉则只日志提醒不拉起。
473
+ void (async () => {
474
+ try {
475
+ const root = readDockRoot(ctx)
476
+ const saved = root && root.remoteGateway
477
+ if (!saved || !saved.enabled) return
478
+ if (!getAuth()) {
479
+ console.warn('[dsh-dock] 远程访问曾开启但账号不存在,网关未自动拉起(请在面板重新设置账号)')
480
+ return
481
+ }
482
+ // 目录选择器浏览模式补丁若被清掉则补回(幂等;重启生效,网关本身不受影响)。
483
+ try {
484
+ const file = serverPatchFile()
485
+ const list = readPatchList(file) || []
486
+ if (!serverPatchApplied(list) || legacyWebserverRowPresent(list)) {
487
+ writePatchList(file, upsertRemotePatches(list))
488
+ }
489
+ } catch { /* 补丁层异常不阻断网关 */ }
490
+ const started = await ensureGateway(wsCtx.webServer, saved.port || undefined, () => mintUpstreamSession(wsCtx.webServer.port))
491
+ console.log(`[dsh-dock] 远程访问网关已自动拉起: http://<本机IP>:${started.port}`)
492
+ } catch (error) {
493
+ console.warn('[dsh-dock] 远程访问网关自动拉起失败:', (error && error.message) || String(error))
494
+ }
495
+ })()
611
496
  }))
612
497
 
613
498
  return () => {