dsh-remote-plugin 0.6.16 → 0.6.17

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.
Binary file
package/gateway.cjs CHANGED
@@ -22,6 +22,8 @@
22
22
  * DSH_REMOTE_FS_ROOT 文件传输额外允许根, 默认 ~, 使用系统路径分隔符配置多根
23
23
  * DSH_REMOTE_FS_MAX_UPLOAD 上传字节上限, 默认 2147483648 (2GB)
24
24
  * DSH_REMOTE_WORKBENCH 工作台绑定文件, 默认 ~/.dsh-remote/workbench.json
25
+ * DSH_REMOTE_ADVERTISE_HOSTS 额外写入配对二维码的宿主 IP/主机名, 逗号或空白分隔
26
+ * DSH_REMOTE_DSH_CONTROL_MODE DSH 生命周期后端: auto/systemd/windows/disabled
25
27
  */
26
28
  'use strict'
27
29
 
@@ -84,6 +86,8 @@ const STARTED_AT = Date.now()
84
86
  const DSH_SERVICE = String(process.env.DSH_REMOTE_DSH_SERVICE || 'dsh-web').trim()
85
87
  const SYSTEMCTL = String(process.env.DSH_REMOTE_SYSTEMCTL || 'systemctl').trim() || 'systemctl'
86
88
  const WINDOWS_SC = String(process.env.DSH_REMOTE_WINDOWS_SC || 'sc.exe').trim() || 'sc.exe'
89
+ const DSH_CONTROL_MODE_RAW = String(process.env.DSH_REMOTE_DSH_CONTROL_MODE || 'auto').trim().toLowerCase()
90
+ const DSH_CONTROL_MODE = ['auto', 'systemd', 'windows', 'disabled'].includes(DSH_CONTROL_MODE_RAW) ? DSH_CONTROL_MODE_RAW : 'auto'
87
91
  const DSH_CONTROL_TIMEOUT_MS = durationEnv('DSH_REMOTE_DSH_CONTROL_TIMEOUT_MS', 45000, 2000, 5 * 60 * 1000)
88
92
  const DSH_CONTROL_POLL_MS = durationEnv('DSH_REMOTE_DSH_CONTROL_POLL_MS', 500, 50, 5000)
89
93
  const HTTP_REQUEST_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_REQUEST_TIMEOUT_MS', 15 * 60 * 1000, 0, 24 * 60 * 60 * 1000)
@@ -104,6 +108,52 @@ function gatewayVersion() {
104
108
  return '0.0.0'
105
109
  }
106
110
  }
111
+
112
+ function validAdvertisedHost(value) {
113
+ const host = String(value || '').trim()
114
+ if (!host || host.length > 253 || host === '0.0.0.0' || host === '127.0.0.1') return ''
115
+ if (!/^[A-Za-z0-9.-]+$/.test(host) || host.startsWith('.') || host.endsWith('.') || host.includes('..')) return ''
116
+ if (/^\d+(?:\.\d+){3}$/.test(host) && host.split('.').some(part => Number(part) > 255)) return ''
117
+ const labels = host.split('.')
118
+ if (labels.some(label => !label || label.length > 63 || label.startsWith('-') || label.endsWith('-'))) return ''
119
+ return host
120
+ }
121
+
122
+ function configuredAdvertisedHosts() {
123
+ return [...new Set(String(process.env.DSH_REMOTE_ADVERTISE_HOSTS || '')
124
+ .split(/[\s,]+/)
125
+ .map(validAdvertisedHost)
126
+ .filter(Boolean))]
127
+ }
128
+
129
+ function containerRuntimeDetected() {
130
+ if (fs.existsSync('/.dockerenv')) return true
131
+ try { return /(?:docker|containerd|kubepods|podman|lxc)/i.test(fs.readFileSync('/proc/1/cgroup', 'utf8')) } catch { return false }
132
+ }
133
+
134
+ function dshControlSupport() {
135
+ if (DSH_CONTROL_MODE === 'disabled') {
136
+ return { supported: false, code: 'EXTERNAL_LIFECYCLE', message: '当前 DSH 由 Docker、面板或其他外部平台管理' }
137
+ }
138
+ if (process.platform === 'win32') {
139
+ if (DSH_CONTROL_MODE === 'systemd') return { supported: false, code: 'CONTROL_MODE_MISMATCH', message: 'Windows 环境不能使用 systemd 控制 DSH' }
140
+ return { supported: true, manager: 'windows' }
141
+ }
142
+ if (DSH_CONTROL_MODE === 'windows') return { supported: false, code: 'CONTROL_MODE_MISMATCH', message: '当前系统不能使用 Windows Service 控制 DSH' }
143
+ if (DSH_CONTROL_MODE === 'systemd' || (process.env.DSH_REMOTE_SYSTEMCTL && SYSTEMCTL !== 'systemctl')) {
144
+ return { supported: true, manager: 'systemd' }
145
+ }
146
+ if (containerRuntimeDetected()) {
147
+ return { supported: false, code: 'EXTERNAL_LIFECYCLE', message: '当前网关运行在容器中,DSH 生命周期应由 Docker、面板或其他外部平台管理' }
148
+ }
149
+ if (!fs.existsSync('/run/systemd/system')) {
150
+ return { supported: false, code: 'EXTERNAL_LIFECYCLE', message: '当前环境没有 systemd,DSH 可能由 Docker、面板或其他外部平台管理' }
151
+ }
152
+ return { supported: true, manager: 'systemd' }
153
+ }
154
+
155
+ const ADVERTISED_HOSTS = configuredAdvertisedHosts()
156
+ const DSH_CONTROL_SUPPORT = dshControlSupport()
107
157
  const VERSION = gatewayVersion()
108
158
  const PROTOCOL_VERSION = 1
