dsh-remote-plugin 0.6.3 → 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.
Binary file
package/index.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
  import { execFileSync, spawn } from 'node:child_process'
9
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 代理: 让插件抽屉显示与 8787 网关管理页完全一致的数据。
24
- const GATEWAY_BASE = (process.env.DSH_REMOTE_GATEWAY || 'http://127.0.0.1:8787').replace(/\/+$/, '')
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(`${GATEWAY_BASE}${path}`, {
115
+ const res = await fetch(`${gatewayBase()}${path}`, {
99
116
  method,
100
117
  headers: {
101
118
  authorization: `Bearer ${token}`,
@@ -126,9 +143,26 @@ 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(`${GATEWAY_BASE}/health`, { signal: AbortSignal.timeout(3000) })
165
+ const res = await fetch(`${gatewayBase()}/health`, { signal: AbortSignal.timeout(3000) })
132
166
  if (!res.ok) return { running: false }
133
167
  const data = await res.json().catch(() => ({}))
134
168
  return {
@@ -217,8 +251,12 @@ async function startGateway() {
217
251
  if (!existsSync(script)) {
218
252
  return { ok: false, running: false, error: '插件包缺少 gateway.cjs, 请升级插件' }
219
253
  }
220
- const port = process.env.DSH_REMOTE_GATEWAY_PORT || '8787'
221
- logGateway('启动网关, 上游: ' + upstream)
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)
222
260
 
223
261
  // 首选 systemd-run: 网关成为独立 user 单元, DSH 重启/升级不会连带杀掉它
224
262
  let sysd = false
@@ -302,7 +340,7 @@ async function stopGateway() {
302
340
  const token = gatewayToken()
303
341
  if (!token) return { ok: false, running: false, error: '找不到 ~/.dsh-remote/token, 无法认证网关' }
304
342
  try {
305
- const res = await fetch(`${GATEWAY_BASE}/admin/api/shutdown`, {
343
+ const res = await fetch(`${gatewayBase()}/admin/api/shutdown`, {
306
344
  method: 'POST',
307
345
  headers: { authorization: `Bearer ${token}`, 'x-dsh-remote-client': 'admin' },
308
346
  signal: AbortSignal.timeout(2000),
@@ -348,7 +386,7 @@ function statsSend(session, event) {
348
386
  const prev = statsQueues.get(session.id) || Promise.resolve()
349
387
  const next = prev.then(async () => {
350
388
  try {
351
- await fetch(`${GATEWAY_BASE}/stats/ingest`, {
389
+ await fetch(`${gatewayBase()}/stats/ingest`, {
352
390
  method: 'POST',
353
391
  headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
354
392
  body: JSON.stringify(payload),
@@ -434,7 +472,7 @@ async function serveStatic(req, res, ctx) {
434
472
  if (proxied !== null) {
435
473
  sendJson(res, proxied.status, proxied.json)
436
474
  } else {
437
- sendJson(res, 502, { ok: false, error: '本地网关不可用, Token 统计需要 8787 网关运行' })
475
+ sendJson(res, 502, { ok: false, error: '本地网关不可用, Token 统计需要网关运行' })
438
476
  }
439
477
  return
440
478
  }
@@ -486,11 +524,65 @@ async function serveStatic(req, res, ctx) {
486
524
  if (proxied !== null) {
487
525
  sendJson(res, proxied.status, proxied.json)
488
526
  } else {
489
- sendJson(res, 502, { ok: false, error: '本地网关不可用, 设备管理需在 8787 网关模式操作' })
527
+ sendJson(res, 502, { ok: false, error: '本地网关不可用, 设备管理需在网关模式操作' })
490
528
  }
491
529
  return
492
530
  }
493
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
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()
583
+ return
584
+ }
585
+
494
586
  // 本地网关开关(仅插件内嵌页使用): GET 状态 / POST {action:'start'|'stop'}
495
587
  if (pathname === `${MOUNT}/admin/api/gateway`) {
496
588
  if (req.method === 'GET') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.6.3",
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': ' (手机连 8787 网关)', 'stat.phoneThis': ' (手机连这个地址)',
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': '统计需要 8787 网关运行', 'stats.empty': '暂无统计,产生会话后自动聚合',
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 8787)', 'stat.phoneThis': ' (phone connects to this address)',
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 on 8787', 'stats.empty': 'No stats yet — they aggregate as sessions happen',
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(20000)
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()) toast(t('history.cacheFallback'), 'ok')
1184
- else toast(t('history.loadFailed', { msg: e.message }), 'err')
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>${renderMarkdown(b.text ?? '')}</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
- /* ---------------- 峰谷计费提醒(每天 9/12/14/18 点本地通知) ---------------- */
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 L = CAP.Plugins?.LocalNotifications
2661
- if (!L?.schedule) return false
2678
+ const b = bgBridge()
2679
+ if (!b?.startPeakReminder) return false
2662
2680
  try {
2663
- await L.schedule({
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 L = CAP.Plugins?.LocalNotifications
2678
- if (!L?.cancel) return false
2688
+ const b = bgBridge()
2689
+ if (!b?.stopPeakReminder) return false
2679
2690
  try {
2680
- await L.cancel({ notifications: PEAK_REMIND_NOTIFS.map(n => ({ id: n.id })) })
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
- const perm = await camera.requestPermissions?.({ permissions: ['camera'] })
2796
- if (perm && perm.camera !== 'granted') { toast(t('scan.permissionDenied'), 'err'); return }
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: 'PROMPT', // 拍照 / 从相册选择都行, 相册可扫截图
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-pair').addEventListener('click', scanPair)
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); }
@@ -511,6 +511,7 @@
511
511
  }
512
512
  </script>
513
513
  <script src="i18n.js"></script>
514
+ <script src="../md.js"></script>
514
515
  <script src="desktop.js"></script>
515
516
  </body>
516
517
  </html>
@@ -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 `<span>${esc(b.text ?? '')}</span>`
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
- <button id="btn-scan-pair" class="mini-btn" data-i18n="settings.scan">扫码</button>
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
+ '&': '&amp;',
18
+ '<': '&lt;',
19
+ '>': '&gt;',
20
+ '"': '&quot;',
21
+ "'": '&#39;'
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(/^&gt;\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) || /^&gt;\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
  /* 多服务器列表(分组) */
@@ -1,10 +1,14 @@
1
1
  {
2
- "version": "0.6.3",
2
+ "version": "0.6.4",
3
3
  "apkUrl": "dsh-remote.apk",
4
- "sha256": "e658387db7cee1e0b5392fad3719056b463d40ce55b5aac0d313d797c07438f2",
5
- "releasedAt": "2026-08-19T03:20:39.386Z",
6
- "notes": "修复 DSH 重启后网关 upstream 端口不刷新(Issue #1):/health 增加 upstream/upstreamOk/pid 探测;插件 ensureGateway 检测上游变化或不可达时自动杀旧网关并按新 DSH_UPSTREAM 重启;启动/刷新写插件日志与 PID 文件。",
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
+ },
8
12
  {
9
13
  "version": "0.6.3",
10
14
  "notes": "修复 DSH 重启后网关 upstream 端口不刷新(Issue #1):/health 增加 upstream/upstreamOk/pid 探测;插件 ensureGateway 检测上游变化或不可达时自动杀旧网关并按新 DSH_UPSTREAM 重启;启动/刷新写插件日志与 PID 文件。"
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.6.3"
2
+ "version": "0.6.4"
3
3
  }