dsh-remote-plugin 0.6.2 → 0.6.4
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/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +12 -2
- package/index.mjs +190 -20
- package/package.json +1 -1
- package/public/admin.html +32 -4
- package/public/admin.js +69 -1
- package/public/app.js +59 -46
- package/public/desktop/desktop.css +10 -0
- package/public/desktop/desktop.html +1 -0
- package/public/desktop/desktop.js +1 -1
- package/public/index.html +15 -3
- package/public/md.js +115 -0
- package/public/styles.css +7 -0
- package/public/update.json +12 -4
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
Binary file
|
package/gateway.cjs
CHANGED
|
@@ -1653,10 +1653,20 @@ function proxyApi(req, res, url) {
|
|
|
1653
1653
|
}
|
|
1654
1654
|
|
|
1655
1655
|
// ---------- 其它 ----------
|
|
1656
|
-
function serveHealth(res) {
|
|
1656
|
+
async function serveHealth(res) {
|
|
1657
|
+
let upstreamOk = false
|
|
1658
|
+
try {
|
|
1659
|
+
const ctrl = new AbortController()
|
|
1660
|
+
const timer = setTimeout(() => ctrl.abort(), 2000)
|
|
1661
|
+
const probe = await fetch(UPSTREAM.origin + '/healthz', { signal: ctrl.signal, cache: 'no-store' })
|
|
1662
|
+
clearTimeout(timer)
|
|
1663
|
+
upstreamOk = probe.ok
|
|
1664
|
+
} catch {
|
|
1665
|
+
upstreamOk = false
|
|
1666
|
+
}
|
|
1657
1667
|
cors(res)
|
|
1658
1668
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
1659
|
-
res.end(JSON.stringify({ ok: true, service: 'dsh-remote', version: VERSION, upstream: UPSTREAM.origin }))
|
|
1669
|
+
res.end(JSON.stringify({ ok: true, service: 'dsh-remote', version: VERSION, pid: process.pid, upstream: UPSTREAM.origin, upstreamOk }))
|
|
1660
1670
|
}
|
|
1661
1671
|
|
|
1662
1672
|
function lanAddresses() {
|
package/index.mjs
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* 网关不可用时回退到插件模式主机状态
|
|
6
6
|
* 浏览器侧入口由 client half 注册在 DSH 原生侧边栏(见 client.js)。
|
|
7
7
|
*/
|
|
8
|
-
import { spawn } from 'node:child_process'
|
|
9
|
-
import { createReadStream, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { execFileSync, spawn } from 'node:child_process'
|
|
9
|
+
import { appendFileSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs'
|
|
10
10
|
import { stat } from 'node:fs/promises'
|
|
11
|
+
import net from 'node:net'
|
|
11
12
|
import { homedir, hostname, networkInterfaces } from 'node:os'
|
|
12
13
|
import { dirname, extname, normalize, resolve } from 'node:path'
|
|
13
14
|
import { fileURLToPath } from 'node:url'
|
|
@@ -20,8 +21,24 @@ const PUBLIC_DIR = fileURLToPath(new URL('./public/', import.meta.url))
|
|
|
20
21
|
const INDEX_FILE = 'index.html'
|
|
21
22
|
const GATEWAY_SCRIPT = fileURLToPath(new URL('./gateway.cjs', import.meta.url))
|
|
22
23
|
const gatewayInstalled = existsSync(GATEWAY_SCRIPT)
|
|
23
|
-
// 本地网关管理 API 代理:
|
|
24
|
-
|
|
24
|
+
// 本地网关管理 API 代理: 让插件抽屉显示与网关管理页完全一致的数据。
|
|
25
|
+
// 端口读取优先级: DSH_REMOTE_GATEWAY_PORT > ~/.dsh-remote/gateway-port > 8787
|
|
26
|
+
function gatewayPortFile() { return `${homedir()}/.dsh-remote/gateway-port` }
|
|
27
|
+
|
|
28
|
+
function readGatewayPort() {
|
|
29
|
+
const valid = (v) => /^\d+$/.test(String(v)) && Number(v) >= 1 && Number(v) <= 65535
|
|
30
|
+
const envPort = process.env.DSH_REMOTE_GATEWAY_PORT
|
|
31
|
+
if (valid(envPort)) return String(Number(envPort))
|
|
32
|
+
try {
|
|
33
|
+
const filePort = readFileSync(gatewayPortFile(), 'utf8').trim()
|
|
34
|
+
if (valid(filePort)) return String(Number(filePort))
|
|
35
|
+
} catch {}
|
|
36
|
+
return '8787'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function gatewayBase() {
|
|
40
|
+
return (process.env.DSH_REMOTE_GATEWAY || `http://127.0.0.1:${readGatewayPort()}`).replace(/\/+$/, '')
|
|
41
|
+
}
|
|
25
42
|
|
|
26
43
|
function gatewayToken() {
|
|
27
44
|
if (process.env.DSH_REMOTE_TOKEN) return process.env.DSH_REMOTE_TOKEN
|
|
@@ -95,7 +112,7 @@ async function proxyGateway(path, method, body) {
|
|
|
95
112
|
const token = gatewayToken()
|
|
96
113
|
if (!token) return null
|
|
97
114
|
try {
|
|
98
|
-
const res = await fetch(`${
|
|
115
|
+
const res = await fetch(`${gatewayBase()}${path}`, {
|
|
99
116
|
method,
|
|
100
117
|
headers: {
|
|
101
118
|
authorization: `Bearer ${token}`,
|
|
@@ -126,11 +143,78 @@ function runExit(cmd, args) {
|
|
|
126
143
|
})
|
|
127
144
|
}
|
|
128
145
|
|
|
146
|
+
/** 127.0.0.1 端口占用预检: 能连上=被占用, 连接被拒/超时=可用。 */
|
|
147
|
+
function portInUse(port) {
|
|
148
|
+
return new Promise((resolvePromise) => {
|
|
149
|
+
const sock = net.connect({ host: '127.0.0.1', port: Number(port) })
|
|
150
|
+
let done = false
|
|
151
|
+
const finish = (used) => {
|
|
152
|
+
if (done) return
|
|
153
|
+
done = true
|
|
154
|
+
sock.destroy()
|
|
155
|
+
resolvePromise(used)
|
|
156
|
+
}
|
|
157
|
+
sock.once('connect', () => finish(true))
|
|
158
|
+
sock.once('error', () => finish(false))
|
|
159
|
+
sock.setTimeout(800, () => finish(false))
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
129
163
|
async function gatewayRunning() {
|
|
130
164
|
try {
|
|
131
|
-
const res = await fetch(`${
|
|
132
|
-
|
|
165
|
+
const res = await fetch(`${gatewayBase()}/health`, { signal: AbortSignal.timeout(3000) })
|
|
166
|
+
if (!res.ok) return { running: false }
|
|
167
|
+
const data = await res.json().catch(() => ({}))
|
|
168
|
+
return {
|
|
169
|
+
running: true,
|
|
170
|
+
pid: Number(data.pid) || 0,
|
|
171
|
+
upstream: typeof data.upstream === 'string' ? data.upstream : '',
|
|
172
|
+
upstreamOk: data.upstreamOk === true,
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
return { running: false }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function gatewayPidFile() { return `${homedir()}/.dsh-remote/plugin-gateway.pid` }
|
|
180
|
+
|
|
181
|
+
function readGatewayPid() {
|
|
182
|
+
try {
|
|
183
|
+
const pid = Number(readFileSync(gatewayPidFile(), 'utf8').trim())
|
|
184
|
+
return Number.isFinite(pid) && pid > 0 ? pid : 0
|
|
133
185
|
} catch {
|
|
186
|
+
return 0
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function writeGatewayPid(pid) {
|
|
191
|
+
try {
|
|
192
|
+
mkdirSync(`${homedir()}/.dsh-remote`, { recursive: true })
|
|
193
|
+
writeFileSync(gatewayPidFile(), String(pid) + '\n')
|
|
194
|
+
} catch {}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function logGateway(msg) {
|
|
198
|
+
try {
|
|
199
|
+
mkdirSync(`${homedir()}/.dsh-remote`, { recursive: true })
|
|
200
|
+
appendFileSync(`${homedir()}/.dsh-remote/plugin-gateway.log`, `[${new Date().toISOString()}] ${msg}\n`)
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function killGateway(health) {
|
|
205
|
+
const pid = (health && Number(health.pid)) || readGatewayPid()
|
|
206
|
+
if (!pid) return false
|
|
207
|
+
try {
|
|
208
|
+
if (process.platform === 'win32') {
|
|
209
|
+
await runExit('taskkill', ['/F', '/PID', String(pid)])
|
|
210
|
+
} else {
|
|
211
|
+
process.kill(pid)
|
|
212
|
+
}
|
|
213
|
+
logGateway('已停止旧网关 PID=' + pid)
|
|
214
|
+
await sleep(300)
|
|
215
|
+
return true
|
|
216
|
+
} catch (e) {
|
|
217
|
+
logGateway('停止旧网关失败 PID=' + pid + ' ' + (e?.message || String(e)))
|
|
134
218
|
return false
|
|
135
219
|
}
|
|
136
220
|
}
|
|
@@ -157,7 +241,9 @@ function setGatewayEnabled(on) {
|
|
|
157
241
|
|
|
158
242
|
/** 启动随插件分发的 gateway.cjs; 已运行则直接返回。 */
|
|
159
243
|
async function startGateway() {
|
|
160
|
-
|
|
244
|
+
const upstream = `http://${dshListen.host}:${dshListen.port}`
|
|
245
|
+
const health = await gatewayRunning()
|
|
246
|
+
if (health.running) {
|
|
161
247
|
setGatewayEnabled(true)
|
|
162
248
|
return { ok: true, running: true, started: false }
|
|
163
249
|
}
|
|
@@ -165,7 +251,12 @@ async function startGateway() {
|
|
|
165
251
|
if (!existsSync(script)) {
|
|
166
252
|
return { ok: false, running: false, error: '插件包缺少 gateway.cjs, 请升级插件' }
|
|
167
253
|
}
|
|
168
|
-
const port =
|
|
254
|
+
const port = readGatewayPort()
|
|
255
|
+
if (await portInUse(port)) {
|
|
256
|
+
logGateway(`端口 ${port} 已被占用, 拒绝启动`)
|
|
257
|
+
return { ok: false, running: false, error: `端口 ${port} 已被占用,请在插件页修改网关端口后重试` }
|
|
258
|
+
}
|
|
259
|
+
logGateway('启动网关, 端口: ' + port + ', 上游: ' + upstream)
|
|
169
260
|
|
|
170
261
|
// 首选 systemd-run: 网关成为独立 user 单元, DSH 重启/升级不会连带杀掉它
|
|
171
262
|
let sysd = false
|
|
@@ -173,10 +264,16 @@ async function startGateway() {
|
|
|
173
264
|
await runExit('systemctl', ['--user', 'reset-failed', 'dsh-remote-gateway'])
|
|
174
265
|
sysd = (await runExit('systemd-run', [
|
|
175
266
|
'--user', '--unit=dsh-remote-gateway', '--service-type=exec',
|
|
176
|
-
'--setenv=PORT=' + port, '--setenv=HOST=0.0.0.0',
|
|
267
|
+
'--setenv=PORT=' + port, '--setenv=HOST=0.0.0.0', '--setenv=DSH_UPSTREAM=' + upstream,
|
|
177
268
|
'--', process.execPath, script,
|
|
178
269
|
])) === 0
|
|
179
270
|
} catch {}
|
|
271
|
+
if (sysd) {
|
|
272
|
+
try {
|
|
273
|
+
const pid = Number(execFileSync('systemctl', ['--user', 'show', '-p', 'MainPID', '--value', 'dsh-remote-gateway'], { encoding: 'utf8' }).trim())
|
|
274
|
+
if (Number.isFinite(pid) && pid > 1) writeGatewayPid(pid)
|
|
275
|
+
} catch {}
|
|
276
|
+
}
|
|
180
277
|
|
|
181
278
|
// 无 systemd 的机器回退: detached 子进程
|
|
182
279
|
if (!sysd) {
|
|
@@ -188,14 +285,17 @@ async function startGateway() {
|
|
|
188
285
|
cwd: dirname(script),
|
|
189
286
|
detached: true,
|
|
190
287
|
stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'],
|
|
191
|
-
env: { ...process.env, PORT: port },
|
|
288
|
+
env: { ...process.env, PORT: port, DSH_UPSTREAM: upstream },
|
|
192
289
|
})
|
|
193
290
|
child.unref()
|
|
291
|
+
writeGatewayPid(child.pid)
|
|
194
292
|
}
|
|
195
293
|
// 最多等 4 秒; 超过可能是端口冲突或首次初始化, 前端稍后刷新即可
|
|
196
294
|
for (let i = 0; i < 16; i++) {
|
|
197
295
|
await sleep(250)
|
|
198
|
-
|
|
296
|
+
const h = await gatewayRunning()
|
|
297
|
+
if (h.running) {
|
|
298
|
+
if (h.pid) writeGatewayPid(h.pid)
|
|
199
299
|
setGatewayEnabled(true)
|
|
200
300
|
return { ok: true, running: true, started: true }
|
|
201
301
|
}
|
|
@@ -210,9 +310,24 @@ function ensureGateway() {
|
|
|
210
310
|
if (ensurePromise) return ensurePromise
|
|
211
311
|
ensurePromise = (async () => {
|
|
212
312
|
try {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
313
|
+
const health = await gatewayRunning()
|
|
314
|
+
if (!health.running) {
|
|
315
|
+
const out = await startGateway()
|
|
316
|
+
return !!out.running
|
|
317
|
+
}
|
|
318
|
+
const upstream = `http://${dshListen.host}:${dshListen.port}`
|
|
319
|
+
const oldUpstream = health.upstream || ''
|
|
320
|
+
if (health.upstreamOk === false || (oldUpstream && oldUpstream !== upstream) || (!oldUpstream && upstream)) {
|
|
321
|
+
logGateway(`网关上游需刷新: 旧=${oldUpstream || '?'} 新=${upstream}`)
|
|
322
|
+
await killGateway(health)
|
|
323
|
+
for (let i = 0; i < 10; i++) {
|
|
324
|
+
if (!(await gatewayRunning()).running) break
|
|
325
|
+
await sleep(200)
|
|
326
|
+
}
|
|
327
|
+
const out = await startGateway()
|
|
328
|
+
return !!out.running
|
|
329
|
+
}
|
|
330
|
+
return true
|
|
216
331
|
} finally {
|
|
217
332
|
setTimeout(() => { ensurePromise = null }, 4000)
|
|
218
333
|
}
|
|
@@ -225,7 +340,7 @@ async function stopGateway() {
|
|
|
225
340
|
const token = gatewayToken()
|
|
226
341
|
if (!token) return { ok: false, running: false, error: '找不到 ~/.dsh-remote/token, 无法认证网关' }
|
|
227
342
|
try {
|
|
228
|
-
const res = await fetch(`${
|
|
343
|
+
const res = await fetch(`${gatewayBase()}/admin/api/shutdown`, {
|
|
229
344
|
method: 'POST',
|
|
230
345
|
headers: { authorization: `Bearer ${token}`, 'x-dsh-remote-client': 'admin' },
|
|
231
346
|
signal: AbortSignal.timeout(2000),
|
|
@@ -271,7 +386,7 @@ function statsSend(session, event) {
|
|
|
271
386
|
const prev = statsQueues.get(session.id) || Promise.resolve()
|
|
272
387
|
const next = prev.then(async () => {
|
|
273
388
|
try {
|
|
274
|
-
await fetch(`${
|
|
389
|
+
await fetch(`${gatewayBase()}/stats/ingest`, {
|
|
275
390
|
method: 'POST',
|
|
276
391
|
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
277
392
|
body: JSON.stringify(payload),
|
|
@@ -357,7 +472,7 @@ async function serveStatic(req, res, ctx) {
|
|
|
357
472
|
if (proxied !== null) {
|
|
358
473
|
sendJson(res, proxied.status, proxied.json)
|
|
359
474
|
} else {
|
|
360
|
-
sendJson(res, 502, { ok: false, error: '本地网关不可用, Token
|
|
475
|
+
sendJson(res, 502, { ok: false, error: '本地网关不可用, Token 统计需要网关运行' })
|
|
361
476
|
}
|
|
362
477
|
return
|
|
363
478
|
}
|
|
@@ -409,15 +524,70 @@ async function serveStatic(req, res, ctx) {
|
|
|
409
524
|
if (proxied !== null) {
|
|
410
525
|
sendJson(res, proxied.status, proxied.json)
|
|
411
526
|
} else {
|
|
412
|
-
sendJson(res, 502, { ok: false, error: '本地网关不可用,
|
|
527
|
+
sendJson(res, 502, { ok: false, error: '本地网关不可用, 设备管理需在网关模式操作' })
|
|
528
|
+
}
|
|
529
|
+
return
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// 网关端口配置(仅插件内嵌页使用): GET 当前生效端口 / PUT 修改端口
|
|
533
|
+
if (pathname === `${MOUNT}/admin/api/config`) {
|
|
534
|
+
if (req.method === 'GET') {
|
|
535
|
+
const h = await gatewayRunning()
|
|
536
|
+
sendJson(res, 200, {
|
|
537
|
+
ok: true,
|
|
538
|
+
port: Number(readGatewayPort()),
|
|
539
|
+
running: h.running,
|
|
540
|
+
source: process.env.DSH_REMOTE_GATEWAY_PORT ? 'env' : existsSync(gatewayPortFile()) ? 'file' : 'default',
|
|
541
|
+
})
|
|
542
|
+
return
|
|
413
543
|
}
|
|
544
|
+
if (req.method === 'PUT') {
|
|
545
|
+
let body = {}
|
|
546
|
+
try {
|
|
547
|
+
body = JSON.parse((await readBody(req, 4096)) || '{}')
|
|
548
|
+
} catch {}
|
|
549
|
+
const port = Number(body.port)
|
|
550
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
551
|
+
sendJson(res, 400, { ok: false, error: '端口必须是 1-65535 的整数' })
|
|
552
|
+
return
|
|
553
|
+
}
|
|
554
|
+
const oldPort = Number(readGatewayPort())
|
|
555
|
+
try {
|
|
556
|
+
mkdirSync(`${homedir()}/.dsh-remote`, { recursive: true })
|
|
557
|
+
writeFileSync(gatewayPortFile(), String(port) + '\n')
|
|
558
|
+
} catch (e) {
|
|
559
|
+
sendJson(res, 500, { ok: false, error: '写入端口配置失败: ' + (e?.message || String(e)) })
|
|
560
|
+
return
|
|
561
|
+
}
|
|
562
|
+
const effectivePort = Number(readGatewayPort())
|
|
563
|
+
if (effectivePort !== oldPort) {
|
|
564
|
+
const h = await gatewayRunning()
|
|
565
|
+
if (h.running) {
|
|
566
|
+
await killGateway(h)
|
|
567
|
+
for (let i = 0; i < 10; i++) {
|
|
568
|
+
if (!(await gatewayRunning()).running) break
|
|
569
|
+
await sleep(200)
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
let running = (await gatewayRunning()).running
|
|
574
|
+
if (gatewayAutostart()) {
|
|
575
|
+
const startOut = await startGateway()
|
|
576
|
+
running = !!startOut.running || (await gatewayRunning()).running
|
|
577
|
+
}
|
|
578
|
+
sendJson(res, 200, { ok: true, port: Number(port), effectivePort, running })
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
res.writeHead(405, { allow: 'GET, PUT' })
|
|
582
|
+
res.end()
|
|
414
583
|
return
|
|
415
584
|
}
|
|
416
585
|
|
|
417
586
|
// 本地网关开关(仅插件内嵌页使用): GET 状态 / POST {action:'start'|'stop'}
|
|
418
587
|
if (pathname === `${MOUNT}/admin/api/gateway`) {
|
|
419
588
|
if (req.method === 'GET') {
|
|
420
|
-
|
|
589
|
+
const h = await gatewayRunning()
|
|
590
|
+
sendJson(res, 200, { ok: true, running: h.running, upstream: h.upstream || '', upstreamOk: h.upstreamOk === true })
|
|
421
591
|
return
|
|
422
592
|
}
|
|
423
593
|
if (req.method === 'POST') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-plugin",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
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
|
@@ -79,6 +79,11 @@
|
|
|
79
79
|
.token-row code { flex: none; width: 100%; background: var(--dsr-bg); border: 1px solid var(--dsr-line); border-radius: 8px; padding: 8px 10px; font-size: 12px; white-space: nowrap; overflow-x: auto; }
|
|
80
80
|
.token-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
81
81
|
.token-actions .mini-btn { flex: 1 1 auto; text-align: center; padding: 6px 8px; font-size: 12.5px; }
|
|
82
|
+
.gateway-port-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 14px; padding: 10px 12px; background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: var(--dsr-radius); }
|
|
83
|
+
.gateway-port-row label { font-size: 12px; color: var(--dsr-muted); flex: none; }
|
|
84
|
+
.gateway-port-row input { width: 92px; background: var(--dsr-bg); color: var(--dsr-text); border: 1px solid var(--dsr-line); border-radius: 8px; padding: 6px 8px; font: inherit; font-size: 13px; outline: none; }
|
|
85
|
+
.gateway-port-row .muted { font-size: 12px; }
|
|
86
|
+
.gateway-port-row .mini-btn { margin-left: auto; }
|
|
82
87
|
.conn-badge { white-space: nowrap; }
|
|
83
88
|
#btn-close-drawer { white-space: nowrap; }
|
|
84
89
|
@media (max-width: 720px) {
|
|
@@ -229,6 +234,13 @@
|
|
|
229
234
|
</div>
|
|
230
235
|
</div>
|
|
231
236
|
|
|
237
|
+
<div id="gateway-port-row" class="gateway-port-row hidden">
|
|
238
|
+
<label data-i18n="gatewayPort">网关端口</label>
|
|
239
|
+
<input id="gateway-port-input" type="number" min="1" max="65535" inputmode="numeric" placeholder="8787">
|
|
240
|
+
<span id="gateway-port-current" class="muted">—</span>
|
|
241
|
+
<button id="btn-save-port" class="mini-btn" data-i18n="savePort">保存</button>
|
|
242
|
+
</div>
|
|
243
|
+
|
|
232
244
|
<div id="pair-box" class="pair-box hidden">
|
|
233
245
|
<div class="pair-title" data-i18n="pairTitle">手机 App 扫码配对</div>
|
|
234
246
|
<div class="pair-qr" id="pair-qr"></div>
|
|
@@ -307,6 +319,14 @@
|
|
|
307
319
|
'stopGateway': '停止网关',
|
|
308
320
|
'starting': '启动中…',
|
|
309
321
|
'stopping': '停止中…',
|
|
322
|
+
'gatewayPort': '网关端口',
|
|
323
|
+
'gatewayPort.current': '当前 {port}',
|
|
324
|
+
'savePort': '保存',
|
|
325
|
+
'toast.portInvalid': '端口必须是 1-65535 的整数',
|
|
326
|
+
'toast.portSaved': '端口已更新,网关已切换至 {port}',
|
|
327
|
+
'toast.portSavedIdle': '端口已保存,网关未运行,下次启动生效',
|
|
328
|
+
'toast.portEnv': '环境变量优先,当前仍使用 {port}',
|
|
329
|
+
'toast.portFailedMsg': '端口保存失败:{msg}',
|
|
310
330
|
'qrCode': '二维码',
|
|
311
331
|
'rotateToken': '轮换令牌',
|
|
312
332
|
'copyToken': '复制令牌',
|
|
@@ -324,7 +344,7 @@
|
|
|
324
344
|
'stat.updateAvailable': '{version} 可用', 'stat.currentV': '当前 v{version}',
|
|
325
345
|
'stat.download': '去下载', 'stat.embedded': 'DSH 内嵌 · 免网关',
|
|
326
346
|
'stat.updateCheck': '更新检查: {error}', 'stat.latest': '已是最新(来源检查)', 'stat.notChecked': '未检查更新',
|
|
327
|
-
'stat.hostIP': '主机 IP · {hostname}', 'stat.ipSep': '、', 'stat.phoneGateway': ' (手机连
|
|
347
|
+
'stat.hostIP': '主机 IP · {hostname}', 'stat.ipSep': '、', 'stat.phoneGateway': ' (手机连 {port} 网关)', 'stat.phoneThis': ' (手机连这个地址)',
|
|
328
348
|
'stat.reachable': '可达', 'stat.unreachable': '不可达', 'stat.dshUpstream': 'DSH 上游 {url}',
|
|
329
349
|
'stat.devicesOnline': '设备在线 / 累计', 'stat.totalRequests': '总请求数', 'stat.authFailures': '认证失败',
|
|
330
350
|
'stat.uptime': '运行时长 · {host}:{port}',
|
|
@@ -338,7 +358,7 @@
|
|
|
338
358
|
'stats.output': '输出',
|
|
339
359
|
'stats.peak': '高峰', 'stats.off': '空闲',
|
|
340
360
|
'stats.days': '近 {n} 天',
|
|
341
|
-
'stats.gatewayDown': '
|
|
361
|
+
'stats.gatewayDown': '统计需要网关运行', 'stats.empty': '暂无统计,产生会话后自动聚合',
|
|
342
362
|
'stats.note': '注:本数据仅在使用 DeepSeek 官方 API 时估算;基于 token 计算,与官网账单可能有出入,一切以官网为准。统计自 2026-08-17 定价生效日起。',
|
|
343
363
|
'unit.sec': ' 秒', 'unit.min': ' 分钟', 'unit.hour': ' 小时 ', 'unit.minShort': ' 分', 'unit.day': ' 天 ',
|
|
344
364
|
'device.installedNotRunning': '网关已安装 · 当前未运行', 'device.noGatewayBinary': '未检测到网关程序',
|
|
@@ -382,6 +402,14 @@
|
|
|
382
402
|
'stopGateway': 'Stop gateway',
|
|
383
403
|
'starting': 'Starting…',
|
|
384
404
|
'stopping': 'Stopping…',
|
|
405
|
+
'gatewayPort': 'Gateway port',
|
|
406
|
+
'gatewayPort.current': 'Current {port}',
|
|
407
|
+
'savePort': 'Save',
|
|
408
|
+
'toast.portInvalid': 'Port must be an integer from 1 to 65535',
|
|
409
|
+
'toast.portSaved': 'Port updated, gateway switched to {port}',
|
|
410
|
+
'toast.portSavedIdle': 'Port saved; gateway not running, takes effect on next start',
|
|
411
|
+
'toast.portEnv': 'Environment variable takes priority, current port remains {port}',
|
|
412
|
+
'toast.portFailedMsg': 'Failed to save port: {msg}',
|
|
385
413
|
'qrCode': 'QR code',
|
|
386
414
|
'rotateToken': 'Rotate token',
|
|
387
415
|
'copyToken': 'Copy token',
|
|
@@ -399,7 +427,7 @@
|
|
|
399
427
|
'stat.updateAvailable': 'v{version} available', 'stat.currentV': 'Current v{version}',
|
|
400
428
|
'stat.download': 'Download', 'stat.embedded': 'Embedded in DSH · no gateway',
|
|
401
429
|
'stat.updateCheck': 'Update check: {error}', 'stat.latest': 'Up to date (source check)', 'stat.notChecked': 'Not checked',
|
|
402
|
-
'stat.hostIP': 'Host IP · {hostname}', 'stat.ipSep': ', ', 'stat.phoneGateway': ' (phone connects to gateway
|
|
430
|
+
'stat.hostIP': 'Host IP · {hostname}', 'stat.ipSep': ', ', 'stat.phoneGateway': ' (phone connects to gateway {port})', 'stat.phoneThis': ' (phone connects to this address)',
|
|
403
431
|
'stat.reachable': 'Reachable', 'stat.unreachable': 'Unreachable', 'stat.dshUpstream': 'DSH upstream {url}',
|
|
404
432
|
'stat.devicesOnline': 'Devices online / total', 'stat.totalRequests': 'Total requests', 'stat.authFailures': 'Auth failures',
|
|
405
433
|
'stat.uptime': 'Uptime · {host}:{port}',
|
|
@@ -413,7 +441,7 @@
|
|
|
413
441
|
'stats.output': 'Output',
|
|
414
442
|
'stats.peak': 'Peak', 'stats.off': 'Off-peak',
|
|
415
443
|
'stats.days': 'Last {n} days',
|
|
416
|
-
'stats.gatewayDown': 'Stats require the gateway
|
|
444
|
+
'stats.gatewayDown': 'Stats require the gateway to be running', 'stats.empty': 'No stats yet — they aggregate as sessions happen',
|
|
417
445
|
'stats.note': 'Note: estimates assume the official DeepSeek API. Token-based calculation may differ from the official bill; always defer to deepseek.com. Stats start from the 2026-08-17 pricing date.',
|
|
418
446
|
'unit.sec': 's', 'unit.min': 'min', 'unit.hour': 'h ', 'unit.minShort': 'm', 'unit.day': 'd ',
|
|
419
447
|
'device.installedNotRunning': 'Gateway installed · not running', 'device.noGatewayBinary': 'Gateway binary not found',
|
package/public/admin.js
CHANGED
|
@@ -24,6 +24,8 @@ let gatewayBusy = false
|
|
|
24
24
|
let shownToken = token
|
|
25
25
|
let lastState = null
|
|
26
26
|
let qrShown = false
|
|
27
|
+
let gatewayPort = 8787
|
|
28
|
+
let gatewayPortLoaded = false
|
|
27
29
|
|
|
28
30
|
const STATS_API = pluginMode ? API + '/stats' : '/stats'
|
|
29
31
|
let statsTimer = null
|
|
@@ -64,6 +66,26 @@ async function loadStats() {
|
|
|
64
66
|
}
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
async function loadGatewayConfig() {
|
|
70
|
+
if (!pluginMode) return
|
|
71
|
+
try {
|
|
72
|
+
const res = await fetch(`${API}/config`, {
|
|
73
|
+
headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' }
|
|
74
|
+
})
|
|
75
|
+
const out = await res.json().catch(() => ({}))
|
|
76
|
+
if (out.ok) {
|
|
77
|
+
gatewayPort = Number(out.port) || 8787
|
|
78
|
+
gatewayPortLoaded = true
|
|
79
|
+
const row = $('gateway-port-row')
|
|
80
|
+
const input = $('gateway-port-input')
|
|
81
|
+
if (row) row.classList.toggle('hidden', !pluginMode)
|
|
82
|
+
if (input && document.activeElement !== input) input.value = gatewayPort
|
|
83
|
+
const cur = $('gateway-port-current')
|
|
84
|
+
if (cur) cur.textContent = t('gatewayPort.current', { port: gatewayPort })
|
|
85
|
+
}
|
|
86
|
+
} catch {}
|
|
87
|
+
}
|
|
88
|
+
|
|
67
89
|
function renderStats(days) {
|
|
68
90
|
if (!days.length) {
|
|
69
91
|
$('stats-cards').innerHTML = ''
|
|
@@ -191,6 +213,12 @@ function render(st) {
|
|
|
191
213
|
? t(gatewayRunning ? 'stopping' : 'starting')
|
|
192
214
|
: t(gatewayRunning ? 'stopGateway' : 'startGateway')
|
|
193
215
|
$('btn-gateway').disabled = gatewayBusy
|
|
216
|
+
// 网关端口配置: 仅插件内嵌页提供
|
|
217
|
+
$('gateway-port-row').classList.toggle('hidden', !pluginMode || !gatewayPortLoaded)
|
|
218
|
+
if (pluginMode) {
|
|
219
|
+
const cur = $('gateway-port-current')
|
|
220
|
+
if (cur) cur.textContent = t('gatewayPort.current', { port: gatewayPort })
|
|
221
|
+
}
|
|
194
222
|
const upOk = st.upstream.reachable
|
|
195
223
|
const hostIPs = (st.lanIPs || []).join(t('stat.ipSep')) || '127.0.0.1'
|
|
196
224
|
const latestHtml = st.latest?.newer
|
|
@@ -199,7 +227,7 @@ function render(st) {
|
|
|
199
227
|
$('stats').innerHTML = `
|
|
200
228
|
<div class="stat-card"><div class="v">v${st.version}</div><div class="k">${t(isPlugin ? 'stat.pluginVersion' : 'stat.gatewayVersion')}</div></div>
|
|
201
229
|
<div class="stat-card ${st.latest?.newer ? 'warn' : 'ok'}">${latestHtml}</div>
|
|
202
|
-
<div class="stat-card ok"><div class="v" style="font-size:13px">${hostIPs}</div><div class="k">${t('stat.hostIP', { hostname: st.hostname })}${isPlugin ? t('stat.phoneGateway') : t('stat.phoneThis')}</div></div>
|
|
230
|
+
<div class="stat-card ok"><div class="v" style="font-size:13px">${hostIPs}</div><div class="k">${t('stat.hostIP', { hostname: st.hostname })}${isPlugin ? t('stat.phoneGateway', { port: gatewayPort }) : t('stat.phoneThis')}</div></div>
|
|
203
231
|
<div class="stat-card ${upOk ? 'ok' : 'warn'}"><div class="v">${t(upOk ? 'stat.reachable' : 'stat.unreachable')}</div><div class="k">${t('stat.dshUpstream', { url: st.upstream.url })}</div></div>
|
|
204
232
|
<div class="stat-card"><div class="v">${st.onlineCount}/${st.deviceCount}</div><div class="k">${t('stat.devicesOnline')}</div></div>
|
|
205
233
|
<div class="stat-card"><div class="v">${st.totalRequests}</div><div class="k">${t('stat.totalRequests')}</div></div>
|
|
@@ -434,6 +462,45 @@ $('btn-gateway').addEventListener('click', async () => {
|
|
|
434
462
|
setTimeout(loadState, 700)
|
|
435
463
|
})
|
|
436
464
|
|
|
465
|
+
$('btn-save-port').addEventListener('click', async () => {
|
|
466
|
+
const input = $('gateway-port-input')
|
|
467
|
+
const raw = input.value.trim()
|
|
468
|
+
const port = Number(raw)
|
|
469
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
470
|
+
toast(t('toast.portInvalid'), 'err')
|
|
471
|
+
return
|
|
472
|
+
}
|
|
473
|
+
const btn = $('btn-save-port')
|
|
474
|
+
const wasRunning = gatewayRunning
|
|
475
|
+
btn.disabled = true
|
|
476
|
+
try {
|
|
477
|
+
const res = await fetch(`${API}/config`, {
|
|
478
|
+
method: 'PUT',
|
|
479
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' },
|
|
480
|
+
body: JSON.stringify({ port })
|
|
481
|
+
})
|
|
482
|
+
const out = await res.json().catch(() => ({}))
|
|
483
|
+
if (out.ok) {
|
|
484
|
+
const saved = Number(out.port) || port
|
|
485
|
+
const effective = Number(out.effectivePort || out.port) || saved
|
|
486
|
+
if (out.effectivePort && effective !== saved) {
|
|
487
|
+
toast(t('toast.portEnv', { port: effective }), 'ok')
|
|
488
|
+
} else if (wasRunning) {
|
|
489
|
+
toast(t('toast.portSaved', { port: effective }), 'ok')
|
|
490
|
+
} else {
|
|
491
|
+
toast(t('toast.portSavedIdle', { port: effective }), 'ok')
|
|
492
|
+
}
|
|
493
|
+
loadGatewayConfig()
|
|
494
|
+
setTimeout(loadState, 800)
|
|
495
|
+
} else {
|
|
496
|
+
toast(out.error || t('toast.portFailedMsg', { msg: res.status }), 'err')
|
|
497
|
+
}
|
|
498
|
+
} catch (e) {
|
|
499
|
+
toast(t('toast.portFailedMsg', { msg: e.message || e }), 'err')
|
|
500
|
+
}
|
|
501
|
+
btn.disabled = false
|
|
502
|
+
})
|
|
503
|
+
|
|
437
504
|
function renderLangBtn() {
|
|
438
505
|
const btn = $('btn-lang')
|
|
439
506
|
if (btn) btn.textContent = I18N.lang === 'zh' ? 'EN' : '中文'
|
|
@@ -521,6 +588,7 @@ function start(showLogin) {
|
|
|
521
588
|
}
|
|
522
589
|
showMain()
|
|
523
590
|
loadState()
|
|
591
|
+
loadGatewayConfig()
|
|
524
592
|
loadStats()
|
|
525
593
|
timer = setInterval(loadState, 5000)
|
|
526
594
|
if (statsTimer) clearInterval(statsTimer)
|
package/public/app.js
CHANGED
|
@@ -275,7 +275,7 @@ async function rpc(method, payload = {}) {
|
|
|
275
275
|
body: JSON.stringify({ type: 'client-request', rpcId: uuid(), method, payload })
|
|
276
276
|
}
|
|
277
277
|
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
278
|
-
opts.signal = AbortSignal.timeout(
|
|
278
|
+
opts.signal = AbortSignal.timeout(45000)
|
|
279
279
|
}
|
|
280
280
|
const res = await fetch(apiUrl('/api/' + method), opts)
|
|
281
281
|
if (res.status === 401) throw new Error('AUTH')
|
|
@@ -1180,8 +1180,19 @@ async function loadHistory(reset) {
|
|
|
1180
1180
|
} catch (e) {
|
|
1181
1181
|
state.history.loading = false
|
|
1182
1182
|
if (e.message === 'AUTH') { authFailure(); return }
|
|
1183
|
-
if (restoreCachedHistory())
|
|
1184
|
-
|
|
1183
|
+
if (restoreCachedHistory()) {
|
|
1184
|
+
toast(t('history.cacheFallback'), 'ok')
|
|
1185
|
+
return
|
|
1186
|
+
}
|
|
1187
|
+
const msg = e.message || t('err.dshError')
|
|
1188
|
+
const box = $('history')
|
|
1189
|
+
if (box && (reset || !state.history.visible.length)) {
|
|
1190
|
+
box.innerHTML = `<div class="empty"><div>${esc(t('history.loadFailed', { msg }))}</div><button type="button" class="mini-btn" id="btn-history-retry" style="margin-top:10px">${esc(t('history.retry'))}</button></div>`
|
|
1191
|
+
const retry = $('btn-history-retry')
|
|
1192
|
+
if (retry) retry.addEventListener('click', () => loadHistory(true))
|
|
1193
|
+
} else {
|
|
1194
|
+
toast(t('history.loadFailed', { msg }), 'err')
|
|
1195
|
+
}
|
|
1185
1196
|
return
|
|
1186
1197
|
}
|
|
1187
1198
|
|
|
@@ -1404,7 +1415,7 @@ function blockHtml(b) {
|
|
|
1404
1415
|
if (!b || typeof b !== 'object') return `<p>${esc(String(b))}</p>`
|
|
1405
1416
|
if ((b.type === 'tool-call' || b.type === 'tool-result') && LS.get('showTools', '1') === '0') return ''
|
|
1406
1417
|
switch (b.type) {
|
|
1407
|
-
case 'text': return `<div>${
|
|
1418
|
+
case 'text': return `<div class="md">${window.mdToHtml ? window.mdToHtml(b.text ?? '') : esc(b.text ?? '')}</div>`
|
|
1408
1419
|
case 'image': return `<img alt="${t('block.image')}" src="data:${esc(b.mediaType || 'image/png')};base64,${esc(b.data || '')}">`
|
|
1409
1420
|
case 'thinking':
|
|
1410
1421
|
case 'reasoning':
|
|
@@ -1418,20 +1429,6 @@ function blockHtml(b) {
|
|
|
1418
1429
|
}
|
|
1419
1430
|
}
|
|
1420
1431
|
|
|
1421
|
-
function renderMarkdown(text) {
|
|
1422
|
-
const parts = String(text ?? '').split(/```/)
|
|
1423
|
-
let out = ''
|
|
1424
|
-
for (let i = 0; i < parts.length; i++) {
|
|
1425
|
-
if (i % 2 === 1) out += `<pre>${esc(parts[i])}</pre>`
|
|
1426
|
-
else out += esc(parts[i])
|
|
1427
|
-
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
1428
|
-
.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
|
|
1429
|
-
.replace(/(?:^|\n)(#{1,4})\s+([^\n]+)/g, (m, h, t) => `\n<b>${t}</b>`)
|
|
1430
|
-
.replace(/\n/g, '<br>')
|
|
1431
|
-
}
|
|
1432
|
-
return out
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
1432
|
function safeJson(v) {
|
|
1436
1433
|
try { return typeof v === 'string' ? v : JSON.stringify(v, null, 2) }
|
|
1437
1434
|
catch { return String(v) }
|
|
@@ -2544,6 +2541,33 @@ function notify(title, body) {
|
|
|
2544
2541
|
} catch {}
|
|
2545
2542
|
}
|
|
2546
2543
|
|
|
2544
|
+
async function sendTestNotification() {
|
|
2545
|
+
if (!CAP?.isNativePlatform?.()) {
|
|
2546
|
+
toast(t('settings.testNotifyUnavailable'), 'err')
|
|
2547
|
+
return
|
|
2548
|
+
}
|
|
2549
|
+
const L = CAP.Plugins?.LocalNotifications
|
|
2550
|
+
if (!L?.schedule) {
|
|
2551
|
+
toast(t('settings.testNotifyUnavailable'), 'err')
|
|
2552
|
+
return
|
|
2553
|
+
}
|
|
2554
|
+
const ok = await ensureNotify()
|
|
2555
|
+
if (!ok) { toast(t('settings.notifyDenied'), 'err'); return }
|
|
2556
|
+
try {
|
|
2557
|
+
await L.schedule({
|
|
2558
|
+
notifications: [{
|
|
2559
|
+
id: 8899,
|
|
2560
|
+
title: 'DSH Remote',
|
|
2561
|
+
body: '测试通知 · Test',
|
|
2562
|
+
schedule: { at: new Date(Date.now() + 3000) }
|
|
2563
|
+
}]
|
|
2564
|
+
})
|
|
2565
|
+
toast(t('settings.testNotifySent'), 'ok')
|
|
2566
|
+
} catch (e) {
|
|
2567
|
+
toast(t('settings.testNotifyFailed', { msg: e?.message || '' }), 'err')
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2547
2571
|
/* ---------------- 后台轮询(Android 前台服务) ---------------- */
|
|
2548
2572
|
function bgBridge() { return window.NativeBackground }
|
|
2549
2573
|
function bgBase() { return (state.server || location.origin || '').replace(/\/+$/, '') }
|
|
@@ -2646,38 +2670,25 @@ function deletePreset(id) {
|
|
|
2646
2670
|
toast(t('presets.deleted'), 'ok')
|
|
2647
2671
|
}
|
|
2648
2672
|
|
|
2649
|
-
/* ---------------- 峰谷计费提醒(
|
|
2650
|
-
const PEAK_REMIND_NOTIFS = [
|
|
2651
|
-
{ id: 8801, hour: 9, periodKey: 'peak0912', enterKey: 'enterPeak' },
|
|
2652
|
-
{ id: 8802, hour: 12, periodKey: 'off1214', enterKey: 'enterOff' },
|
|
2653
|
-
{ id: 8803, hour: 14, periodKey: 'peak1418', enterKey: 'enterPeak' },
|
|
2654
|
-
{ id: 8804, hour: 18, periodKey: 'off1809', enterKey: 'enterOff' },
|
|
2655
|
-
]
|
|
2673
|
+
/* ---------------- 峰谷计费提醒(前台服务进程内定时, 绕开 MIUI 后台限制) ---------------- */
|
|
2656
2674
|
function peakRemindOn() { return LS.get('peakRemind', '0') === '1' }
|
|
2657
2675
|
|
|
2658
2676
|
async function schedulePeakReminders() {
|
|
2659
2677
|
if (!CAP?.isNativePlatform?.()) return false
|
|
2660
|
-
const
|
|
2661
|
-
if (!
|
|
2678
|
+
const b = bgBridge()
|
|
2679
|
+
if (!b?.startPeakReminder) return false
|
|
2662
2680
|
try {
|
|
2663
|
-
|
|
2664
|
-
notifications: PEAK_REMIND_NOTIFS.map(n => ({
|
|
2665
|
-
id: n.id,
|
|
2666
|
-
title: 'DSH Remote',
|
|
2667
|
-
body: `${t('peakRemind.' + n.enterKey)} · ${t('peakRemind.' + n.periodKey)}`,
|
|
2668
|
-
schedule: { every: 'day', on: { hour: n.hour, minute: 0 } },
|
|
2669
|
-
}))
|
|
2670
|
-
})
|
|
2681
|
+
b.startPeakReminder()
|
|
2671
2682
|
return true
|
|
2672
2683
|
} catch { return false }
|
|
2673
2684
|
}
|
|
2674
2685
|
|
|
2675
2686
|
async function cancelPeakReminders() {
|
|
2676
2687
|
if (!CAP?.isNativePlatform?.()) return false
|
|
2677
|
-
const
|
|
2678
|
-
if (!
|
|
2688
|
+
const b = bgBridge()
|
|
2689
|
+
if (!b?.stopPeakReminder) return false
|
|
2679
2690
|
try {
|
|
2680
|
-
|
|
2691
|
+
b.stopPeakReminder()
|
|
2681
2692
|
return true
|
|
2682
2693
|
} catch { return false }
|
|
2683
2694
|
}
|
|
@@ -2784,7 +2795,7 @@ async function decodeQrDataUrl(dataUrl) {
|
|
|
2784
2795
|
/** App 内扫码: 官方 Camera 拍照/相册 + jsQR 本地解码(无 Google ML Kit/GMS 依赖, 国内可用)。
|
|
2785
2796
|
* 冗余路径 1: 系统相机扫 dshremote:// 二维码直接唤起 App(见 bindNativeLinks);
|
|
2786
2797
|
* 冗余路径 2: 设置页手动粘贴令牌。 */
|
|
2787
|
-
async function scanPair() {
|
|
2798
|
+
async function scanPair(source) {
|
|
2788
2799
|
if (!CAP?.isNativePlatform?.()) {
|
|
2789
2800
|
toast(t('scan.browserHint'), 'err')
|
|
2790
2801
|
return
|
|
@@ -2792,17 +2803,17 @@ async function scanPair() {
|
|
|
2792
2803
|
const camera = CAP.Plugins?.Camera
|
|
2793
2804
|
if (!camera?.getPhoto) { toast(t('scan.unsupported'), 'err'); return }
|
|
2794
2805
|
try {
|
|
2795
|
-
|
|
2796
|
-
if (
|
|
2806
|
+
// 显式指定来源绕过 PROMPT: 小米/HyperOS 的 PROMPT 选择器会错乱(选拍照开相册/选相册开相机)
|
|
2807
|
+
if (source === 'CAMERA') {
|
|
2808
|
+
const perm = await camera.requestPermissions?.({ permissions: ['camera'] })
|
|
2809
|
+
if (perm && perm.camera !== 'granted') { toast(t('scan.permissionDenied'), 'err'); return }
|
|
2810
|
+
}
|
|
2797
2811
|
const photo = await camera.getPhoto({
|
|
2798
2812
|
resultType: 'dataUrl',
|
|
2799
|
-
source: '
|
|
2813
|
+
source: source === 'PHOTOS' ? 'PHOTOS' : 'CAMERA',
|
|
2800
2814
|
quality: 85,
|
|
2801
2815
|
correctOrientation: true,
|
|
2802
2816
|
saveToGallery: false,
|
|
2803
|
-
promptLabelHeader: t('scan.promptHeader'),
|
|
2804
|
-
promptLabelPhoto: t('scan.promptPhoto'),
|
|
2805
|
-
promptLabelPicture: t('scan.promptGallery'),
|
|
2806
2817
|
})
|
|
2807
2818
|
if (!photo?.dataUrl) { toast(t('scan.noPhoto'), 'err'); return }
|
|
2808
2819
|
const raw = await decodeQrDataUrl(photo.dataUrl)
|
|
@@ -3020,7 +3031,8 @@ function bindUi() {
|
|
|
3020
3031
|
if (group) { showSettingsPage(group.dataset.settingsGroup); return }
|
|
3021
3032
|
if (e.target.closest('[data-settings-back]')) { showSettingsHome(); return }
|
|
3022
3033
|
})
|
|
3023
|
-
$('btn-scan-
|
|
3034
|
+
$('btn-scan-camera').addEventListener('click', () => scanPair('CAMERA'))
|
|
3035
|
+
$('btn-scan-gallery').addEventListener('click', () => scanPair('PHOTOS'))
|
|
3024
3036
|
$('btn-change-token').addEventListener('click', () => {
|
|
3025
3037
|
const input = prompt(t('token.prompt'), state.token)
|
|
3026
3038
|
if (input && input.trim()) { state.token = input.trim(); LS.set('token', input.trim()); $('token-desc').textContent = t('token.saved'); toast(t('token.savedReconnect'), 'ok'); openStreams(); refreshAll(); syncBgConfig() }
|
|
@@ -3076,6 +3088,7 @@ function bindUi() {
|
|
|
3076
3088
|
}
|
|
3077
3089
|
LS.set('peakRemind', e.target.checked ? '1' : '0')
|
|
3078
3090
|
})
|
|
3091
|
+
$('btn-test-notify').addEventListener('click', sendTestNotification)
|
|
3079
3092
|
// 已开启则启动时重新调度, 防止系统清理后丢失
|
|
3080
3093
|
if (peakRemindOn() && CAP?.isNativePlatform?.()) schedulePeakReminders()
|
|
3081
3094
|
applyBgConfigFromNative()
|
|
@@ -103,6 +103,16 @@ a.ds-btn { text-decoration: none; }
|
|
|
103
103
|
.ds-msg.user { align-self: flex-end; background: var(--dsr-accent-soft); border: 1px solid var(--dsr-accent-line); color: var(--dsr-accent-strong); }
|
|
104
104
|
.ds-msg.assistant { align-self: flex-start; background: var(--dsr-panel); border: 1px solid var(--dsr-line); color: var(--dsr-text); }
|
|
105
105
|
.ds-msg .role { font-size: 10px; color: var(--dsr-muted); margin-bottom: 3px; }
|
|
106
|
+
.ds-msg p { margin: 4px 0; }
|
|
107
|
+
.ds-msg p:first-child { margin-top: 0; } .ds-msg p:last-child { margin-bottom: 0; }
|
|
108
|
+
.ds-msg ul, .ds-msg ol { margin: 4px 0; padding-left: 20px; }
|
|
109
|
+
.ds-msg li { margin: 2px 0; }
|
|
110
|
+
.ds-msg h1, .ds-msg h2, .ds-msg h3 { margin: 8px 0 4px; line-height: 1.3; font-weight: 700; }
|
|
111
|
+
.ds-msg h1 { font-size: 1.3em; } .ds-msg h2 { font-size: 1.18em; } .ds-msg h3 { font-size: 1.05em; }
|
|
112
|
+
.ds-msg blockquote { margin: 6px 0; padding: 2px 10px; border-left: 3px solid var(--dsr-accent-2); color: var(--dsr-muted); }
|
|
113
|
+
.ds-msg a { color: inherit; text-decoration: underline; word-break: break-all; }
|
|
114
|
+
.ds-msg code { font-family: ui-monospace, monospace; font-size: .9em; background: var(--dsr-accent-soft); padding: 0 4px; border-radius: 4px; }
|
|
115
|
+
.ds-msg pre { margin: 6px 0; background: var(--dsr-code-bg); border-radius: 8px; padding: 8px 10px; overflow-x: auto; font-size: 12px; max-height: 220px; overflow-y: auto; }
|
|
106
116
|
.ds-tool { align-self: flex-start; max-width: min(78%, 680px); width: fit-content; box-sizing: border-box; background: var(--dsr-bg-2); border: 1px solid var(--dsr-line); border-radius: 10px; padding: 8px 11px; font-size: 12px; }
|
|
107
117
|
.ds-tool summary { cursor: pointer; color: var(--dsr-muted); }
|
|
108
118
|
.ds-tool pre { margin: 6px 0 0; white-space: pre-wrap; word-break: break-all; font-size: 11px; color: var(--dsr-text); }
|
|
@@ -1060,7 +1060,7 @@ function shouldShowEvent(type) { return INTERESTING_EVENTS.has(type) }
|
|
|
1060
1060
|
function safeJson(v) { try { return JSON.stringify(v, null, 2) } catch { return String(v) } }
|
|
1061
1061
|
function blockHtml(b) {
|
|
1062
1062
|
if (!b) return ''
|
|
1063
|
-
if (b.type === 'text') return `<
|
|
1063
|
+
if (b.type === 'text') return `<div class="md">${window.mdToHtml ? window.mdToHtml(b.text ?? '') : esc(b.text ?? '')}</div>`
|
|
1064
1064
|
if (b.type === 'reasoning') return `<span style="opacity:.75">${esc(b.text ?? '')}</span>`
|
|
1065
1065
|
if (b.type === 'tool-call') return `<div>🔧 ${esc(b.name || '')}</div>`
|
|
1066
1066
|
if (b.type === 'tool-result') return `<div>📦</div>`
|
package/public/index.html
CHANGED
|
@@ -250,7 +250,10 @@
|
|
|
250
250
|
</div>
|
|
251
251
|
<div class="setting-row">
|
|
252
252
|
<div><div class="setting-name" data-i18n="settings.scanTitle">扫码连接</div><div class="setting-desc" data-i18n="settings.scanDesc">拍照/相册本地解码(无谷歌服务依赖);也可系统相机扫码或手动粘贴</div></div>
|
|
253
|
-
<
|
|
253
|
+
<div class="setting-actions">
|
|
254
|
+
<button id="btn-scan-camera" class="mini-btn" data-i18n="scan.promptPhoto">拍照扫描</button>
|
|
255
|
+
<button id="btn-scan-gallery" class="mini-btn" data-i18n="scan.promptGallery">从相册选择</button>
|
|
256
|
+
</div>
|
|
254
257
|
</div>
|
|
255
258
|
</div>
|
|
256
259
|
</div>
|
|
@@ -271,6 +274,10 @@
|
|
|
271
274
|
<div><div class="setting-name" data-i18n="settings.peakRemindTitle">峰谷计费提醒</div><div class="setting-desc" data-i18n="settings.peakRemindDesc">每天 9:00 / 12:00 / 14:00 / 18:00 推送峰谷切换(App 内有效)</div></div>
|
|
272
275
|
<label class="switch"><input type="checkbox" id="opt-peak-remind"><span class="slider"></span></label>
|
|
273
276
|
</div>
|
|
277
|
+
<div class="setting-row">
|
|
278
|
+
<div><div class="setting-name" data-i18n="settings.testNotifyTitle">测试通知</div><div class="setting-desc" data-i18n="settings.testNotifyDesc">立即排一条 3 秒后的本地通知,用于排查系统通知限制</div></div>
|
|
279
|
+
<button id="btn-test-notify" class="mini-btn" data-i18n="settings.testNotify">发送测试通知</button>
|
|
280
|
+
</div>
|
|
274
281
|
<div class="setting-row">
|
|
275
282
|
<div><div class="setting-name" data-i18n="settings.bgTitle">后台轮询</div><div class="setting-desc" data-i18n="settings.bgDesc">退后台后由前台服务定时拉取事件,待办审批/提问不遗漏</div><div id="bg-auth-status" class="setting-desc hidden" data-i18n="settings.bgAuthFailed">登录失效:连续 3 次 401,后台轮询已停止</div></div>
|
|
276
283
|
<label class="switch"><input type="checkbox" id="opt-bg-poll"><span class="slider"></span></label>
|
|
@@ -530,7 +537,7 @@
|
|
|
530
537
|
'session.confirmStop': '停止当前会话正在运行的任务?', 'session.stopFailed': '停止失败', 'session.stopRequested': '已请求停止',
|
|
531
538
|
'notify.approvalTitle': '工具审批', 'notify.approvalBody': '{tool} 需要批准', 'notify.questionTitle': 'DSH 提问', 'notify.questionBody': '需要你回答',
|
|
532
539
|
'notify.permissionFailed': '通知权限申请失败:{msg}',
|
|
533
|
-
'history.loading': '加载历史…', 'history.cacheFallback': '网络不可用:显示本地缓存的历史', 'history.loadFailed': '加载历史失败:{msg}',
|
|
540
|
+
'history.loading': '加载历史…', 'history.cacheFallback': '网络不可用:显示本地缓存的历史', 'history.loadFailed': '加载历史失败:{msg}', 'history.retry': '重试',
|
|
534
541
|
'history.offlineCache': '离线缓存 {n} 条', 'history.count': '{n} 条', 'history.empty': '还没有消息',
|
|
535
542
|
'history.queueAndCount': '队列 {q} · 历史 {n}', 'history.countOnly': '历史 {n}',
|
|
536
543
|
'role.me': '我', 'role.dsh': 'DSH',
|
|
@@ -610,6 +617,8 @@
|
|
|
610
617
|
'settings.servers': '服务器地址(可多个)', 'settings.speedTest': '测速', 'settings.add': '添加',
|
|
611
618
|
'settings.scanTitle': '扫码连接', 'settings.scanDesc': '拍照/相册本地解码(无谷歌服务依赖);也可系统相机扫码或手动粘贴', 'settings.scan': '扫码',
|
|
612
619
|
'settings.notifyTitle': '通知', 'settings.notifyDesc': '收到审批/提问时推送',
|
|
620
|
+
'settings.testNotifyTitle': '测试通知', 'settings.testNotifyDesc': '立即排一条 3 秒后的本地通知,用于排查系统通知限制', 'settings.testNotify': '发送测试通知',
|
|
621
|
+
'settings.testNotifySent': '测试通知已排定,3 秒后到达', 'settings.testNotifyFailed': '发送测试通知失败:{msg}', 'settings.testNotifyUnavailable': '测试通知仅在 App 内可用',
|
|
613
622
|
'settings.peakRemindTitle': '峰谷计费提醒', 'settings.peakRemindDesc': '每天 9:00 / 12:00 / 14:00 / 18:00 推送峰谷切换(App 内有效)',
|
|
614
623
|
'peakRemind.enterPeak': '已进入高峰计费时段', 'peakRemind.enterOff': '已进入空闲计费时段',
|
|
615
624
|
'peakRemind.peak0912': '高峰 9:00-12:00', 'peakRemind.off1214': '空闲 12:00-14:00',
|
|
@@ -709,7 +718,7 @@
|
|
|
709
718
|
'session.confirmStop': 'Stop the currently running task in this session?', 'session.stopFailed': 'Stop failed', 'session.stopRequested': 'Stop requested',
|
|
710
719
|
'notify.approvalTitle': 'Tool approval', 'notify.approvalBody': '{tool} needs approval', 'notify.questionTitle': 'DSH question', 'notify.questionBody': 'Needs your answer',
|
|
711
720
|
'notify.permissionFailed': 'Notification permission failed: {msg}',
|
|
712
|
-
'history.loading': 'Loading history…', 'history.cacheFallback': 'Network unavailable: showing cached history', 'history.loadFailed': 'Failed to load history: {msg}',
|
|
721
|
+
'history.loading': 'Loading history…', 'history.cacheFallback': 'Network unavailable: showing cached history', 'history.loadFailed': 'Failed to load history: {msg}', 'history.retry': 'Retry',
|
|
713
722
|
'history.offlineCache': 'Offline cache {n} items', 'history.count': '{n} items', 'history.empty': 'No messages yet',
|
|
714
723
|
'history.queueAndCount': 'Queue {q} · History {n}', 'history.countOnly': 'History {n}',
|
|
715
724
|
'role.me': 'Me', 'role.dsh': 'DSH',
|
|
@@ -789,6 +798,8 @@
|
|
|
789
798
|
'settings.servers': 'Servers (one or more)', 'settings.speedTest': 'Test', 'settings.add': 'Add',
|
|
790
799
|
'settings.scanTitle': 'Scan to pair', 'settings.scanDesc': 'Photo/gallery QR decoding on device (no Google services); system camera or manual paste also work', 'settings.scan': 'Scan',
|
|
791
800
|
'settings.notifyTitle': 'Notifications', 'settings.notifyDesc': 'Push on approvals / questions',
|
|
801
|
+
'settings.testNotifyTitle': 'Test notification', 'settings.testNotifyDesc': 'Schedule a local notification in 3 seconds to verify the notification pipeline', 'settings.testNotify': 'Send test notification',
|
|
802
|
+
'settings.testNotifySent': 'Test notification scheduled, arriving in 3s', 'settings.testNotifyFailed': 'Failed to send test notification: {msg}', 'settings.testNotifyUnavailable': 'Test notification is only available in the app',
|
|
792
803
|
'settings.peakRemindTitle': 'Peak/off-peak reminders', 'settings.peakRemindDesc': 'Push at 9:00 / 12:00 / 14:00 / 18:00 daily (in-app only)',
|
|
793
804
|
'peakRemind.enterPeak': 'Peak pricing period started', 'peakRemind.enterOff': 'Off-peak pricing period started',
|
|
794
805
|
'peakRemind.peak0912': 'Peak 9:00-12:00', 'peakRemind.off1214': 'Off-peak 12:00-14:00',
|
|
@@ -845,6 +856,7 @@
|
|
|
845
856
|
<script src="jsqr.min.js"></script>
|
|
846
857
|
<script src="i18n.js"></script>
|
|
847
858
|
<script src="theme.js"></script>
|
|
859
|
+
<script src="md.js"></script>
|
|
848
860
|
<script src="app.js"></script>
|
|
849
861
|
</body>
|
|
850
862
|
</html>
|
package/public/md.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/* DSH Remote 轻量 Markdown 渲染器 · 零依赖
|
|
2
|
+
* 用法: mdToHtml(text) -> HTML 字符串
|
|
3
|
+
* 安全: 先 HTML 转义再转标记; 链接仅允许 http/https, 其余保留为纯文本。
|
|
4
|
+
* 支持: 代码块 / 行内代码 / #~### 标题 / **粗体** / *斜体* / - 无序列表 /
|
|
5
|
+
* 1. 有序列表 / > 引用 / [text](url) 链接 / 换行。
|
|
6
|
+
* 同时支持浏览器全局 window.mdToHtml 与 Node CommonJS module.exports。
|
|
7
|
+
*/
|
|
8
|
+
(function (root, factory) {
|
|
9
|
+
if (typeof module === 'object' && module.exports) module.exports = factory()
|
|
10
|
+
else root.mdToHtml = factory()
|
|
11
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
12
|
+
'use strict'
|
|
13
|
+
|
|
14
|
+
function escapeHtml(s) {
|
|
15
|
+
return String(s).replace(/[&<>"']/g, function (c) {
|
|
16
|
+
return {
|
|
17
|
+
'&': '&',
|
|
18
|
+
'<': '<',
|
|
19
|
+
'>': '>',
|
|
20
|
+
'"': '"',
|
|
21
|
+
"'": '''
|
|
22
|
+
}[c]
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function inline(s) {
|
|
27
|
+
s = s.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
28
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
29
|
+
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>')
|
|
30
|
+
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, function (m, text, url) {
|
|
31
|
+
url = url.trim()
|
|
32
|
+
if (!/^https?:\/\//i.test(url)) return m
|
|
33
|
+
return '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + text + '</a>'
|
|
34
|
+
})
|
|
35
|
+
return s
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renderLines(lines) {
|
|
39
|
+
var html = ''
|
|
40
|
+
var i = 0
|
|
41
|
+
while (i < lines.length) {
|
|
42
|
+
var line = lines[i]
|
|
43
|
+
var t = line.trim()
|
|
44
|
+
if (!t) { i++; continue }
|
|
45
|
+
var h = line.match(/^(#{1,3})\s+(.*)$/)
|
|
46
|
+
if (h) {
|
|
47
|
+
var level = h[1].length
|
|
48
|
+
html += '<h' + level + '>' + inline(h[2]) + '</h' + level + '>'
|
|
49
|
+
i++
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
var q = line.match(/^>\s?(.*)$/)
|
|
53
|
+
if (q) {
|
|
54
|
+
html += '<blockquote>' + inline(q[1]) + '</blockquote>'
|
|
55
|
+
i++
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
var ul = line.match(/^[-*]\s+(.*)$/)
|
|
59
|
+
if (ul) {
|
|
60
|
+
var items = []
|
|
61
|
+
while (i < lines.length) {
|
|
62
|
+
var um = lines[i].match(/^[-*]\s+(.*)$/)
|
|
63
|
+
if (!um) break
|
|
64
|
+
items.push(inline(um[1]))
|
|
65
|
+
i++
|
|
66
|
+
}
|
|
67
|
+
html += '<ul>' + items.map(function (x) { return '<li>' + x + '</li>' }).join('') + '</ul>'
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
var ol = line.match(/^\d+[.)]\s+(.*)$/)
|
|
71
|
+
if (ol) {
|
|
72
|
+
var oitems = []
|
|
73
|
+
while (i < lines.length) {
|
|
74
|
+
var om = lines[i].match(/^\d+[.)]\s+(.*)$/)
|
|
75
|
+
if (!om) break
|
|
76
|
+
oitems.push(inline(om[1]))
|
|
77
|
+
i++
|
|
78
|
+
}
|
|
79
|
+
html += '<ol>' + oitems.map(function (x) { return '<li>' + x + '</li>' }).join('') + '</ol>'
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
var para = []
|
|
83
|
+
while (i < lines.length) {
|
|
84
|
+
var cur = lines[i]
|
|
85
|
+
var ct = cur.trim()
|
|
86
|
+
if (!ct) break
|
|
87
|
+
if (/^(#{1,3})\s+/.test(cur) || /^>\s?/.test(cur) || /^[-*]\s+/.test(cur) || /^\d+[.)]\s+/.test(cur)) break
|
|
88
|
+
para.push(inline(cur))
|
|
89
|
+
i++
|
|
90
|
+
}
|
|
91
|
+
if (para.length) html += '<p>' + para.join('<br>') + '</p>'
|
|
92
|
+
}
|
|
93
|
+
return html
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function mdToHtml(text) {
|
|
97
|
+
var raw = String(text == null ? '' : text)
|
|
98
|
+
var parts = raw.split(/```/)
|
|
99
|
+
var out = ''
|
|
100
|
+
for (var i = 0; i < parts.length; i++) {
|
|
101
|
+
if (i % 2 === 1) {
|
|
102
|
+
var code = parts[i]
|
|
103
|
+
code = code.replace(/^\n/, '')
|
|
104
|
+
if (code.slice(-1) === '\n') code = code.slice(0, -1)
|
|
105
|
+
out += '<pre><code>' + escapeHtml(code) + '</code></pre>'
|
|
106
|
+
} else {
|
|
107
|
+
var escaped = escapeHtml(parts[i])
|
|
108
|
+
out += renderLines(escaped.split('\n'))
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return out
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return mdToHtml
|
|
115
|
+
})
|
package/public/styles.css
CHANGED
|
@@ -298,6 +298,12 @@ body.in-session .view {
|
|
|
298
298
|
.msg p:first-child { margin-top: 0; } .msg p:last-child { margin-bottom: 0; }
|
|
299
299
|
.msg ul, .msg ol { margin: 4px 0; padding-left: 20px; }
|
|
300
300
|
.msg code { font-family: ui-monospace, monospace; font-size: .9em; background: var(--dsr-accent-soft); padding: 0 4px; border-radius: 4px; }
|
|
301
|
+
.msg h1, .msg h2, .msg h3 { margin: 8px 0 4px; line-height: 1.3; font-weight: 700; }
|
|
302
|
+
.msg h1 { font-size: 1.3em; } .msg h2 { font-size: 1.18em; } .msg h3 { font-size: 1.05em; }
|
|
303
|
+
.msg blockquote { margin: 6px 0; padding: 2px 10px; border-left: 3px solid var(--dsr-accent-2); color: var(--dsr-muted); }
|
|
304
|
+
.msg a { color: var(--dsr-accent-strong); text-decoration: underline; word-break: break-all; }
|
|
305
|
+
.msg.user a { color: var(--dsr-on-accent); }
|
|
306
|
+
.msg li { margin: 2px 0; }
|
|
301
307
|
|
|
302
308
|
.event {
|
|
303
309
|
align-self: center; font-size: 11px; color: var(--dsr-muted);
|
|
@@ -454,6 +460,7 @@ body.in-session .main { padding-bottom: 84px; }
|
|
|
454
460
|
.setting-name { font-size: 14.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
455
461
|
.setting-desc { font-size: 12px; color: var(--dsr-muted); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
456
462
|
.setting-desc.expanded { white-space: normal; overflow: visible; text-overflow: clip; word-break: break-word; }
|
|
463
|
+
.setting-actions { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
|
457
464
|
.about { text-align: center; color: var(--dsr-muted); font-size: 12px; margin-top: 16px; line-height: 1.8; }
|
|
458
465
|
|
|
459
466
|
/* 多服务器列表(分组) */
|
package/public/update.json
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.6.
|
|
2
|
+
"version": "0.6.4",
|
|
3
3
|
"apkUrl": "dsh-remote.apk",
|
|
4
|
-
"sha256": "
|
|
5
|
-
"releasedAt": "2026-08-
|
|
6
|
-
"notes": "
|
|
4
|
+
"sha256": "7e9a67c36a77e13556034758b5e9b4d950b3e183d9a9bbfa0219f077a5c06ab2",
|
|
5
|
+
"releasedAt": "2026-08-19T11:39:19.543Z",
|
|
6
|
+
"notes": "扫码配对改为显式双按钮(拍照/相册),修复小米等 ROM 选择器错乱;会话历史加载超时放宽至 45 秒,失败可重试;网关端口自定义(插件管理页可改,环境变量仍优先)+ 启动前端口占用检测;峰谷计费提醒改前台服务驱动,后台按时必达;消息 Markdown 渲染(标题/代码块/列表/链接);修复 DSH 重启后网关上游端口不刷新(Issue #1);新增测试通知按钮。",
|
|
7
7
|
"history": [
|
|
8
|
+
{
|
|
9
|
+
"version": "0.6.4",
|
|
10
|
+
"notes": "扫码配对改为显式双按钮(拍照/相册),修复小米等 ROM 选择器错乱;会话历史加载超时放宽至 45 秒,失败可重试;网关端口自定义(插件管理页可改,环境变量仍优先)+ 启动前端口占用检测;峰谷计费提醒改前台服务驱动,后台按时必达;消息 Markdown 渲染(标题/代码块/列表/链接);修复 DSH 重启后网关上游端口不刷新(Issue #1);新增测试通知按钮。"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"version": "0.6.3",
|
|
14
|
+
"notes": "修复 DSH 重启后网关 upstream 端口不刷新(Issue #1):/health 增加 upstream/upstreamOk/pid 探测;插件 ensureGateway 检测上游变化或不可达时自动杀旧网关并按新 DSH_UPSTREAM 重启;启动/刷新写插件日志与 PID 文件。"
|
|
15
|
+
},
|
|
8
16
|
{
|
|
9
17
|
"version": "0.6.2",
|
|
10
18
|
"notes": "桌面端 WebUI:设置页清理与输入区饱满化;新增会话功能(goal/todo/subagent/模型切换/思考深度);预设提示词管理与空状态引导;更新弹窗改为按正式版逐版展示历史(App/桌面端);移除通知分组;聊天宽度约 680px、输入框按钮对齐。"
|
package/public/version.json
CHANGED