dsh-remote-plugin 0.6.15 → 0.6.17

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/public/admin.js CHANGED
@@ -18,6 +18,13 @@ const store = {
18
18
  del(k) { try { localStorage.removeItem(k) } catch {} }
19
19
  }
20
20
  let token = store.get('dshAdminToken') || new URLSearchParams(location.search).get('token') || ''
21
+ function adminHeaders(extra = {}, accessToken = token) {
22
+ const headers = { 'x-dsh-remote-client': 'admin', ...extra }
23
+ // /remote/* 可能由 Caddy Basic Auth 保护。插件路由自身已在 DSH 登录态内,
24
+ // 这里不能再写 Authorization: Bearer,否则会覆盖浏览器的 Basic 凭据并形成 401 循环。
25
+ if (!pluginMode && accessToken) headers.authorization = 'Bearer ' + accessToken
26
+ return headers
27
+ }
21
28
  let timer = null
22
29
  let gatewayRunning = false
23
30
  let gatewayBusy = false
@@ -31,6 +38,132 @@ let gatewayPort = 8787
31
38
  let gatewayPortLoaded = false
32
39
  let doctorExpanded = store.get('dshAdminDoctorCollapsed') !== '1'
33
40
  let doctorChecks = []
41
+ const HOST_IP_SELECTION_KEY = 'dshAdminEnabledHostIPsV1'
42
+ const MANUAL_HOST_IP_KEY = 'dshAdminManualHostIPsV1'
43
+
44
+ function normalizedHostIPs(st) {
45
+ const detected = Array.isArray(st?.lanIPs) ? st.lanIPs : []
46
+ return [...new Set([...detected, ...manualHostIPs(st)].map(validManualHost).filter(Boolean))]
47
+ }
48
+
49
+ function validManualHost(value) {
50
+ const host = String(value || '').trim()
51
+ if (!host || host.length > 253 || host === '0.0.0.0' || host === '127.0.0.1') return ''
52
+ if (!/^[A-Za-z0-9.-]+$/.test(host) || host.startsWith('.') || host.endsWith('.') || host.includes('..')) return ''
53
+ if (/^\d+(?:\.\d+){3}$/.test(host) && host.split('.').some(part => Number(part) > 255)) return ''
54
+ const labels = host.split('.')
55
+ if (labels.some(label => !label || label.length > 63 || label.startsWith('-') || label.endsWith('-'))) return ''
56
+ return host
57
+ }
58
+
59
+ function hostIPScope(st) {
60
+ return [st?.hostname || location.hostname || 'host', st?.host || ''].join('|')
61
+ }
62
+
63
+ function manualHostIPScope(st) {
64
+ return st?.hostname || location.hostname || 'host'
65
+ }
66
+
67
+ function hostIPPreferences() {
68
+ try {
69
+ const value = JSON.parse(store.get(HOST_IP_SELECTION_KEY) || '{}')
70
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
71
+ } catch {
72
+ return {}
73
+ }
74
+ }
75
+
76
+ function manualHostPreferences() {
77
+ try {
78
+ const value = JSON.parse(store.get(MANUAL_HOST_IP_KEY) || '{}')
79
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
80
+ } catch {
81
+ return {}
82
+ }
83
+ }
84
+
85
+ function manualHostIPs(st) {
86
+ const values = manualHostPreferences()[manualHostIPScope(st)]
87
+ return Array.isArray(values) ? [...new Set(values.map(validManualHost).filter(Boolean))] : []
88
+ }
89
+
90
+ function saveManualHostIPs(st, values) {
91
+ const prefs = manualHostPreferences()
92
+ prefs[manualHostIPScope(st)] = [...new Set(values.map(validManualHost).filter(Boolean))]
93
+ store.set(MANUAL_HOST_IP_KEY, JSON.stringify(prefs))
94
+ }
95
+
96
+ function enabledHostIPs(st) {
97
+ const all = normalizedHostIPs(st)
98
+ if (!all.length) return []
99
+ const saved = hostIPPreferences()[hostIPScope(st)]
100
+ if (!Array.isArray(saved)) return all
101
+ const selected = all.filter(ip => saved.includes(ip))
102
+ return selected.length ? selected : all
103
+ }
104
+
105
+ function saveEnabledHostIPs(st, selected) {
106
+ const prefs = hostIPPreferences()
107
+ prefs[hostIPScope(st)] = normalizedHostIPs(st).filter(ip => selected.includes(ip))
108
+ store.set(HOST_IP_SELECTION_KEY, JSON.stringify(prefs))
109
+ }
110
+
111
+ function addManualHostIP() {
112
+ if (!lastState) return
113
+ const input = prompt(t('hostIPs.addPrompt'), '')
114
+ if (input === null) return
115
+ const host = validManualHost(input)
116
+ if (!host) {
117
+ toast(t('hostIPs.invalid'), 'err')
118
+ return
119
+ }
120
+ if (normalizedHostIPs(lastState).includes(host)) {
121
+ toast(t('hostIPs.exists'), 'err')
122
+ return
123
+ }
124
+ saveManualHostIPs(lastState, [...manualHostIPs(lastState), host])
125
+ saveEnabledHostIPs(lastState, [...enabledHostIPs(lastState), host])
126
+ render(lastState)
127
+ }
128
+
129
+ function removeManualHostIP(st, host) {
130
+ saveManualHostIPs(st, manualHostIPs(st).filter(value => value !== host))
131
+ render(st)
132
+ }
133
+
134
+ function renderHostIPs(st) {
135
+ const all = normalizedHostIPs(st)
136
+ const selected = enabledHostIPs(st)
137
+ const manual = new Set(manualHostIPs(st))
138
+ const rows = $('host-ip-rows')
139
+ const empty = $('host-ip-empty')
140
+ const summary = $('host-ip-summary')
141
+ if (!rows || !empty || !summary) return
142
+ summary.textContent = all.length
143
+ ? t('hostIPs.summary', { enabled: selected.length, total: all.length })
144
+ : t('hostIPs.empty')
145
+ rows.innerHTML = all.map(ip => `<tr>
146
+ <td class="host-ip-toggle"><label class="host-ip-switch" title="${esc(t('hostIPs.enable'))}"><input type="checkbox" data-host-ip-toggle="${esc(ip)}" ${selected.includes(ip) ? 'checked' : ''}><span aria-hidden="true"></span></label></td>
147
+ <td class="mono host-ip-value">${esc(ip)}</td>
148
+ <td class="host-ip-use">${esc(t(selected.includes(ip) ? 'hostIPs.enabled' : 'hostIPs.disabled'))}${manual.has(ip) ? ` <button class="mini-btn host-ip-remove" type="button" data-host-ip-remove="${esc(ip)}">${esc(t('hostIPs.remove'))}</button>` : ''}</td>
149
+ </tr>`).join('')
150
+ empty.classList.toggle('hidden', all.length > 0)
151
+ rows.querySelectorAll('[data-host-ip-toggle]').forEach(input => input.addEventListener('change', () => {
152
+ const ip = input.dataset.hostIpToggle
153
+ const next = enabledHostIPs(st).filter(value => value !== ip)
154
+ if (input.checked) next.push(ip)
155
+ if (!next.length) {
156
+ input.checked = true
157
+ toast(t('hostIPs.keepOne'), 'err')
158
+ return
159
+ }
160
+ saveEnabledHostIPs(st, next)
161
+ render(st)
162
+ }))
163
+ rows.querySelectorAll('[data-host-ip-remove]').forEach(button => button.addEventListener('click', () => {
164
+ removeManualHostIP(st, button.dataset.hostIpRemove)
165
+ }))
166
+ }
34
167
 
