dsh-remote-plugin 0.6.10 → 0.6.11

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
@@ -8,12 +8,15 @@ The official DSH bundle plugin for DSH Remote. It adds a DSH sidebar entry, a co
8
8
 
9
9
  ```sh
10
10
  dsh plugin --profile web add dsh-remote-plugin
11
+ dsh plugin --profile web list --depth 0
11
12
 
12
13
  # Optional version pin
13
14
  dsh plugin --profile web add dsh-remote-plugin@0.6.8
14
15
  ```
15
16
 
16
- Restart DSH Web and refresh the browser. The DSH Remote entry will appear in the sidebar.
17
+ The second command verifies installation in the `web` profile. Completely restart DSH Web and hard-refresh with Ctrl+F5. For a user service, a typical restart is `systemctl --user restart dsh-web`; for a manual setup, stop the old `dsh web` process and launch it again.
18
+
19
+ Open `http://127.0.0.1:8787/health` on the DSH host before pairing a phone. Once it returns JSON, connect the phone to `http://PC-LAN-IP:8787` or the host's Tailscale IP. Do not use `127.0.0.1` or `localhost` on the phone; those point to the phone itself.
17
20
 
18
21
  ## What the plugin provides
19
22
 
@@ -41,6 +44,22 @@ Notification settings include approval / question notifications, background poll
41
44
 
42
45
  The token grants remote control of DSH. Keep it private. For cross-network access, prefer Tailscale or another authenticated secure tunnel.
43
46
 
47
+ ## Gateway cannot be opened
48
+
49
+ ```bash
50
+ curl -i http://127.0.0.1:8787/health
51
+ ss -ltnp | grep ':8787'
52
+ curl -i http://127.0.0.1:3080/
53
+ ```
54
+
55
+ - If port 8787 refuses locally, verify the `web` profile installation, gateway state in the plugin panel, a real DSH Web restart, and port ownership.
56
+ - If `/health` works but `upstreamOk` is false, the gateway is running; troubleshoot DSH Web on port 3080.
57
+ - If local access works but the phone fails, use the host LAN/Tailscale IP and permit inbound TCP 8787. Do not expose port 3080 publicly.
58
+ - A 401 response means the token is wrong; pair again or copy the current `~/.dsh-remote/token`.
59
+ - For a blank/stale page after an upgrade, hard-refresh or fully close and reopen the app.
60
+
61
+ The managed gateway may be a transient process. Do not rely on `systemctl --user restart dsh-remote-gateway.service`; use Start Gateway in the plugin panel or restart DSH Web to trigger self-healing.
62
+
44
63
  ## Links
45
64
 
46
65
  - Admin page: `http://<gateway-ip>:8787/admin`
package/README.md CHANGED
@@ -8,12 +8,15 @@ DSH Remote 的官方 bundle 插件:在 DSH 侧边栏提供入口,打开快
8
8
 
9
9
  ```sh
10
10
  dsh plugin --profile web add dsh-remote-plugin
11
+ dsh plugin --profile web list --depth 0
11
12
 
12
13
  # 也可以安装指定版本
13
14
  dsh plugin --profile web add dsh-remote-plugin@0.6.8
14
15
  ```
15
16
 
16
- 重启 DSH Web 后刷新浏览器,左侧边栏会出现 DSH Remote 入口。
17
+ 第二条命令用于确认插件安装在正确的 `web` profile。然后完整重启 DSH Web 进程并 Ctrl+F5 强刷浏览器,左侧边栏会出现 DSH Remote 入口。如果 DSH Web 由 systemd 用户服务管理,可执行 `systemctl --user restart dsh-web`;手动运行时则需停止旧的 `dsh web` 进程后重新启动。
18
+
19
+ 安装后先在 DSH 主机访问 `http://127.0.0.1:8787/health`。看到 JSON 后再用手机连接 `http://电脑局域网IP:8787`;手机上的 `127.0.0.1` 和 `localhost` 指向手机自己,不是 DSH 主机。
17
20
 
18
21
  ## 插件提供什么
19
22
 
@@ -41,6 +44,22 @@ Android 应用 / 手机 WebUI 采用五个主要页面:会话、文件、主
41
44
 
42
45
  令牌等同于 DSH 远程操作凭证,请不要公开或提交到仓库。局域网访问建议配合防火墙;跨网络访问建议使用 Tailscale 或其他带认证的安全隧道。
43
46
 
