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 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.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`,设置服务器 `http://电脑IP:8787` + 抽屉里的令牌即可。
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
 
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.5",
4
- "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控,配合 Android App 远程操控会话/审批/提问/goal。",
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 (streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN) openStreams()
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) return
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
- const v = await safeRpc('session.history', payload, '加载历史失败')
396
- if (!v) { state.history.loading = false; return }
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
- state.server = LS.get('server', '')
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-change-server').addEventListener('click', () => {
1069
- const s = prompt('输入网关地址(留空 = 当前页面地址):\n例: http://192.168.1.100:8787', state.server)
1070
- if (s === null) return
1071
- const v = (s || '').trim().replace(/\/+$/, '')
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">服务器地址</div><div class="setting-desc" id="server-desc">默认 = 当前页面地址</div></div>
85
- <button id="btn-change-server" class="mini-btn">设置</button>
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; }
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.4.5",
2
+ "version": "0.4.8",
3
3
  "apkUrl": "dsh-remote.apk",
4
- "releasedAt": "2026-08-15T14:12:16.007Z",
5
- "notes": "手机更新走局域网直传: APK 随插件包分发, update.json 不再指向 GitHub"
4
+ "releasedAt": "2026-08-15T16:19:06.176Z",
5
+ "notes": "新增文件传输: /fs/list /fs/file /fs/upload, 局域网/Tailscale 直传大小文件; 多服务器地址自动测速切换; 聊天记录本地缓存离线可看; 下载统一到 Downloads/dsh-remote; 修复从会话页切出后顶栏消失"
6
6
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.4.5"
2
+ "version": "0.4.8"
3
3
  }