35
168
  function onlineClientDevices(st) {
36
169
  return (st.devices || []).filter(device => device.online && (device.kind === 'app' || device.kind === 'web'))
@@ -38,7 +171,7 @@ function onlineClientDevices(st) {
38
171
 
39
172
  function firewallCommand(st) {
40
173
  const port = Number(st.port || gatewayPort) || 8787
41
- const ip = (st.lanIPs || []).find(value => /^10\.|^192\.168\.|^172\.(1[6-9]|2\d|3[01])\.|^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(value || ''))
174
+ const ip = enabledHostIPs(st).find(value => /^10\.|^192\.168\.|^172\.(1[6-9]|2\d|3[01])\.|^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(value || ''))
42
175
  let cidr = 'LocalSubnet'
43
176
  if (/^10\./.test(ip || '')) cidr = '10.0.0.0/8'
44
177
  else if (/^192\.168\./.test(ip || '')) cidr = '192.168.0.0/16'
@@ -52,7 +185,7 @@ function firewallCommand(st) {
52
185
  function buildDoctorChecks(st) {
53
186
  const isGateway = st.mode === 'gateway'
54
187
  const port = Number(st.port || gatewayPort) || 8787
55
- const ip = (st.lanIPs || []).find(value => value && value !== '127.0.0.1' && value !== '0.0.0.0')
188
+ const ip = enabledHostIPs(st).find(value => value && value !== '127.0.0.1' && value !== '0.0.0.0')
56
189
  const base = ip ? `http://${ip}:${port}` : ''
57
190
  const clients = onlineClientDevices(st)
58
191
  const events = st.events || {}
@@ -118,7 +251,7 @@ async function loadStats() {
118
251
  if (!token && !pluginMode) return
119
252
  try {
120
253
  const res = await fetch(`${STATS_API}/summary?days=7`, {
121
- headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' }
254
+ headers: adminHeaders(), credentials: 'same-origin'
122
255
  })
123
256
  if (!res.ok) {
124
257
  if (res.status === 401) return
@@ -139,7 +272,7 @@ async function loadGatewayConfig() {
139
272
  if (!pluginMode) return
140
273
  try {
141
274
  const res = await fetch(`${API}/config`, {
142
- headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' }
275
+ headers: adminHeaders(), credentials: 'same-origin'
143
276
  })
144
277
  const out = await res.json().catch(() => ({}))
145
278
  if (out.ok) {
@@ -290,15 +423,19 @@ async function loadState() {
290
423
  if (!token && !pluginMode) return
291
424
  try {
292
425
  const res = await fetch(`${API}/state`, {
293
- headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' }
426
+ headers: adminHeaders(), credentials: 'same-origin'
294
427
  })
295
- if (res.status === 401) throw new Error('AUTH')
428
+ if (res.status === 401) throw new Error(pluginMode ? 'AUTH_LAYER' : 'AUTH')
429
+ if (pluginMode && !String(res.headers.get('content-type') || '').includes('application/json')) throw new Error('AUTH_LAYER')
296
430
  const st = await res.json()
297
431
  render(st)
298
432
  } catch (e) {
299
433
  if (e.message === 'AUTH') {
300
434
  toast(t('toast.tokenInvalid'), 'err')
301
435
  logout()
436
+ } else if (e.message === 'AUTH_LAYER') {
437
+ $('conn-badge').textContent = t('toast.authLayer')
438
+ $('conn-badge').className = 'conn-badge off'
302
439
  } else {
303
440
  $('conn-badge').textContent = t('toast.connFailed')
304
441
  $('conn-badge').className = 'conn-badge off'
@@ -322,6 +459,7 @@ function render(st) {
322
459
  $('btn-qr').classList.toggle('hidden', isGateway !== true || !shownToken)
323
460
  $('btn-rotate').classList.toggle('hidden', isGateway !== true || !shownToken || !!st.tokenFromEnv)
324
461
  renderDeviceKeys(st, isGateway)
462
+ renderHostIPs(st)
325
463
  renderQr(st)
326
464
  renderDoctor(st)
327
465
  // 网关开关: 仅插件内嵌页提供, 网关运行/停止两种状态
@@ -356,14 +494,12 @@ function render(st) {
356
494
  action.dataset.heroAction = heroState === 'plugin' ? 'start' : heroState === 'offline' ? 'copy' : 'devices'
357
495
  }
358
496
  }
359
- const hostIPs = (st.lanIPs || []).join(t('stat.ipSep')) || '127.0.0.1'
360
497
  const latestHtml = st.latest?.newer
361
498
  ? `<div class="v">${t('stat.updateAvailable', { version: st.latest.version })}</div><div class="k">${t('stat.currentV', { version: st.version })} · <a href="${st.latest.url || '#'}" target="_blank" rel="noopener" style="color:var(--dsr-accent-strong)">${t('stat.download')}</a></div>`
362
499
  : `<div class="v">v${st.version}</div><div class="k">${isPlugin ? t('stat.embedded') : st.latest?.error ? t('stat.updateCheck', { error: st.latest.error }) : st.latest?.version ? t('stat.latest') : t('stat.notChecked')}</div>`
363
500
  $('stats').innerHTML = `
364
501
  <div class="stat-card"><div class="v">v${st.version}</div><div class="k">${t(isPlugin ? 'stat.pluginVersion' : 'stat.gatewayVersion')}</div></div>
365
502
  <div class="stat-card ${st.latest?.newer ? 'warn' : 'ok'}">${latestHtml}</div>
366
- <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>
367
503
  <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>
368
504
  <div class="stat-card"><div class="v">${st.onlineCount}/${st.deviceCount}</div><div class="k">${t('stat.devicesOnline')}</div></div>
369
505
  <div class="stat-card"><div class="v">${st.totalRequests}</div><div class="k">${t('stat.totalRequests')}</div></div>
@@ -424,13 +560,19 @@ function render(st) {
424
560
  }
425
561
 
426
562
  function pairTarget(st, accessToken) {
427
- const ip = (st.lanIPs || []).find(x => x && x !== '127.0.0.1' && x !== '0.0.0.0') || (st.lanIPs || [])[0]
428
- const host = ip || (st.host && st.host !== '0.0.0.0' ? st.host : location.hostname)
429
563
  const port = st.port || 8787
430
- const base = `http://${host}:${port}`
564
+ const hosts = enabledHostIPs(st).slice()
565
+ if (!hosts.length) {
566
+ const fallback = st.host && st.host !== '0.0.0.0' ? String(st.host).trim() : location.hostname
567
+ if (fallback) hosts.push(fallback)
568
+ }
569
+ const bases = hosts.map(host => `http://${host}:${port}`)
570
+ const query = new URLSearchParams({ token: String(accessToken || '') })
571
+ for (const base of bases) query.append('server', base)
431
572
  return {
432
- url: `dshremote://pair?token=${encodeURIComponent(accessToken)}&server=${encodeURIComponent(base)}`,
433
- base
573
+ url: `dshremote://pair?${query.toString()}`,
574
+ base: bases[0] || '',
575
+ bases
434
576
  }
435
577
  }
436
578
 
@@ -460,7 +602,7 @@ async function setNote(ip, current) {
460
602
  if (name === null) return
461
603
  const res = await fetch(`${API}/note`, {
462
604
  method: 'POST',
463
- headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' },
605
+ headers: adminHeaders({ 'content-type': 'application/json' }), credentials: 'same-origin',
464
606
  body: JSON.stringify({ ip, name })
465
607
  })
466
608
  if (res.ok) {
@@ -475,7 +617,7 @@ async function kick(ip) {
475
617
  if (!confirm(t('confirm.kick'))) return
476
618
  const res = await fetch(`${API}/kick`, {
477
619
  method: 'POST',
478
- headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' },
620
+ headers: adminHeaders({ 'content-type': 'application/json' }), credentials: 'same-origin',
479
621
  body: JSON.stringify({ ip })
480
622
  })
481
623
  if (res.ok) {
@@ -493,7 +635,7 @@ async function deviceKeyMutation(action, payload = {}) {
493
635
  try {
494
636
  const res = await fetch(`${API}/device-keys/${action}`, {
495
637
  method: 'POST',
496
- headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' },
638
+ headers: adminHeaders({ 'content-type': 'application/json' }), credentials: 'same-origin',
497
639
  body: JSON.stringify(payload),
498
640
  })
499
641
  const out = await res.json().catch(() => ({}))
@@ -685,6 +827,8 @@ $('btn-qr').addEventListener('click', () => {
685
827
  renderQr(lastState || { mode: '', token: shownToken })
686
828
  })
687
829
 
830
+ $('btn-host-ip-add').addEventListener('click', addManualHostIP)
831
+
688
832
  /* 右上角「网关」徽章: 新标签页打开独立网关管理面板(带 token 免登录) */
689
833
  $('conn-badge').addEventListener('click', () => {
690
834
  const st = lastState
@@ -704,7 +848,7 @@ $('btn-rotate').addEventListener('click', async () => {
704
848
  try {
705
849
  const res = await fetch(`${API}/token/rotate`, {
706
850
  method: 'POST',
707
- headers: { authorization: 'Bearer ' + (token || shownToken), 'x-dsh-remote-client': 'admin' }
851
+ headers: adminHeaders({}, token || shownToken), credentials: 'same-origin'
708
852
  })
709
853
  const out = await res.json().catch(() => ({}))
710
854
  if (out.ok && out.token) {
@@ -729,7 +873,7 @@ $('btn-gateway').addEventListener('click', async () => {
729
873
  try {
730
874
  const res = await fetch(`${API}/gateway`, {
731
875
  method: 'POST',
732
- headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' },
876
+ headers: adminHeaders({ 'content-type': 'application/json' }), credentials: 'same-origin',
733
877
  body: JSON.stringify({ action: gatewayRunning ? 'stop' : 'start' })
734
878
  })
735
879
  const out = await res.json().catch(() => ({}))
@@ -759,7 +903,7 @@ $('btn-save-port').addEventListener('click', async () => {
759
903
  try {
760
904
  const res = await fetch(`${API}/config`, {
761
905
  method: 'PUT',
762
- headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' },
906
+ headers: adminHeaders({ 'content-type': 'application/json' }), credentials: 'same-origin',
763
907
  body: JSON.stringify({ port })
764
908
  })
765
909
  const out = await res.json().catch(() => ({}))
@@ -75,6 +75,39 @@
75
75
  "minVersion": "",
76
76
  "maxVersion": "",
77
77
  "publishedAt": "2026-08-25T00:24:27+08:00"
78
+ },
79
+ {
80
+ "id": "2026-08-26-dsh-local-android-rc1",
81
+ "title": "dsh-local-android v0.1.0-rc.1 发布公告",
82
+ "content": "大家好,dsh-local-android 现已发布 v0.1.0-rc.1 测试版本。\n\ndsh-local-android 是 DSH 的 Android 本地发行版,应用名称为 DSH for Android。它将 DSH 运行时、Local Gateway 和交互界面整合到 Android 设备本地运行,适合希望直接在手机或 Android 环境中使用 DSH 的用户。\n\n当前版本支持:\n\n- 在 Android 设备本地运行 DSH;\n- 使用本地 Gateway 和 WebView 界面进行交互;\n- 通过系统文件选择器访问本地文件;\n- 在“设置 → 模型”中配置模型提供方、API 地址、API 密钥和模型目录;\n- 分层显示安装、Engine、Gateway 和界面启动状态,便于排查问题。\n\n本次发布同时提供 arm64-v8a 和 x86_64 架构版本,请根据设备或模拟器的 CPU 架构选择对应 APK。\n\n目前版本仍处于 RC 测试阶段,不同设备、Android 版本和运行环境下可能存在兼容性或稳定性差异。默认运行时采用 minimal profile,部分扩展能力暂未包含。\n\n如果你有在 Android 环境中本地运行 DSH 的需求,欢迎下载尝试,并反馈实际使用体验、设备兼容性和遇到的问题。\n\n提交日志或截图时,请注意隐藏 API 密钥、Token 及其他敏感信息。感谢大家的支持与反馈。",
83
+ "minVersion": "",
84
+ "maxVersion": "",
85
+ "publishedAt": "2026-08-26T18:28:26+08:00",
86
+ "actionUrl": "https://github.com/Blank-not-black/dsh-local-android/releases",
87
+ "actionText": "下载 DSH for Android"
88
+ },
89
+ {
90
+ "id": "2026-08-27-meeting-asr-brand-poll",
91
+ "title": "投票:你的手机品牌是什么?(会议转写调研)",
92
+ "content": "大家好,我们正在研究为 dsh-Remote 增加会议模式:使用手机录音,调用语音识别生成完整文字,再由 DSH 整理会议纪要、待办事项或指定文件。\n\n第一阶段准备优先验证手机厂商提供的云端语音识别能力,因此先统计大家正在使用的手机品牌。本次投票只用于了解设备分布和安排后续适配顺序,不代表某个品牌已经确认支持,也不会立即改变现有功能。\n\n后续如果需要进一步确认识别能力,我们会在 App 内提供自愿参与的诊断测试,只记录识别服务是否可用、是否返回 partial results、连续识别时长和错误类型,不收集录音、识别文字或 API 密钥。",
93
+ "minVersion": "0.6.11",
94
+ "maxVersion": "",
95
+ "publishedAt": "2026-08-27T12:00:00+08:00",
96
+ "poll": {
97
+ "id": "meeting-asr-brand-2026-08",
98
+ "question": "你的手机品牌是什么?",
99
+ "options": [
100
+ { "id": "xiaomi-redmi", "label": "小米 / Redmi" },
101
+ { "id": "huawei", "label": "华为" },
102
+ { "id": "honor", "label": "荣耀" },
103
+ { "id": "oppo-oneplus", "label": "OPPO / 一加" },
104
+ { "id": "vivo-iqoo", "label": "vivo / iQOO" },
105
+ { "id": "samsung", "label": "三星" },
106
+ { "id": "meizu", "label": "魅族" },
107
+ { "id": "google-pixel", "label": "Google Pixel" },
108
+ { "id": "other", "label": "其他品牌" }
109
+ ]
110
+ }
78
111
  }
79
112
  ]
80
113
  }