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.
- package/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +87 -8
- package/index.mjs +21 -3
- package/package.json +1 -1
- package/public/admin.html +11 -6
- package/public/admin.js +88 -14
- package/public/app.js +190 -26
- package/public/desktop/desktop.css +1 -0
- package/public/desktop/desktop.html +4 -3
- package/public/desktop/desktop.js +167 -20
- package/public/update.json +8 -8
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
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
|
|
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
|
|
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
|
|
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, {
|
|
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.
|
|
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-
|
|
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"
|
|
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': '
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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',
|
|
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',
|
|
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',
|
|
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: {
|
|
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',
|
|
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',
|
|
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')
|
|
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
|
|
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)
|
|
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())
|
|
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)
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
3288
|
-
|
|
3289
|
-
if (
|
|
3290
|
-
|
|
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
|
-
|
|
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(
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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')
|
|
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
|
|
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)
|
|
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())
|
|
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
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
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
|
|
2059
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
2964
|
+
await selectFastestServer({ silent: true, reconnect: false })
|
|
2817
2965
|
openStreams()
|
|
2818
2966
|
await refreshSessions()
|
|
2819
|
-
|
|
2820
|
-
if (host) state.hostInfo = host
|
|
2967
|
+
await refreshHostDescriptionDesktop()
|
|
2821
2968
|
refreshWorkbench({ silent: true })
|
|
2822
2969
|
}
|
|
2823
2970
|
renderOverviewDesktop()
|
package/public/update.json
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.6.
|
|
2
|
+
"version": "0.6.17",
|
|
3
3
|
"apkUrl": "dsh-remote.apk",
|
|
4
|
-
"sha256": "
|
|
5
|
-
"releasedAt": "2026-08-
|
|
6
|
-
"notes": "0.6.
|
|
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
|
}
|
package/public/version.json
CHANGED