47
+ ## 网关打不开
48
+
49
+ ```bash
50
+ curl -i http://127.0.0.1:8787/health
51
+ ss -ltnp | grep ':8787'
52
+ curl -i http://127.0.0.1:3080/
53
+ ```
54
+
55
+ - 8787 本机也拒绝连接:检查插件是否装在 `web` profile、插件面板的网关开关、DSH Web 是否真正重启,以及 8787 是否被占用。
56
+ - `/health` 能打开但 `upstreamOk: false`:网关已运行,应检查 DSH Web 的 3080 端口。
57
+ - 本机能打开但手机不能:使用电脑局域网/Tailscale IP,并允许防火墙 TCP 8787 入站;不需要对公网放行 3080。
58
+ - 出现 401:重新扫码或复制 `~/.dsh-remote/token`。
59
+ - 页面黑屏/功能未更新:Ctrl+F5 强刷,手机端完全退出后重开。
60
+
61
+ 插件网关可能以 transient 进程运行,不要依赖 `systemctl --user restart dsh-remote-gateway.service`。优先在插件面板启动网关,或重启 DSH Web 触发自愈。
62
+
44
63
  ## 相关地址
45
64
 
46
65
  - 管理页:`http://<网关IP>:8787/admin`
Binary file
package/client.js CHANGED
@@ -24,6 +24,61 @@ window.__ModuleLoader__.load({
24
24
  // 动态注入列布局, 让各 footer action 上下堆叠。
25
25
  var FOOTER_FIX_ID = 'dsh-remote-footer-stack'
26
26
  var FOOTER_FIX_CSS = '.kIs9zW_footerActions{flex-direction:column!important;align-items:stretch!important}'
27
+ var PLUGIN_THEMES = ['default', 'dark', 'light', 'neutral']
28
+ var ICON_PALETTES = {
29
+ default: { bg: '#F6F9FF', upper: '#4F7FEF', lower: '#8A64D8' },
30
+ dark: { bg: '#FFF4E3', upper: '#C66A00', lower: '#8A5A24' },
31
+ light: { bg: '#FFF9EF', upper: '#9B6A20', lower: '#66517F' },
32
+ neutral: { bg: '#F4F0DF', upper: '#585818', lower: '#832D15' },
33
+ }
34
+
35
+ function currentPluginTheme() {
36
+ var saved = null
37
+ try { saved = localStorage.getItem('dshTheme') } catch (_) {}
38
+ if (PLUGIN_THEMES.indexOf(saved) >= 0) return saved
39
+ return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'default'
40
+ }
41
+
42
+ function usePluginTheme() {
43
+ var themeState = React.useState(currentPluginTheme)
44
+ var theme = themeState[0]
45
+ var setTheme = themeState[1]
46
+ React.useEffect(function () {
47
+ var mq = window.matchMedia('(prefers-color-scheme: light)')
48
+ function sync() { setTheme(currentPluginTheme()) }
49
+ function onStorage(e) { if (!e || e.key === 'dshTheme') sync() }
50
+ function onMessage(e) {
51
+ var d = e.data
52
+ if (e.origin !== location.origin || !d || d.source !== 'dsh-remote-theme' || d.type !== 'change') return
53
+ if (PLUGIN_THEMES.indexOf(d.theme) >= 0) setTheme(d.theme)
54
+ }
55
+ window.addEventListener('storage', onStorage)
56
+ window.addEventListener('message', onMessage)
57
+ if (mq.addEventListener) mq.addEventListener('change', sync)
58
+ else if (mq.addListener) mq.addListener(sync)
59
+ return function () {
60
+ window.removeEventListener('storage', onStorage)
61
+ window.removeEventListener('message', onMessage)
62
+ if (mq.removeEventListener) mq.removeEventListener('change', sync)
63
+ else if (mq.removeListener) mq.removeListener(sync)
64
+ }
65
+ }, [])
66
+ return theme
67
+ }
68
+
69
+ function PluginIcon() {
70
+ var palette = ICON_PALETTES[usePluginTheme()] || ICON_PALETTES.default
71
+ var common = {
72
+ fill: 'none', strokeWidth: 2.8, strokeLinecap: 'square', strokeLinejoin: 'miter',
73
+ }
74
+ return React.createElement('svg', {
75
+ viewBox: '0 0 24 24', 'aria-hidden': true, focusable: false,
76
+ style: { width: 16, height: 16, flexShrink: 0, display: 'block' },
77
+ },
78
+ React.createElement('rect', { x: .75, y: .75, width: 22.5, height: 22.5, rx: 5, fill: palette.bg, stroke: 'rgba(15, 23, 42, .14)', strokeWidth: 1 }),
79
+ React.createElement('path', Object.assign({ d: 'M10 6l4 4 4-4m-4 4 4 4', stroke: palette.upper }, common)),
80
+ React.createElement('path', Object.assign({ d: 'M6 10l4 4-4 4m4-4 4 4', stroke: palette.lower }, common)))
81
+ }
27
82
 
28
83
  function useFooterStack() {
29
84
  React.useEffect(function () {
@@ -59,10 +114,7 @@ window.__ModuleLoader__.load({
59
114
  font: '500 13px/1.3 system-ui, sans-serif', textAlign: 'left',
60
115
  },
61
116
  },
62
- React.createElement('img', {
63
- src: '/remote/icon.svg', alt: '', 'aria-hidden': true,
64
- style: { width: 16, height: 16, flexShrink: 0 },
65
- }),
117
+ React.createElement(PluginIcon),
66
118
  props.wide ? React.createElement('span', null, 'DSH Remote') : null)
67
119
  }
68
120
 
package/gateway-stats.cjs CHANGED
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * 聚合:
8
8
  * time(UTC 毫秒) -> Asia/Shanghai(固定 UTC+8, 无夏令时) -> 日 × 小时 × 模型
9
- * 时段: 高峰 9:00-12:00 / 14:00-18:00(含起点, 不含终点), 其余空闲
9
+ * 时段: 工作日高峰 9:00-12:00 / 14:00-18:00(含起点, 不含终点), 周末全天空闲
10
10
  * 四桶: input(未缓存输入) / cacheRead(缓存命中读) / cacheWrite(缓存写) / output(输出)
11
11
  *
12
12
  * 存储:
@@ -24,7 +24,7 @@ const { spawn } = require('node:child_process')
24
24
  const readline = require('node:readline')
25
25
 
26
26
  // ---------- 固定价格表(v1 硬编码; v2 将改为配置文件/环境变量) ----------
27
- // 时段判定: 北京时间 9:00-12:00 与 14:00-18:00 为高峰(含起点不含终点)
27
+ // 时段判定: 工作日北京时间 9:00-12:00 与 14:00-18:00 为高峰(含起点不含终点),周末全天谷时
28
28
  const PEAK_HOURS = [[9, 12], [14, 18]]
29
29
  const PRICES = {
30
30
  'deepseek-v4-flash': {
@@ -73,6 +73,19 @@ function periodOfHour(hour) {
73
73
  return 'off'
74
74
  }
75
75
 
76
+ /** 北京自然日是否为周六/周日。 */
77
+ function isWeekendDate(date) {
78
+ const parts = String(date || '').split('-').map(Number)
79
+ if (parts.length !== 3 || parts.some(n => !Number.isInteger(n))) return false
80
+ const day = new Date(Date.UTC(parts[0], parts[1] - 1, parts[2])).getUTCDay()
81
+ return day === 0 || day === 6
82
+ }
83
+
84
+ /** 北京日期 + 小时 -> 计费时段;周末全天谷时。 */
85
+ function periodOfDateHour(date, hour) {
86
+ return isWeekendDate(date) ? 'off' : periodOfHour(hour)
87
+ }
88
+
76
89
  /** 时段单价; 未知模型返回全 0(统计照记, 费用为 0)。 */
77
90
  function pricesFor(model) {
78
91
  return PRICES[model] || null
@@ -105,6 +118,11 @@ function addUsage(bucket, model, period, usage) {
105
118
  return bucket
106
119
  }
107
120
 
121
+ /** 按当前日期规则重算 bucket 费用,兼容升级前已按工作日价格写入的周末历史数据。 */
122
+ function pricedBucket(model, period, bucket) {
123
+ return addUsage(emptyBucket(), model, period, bucket)
124
+ }
125
+
108
126
  function mergeBucket(dst, src) {
109
127
  for (const key of ['input', 'cacheRead', 'cacheWrite', 'output', 'cost']) {
110
128
  dst[key] += src[key]
@@ -114,7 +132,9 @@ function mergeBucket(dst, src) {
114
132
 
115
133
  /** 时段事件 -> 日/小时/模型聚合的 key。 */
116
134
  function eventKey(timeMs) {
117
- return { date: beijingDate(timeMs), hour: beijingHour(timeMs), period: periodOfHour(beijingHour(timeMs)) }
135
+ const date = beijingDate(timeMs)
136
+ const hour = beijingHour(timeMs)
137
+ return { date, hour, period: periodOfDateHour(date, hour) }
118
138
  }
119
139
 
120
140
  /** 事件里的模型: 优先 message.source.model。 */
@@ -143,9 +163,10 @@ function summarizeDay(day) {
143
163
  const off = emptyBucket()
144
164
  for (const hourStr of Object.keys(day?.hours || {})) {
145
165
  const hour = Number(hourStr)
146
- const target = periodOfHour(hour) === 'peak' ? peak : off
166
+ const period = periodOfDateHour(day?.date, hour)
167
+ const target = period === 'peak' ? peak : off
147
168
  for (const model of Object.keys(day.hours[hourStr] || {})) {
148
- mergeBucket(target, day.hours[hourStr][model])
169
+ mergeBucket(target, pricedBucket(model, period, day.hours[hourStr][model]))
149
170
  }
150
171
  }
151
172
  const total = emptyBucket()
@@ -403,11 +424,12 @@ class StatsStore {
403
424
  const s = dayTotals(day)
404
425
  const byModel = {}
405
426
  for (const hourStr of Object.keys(day.hours || {})) {
406
- const period = periodOfHour(Number(hourStr))
427
+ const period = periodOfDateHour(date, Number(hourStr))
407
428
  for (const [model, bucket] of Object.entries(day.hours[hourStr])) {
408
429
  const m = byModel[model] || (byModel[model] = { peak: emptyBucket(), off: emptyBucket(), total: emptyBucket() })
409
- mergeBucket(m[period], bucket)
410
- mergeBucket(m.total, bucket)
430
+ const priced = pricedBucket(model, period, bucket)
431
+ mergeBucket(m[period], priced)
432
+ mergeBucket(m.total, priced)
411
433
  }
412
434
  }
413
435
  out.push({ date, ...s, byModel })
@@ -421,10 +443,14 @@ class StatsStore {
421
443
  const day = this._loadDay(effective)
422
444
  const hours = []
423
445
  for (let hour = 0; hour < 24; hour++) {
424
- const models = day.hours[hour] || {}
446
+ const period = periodOfDateHour(effective, hour)
447
+ const models = {}
425
448
  const total = emptyBucket()
426
- for (const bucket of Object.values(models)) mergeBucket(total, bucket)
427
- hours.push({ hour, period: periodOfHour(hour), models, total })
449
+ for (const [model, bucket] of Object.entries(day.hours[hour] || {})) {
450
+ models[model] = pricedBucket(model, period, bucket)
451
+ mergeBucket(total, models[model])
452
+ }
453
+ hours.push({ hour, period, models, total })
428
454
  }
429
455
  return { date: effective, pricingStart: PRICING_START_DATE, hours }
430
456
  }
@@ -438,8 +464,11 @@ module.exports = {
438
464
  beijingHour,
439
465
  beijingDate,
440
466
  periodOfHour,
467
+ isWeekendDate,
468
+ periodOfDateHour,
441
469
  emptyBucket,
442
470
  addUsage,
471
+ pricedBucket,
443
472
  summarizeDay,
444
473
  dayTotals,
445
474
  eventKey,
package/gateway.cjs CHANGED
@@ -43,6 +43,7 @@ try {
43
43
 
44
44
  const ROOT = __dirname
45
45
  const PUBLIC_DIR = path.join(ROOT, 'public')
46
+ const ANNOUNCEMENTS_FILE = process.env.DSH_REMOTE_ANNOUNCEMENTS_FILE || path.join(PUBLIC_DIR, 'announcements.json')
46
47
  const PORT = Number(process.env.PORT) || 8787
47
48
  const HOST = process.env.HOST || '0.0.0.0'
48
49
 
@@ -166,6 +167,14 @@ const FS_MIME = {
166
167
  '.epub': 'application/epub+zip',
167
168
  '.wasm': 'application/wasm',
168
169
  }
170
+ const FS_PREVIEW_MAX = 1024 * 1024
171
+ const FS_PREVIEW_EXTENSIONS = new Set([
172
+ '.txt', '.md', '.markdown', '.log', '.json', '.jsonl', '.js', '.mjs', '.cjs', '.jsx',
173
+ '.ts', '.tsx', '.py', '.css', '.html', '.htm', '.xml', '.yaml', '.yml', '.toml',
174
+ '.ini', '.conf', '.env', '.sh', '.bash', '.zsh', '.fish', '.sql', '.java', '.kt',
175
+ '.kts', '.go', '.rs', '.c', '.h', '.cpp', '.hpp', '.cs', '.php', '.rb', '.vue',
176
+ '.svelte', '.gradle', '.properties', '.gitignore', '.dockerfile'
177
+ ])
169
178
 
170
179
  // ---------- token ----------
171
180
  function loadToken() {
@@ -991,6 +1000,25 @@ function maskIp(ip) {
991
1000
  return s
992
1001
  }
993
1002
 
1003
+ function validatePollVote(payload) {
1004
+ const announcementId = String(payload.announcementId || '').trim()
1005
+ const pollId = String(payload.pollId || '').trim()
1006
+ const optionId = String(payload.optionId || '').trim()
1007
+ if (!announcementId || !pollId || !optionId) return { error: 'poll fields required' }
1008
+ if (announcementId.length > 120 || pollId.length > 120 || optionId.length > 120) return { error: 'poll fields too long' }
1009
+ let source
1010
+ try { source = JSON.parse(fs.readFileSync(ANNOUNCEMENTS_FILE, 'utf8')) } catch { return { error: 'poll config unavailable' } }
1011
+ const items = Array.isArray(source) ? source : (Array.isArray(source?.items) ? source.items : [source])
1012
+ const announcement = items.find(item => String(item?.id || '').trim() === announcementId)
1013
+ const poll = announcement?.poll
1014
+ if (!poll || String(poll.id || '').trim() !== pollId || !Array.isArray(poll.options)) return { error: 'poll not found' }
1015
+ const option = poll.options.find(item => String(item?.id || '').trim() === optionId)
1016
+ if (!option) return { error: 'poll option not found' }
1017
+ const optionLabel = String(option.label || '').trim().slice(0, 200)
1018
+ if (!optionLabel) return { error: 'poll option invalid' }
1019
+ return { announcementId, pollId, optionId, optionLabel }
1020
+ }
1021
+
994
1022
  function serveFeedback(req, res, url) {
995
1023
  cors(res)
996
1024
  if (req.method === 'OPTIONS') {
@@ -1024,14 +1052,23 @@ function serveFeedback(req, res, url) {
1024
1052
  return
1025
1053
  }
1026
1054
  const type = payload.type
1027
- const message = String(payload.message || '').trim()
1055
+ let message = String(payload.message || '').trim()
1028
1056
  const contact = String(payload.contact || '').trim()
1029
1057
  const appVersion = String(payload.appVersion || '').trim()
1030
- if (!['bug', 'suggestion', 'other'].includes(type)) {
1058
+ if (!['bug', 'suggestion', 'other', 'poll'].includes(type)) {
1059
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
1060
+ res.end(JSON.stringify({ error: 'invalid type', expect: 'bug|suggestion|other|poll' }))
1061
+ return
1062
+ }
1063
+ const pollVote = type === 'poll' ? validatePollVote(payload) : null
1064
+ if (pollVote?.error) {
1031
1065
  res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
1032
- res.end(JSON.stringify({ error: 'invalid type', expect: 'bug|suggestion|other' }))
1066
+ res.end(JSON.stringify({ error: pollVote.error }))
1033
1067
  return
1034
1068
  }
1069
+ // 公网收集器的旧版只保留 type/message 等通用字段。同时发送结构化
1070
+ // 字段和稳定的 message 编码,旧收集器也能用 scripts/summarize-polls.mjs 汇总。
1071
+ if (pollVote) message = 'POLL ' + JSON.stringify({ announcementId: pollVote.announcementId, pollId: pollVote.pollId, optionId: pollVote.optionId })
1035
1072
  if (!message) {
1036
1073
  res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
1037
1074
  res.end(JSON.stringify({ error: 'message required' }))
@@ -1068,7 +1105,8 @@ function serveFeedback(req, res, url) {
1068
1105
  contact: contact || undefined,
1069
1106
  appVersion: appVersion || 'unknown',
1070
1107
  gatewayVersion: VERSION,
1071
- clientIp: maskIp(ip)
1108
+ clientIp: maskIp(ip),
1109
+ ...(pollVote || {})
1072
1110
  }),
1073
1111
  signal: AbortSignal.timeout(8000)
1074
1112
  }).then(async (r) => {
@@ -1108,6 +1146,24 @@ function serveStatic(req, res, url) {
1108
1146
  }
1109
1147
  if (pathname === '/') pathname = '/index.html'
1110
1148
  if (pathname === '/admin') pathname = '/admin.html'
1149
+ if (pathname === '/announcements.json') {
1150
+ fs.readFile(ANNOUNCEMENTS_FILE, 'utf8', (err, raw) => {
1151
+ if (err) {
1152
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
1153
+ res.end('404 Not Found')
1154
+ return
1155
+ }
1156
+ try { JSON.parse(raw) } catch {
1157
+ res.writeHead(500, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
1158
+ res.end(JSON.stringify({ error: 'invalid announcements config' }))
1159
+ return
1160
+ }
1161
+ cors(res)
1162
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
1163
+ res.end(req.method === 'HEAD' ? '' : raw)
1164
+ })
1165
+ return
1166
+ }
1111
1167
  // 兼容旧版 App(版本比较不认 -rc): 无 local 参数的请求把 0.5.2-rc.1 显示为 0.5.2,
1112
1168
  // 引导升级到新 APK; 新 App 带 ?local= 拿到真实 rc 版本, 不会循环提示。
1113
1169
  if (pathname === '/update.json') {
@@ -1485,6 +1541,45 @@ function fsFile(req, res, url) {
1485
1541
  stream.pipe(res)
1486
1542
  }
1487
1543
 
1544
+ function fsPreview(req, res, url) {
1545
+ if (req.method !== 'GET') {
1546
+ res.writeHead(405, { allow: 'GET' })
1547
+ res.end()
1548
+ return
1549
+ }
1550
+ if (!fsAuthorized(req, url, res)) return
1551
+ const resolved = fsResolve(url.searchParams.get('path') ?? '')
1552
+ if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
1553
+ const checked = fsRealChecked(resolved.abs)
1554
+ if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
1555
+
1556
+ let st
1557
+ try { st = fs.statSync(checked.abs) } catch (err) {
1558
+ return fsJson(res, err.code === 'ENOENT' ? 404 : 403, { error: err.code === 'ENOENT' ? 'not-found' : 'permission-denied' })
1559
+ }
1560
+ if (!st.isFile()) return fsJson(res, 400, { error: 'not-a-file' })
1561
+
1562
+ const name = path.basename(checked.abs)
1563
+ const lowerName = name.toLowerCase()
1564
+ const extension = lowerName === 'dockerfile' ? '.dockerfile' : path.extname(lowerName)
1565
+ if (!FS_PREVIEW_EXTENSIONS.has(extension)) {
1566
+ return fsJson(res, 415, { error: 'preview-unsupported', extension })
1567
+ }
1568
+ if (st.size > FS_PREVIEW_MAX) {
1569
+ return fsJson(res, 413, { error: 'preview-too-large', size: st.size, limit: FS_PREVIEW_MAX })
1570
+ }
1571
+
1572
+ let content
1573
+ try {
1574
+ const bytes = fs.readFileSync(checked.abs)
1575
+ if (bytes.includes(0)) return fsJson(res, 415, { error: 'preview-binary' })
1576
+ content = bytes.toString('utf8')
1577
+ } catch (err) {
1578
+ return fsJson(res, 403, { error: 'permission-denied', detail: err.message })
1579
+ }
1580
+ fsJson(res, 200, { name, path: resolved.abs, extension, size: st.size, content })
1581
+ }
1582
+
1488
1583
  function fsValidName(name) {
1489
1584
  if (typeof name !== 'string') return false
1490
1585
  if (!name || name === '.' || name === '..') return false
@@ -1948,6 +2043,7 @@ function serveFs(req, res, url) {
1948
2043
 
1949
2044
  if (sub === '/list') return fsList(req, res, url)
1950
2045
  if (sub === '/file') return fsFile(req, res, url)
2046
+ if (sub === '/preview') return fsPreview(req, res, url)
1951
2047
  if (sub === '/mkdir') return fsMkdir(req, res, url)
1952
2048
  if (sub === '/upload-probe') return fsUploadProbe(req, res, url)
1953
2049
  if (sub === '/upload-control') return fsUploadControl(req, res, url)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.6.10",
3
+ "version": "0.6.11",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",