109
159
  const CAPABILITIES = Object.freeze({
@@ -111,7 +161,7 @@ const CAPABILITIES = Object.freeze({
111
161
  eventPolling: 1,
112
162
  workspaceFiles: 2,
113
163
  imagePromptTransport: 1,
114
- dshLifecycle: 2,
164
+ dshLifecycle: DSH_CONTROL_SUPPORT.supported ? 2 : 0,
115
165
  centralAnnouncements: 2,
116
166
  feedback: 1,
117
167
  deviceKeys: 1,
@@ -141,10 +191,16 @@ const MIME = {
141
191
  // 所有 /fs/* 路径 resolve 后都必须位于某个根内, 已存在的路径还会用 realpath
142
192
  // 复核一次, 防止 ../ 穿越与符号链接逃逸。
143
193
  const FS_DEFAULT_ROOT = path.resolve(os.homedir())
194
+ function fsConfiguredRoot(value) {
195
+ const raw = String(value || '').trim()
196
+ if (!raw || raw === '~') return raw === '~' ? FS_DEFAULT_ROOT : ''
197
+ if (/^~[\\/]/.test(raw)) return path.resolve(FS_DEFAULT_ROOT, raw.slice(2))
198
+ return path.resolve(raw)
199
+ }
144
200
  const FS_ROOTS = (process.env.DSH_REMOTE_FS_ROOT || FS_DEFAULT_ROOT)
145
201
  .split(path.delimiter)
202
+ .map(fsConfiguredRoot)
146
203
  .filter(Boolean)
147
- .map(r => path.resolve(r.trim() === '~' ? FS_DEFAULT_ROOT : r.trim()))
148
204
  const FS_WORKSPACE_CACHE_MS = durationEnv('DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 15_000, 1000, 10 * 60_000)
149
205
  const FS_MAX_UPLOAD = Number(process.env.DSH_REMOTE_FS_MAX_UPLOAD) || 2 * 1024 * 1024 * 1024
150
206
  const FS_UPLOAD_TTL_MS = durationEnv('DSH_REMOTE_FS_UPLOAD_TTL_MS', 24 * 60 * 60 * 1000, 60_000, 7 * 24 * 60 * 60 * 1000)
@@ -159,7 +215,7 @@ function fsRootReals() {
159
215
  }
160
216
  function fsInsideReal(real) {
161
217
  for (const root of [...fsRootReals(), ...fsWorkspaceRootsCache.reals]) {
162
- if (real === root || real.startsWith(root + path.sep)) return true
218
+ if (fsInsideRoot(real, root)) return true
163
219
  }
164
220
  return false
165
221
  }
@@ -925,6 +981,9 @@ async function dshServiceStatus() {
925
981
  if (!/^[A-Za-z0-9_.@-]+$/.test(DSH_SERVICE)) {
926
982
  return { ok: false, supported: false, running: false, service: DSH_SERVICE, code: 'INVALID_SERVICE', message: 'DSH_REMOTE_DSH_SERVICE 服务名配置不合法' }
927
983
  }
984
+ if (!DSH_CONTROL_SUPPORT.supported) {
985
+ return { ok: true, supported: false, running: false, service: DSH_SERVICE, ...DSH_CONTROL_SUPPORT }
986
+ }
928
987
  if (process.platform === 'win32') return windowsServiceStatus()
929
988
  const r = await execFileResult(SYSTEMCTL, [
930
989
  '--user', 'show', DSH_SERVICE,
@@ -2026,6 +2085,7 @@ function serveAdminApi(req, res, url) {
2026
2085
  port: PORT,
2027
2086
  protocol: { version: PROTOCOL_VERSION },
2028
2087
  capabilities: CAPABILITIES,
2088
+ dshControl: DSH_CONTROL_SUPPORT,
2029
2089
  upstream: { url: UPSTREAM.origin, reachable },
2030
2090
  latest: {
2031
2091
  version: latestState.version,
@@ -2243,8 +2303,19 @@ function fsAuthorized(req, url, res) {
2243
2303
  return true
2244
2304
  }
2245
2305
 
2306
+ function fsInsideRootFor(pathApi, abs, root, caseInsensitive = false) {
2307
+ let candidate = pathApi.resolve(String(abs || ''))
2308
+ let boundary = pathApi.resolve(String(root || ''))
2309
+ if (caseInsensitive) {
2310
+ candidate = candidate.toLowerCase()
2311
+ boundary = boundary.toLowerCase()
2312
+ }
2313
+ const relative = pathApi.relative(boundary, candidate)
2314
+ return relative === '' || (relative !== '..' && !relative.startsWith('..' + pathApi.sep) && !pathApi.isAbsolute(relative))
2315
+ }
2316
+
2246
2317
  function fsInsideRoot(abs, root) {
2247
- return abs === root || abs.startsWith(root + path.sep)
2318
+ return fsInsideRootFor(path, abs, root, process.platform === 'win32')
2248
2319
  }
2249
2320
 
2250
2321
  function fsWorkspacePath(value) {
@@ -2289,7 +2360,7 @@ async function fsResolve(input) {
2289
2360
  const raw = String(input ?? '').trim()
2290
2361
  let abs
2291
2362
  if (!raw || raw === '~') abs = FS_ROOTS[0]
2292
- else if (raw.startsWith('~/')) abs = path.resolve(FS_DEFAULT_ROOT, raw.slice(2))
2363
+ else if (/^~[\\/]/.test(raw)) abs = path.resolve(FS_DEFAULT_ROOT, raw.slice(2))
2293
2364
  else if (path.isAbsolute(raw)) abs = path.resolve(raw)
2294
2365
  else abs = path.resolve(FS_ROOTS[0], raw) // 相对路径按默认根解析
2295
2366
  if (FS_ROOTS.some(root => fsInsideRoot(abs, root))) return { abs }
@@ -2386,6 +2457,7 @@ async function fsList(req, res, url) {
2386
2457
  if (!info.isFile() && !info.isDirectory()) continue
2387
2458
  entries.push({
2388
2459
  name: d.name,
2460
+ path: full,
2389
2461
  type: info.isDirectory() ? 'dir' : 'file',
2390
2462
  size: info.isDirectory() ? 0 : info.size,
2391
2463
  mtimeMs: Math.round(info.mtimeMs)
@@ -2398,7 +2470,13 @@ async function fsList(req, res, url) {
2398
2470
  if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
2399
2471
  return a.name.localeCompare(b.name, 'zh-CN', { numeric: true })
2400
2472
  })
2401
- fsJson(res, 200, { path: resolved.abs, entries })
2473
+ fsJson(res, 200, {
2474
+ path: resolved.abs,
2475
+ entries,
2476
+ roots: FS_ROOTS,
2477
+ platform: process.platform,
2478
+ separator: path.sep,
2479
+ })
2402
2480
  }
2403
2481
 
2404
2482
  async function fsFile(req, res, url) {
@@ -3309,6 +3387,7 @@ async function serveHealth(req, res, url) {
3309
3387
  version: VERSION,
3310
3388
  protocol: { version: PROTOCOL_VERSION },
3311
3389
  capabilities: CAPABILITIES,
3390
+ dshControl: DSH_CONTROL_SUPPORT,
3312
3391
  pid: process.pid,
3313
3392
  upstream: UPSTREAM.origin,
3314
3393
  upstreamProbe: DSH_HEALTH_PATH,
@@ -3322,12 +3401,12 @@ async function serveHealth(req, res, url) {
3322
3401
  }
3323
3402
 
3324
3403
  function lanAddresses() {
3325
- const out = []
3404
+ const out = [...ADVERTISED_HOSTS]
3326
3405
  let groups
3327
3406
  try { groups = Object.values(os.networkInterfaces()) } catch { return out }
3328
3407
  for (const infos of groups) {
3329
3408
  for (const info of infos || []) {
3330
- if (info.family === 'IPv4' && !info.internal) out.push(info.address)
3409
+ if (info.family === 'IPv4' && !info.internal && !out.includes(info.address)) out.push(info.address)
3331
3410
  }
3332
3411
  }
3333
3412
  return out
package/index.mjs CHANGED
@@ -70,17 +70,34 @@ try {
70
70
  let dshListen = { host: '127.0.0.1', port: 3080 }
71
71
 
72
72
  function lanIPs() {
73
- const out = []
73
+ const out = [...configuredAdvertisedHosts()]
74
74
  let groups
75
75
  try { groups = Object.values(networkInterfaces()) } catch { return out }
76
76
  for (const list of groups) {
77
77
  for (const it of list ?? []) {
78
- if (it.family === 'IPv4' && !it.internal) out.push(it.address)
78
+ if (it.family === 'IPv4' && !it.internal && !out.includes(it.address)) out.push(it.address)
79
79
  }
80
80
  }
81
81
  return out
82
82
  }
83
83
 
84
+ function validAdvertisedHost(value) {
85
+ const host = String(value || '').trim()
86
+ if (!host || host.length > 253 || host === '0.0.0.0' || host === '127.0.0.1') return ''
87
+ if (!/^[A-Za-z0-9.-]+$/.test(host) || host.startsWith('.') || host.endsWith('.') || host.includes('..')) return ''
88
+ if (/^\d+(?:\.\d+){3}$/.test(host) && host.split('.').some(part => Number(part) > 255)) return ''
89
+ const labels = host.split('.')
90
+ if (labels.some(label => !label || label.length > 63 || label.startsWith('-') || label.endsWith('-'))) return ''
91
+ return host
92
+ }
93
+
94
+ function configuredAdvertisedHosts() {
95
+ return [...new Set(String(process.env.DSH_REMOTE_ADVERTISE_HOSTS || '')
96
+ .split(/[\s,]+/)
97
+ .map(validAdvertisedHost)
98
+ .filter(Boolean))]
99
+ }
100
+
84
101
  function targetPath(pathname) {
85
102
  let rel
86
103
  try {
@@ -152,7 +169,7 @@ function runExit(cmd, args) {
152
169
 
153
170
  const GATEWAY_ENV_KEYS = [
154
171
  'TOKEN', 'TOKEN_FILE', 'DSH_REMOTE_TOKEN', 'DSH_REMOTE_DEVICE_KEYS', 'DSH_REMOTE_FS_ROOT', 'DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 'DSH_REMOTE_FS_MAX_UPLOAD',
155
- 'DSH_REMOTE_NOTES', 'DSH_REMOTE_WORKBENCH', 'DSH_REMOTE_DSH_SERVICE', 'DSH_REMOTE_SYSTEMCTL',
172
+ 'DSH_REMOTE_NOTES', 'DSH_REMOTE_WORKBENCH', 'DSH_REMOTE_ADVERTISE_HOSTS', 'DSH_REMOTE_DSH_SERVICE', 'DSH_REMOTE_SYSTEMCTL', 'DSH_REMOTE_DSH_CONTROL_MODE',
156
173
  'DSH_REMOTE_DSH_CONTROL_TIMEOUT_MS', 'DSH_REMOTE_DSH_CONTROL_POLL_MS', 'DSH_REMOTE_FEEDBACK_URL',
157
174
  'UPDATE_CHECK_URL', 'UPDATE_INTERVAL_MS', 'UPDATE_PROXY', 'DSH_HEALTH_PATH',
158
175
  'GATEWAY_WS_IDLE_MS', 'GATEWAY_WS_PING_MS', 'GATEWAY_WS_PONG_TIMEOUT_MS',
@@ -552,6 +569,7 @@ async function serveStatic(req, res, ctx) {
552
569
  feedback: 0,
553
570
  deviceKeys: 0,
554
571
  },
572
+ dshControl: { supported: false, code: 'EXTERNAL_LIFECYCLE', message: '插件内嵌模式不负责管理 DSH 自身进程' },
555
573
  deviceKeys: { supported: false, enabled: false, entries: [] },
556
574
  upstream: { url: 'DSH 内嵌(同进程, 无需网关)', reachable: true },
557
575
  latest: { version, newer: false },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.6.16",
3
+ "version": "0.6.17",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
package/public/admin.html CHANGED
@@ -113,6 +113,8 @@
113
113
  .host-ip-title { font-size: 16px; font-weight: 750; }
114
114
  .host-ip-head .muted { margin-top: 4px; font-size: 12px; }
115
115
  .host-ip-badge { flex: none; padding: 5px 9px; border: 1px solid var(--dsr-accent-line); border-radius: 999px; color: var(--dsr-accent-strong); background: var(--dsr-accent-soft); font-size: 11px; font-weight: 700; }
116
+ .host-ip-head-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
117
+ .host-ip-remove { margin-left: 8px; padding: 3px 7px; }
116
118
  .host-ip-desc { margin: 12px 0; color: var(--dsr-muted); font-size: 12px; line-height: 1.55; }
117
119
  .host-ip-table-wrap { overflow: hidden; border: 1px solid var(--dsr-line); border-radius: 13px; }
118
120
  .host-ip-table { width: 100%; border-collapse: collapse; }
@@ -465,7 +467,10 @@
465
467
  <div class="host-ip-title" data-i18n="hostIPs.title">主机 IP 地址</div>
466
468
  <div id="host-ip-summary" class="muted" data-i18n="hostIPs.loading">正在读取主机地址…</div>
467
469
  </div>
468
- <div class="host-ip-badge" data-i18n="hostIPs.badge">手机连接候选</div>
470
+ <div class="host-ip-head-actions">
471
+ <button id="btn-host-ip-add" class="mini-btn" type="button" data-i18n="hostIPs.add">添加宿主地址</button>
472
+ <div class="host-ip-badge" data-i18n="hostIPs.badge">手机连接候选</div>
473
+ </div>
469
474
  </div>
470
475
  <div class="host-ip-desc" data-i18n="hostIPs.desc">关闭虚拟机、容器或其他无效地址后,它们不会再写入配对二维码,也不会作为诊断和防火墙建议地址。</div>
471
476
  <div class="host-ip-table-wrap">
@@ -473,7 +478,7 @@
473
478
  <thead><tr><th data-i18n="hostIPs.enabledColumn">启用</th><th data-i18n="hostIPs.addressColumn">主机 IP</th><th data-i18n="hostIPs.statusColumn">状态</th></tr></thead>
474
479
  <tbody id="host-ip-rows"></tbody>
475
480
  </table>
476
- <div id="host-ip-empty" class="host-ip-empty hidden" data-i18n="hostIPs.empty">暂无可用的非回环 IPv4 地址</div>
481
+ <div id="host-ip-empty" class="host-ip-empty hidden" data-i18n="hostIPs.empty">暂无可用的非回环地址</div>
477
482
  </div>
478
483
  </section>
479
484
 
@@ -601,13 +606,13 @@
601
606
  'badge.embedded': '内嵌', 'badge.gateway': '网关', 'badge.connected': '已连接',
602
607
  'badge.gateway.title': '新标签页打开网关管理面板', 'badge.gatewayDown': '网关未运行',
603
608
  'token.pluginNoGateway': '插件模式 · 未接网关, 无需令牌', 'token.unavailable': '未获取到令牌',
604
- 'toast.tokenInvalid': '令牌无效', 'toast.connFailed': '连接失败',
609
+ 'toast.tokenInvalid': '令牌无效', 'toast.connFailed': '连接失败', 'toast.authLayer': '登录代理拦截了管理 API,请检查 /remote/* 的认证规则',
605
610
  'stat.pluginVersion': '插件版本', 'stat.gatewayVersion': '网关版本',
606
611
  'stat.updateAvailable': '{version} 可用', 'stat.currentV': '当前 v{version}',
607
612
  'stat.download': '去下载', 'stat.embedded': 'DSH 内嵌 · 免网关',
608
613
  'stat.updateCheck': '更新检查: {error}', 'stat.latest': '已是最新(来源检查)', 'stat.notChecked': '未检查更新',
609
614
  'stat.hostIP': '主机 IP · {hostname}', 'stat.ipSep': '、', 'stat.phoneGateway': ' (手机连 {port} 网关)', 'stat.phoneThis': ' (手机连这个地址)',
610
- 'hostIPs.title': '主机 IP 地址', 'hostIPs.badge': '手机连接候选', 'hostIPs.loading': '正在读取主机地址…', 'hostIPs.summary': '已启用 {enabled}/{total} 个地址', 'hostIPs.desc': '关闭虚拟机、容器或其他无效地址后,它们不会再写入配对二维码,也不会作为诊断和防火墙建议地址。', 'hostIPs.enabledColumn': '启用', 'hostIPs.addressColumn': '主机 IP', 'hostIPs.statusColumn': '状态', 'hostIPs.enabled': '已启用 · 会用于配对', 'hostIPs.disabled': '已关闭 · 不用于配对', 'hostIPs.enable': '切换此地址', 'hostIPs.empty': '暂无可用的非回环 IPv4 地址', 'hostIPs.keepOne': '至少保留一个可用地址',
615
+ 'hostIPs.title': '主机 IP 地址', 'hostIPs.badge': '手机连接候选', 'hostIPs.loading': '正在读取主机地址…', 'hostIPs.summary': '已启用 {enabled}/{total} 个地址', 'hostIPs.desc': '关闭虚拟机、容器或其他无效地址后,它们不会再写入配对二维码,也不会作为诊断和防火墙建议地址。Docker 中可手动添加宿主地址。', 'hostIPs.enabledColumn': '启用', 'hostIPs.addressColumn': '主机 IP', 'hostIPs.statusColumn': '状态', 'hostIPs.enabled': '已启用 · 会用于配对', 'hostIPs.disabled': '已关闭 · 不用于配对', 'hostIPs.enable': '切换此地址', 'hostIPs.empty': '暂无可用的非回环地址', 'hostIPs.keepOne': '至少保留一个可用地址', 'hostIPs.add': '添加宿主地址', 'hostIPs.addPrompt': '输入手机可访问的宿主 IP 或主机名:', 'hostIPs.invalid': '地址格式无效', 'hostIPs.exists': '该地址已在列表中', 'hostIPs.remove': '删除',
611
616
  'stat.reachable': '可达', 'stat.unreachable': '不可达', 'stat.dshUpstream': 'DSH 上游 {url}',
612
617
  'stat.devicesOnline': '设备在线 / 累计', 'stat.totalRequests': '总请求数', 'stat.authFailures': '认证失败',
613
618
  'stat.uptime': '运行时长 · {host}:{port}',
@@ -711,13 +716,13 @@
711
716
  'badge.embedded': 'Embedded', 'badge.gateway': 'Gateway', 'badge.connected': 'Connected',
712
717
  'badge.gateway.title': 'Open gateway admin in a new tab', 'badge.gatewayDown': 'Gateway not running',
713
718
  'token.pluginNoGateway': 'Plugin mode · no gateway, no token needed', 'token.unavailable': 'Token unavailable',
714
- 'toast.tokenInvalid': 'Invalid token', 'toast.connFailed': 'Connection failed',
719
+ 'toast.tokenInvalid': 'Invalid token', 'toast.connFailed': 'Connection failed', 'toast.authLayer': 'The login proxy blocked the admin API; check authentication rules for /remote/*',
715
720
  'stat.pluginVersion': 'Plugin version', 'stat.gatewayVersion': 'Gateway version',
716
721
  'stat.updateAvailable': 'v{version} available', 'stat.currentV': 'Current v{version}',
717
722
  'stat.download': 'Download', 'stat.embedded': 'Embedded in DSH · no gateway',
718
723
  'stat.updateCheck': 'Update check: {error}', 'stat.latest': 'Up to date (source check)', 'stat.notChecked': 'Not checked',
719
724
  'stat.hostIP': 'Host IP · {hostname}', 'stat.ipSep': ', ', 'stat.phoneGateway': ' (phone connects to gateway {port})', 'stat.phoneThis': ' (phone connects to this address)',
720
- 'hostIPs.title': 'Host IP addresses', 'hostIPs.badge': 'Phone connection candidates', 'hostIPs.loading': 'Reading host addresses…', 'hostIPs.summary': '{enabled}/{total} addresses enabled', 'hostIPs.desc': 'Disabled virtual-machine, container, or other invalid addresses are excluded from pairing QR codes, diagnostics, and firewall suggestions.', 'hostIPs.enabledColumn': 'Enabled', 'hostIPs.addressColumn': 'Host IP', 'hostIPs.statusColumn': 'Status', 'hostIPs.enabled': 'Enabled · used for pairing', 'hostIPs.disabled': 'Disabled · excluded from pairing', 'hostIPs.enable': 'Toggle this address', 'hostIPs.empty': 'No usable non-loopback IPv4 address', 'hostIPs.keepOne': 'Keep at least one usable address enabled',
725
+ 'hostIPs.title': 'Host IP addresses', 'hostIPs.badge': 'Phone connection candidates', 'hostIPs.loading': 'Reading host addresses…', 'hostIPs.summary': '{enabled}/{total} addresses enabled', 'hostIPs.desc': 'Disabled virtual-machine, container, or other invalid addresses are excluded from pairing QR codes, diagnostics, and firewall suggestions. In Docker, add a host address manually.', 'hostIPs.enabledColumn': 'Enabled', 'hostIPs.addressColumn': 'Host IP', 'hostIPs.statusColumn': 'Status', 'hostIPs.enabled': 'Enabled · used for pairing', 'hostIPs.disabled': 'Disabled · excluded from pairing', 'hostIPs.enable': 'Toggle this address', 'hostIPs.empty': 'No usable non-loopback address', 'hostIPs.keepOne': 'Keep at least one usable address enabled', 'hostIPs.add': 'Add host address', 'hostIPs.addPrompt': 'Enter a host IP or hostname reachable by the phone:', 'hostIPs.invalid': 'Invalid address format', 'hostIPs.exists': 'This address is already listed', 'hostIPs.remove': 'Remove',
721
726
  'stat.reachable': 'Reachable', 'stat.unreachable': 'Unreachable', 'stat.dshUpstream': 'DSH upstream {url}',
722
727
  'stat.devicesOnline': 'Devices online / total', 'stat.totalRequests': 'Total requests', 'stat.authFailures': 'Auth failures',
723
728
  'stat.uptime': 'Uptime · {host}:{port}',
package/public/admin.js CHANGED
@@ -18,6 +18,13 @@ const store = {
18
18
  del(k) { try { localStorage.removeItem(k) } catch {} }
19
19
  }
20
20
  let token = store.get('dshAdminToken') || new URLSearchParams(location.search).get('token') || ''
21
+ function adminHeaders(extra = {}, accessToken = token) {
22
+ const headers = { 'x-dsh-remote-client': 'admin', ...extra }
23
+ // /remote/* 可能由 Caddy Basic Auth 保护。插件路由自身已在 DSH 登录态内,
24
+ // 这里不能再写 Authorization: Bearer,否则会覆盖浏览器的 Basic 凭据并形成 401 循环。
25
+ if (!pluginMode && accessToken) headers.authorization = 'Bearer ' + accessToken
26
+ return headers
27
+ }
21
28
  let timer = null
22
29
  let gatewayRunning = false
23
30
  let gatewayBusy = false
@@ -32,17 +39,31 @@ let gatewayPortLoaded = false
32
39
  let doctorExpanded = store.get('dshAdminDoctorCollapsed') !== '1'
33
40
  let doctorChecks = []
34
41
  const HOST_IP_SELECTION_KEY = 'dshAdminEnabledHostIPsV1'
42
+ const MANUAL_HOST_IP_KEY = 'dshAdminManualHostIPsV1'
35
43
 
36
44
  function normalizedHostIPs(st) {
37
- return Array.isArray(st?.lanIPs)
38
- ? [...new Set(st.lanIPs.map(value => String(value || '').trim()).filter(value => value && value !== '127.0.0.1' && value !== '0.0.0.0'))]
39
- : []
45
+ const detected = Array.isArray(st?.lanIPs) ? st.lanIPs : []
46
+ return [...new Set([...detected, ...manualHostIPs(st)].map(validManualHost).filter(Boolean))]
47
+ }
48
+
49
+ function validManualHost(value) {
50
+ const host = String(value || '').trim()
51
+ if (!host || host.length > 253 || host === '0.0.0.0' || host === '127.0.0.1') return ''
52
+ if (!/^[A-Za-z0-9.-]+$/.test(host) || host.startsWith('.') || host.endsWith('.') || host.includes('..')) return ''
53
+ if (/^\d+(?:\.\d+){3}$/.test(host) && host.split('.').some(part => Number(part) > 255)) return ''
54
+ const labels = host.split('.')
55
+ if (labels.some(label => !label || label.length > 63 || label.startsWith('-') || label.endsWith('-'))) return ''
56
+ return host
40
57
  }
41
58
 
42
59
  function hostIPScope(st) {
43
60
  return [st?.hostname || location.hostname || 'host', st?.host || ''].join('|')
44
61
  }
45
62
 
63
+ function manualHostIPScope(st) {
64
+ return st?.hostname || location.hostname || 'host'
65
+ }
66
+
46
67
  function hostIPPreferences() {
47
68
  try {
48
69
  const value = JSON.parse(store.get(HOST_IP_SELECTION_KEY) || '{}')
@@ -52,6 +73,26 @@ function hostIPPreferences() {
52
73
  }
53
74
  }
54
75
 
76
+ function manualHostPreferences() {
77
+ try {
78
+ const value = JSON.parse(store.get(MANUAL_HOST_IP_KEY) || '{}')
79
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
80
+ } catch {
81
+ return {}
82
+ }
83
+ }
84
+
85
+ function manualHostIPs(st) {
86
+ const values = manualHostPreferences()[manualHostIPScope(st)]
87
+ return Array.isArray(values) ? [...new Set(values.map(validManualHost).filter(Boolean))] : []
88
+ }
89
+
90
+ function saveManualHostIPs(st, values) {
91
+ const prefs = manualHostPreferences()
92
+ prefs[manualHostIPScope(st)] = [...new Set(values.map(validManualHost).filter(Boolean))]
93
+ store.set(MANUAL_HOST_IP_KEY, JSON.stringify(prefs))
94
+ }
95
+
55
96
  function enabledHostIPs(st) {
56
97
  const all = normalizedHostIPs(st)
57
98
  if (!all.length) return []
@@ -67,9 +108,33 @@ function saveEnabledHostIPs(st, selected) {
67
108
  store.set(HOST_IP_SELECTION_KEY, JSON.stringify(prefs))
68
109
  }
69
110
 
111
+ function addManualHostIP() {
112
+ if (!lastState) return
113
+ const input = prompt(t('hostIPs.addPrompt'), '')
114
+ if (input === null) return
115
+ const host = validManualHost(input)
116
+ if (!host) {
117
+ toast(t('hostIPs.invalid'), 'err')
118
+ return
119
+ }
120
+ if (normalizedHostIPs(lastState).includes(host)) {
121
+ toast(t('hostIPs.exists'), 'err')
122
+ return
123
+ }
124
+ saveManualHostIPs(lastState, [...manualHostIPs(lastState), host])
125
+ saveEnabledHostIPs(lastState, [...enabledHostIPs(lastState), host])
126
+ render(lastState)
127
+ }
128
+
129
+ function removeManualHostIP(st, host) {
130
+ saveManualHostIPs(st, manualHostIPs(st).filter(value => value !== host))
131
+ render(st)
132
+ }
133
+
70
134
  function renderHostIPs(st) {
71
135
  const all = normalizedHostIPs(st)
72
136
  const selected = enabledHostIPs(st)
137
+ const manual = new Set(manualHostIPs(st))
73
138
  const rows = $('host-ip-rows')
74
139
  const empty = $('host-ip-empty')
75
140
  const summary = $('host-ip-summary')
@@ -80,7 +145,7 @@ function renderHostIPs(st) {
80
145
  rows.innerHTML = all.map(ip => `<tr>
81
146
  <td class="host-ip-toggle"><label class="host-ip-switch" title="${esc(t('hostIPs.enable'))}"><input type="checkbox" data-host-ip-toggle="${esc(ip)}" ${selected.includes(ip) ? 'checked' : ''}><span aria-hidden="true"></span></label></td>
82
147
  <td class="mono host-ip-value">${esc(ip)}</td>
83
- <td class="host-ip-use">${esc(t(selected.includes(ip) ? 'hostIPs.enabled' : 'hostIPs.disabled'))}</td>
148
+ <td class="host-ip-use">${esc(t(selected.includes(ip) ? 'hostIPs.enabled' : 'hostIPs.disabled'))}${manual.has(ip) ? ` <button class="mini-btn host-ip-remove" type="button" data-host-ip-remove="${esc(ip)}">${esc(t('hostIPs.remove'))}</button>` : ''}</td>
84
149
  </tr>`).join('')
85
150
  empty.classList.toggle('hidden', all.length > 0)
86
151
  rows.querySelectorAll('[data-host-ip-toggle]').forEach(input => input.addEventListener('change', () => {
@@ -95,6 +160,9 @@ function renderHostIPs(st) {
95
160
  saveEnabledHostIPs(st, next)
96
161
  render(st)
97
162
  }))
163
+ rows.querySelectorAll('[data-host-ip-remove]').forEach(button => button.addEventListener('click', () => {
164
+ removeManualHostIP(st, button.dataset.hostIpRemove)
165
+ }))
98
166
  }
99
167
 
100
168
  function onlineClientDevices(st) {
@@ -183,7 +251,7 @@ async function loadStats() {
183
251
  if (!token && !pluginMode) return
184
252
  try {
185
253
  const res = await fetch(`${STATS_API}/summary?days=7`, {
186
- headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' }
254
+ headers: adminHeaders(), credentials: 'same-origin'
187
255
  })
188
256
  if (!res.ok) {
189
257
  if (res.status === 401) return
@@ -204,7 +272,7 @@ async function loadGatewayConfig() {
204
272
  if (!pluginMode) return
205
273
  try {
206
274
  const res = await fetch(`${API}/config`, {
207
- headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' }
275
+ headers: adminHeaders(), credentials: 'same-origin'
208
276
  })
209
277
  const out = await res.json().catch(() => ({}))
210
278
  if (out.ok) {
@@ -355,15 +423,19 @@ async function loadState() {
355
423
  if (!token && !pluginMode) return
356
424
  try {
357
425
  const res = await fetch(`${API}/state`, {
358
- headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' }
426
+ headers: adminHeaders(), credentials: 'same-origin'
359
427
  })
360
- if (res.status === 401) throw new Error('AUTH')
428
+ if (res.status === 401) throw new Error(pluginMode ? 'AUTH_LAYER' : 'AUTH')
429
+ if (pluginMode && !String(res.headers.get('content-type') || '').includes('application/json')) throw new Error('AUTH_LAYER')
361
430
  const st = await res.json()
362
431
  render(st)
363
432
  } catch (e) {
364
433
  if (e.message === 'AUTH') {
365
434
  toast(t('toast.tokenInvalid'), 'err')
366
435
  logout()
436
+ } else if (e.message === 'AUTH_LAYER') {
437
+ $('conn-badge').textContent = t('toast.authLayer')
438
+ $('conn-badge').className = 'conn-badge off'
367
439
  } else {
368
440
  $('conn-badge').textContent = t('toast.connFailed')
369
441
  $('conn-badge').className = 'conn-badge off'
@@ -530,7 +602,7 @@ async function setNote(ip, current) {
530
602
  if (name === null) return
531
603
  const res = await fetch(`${API}/note`, {
532
604
  method: 'POST',
533
- headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' },
605
+ headers: adminHeaders({ 'content-type': 'application/json' }), credentials: 'same-origin',
534
606
  body: JSON.stringify({ ip, name })
535
607
  })
536
608
  if (res.ok) {
@@ -545,7 +617,7 @@ async function kick(ip) {
545
617
  if (!confirm(t('confirm.kick'))) return
546
618
  const res = await fetch(`${API}/kick`, {
547
619
  method: 'POST',
548
- headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' },
620
+ headers: adminHeaders({ 'content-type': 'application/json' }), credentials: 'same-origin',
549
621
  body: JSON.stringify({ ip })
550
622
  })
551
623
  if (res.ok) {
@@ -563,7 +635,7 @@ async function deviceKeyMutation(action, payload = {}) {
563
635
  try {
564
636
  const res = await fetch(`${API}/device-keys/${action}`, {
565
637
  method: 'POST',
566
- headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' },
638
+ headers: adminHeaders({ 'content-type': 'application/json' }), credentials: 'same-origin',
567
639
  body: JSON.stringify(payload),
568
640
  })
569
641
  const out = await res.json().catch(() => ({}))
@@ -755,6 +827,8 @@ $('btn-qr').addEventListener('click', () => {
755
827
  renderQr(lastState || { mode: '', token: shownToken })
756
828
  })
757
829
 
830
+ $('btn-host-ip-add').addEventListener('click', addManualHostIP)
831
+
758
832
  /* 右上角「网关」徽章: 新标签页打开独立网关管理面板(带 token 免登录) */
759
833
  $('conn-badge').addEventListener('click', () => {
760
834
  const st = lastState
@@ -774,7 +848,7 @@ $('btn-rotate').addEventListener('click', async () => {
774
848
  try {
775
849
  const res = await fetch(`${API}/token/rotate`, {
776
850
  method: 'POST',
777
- headers: { authorization: 'Bearer ' + (token || shownToken), 'x-dsh-remote-client': 'admin' }
851
+ headers: adminHeaders({}, token || shownToken), credentials: 'same-origin'
778
852
  })
779
853
  const out = await res.json().catch(() => ({}))
780
854
  if (out.ok && out.token) {
@@ -799,7 +873,7 @@ $('btn-gateway').addEventListener('click', async () => {
799
873
  try {
800
874
  const res = await fetch(`${API}/gateway`, {
801
875
  method: 'POST',
802
- headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' },
876
+ headers: adminHeaders({ 'content-type': 'application/json' }), credentials: 'same-origin',
803
877
  body: JSON.stringify({ action: gatewayRunning ? 'stop' : 'start' })
804
878
  })
805
879
  const out = await res.json().catch(() => ({}))
@@ -829,7 +903,7 @@ $('btn-save-port').addEventListener('click', async () => {
829
903
  try {
830
904
  const res = await fetch(`${API}/config`, {
831
905
  method: 'PUT',
832
- headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' },
906
+ headers: adminHeaders({ 'content-type': 'application/json' }), credentials: 'same-origin',
833
907
  body: JSON.stringify({ port })
834
908
  })
835
909
  const out = await res.json().catch(() => ({}))
package/public/app.js CHANGED
@@ -90,7 +90,7 @@ const state = {
90
90
  streamMode: 'ws', // 'ws' | 'poll'
91
91
  pollSeq: { mux: 0, host: 0 },
92
92
  refreshTimer: null,
93
- fs: { path: null, initial: null, loaded: false, upload: null, workspaceId: LS.get('fsWorkspaceIdV1', ''), preview: null },
93
+ fs: { path: null, initial: null, loaded: false, upload: null, workspaceId: LS.get('fsWorkspaceIdV1', ''), roots: [], rootIndex: 0, preview: null },
94
94
  composerImages: [], // 当前草稿中的图片附件:只在发送成功后释放
95
95
  models: { loaded: false, loading: false, groups: [], current: null, failures: [] },
96
96
  modelSettings: { status: 'idle', error: '', writable: false, hasDocument: false, providers: [], namespaces: [], credentials: {} },
@@ -536,6 +536,55 @@ async function safeRpc(method, payload, errText) {
536
536
  }
537
537
  }
538
538
 
539
+ let hostDescribePromise = null
540
+ let hostDescribeRetryTimer = null
541
+ let hostDescribeFailures = 0
542
+
543
+ function scheduleHostDescribeRetry() {
544
+ if (!state.token || hostDescribeRetryTimer) return
545
+ const delays = [2000, 5000, 15000, 30000, 60000]
546
+ const delay = delays[Math.min(Math.max(0, hostDescribeFailures - 1), delays.length - 1)]
547
+ hostDescribeRetryTimer = setTimeout(() => {
548
+ hostDescribeRetryTimer = null
549
+ void refreshHostDescription()
550
+ }, delay)
551
+ }
552
+
553
+ async function refreshHostDescription({ notify = false } = {}) {
554
+ if (!state.token) return null
555
+ if (hostDescribePromise) return hostDescribePromise
556
+ const server = state.server
557
+ hostDescribePromise = (async () => {
558
+ try {
559
+ const host = await rpc('host.describe', {}, 5000)
560
+ if (server !== state.server) return null
561
+ state.hostInfo = host
562
+ const health = activeGatewayHealth()
563
+ if (health) health.upstreamReachable = true
564
+ hostDescribeFailures = 0
565
+ if (hostDescribeRetryTimer) clearTimeout(hostDescribeRetryTimer)
566
+ hostDescribeRetryTimer = null
567
+ const desc = $('host-desc')
568
+ if (desc && host) desc.textContent = t('settings.hostDesc', { version: host.version, cwd: host.cwd, n: host.attachedSessions })
569
+ renderOverview()
570
+ return host
571
+ } catch (error) {
572
+ if (server !== state.server) return null
573
+ if (error.message === 'AUTH') authFailure()
574
+ else {
575
+ hostDescribeFailures++
576
+ scheduleHostDescribeRetry()
577
+ if (notify) toast(`${t('settings.probeFailed')}:${error.message}`, 'err')
578
+ }
579
+ return null
580
+ } finally {
581
+ hostDescribePromise = null
582
+ if (server !== state.server) scheduleHostDescribeRetry()
583
+ }
584
+ })()
585
+ return hostDescribePromise
586
+ }
587
+
539
588
  function authFailure() {
540
589
  toast(t('err.accessDenied'), 'err')
541
590
  showView('view-settings')
@@ -640,7 +689,10 @@ async function pingServer(base) {
640
689
  const res = await fetch(u + '/health?t=' + Date.now(), { signal: ctrl.signal, cache: 'no-store' })
641
690
  if (!res.ok) return Infinity
642
691
  const health = await res.json().catch(() => null)
643
- if (health && typeof health === 'object') state.gatewayHealth[u] = health
692
+ if (health && typeof health === 'object') {
693
+ state.gatewayHealth[u] = health
694
+ renderOverview()
695
+ }
644
696
  return Math.round(performance.now() - t0)
645
697
  } catch {
646
698
  return Infinity
@@ -650,12 +702,22 @@ async function pingServer(base) {
650
702
  }
651
703
 
652
704
  function activeGatewayCapability(name) {
653
- const key = String(state.server || location.origin || '').replace(/\/+$/, '')
654
- const capabilities = state.gatewayHealth[key]?.capabilities
705
+ const capabilities = activeGatewayHealth()?.capabilities
655
706
  if (!capabilities || !Object.prototype.hasOwnProperty.call(capabilities, name)) return null
656
707
  return Number(capabilities[name]) > 0
657
708
  }
658
709
 
710
+ function activeGatewayHealth() {
711
+ const key = String(state.server || location.origin || '').replace(/\/+$/, '')
712
+ return state.gatewayHealth[key] || null
713
+ }
714
+
715
+ function dshReachable() {
716
+ const health = activeGatewayHealth()
717
+ if (health && typeof health.upstreamReachable === 'boolean') return health.upstreamReachable
718
+ return !!state.hostInfo
719
+ }
720
+
659
721
  async function selectFastestServer({ silent = false, reconnect = true } = {}) {
660
722
  if (state.selectingServer) return null
661
723
  state.selectingServer = true
@@ -687,6 +749,11 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
687
749
 
688
750
  renderServers()
689
751
  if (chosen !== state.server) {
752
+ state.hostInfo = null
753
+ hostDescribeFailures = 0
754
+ if (hostDescribeRetryTimer) clearTimeout(hostDescribeRetryTimer)
755
+ hostDescribeRetryTimer = null
756
+ resetFsForServer()
690
757
  state.server = chosen
691
758
  if (state.autoSelect[state.activeGroup] !== false && best) {
692
759
  const srv = state.servers.find(s => s.url === best)
@@ -809,6 +876,7 @@ function renderServers() {
809
876
  // 手动模式: 点击条目 = 选中该服务器连接
810
877
  state.groupActive[group] = id
811
878
  state.activeGroup = group
879
+ if (state.server !== s.url) resetFsForServer()
812
880
  state.server = s.url
813
881
  saveServers()
814
882
  renderServers()
@@ -863,10 +931,14 @@ function editServer(id) {
863
931
  const group = prompt(t('servers.promptEditGroup'), s.group || '默认')
864
932
  if (group === null) return
865
933
  const wasActive = state.server === s.url
934
+ const changedActiveUrl = wasActive && state.server !== raw
866
935
  s.url = raw
867
936
  s.note = note.trim()
868
937
  s.group = ensureGroup(group.trim() || '默认')
869
- if (wasActive) state.server = raw
938
+ if (wasActive) {
939
+ if (changedActiveUrl) resetFsForServer()
940
+ state.server = raw
941
+ }
870
942
  saveServers()
871
943
  renderServers()
872
944
  toast(t('servers.edited'), 'ok')
@@ -1090,7 +1162,11 @@ function openStream(kind, handler, refreshOnOpen, isRestore, ticket = null) {
1090
1162
  renderPending()
1091
1163
  }
1092
1164
  if (refreshOnOpen) refreshAll()
1093
- if (allStreamsOpen()) resyncAfterStreamOpen()
1165
+ if (allStreamsOpen()) {
1166
+ resyncAfterStreamOpen()
1167
+ void pingServer(state.server || location.origin)
1168
+ void refreshHostDescription()
1169
+ }
1094
1170
  }
1095
1171
  ws.onmessage = (msg) => {
1096
1172
  if (!streamIsCurrent(kind, ws, generation)) return
@@ -1573,7 +1649,13 @@ function workspaceOptionLabel(workspace) {
1573
1649
  function workspaceOptionsHtml({ all = false, ungrouped = false, root = false, selected = '' } = {}) {
1574
1650
  const rows = []
1575
1651
  if (all) rows.push({ id: '', label: t('workspace.all') })
1576
- if (root) rows.push({ id: '', label: t('fs.root') })
1652
+ if (root) {
1653
+ const roots = state.fs.roots.length ? state.fs.roots : ['']
1654
+ roots.forEach((rootPath, index) => rows.push({
1655
+ id: index === 0 ? '' : `__fs_root__:${index}`,
1656
+ label: rootPath && roots.length > 1 ? `${t('fs.root')} — ${rootPath}` : t('fs.root'),
1657
+ }))
1658
+ }
1577
1659
  for (const workspace of workspaceItems()) rows.push({ id: workspace.workspaceId, label: workspaceOptionLabel(workspace) })
1578
1660
  if (ungrouped) rows.push({ id: WORKSPACE_UNGROUPED, label: t('workspace.ungrouped') })
1579
1661
  return rows.map(row => `<option value="${esc(row.id)}"${row.id === selected ? ' selected' : ''}>${esc(row.label)}</option>`).join('')
@@ -1601,7 +1683,8 @@ function renderWorkspaceNavigation() {
1601
1683
  }
1602
1684
  const fsSelect = $('fs-workspace')
1603
1685
  if (fsSelect) {
1604
- fsSelect.innerHTML = workspaceOptionsHtml({ root: true, selected: state.fs.workspaceId })
1686
+ const selected = state.fs.workspaceId || (state.fs.rootIndex > 0 ? `__fs_root__:${state.fs.rootIndex}` : '')
1687
+ fsSelect.innerHTML = workspaceOptionsHtml({ root: true, selected })
1605
1688
  syncCustomSelect(fsSelect)
1606
1689
  }
1607
1690
  if ($('modal-new-session') && !$('modal-new-session').classList.contains('hidden')) renderNewSessionWorkspace()
@@ -3036,7 +3119,7 @@ function renderOverview() {
3036
3119
  // 独立网关页面默认走同源,此时 state.server 合法地为空;不能因此把
3037
3120
  // 已连接网关误报为离线。Capacitor 等非 HTTP 页面仍要求显式服务器。
3038
3121
  gateway: !!state.token && (!!state.server || /^https?:$/.test(location.protocol)),
3039
- dsh: !!state.hostInfo,
3122
+ dsh: dshReachable(),
3040
3123
  mux: !!state.streamsOk?.mux,
3041
3124
  host: !!state.streamsOk?.host
3042
3125
  }
@@ -3280,14 +3363,80 @@ function fsHeaders() {
3280
3363
  }
3281
3364
 
3282
3365
  function fsJoin(dir, name) {
3283
- return dir.replace(/\/+$/, '') + '/' + name
3366
+ const meta = fsPathMeta(dir)
3367
+ if (!meta.value) return String(name || '')
3368
+ return meta.value.endsWith(meta.separator) ? meta.value + name : meta.value + meta.separator + name
3284
3369
  }
3285
3370
 
3286
3371
  function fsParent(p) {
3287
- const clean = String(p || '').replace(/\/+$/, '')
3288
- const idx = clean.lastIndexOf('/')
3289
- if (idx <= 0) return clean === '' ? '' : '/'
3290
- return clean.slice(0, idx)
3372
+ const meta = fsPathMeta(p)
3373
+ if (!meta.value) return ''
3374
+ if (meta.root && fsPathEqual(meta.value, meta.root)) return meta.root
3375
+ const idx = meta.value.lastIndexOf(meta.separator)
3376
+ if (idx < 0) return meta.root || ''
3377
+ const parent = meta.value.slice(0, idx)
3378
+ return meta.root && parent.length < meta.root.length ? meta.root : (parent || meta.root)
3379
+ }
3380
+
3381
+ function fsPathMeta(input) {
3382
+ const source = String(input || '').trim()
3383
+ const windows = /^[A-Za-z]:(?:[\\/]|$)/.test(source) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(source) || source.includes('\\')
3384
+ if (!windows) {
3385
+ let value = source.replace(/\/+/g, '/')
3386
+ const root = value.startsWith('/') ? '/' : ''
3387
+ if (root && value.length > root.length) value = value.replace(/\/+$/, '')
3388
+ return { value, root, separator: '/', windows: false }
3389
+ }
3390
+ let value = source.replace(/\//g, '\\')
3391
+ const unc = /^\\{2,}/.test(value)
3392
+ value = unc
3393
+ ? '\\\\' + value.replace(/^\\+/, '').replace(/\\+/g, '\\')
3394
+ : value.replace(/\\+/g, '\\')
3395
+ let root = ''
3396
+ if (unc) {
3397
+ const parts = value.slice(2).split('\\').filter(Boolean)
3398
+ root = parts.length >= 2 ? `\\\\${parts[0]}\\${parts[1]}` : value
3399
+ } else {
3400
+ const drive = /^([A-Za-z]:)/.exec(value)
3401
+ if (drive) {
3402
+ root = drive[1] + '\\'
3403
+ if (value === drive[1]) value = root
3404
+ }
3405
+ }
3406
+ if (root && value.length > root.length) value = value.replace(/\\+$/, '')
3407
+ return { value, root, separator: '\\', windows: true }
3408
+ }
3409
+
3410
+ function fsPathKey(value) {
3411
+ const meta = fsPathMeta(value)
3412
+ return meta.windows ? meta.value.toLowerCase() : meta.value
3413
+ }
3414
+
3415
+ function fsPathEqual(left, right) {
3416
+ return fsPathKey(left) === fsPathKey(right)
3417
+ }
3418
+
3419
+ function fsPathInside(candidate, root) {
3420
+ const child = fsPathMeta(candidate)
3421
+ const boundary = fsPathMeta(root)
3422
+ if (!child.value || !boundary.value || child.windows !== boundary.windows) return false
3423
+ const childKey = child.windows ? child.value.toLowerCase() : child.value
3424
+ const rootKey = boundary.windows ? boundary.value.toLowerCase() : boundary.value
3425
+ if (childKey === rootKey) return true
3426
+ const prefix = rootKey.endsWith(boundary.separator) ? rootKey : rootKey + boundary.separator
3427
+ return childKey.startsWith(prefix)
3428
+ }
3429
+
3430
+ function resetFsForServer() {
3431
+ state.fs.path = null
3432
+ state.fs.initial = null
3433
+ state.fs.loaded = false
3434
+ state.fs.upload = null
3435
+ state.fs.preview = null
3436
+ state.fs.roots = []
3437
+ state.fs.rootIndex = 0
3438
+ state.fs.workspaceId = ''
3439
+ LS.del('fsWorkspaceIdV1')
3291
3440
  }
3292
3441
 
3293
3442
  const FS_PREVIEW_EXTENSIONS = new Set([
@@ -3403,7 +3552,14 @@ async function loadFs(dir, { silent = false, resetRoot = false } = {}) {
3403
3552
  if (!res.ok || !Array.isArray(data.entries)) throw new Error(data.error === 'not-found' ? t('fs.notFound') : data.error === 'forbidden' ? t('fs.forbidden') : data.error || ('HTTP ' + res.status))
3404
3553
  state.fs.path = data.path
3405
3554
  if (!state.fs.initial) state.fs.initial = data.path
3555
+ if (Array.isArray(data.roots) && data.roots.length) state.fs.roots = data.roots.map(value => String(value || '')).filter(Boolean)
3556
+ if (!state.fs.roots.length) state.fs.roots = [data.path]
3557
+ if (!state.fs.workspaceId) {
3558
+ const rootIndex = state.fs.roots.findIndex(root => fsPathInside(data.path, root))
3559
+ if (rootIndex >= 0) state.fs.rootIndex = rootIndex
3560
+ }
3406
3561
  state.fs.loaded = true
3562
+ renderWorkspaceNavigation()
3407
3563
  renderFs(data)
3408
3564
  } catch (e) {
3409
3565
  if (e.message === 'AUTH') return
@@ -3807,11 +3963,12 @@ async function runFsUpload(up) {
3807
3963
 
3808
3964
  function fsUp() {
3809
3965
  if (!state.fs.path || !state.fs.initial) return
3810
- if (state.fs.path === state.fs.initial) {
3966
+ const parent = fsParent(state.fs.path)
3967
+ if (fsPathEqual(state.fs.path, state.fs.initial) || !fsPathInside(parent, state.fs.initial)) {
3811
3968
  toast(t('fs.alreadyRoot'))
3812
3969
  return
3813
3970
  }
3814
- loadFs(fsParent(state.fs.path))
3971
+ loadFs(parent)
3815
3972
  }
3816
3973
 
3817
3974
  function bindFsPullRefresh() {
@@ -5570,6 +5727,7 @@ function applyPairUrl(url) {
5570
5727
  if (!tok || !servers.length) return false
5571
5728
  state.token = tok
5572
5729
  LS.set('token', tok)
5730
+ if (state.server !== servers[0]) resetFsForServer()
5573
5731
  state.server = servers[0]
5574
5732
  for (let i = servers.length - 1; i >= 0; i--) {
5575
5733
  const server = servers[i]
@@ -6003,7 +6161,10 @@ function renderDshControlStatus(value) {
6003
6161
  async function loadDshControl() {
6004
6162
  if (!state.token || !$('dsh-control-desc')) return
6005
6163
  if (activeGatewayCapability('dshLifecycle') === false) {
6006
- renderDshControlStatus({ supported: false, message: t('settings.dshUnsupported') })
6164
+ renderDshControlStatus({
6165
+ supported: false,
6166
+ message: activeGatewayHealth()?.dshControl?.message || t('settings.dshUnsupported')
6167
+ })
6007
6168
  return
6008
6169
  }
6009
6170
  try {
@@ -6282,12 +6443,20 @@ function bindUi() {
6282
6443
  renderSessions()
6283
6444
  })
6284
6445
  $('fs-workspace').addEventListener('change', (e) => {
6285
- state.fs.workspaceId = e.target.value
6446
+ const rootMatch = /^__fs_root__:(\d+)$/.exec(e.target.value)
6447
+ if (rootMatch) {
6448
+ state.fs.rootIndex = Math.min(Number(rootMatch[1]), Math.max(0, state.fs.roots.length - 1))
6449
+ state.fs.workspaceId = ''
6450
+ } else {
6451
+ state.fs.rootIndex = 0
6452
+ state.fs.workspaceId = e.target.value
6453
+ }
6286
6454
  if (state.fs.workspaceId) LS.set('fsWorkspaceIdV1', state.fs.workspaceId)
6287
6455
  else LS.del('fsWorkspaceIdV1')
6288
6456
  state.fs.loaded = false
6289
6457
  const workspace = workspaceById(state.fs.workspaceId)
6290
- loadFs(workspace?.path || null, { resetRoot: true })
6458
+ const rootPath = !workspace && rootMatch ? state.fs.roots[state.fs.rootIndex] : null
6459
+ loadFs(workspace?.path || rootPath || null, { resetRoot: true })
6291
6460
  })
6292
6461
  $('file-preview-close').addEventListener('click', closeFsPreview)
6293
6462
  $('file-preview-done').addEventListener('click', closeFsPreview)
@@ -6459,11 +6628,7 @@ function bindUi() {
6459
6628
  if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); addServer() }
6460
6629
  })
6461
6630
  $('btn-host-describe').addEventListener('click', async () => {
6462
- const v = await safeRpc('host.describe', {}, t('settings.probeFailed'))
6463
- if (v) {
6464
- state.hostInfo = v
6465
- $('host-desc').textContent = t('settings.hostDesc', { version: v.version, cwd: v.cwd, n: v.attachedSessions })
6466
- }
6631
+ await refreshHostDescription({ notify: true })
6467
6632
  })
6468
6633
  $('btn-dsh-start')?.addEventListener('click', () => controlDsh('start'))
6469
6634
  $('btn-dsh-restart')?.addEventListener('click', () => controlDsh('restart'))
@@ -6632,8 +6797,7 @@ async function boot() {
6632
6797
  await maybeWarnAppBehindGateway({ probe: true })
6633
6798
  openStreams()
6634
6799
  await refreshAll()
6635
- const host = await safeRpc('host.describe', {}, '')
6636
- if (host) { state.hostInfo = host; $('host-desc').textContent = t('settings.hostDesc', { version: host.version, cwd: host.cwd, n: host.attachedSessions }) }
6800
+ await refreshHostDescription()
6637
6801
  loadDshControl()
6638
6802
  }
6639
6803
  // 网关从中央 HTTPS 公告源读取并在不可达时回退内置文件。前台每 30 秒检查,
@@ -334,6 +334,7 @@ button.ds-overview-attention-item, button.ds-overview-session-item { cursor:poin
334
334
 
335
335
  /* 文件传输 */
336
336
  .ds-fs-bar { display: flex; align-items: center; gap: 8px; margin: 8px 0; }
337
+ .ds-fs-root { max-width: 260px; min-width: 120px; border: 1px solid var(--dsr-line); border-radius: 9px; background: var(--dsr-panel); color: var(--dsr-text); padding: 7px 9px; }
337
338
  .ds-fs-path { flex: 1; min-width: 0; font-size: 12.5px; color: var(--dsr-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
338
339
  .ds-fs-list { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; border: 1px solid var(--dsr-line); border-radius: 12px; background: var(--dsr-panel); }
339
340
  .ds-fs-row { display: flex; align-items: center; gap: 9px; padding: 9px 12px; border-bottom: 1px solid var(--dsr-divider); font-size: 13px; }
@@ -189,7 +189,8 @@
189
189
  <div class="ds-section-label" data-i18n="ds.files">文件传输</div>
190
190
  <div class="ds-fs-bar">
191
191
  <button id="fs-up" class="ds-btn" data-i18n="ds.fsUp">上级</button>
192
- <span id="fs-path" class="ds-fs-path">/</span>
192
+ <select id="fs-root" class="ds-fs-root hidden" data-i18n-aria="ds.fsRoot" aria-label="允许根目录"></select>
193
+ <span id="fs-path" class="ds-fs-path">~</span>
193
194
  <button id="fs-new-workspace" class="ds-btn" data-i18n="ds.fsNewWorkspace">新建工作区</button>
194
195
  <button id="fs-refresh" class="ds-btn" data-i18n="ds.fsRefresh">刷新</button>
195
196
  </div>
@@ -458,7 +459,7 @@
458
459
  'ds.cmdCompact': '/compact 压缩对话历史', 'ds.cmdExport': '/export 导出会话日志 ZIP',
459
460
  'ds.cmdFeedback': '/feedback 反馈当前会话', 'ds.cmdGoal': '/goal 设置/查看任务目标',
460
461
  'ds.cmdPermission': '/permission 切换权限预设', 'ds.cmdPlan': '/plan 进入/退出计划模式',
461
- 'ds.fsUp': '上级', 'ds.fsNewWorkspace': '新建工作区', 'ds.fsRefresh': '刷新', 'ds.fsEmpty': '目录为空',
462
+ 'ds.fsUp': '上级', 'ds.fsRoot': '允许根目录', 'ds.fsNewWorkspace': '新建工作区', 'ds.fsRefresh': '刷新', 'ds.fsEmpty': '目录为空',
462
463
  'ds.workspaceCreateTitle': '新建工作区', 'ds.workspaceCreateDesc': '将在当前文件目录下创建文件夹,并自动打开新会话。', 'ds.workspaceParent': '父目录', 'ds.workspaceNamePlaceholder': '例如:my-project', 'ds.workspaceCreate': '创建并打开', 'ds.cancel': '取消', 'ds.workspaceNameRequired': '请输入工作区名称', 'ds.workspaceExists': '该目录已存在', 'ds.workspaceInvalidName': '名称不能包含路径分隔符', 'ds.workspaceCreateFailed': '创建工作区失败', 'ds.workspaceCreated': '工作区已创建', 'ds.workspaceCreatedNoSession': '工作区已创建,但新会话未能打开',
463
464
  'ds.groupGeneral': '通用', 'ds.groupGeneralDesc': '工具调用、预设提示词',
464
465
  'ds.groupServers': '服务器', 'ds.groupServersDesc': '服务器地址',
@@ -560,7 +561,7 @@
560
561
  'ds.cmdCompact': '/compact Compress conversation history', 'ds.cmdExport': '/export Export session log ZIP',
561
562
  'ds.cmdFeedback': '/feedback Feedback current session', 'ds.cmdGoal': '/goal Set/view task goal',
562
563
  'ds.cmdPermission': '/permission Switch permission preset', 'ds.cmdPlan': '/plan Enter/exit plan mode',
563
- 'ds.fsUp': 'Up', 'ds.fsNewWorkspace': 'New workspace', 'ds.fsRefresh': 'Refresh', 'ds.fsEmpty': 'Empty directory',
564
+ 'ds.fsUp': 'Up', 'ds.fsRoot': 'Allowed root', 'ds.fsNewWorkspace': 'New workspace', 'ds.fsRefresh': 'Refresh', 'ds.fsEmpty': 'Empty directory',
564
565
  'ds.workspaceCreateTitle': 'New workspace', 'ds.workspaceCreateDesc': 'Create a folder in the current file directory and open a new session there.', 'ds.workspaceParent': 'Parent folder', 'ds.workspaceNamePlaceholder': 'For example: my-project', 'ds.workspaceCreate': 'Create & open', 'ds.cancel': 'Cancel', 'ds.workspaceNameRequired': 'Enter a workspace name', 'ds.workspaceExists': 'That folder already exists', 'ds.workspaceInvalidName': 'The name cannot contain path separators', 'ds.workspaceCreateFailed': 'Could not create workspace', 'ds.workspaceCreated': 'Workspace created', 'ds.workspaceCreatedNoSession': 'Workspace created, but the new session could not be opened',
565
566
  'ds.groupGeneral': 'General', 'ds.groupGeneralDesc': 'Tool calls, prompt presets',
566
567
  'ds.groupServers': 'Servers', 'ds.groupServersDesc': 'Server address',
@@ -88,7 +88,7 @@ const state = {
88
88
  },
89
89
  streamMode: 'ws', // 'ws' | 'poll'
90
90
  pollSeq: { mux: 0, host: 0 },
91
- fs: { path: null, initial: null, loaded: false },
91
+ fs: { path: null, initial: null, loaded: false, roots: [], rootIndex: 0 },
92
92
  models: { loaded: false, loading: false, groups: [], current: null, failures: [] },
93
93
  wb: { bound: false, path: '', title: '', expanded: false, projects: null, open: null, apiMissing: false },
94
94
  archivedIds: [],
@@ -603,6 +603,49 @@ async function safeRpc(method, payload, errText) {
603
603
  return null
604
604
  }
605
605
  }
606
+ let hostDescribePromise = null
607
+ let hostDescribeRetryTimer = null
608
+ let hostDescribeFailures = 0
609
+
610
+ function scheduleHostDescribeRetryDesktop() {
611
+ if (!state.token || hostDescribeRetryTimer) return
612
+ const delays = [2000, 5000, 15000, 30000, 60000]
613
+ const delay = delays[Math.min(Math.max(0, hostDescribeFailures - 1), delays.length - 1)]
614
+ hostDescribeRetryTimer = setTimeout(() => {
615
+ hostDescribeRetryTimer = null
616
+ void refreshHostDescriptionDesktop()
617
+ }, delay)
618
+ }
619
+
620
+ async function refreshHostDescriptionDesktop({ notify = false } = {}) {
621
+ if (!state.token) return null
622
+ if (hostDescribePromise) return hostDescribePromise
623
+ const server = state.server
624
+ hostDescribePromise = (async () => {
625
+ try {
626
+ const host = await rpc('host.describe', {}, 5000)
627
+ if (server !== state.server) return null
628
+ state.hostInfo = host
629
+ const health = activeGatewayHealth()
630
+ if (health) health.upstreamReachable = true
631
+ hostDescribeFailures = 0
632
+ if (hostDescribeRetryTimer) clearTimeout(hostDescribeRetryTimer)
633
+ hostDescribeRetryTimer = null
634
+ renderOverviewDesktop()
635
+ return host
636
+ } catch (error) {
637
+ if (server !== state.server) return null
638
+ hostDescribeFailures++
639
+ scheduleHostDescribeRetryDesktop()
640
+ if (notify) toast(error.message === 'AUTH' ? t('ds.toastAuth') : error.message, 'err')
641
+ return null
642
+ } finally {
643
+ hostDescribePromise = null
644
+ if (server !== state.server) scheduleHostDescribeRetryDesktop()
645
+ }
646
+ })()
647
+ return hostDescribePromise
648
+ }
606
649
  function uuid() {
607
650
  try { return crypto.randomUUID() } catch { return 'id-' + Date.now() + '-' + Math.random().toString(36).slice(2) }
608
651
  }
@@ -676,16 +719,29 @@ async function pingServer(base) {
676
719
  const res = await fetch(u + '/health?t=' + Date.now(), { signal: ctrl.signal, cache: 'no-store' })
677
720
  if (!res.ok) return Infinity
678
721
  const health = await res.json().catch(() => null)
679
- if (health && typeof health === 'object') state.gatewayHealth[u] = health
722
+ if (health && typeof health === 'object') {
723
+ state.gatewayHealth[u] = health
724
+ renderOverviewDesktop()
725
+ }
680
726
  return Math.round(performance.now() - t0)
681
727
  } catch { return Infinity } finally { clearTimeout(timer) }
682
728
  }
683
729
  function activeGatewayCapability(name) {
684
- const key = String(state.server || location.origin || '').replace(/\/+$/, '')
685
- const capabilities = state.gatewayHealth[key]?.capabilities
730
+ const capabilities = activeGatewayHealth()?.capabilities
686
731
  if (!capabilities || !Object.prototype.hasOwnProperty.call(capabilities, name)) return null
687
732
  return Number(capabilities[name]) > 0
688
733
  }
734
+
735
+ function activeGatewayHealth() {
736
+ const key = String(state.server || location.origin || '').replace(/\/+$/, '')
737
+ return state.gatewayHealth[key] || null
738
+ }
739
+
740
+ function dshReachable() {
741
+ const health = activeGatewayHealth()
742
+ if (health && typeof health.upstreamReachable === 'boolean') return health.upstreamReachable
743
+ return !!state.hostInfo
744
+ }
689
745
  async function selectFastestServer({ silent = false, reconnect = true } = {}) {
690
746
  if (state.selectingServer) return null
691
747
  state.selectingServer = true
@@ -709,6 +765,11 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
709
765
  }
710
766
  renderServers()
711
767
  if (chosen !== state.server) {
768
+ state.hostInfo = null
769
+ hostDescribeFailures = 0
770
+ if (hostDescribeRetryTimer) clearTimeout(hostDescribeRetryTimer)
771
+ hostDescribeRetryTimer = null
772
+ resetFsForServerDesktop()
712
773
  state.server = chosen
713
774
  if (best) { const srv = state.servers.find(s => s.url === best); if (srv) state.groupActive[state.activeGroup] = srv.id }
714
775
  saveServers()
@@ -794,6 +855,7 @@ function renderServers() {
794
855
  if (state.autoSelect[s.group] !== false) { editServer(id); return }
795
856
  state.groupActive[s.group] = id
796
857
  state.activeGroup = s.group
858
+ if (state.server !== s.url) resetFsForServerDesktop()
797
859
  state.server = s.url
798
860
  saveServers()
799
861
  renderServers()
@@ -836,8 +898,12 @@ function editServer(id) {
836
898
  const group = prompt(t('ds.serversPromptEditGroup'), s.group || '默认')
837
899
  if (group === null) return
838
900
  const wasActive = state.server === s.url
901
+ const changedActiveUrl = wasActive && state.server !== raw
839
902
  s.url = raw; s.note = note.trim(); s.group = ensureGroup(group.trim() || '默认')
840
- if (wasActive) state.server = raw
903
+ if (wasActive) {
904
+ if (changedActiveUrl) resetFsForServerDesktop()
905
+ state.server = raw
906
+ }
841
907
  saveServers(); renderServers(); toast(t('ds.serversEdited'), 'ok')
842
908
  if (wasActive && state.token) selectFastestServer({ silent: true })
843
909
  }
@@ -1020,7 +1086,11 @@ function openStream(kind, handler, refreshOnOpen, isRestore, ticket = null) {
1020
1086
  updateConn()
1021
1087
  if (kind === 'mux') { state.approvals = []; state.questions = []; renderNotifStack() }
1022
1088
  if (refreshOnOpen) refreshSessions()
1023
- if (allStreamsOpen()) resyncAfterStreamOpen()
1089
+ if (allStreamsOpen()) {
1090
+ resyncAfterStreamOpen()
1091
+ void pingServer(state.server || location.origin)
1092
+ void refreshHostDescriptionDesktop()
1093
+ }
1024
1094
  }
1025
1095
  ws.onmessage = (msg) => {
1026
1096
  if (!streamIsCurrent(kind, ws, generation)) return
@@ -1966,10 +2036,70 @@ function fsHeaders() {
1966
2036
  return { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() }
1967
2037
  }
1968
2038
  function fsParent(p) {
1969
- if (!p) return null
1970
- const parts = String(p).split('/').filter(Boolean)
1971
- parts.pop()
1972
- return parts.length ? '/' + parts.join('/') : '/'
2039
+ const meta = fsPathMeta(p)
2040
+ if (!meta.value) return null
2041
+ if (meta.root && fsPathEqual(meta.value, meta.root)) return meta.root
2042
+ const idx = meta.value.lastIndexOf(meta.separator)
2043
+ if (idx < 0) return meta.root || null
2044
+ const parent = meta.value.slice(0, idx)
2045
+ return meta.root && parent.length < meta.root.length ? meta.root : (parent || meta.root)
2046
+ }
2047
+ function fsJoin(dir, name) {
2048
+ const meta = fsPathMeta(dir)
2049
+ if (!meta.value) return String(name || '')
2050
+ return meta.value.endsWith(meta.separator) ? meta.value + name : meta.value + meta.separator + name
2051
+ }
2052
+ function fsPathMeta(input) {
2053
+ const source = String(input || '').trim()
2054
+ const windows = /^[A-Za-z]:(?:[\\/]|$)/.test(source) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(source) || source.includes('\\')
2055
+ if (!windows) {
2056
+ let value = source.replace(/\/+/g, '/')
2057
+ const root = value.startsWith('/') ? '/' : ''
2058
+ if (root && value.length > root.length) value = value.replace(/\/+$/, '')
2059
+ return { value, root, separator: '/', windows: false }
2060
+ }
2061
+ let value = source.replace(/\//g, '\\')
2062
+ const unc = /^\\{2,}/.test(value)
2063
+ value = unc
2064
+ ? '\\\\' + value.replace(/^\\+/, '').replace(/\\+/g, '\\')
2065
+ : value.replace(/\\+/g, '\\')
2066
+ let root = ''
2067
+ if (unc) {
2068
+ const parts = value.slice(2).split('\\').filter(Boolean)
2069
+ root = parts.length >= 2 ? `\\\\${parts[0]}\\${parts[1]}` : value
2070
+ } else {
2071
+ const drive = /^([A-Za-z]:)/.exec(value)
2072
+ if (drive) {
2073
+ root = drive[1] + '\\'
2074
+ if (value === drive[1]) value = root
2075
+ }
2076
+ }
2077
+ if (root && value.length > root.length) value = value.replace(/\\+$/, '')
2078
+ return { value, root, separator: '\\', windows: true }
2079
+ }
2080
+ function fsPathKey(value) {
2081
+ const meta = fsPathMeta(value)
2082
+ return meta.windows ? meta.value.toLowerCase() : meta.value
2083
+ }
2084
+ function fsPathEqual(left, right) { return fsPathKey(left) === fsPathKey(right) }
2085
+ function fsPathInside(candidate, root) {
2086
+ const child = fsPathMeta(candidate)
2087
+ const boundary = fsPathMeta(root)
2088
+ if (!child.value || !boundary.value || child.windows !== boundary.windows) return false
2089
+ const childKey = child.windows ? child.value.toLowerCase() : child.value
2090
+ const rootKey = boundary.windows ? boundary.value.toLowerCase() : boundary.value
2091
+ if (childKey === rootKey) return true
2092
+ const prefix = rootKey.endsWith(boundary.separator) ? rootKey : rootKey + boundary.separator
2093
+ return childKey.startsWith(prefix)
2094
+ }
2095
+ function resetFsForServerDesktop() {
2096
+ state.fs.path = null
2097
+ state.fs.initial = null
2098
+ state.fs.loaded = false
2099
+ state.fs.roots = []
2100
+ state.fs.rootIndex = 0
2101
+ const select = $('fs-root')
2102
+ if (select) select.classList.add('hidden')
1973
2103
  }
1974
2104
  async function openWorkspaceModal() {
1975
2105
  if (!state.token) { toast(t('ds.toastAuth'), 'err'); showView('view-settings'); return }
@@ -2031,10 +2161,15 @@ async function loadFs(dir, silent) {
2031
2161
  if (!res.ok || !Array.isArray(data.entries)) throw new Error(data.error || ('HTTP ' + res.status))
2032
2162
  state.fs.path = data.path
2033
2163
  if (!state.fs.initial) state.fs.initial = data.path
2164
+ if (Array.isArray(data.roots) && data.roots.length) state.fs.roots = data.roots.map(value => String(value || '')).filter(Boolean)
2165
+ if (!state.fs.roots.length) state.fs.roots = [data.path]
2166
+ const rootIndex = state.fs.roots.findIndex(root => fsPathInside(data.path, root))
2167
+ if (rootIndex >= 0) state.fs.rootIndex = rootIndex
2034
2168
  state.fs.loaded = true
2169
+ renderFsRootsDesktop()
2035
2170
  $('fs-path').textContent = data.path
2036
2171
  $('fs-list').innerHTML = (data.entries || []).map(e => `
2037
- <div class="ds-fs-row" data-fs-path="${esc(e.path)}" data-fs-dir="${e.type === 'dir' ? '1' : '0'}">
2172
+ <div class="ds-fs-row" data-fs-path="${esc(e.path || fsJoin(data.path, e.name))}" data-fs-dir="${e.type === 'dir' ? '1' : '0'}">
2038
2173
  <span class="ds-fs-type">${desktopFsIconSvg(e.type === 'dir')}</span>
2039
2174
  <span class="ds-fs-name">${esc(e.name)}</span>
2040
2175
  <span class="ds-fs-size">${e.type === 'dir' ? '' : fmtSize(e.size)}</span>
@@ -2049,15 +2184,23 @@ async function loadFs(dir, silent) {
2049
2184
  }
2050
2185
  }
2051
2186
 
2187
+ function renderFsRootsDesktop() {
2188
+ const select = $('fs-root')
2189
+ if (!select) return
2190
+ select.classList.toggle('hidden', state.fs.roots.length <= 1)
2191
+ select.innerHTML = state.fs.roots.map((root, index) => `<option value="${index}"${index === state.fs.rootIndex ? ' selected' : ''}>${esc(root)}</option>`).join('')
2192
+ }
2193
+
2052
2194
  function desktopFsIconSvg(isDir) {
2053
2195
  return isDir
2054
2196
  ? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 6.5h6l2 2H20a1 1 0 0 1 1 1v8.5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7.5a1 1 0 0 1 .5-1Z"/></svg>'
2055
2197
  : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3.5h8l4 4V20a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1Z"/><path d="M14 3.5v4h4M8 13h8M8 16h6"/></svg>'
2056
2198
  }
2057
2199
  function fsUp() {
2058
- if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) {
2059
- loadFs(fsParent(state.fs.path))
2060
- }
2200
+ if (!state.fs.path || !state.fs.initial) return
2201
+ const parent = fsParent(state.fs.path)
2202
+ if (fsPathEqual(state.fs.path, state.fs.initial) || !fsPathInside(parent, state.fs.initial)) return
2203
+ loadFs(parent)
2061
2204
  }
2062
2205
 
2063
2206
  /* ---------------- 工作台绑定 / 项目会话 ---------------- */
@@ -2431,7 +2574,7 @@ function renderOverviewDesktop() {
2431
2574
  const checks = {
2432
2575
  // 桌面独立页面默认使用当前 origin,state.server 为空不代表网关离线。
2433
2576
  gateway: !!state.token && (!!state.server || /^https?:$/.test(location.protocol)),
2434
- dsh: !!state.hostInfo,
2577
+ dsh: dshReachable(),
2435
2578
  mux: !!state.streamsOk?.mux,
2436
2579
  host: !!state.streamsOk?.host
2437
2580
  }
@@ -2626,8 +2769,7 @@ function bindUi() {
2626
2769
  toast(t('ds.loading'))
2627
2770
  if (state.token) {
2628
2771
  await refreshSessions()
2629
- const host = await safeRpc('host.describe', {}, '')
2630
- if (host) state.hostInfo = host
2772
+ await refreshHostDescriptionDesktop({ notify: true })
2631
2773
  }
2632
2774
  renderOverviewDesktop()
2633
2775
  })
@@ -2791,6 +2933,12 @@ function bindUi() {
2791
2933
  renderServers(); renderSessions(); renderNotifStack(); renderOverviewDesktop(); updateConn(); themeApply()
2792
2934
  })
2793
2935
  $('fs-up').addEventListener('click', fsUp)
2936
+ $('fs-root').addEventListener('change', (event) => {
2937
+ const index = Math.min(Number(event.target.value) || 0, Math.max(0, state.fs.roots.length - 1))
2938
+ state.fs.rootIndex = index
2939
+ state.fs.initial = null
2940
+ loadFs(state.fs.roots[index] || null, true)
2941
+ })
2794
2942
  $('fs-new-workspace').addEventListener('click', openWorkspaceModal)
2795
2943
  $('fs-refresh').addEventListener('click', () => loadFs(state.fs.path || null))
2796
2944
  $('btn-question-submit').addEventListener('click', submitQuestion)
@@ -2813,11 +2961,10 @@ async function start() {
2813
2961
  updateConn()
2814
2962
  checkNotesOnStart()
2815
2963
  if (state.token) {
2816
- if (state.servers.length) await selectFastestServer({ silent: true, reconnect: false })
2964
+ await selectFastestServer({ silent: true, reconnect: false })
2817
2965
  openStreams()
2818
2966
  await refreshSessions()
2819
- const host = await safeRpc('host.describe', {}, '')
2820
- if (host) state.hostInfo = host
2967
+ await refreshHostDescriptionDesktop()
2821
2968
  refreshWorkbench({ silent: true })
2822
2969
  }
2823
2970
  renderOverviewDesktop()
@@ -1,10 +1,14 @@
1
1
  {
2
- "version": "0.6.16",
2
+ "version": "0.6.17",
3
3
  "apkUrl": "dsh-remote.apk",
4
- "sha256": "f2bae1c266867199cc0c21f9e9d08714543acd1b186220f3de906712ba1d9518",
5
- "releasedAt": "2026-08-27T05:20:03.020Z",
6
- "notes": "0.6.16:Windows DSH 服务启动/重启接入 Windows Service(实验性功能),启动后继续检查 DSH HTTP mux/host 实时通道;空对话退出时清理 App 本地残留;网关控制台主机 IP 改为可逐项启用的地址表,关闭地址不再进入配对二维码、诊断和防火墙建议;Android 设置新增模型配置、自定义模型思考深度档位和小米/系统 ASR 功能测试,支持复制设备、权限、partial/final、session 重建和错误诊断日志。",
4
+ "sha256": "fb9c0cc12d1e6479d509f5755090aa95ddff904bbd213545b8eb547030e7d1a3",
5
+ "releasedAt": "2026-08-28T05:53:18.723Z",
6
+ "notes": "0.6.17:修复 host.describe 初次失败后总览误报 DSH 上游离线并增加恢复重试;识别 Docker、无 systemd 和插件内嵌等外部生命周期环境,隐藏不可用的 DSH 启停并给出明确提示;兼容 Caddy Basic Auth,避免管理 API 覆盖 Authorization 导致登录循环;支持通过环境变量或管理页手动补充 Docker 宿主、局域网和 Tailscale 地址并写入配对二维码;完整适配 Windows 盘符、UNC、多文件根和跨主机文件路径清理,并新增 Windows 原生 CI 安全门禁。",
7
7
  "history": [
8
+ {
9
+ "version": "0.6.17",
10
+ "notes": "0.6.17:修复 host.describe 初次失败后总览误报 DSH 上游离线并增加恢复重试;识别 Docker、无 systemd 和插件内嵌等外部生命周期环境,隐藏不可用的 DSH 启停并给出明确提示;兼容 Caddy Basic Auth,避免管理 API 覆盖 Authorization 导致登录循环;支持通过环境变量或管理页手动补充 Docker 宿主、局域网和 Tailscale 地址并写入配对二维码;完整适配 Windows 盘符、UNC、多文件根和跨主机文件路径清理,并新增 Windows 原生 CI 安全门禁。"
11
+ },
8
12
  {
9
13
  "version": "0.6.16",
10
14
  "notes": "0.6.16:Windows 端 DSH 服务启动/重启接入 Windows Service(实验性功能),启动后继续检查 DSH HTTP 与 mux/host 实时通道;空对话退出时清理 App 本地残留;网关控制台主机 IP 改为可逐项启用的地址表,关闭地址不再进入配对二维码、诊断和防火墙建议;Android 设置新增模型配置、自定义模型思考深度档位和小米/系统 ASR 功能测试,支持复制设备、权限、partial/final、session 重建和错误诊断日志。"
@@ -40,10 +44,6 @@
40
44
  {
41
45
  "version": "0.6.8",
42
46
  "notes": "0.6.8 正式版:重构手机端、桌面端和插件管理界面;新增工作台、图片附件、历史公告和全屏输入;修复会话布局、系统提示裁切、主题图标对比度与全屏输入高度问题。"
43
- },
44
- {
45
- "version": "0.6.7",
46
- "notes": "修复手机端左滑归档松手后按钮不固定;新增云端公告弹窗,支持按 App 版本和有效期定向发布;新增工作台绑定与项目级会话;新增手机端左滑归档和二次确认;新增手机回车行为设置;新增归档会话折叠和 /permission 参数选择。"
47
47
  }
48
48
  ]
49
49
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.6.16"
2
+ "version": "0.6.17"
3
3
  }