dsh-remote-plugin 0.4.5 → 0.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +451 -0
- package/package.json +2 -2
- package/public/app.js +480 -18
- package/public/index.html +25 -2
- package/public/styles.css +71 -0
- package/public/update.json +3 -3
- package/public/version.json +1 -1
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# dsh-remote-plugin
|
|
2
2
|
|
|
3
|
-
DSH Remote 的 DSH bundle 插件:在 DSH 左侧原生边栏注册入口,点击从右侧滑出管理抽屉;插件**内置网关程序并随 DSH 自动启停**(独立 systemd 单元),抽屉直显令牌、主机 IP 与设备监控,配合 [dsh-Remote](https://github.com/Blank-not-black/dsh-Remote) 的 Android App
|
|
3
|
+
DSH Remote 的 DSH bundle 插件:在 DSH 左侧原生边栏注册入口,点击从右侧滑出管理抽屉;插件**内置网关程序并随 DSH 自动启停**(独立 systemd 单元),抽屉直显令牌、主机 IP 与设备监控,配合 [dsh-Remote](https://github.com/Blank-not-black/dsh-Remote) 的 Android App 实现手机远程操控与文件互传(`/fs/list`、`/fs/file`、`/fs/upload`)。
|
|
4
4
|
|
|
5
5
|
## 安装
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
8
|
dsh plugin --profile web add dsh-remote-plugin
|
|
9
9
|
# 或 pin 版本
|
|
10
|
-
dsh plugin --profile web add dsh-remote-plugin@0.4.
|
|
10
|
+
dsh plugin --profile web add dsh-remote-plugin@0.4.8
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
重启 DSH Web 后 Ctrl+F5,左侧边栏底部出现 App 图标入口。
|
|
@@ -24,10 +24,11 @@ dsh plugin --profile web add "github:Blank-not-black/dsh-Remote#main&path:/packa
|
|
|
24
24
|
- 开关持久化在 `~/.dsh-remote/gateway.enabled`;抽屉内可停止/启动。
|
|
25
25
|
- 令牌在 `~/.dsh-remote/token`(首次自动生成,重复使用不覆盖),抽屉里显示并可复制。
|
|
26
26
|
- 环境变量 `DSH_REMOTE_AUTOSTART=0` 可关闭自动管理。
|
|
27
|
+
- 文件端点:`/fs/list`(列目录)、`/fs/file`(下载,支持 Range)、`/fs/upload`(上传,默认上限 2GB);默认根目录 `~`,`DSH_REMOTE_FS_ROOT` 可开多根(`:` 分隔)。
|
|
27
28
|
|
|
28
29
|
## 手机 App
|
|
29
30
|
|
|
30
|
-
从 [Releases](https://github.com/Blank-not-black/dsh-Remote/releases/latest) 下载 `dsh-remote.apk
|
|
31
|
+
从 [Releases](https://github.com/Blank-not-black/dsh-Remote/releases/latest) 下载 `dsh-remote.apk`,App「设置」里可添加多个服务器地址(局域网 + Tailscale),点「测速」自动选最快;填抽屉里的令牌即可。聊天记录会本地缓存,断网也能看历史;下载的文件统一放系统「下载/dsh-remote」。
|
|
31
32
|
|
|
32
33
|
## License
|
|
33
34
|
|
package/apk/dsh-remote.apk
CHANGED
|
Binary file
|
package/gateway.cjs
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* DSH_UPSTREAM DSH web 服务地址, 默认 http://127.0.0.1:3080
|
|
19
19
|
* TOKEN 访问令牌; 不设置则读 TOKEN_FILE, 仍没有则自动生成
|
|
20
20
|
* TOKEN_FILE 令牌文件, 默认 ~/.dsh-remote/token
|
|
21
|
+
* DSH_REMOTE_FS_ROOT 文件传输允许根, 默认 ~, 多个用 ':' 分隔
|
|
22
|
+
* DSH_REMOTE_FS_MAX_UPLOAD 上传字节上限, 默认 2147483648 (2GB)
|
|
21
23
|
*/
|
|
22
24
|
'use strict'
|
|
23
25
|
|
|
@@ -69,6 +71,63 @@ const MIME = {
|
|
|
69
71
|
'.apk': 'application/vnd.android.package-archive'
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
// ---------- /fs 文件传输 ----------
|
|
75
|
+
// 允许访问的根目录: DSH_REMOTE_FS_ROOT 用 ':' 分隔多个根, 默认仅 ~。
|
|
76
|
+
// 所有 /fs/* 路径 resolve 后都必须位于某个根内, 已存在的路径还会用 realpath
|
|
77
|
+
// 复核一次, 防止 ../ 穿越与符号链接逃逸。
|
|
78
|
+
const FS_DEFAULT_ROOT = path.resolve(os.homedir())
|
|
79
|
+
const FS_ROOTS = (process.env.DSH_REMOTE_FS_ROOT || FS_DEFAULT_ROOT)
|
|
80
|
+
.split(':')
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.map(r => path.resolve(r.trim() === '~' ? FS_DEFAULT_ROOT : r.trim()))
|
|
83
|
+
const FS_MAX_UPLOAD = Number(process.env.DSH_REMOTE_FS_MAX_UPLOAD) || 2 * 1024 * 1024 * 1024
|
|
84
|
+
let FS_ROOT_REALS = null
|
|
85
|
+
function fsRootReals() {
|
|
86
|
+
if (!FS_ROOT_REALS) {
|
|
87
|
+
FS_ROOT_REALS = FS_ROOTS.map(r => { try { return fs.realpathSync(r) } catch { return null } }).filter(Boolean)
|
|
88
|
+
}
|
|
89
|
+
return FS_ROOT_REALS
|
|
90
|
+
}
|
|
91
|
+
function fsInsideReal(real) {
|
|
92
|
+
for (const root of fsRootReals()) {
|
|
93
|
+
if (real === root || real.startsWith(root + path.sep)) return true
|
|
94
|
+
}
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const FS_MIME = {
|
|
99
|
+
...MIME,
|
|
100
|
+
'.jpg': 'image/jpeg',
|
|
101
|
+
'.jpeg': 'image/jpeg',
|
|
102
|
+
'.gif': 'image/gif',
|
|
103
|
+
'.webp': 'image/webp',
|
|
104
|
+
'.bmp': 'image/bmp',
|
|
105
|
+
'.mp3': 'audio/mpeg',
|
|
106
|
+
'.wav': 'audio/wav',
|
|
107
|
+
'.ogg': 'audio/ogg',
|
|
108
|
+
'.flac': 'audio/flac',
|
|
109
|
+
'.m4a': 'audio/mp4',
|
|
110
|
+
'.mp4': 'video/mp4',
|
|
111
|
+
'.mkv': 'video/x-matroska',
|
|
112
|
+
'.webm': 'video/webm',
|
|
113
|
+
'.mov': 'video/quicktime',
|
|
114
|
+
'.avi': 'video/x-msvideo',
|
|
115
|
+
'.pdf': 'application/pdf',
|
|
116
|
+
'.zip': 'application/zip',
|
|
117
|
+
'.gz': 'application/gzip',
|
|
118
|
+
'.tar': 'application/x-tar',
|
|
119
|
+
'.7z': 'application/x-7z-compressed',
|
|
120
|
+
'.rar': 'application/vnd.rar',
|
|
121
|
+
'.doc': 'application/msword',
|
|
122
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
123
|
+
'.xls': 'application/vnd.ms-excel',
|
|
124
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
125
|
+
'.ppt': 'application/vnd.ms-powerpoint',
|
|
126
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
127
|
+
'.epub': 'application/epub+zip',
|
|
128
|
+
'.wasm': 'application/wasm',
|
|
129
|
+
}
|
|
130
|
+
|
|
72
131
|
// ---------- token ----------
|
|
73
132
|
function loadToken() {
|
|
74
133
|
if (process.env.TOKEN) return process.env.TOKEN
|
|
@@ -472,6 +531,397 @@ function serveAdminApi(req, res, url) {
|
|
|
472
531
|
res.end(JSON.stringify({ error: 'not-found' }))
|
|
473
532
|
}
|
|
474
533
|
|
|
534
|
+
// ---------- /fs 文件传输: 实现 ----------
|
|
535
|
+
function fsJson(res, status, body) {
|
|
536
|
+
cors(res)
|
|
537
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
538
|
+
res.end(JSON.stringify(body))
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function fsAuthorized(req, url, res) {
|
|
542
|
+
const ok = authorized(req, url)
|
|
543
|
+
touchDevice(req, ok ? {} : { failedAuth: true })
|
|
544
|
+
if (!ok) {
|
|
545
|
+
authFailures++
|
|
546
|
+
fsJson(res, 401, { error: 'unauthorized' })
|
|
547
|
+
return false
|
|
548
|
+
}
|
|
549
|
+
return true
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** 把用户给的 path 解析为绝对路径并做词法根检查; 返回 {abs} 或 {error}。 */
|
|
553
|
+
function fsResolve(input) {
|
|
554
|
+
const raw = String(input ?? '').trim()
|
|
555
|
+
let abs
|
|
556
|
+
if (!raw || raw === '~') abs = FS_ROOTS[0]
|
|
557
|
+
else if (raw.startsWith('~/')) abs = path.resolve(FS_DEFAULT_ROOT, raw.slice(2))
|
|
558
|
+
else if (path.isAbsolute(raw)) abs = path.resolve(raw)
|
|
559
|
+
else abs = path.resolve(FS_ROOTS[0], raw) // 相对路径按默认根解析
|
|
560
|
+
for (const root of FS_ROOTS) {
|
|
561
|
+
if (abs === root || abs.startsWith(root + path.sep)) return { abs }
|
|
562
|
+
}
|
|
563
|
+
return { error: 'forbidden' }
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** realpath 复核: 符号链接目标也必须落在允许根内。 */
|
|
567
|
+
function fsRealChecked(abs) {
|
|
568
|
+
let real
|
|
569
|
+
try {
|
|
570
|
+
real = fs.realpathSync(abs)
|
|
571
|
+
} catch (err) {
|
|
572
|
+
return { error: err.code === 'ENOENT' ? 'not-found' : 'permission-denied' }
|
|
573
|
+
}
|
|
574
|
+
if (!fsInsideReal(real)) return { error: 'forbidden' }
|
|
575
|
+
return { abs: real }
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function fsContentDisposition(name) {
|
|
579
|
+
const ascii = String(name).replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_') || 'download'
|
|
580
|
+
const star = encodeURIComponent(name).replace(/['()*]/g, c =>
|
|
581
|
+
'%' + c.charCodeAt(0).toString(16).toUpperCase())
|
|
582
|
+
return `attachment; filename="${ascii}"; filename*=UTF-8''${star}`
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** 单段 Range: bytes=a-b / bytes=a- / bytes=-n。多段或不合法返回 null(按 200 整文件处理)。 */
|
|
586
|
+
function fsParseRange(header, size) {
|
|
587
|
+
if (!header || size <= 0) return null
|
|
588
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(String(header).trim())
|
|
589
|
+
if (!m) return null
|
|
590
|
+
const s = m[1], e = m[2]
|
|
591
|
+
if (s === '' && e === '') return null
|
|
592
|
+
if (s === '') { // 末尾 n 字节
|
|
593
|
+
const n = Number(e)
|
|
594
|
+
if (!Number.isFinite(n) || n <= 0) return null
|
|
595
|
+
return { start: Math.max(0, size - n), end: size - 1 }
|
|
596
|
+
}
|
|
597
|
+
const start = Number(s)
|
|
598
|
+
if (!Number.isFinite(start) || start < 0) return null
|
|
599
|
+
if (e === '') return { start, end: size - 1 }
|
|
600
|
+
const end = Number(e)
|
|
601
|
+
if (!Number.isFinite(end) || end < start) return null
|
|
602
|
+
return { start, end: Math.min(end, size - 1) }
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function fsList(req, res, url) {
|
|
606
|
+
if (req.method !== 'GET') {
|
|
607
|
+
res.writeHead(405, { allow: 'GET' })
|
|
608
|
+
res.end()
|
|
609
|
+
return
|
|
610
|
+
}
|
|
611
|
+
if (!fsAuthorized(req, url, res)) return
|
|
612
|
+
const resolved = fsResolve(url.searchParams.get('path') ?? '')
|
|
613
|
+
if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
|
|
614
|
+
const checked = fsRealChecked(resolved.abs)
|
|
615
|
+
if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
|
|
616
|
+
|
|
617
|
+
let st
|
|
618
|
+
try { st = fs.statSync(checked.abs) } catch (err) {
|
|
619
|
+
return fsJson(res, err.code === 'ENOENT' ? 404 : 403, { error: err.code === 'ENOENT' ? 'not-found' : 'permission-denied' })
|
|
620
|
+
}
|
|
621
|
+
if (!st.isDirectory()) return fsJson(res, 400, { error: 'not-a-directory' })
|
|
622
|
+
|
|
623
|
+
let dirents
|
|
624
|
+
try { dirents = fs.readdirSync(checked.abs, { withFileTypes: true }) } catch {
|
|
625
|
+
return fsJson(res, 403, { error: 'permission-denied' })
|
|
626
|
+
}
|
|
627
|
+
const entries = []
|
|
628
|
+
for (const d of dirents) {
|
|
629
|
+
const full = path.join(checked.abs, d.name)
|
|
630
|
+
try {
|
|
631
|
+
// 符号链接指向允许根之外时直接不展示, 点进去/下载也必然被 realpath 复核拒绝
|
|
632
|
+
if (d.isSymbolicLink()) {
|
|
633
|
+
const real = fs.realpathSync(full)
|
|
634
|
+
if (!fsInsideReal(real)) continue
|
|
635
|
+
}
|
|
636
|
+
const info = fs.statSync(full)
|
|
637
|
+
if (!info.isFile() && !info.isDirectory()) continue
|
|
638
|
+
entries.push({
|
|
639
|
+
name: d.name,
|
|
640
|
+
type: info.isDirectory() ? 'dir' : 'file',
|
|
641
|
+
size: info.isDirectory() ? 0 : info.size,
|
|
642
|
+
mtimeMs: Math.round(info.mtimeMs)
|
|
643
|
+
})
|
|
644
|
+
} catch {
|
|
645
|
+
// 单个条目无权限/已消失: 跳过, 不让整个列表失败
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
entries.sort((a, b) => {
|
|
649
|
+
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
|
|
650
|
+
return a.name.localeCompare(b.name, 'zh-CN', { numeric: true })
|
|
651
|
+
})
|
|
652
|
+
fsJson(res, 200, { path: resolved.abs, entries })
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function fsFile(req, res, url) {
|
|
656
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
657
|
+
res.writeHead(405, { allow: 'GET, HEAD' })
|
|
658
|
+
res.end()
|
|
659
|
+
return
|
|
660
|
+
}
|
|
661
|
+
if (!fsAuthorized(req, url, res)) return
|
|
662
|
+
const resolved = fsResolve(url.searchParams.get('path') ?? '')
|
|
663
|
+
if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
|
|
664
|
+
const checked = fsRealChecked(resolved.abs)
|
|
665
|
+
if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
|
|
666
|
+
|
|
667
|
+
let st
|
|
668
|
+
try { st = fs.statSync(checked.abs) } catch (err) {
|
|
669
|
+
return fsJson(res, err.code === 'ENOENT' ? 404 : 403, { error: err.code === 'ENOENT' ? 'not-found' : 'permission-denied' })
|
|
670
|
+
}
|
|
671
|
+
if (!st.isFile()) return fsJson(res, 400, { error: 'not-a-file' })
|
|
672
|
+
|
|
673
|
+
const range = fsParseRange(req.headers.range, st.size)
|
|
674
|
+
if (range && range.start >= st.size) {
|
|
675
|
+
cors(res)
|
|
676
|
+
res.writeHead(416, {
|
|
677
|
+
'content-type': 'application/json; charset=utf-8',
|
|
678
|
+
'content-range': `bytes */${st.size}`,
|
|
679
|
+
'accept-ranges': 'bytes'
|
|
680
|
+
})
|
|
681
|
+
res.end(JSON.stringify({ error: 'range-not-satisfiable', size: st.size }))
|
|
682
|
+
return
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const ext = path.extname(checked.abs).toLowerCase()
|
|
686
|
+
cors(res)
|
|
687
|
+
res.writeHead(range ? 206 : 200, {
|
|
688
|
+
'content-type': FS_MIME[ext] || 'application/octet-stream',
|
|
689
|
+
'content-length': range ? range.end - range.start + 1 : st.size,
|
|
690
|
+
'content-disposition': fsContentDisposition(path.basename(checked.abs)),
|
|
691
|
+
'accept-ranges': 'bytes',
|
|
692
|
+
'cache-control': 'no-cache',
|
|
693
|
+
...(range ? { 'content-range': `bytes ${range.start}-${range.end}/${st.size}` } : {})
|
|
694
|
+
})
|
|
695
|
+
if (req.method === 'HEAD') { res.end(); return }
|
|
696
|
+
const stream = range
|
|
697
|
+
? fs.createReadStream(checked.abs, { start: range.start, end: range.end })
|
|
698
|
+
: fs.createReadStream(checked.abs)
|
|
699
|
+
stream.on('error', () => { try { res.destroy() } catch {} })
|
|
700
|
+
stream.pipe(res)
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function fsValidName(name) {
|
|
704
|
+
if (typeof name !== 'string') return false
|
|
705
|
+
if (!name || name === '.' || name === '..') return false
|
|
706
|
+
if (name.includes('/') || name.includes('\\') || name.includes('\0')) return false
|
|
707
|
+
if (path.basename(name) !== name) return false
|
|
708
|
+
return true
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** 打开上传目标: 同名冲突/符号链接/临时文件都在这层判定。 */
|
|
712
|
+
function fsOpenUploadTarget(res, url, dirLex, dirReal, name) {
|
|
713
|
+
if (!fsValidName(name)) {
|
|
714
|
+
fsJson(res, 400, { error: 'bad-name', detail: '文件名不能为空且不能包含路径分隔符' })
|
|
715
|
+
return null
|
|
716
|
+
}
|
|
717
|
+
const target = path.join(dirReal, name)
|
|
718
|
+
const overwrite = url.searchParams.get('overwrite') === '1' || url.searchParams.get('overwrite') === 'true'
|
|
719
|
+
let exists = false
|
|
720
|
+
try {
|
|
721
|
+
const st = fs.lstatSync(target)
|
|
722
|
+
exists = true
|
|
723
|
+
if (st.isSymbolicLink()) {
|
|
724
|
+
fsJson(res, 403, { error: 'symlink-forbidden', detail: '拒绝覆盖符号链接' })
|
|
725
|
+
return null
|
|
726
|
+
}
|
|
727
|
+
} catch (err) {
|
|
728
|
+
if (err.code !== 'ENOENT') {
|
|
729
|
+
fsJson(res, 403, { error: 'permission-denied', detail: err.message })
|
|
730
|
+
return null
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
if (exists && !overwrite) {
|
|
734
|
+
fsJson(res, 409, { error: 'conflict', detail: '文件已存在, 追加 overwrite=1 可覆盖' })
|
|
735
|
+
return null
|
|
736
|
+
}
|
|
737
|
+
const tmp = path.join(dirReal, `.${name}.dsh-remote-part-${process.pid}-${crypto.randomBytes(4).toString('hex')}`)
|
|
738
|
+
let stream
|
|
739
|
+
try {
|
|
740
|
+
stream = fs.createWriteStream(tmp, { flags: 'wx', mode: 0o600 })
|
|
741
|
+
} catch (err) {
|
|
742
|
+
fsJson(res, 403, { error: 'permission-denied', detail: err.message })
|
|
743
|
+
return null
|
|
744
|
+
}
|
|
745
|
+
return { stream, tmp, target, displayPath: path.join(dirLex, name), name, overwrite, bytes: 0 }
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** 上传管道: 计数限量, 成功后 rename(先写 .part 再原子落位)。 */
|
|
749
|
+
function fsUploadPipe(res, url, dirLex, dirReal, name) {
|
|
750
|
+
const up = fsOpenUploadTarget(res, url, dirLex, dirReal, name)
|
|
751
|
+
return up ? fsUploadPipeFromTarget(res, up) : null
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function fsUploadPipeFromTarget(res, up) {
|
|
755
|
+
let finished = false
|
|
756
|
+
const cleanup = () => {
|
|
757
|
+
if (finished) return
|
|
758
|
+
finished = true
|
|
759
|
+
try { up.stream.destroy() } catch {}
|
|
760
|
+
try { fs.unlinkSync(up.tmp) } catch {}
|
|
761
|
+
}
|
|
762
|
+
up.stream.on('error', () => {
|
|
763
|
+
if (finished) return
|
|
764
|
+
finished = true
|
|
765
|
+
try { fs.unlinkSync(up.tmp) } catch {}
|
|
766
|
+
if (!res.headersSent) fsJson(res, 500, { error: 'write-failed' })
|
|
767
|
+
else try { res.destroy() } catch {}
|
|
768
|
+
})
|
|
769
|
+
return {
|
|
770
|
+
write(chunk) {
|
|
771
|
+
if (finished) return
|
|
772
|
+
up.bytes += chunk.length
|
|
773
|
+
if (up.bytes > FS_MAX_UPLOAD) {
|
|
774
|
+
cleanup()
|
|
775
|
+
if (!res.headersSent) fsJson(res, 413, { error: 'too-large', limit: FS_MAX_UPLOAD })
|
|
776
|
+
else try { res.destroy() } catch {}
|
|
777
|
+
return
|
|
778
|
+
}
|
|
779
|
+
up.stream.write(chunk)
|
|
780
|
+
},
|
|
781
|
+
end() {
|
|
782
|
+
if (finished) return
|
|
783
|
+
finished = true
|
|
784
|
+
up.stream.end(() => {
|
|
785
|
+
try {
|
|
786
|
+
if (up.overwrite) fs.rmSync(up.target, { force: true })
|
|
787
|
+
fs.renameSync(up.tmp, up.target)
|
|
788
|
+
} catch (err) {
|
|
789
|
+
try { fs.unlinkSync(up.tmp) } catch {}
|
|
790
|
+
if (!res.headersSent) return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
|
|
791
|
+
return
|
|
792
|
+
}
|
|
793
|
+
fsJson(res, 201, { ok: true, path: up.displayPath, name: up.name, size: up.bytes })
|
|
794
|
+
})
|
|
795
|
+
},
|
|
796
|
+
abort(status, msg) {
|
|
797
|
+
cleanup()
|
|
798
|
+
if (!res.headersSent) fsJson(res, status, { error: msg })
|
|
799
|
+
else try { res.destroy() } catch {}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function fsUploadRaw(req, res, url, dirLex, dirReal) {
|
|
805
|
+
const name = url.searchParams.get('name') || ''
|
|
806
|
+
const pipe = fsUploadPipe(res, url, dirLex, dirReal, name)
|
|
807
|
+
if (!pipe) return
|
|
808
|
+
req.on('aborted', () => pipe.abort(400, 'client-aborted'))
|
|
809
|
+
req.on('error', () => pipe.abort(400, 'client-aborted'))
|
|
810
|
+
req.on('data', (chunk) => pipe.write(chunk))
|
|
811
|
+
req.on('end', () => pipe.end())
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** 零依赖流式 multipart 解析: 只取第一个文件部分, 2GB 也不会整块进内存。 */
|
|
815
|
+
function fsUploadMultipart(req, res, url, dirLex, dirReal, boundary) {
|
|
816
|
+
const queryName = url.searchParams.get('name') || ''
|
|
817
|
+
const marker = Buffer.from('\r\n--' + boundary)
|
|
818
|
+
let head = Buffer.alloc(0)
|
|
819
|
+
let tail = Buffer.alloc(0)
|
|
820
|
+
let state = 'headers' // headers -> data -> done
|
|
821
|
+
let pipe = null
|
|
822
|
+
|
|
823
|
+
const fail = (status, msg) => {
|
|
824
|
+
if (pipe) pipe.abort(status, msg)
|
|
825
|
+
else if (!res.headersSent) fsJson(res, status, { error: msg })
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const process = (buf) => {
|
|
829
|
+
if (state === 'done') return
|
|
830
|
+
if (state === 'headers') {
|
|
831
|
+
head = Buffer.concat([head, buf])
|
|
832
|
+
if (head.length > 64 * 1024) return fail(400, 'multipart-headers-too-large')
|
|
833
|
+
const idx = head.indexOf('\r\n\r\n')
|
|
834
|
+
if (idx === -1) return
|
|
835
|
+
const headerText = head.slice(0, idx).toString('utf8')
|
|
836
|
+
let partName = queryName
|
|
837
|
+
if (!partName) {
|
|
838
|
+
const m = /filename="([^"]*)"/i.exec(headerText)
|
|
839
|
+
partName = m ? path.basename(String(m[1]).replace(/\\/g, '/')) : ''
|
|
840
|
+
}
|
|
841
|
+
if (!fsValidName(partName)) return fail(400, 'bad-name')
|
|
842
|
+
pipe = fsUploadPipe(res, url, dirLex, dirReal, partName)
|
|
843
|
+
if (!pipe) { state = 'done'; return }
|
|
844
|
+
const rest = head.slice(idx + 4)
|
|
845
|
+
head = null
|
|
846
|
+
state = 'data'
|
|
847
|
+
if (rest.length) process(rest)
|
|
848
|
+
return
|
|
849
|
+
}
|
|
850
|
+
// data: 滑动窗口找 \r\n--boundary, 未命中时保留尾部防跨 chunk 边界
|
|
851
|
+
buf = Buffer.concat([tail, buf])
|
|
852
|
+
const idx = buf.indexOf(marker)
|
|
853
|
+
if (idx === -1) {
|
|
854
|
+
const keep = Math.min(buf.length, marker.length - 1)
|
|
855
|
+
if (buf.length > keep) pipe.write(buf.slice(0, buf.length - keep))
|
|
856
|
+
tail = buf.slice(buf.length - keep)
|
|
857
|
+
return
|
|
858
|
+
}
|
|
859
|
+
if (idx > 0) pipe.write(buf.slice(0, idx))
|
|
860
|
+
state = 'done'
|
|
861
|
+
pipe.end()
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
req.on('aborted', () => { if (pipe) pipe.abort(400, 'client-aborted') })
|
|
865
|
+
req.on('error', () => { if (pipe) pipe.abort(400, 'client-aborted') })
|
|
866
|
+
req.on('data', (chunk) => process(chunk))
|
|
867
|
+
req.on('end', () => {
|
|
868
|
+
if (state === 'headers') return fail(400, 'no-file-part')
|
|
869
|
+
if (state === 'data' && pipe) {
|
|
870
|
+
if (tail.length) pipe.write(tail)
|
|
871
|
+
pipe.end()
|
|
872
|
+
}
|
|
873
|
+
})
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function serveFs(req, res, url) {
|
|
877
|
+
const sub = url.pathname.slice('/fs'.length)
|
|
878
|
+
|
|
879
|
+
// 跨域预检: 浏览器控制台可能从 DSH /remote 页访问网关(Authorization 非简单头)
|
|
880
|
+
if (req.method === 'OPTIONS') {
|
|
881
|
+
cors(res)
|
|
882
|
+
res.writeHead(204)
|
|
883
|
+
res.end()
|
|
884
|
+
return
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
if (sub === '/list') return fsList(req, res, url)
|
|
888
|
+
if (sub === '/file') return fsFile(req, res, url)
|
|
889
|
+
|
|
890
|
+
if (sub === '/upload') {
|
|
891
|
+
if (req.method !== 'POST') {
|
|
892
|
+
res.writeHead(405, { allow: 'POST' })
|
|
893
|
+
res.end()
|
|
894
|
+
return
|
|
895
|
+
}
|
|
896
|
+
if (!fsAuthorized(req, url, res)) return
|
|
897
|
+
touchDevice(req)
|
|
898
|
+
const resolved = fsResolve(url.searchParams.get('path') ?? '')
|
|
899
|
+
if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
|
|
900
|
+
const checked = fsRealChecked(resolved.abs)
|
|
901
|
+
if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
|
|
902
|
+
try {
|
|
903
|
+
const st = fs.statSync(checked.abs)
|
|
904
|
+
if (!st.isDirectory()) return fsJson(res, 400, { error: 'not-a-directory' })
|
|
905
|
+
} catch (err) {
|
|
906
|
+
return fsJson(res, err.code === 'ENOENT' ? 404 : 403, { error: err.code === 'ENOENT' ? 'not-found' : 'permission-denied' })
|
|
907
|
+
}
|
|
908
|
+
const contentLength = Number(req.headers['content-length'])
|
|
909
|
+
if (Number.isFinite(contentLength) && contentLength > FS_MAX_UPLOAD) {
|
|
910
|
+
return fsJson(res, 413, { error: 'too-large', limit: FS_MAX_UPLOAD })
|
|
911
|
+
}
|
|
912
|
+
const contentType = String(req.headers['content-type'] || '')
|
|
913
|
+
if (contentType.startsWith('multipart/form-data')) {
|
|
914
|
+
const m = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType)
|
|
915
|
+
const boundary = (m ? (m[1] || m[2]) : '').trim()
|
|
916
|
+
if (!boundary) return fsJson(res, 400, { error: 'bad-multipart', detail: '缺少 boundary' })
|
|
917
|
+
return fsUploadMultipart(req, res, url, resolved.abs, checked.abs, boundary)
|
|
918
|
+
}
|
|
919
|
+
return fsUploadRaw(req, res, url, resolved.abs, checked.abs)
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
fsJson(res, 404, { error: 'not-found' })
|
|
923
|
+
}
|
|
924
|
+
|
|
475
925
|
// ---------- /api 代理 ----------
|
|
476
926
|
function proxyApi(req, res, url) {
|
|
477
927
|
if (req.method === 'OPTIONS') {
|
|
@@ -547,6 +997,7 @@ function lanAddresses() {
|
|
|
547
997
|
const server = http.createServer((req, res) => {
|
|
548
998
|
try {
|
|
549
999
|
const url = new URL(req.url, 'http://dsh-remote.local')
|
|
1000
|
+
if (url.pathname === '/fs' || url.pathname.startsWith('/fs/')) return serveFs(req, res, url)
|
|
550
1001
|
if (url.pathname.startsWith('/admin/api')) return serveAdminApi(req, res, url)
|
|
551
1002
|
if (url.pathname.startsWith('/api/')) return proxyApi(req, res, url)
|
|
552
1003
|
if (url.pathname === '/health') return serveHealth(res)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-plugin",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元)
|
|
3
|
+
"version": "0.4.8",
|
|
4
|
+
"description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
|
7
7
|
"exports": {
|
package/public/app.js
CHANGED
|
@@ -8,9 +8,35 @@ const LS = {
|
|
|
8
8
|
del(k) { try { localStorage.removeItem(k) } catch {} }
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/* 离线缓存: 会话列表 + 每会话聊天记录。只在网络失败时兜底展示, 不会替代线上数据。 */
|
|
12
|
+
const CACHE = {
|
|
13
|
+
sessions: 'sessionsCacheV1',
|
|
14
|
+
history: 'historyCacheV1'
|
|
15
|
+
}
|
|
16
|
+
function cacheRead(key, d = null) {
|
|
17
|
+
try { return JSON.parse(LS.get(key, '')) || d } catch { return d }
|
|
18
|
+
}
|
|
19
|
+
function cacheWrite(key, value) {
|
|
20
|
+
try { LS.set(key, JSON.stringify(value)) } catch { LS.del(key) }
|
|
21
|
+
}
|
|
22
|
+
function readHistoryCache() { return cacheRead(CACHE.history, {}) || {} }
|
|
23
|
+
function writeHistoryCache(cache) {
|
|
24
|
+
try { LS.set(CACHE.history, JSON.stringify(cache)); return }
|
|
25
|
+
catch {
|
|
26
|
+
// localStorage 配额不足: 每会话只留最近 50 条再试一次
|
|
27
|
+
try {
|
|
28
|
+
for (const k of Object.keys(cache)) cache[k].events = (cache[k].events || []).slice(-50)
|
|
29
|
+
LS.set(CACHE.history, JSON.stringify(cache))
|
|
30
|
+
} catch { LS.del(CACHE.history) }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
11
34
|
const state = {
|
|
12
35
|
token: '',
|
|
13
|
-
server: '', //
|
|
36
|
+
server: '', // 当前生效的网关地址, 空 = 同源(浏览器模式)
|
|
37
|
+
servers: [], // 备选服务器列表(局域网/远程多地址)
|
|
38
|
+
serverLatency: {}, // url -> 最近一次 /health 测速毫秒数
|
|
39
|
+
selectingServer: false, // 防重入: 测速/切换中
|
|
14
40
|
sessions: [],
|
|
15
41
|
byId: new Map(),
|
|
16
42
|
current: null, // 当前打开的 sessionId
|
|
@@ -23,7 +49,8 @@ const state = {
|
|
|
23
49
|
jobs: {}, // sessionId -> jobs
|
|
24
50
|
history: emptyHistory(),
|
|
25
51
|
errCount: 0,
|
|
26
|
-
refreshTimer: null
|
|
52
|
+
refreshTimer: null,
|
|
53
|
+
fs: { path: null, initial: null, loaded: false }
|
|
27
54
|
}
|
|
28
55
|
|
|
29
56
|
const $ = (id) => document.getElementById(id)
|
|
@@ -57,6 +84,21 @@ function fmtTokens(n) {
|
|
|
57
84
|
return String(n)
|
|
58
85
|
}
|
|
59
86
|
|
|
87
|
+
function fmtSize(n) {
|
|
88
|
+
if (n == null || Number.isNaN(Number(n))) return '—'
|
|
89
|
+
const b = Number(n)
|
|
90
|
+
if (b >= 1024 ** 3) return (b / 1024 ** 3).toFixed(2) + ' GB'
|
|
91
|
+
if (b >= 1024 ** 2) return (b / 1024 ** 2).toFixed(1) + ' MB'
|
|
92
|
+
if (b >= 1024) return (b / 1024).toFixed(1) + ' KB'
|
|
93
|
+
return b + ' B'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function fmtFullTime(ts) {
|
|
97
|
+
if (!ts) return '—'
|
|
98
|
+
const d = new Date(ts)
|
|
99
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
100
|
+
}
|
|
101
|
+
|
|
60
102
|
/* ---------------- API ---------------- */
|
|
61
103
|
function apiUrl(path) {
|
|
62
104
|
return (state.server || '') + path
|
|
@@ -104,6 +146,133 @@ function authFailure() {
|
|
|
104
146
|
$('token-desc').textContent = '令牌无效,点「更换」重新设置'
|
|
105
147
|
}
|
|
106
148
|
|
|
149
|
+
/* ---------------- 多服务器 + 自动选优 ---------------- */
|
|
150
|
+
function loadServers() {
|
|
151
|
+
let arr = null
|
|
152
|
+
try { arr = JSON.parse(LS.get('servers', '')) } catch {}
|
|
153
|
+
if (!Array.isArray(arr)) {
|
|
154
|
+
const legacy = LS.get('server', '')
|
|
155
|
+
arr = legacy ? [legacy] : []
|
|
156
|
+
}
|
|
157
|
+
state.servers = arr.map(s => String(s || '').trim().replace(/\/+$/, ''))
|
|
158
|
+
.filter(s => /^https?:\/\//i.test(s))
|
|
159
|
+
const active = LS.get('activeServer', '')
|
|
160
|
+
if (active === 'origin') state.server = '' // 浏览器里明确选了同源页面
|
|
161
|
+
else state.server = state.servers.includes(active) ? active : (state.servers[0] || '')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function saveServers() {
|
|
165
|
+
LS.set('servers', JSON.stringify(state.servers))
|
|
166
|
+
LS.set('server', state.server) // 兼容旧字段
|
|
167
|
+
LS.set('activeServer', state.server === '' && !CAP?.isNativePlatform?.() ? 'origin' : state.server)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function serverCandidates() {
|
|
171
|
+
const list = [...state.servers]
|
|
172
|
+
// 浏览器控制台: 当前页面(同源网关)也作为候选, 通常 0 跳内最快
|
|
173
|
+
if (!CAP?.isNativePlatform?.() && location.origin && !list.includes(location.origin)) list.push(location.origin)
|
|
174
|
+
return list
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function pingServer(base) {
|
|
178
|
+
const u = String(base || '').replace(/\/+$/, '')
|
|
179
|
+
if (!u) return Infinity
|
|
180
|
+
const t0 = performance.now()
|
|
181
|
+
const ctrl = new AbortController()
|
|
182
|
+
const timer = setTimeout(() => ctrl.abort(), 3500)
|
|
183
|
+
try {
|
|
184
|
+
const res = await fetch(u + '/health?t=' + Date.now(), { signal: ctrl.signal, cache: 'no-store' })
|
|
185
|
+
return res.ok ? Math.round(performance.now() - t0) : Infinity
|
|
186
|
+
} catch {
|
|
187
|
+
return Infinity
|
|
188
|
+
} finally {
|
|
189
|
+
clearTimeout(timer)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function selectFastestServer({ silent = false, reconnect = true } = {}) {
|
|
194
|
+
if (state.selectingServer) return null
|
|
195
|
+
state.selectingServer = true
|
|
196
|
+
try {
|
|
197
|
+
if (!silent) toast('正在测速各服务器…')
|
|
198
|
+
const candidates = serverCandidates()
|
|
199
|
+
for (const u of candidates) state.serverLatency[u] = await pingServer(u)
|
|
200
|
+
const best = candidates
|
|
201
|
+
.filter(u => Number.isFinite(state.serverLatency[u]))
|
|
202
|
+
.sort((a, b) => state.serverLatency[a] - state.serverLatency[b])[0]
|
|
203
|
+
const sameOrigin = !CAP?.isNativePlatform?.() && best === location.origin
|
|
204
|
+
// 全部不可达时保持原服务器, 不硬切到空(同源)地址
|
|
205
|
+
const chosen = best ? (sameOrigin && !state.servers.includes(best) ? '' : best) : (state.server || '')
|
|
206
|
+
renderServers()
|
|
207
|
+
if (chosen !== state.server) {
|
|
208
|
+
state.server = chosen
|
|
209
|
+
saveServers()
|
|
210
|
+
if (!silent) toast(`已切换到最快服务器:${chosen || '当前页面'}(${state.serverLatency[best]}ms)`, 'ok')
|
|
211
|
+
if (reconnect && state.token) { openStreams(); refreshAll() }
|
|
212
|
+
} else if (!silent) {
|
|
213
|
+
if (best) toast(`当前已是最快:${chosen || '当前页面'}(${state.serverLatency[best]}ms)`, 'ok')
|
|
214
|
+
else toast('全部服务器不可达', 'err')
|
|
215
|
+
}
|
|
216
|
+
return chosen
|
|
217
|
+
} finally {
|
|
218
|
+
state.selectingServer = false
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function renderServers() {
|
|
223
|
+
const box = $('server-list')
|
|
224
|
+
if (!box) return
|
|
225
|
+
if (!state.servers.length) {
|
|
226
|
+
box.innerHTML = '<div class="server-empty">未添加备用服务器 · 默认使用当前页面地址</div>'
|
|
227
|
+
} else {
|
|
228
|
+
box.innerHTML = state.servers.map(u => {
|
|
229
|
+
const ms = state.serverLatency[u]
|
|
230
|
+
let badge = '<span class="server-badge">未测速</span>'
|
|
231
|
+
if (Number.isFinite(ms)) badge = `<span class="server-badge ${u === state.server ? 'good' : ''}">${ms}ms${u === state.server ? ' · 当前' : ''}</span>`
|
|
232
|
+
else if (ms !== undefined) badge = '<span class="server-badge bad">不可达</span>'
|
|
233
|
+
return `<div class="server-row ${u === state.server ? 'active' : ''}">
|
|
234
|
+
<span class="server-url">${esc(u)}</span>${badge}
|
|
235
|
+
<button class="server-del" data-del="${esc(u)}" aria-label="删除服务器">✕</button>
|
|
236
|
+
</div>`
|
|
237
|
+
}).join('')
|
|
238
|
+
box.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', () => removeServer(b.dataset.del)))
|
|
239
|
+
}
|
|
240
|
+
$('server-desc').textContent = state.server
|
|
241
|
+
? `当前: ${state.server}`
|
|
242
|
+
: (CAP?.isNativePlatform?.() ? '未设置服务器' : '默认 = 当前页面地址')
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function addServer() {
|
|
246
|
+
const input = $('server-input')
|
|
247
|
+
let raw = (input?.value || '').trim().replace(/\/+$/, '')
|
|
248
|
+
if (!raw) return toast('请输入服务器地址', 'err')
|
|
249
|
+
try {
|
|
250
|
+
const u = new URL(raw)
|
|
251
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad')
|
|
252
|
+
} catch {
|
|
253
|
+
return toast('地址需以 http:// 或 https:// 开头', 'err')
|
|
254
|
+
}
|
|
255
|
+
if (state.servers.includes(raw)) return toast('该地址已在列表中')
|
|
256
|
+
state.servers.push(raw)
|
|
257
|
+
saveServers()
|
|
258
|
+
if (input) input.value = ''
|
|
259
|
+
renderServers()
|
|
260
|
+
toast('已添加服务器', 'ok')
|
|
261
|
+
if (state.token) selectFastestServer({ silent: false })
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function removeServer(url) {
|
|
265
|
+
state.servers = state.servers.filter(s => s !== url)
|
|
266
|
+
const wasActive = state.server === url
|
|
267
|
+
if (wasActive) state.server = ''
|
|
268
|
+
saveServers()
|
|
269
|
+
renderServers()
|
|
270
|
+
if (wasActive) {
|
|
271
|
+
toast('已删除当前服务器,重新测速…')
|
|
272
|
+
selectFastestServer({ silent: true })
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
107
276
|
/* ---------------- 事件流 (WebSocket) ---------------- */
|
|
108
277
|
const streams = {}
|
|
109
278
|
state.streamsOk = { mux: false, host: false }
|
|
@@ -145,6 +314,8 @@ function openStream(kind, handler, refreshOnOpen) {
|
|
|
145
314
|
state.errCount++
|
|
146
315
|
updateConn()
|
|
147
316
|
if (state.errCount === 3) toast('连接中断,正在重连…', 'err')
|
|
317
|
+
// 多服务器: 连续掉线若干次就重测速, 自动换到当前可达的最快地址
|
|
318
|
+
if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
|
|
148
319
|
// 无条件重连; 页面被挂起时定时器暂停, visibilitychange 会再触发一次
|
|
149
320
|
if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), 1200)
|
|
150
321
|
}
|
|
@@ -167,11 +338,12 @@ function onResume() {
|
|
|
167
338
|
updateConn()
|
|
168
339
|
}
|
|
169
340
|
|
|
170
|
-
/* 回前台 / 定时兜底: 任何流不在 OPEN
|
|
341
|
+
/* 回前台 / 定时兜底: 任何流不在 OPEN 就重连; 多服务器时顺便重测速 */
|
|
171
342
|
document.addEventListener('visibilitychange', () => {
|
|
172
343
|
if (document.visibilityState === 'visible') {
|
|
173
344
|
onResume()
|
|
174
|
-
if (
|
|
345
|
+
if (state.servers.length) selectFastestServer({ silent: true })
|
|
346
|
+
else if (streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN) openStreams()
|
|
175
347
|
}
|
|
176
348
|
})
|
|
177
349
|
window.addEventListener('pageshow', onResume)
|
|
@@ -180,6 +352,10 @@ setInterval(() => {
|
|
|
180
352
|
if (streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN) openStreams()
|
|
181
353
|
}
|
|
182
354
|
}, 15000)
|
|
355
|
+
// 多服务器: 每 5 分钟重测一次延迟, 网络环境变化(离开 Wi-Fi / 挂上 Tailscale)时自动换线
|
|
356
|
+
setInterval(() => {
|
|
357
|
+
if (document.visibilityState === 'visible' && state.servers.length) selectFastestServer({ silent: true })
|
|
358
|
+
}, 300000)
|
|
183
359
|
function onMuxFrame(full) {
|
|
184
360
|
const f = full.payload
|
|
185
361
|
if (!f) return
|
|
@@ -247,9 +423,22 @@ async function refreshAll() {
|
|
|
247
423
|
|
|
248
424
|
async function refreshSessions() {
|
|
249
425
|
const v = await safeRpc('session.list', {}, '拉取会话列表失败')
|
|
250
|
-
if (!v)
|
|
426
|
+
if (!v) {
|
|
427
|
+
// 网关不可达: 用上次成功的会话列表兜底, 用户仍能打开历史缓存
|
|
428
|
+
if (!state.sessions.length) {
|
|
429
|
+
const cached = cacheRead(CACHE.sessions, [])
|
|
430
|
+
if (Array.isArray(cached) && cached.length) {
|
|
431
|
+
state.sessions = cached
|
|
432
|
+
state.byId = new Map(cached.map(s => [s.sessionId, s]))
|
|
433
|
+
renderSessions()
|
|
434
|
+
toast('网络不可用:显示本地缓存的会话列表', 'ok')
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return
|
|
438
|
+
}
|
|
251
439
|
state.sessions = v.items || []
|
|
252
440
|
state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
|
|
441
|
+
cacheWrite(CACHE.sessions, state.sessions.slice(0, 80))
|
|
253
442
|
renderSessions()
|
|
254
443
|
}
|
|
255
444
|
|
|
@@ -341,6 +530,10 @@ function bindNativeBack() {
|
|
|
341
530
|
const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
|
|
342
531
|
if (openModal) { openModal.classList.add('hidden'); return } // 先关弹窗
|
|
343
532
|
if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
|
|
533
|
+
if (!$('view-files').classList.contains('hidden')) { // 文件页 → 上级目录 → 主页
|
|
534
|
+
if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) { fsUp(); return }
|
|
535
|
+
showView('view-home'); return
|
|
536
|
+
}
|
|
344
537
|
try { CAP.Plugins?.App?.exitApp?.() } catch {} // 主页再返回 → 退出(与系统一致)
|
|
345
538
|
})
|
|
346
539
|
} catch {}
|
|
@@ -384,6 +577,50 @@ function trimVisible() {
|
|
|
384
577
|
h.renderEnd = Math.max(h.renderStart, h.renderEnd - drop.length)
|
|
385
578
|
}
|
|
386
579
|
|
|
580
|
+
/* 聊天记录本地缓存: 每会话最多 250 条, 全局最多 10 个会话 */
|
|
581
|
+
function scheduleHistoryCacheSave() {
|
|
582
|
+
clearTimeout(scheduleHistoryCacheSave._t)
|
|
583
|
+
scheduleHistoryCacheSave._t = setTimeout(saveHistoryCache, 400)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function saveHistoryCache() {
|
|
587
|
+
const id = state.current
|
|
588
|
+
if (!id || !state.history.visible.length) return
|
|
589
|
+
const s = state.byId.get(id)
|
|
590
|
+
const cache = readHistoryCache()
|
|
591
|
+
cache[id] = {
|
|
592
|
+
title: s ? titleOf(s) : '',
|
|
593
|
+
updatedAt: Date.now(),
|
|
594
|
+
events: state.history.visible.slice(-250).map(e => ({ seq: e.seq, event: e.event }))
|
|
595
|
+
}
|
|
596
|
+
const keys = Object.entries(cache)
|
|
597
|
+
.sort((a, b) => (b[1].updatedAt || 0) - (a[1].updatedAt || 0))
|
|
598
|
+
.slice(0, 10)
|
|
599
|
+
.map(([k]) => k)
|
|
600
|
+
const pruned = {}
|
|
601
|
+
for (const k of keys) pruned[k] = cache[k]
|
|
602
|
+
writeHistoryCache(pruned)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** 网关不可达时回填本地缓存的历史; 返回是否命中。 */
|
|
606
|
+
function restoreCachedHistory() {
|
|
607
|
+
const id = state.current
|
|
608
|
+
if (!id) return false
|
|
609
|
+
const cached = readHistoryCache()[id]
|
|
610
|
+
if (!cached?.events?.length) return false
|
|
611
|
+
const h = emptyHistory()
|
|
612
|
+
for (const e of cached.events) {
|
|
613
|
+
if (!e?.seq) continue
|
|
614
|
+
h.seqs.add(e.seq)
|
|
615
|
+
h.visible.push(e)
|
|
616
|
+
}
|
|
617
|
+
h.visible.sort((a, b) => a.seq - b.seq)
|
|
618
|
+
state.history = h
|
|
619
|
+
$('history-hint').textContent = `离线缓存 ${h.visible.length} 条`
|
|
620
|
+
renderHistory(true)
|
|
621
|
+
return true
|
|
622
|
+
}
|
|
623
|
+
|
|
387
624
|
async function loadHistory(reset) {
|
|
388
625
|
const id = state.current
|
|
389
626
|
if (!id || state.history.loading) return
|
|
@@ -392,8 +629,18 @@ async function loadHistory(reset) {
|
|
|
392
629
|
if (moreBtn) moreBtn.classList.add('hidden')
|
|
393
630
|
const payload = { sessionId: id, maxMessages: 60 }
|
|
394
631
|
if (!reset && state.history.minSeq !== Infinity) payload.beforeSeq = state.history.minSeq
|
|
395
|
-
|
|
396
|
-
|
|
632
|
+
|
|
633
|
+
let v
|
|
634
|
+
try {
|
|
635
|
+
v = await rpc('session.history', payload)
|
|
636
|
+
} catch (e) {
|
|
637
|
+
state.history.loading = false
|
|
638
|
+
if (e.message === 'AUTH') { authFailure(); return }
|
|
639
|
+
if (restoreCachedHistory()) toast('网络不可用:显示本地缓存的历史', 'ok')
|
|
640
|
+
else toast('加载历史失败:' + e.message, 'err')
|
|
641
|
+
return
|
|
642
|
+
}
|
|
643
|
+
|
|
397
644
|
const incoming = v.events || []
|
|
398
645
|
let added = 0
|
|
399
646
|
for (const entry of incoming) {
|
|
@@ -416,6 +663,7 @@ async function loadHistory(reset) {
|
|
|
416
663
|
else if (added) renderHistory(false, 'keep')
|
|
417
664
|
if (moreBtn) moreBtn.classList.toggle('hidden', !state.history.hasMore)
|
|
418
665
|
$('history-hint').textContent = state.history.visible.length ? `${state.history.visible.length} 条` : ''
|
|
666
|
+
scheduleHistoryCacheSave()
|
|
419
667
|
}
|
|
420
668
|
|
|
421
669
|
function insertLiveEvent(event) {
|
|
@@ -435,6 +683,7 @@ function insertLiveEvent(event) {
|
|
|
435
683
|
} else {
|
|
436
684
|
renderHistory(false, 'keep')
|
|
437
685
|
}
|
|
686
|
+
scheduleHistoryCacheSave()
|
|
438
687
|
}
|
|
439
688
|
|
|
440
689
|
function isToolEvent(type) { return type === 'tool/call' || type === 'tool/result' }
|
|
@@ -848,6 +1097,206 @@ function renderJobs() {
|
|
|
848
1097
|
}).join(''))
|
|
849
1098
|
}
|
|
850
1099
|
|
|
1100
|
+
/* ---------------- 文件传输 ---------------- */
|
|
1101
|
+
function fsHeaders() {
|
|
1102
|
+
return {
|
|
1103
|
+
authorization: 'Bearer ' + state.token,
|
|
1104
|
+
'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web'
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function fsJoin(dir, name) {
|
|
1109
|
+
return dir.replace(/\/+$/, '') + '/' + name
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function fsParent(p) {
|
|
1113
|
+
const clean = String(p || '').replace(/\/+$/, '')
|
|
1114
|
+
const idx = clean.lastIndexOf('/')
|
|
1115
|
+
if (idx <= 0) return clean === '' ? '' : '/'
|
|
1116
|
+
return clean.slice(0, idx)
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function fsApiUrl(sub, params = {}) {
|
|
1120
|
+
const u = new URL(apiUrl('/fs' + sub), location.href)
|
|
1121
|
+
for (const [k, v] of Object.entries(params)) {
|
|
1122
|
+
if (v != null && v !== '') u.searchParams.set(k, v)
|
|
1123
|
+
}
|
|
1124
|
+
return u.href
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function fsAuthError(status) {
|
|
1128
|
+
if (status === 401) authFailure()
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
async function loadFs(dir, { silent = false, resetRoot = false } = {}) {
|
|
1132
|
+
if (!state.token) {
|
|
1133
|
+
$('fs-path').textContent = '未设置令牌'
|
|
1134
|
+
$('fs-list').innerHTML = '<div class="empty">请先到「设置」页粘贴网关令牌</div>'
|
|
1135
|
+
return
|
|
1136
|
+
}
|
|
1137
|
+
if (resetRoot) { state.fs.initial = null; state.fs.path = null }
|
|
1138
|
+
const target = dir ?? state.fs.path ?? ''
|
|
1139
|
+
if (!silent) {
|
|
1140
|
+
$('fs-list').innerHTML = '<div class="empty">加载中…</div>'
|
|
1141
|
+
$('fs-path').textContent = target ? '…' + target.slice(-40) : '加载中…'
|
|
1142
|
+
}
|
|
1143
|
+
try {
|
|
1144
|
+
const res = await fetch(fsApiUrl('/list', target ? { path: target } : {}), { headers: fsHeaders() })
|
|
1145
|
+
if (res.status === 401) { fsAuthError(401); return }
|
|
1146
|
+
const data = await res.json().catch(() => ({}))
|
|
1147
|
+
if (!res.ok || !Array.isArray(data.entries)) throw new Error(data.error === 'not-found' ? '目录不存在' : data.error === 'forbidden' ? '路径不在允许范围内' : data.error || ('HTTP ' + res.status))
|
|
1148
|
+
state.fs.path = data.path
|
|
1149
|
+
if (!state.fs.initial) state.fs.initial = data.path
|
|
1150
|
+
state.fs.loaded = true
|
|
1151
|
+
renderFs(data)
|
|
1152
|
+
} catch (e) {
|
|
1153
|
+
if (e.message === 'AUTH') return
|
|
1154
|
+
$('fs-path').textContent = target || '~'
|
|
1155
|
+
$('fs-list').innerHTML = `<div class="empty">加载失败:${esc(e.message || '网络错误')}</div>`
|
|
1156
|
+
if (!silent) toast('文件列表加载失败:' + e.message, 'err')
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function renderFs(data) {
|
|
1161
|
+
$('fs-path').textContent = data.path || '~'
|
|
1162
|
+
const list = $('fs-list')
|
|
1163
|
+
if (!data.entries.length) {
|
|
1164
|
+
list.innerHTML = '<div class="empty">空目录</div>'
|
|
1165
|
+
return
|
|
1166
|
+
}
|
|
1167
|
+
list.innerHTML = data.entries.map(e => {
|
|
1168
|
+
const isDir = e.type === 'dir'
|
|
1169
|
+
return `<div class="fs-row" data-name="${esc(e.name)}" data-type="${esc(e.type)}">
|
|
1170
|
+
<span class="fs-ico">${isDir ? '📁' : '📄'}</span>
|
|
1171
|
+
<span class="fs-meta">
|
|
1172
|
+
<span class="fs-name">${esc(e.name)}</span>
|
|
1173
|
+
<span class="fs-sub">${isDir ? '目录' : fmtSize(e.size)} · ${fmtFullTime(e.mtimeMs)}</span>
|
|
1174
|
+
</span>
|
|
1175
|
+
<span class="fs-arrow">${isDir ? '›' : '↓'}</span>
|
|
1176
|
+
</div>`
|
|
1177
|
+
}).join('')
|
|
1178
|
+
list.querySelectorAll('.fs-row').forEach(row =>
|
|
1179
|
+
row.addEventListener('click', () => fsOpenEntry(row.dataset.name, row.dataset.type)))
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function fsOpenEntry(name, type) {
|
|
1183
|
+
if (!name) return
|
|
1184
|
+
const p = fsJoin(state.fs.path, name)
|
|
1185
|
+
if (type === 'dir') return loadFs(p)
|
|
1186
|
+
downloadFsFile(name)
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function downloadFsFile(name) {
|
|
1190
|
+
const p = fsJoin(state.fs.path, name)
|
|
1191
|
+
const url = fsApiUrl('/file', { path: p })
|
|
1192
|
+
if (CAP?.isNativePlatform?.()) {
|
|
1193
|
+
if (window.NativeFile?.downloadToDownloads) {
|
|
1194
|
+
try {
|
|
1195
|
+
window.NativeFile.downloadToDownloads(url, name, state.token)
|
|
1196
|
+
toast('开始下载到「下载/dsh-remote」目录', 'ok')
|
|
1197
|
+
} catch (e) {
|
|
1198
|
+
toast('无法启动下载:' + (e?.message || ''), 'err')
|
|
1199
|
+
}
|
|
1200
|
+
return
|
|
1201
|
+
}
|
|
1202
|
+
toast('当前 App 版本不支持系统下载,请先更新 App', 'err')
|
|
1203
|
+
return
|
|
1204
|
+
}
|
|
1205
|
+
// 浏览器控制台: <a download> + ?token= 兜底(主通道仍是 Bearer 头)
|
|
1206
|
+
const a = document.createElement('a')
|
|
1207
|
+
const u = new URL(url)
|
|
1208
|
+
u.searchParams.set('token', state.token)
|
|
1209
|
+
a.href = u.href
|
|
1210
|
+
a.download = name
|
|
1211
|
+
document.body.appendChild(a)
|
|
1212
|
+
a.click()
|
|
1213
|
+
a.remove()
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function showFsProgress(pct, loaded, total) {
|
|
1217
|
+
$('fs-progress').classList.remove('hidden')
|
|
1218
|
+
$('fs-progress-bar').style.width = Math.max(2, Math.min(100, pct)) + '%'
|
|
1219
|
+
$('fs-progress-text').textContent = `${pct}% · ${fmtSize(loaded)} / ${fmtSize(total)}`
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function hideFsProgress() {
|
|
1223
|
+
$('fs-progress').classList.add('hidden')
|
|
1224
|
+
$('fs-progress-bar').style.width = '0%'
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function uploadFsFile(file) {
|
|
1228
|
+
if (!file) return
|
|
1229
|
+
if (!state.token) { toast('请先到「设置」页设置令牌', 'err'); showView('view-settings'); return }
|
|
1230
|
+
if (file.size > 2 * 1024 * 1024 * 1024) { toast('单文件超过 2GB 上限', 'err'); return }
|
|
1231
|
+
|
|
1232
|
+
const doUpload = (overwrite) => {
|
|
1233
|
+
const params = { path: state.fs.path, name: file.name }
|
|
1234
|
+
if (overwrite) params.overwrite = '1'
|
|
1235
|
+
const xhr = new XMLHttpRequest()
|
|
1236
|
+
xhr.open('POST', fsApiUrl('/upload', params))
|
|
1237
|
+
xhr.setRequestHeader('authorization', 'Bearer ' + state.token)
|
|
1238
|
+
xhr.setRequestHeader('x-dsh-remote-client', CAP?.isNativePlatform?.() ? 'app' : 'web')
|
|
1239
|
+
showFsProgress(0, 0, file.size)
|
|
1240
|
+
xhr.upload.onprogress = (e) => {
|
|
1241
|
+
if (e.lengthComputable) showFsProgress(Math.round(e.loaded / Math.max(1, e.total) * 100), e.loaded, e.total)
|
|
1242
|
+
}
|
|
1243
|
+
xhr.onload = () => {
|
|
1244
|
+
hideFsProgress()
|
|
1245
|
+
if (xhr.status === 201 || xhr.status === 200) {
|
|
1246
|
+
toast(`已上传 ${file.name}`, 'ok')
|
|
1247
|
+
loadFs()
|
|
1248
|
+
return
|
|
1249
|
+
}
|
|
1250
|
+
if (xhr.status === 401) { fsAuthError(401); return }
|
|
1251
|
+
let err = 'HTTP ' + xhr.status
|
|
1252
|
+
try { err = JSON.parse(xhr.responseText || '{}').error || err } catch {}
|
|
1253
|
+
if (xhr.status === 409 && confirm('文件已存在,覆盖它?')) { doUpload(true); return }
|
|
1254
|
+
toast('上传失败:' + err, 'err')
|
|
1255
|
+
}
|
|
1256
|
+
xhr.onerror = () => { hideFsProgress(); toast('上传失败:网络错误', 'err') }
|
|
1257
|
+
xhr.upload.onerror = () => { hideFsProgress(); toast('上传失败:网络中断', 'err') }
|
|
1258
|
+
xhr.send(file) // raw body, 网关直接流式落盘
|
|
1259
|
+
}
|
|
1260
|
+
doUpload(false)
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function fsUp() {
|
|
1264
|
+
if (!state.fs.path || !state.fs.initial) return
|
|
1265
|
+
if (state.fs.path === state.fs.initial) {
|
|
1266
|
+
toast('已在允许的根目录')
|
|
1267
|
+
return
|
|
1268
|
+
}
|
|
1269
|
+
loadFs(fsParent(state.fs.path))
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
function bindFsPullRefresh() {
|
|
1273
|
+
const view = $('view-files')
|
|
1274
|
+
if (!view) return
|
|
1275
|
+
const pull = $('fs-pull')
|
|
1276
|
+
let startY = null
|
|
1277
|
+
view.addEventListener('touchstart', (e) => {
|
|
1278
|
+
if (window.scrollY <= 0) { startY = e.touches[0].clientY; pull.style.height = '0px' }
|
|
1279
|
+
}, { passive: true })
|
|
1280
|
+
view.addEventListener('touchmove', (e) => {
|
|
1281
|
+
if (startY == null) return
|
|
1282
|
+
const dy = e.touches[0].clientY - startY
|
|
1283
|
+
if (dy > 4 && window.scrollY <= 0) {
|
|
1284
|
+
pull.style.height = Math.min(64, dy / 2) + 'px'
|
|
1285
|
+
pull.textContent = dy > 80 ? '松开刷新' : '下拉刷新'
|
|
1286
|
+
}
|
|
1287
|
+
}, { passive: true })
|
|
1288
|
+
view.addEventListener('touchend', () => {
|
|
1289
|
+
if (startY == null) return
|
|
1290
|
+
const h = parseFloat(pull.style.height || '0')
|
|
1291
|
+
startY = null
|
|
1292
|
+
if (h >= 40) {
|
|
1293
|
+
pull.textContent = '刷新中…'
|
|
1294
|
+
loadFs(null, { silent: true })
|
|
1295
|
+
}
|
|
1296
|
+
pull.style.height = '0px'
|
|
1297
|
+
})
|
|
1298
|
+
}
|
|
1299
|
+
|
|
851
1300
|
/* ---------------- goal 编辑 ---------------- */
|
|
852
1301
|
function openGoalModal(goal) {
|
|
853
1302
|
state.goalEdit = goal
|
|
@@ -988,9 +1437,12 @@ function notify(title, body) {
|
|
|
988
1437
|
|
|
989
1438
|
/* ---------------- 视图切换 ---------------- */
|
|
990
1439
|
function showView(id) {
|
|
991
|
-
for (const v of ['view-home', 'view-session', 'view-activity', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
|
|
1440
|
+
for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
|
|
1441
|
+
// 离开会话页必须清掉 in-session, 否则其他页面顶栏被 body 样式隐藏
|
|
1442
|
+
document.body.classList.toggle('in-session', id === 'view-session')
|
|
992
1443
|
document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === id))
|
|
993
1444
|
window.scrollTo(0, 0)
|
|
1445
|
+
if (id === 'view-files' && !state.fs.loaded) loadFs(null, { silent: true })
|
|
994
1446
|
}
|
|
995
1447
|
|
|
996
1448
|
function updateConn() {
|
|
@@ -1015,12 +1467,13 @@ function initToken() {
|
|
|
1015
1467
|
} else {
|
|
1016
1468
|
state.token = LS.get('token', '')
|
|
1017
1469
|
}
|
|
1018
|
-
|
|
1470
|
+
loadServers()
|
|
1019
1471
|
$('token-desc').textContent = state.token ? '已保存(本机)' : '未设置'
|
|
1020
1472
|
$('server-desc').textContent = state.server || '默认 = 当前页面地址'
|
|
1021
1473
|
}
|
|
1022
1474
|
|
|
1023
1475
|
function bindUi() {
|
|
1476
|
+
renderServers()
|
|
1024
1477
|
// 底部导航
|
|
1025
1478
|
document.querySelectorAll('.nav-btn').forEach(b =>
|
|
1026
1479
|
b.addEventListener('click', () => showView(b.dataset.view)))
|
|
@@ -1065,15 +1518,10 @@ function bindUi() {
|
|
|
1065
1518
|
const t = prompt('输入访问令牌(网关启动时打印的 token):', state.token)
|
|
1066
1519
|
if (t && t.trim()) { state.token = t.trim(); LS.set('token', t.trim()); $('token-desc').textContent = '已保存'; toast('已保存,正在重连', 'ok'); openStreams(); refreshAll() }
|
|
1067
1520
|
})
|
|
1068
|
-
$('btn-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
state.server = v
|
|
1073
|
-
v ? LS.set('server', v) : LS.del('server')
|
|
1074
|
-
$('server-desc').textContent = v || '默认 = 当前页面地址'
|
|
1075
|
-
toast('服务器已设置,正在重连', 'ok')
|
|
1076
|
-
openStreams(); refreshAll()
|
|
1521
|
+
$('btn-server-speed').addEventListener('click', () => selectFastestServer({ silent: false }))
|
|
1522
|
+
$('btn-server-add').addEventListener('click', addServer)
|
|
1523
|
+
$('server-input').addEventListener('keydown', (e) => {
|
|
1524
|
+
if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); addServer() }
|
|
1077
1525
|
})
|
|
1078
1526
|
$('btn-host-describe').addEventListener('click', async () => {
|
|
1079
1527
|
const v = await safeRpc('host.describe', {}, '探测失败')
|
|
@@ -1103,6 +1551,18 @@ function bindUi() {
|
|
|
1103
1551
|
if (state.current) renderHistory(true)
|
|
1104
1552
|
toast(e.target.checked ? '已显示工具调用' : '已隐藏工具调用', 'ok')
|
|
1105
1553
|
})
|
|
1554
|
+
|
|
1555
|
+
// 文件页
|
|
1556
|
+
$('fs-up').addEventListener('click', fsUp)
|
|
1557
|
+
$('fs-refresh').addEventListener('click', () => { toast('刷新中…'); loadFs() })
|
|
1558
|
+
$('fs-upload-btn').addEventListener('click', () => $('fs-file-input').click())
|
|
1559
|
+
$('fs-file-input').addEventListener('change', (e) => {
|
|
1560
|
+
const f = e.target.files?.[0]
|
|
1561
|
+
if (f) uploadFsFile(f)
|
|
1562
|
+
e.target.value = '' // 允许连续选同一个文件
|
|
1563
|
+
})
|
|
1564
|
+
bindFsPullRefresh()
|
|
1565
|
+
|
|
1106
1566
|
bindRail()
|
|
1107
1567
|
|
|
1108
1568
|
// 向上翻历史 / 向下回最新
|
|
@@ -1150,6 +1610,8 @@ async function boot() {
|
|
|
1150
1610
|
showView('view-settings')
|
|
1151
1611
|
$('token-desc').textContent = '未设置——点「更换」粘贴网关启动时打印的 token'
|
|
1152
1612
|
} else {
|
|
1613
|
+
// 多服务器: 启动时静默测速一次, 选最快的连接(同源页面也参与比较)
|
|
1614
|
+
await selectFastestServer({ silent: true, reconnect: false })
|
|
1153
1615
|
openStreams()
|
|
1154
1616
|
await refreshAll()
|
|
1155
1617
|
const host = await safeRpc('host.describe', {}, '')
|
package/public/index.html
CHANGED
|
@@ -36,6 +36,23 @@
|
|
|
36
36
|
<div id="home-empty" class="empty hidden">暂无会话</div>
|
|
37
37
|
</section>
|
|
38
38
|
|
|
39
|
+
<!-- 文件传输 -->
|
|
40
|
+
<section id="view-files" class="view hidden">
|
|
41
|
+
<div class="fs-head">
|
|
42
|
+
<button id="fs-up" class="icon-btn" title="返回上级" aria-label="返回上级">‹</button>
|
|
43
|
+
<div id="fs-path" class="fs-path">加载中…</div>
|
|
44
|
+
<button id="fs-upload-btn" class="mini-btn">上传</button>
|
|
45
|
+
<button id="fs-refresh" class="icon-btn" title="刷新" aria-label="刷新">⟳</button>
|
|
46
|
+
</div>
|
|
47
|
+
<div id="fs-progress" class="fs-progress hidden">
|
|
48
|
+
<div class="fs-progress-track"><div id="fs-progress-bar" class="fs-progress-bar"></div></div>
|
|
49
|
+
<div id="fs-progress-text" class="fs-progress-text muted">0%</div>
|
|
50
|
+
</div>
|
|
51
|
+
<div id="fs-pull" class="fs-pull">松开刷新</div>
|
|
52
|
+
<div id="fs-list" class="fs-list"></div>
|
|
53
|
+
<input type="file" id="fs-file-input" class="hidden">
|
|
54
|
+
</section>
|
|
55
|
+
|
|
39
56
|
<!-- 会话详情 -->
|
|
40
57
|
<section id="view-session" class="view hidden">
|
|
41
58
|
<div class="session-head">
|
|
@@ -81,8 +98,13 @@
|
|
|
81
98
|
<section id="view-settings" class="view hidden">
|
|
82
99
|
<div class="settings-group">
|
|
83
100
|
<div class="setting-row">
|
|
84
|
-
<div><div class="setting-name"
|
|
85
|
-
<button id="btn-
|
|
101
|
+
<div><div class="setting-name">服务器地址(可多个)</div><div class="setting-desc" id="server-desc">默认 = 当前页面地址</div></div>
|
|
102
|
+
<button id="btn-server-speed" class="mini-btn">测速</button>
|
|
103
|
+
</div>
|
|
104
|
+
<div id="server-list" class="server-list"></div>
|
|
105
|
+
<div class="server-add">
|
|
106
|
+
<input id="server-input" type="url" placeholder="http://IP:8787">
|
|
107
|
+
<button id="btn-server-add" class="mini-btn">添加</button>
|
|
86
108
|
</div>
|
|
87
109
|
<div class="setting-row">
|
|
88
110
|
<div><div class="setting-name">通知</div><div class="setting-desc">收到审批/提问时推送</div></div>
|
|
@@ -118,6 +140,7 @@
|
|
|
118
140
|
|
|
119
141
|
<nav class="bottom-nav">
|
|
120
142
|
<button data-view="view-home" class="nav-btn active"><span class="nav-ico">▤</span><span>会话</span></button>
|
|
143
|
+
<button data-view="view-files" class="nav-btn"><span class="nav-ico">⇅</span><span>文件</span></button>
|
|
121
144
|
<button data-view="view-activity" class="nav-btn"><span class="nav-ico">◷</span><span>待办<b id="nav-pending" class="nav-badge hidden"></b></span></button>
|
|
122
145
|
<button data-view="view-settings" class="nav-btn"><span class="nav-ico">⚙</span><span>设置</span></button>
|
|
123
146
|
</nav>
|
package/public/styles.css
CHANGED
|
@@ -307,6 +307,47 @@ body.in-session .main { padding-bottom: 84px; }
|
|
|
307
307
|
.job-card .job-name { font-weight: 600; font-size: 14px; }
|
|
308
308
|
.job-state { font-size: 12px; color: var(--muted); margin-top: 3px; }
|
|
309
309
|
|
|
310
|
+
/* ---------- 文件传输 ---------- */
|
|
311
|
+
.fs-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
|
312
|
+
.fs-path {
|
|
313
|
+
flex: 1; min-width: 0; font-size: 12.5px; color: var(--muted);
|
|
314
|
+
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
|
315
|
+
padding: 8px 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
316
|
+
}
|
|
317
|
+
.fs-progress {
|
|
318
|
+
display: flex; align-items: center; gap: 10px;
|
|
319
|
+
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
|
320
|
+
padding: 9px 11px; margin-bottom: 10px;
|
|
321
|
+
}
|
|
322
|
+
.fs-progress-track {
|
|
323
|
+
flex: 1; height: 7px; border-radius: 999px; background: var(--panel-2); overflow: hidden;
|
|
324
|
+
}
|
|
325
|
+
.fs-progress-bar {
|
|
326
|
+
height: 100%; width: 0%; border-radius: 999px;
|
|
327
|
+
background: linear-gradient(90deg, var(--blue), var(--cyan));
|
|
328
|
+
transition: width .15s ease;
|
|
329
|
+
}
|
|
330
|
+
.fs-progress-text { flex-shrink: 0; min-width: 112px; text-align: right; }
|
|
331
|
+
.fs-pull {
|
|
332
|
+
height: 0; overflow: hidden; text-align: center; font-size: 12px; color: var(--cyan);
|
|
333
|
+
transition: height .12s ease; line-height: 26px;
|
|
334
|
+
}
|
|
335
|
+
.fs-list { display: flex; flex-direction: column; gap: 8px; }
|
|
336
|
+
.fs-row {
|
|
337
|
+
display: flex; align-items: center; gap: 11px;
|
|
338
|
+
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
|
|
339
|
+
padding: 11px 13px; min-width: 0;
|
|
340
|
+
}
|
|
341
|
+
.fs-row:active { background: var(--panel-2); }
|
|
342
|
+
.fs-ico { flex-shrink: 0; font-size: 22px; }
|
|
343
|
+
.fs-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
|
344
|
+
.fs-name {
|
|
345
|
+
font-size: 14.5px; font-weight: 600; white-space: nowrap;
|
|
346
|
+
overflow: hidden; text-overflow: ellipsis;
|
|
347
|
+
}
|
|
348
|
+
.fs-sub { font-size: 12px; color: var(--muted); margin-top: 1px; }
|
|
349
|
+
.fs-arrow { flex-shrink: 0; color: var(--muted); font-size: 18px; }
|
|
350
|
+
|
|
310
351
|
/* ---------- 设置 ---------- */
|
|
311
352
|
.settings-group {
|
|
312
353
|
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
|
|
@@ -321,6 +362,36 @@ body.in-session .main { padding-bottom: 84px; }
|
|
|
321
362
|
.setting-desc { font-size: 12px; color: var(--muted); margin-top: 2px; word-break: break-all; }
|
|
322
363
|
.about { text-align: center; color: var(--muted); font-size: 12px; margin-top: 16px; line-height: 1.8; }
|
|
323
364
|
|
|
365
|
+
/* 多服务器列表 */
|
|
366
|
+
.server-list { padding: 0 14px 10px; display: flex; flex-direction: column; gap: 6px; }
|
|
367
|
+
.server-empty { font-size: 12px; color: var(--muted); }
|
|
368
|
+
.server-row {
|
|
369
|
+
display: flex; align-items: center; gap: 8px;
|
|
370
|
+
background: var(--bg-2); border: 1px solid var(--line); border-radius: 10px;
|
|
371
|
+
padding: 8px 10px;
|
|
372
|
+
}
|
|
373
|
+
.server-row.active { border-color: rgba(125, 207, 255, .55); background: rgba(125, 207, 255, .06); }
|
|
374
|
+
.server-url { flex: 1; min-width: 0; font-size: 12.5px; word-break: break-all; }
|
|
375
|
+
.server-badge {
|
|
376
|
+
flex-shrink: 0; font-size: 11px; padding: 1px 7px; border-radius: 999px;
|
|
377
|
+
background: rgba(91, 140, 255, .12); color: var(--blue);
|
|
378
|
+
border: 1px solid rgba(91, 140, 255, .3); white-space: nowrap;
|
|
379
|
+
}
|
|
380
|
+
.server-badge.good { background: rgba(125, 207, 255, .1); color: var(--cyan); border-color: rgba(125, 207, 255, .35); }
|
|
381
|
+
.server-badge.bad { background: rgba(255, 158, 100, .1); color: var(--orange); border-color: rgba(255, 158, 100, .35); }
|
|
382
|
+
.server-del {
|
|
383
|
+
flex-shrink: 0; width: 24px; height: 24px; border-radius: 7px;
|
|
384
|
+
background: var(--panel); border: 1px solid var(--line); color: var(--muted); font-size: 12px;
|
|
385
|
+
}
|
|
386
|
+
.server-del:active { color: var(--danger); }
|
|
387
|
+
.server-add { display: flex; gap: 8px; padding: 0 14px 13px; }
|
|
388
|
+
.server-add input {
|
|
389
|
+
flex: 1; min-width: 0; background: var(--bg-2); color: var(--text);
|
|
390
|
+
border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px;
|
|
391
|
+
font: inherit; font-size: 13px; outline: none;
|
|
392
|
+
}
|
|
393
|
+
.server-add input:focus { border-color: rgba(91, 140, 255, .6); }
|
|
394
|
+
|
|
324
395
|
/* 开关 */
|
|
325
396
|
.switch { position: relative; display: inline-block; width: 46px; height: 27px; flex-shrink: 0; }
|
|
326
397
|
.switch input { opacity: 0; width: 0; height: 0; }
|
package/public/update.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.4.
|
|
2
|
+
"version": "0.4.8",
|
|
3
3
|
"apkUrl": "dsh-remote.apk",
|
|
4
|
-
"releasedAt": "2026-08-
|
|
5
|
-
"notes": "
|
|
4
|
+
"releasedAt": "2026-08-15T16:19:06.176Z",
|
|
5
|
+
"notes": "新增文件传输: /fs/list /fs/file /fs/upload, 局域网/Tailscale 直传大小文件; 多服务器地址自动测速切换; 聊天记录本地缓存离线可看; 下载统一到 Downloads/dsh-remote; 修复从会话页切出后顶栏消失"
|
|
6
6
|
}
|
package/public/version.json
CHANGED