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 +20 -1
- package/README.md +20 -1
- package/apk/dsh-remote.apk +0 -0
- package/client.js +56 -4
- package/gateway-stats.cjs +40 -11
- package/gateway.cjs +100 -4
- package/package.json +1 -1
- package/public/app.js +524 -43
- package/public/index.html +84 -6
- package/public/plugin-icon.svg +5 -0
- package/public/styles.css +86 -0
- package/public/theme.js +9 -2
- package/public/update.json +8 -4
- package/public/version.json +1 -1
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
|
-
|
|
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
|
-
|
|
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`
|
package/apk/dsh-remote.apk
CHANGED
|
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(
|
|
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
|
+
* 时段: 工作日高峰 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
|
-
// 时段判定:
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
410
|
-
mergeBucket(m
|
|
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
|
|
446
|
+
const period = periodOfDateHour(effective, hour)
|
|
447
|
+
const models = {}
|
|
425
448
|
const total = emptyBucket()
|
|
426
|
-
for (const bucket of Object.
|
|
427
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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",
|