dsh-remote-plugin 0.6.6 → 0.6.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.en.md CHANGED
@@ -1,46 +1,52 @@
1
1
  # dsh-remote-plugin
2
2
 
3
- [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
3
+ The official DSH bundle plugin for DSH Remote. It adds a DSH sidebar entry, a compact status panel, a full admin console, and a gateway managed alongside DSH.
4
4
 
5
- **English** · [中文](README.md)
6
-
7
- The DSH bundle plugin for DSH Remote: registers a native entry in DSH's left sidebar and opens an admin drawer from the right. The plugin **bundles the gateway and auto-starts/stops it with DSH** (standalone systemd unit); the drawer shows the token, host IP and device monitor. Works with the [dsh-Remote](https://github.com/Blank-not-black/dsh-Remote) Android app for remote control and file transfer (`/fs/list`, `/fs/file`, `/fs/upload`). The panel ships four themes (Deep Space / Sunset / Elbphilharmonie / Prairie Tower) — light / dark / neutral, following the system preference automatically.
5
+ [English](README.en.md) · [中文](README.md)
8
6
 
9
7
  ## Install
10
8
 
11
9
  ```sh
12
10
  dsh plugin --profile web add dsh-remote-plugin
13
- # or pin a version
14
- dsh plugin --profile web add dsh-remote-plugin@0.5.0
11
+
12
+ # Optional version pin
13
+ dsh plugin --profile web add dsh-remote-plugin@0.6.8
15
14
  ```
16
15
 
17
- Restart DSH Web and hard-refresh (Ctrl+F5); the app icon entry appears at the bottom of the left sidebar.
16
+ Restart DSH Web and refresh the browser. The DSH Remote entry will appear in the sidebar.
18
17
 
19
- git source install (equivalent):
18
+ ## What the plugin provides
20
19
 
21
- ```sh
22
- dsh plugin --profile web add "github:Blank-not-black/dsh-Remote#main&path:/packages/plugin"
23
- ```
20
+ - Compact panel: gateway state, connected devices, token usage, and quick actions.
21
+ - Admin console: port, upstream, devices, requests, token statistics, QR pairing, and token rotation.
22
+ - Bundled `gateway.cjs`: Bearer-token gateway listening on `0.0.0.0:8787` by default.
23
+ - Self-healing lifecycle: relaunches the gateway after a DSH restart or unexpected exit; start and stop it from the panel.
24
+ - `/fs/*` file endpoints: list, download, chunked upload, resume, pause/continue/cancel, and SHA-256 verification.
25
+ - Mobile, desktop, and admin WebUI assets, plus the Android APK distributed with the plugin.
26
+
27
+ ## Mobile capabilities
28
+
29
+ The Android app / mobile WebUI has five main destinations: Sessions, Files, Home, Stats, and Settings. Session detail supports goals, subagent interruption, model selection, fullscreen input, slash commands, and image attachments. Images can come from the camera or gallery and are sent as image content in `session.prompt`.
24
30
 
25
- ## Gateway
31
+ Notification settings include approval / question notifications, background polling, peak reminders, task completion notices, and announcement history. Four themes are retained: Default Deep Space, Sunset, Elbphilharmonie, and Prairie Tower.
26
32
 
27
- - Auto-start by default: on DSH startup or drawer refresh, the plugin launches the built-in `gateway.cjs` (`0.0.0.0:8787`), independent of the DSH process; Linux uses a standalone systemd unit, and environments without systemd (e.g. macOS) automatically fall back to a resident child process.
28
- - On/off intent persists in `~/.dsh-remote/gateway.enabled`; it can be stopped/started from the drawer.
29
- - Token lives in `~/.dsh-remote/token` (auto-generated on first run, reused and never overwritten), shown in the drawer and copyable; supports **QR pairing** and **one-click rotation**.
30
- - Env var `DSH_REMOTE_AUTOSTART=0` disables auto management.
31
- - File endpoints: `/fs/list` (list directory), `/fs/file` (download with Range support), `/fs/upload` (chunked resume with pause/cancel, SHA-256 verified before writing to disk); default root is `~`, and `DSH_REMOTE_FS_ROOT` opens multiple roots (`:` on Linux/macOS, `;` on Windows).
32
- - Feedback endpoint: `POST /feedback` (the app / desktop "Write feedback" dialog), forwarded by the gateway to the feedback collector; default `http://100.84.128.29/submit` (Tailscale internal network), overridable via `DSH_REMOTE_FEEDBACK_URL` — no tokens to configure.
33
+ ## Gateway configuration
33
34
 
34
- ## Mobile App
35
+ - Port priority: `DSH_REMOTE_GATEWAY_PORT` → `~/.dsh-remote/gateway-port` → `8787`.
36
+ - Token: `~/.dsh-remote/token`, generated on first run.
37
+ - Self-healing: `~/.dsh-remote/gateway.enabled`; use `DSH_REMOTE_AUTOSTART=0` to disable automatic management.
38
+ - File roots: `DSH_REMOTE_FS_ROOT`, separated by `:` on Linux/macOS and `;` on Windows.
39
+ - Upload limit: `DSH_REMOTE_FS_MAX_UPLOAD`, 2 GB by default.
40
+ - DSH upstream: `http://127.0.0.1:3080` by default.
35
41
 
36
- The app is embedded in the plugin package (`apk/dsh-remote.apk`) install the plugin and you have it, no GitHub needed:
42
+ The token grants remote control of DSH. Keep it private. For cross-network access, prefer Tailscale or another authenticated secure tunnel.
37
43
 
38
- - Tap **QR code** in the desktop drawer → scan it with the phone to open the admin page → download the app from that page
39
- - Or open `http://<gateway-IP>:8787` in a browser to download
40
- - App updates are pushed by the gateway too (`update.json` relative path), never touching GitHub
41
- - A desktop browser opening `http://<gateway-IP>:8787` auto-enters the desktop WebUI (sidebar sessions + files + settings + stats + approval notification stack)
44
+ ## Links
42
45
 
43
- The app has built-in multi-server latency switching and offline chat history caching; downloaded files are stored in the system `Download/dsh-remote` folder.
46
+ - Admin page: `http://<gateway-ip>:8787/admin`
47
+ - Desktop WebUI: `http://<gateway-ip>:8787`
48
+ - Main project: [dsh-Remote](https://github.com/Blank-not-black/dsh-Remote)
49
+ - Stable releases: [GitHub Releases](https://github.com/Blank-not-black/dsh-Remote/releases/latest)
44
50
 
45
51
  ## License
46
52
 
package/README.md CHANGED
@@ -1,46 +1,52 @@
1
1
  # dsh-remote-plugin
2
2
 
3
- [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
3
+ DSH Remote 的官方 bundle 插件:在 DSH 侧边栏提供入口,打开快速状态面板和完整管理控制台,并内置随 DSH 自动启停的远程网关。
4
4
 
5
5
  **中文** · [English](README.en.md)
6
6
 
7
- 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`)。面板内置四套皮肤(深空 / 落日 / 易北爱乐厅 / 草原孤塔),亮暗中性三档随系统偏好自动切换。
8
-
9
7
  ## 安装
10
8
 
11
9
  ```sh
12
10
  dsh plugin --profile web add dsh-remote-plugin
13
- # 或 pin 版本
14
- dsh plugin --profile web add dsh-remote-plugin@0.5.0
11
+
12
+ # 也可以安装指定版本
13
+ dsh plugin --profile web add dsh-remote-plugin@0.6.8
15
14
  ```
16
15
 
17
- 重启 DSH Web Ctrl+F5,左侧边栏底部出现 App 图标入口。
16
+ 重启 DSH Web 后刷新浏览器,左侧边栏会出现 DSH Remote 入口。
18
17
 
19
- git 源安装(等价):
18
+ ## 插件提供什么
20
19
 
21
- ```sh
22
- dsh plugin --profile web add "github:Blank-not-black/dsh-Remote#main&path:/packages/plugin"
23
- ```
20
+ - 快速面板:网关状态、在线设备、Token 用量和快捷操作;
21
+ - 管理控制台:端口、上游、设备、请求、Token 统计、二维码和令牌轮换;
22
+ - 内置 `gateway.cjs`:默认监听 `0.0.0.0:8787`,带 Bearer token 鉴权;
23
+ - 网关自愈:DSH 重启或网关意外退出后自动拉起,可在面板中停止或启动;
24
+ - `/fs/*` 文件端点:列表、下载、分块上传、断点续传、暂停/继续/取消和 SHA-256 校验;
25
+ - 手机端、桌面端和管理页 WebUI,以及随插件分发的 Android APK。
26
+
27
+ ## 手机端能力
28
+
29
+ Android 应用 / 手机 WebUI 采用五个主要页面:会话、文件、主页、统计、设置。会话详情支持目标控制、子代理中断、模型切换、全屏输入、斜杠命令和图片附件。图片附件可从相机或相册选择,并以 `session.prompt` 图片内容发送到当前会话。
24
30
 
25
- ## 网关
31
+ 通知设置支持审批 / 提问通知、后台轮询、峰谷提醒、任务完成提醒和历史公告。当前保留四套主题:默认深空、落日、易北爱乐厅、草原孤塔。
26
32
 
27
- - 默认**自动启动**:DSH 启动或抽屉刷新时,插件会拉起内置 `gateway.cjs`(`0.0.0.0:8787`),独立于 DSH 进程;Linux 使用独立 systemd 单元,macOS 等无 systemd 环境自动回退为常驻子进程。
28
- - 开关持久化在 `~/.dsh-remote/gateway.enabled`;抽屉内可停止/启动。
29
- - 令牌在 `~/.dsh-remote/token`(首次自动生成,重复使用不覆盖),抽屉里显示并可复制;支持**二维码扫码配对**与**一键轮换**。
30
- - 环境变量 `DSH_REMOTE_AUTOSTART=0` 可关闭自动管理。
31
- - 文件端点:`/fs/list`(列目录)、`/fs/file`(下载,支持 Range)、`/fs/upload`(分块续传,支持暂停/取消,落盘前 SHA-256 校验);默认根目录 `~`,`DSH_REMOTE_FS_ROOT` 可开多根(Linux/macOS 用 `:`,Windows 用 `;` 分隔)。
32
- - 反馈端点:`POST /feedback`(App / 桌面端「写反馈」),网关转发到反馈收集器;默认 `http://100.84.128.29/submit`(Tailscale 内网),可用 `DSH_REMOTE_FEEDBACK_URL` 覆盖,无需配置任何 token。
33
+ ## 网关配置
33
34
 
34
- ## 手机 App
35
+ - 网关端口优先级:`DSH_REMOTE_GATEWAY_PORT` 环境变量 → `~/.dsh-remote/gateway-port` → `8787`;
36
+ - 令牌:`~/.dsh-remote/token`,首次运行自动生成;
37
+ - 自愈开关:`~/.dsh-remote/gateway.enabled`,或使用 `DSH_REMOTE_AUTOSTART=0` 禁用自动管理;
38
+ - 文件根目录:`DSH_REMOTE_FS_ROOT`,Linux/macOS 使用 `:`,Windows 使用 `;`;
39
+ - 文件上限:`DSH_REMOTE_FS_MAX_UPLOAD`,默认 2GB;
40
+ - DSH 上游:默认 `http://127.0.0.1:3080`。
35
41
 
36
- App 已随插件包内嵌(`apk/dsh-remote.apk`),装好插件即可获取,无需上 GitHub:
42
+ 令牌等同于 DSH 远程操作凭证,请不要公开或提交到仓库。局域网访问建议配合防火墙;跨网络访问建议使用 Tailscale 或其他带认证的安全隧道。
37
43
 
38
- - 桌面抽屉点「二维码」→ 手机扫码打开管理页 → 页面提供 App 下载
39
- - 或浏览器直接访问 `http://<网关IP>:8787` 下载
40
- - App 更新同样走网关自动推送(`update.json` 相对路径),全程不绕 GitHub
41
- - 电脑浏览器打开 `http://<网关IP>:8787` 自动进入桌面端 WebUI(侧栏会话 + 文件 + 设置 + 统计 + 审批通知卡片栈)
44
+ ## 相关地址
42
45
 
43
- App 内置多服务器测速切换与聊天记录离线缓存,下载文件统一放系统「下载/dsh-remote」。
46
+ - 管理页:`http://<网关IP>:8787/admin`
47
+ - 桌面 WebUI:`http://<网关IP>:8787`
48
+ - 主项目:[dsh-Remote](https://github.com/Blank-not-black/dsh-Remote)
49
+ - 正式版本:[GitHub Releases](https://github.com/Blank-not-black/dsh-Remote/releases/latest)
44
50
 
45
51
  ## License
46
52
 
Binary file
package/client.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /* dsh-remote 插件 client half
2
2
  * 注册进 DSH 原生左侧边栏 footer(与 OpenBiliClaw 上下并列) + 右侧 shell.overlay 抽屉。
3
- * 抽屉内 iframe 懒加载 /remote/admin/ —— 即本地网关管理控制台的桌面 UI。
3
+ * 抽屉内 iframe 懒加载 /remote/plugin.html —— 插件端快速状态面板;深入管理再打开控制台。
4
4
  * 产物入库, 无构建步骤; 参考 @openbiliclaw/dsh-plugin 的官方 slot 注册模式。
5
5
  */
6
6
  window.__ModuleLoader__.load({
@@ -13,9 +13,10 @@ window.__ModuleLoader__.load({
13
13
  var runtime = require('@deepseek-ai/dsh-client-runtime/client')
14
14
 
15
15
  var DRAWER_STYLE = {
16
- position: 'fixed', top: 0, right: 0, bottom: 0, zIndex: 2147483000,
17
- width: 'min(460px, 96vw)', background: '#0b0e1a',
18
- boxShadow: '-10px 0 32px rgba(0,0,0,.5)',
16
+ position: 'fixed', top: 12, right: 12, bottom: 12, zIndex: 2147483000,
17
+ width: 'min(520px, calc(100vw - 24px))', background: '#0b0e1a',
18
+ border: '1px solid rgba(125, 207, 255, .22)', borderRadius: 24, overflow: 'hidden',
19
+ boxShadow: '-16px 18px 48px rgba(0,0,0,.46), 0 0 0 1px rgba(91,140,255,.08)',
19
20
  transform: 'translateX(102%)', transition: 'transform .22s ease',
20
21
  display: 'flex', flexDirection: 'column',
21
22
  }
@@ -51,8 +52,9 @@ window.__ModuleLoader__.load({
51
52
  style: {
52
53
  display: 'flex', alignItems: 'center', gap: 7, width: '100%',
53
54
  padding: '6px 10px', border: 'none', borderRadius: 8,
54
- background: hover ? 'rgba(125, 207, 255, .14)' : 'transparent',
55
- transition: 'background .15s ease',
55
+ background: open ? 'rgba(125, 207, 255, .14)' : hover ? 'rgba(125, 207, 255, .10)' : 'transparent',
56
+ border: open ? '1px solid rgba(125, 207, 255, .24)' : '1px solid transparent',
57
+ transition: 'background .15s ease, border-color .15s ease',
56
58
  cursor: 'pointer', color: open ? '#5df2d6' : 'inherit',
57
59
  font: '500 13px/1.3 system-ui, sans-serif', textAlign: 'left',
58
60
  },
@@ -73,7 +75,7 @@ window.__ModuleLoader__.load({
73
75
  function onMsg(e) {
74
76
  if (e.origin !== location.origin) return
75
77
  var d = e.data
76
- if (d && d.source === 'dsh-remote-admin' && d.type === 'close') props.actions.close()
78
+ if (d && (d.source === 'dsh-remote-admin' || d.source === 'dsh-remote-plugin') && d.type === 'close') props.actions.close()
77
79
  }
78
80
  window.addEventListener('message', onMsg)
79
81
  return function () { window.removeEventListener('message', onMsg) }
@@ -83,8 +85,8 @@ window.__ModuleLoader__.load({
83
85
  style: Object.assign({}, DRAWER_STYLE, { transform: open ? 'translateX(0)' : 'translateX(102%)' }),
84
86
  },
85
87
  loaded.current ? React.createElement('iframe', {
86
- src: '/remote/admin/?embedded=1',
87
- title: 'DSH Remote',
88
+ src: '/remote/plugin.html',
89
+ title: 'DSH Remote 快速状态面板',
88
90
  sandbox: 'allow-scripts allow-same-origin allow-forms allow-modals allow-popups',
89
91
  style: { flex: 1, width: '100%', border: 'none', background: '#0b0e1a' },
90
92
  }) : null)
package/gateway.cjs CHANGED
@@ -20,6 +20,7 @@
20
20
  * TOKEN_FILE 令牌文件, 默认 ~/.dsh-remote/token
21
21
  * DSH_REMOTE_FS_ROOT 文件传输允许根, 默认 ~, 使用系统路径分隔符配置多根
22
22
  * DSH_REMOTE_FS_MAX_UPLOAD 上传字节上限, 默认 2147483648 (2GB)
23
+ * DSH_REMOTE_WORKBENCH 工作台绑定文件, 默认 ~/.dsh-remote/workbench.json
23
24
  */
24
25
  'use strict'
25
26
 
@@ -48,6 +49,7 @@ const WS_IDLE_MS = Number(process.env.GATEWAY_WS_IDLE_MS) || 60000
48
49
  const UPSTREAM = new URL(process.env.DSH_UPSTREAM || 'http://127.0.0.1:3080')
49
50
  const TOKEN_FILE = process.env.TOKEN_FILE || path.join(os.homedir(), '.dsh-remote', 'token')
50
51
  const NOTES_FILE = process.env.DSH_REMOTE_NOTES || path.join(os.homedir(), '.dsh-remote', 'device-notes.json')
52
+ const WORKBENCH_FILE = process.env.DSH_REMOTE_WORKBENCH || path.join(os.homedir(), '.dsh-remote', 'workbench.json')
51
53
  const STARTED_AT = Date.now()
52
54
  const DSH_SERVICE = String(process.env.DSH_REMOTE_DSH_SERVICE || 'dsh-web').trim()
53
55
 
@@ -914,11 +916,20 @@ function serveStatic(req, res, url) {
914
916
  return
915
917
  }
916
918
  const ext = path.extname(filePath).toLowerCase()
919
+ const lastModified = st.mtime.toUTCString()
920
+ const mtimeSec = Math.floor(st.mtime.getTime() / 1000) * 1000
917
921
  cors(res)
922
+ const ims = req.headers['if-modified-since']
923
+ if (ims && new Date(ims).getTime() >= mtimeSec) {
924
+ res.writeHead(304, { 'last-modified': lastModified })
925
+ res.end()
926
+ return
927
+ }
918
928
  res.writeHead(200, {
919
929
  'content-type': MIME[ext] || 'application/octet-stream',
920
930
  'cache-control': ext === '.html' || ext === '.js' || ext === '.css' ? 'no-cache' : 'public, max-age=300',
921
- 'content-length': st.size
931
+ 'content-length': st.size,
932
+ 'last-modified': lastModified
922
933
  })
923
934
  if (req.method === 'HEAD') res.end()
924
935
  else fs.createReadStream(filePath).pipe(res)
@@ -1753,6 +1764,112 @@ function serveFs(req, res, url) {
1753
1764
  fsJson(res, 404, { error: 'not-found' })
1754
1765
  }
1755
1766
 
1767
+ // ---------- /workbench 工作台绑定 ----------
1768
+ // 工作台绑定一个文件夹;其下的子文件夹由客户端映射为 DSH 项目工作区。
1769
+ function workbenchPathInfo(rawPath) {
1770
+ if (typeof rawPath !== 'string' || !path.isAbsolute(rawPath)) return { error: 'bad-path' }
1771
+ const abs = path.resolve(rawPath)
1772
+ let st
1773
+ try { st = fs.statSync(abs) } catch (err) {
1774
+ return { error: err.code === 'ENOENT' ? 'not-found' : 'permission-denied' }
1775
+ }
1776
+ if (!st.isDirectory()) return { error: 'not-a-directory' }
1777
+ const checked = fsRealChecked(abs)
1778
+ if (checked.error) return { error: checked.error === 'forbidden' ? 'outside-roots' : checked.error }
1779
+ return { path: checked.abs }
1780
+ }
1781
+
1782
+ function loadWorkbench() {
1783
+ try {
1784
+ const raw = JSON.parse(fs.readFileSync(WORKBENCH_FILE, 'utf8'))
1785
+ if (!raw || typeof raw.path !== 'string' || !raw.path) return null
1786
+ const checked = workbenchPathInfo(raw.path)
1787
+ return checked.path ? { path: checked.path } : null
1788
+ } catch {
1789
+ return null
1790
+ }
1791
+ }
1792
+
1793
+ function saveWorkbench(binding) {
1794
+ try {
1795
+ fs.mkdirSync(path.dirname(WORKBENCH_FILE), { recursive: true })
1796
+ fs.writeFileSync(WORKBENCH_FILE, JSON.stringify(binding, null, 2) + '\n')
1797
+ return true
1798
+ } catch {
1799
+ return false
1800
+ }
1801
+ }
1802
+
1803
+ function serveWorkbench(req, res, url) {
1804
+ const sub = url.pathname.slice('/workbench'.length)
1805
+ if (req.method === 'OPTIONS') {
1806
+ cors(res)
1807
+ res.writeHead(204)
1808
+ res.end()
1809
+ return
1810
+ }
1811
+ if (!fsAuthorized(req, url, res)) return
1812
+
1813
+ if (sub === '' && req.method === 'GET') {
1814
+ const binding = loadWorkbench()
1815
+ fsJson(res, 200, {
1816
+ bound: !!binding,
1817
+ path: binding?.path || null,
1818
+ title: binding ? path.basename(binding.path) : null
1819
+ })
1820
+ return
1821
+ }
1822
+
1823
+ if (sub === '/bind' && req.method === 'POST') {
1824
+ let body = ''
1825
+ let done = false
1826
+ const fail = (status, payload) => {
1827
+ if (done || res.headersSent) return
1828
+ done = true
1829
+ fsJson(res, status, payload)
1830
+ }
1831
+ req.on('data', chunk => {
1832
+ if (done) return
1833
+ body += chunk
1834
+ if (Buffer.byteLength(body) > 4096) {
1835
+ req.destroy()
1836
+ fail(413, { error: 'too-large' })
1837
+ }
1838
+ })
1839
+ req.on('error', () => { if (!done) done = true })
1840
+ req.on('end', () => {
1841
+ if (done) return
1842
+ try {
1843
+ const rawPath = JSON.parse(body || '{}')?.path
1844
+ const checked = workbenchPathInfo(rawPath)
1845
+ if (checked.error) {
1846
+ const status = checked.error === 'forbidden' ? 403 : 400
1847
+ fail(status, { error: checked.error, detail: checked.error === 'outside-roots' ? '绑定目录必须在文件传输允许根目录内' : undefined })
1848
+ return
1849
+ }
1850
+ if (!saveWorkbench({ path: checked.path })) {
1851
+ fail(500, { error: 'save-failed' })
1852
+ return
1853
+ }
1854
+ done = true
1855
+ fsJson(res, 200, { bound: true, path: checked.path, title: path.basename(checked.path) })
1856
+ } catch {
1857
+ fail(400, { error: 'bad-request' })
1858
+ }
1859
+ })
1860
+ return
1861
+ }
1862
+
1863
+ if (sub === '/unbind' && req.method === 'POST') {
1864
+ try { fs.rmSync(WORKBENCH_FILE, { force: true }) } catch {}
1865
+ fsJson(res, 200, { bound: false })
1866
+ return
1867
+ }
1868
+
1869
+ res.writeHead(405, { allow: 'GET, POST' })
1870
+ res.end()
1871
+ }
1872
+
1756
1873
  // ---------- /api 代理 ----------
1757
1874
  function proxyApi(req, res, url) {
1758
1875
  if (req.method === 'OPTIONS') {
@@ -1851,6 +1968,7 @@ const server = http.createServer((req, res) => {
1851
1968
  try {
1852
1969
  const url = new URL(req.url, 'http://dsh-remote.local')
1853
1970
  if (url.pathname === '/fs' || url.pathname.startsWith('/fs/')) return serveFs(req, res, url)
1971
+ if (url.pathname === '/workbench' || url.pathname.startsWith('/workbench/')) return serveWorkbench(req, res, url)
1854
1972
  if (url.pathname === '/feedback') return serveFeedback(req, res, url)
1855
1973
  if (url.pathname.startsWith('/admin/api')) return serveAdminApi(req, res, url)
1856
1974
  if (url.pathname.startsWith('/stats')) return serveStats(req, res, url)
package/index.mjs CHANGED
@@ -150,7 +150,7 @@ function runExit(cmd, args) {
150
150
 
151
151
  const GATEWAY_ENV_KEYS = [
152
152
  'TOKEN', 'TOKEN_FILE', 'DSH_REMOTE_TOKEN', 'DSH_REMOTE_FS_ROOT', 'DSH_REMOTE_FS_MAX_UPLOAD',
153
- 'DSH_REMOTE_NOTES', 'DSH_REMOTE_DSH_SERVICE', 'DSH_REMOTE_FEEDBACK_URL',
153
+ 'DSH_REMOTE_NOTES', 'DSH_REMOTE_WORKBENCH', 'DSH_REMOTE_DSH_SERVICE', 'DSH_REMOTE_FEEDBACK_URL',
154
154
  'UPDATE_CHECK_URL', 'UPDATE_INTERVAL_MS', 'UPDATE_PROXY', 'GATEWAY_WS_IDLE_MS',
155
155
  'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
156
156
  'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy'
@@ -704,10 +704,19 @@ async function serveStatic(req, res, ctx) {
704
704
  return
705
705
  }
706
706
  const { abs, info } = found
707
+ const lastModified = info.mtime.toUTCString()
708
+ const mtimeSec = Math.floor(info.mtime.getTime() / 1000) * 1000
709
+ const ims = req.headers['if-modified-since']
710
+ if (ims && new Date(ims).getTime() >= mtimeSec) {
711
+ res.writeHead(304, { 'last-modified': lastModified })
712
+ res.end()
713
+ return
714
+ }
707
715
  res.writeHead(200, {
708
716
  'content-type': MIME[extname(abs)] ?? 'application/octet-stream',
709
717
  'content-length': info.size,
710
718
  'cache-control': 'no-cache',
719
+ 'last-modified': lastModified,
711
720
  })
712
721
  if (req.method === 'HEAD') {
713
722
  res.end()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
package/public/admin.html CHANGED
@@ -145,7 +145,7 @@
145
145
  padding: 7px 8px; border-radius: 10px; border: none; background: transparent; color: var(--dsr-text);
146
146
  font: inherit; font-size: 13px; text-align: left; cursor: pointer; text-decoration: none;
147
147
  }
148
- .fb-item:hover, .fb-item:focus-visible { background: var(--dsr-bg); outline: none; }
148
+ .fb-item:hover, .fb-item:focus-visible { background: var(--dsr-bg); }
149
149
  .fb-item.primary { background: var(--dsr-accent-soft); border: 1px solid var(--dsr-accent-line); }
150
150
  .fb-ico {
151
151
  width: 32px; height: 32px; border-radius: 9px; flex-shrink: 0;
@@ -174,6 +174,86 @@
174
174
  .admin-title .right { gap: 4px; }
175
175
  .tb-btn { padding: 0 8px; }
176
176
  }
177
+ .login-card input:focus-visible, .gateway-port-row input:focus-visible {
178
+ outline: 2px solid var(--dsr-accent);
179
+ outline-offset: 2px;
180
+ }
181
+ @media (prefers-reduced-motion: reduce) {
182
+ *, *::before, *::after {
183
+ animation-duration: .01ms !important;
184
+ animation-iteration-count: 1 !important;
185
+ transition-duration: .01ms !important;
186
+ }
187
+ }
188
+
189
+ /* 控制台面板:减少整面纯色,建立“工具台”层次 */
190
+ html, body { font-family: system-ui, -apple-system, "PingFang SC", "Noto Sans CJK SC", sans-serif; text-rendering: optimizeLegibility; }
191
+ body { background: radial-gradient(900px 520px at 92% -12%, var(--dsr-accent-soft), transparent 68%), linear-gradient(180deg, var(--dsr-bg), var(--dsr-bg-2)); }
192
+ .admin-wrap { max-width: 1320px; padding: calc(env(safe-area-inset-top, 0px) + 18px) clamp(14px, 3vw, 30px) 48px; }
193
+ .admin-title { margin-bottom: 18px; padding: 12px 14px; border: 1px solid var(--dsr-line); border-radius: 18px; background: linear-gradient(135deg, var(--dsr-panel), var(--dsr-bg-2)); box-shadow: 0 14px 36px var(--dsr-shadow); backdrop-filter: blur(16px); }
194
+ .admin-title h1 { font-size: 21px; letter-spacing: -.2px; }
195
+ .admin-title .left, .admin-title .right { gap: 7px; }
196
+ .tb-btn { border-radius: 11px; background: linear-gradient(135deg, var(--dsr-panel-2), var(--dsr-bg-2)); border-color: var(--dsr-line); transition: transform .18s ease, border-color .18s ease, background .18s ease; }
197
+ .tb-btn:hover { transform: translateY(-1px); border-color: var(--dsr-accent-line); background: var(--dsr-accent-soft); }
198
+ .tb-btn svg { width: 17px; height: 17px; }
199
+ .login-card, .stat-card, .stats-chart, .table-wrap, .gateway-port-row, .pair-box { background: linear-gradient(145deg, var(--dsr-panel), var(--dsr-bg-2)); border-color: var(--dsr-line); border-radius: 18px; box-shadow: 0 12px 30px var(--dsr-shadow); }
200
+ .login-card { padding: 18px; }
201
+ .stat-grid { gap: 12px; }
202
+ .stat-card { padding: 15px 16px; box-shadow: inset 0 1px var(--dsr-divider), 0 10px 26px var(--dsr-shadow); }
203
+ .stat-card .v { font-size: 24px; font-variant-numeric: tabular-nums; }
204
+ .stats-dash { margin-top: 4px; }
205
+ .stats-chart { height: 210px; padding: 16px 18px 12px; }
206
+ .stats-bar .bars { border-radius: 9px 9px 3px 3px; box-shadow: 0 0 16px var(--dsr-glow); }
207
+ .section-head { margin: 18px 2px 9px; font-weight: 750; letter-spacing: .2px; }
208
+ .token-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 13px; margin: 0 0 14px; border: 1px solid var(--dsr-line); border-radius: 18px; background: linear-gradient(145deg, var(--dsr-panel), var(--dsr-bg-2)); box-shadow: 0 10px 28px var(--dsr-shadow); }
209
+ .token-row code { width: auto; min-width: 0; padding: 10px 12px; border-radius: 11px; background: var(--dsr-bg); border-color: var(--dsr-line); }
210
+ .token-actions { justify-content: flex-end; }
211
+ .token-actions .mini-btn { flex: 0 0 auto; border-radius: 10px; }
212
+ .gateway-port-row { padding: 12px 14px; }
213
+ .table-wrap { overflow: hidden; }
214
+ th, td { border-bottom-color: var(--dsr-divider); }
215
+ tbody tr { transition: background .18s ease; }
216
+ .pair-box { padding: 18px; }
217
+ .admin-hero { display:flex; align-items:flex-end; justify-content:space-between; gap:18px; padding:20px; margin:0 0 16px; border:1px solid var(--dsr-accent-line); border-radius:20px; background:linear-gradient(135deg, var(--dsr-panel-2), var(--dsr-bg-2)); box-shadow:0 16px 34px var(--dsr-shadow); }
218
+ .admin-hero-copy { min-width:0; }
219
+ .admin-eyebrow { color:var(--dsr-accent-strong); font-size:10px; font-weight:800; letter-spacing:1.6px; }
220
+ .admin-hero h2 { margin:7px 0 4px; font-size:26px; line-height:1.15; letter-spacing:-.5px; }
221
+ .admin-hero p { margin:0; color:var(--dsr-muted); font-size:12px; }
222
+ .admin-hero-side { display:flex; flex-direction:column; align-items:flex-end; gap:10px; flex:none; }
223
+ .admin-hero-status { color:var(--dsr-success); font-size:12px; font-weight:700; }
224
+ .admin-hero.attention { border-color:var(--dsr-warning-line); }
225
+ .admin-hero.attention .admin-hero-status { color:var(--dsr-warning); }
226
+ .admin-hero.plugin, .admin-hero.offline { border-color:var(--dsr-line); }
227
+ .admin-hero.plugin .admin-hero-status, .admin-hero.offline .admin-hero-status { color:var(--dsr-muted); }
228
+ .admin-hero-action { min-height:36px; padding:7px 13px; border-color:var(--dsr-accent-strong); background:var(--dsr-accent-strong); color:var(--dsr-on-accent); font-weight:700; }
229
+ .admin-hero.plugin .admin-hero-action { border-color:var(--dsr-accent-strong); background:var(--dsr-accent-strong); }
230
+ .admin-hero.offline .admin-hero-action { border-color:var(--dsr-accent-line); background:var(--dsr-accent-soft); color:var(--dsr-accent-strong); }
231
+ #stats { grid-template-columns:repeat(4,minmax(0,1fr)); }
232
+ #stats .stat-card:nth-child(n+5) { background:transparent; box-shadow:none; }
233
+ #stats .stat-card:nth-child(n+5) .v { font-size:20px; }
234
+ @media (max-width: 760px) {
235
+ .admin-hero { align-items:stretch; flex-direction:column; gap:14px; padding:16px; }
236
+ .admin-hero h2 { font-size:23px; }
237
+ .admin-hero-side { align-items:stretch; flex-direction:row; justify-content:space-between; }
238
+ .admin-hero-action { flex:0 0 auto; }
239
+ #stats { grid-template-columns:repeat(2,minmax(0,1fr)); }
240
+ #stats .stat-card:nth-child(n+5) { background:linear-gradient(145deg, var(--dsr-panel), var(--dsr-bg-2)); box-shadow:inset 0 1px var(--dsr-divider), 0 10px 26px var(--dsr-shadow); }
241
+ }
242
+ @media (max-width: 720px) {
243
+ .token-row { display: flex; flex-direction: column; align-items: stretch; }
244
+ .token-actions { justify-content: stretch; }
245
+ .token-actions .mini-btn { flex: 1 1 auto; }
246
+ }
247
+ @media (max-width: 900px) {
248
+ .admin-title { display: grid; grid-template-columns: minmax(0, 1fr) auto; row-gap: 10px; }
249
+ .admin-title .left { min-width: 0; }
250
+ .admin-title .right { grid-column: 1 / -1; justify-content: flex-start; min-width: 0; overflow-x: auto; scrollbar-width: none; }
251
+ .admin-title .right::-webkit-scrollbar { display: none; }
252
+ }
253
+ @media (max-width: 440px) {
254
+ .admin-title h1 { display: block; min-width: 0; overflow: visible; font-size: 18px; }
255
+ .admin-title .left { gap: 7px; }
256
+ }
177
257
  </style>
178
258
  </head>
179
259
  <body>
@@ -185,11 +265,11 @@
185
265
  <h1>DSH Remote <span data-i18n="admin">管理</span></h1>
186
266
  </div>
187
267
  <div class="right">
188
- <a class="mini-btn gh-btn tb-btn" href="https://github.com/Blank-not-black/dsh-Remote" target="_blank" rel="noopener" title="GitHub">
268
+ <a class="mini-btn gh-btn tb-btn" href="https://github.com/Blank-not-black/dsh-Remote" target="_blank" rel="noopener" title="GitHub" data-i18n-aria="repo">
189
269
  <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8a8.01 8.01 0 0 0 5.47 7.59c.4.08.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg>
190
270
  <span class="gh-label" data-i18n="repo">仓库</span>
191
271
  </a>
192
- <button id="btn-donate" class="mini-btn tb-btn" data-i18n-title="donateTitle">🎁</button>
272
+ <button id="btn-donate" class="mini-btn tb-btn" data-i18n-title="donateTitle" data-i18n-aria="donateTitle"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8h11v6a4 4 0 0 1-4 4H10a4 4 0 0 1-4-4Z"/><path d="M17 10h1.5a2.5 2.5 0 0 1 0 5H17M9 4c0 1 1 1.2 1 2M13 4c0 1 1 1.2 1 2M5 20h13"/></svg></button>
193
273
  <div class="fb-wrap">
194
274
  <button id="btn-feedback" class="mini-btn fb-btn tb-btn" data-i18n-title="feedbackTitle" data-i18n-aria="feedbackTitle" aria-haspopup="menu" aria-expanded="false">
195
275
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a8 8 0 0 1-8 8H5l-1.5 2L4 20a8 8 0 0 1-1-4 8 8 0 0 1 18-4Z"/></svg>
@@ -223,6 +303,18 @@
223
303
  </div>
224
304
 
225
305
  <div id="main-view" class="hidden">
306
+ <section id="admin-hero" class="admin-hero" aria-live="polite">
307
+ <div class="admin-hero-copy">
308
+ <div class="admin-eyebrow" data-i18n="hero.eyebrow">GATEWAY CONTROL</div>
309
+ <h2 id="admin-hero-title">正在检查网关</h2>
310
+ <p id="admin-hero-desc">读取运行状态与上游连接</p>
311
+ </div>
312
+ <div class="admin-hero-side">
313
+ <span id="admin-hero-status" class="admin-hero-status">—</span>
314
+ <button id="admin-hero-action" class="mini-btn admin-hero-action" type="button" data-i18n="hero.openDevices">查看设备</button>
315
+ </div>
316
+ </section>
317
+
226
318
  <div class="token-row">
227
319
  <code id="token-full">—</code>
228
320
  <div class="token-actions">
@@ -274,7 +366,7 @@
274
366
  </div>
275
367
 
276
368
  <!-- 皮肤选择模态 -->
277
- <div id="modal-theme" class="modal hidden">
369
+ <div id="modal-theme" class="modal hidden" role="dialog" aria-modal="true">
278
370
  <div class="modal-card">
279
371
  <div class="modal-title" data-i18n="theme.panelTitle">选择配色</div>
280
372
  <div id="theme-options" class="theme-panel-list"></div>
@@ -285,11 +377,11 @@
285
377
  </div>
286
378
 
287
379
  <!-- 赞赏支持模态 -->
288
- <div id="modal-donate" class="modal hidden">
380
+ <div id="modal-donate" class="modal hidden" role="dialog" aria-modal="true">
289
381
  <div class="modal-card">
290
382
  <div class="modal-title" data-i18n="donateTitle">赞赏支持</div>
291
383
  <div class="modal-body donate-body">
292
- <img src="donate.png" alt="" class="donate-img" style="display:block;margin:0 auto;max-width:100%;max-height:60vh;border-radius:8px">
384
+ <img src="../donate.png" alt="赞赏二维码" width="1152" height="1152" class="donate-img" style="display:block;margin:0 auto;max-width:100%;height:auto;max-height:60vh;border-radius:12px">
293
385
  <p class="donate-thanks" data-i18n="donateThanks" style="text-align:center;margin-top:12px">感谢你的支持 ☕</p>
294
386
  </div>
295
387
  <div class="modal-actions">
@@ -298,7 +390,7 @@
298
390
  </div>
299
391
  </div>
300
392
 
301
- <div id="toast" class="toast hidden"></div>
393
+ <div id="toast" class="toast hidden" role="status" aria-live="polite"></div>
302
394
  <script>
303
395
  window.ADMIN_STR = {
304
396
  zh: {
@@ -306,6 +398,8 @@
306
398
  'consoleTitle': '返回控制台',
307
399
  'collapse': '收起面板',
308
400
  'admin': '管理',
401
+ 'hero.eyebrow': 'GATEWAY CONTROL', 'hero.running': '网关运行正常', 'hero.attention': '网关需要关注', 'hero.plugin': 'DSH 插件已连接', 'hero.offline': '网关未运行', 'hero.checking': '正在检查网关',
402
+ 'hero.runningDesc': '{online} 台设备在线 · {requests} 次请求', 'hero.attentionDesc': 'DSH 上游暂时不可达,请打开诊断', 'hero.pluginDesc': '插件已连接 DSH,网关可按需启动', 'hero.offlineDesc': '启动网关后即可接受 App 和桌面端连接', 'hero.openDevices': '查看设备', 'hero.startGateway': '启动网关', 'hero.copyToken': '复制令牌',
309
403
  'theme.default': '默认深空', 'theme.dark': '落日', 'theme.light': '易北爱乐厅', 'theme.neutral': '草原孤塔',
310
404
  'theme.panelTitle': '选择配色', 'theme.close': '关闭',
311
405
  'repo': '仓库',
@@ -389,6 +483,8 @@
389
483
  'consoleTitle': 'Back to console',
390
484
  'collapse': 'Collapse panel',
391
485
  'admin': 'Admin',
486
+ 'hero.eyebrow': 'GATEWAY CONTROL', 'hero.running': 'Gateway is healthy', 'hero.attention': 'Gateway needs attention', 'hero.plugin': 'DSH plugin connected', 'hero.offline': 'Gateway is not running', 'hero.checking': 'Checking gateway',
487
+ 'hero.runningDesc': '{online} devices online · {requests} requests', 'hero.attentionDesc': 'DSH upstream is unreachable; open diagnostics', 'hero.pluginDesc': 'The plugin is connected to DSH; start the gateway when needed', 'hero.offlineDesc': 'Start the gateway to accept App and desktop connections', 'hero.openDevices': 'View devices', 'hero.startGateway': 'Start gateway', 'hero.copyToken': 'Copy token',
392
488
  'theme.default': 'Default', 'theme.dark': 'Sunset', 'theme.light': 'Elbphilharmonie', 'theme.neutral': 'Prairie Tower',
393
489
  'theme.panelTitle': 'Choose theme', 'theme.close': 'Close',
394
490
  'repo': 'Repo',
package/public/admin.js CHANGED
@@ -220,6 +220,24 @@ function render(st) {
220
220
  if (cur) cur.textContent = t('gatewayPort.current', { port: gatewayPort })
221
221
  }
222
222
  const upOk = st.upstream.reachable
223
+ const hero = $('admin-hero')
224
+ if (hero) {
225
+ const heroState = isGateway ? (upOk ? 'running' : 'attention') : isPlugin ? 'plugin' : 'offline'
226
+ hero.className = 'admin-hero ' + heroState
227
+ const titleKey = heroState === 'running' ? 'hero.running' : heroState === 'attention' ? 'hero.attention' : heroState === 'plugin' ? 'hero.plugin' : 'hero.offline'
228
+ const descKey = heroState === 'running' ? 'hero.runningDesc' : heroState === 'attention' ? 'hero.attentionDesc' : heroState === 'plugin' ? 'hero.pluginDesc' : 'hero.offlineDesc'
229
+ $('admin-hero-title').textContent = t(titleKey)
230
+ $('admin-hero-desc').textContent = heroState === 'running'
231
+ ? t(descKey, { online: st.onlineCount || 0, requests: st.totalRequests || 0 })
232
+ : t(descKey)
233
+ $('admin-hero-status').textContent = isGateway ? (upOk ? t('stat.reachable') : t('stat.unreachable')) : t(isPlugin ? 'badge.embedded' : 'badge.gatewayDown')
234
+ const action = $('admin-hero-action')
235
+ if (action) {
236
+ const actionKey = heroState === 'plugin' ? 'hero.startGateway' : heroState === 'offline' ? 'hero.copyToken' : 'hero.openDevices'
237
+ action.textContent = t(actionKey)
238
+ action.dataset.heroAction = heroState === 'plugin' ? 'start' : heroState === 'offline' ? 'copy' : 'devices'
239
+ }
240
+ }
223
241
  const hostIPs = (st.lanIPs || []).join(t('stat.ipSep')) || '127.0.0.1'
224
242
  const latestHtml = st.latest?.newer
225
243
  ? `<div class="v">${t('stat.updateAvailable', { version: st.latest.version })}</div><div class="k">${t('stat.currentV', { version: st.version })} · <a href="${st.latest.url || '#'}" target="_blank" rel="noopener" style="color:var(--dsr-accent-strong)">${t('stat.download')}</a></div>`
@@ -388,6 +406,12 @@ $('btn-logout').addEventListener('click', logout)
388
406
  $('btn-close-drawer').addEventListener('click', () => {
389
407
  window.parent.postMessage({ source: 'dsh-remote-admin', type: 'close' }, location.origin)
390
408
  })
409
+ $('admin-hero-action').addEventListener('click', () => {
410
+ const action = $('admin-hero-action').dataset.heroAction
411
+ if (action === 'start') return $('btn-gateway').click()
412
+ if (action === 'copy') return $('btn-copy').click()
413
+ $('device-rows').closest('.table-wrap')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
414
+ })
391
415
  $('btn-copy').addEventListener('click', async () => {
392
416
  try {
393
417
  await navigator.clipboard.writeText(shownToken || token)