dsh-remote-plugin 0.6.10-rc.1 → 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 +149 -34
- package/index.mjs +3 -1
- package/package.json +1 -1
- package/public/app.js +541 -47
- package/public/desktop/desktop.js +10 -3
- 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 +12 -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() {
|
|
@@ -797,53 +806,70 @@ function startEventCollector(kind) {
|
|
|
797
806
|
}
|
|
798
807
|
const connect = () => {
|
|
799
808
|
if (stopped) return
|
|
809
|
+
let current
|
|
800
810
|
try {
|
|
801
|
-
|
|
811
|
+
current = new WebSocket(url)
|
|
812
|
+
ws = current
|
|
802
813
|
} catch (err) {
|
|
803
814
|
state.lastError = String(err?.message || err)
|
|
815
|
+
state.connected = false
|
|
816
|
+
state.reconnects++
|
|
804
817
|
schedule()
|
|
805
818
|
return
|
|
806
819
|
}
|
|
807
|
-
|
|
820
|
+
let finished = false
|
|
821
|
+
const clearConnectTimer = () => {
|
|
822
|
+
if (connectTimer) clearTimeout(connectTimer)
|
|
823
|
+
connectTimer = null
|
|
824
|
+
}
|
|
825
|
+
// Node WebSocket 在 CONNECTING 阶段失败时可能只触发 error,
|
|
826
|
+
// 调用 close() 后不保证再触发 close。每次尝试因此必须有一个
|
|
827
|
+
// 幂等的收口,任何 error/close/timeout 都只安排一次重连。
|
|
828
|
+
const finishAndRetry = (closeCode = 0, closeReason = '') => {
|
|
829
|
+
if (finished) return
|
|
830
|
+
finished = true
|
|
831
|
+
clearConnectTimer()
|
|
832
|
+
state.connected = false
|
|
833
|
+
state.lastCloseCode = Number(closeCode) || 0
|
|
834
|
+
state.lastCloseReason = String(closeReason || '')
|
|
835
|
+
if (ws === current) ws = null
|
|
836
|
+
if (stopped) return
|
|
837
|
+
state.reconnects++
|
|
838
|
+
schedule()
|
|
839
|
+
}
|
|
808
840
|
connectTimer = setTimeout(() => {
|
|
809
|
-
if (ws === current && current.readyState === 0) {
|
|
841
|
+
if (!finished && ws === current && current.readyState === 0) {
|
|
810
842
|
state.lastError = 'websocket connect timeout'
|
|
843
|
+
finishAndRetry()
|
|
811
844
|
try { current.close() } catch {}
|
|
812
845
|
}
|
|
813
846
|
}, WS_UPGRADE_TIMEOUT_MS)
|
|
814
847
|
connectTimer.unref?.()
|
|
815
|
-
|
|
816
|
-
if (
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
state.connected = true
|
|
820
|
-
state.lastConnectAt = Date.now()
|
|
821
|
-
state.lastError = ''
|
|
822
|
-
state.attempt = 0
|
|
848
|
+
current.onopen = () => {
|
|
849
|
+
if (finished || ws !== current || stopped) {
|
|
850
|
+
try { current.close() } catch {}
|
|
851
|
+
return
|
|
823
852
|
}
|
|
824
|
-
|
|
853
|
+
clearConnectTimer()
|
|
854
|
+
state.connected = true
|
|
855
|
+
state.lastConnectAt = Date.now()
|
|
856
|
+
state.lastError = ''
|
|
857
|
+
state.attempt = 0
|
|
825
858
|
}
|
|
826
|
-
|
|
859
|
+
current.onmessage = (ev) => {
|
|
827
860
|
if (stopped) return
|
|
828
861
|
try {
|
|
829
862
|
const data = typeof ev.data === 'string' ? ev.data : Buffer.isBuffer(ev.data) ? ev.data.toString() : String(ev.data)
|
|
830
863
|
pushEvent(kind, JSON.parse(data), data)
|
|
831
864
|
} catch {}
|
|
832
865
|
}
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
connectTimer = null
|
|
836
|
-
if (state) {
|
|
837
|
-
state.connected = false
|
|
838
|
-
state.reconnects++
|
|
839
|
-
state.lastCloseCode = Number(current?.closeCode) || 0
|
|
840
|
-
state.lastCloseReason = String(current?.closeReason || '')
|
|
841
|
-
}
|
|
842
|
-
ws = null
|
|
843
|
-
schedule()
|
|
866
|
+
current.onclose = (ev) => {
|
|
867
|
+
finishAndRetry(ev?.code, ev?.reason)
|
|
844
868
|
}
|
|
845
|
-
|
|
846
|
-
if (
|
|
869
|
+
current.onerror = (err) => {
|
|
870
|
+
if (finished) return
|
|
871
|
+
state.lastError = String(err?.error?.message || err?.message || 'websocket error')
|
|
872
|
+
finishAndRetry()
|
|
847
873
|
try { current.close() } catch {}
|
|
848
874
|
}
|
|
849
875
|
}
|
|
@@ -958,9 +984,9 @@ function serveStats(req, res, url) {
|
|
|
958
984
|
// ---------- 反馈提交 ----------
|
|
959
985
|
const feedbackThrottle = new Map() // ip -> 上次受理时间戳
|
|
960
986
|
const FEEDBACK_WINDOW_MS = 60 * 1000
|
|
961
|
-
// 反馈收集器: 环境变量可覆盖,
|
|
962
|
-
//
|
|
963
|
-
const FEEDBACK_URL = process.env.DSH_REMOTE_FEEDBACK_URL || 'https://
|
|
987
|
+
// 反馈收集器: 环境变量可覆盖, 默认使用 Tailscale Funnel 提供的公网 HTTPS
|
|
988
|
+
// 入口。这里是公开的 ts.net 域名,不要求提交反馈的用户加入 tailnet。
|
|
989
|
+
const FEEDBACK_URL = process.env.DSH_REMOTE_FEEDBACK_URL || 'https://vm-0-2-ubuntu.tail1f6fc4.ts.net/submit'
|
|
964
990
|
|
|
965
991
|
function maskIp(ip) {
|
|
966
992
|
if (!ip) return 'unknown'
|
|
@@ -974,6 +1000,25 @@ function maskIp(ip) {
|
|
|
974
1000
|
return s
|
|
975
1001
|
}
|
|
976
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
|
+
|
|
977
1022
|
function serveFeedback(req, res, url) {
|
|
978
1023
|
cors(res)
|
|
979
1024
|
if (req.method === 'OPTIONS') {
|
|
@@ -1007,14 +1052,23 @@ function serveFeedback(req, res, url) {
|
|
|
1007
1052
|
return
|
|
1008
1053
|
}
|
|
1009
1054
|
const type = payload.type
|
|
1010
|
-
|
|
1055
|
+
let message = String(payload.message || '').trim()
|
|
1011
1056
|
const contact = String(payload.contact || '').trim()
|
|
1012
1057
|
const appVersion = String(payload.appVersion || '').trim()
|
|
1013
|
-
if (!['bug', 'suggestion', 'other'].includes(type)) {
|
|
1058
|
+
if (!['bug', 'suggestion', 'other', 'poll'].includes(type)) {
|
|
1014
1059
|
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
1015
|
-
res.end(JSON.stringify({ error: 'invalid type', expect: 'bug|suggestion|other' }))
|
|
1060
|
+
res.end(JSON.stringify({ error: 'invalid type', expect: 'bug|suggestion|other|poll' }))
|
|
1016
1061
|
return
|
|
1017
1062
|
}
|
|
1063
|
+
const pollVote = type === 'poll' ? validatePollVote(payload) : null
|
|
1064
|
+
if (pollVote?.error) {
|
|
1065
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
1066
|
+
res.end(JSON.stringify({ error: pollVote.error }))
|
|
1067
|
+
return
|
|
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 })
|
|
1018
1072
|
if (!message) {
|
|
1019
1073
|
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
1020
1074
|
res.end(JSON.stringify({ error: 'message required' }))
|
|
@@ -1051,7 +1105,8 @@ function serveFeedback(req, res, url) {
|
|
|
1051
1105
|
contact: contact || undefined,
|
|
1052
1106
|
appVersion: appVersion || 'unknown',
|
|
1053
1107
|
gatewayVersion: VERSION,
|
|
1054
|
-
clientIp: maskIp(ip)
|
|
1108
|
+
clientIp: maskIp(ip),
|
|
1109
|
+
...(pollVote || {})
|
|
1055
1110
|
}),
|
|
1056
1111
|
signal: AbortSignal.timeout(8000)
|
|
1057
1112
|
}).then(async (r) => {
|
|
@@ -1091,6 +1146,24 @@ function serveStatic(req, res, url) {
|
|
|
1091
1146
|
}
|
|
1092
1147
|
if (pathname === '/') pathname = '/index.html'
|
|
1093
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
|
+
}
|
|
1094
1167
|
// 兼容旧版 App(版本比较不认 -rc): 无 local 参数的请求把 0.5.2-rc.1 显示为 0.5.2,
|
|
1095
1168
|
// 引导升级到新 APK; 新 App 带 ?local= 拿到真实 rc 版本, 不会循环提示。
|
|
1096
1169
|
if (pathname === '/update.json') {
|
|
@@ -1468,6 +1541,45 @@ function fsFile(req, res, url) {
|
|
|
1468
1541
|
stream.pipe(res)
|
|
1469
1542
|
}
|
|
1470
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
|
+
|
|
1471
1583
|
function fsValidName(name) {
|
|
1472
1584
|
if (typeof name !== 'string') return false
|
|
1473
1585
|
if (!name || name === '.' || name === '..') return false
|
|
@@ -1931,6 +2043,7 @@ function serveFs(req, res, url) {
|
|
|
1931
2043
|
|
|
1932
2044
|
if (sub === '/list') return fsList(req, res, url)
|
|
1933
2045
|
if (sub === '/file') return fsFile(req, res, url)
|
|
2046
|
+
if (sub === '/preview') return fsPreview(req, res, url)
|
|
1934
2047
|
if (sub === '/mkdir') return fsMkdir(req, res, url)
|
|
1935
2048
|
if (sub === '/upload-probe') return fsUploadProbe(req, res, url)
|
|
1936
2049
|
if (sub === '/upload-control') return fsUploadControl(req, res, url)
|
|
@@ -2194,7 +2307,9 @@ async function serveHealth(res) {
|
|
|
2194
2307
|
|
|
2195
2308
|
function lanAddresses() {
|
|
2196
2309
|
const out = []
|
|
2197
|
-
|
|
2310
|
+
let groups
|
|
2311
|
+
try { groups = Object.values(os.networkInterfaces()) } catch { return out }
|
|
2312
|
+
for (const infos of groups) {
|
|
2198
2313
|
for (const info of infos || []) {
|
|
2199
2314
|
if (info.family === 'IPv4' && !info.internal) out.push(info.address)
|
|
2200
2315
|
}
|
package/index.mjs
CHANGED
|
@@ -71,7 +71,9 @@ let dshListen = { host: '127.0.0.1', port: 3080 }
|
|
|
71
71
|
|
|
72
72
|
function lanIPs() {
|
|
73
73
|
const out = []
|
|
74
|
-
|
|
74
|
+
let groups
|
|
75
|
+
try { groups = Object.values(networkInterfaces()) } catch { return out }
|
|
76
|
+
for (const list of groups) {
|
|
75
77
|
for (const it of list ?? []) {
|
|
76
78
|
if (it.family === 'IPv4' && !it.internal) out.push(it.address)
|
|
77
79
|
}
|
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",
|