dsh-remote-plugin 0.6.15 → 0.6.16
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 +112 -7
- package/package.json +1 -1
- package/public/admin.html +46 -1
- package/public/admin.js +79 -9
- package/public/announcements.json +33 -0
- package/public/app.js +850 -35
- package/public/index.html +95 -7
- package/public/styles.css +76 -0
- package/public/update.json +8 -8
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
Binary file
|
package/gateway.cjs
CHANGED
|
@@ -83,6 +83,7 @@ const WORKBENCH_FILE = process.env.DSH_REMOTE_WORKBENCH || path.join(os.homedir(
|
|
|
83
83
|
const STARTED_AT = Date.now()
|
|
84
84
|
const DSH_SERVICE = String(process.env.DSH_REMOTE_DSH_SERVICE || 'dsh-web').trim()
|
|
85
85
|
const SYSTEMCTL = String(process.env.DSH_REMOTE_SYSTEMCTL || 'systemctl').trim() || 'systemctl'
|
|
86
|
+
const WINDOWS_SC = String(process.env.DSH_REMOTE_WINDOWS_SC || 'sc.exe').trim() || 'sc.exe'
|
|
86
87
|
const DSH_CONTROL_TIMEOUT_MS = durationEnv('DSH_REMOTE_DSH_CONTROL_TIMEOUT_MS', 45000, 2000, 5 * 60 * 1000)
|
|
87
88
|
const DSH_CONTROL_POLL_MS = durationEnv('DSH_REMOTE_DSH_CONTROL_POLL_MS', 500, 50, 5000)
|
|
88
89
|
const HTTP_REQUEST_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_REQUEST_TIMEOUT_MS', 15 * 60 * 1000, 0, 24 * 60 * 60 * 1000)
|
|
@@ -840,13 +841,91 @@ function classifySystemctlFailure(result) {
|
|
|
840
841
|
return { code: 'COMMAND_FAILED', message: 'systemctl 未能接受 DSH 控制命令', detail }
|
|
841
842
|
}
|
|
842
843
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
844
|
+
const WINDOWS_SERVICE_STATE_NAMES = Object.freeze({
|
|
845
|
+
1: 'STOPPED',
|
|
846
|
+
2: 'START_PENDING',
|
|
847
|
+
3: 'STOP_PENDING',
|
|
848
|
+
4: 'RUNNING',
|
|
849
|
+
5: 'CONTINUE_PENDING',
|
|
850
|
+
6: 'PAUSE_PENDING',
|
|
851
|
+
7: 'PAUSED',
|
|
852
|
+
})
|
|
853
|
+
|
|
854
|
+
function parseWindowsServiceQuery(output) {
|
|
855
|
+
const text = String(output || '')
|
|
856
|
+
const state = /\bSTATE\s*:\s*(\d+)(?:\s+([^\r\n(]+))?/i.exec(text)
|
|
857
|
+
if (!state) return null
|
|
858
|
+
const stateCode = Number(state[1])
|
|
859
|
+
const stateName = WINDOWS_SERVICE_STATE_NAMES[stateCode] || String(state[2] || 'UNKNOWN').trim().split(/\s+/)[0].toUpperCase()
|
|
860
|
+
const pid = /\bPID\s*:\s*(\d+)/i.exec(text)
|
|
861
|
+
const pending = [2, 3, 5, 6].includes(stateCode)
|
|
862
|
+
return {
|
|
863
|
+
stateCode,
|
|
864
|
+
stateName,
|
|
865
|
+
pending,
|
|
866
|
+
running: stateCode === 4,
|
|
867
|
+
mainPid: Number(pid?.[1]) || 0,
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function classifyWindowsServiceFailure(result) {
|
|
872
|
+
const detail = [result?.stderr, result?.stdout, result?.error].filter(Boolean).join(' · ').slice(0, 1000)
|
|
873
|
+
const code = String(result?.code ?? '')
|
|
874
|
+
if (result?.timedOut) return { code: 'COMMAND_TIMEOUT', message: 'Windows 服务控制命令超时', detail }
|
|
875
|
+
if (code === 'ENOENT') return { code: 'SERVICE_CONTROL_NOT_FOUND', message: '系统中找不到 sc.exe', detail }
|
|
876
|
+
if (code === '1060' || /1060|does not exist|cannot find the file specified|找不到指定的服务/i.test(detail)) {
|
|
877
|
+
return { code: 'SERVICE_NOT_FOUND', message: `未找到 Windows 服务 ${DSH_SERVICE}`, detail }
|
|
878
|
+
}
|
|
879
|
+
if (code === '1058' || /1058|disabled|禁用/i.test(detail)) return { code: 'SERVICE_DISABLED', message: `Windows 服务 ${DSH_SERVICE} 已被禁用`, detail }
|
|
880
|
+
if (/access is denied|permission denied|not authorized|需要提升|拒绝访问/i.test(detail)) return { code: 'PERMISSION_DENIED', message: '当前用户无权控制 Windows DSH 服务', detail }
|
|
881
|
+
if (code === 'SERVICE_STOP_TIMEOUT') return { code, message: `Windows 服务 ${DSH_SERVICE} 停止超时`, detail }
|
|
882
|
+
return { code: 'COMMAND_FAILED', message: 'Windows 服务控制命令失败', detail }
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function classifyDshServiceFailure(result) {
|
|
886
|
+
return process.platform === 'win32' ? classifyWindowsServiceFailure(result) : classifySystemctlFailure(result)
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
async function windowsServiceStatus() {
|
|
890
|
+
const r = await execFileResult(WINDOWS_SC, ['queryex', DSH_SERVICE], 5000)
|
|
891
|
+
if (!r.ok) {
|
|
892
|
+
const failure = classifyWindowsServiceFailure(r)
|
|
893
|
+
if (failure.code === 'SERVICE_NOT_FOUND') {
|
|
894
|
+
return { ok: true, supported: false, running: false, service: DSH_SERVICE, ...failure }
|
|
895
|
+
}
|
|
896
|
+
return { ok: false, supported: false, running: false, service: DSH_SERVICE, ...failure }
|
|
897
|
+
}
|
|
898
|
+
const parsed = parseWindowsServiceQuery([r.stdout, r.stderr].filter(Boolean).join('\n'))
|
|
899
|
+
if (!parsed) {
|
|
900
|
+
return {
|
|
901
|
+
ok: false, supported: false, running: false, service: DSH_SERVICE,
|
|
902
|
+
code: 'STATUS_PARSE_FAILED', message: '无法解析 Windows DSH 服务状态', detail: r.stdout || r.stderr || 'sc.exe 没有返回 STATE',
|
|
903
|
+
}
|
|
846
904
|
}
|
|
905
|
+
const activeState = parsed.running ? 'active' : parsed.stateCode === 7 ? 'paused' : parsed.pending ? 'activating' : 'inactive'
|
|
906
|
+
return {
|
|
907
|
+
ok: true,
|
|
908
|
+
supported: true,
|
|
909
|
+
running: parsed.running,
|
|
910
|
+
service: DSH_SERVICE,
|
|
911
|
+
state: activeState,
|
|
912
|
+
loadState: 'loaded',
|
|
913
|
+
activeState,
|
|
914
|
+
subState: parsed.stateName.toLowerCase(),
|
|
915
|
+
unitFileState: 'windows-service',
|
|
916
|
+
mainPid: parsed.mainPid,
|
|
917
|
+
result: '',
|
|
918
|
+
execMainStatus: 0,
|
|
919
|
+
serviceStateCode: parsed.stateCode,
|
|
920
|
+
serviceState: parsed.stateName,
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
async function dshServiceStatus() {
|
|
847
925
|
if (!/^[A-Za-z0-9_.@-]+$/.test(DSH_SERVICE)) {
|
|
848
926
|
return { ok: false, supported: false, running: false, service: DSH_SERVICE, code: 'INVALID_SERVICE', message: 'DSH_REMOTE_DSH_SERVICE 服务名配置不合法' }
|
|
849
927
|
}
|
|
928
|
+
if (process.platform === 'win32') return windowsServiceStatus()
|
|
850
929
|
const r = await execFileResult(SYSTEMCTL, [
|
|
851
930
|
'--user', 'show', DSH_SERVICE,
|
|
852
931
|
'--property=Id,LoadState,ActiveState,SubState,UnitFileState,MainPID,Result,ExecMainStatus',
|
|
@@ -884,6 +963,31 @@ async function dshServiceStatus() {
|
|
|
884
963
|
}
|
|
885
964
|
}
|
|
886
965
|
|
|
966
|
+
async function executeDshServiceAction(action, initial) {
|
|
967
|
+
if (process.platform !== 'win32') {
|
|
968
|
+
return execFileResult(SYSTEMCTL, ['--user', '--no-block', action, DSH_SERVICE], 5000)
|
|
969
|
+
}
|
|
970
|
+
if (action === 'restart' && initial?.running) {
|
|
971
|
+
const stop = await execFileResult(WINDOWS_SC, ['stop', DSH_SERVICE], 5000)
|
|
972
|
+
if (!stop.ok) {
|
|
973
|
+
const current = await windowsServiceStatus()
|
|
974
|
+
if (!current.supported || current.running || current.serviceStateCode !== 1) return stop
|
|
975
|
+
}
|
|
976
|
+
let stopped = false
|
|
977
|
+
const checks = Math.max(1, Math.ceil(DSH_CONTROL_TIMEOUT_MS / Math.max(50, DSH_CONTROL_POLL_MS)))
|
|
978
|
+
for (let i = 0; i < checks; i++) {
|
|
979
|
+
const current = await windowsServiceStatus()
|
|
980
|
+
if (!current.supported) {
|
|
981
|
+
return { ok: false, code: current.code || 'SERVICE_STATUS_FAILED', error: current.message, stderr: current.detail }
|
|
982
|
+
}
|
|
983
|
+
if (current.serviceStateCode === 1) { stopped = true; break }
|
|
984
|
+
await delay(DSH_CONTROL_POLL_MS)
|
|
985
|
+
}
|
|
986
|
+
if (!stopped) return { ok: false, code: 'SERVICE_STOP_TIMEOUT', error: `Windows 服务 ${DSH_SERVICE} 停止超时` }
|
|
987
|
+
}
|
|
988
|
+
return execFileResult(WINDOWS_SC, ['start', DSH_SERVICE], 5000)
|
|
989
|
+
}
|
|
990
|
+
|
|
887
991
|
function delay(ms) {
|
|
888
992
|
return new Promise(resolvePromise => setTimeout(resolvePromise, ms))
|
|
889
993
|
}
|
|
@@ -949,7 +1053,8 @@ function reconnectDshEventCollectors() {
|
|
|
949
1053
|
|
|
950
1054
|
async function runDshControlOperation(operation) {
|
|
951
1055
|
try {
|
|
952
|
-
|
|
1056
|
+
const manager = process.platform === 'win32' ? 'Windows 服务' : 'systemd 用户服务'
|
|
1057
|
+
dshOperationStep(operation, 'checking', `正在检查 ${manager} ${DSH_SERVICE}`)
|
|
953
1058
|
const initial = await dshServiceStatus()
|
|
954
1059
|
operation.initialStatus = initial
|
|
955
1060
|
operation.observed = initial
|
|
@@ -964,11 +1069,11 @@ async function runDshControlOperation(operation) {
|
|
|
964
1069
|
return
|
|
965
1070
|
}
|
|
966
1071
|
|
|
967
|
-
dshOperationStep(operation, 'command', `正在向
|
|
968
|
-
const command = await
|
|
1072
|
+
dshOperationStep(operation, 'command', `正在向 ${manager} 提交 DSH ${operation.action === 'start' ? '启动' : '重启'}命令`)
|
|
1073
|
+
const command = await executeDshServiceAction(operation.action, initial)
|
|
969
1074
|
operation.command = { ok: command.ok, code: command.code, signal: command.signal }
|
|
970
1075
|
if (!command.ok) {
|
|
971
|
-
const failure =
|
|
1076
|
+
const failure = classifyDshServiceFailure(command)
|
|
972
1077
|
failDshOperation(operation, failure.code, failure.message, failure.detail, await dshServiceStatus())
|
|
973
1078
|
return
|
|
974
1079
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-plugin",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.16",
|
|
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
|
@@ -108,6 +108,28 @@
|
|
|
108
108
|
.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; }
|
|
109
109
|
.gateway-port-row .muted { font-size: 12px; }
|
|
110
110
|
.gateway-port-row .mini-btn { margin-left: auto; }
|
|
111
|
+
.host-ip-card { margin: 0 0 14px; padding: 18px; background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: var(--dsr-radius); }
|
|
112
|
+
.host-ip-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
|
113
|
+
.host-ip-title { font-size: 16px; font-weight: 750; }
|
|
114
|
+
.host-ip-head .muted { margin-top: 4px; font-size: 12px; }
|
|
115
|
+
.host-ip-badge { flex: none; padding: 5px 9px; border: 1px solid var(--dsr-accent-line); border-radius: 999px; color: var(--dsr-accent-strong); background: var(--dsr-accent-soft); font-size: 11px; font-weight: 700; }
|
|
116
|
+
.host-ip-desc { margin: 12px 0; color: var(--dsr-muted); font-size: 12px; line-height: 1.55; }
|
|
117
|
+
.host-ip-table-wrap { overflow: hidden; border: 1px solid var(--dsr-line); border-radius: 13px; }
|
|
118
|
+
.host-ip-table { width: 100%; border-collapse: collapse; }
|
|
119
|
+
.host-ip-table th, .host-ip-table td { padding: 10px 12px; border-bottom: 1px solid var(--dsr-divider); text-align: left; font-size: 12px; }
|
|
120
|
+
.host-ip-table th { color: var(--dsr-muted); background: var(--dsr-bg-2); font-size: 11px; font-weight: 750; }
|
|
121
|
+
.host-ip-table tr:last-child td { border-bottom: none; }
|
|
122
|
+
.host-ip-toggle { width: 74px; }
|
|
123
|
+
.host-ip-value { font-size: 13px !important; }
|
|
124
|
+
.host-ip-use { color: var(--dsr-muted); }
|
|
125
|
+
.host-ip-switch { position: relative; display: inline-block; width: 38px; height: 22px; vertical-align: middle; }
|
|
126
|
+
.host-ip-switch input { position: absolute; width: 1px; height: 1px; opacity: 0; }
|
|
127
|
+
.host-ip-switch span { position: absolute; inset: 0; border: 1px solid var(--dsr-line); border-radius: 999px; background: var(--dsr-bg-2); cursor: pointer; transition: .18s ease; }
|
|
128
|
+
.host-ip-switch span::after { content: ''; position: absolute; width: 16px; height: 16px; left: 2px; top: 2px; border-radius: 50%; background: var(--dsr-muted); transition: .18s ease; }
|
|
129
|
+
.host-ip-switch input:checked + span { border-color: var(--dsr-accent-line); background: var(--dsr-accent-soft); }
|
|
130
|
+
.host-ip-switch input:checked + span::after { transform: translateX(16px); background: var(--dsr-accent-strong); }
|
|
131
|
+
.host-ip-switch input:focus-visible + span { outline: 2px solid var(--dsr-accent-strong); outline-offset: 2px; }
|
|
132
|
+
.host-ip-empty { padding: 18px; color: var(--dsr-muted); text-align: center; font-size: 12px; }
|
|
111
133
|
.conn-badge { white-space: nowrap; }
|
|
112
134
|
#btn-close-drawer { white-space: nowrap; }
|
|
113
135
|
@media (max-width: 720px) {
|
|
@@ -220,7 +242,7 @@
|
|
|
220
242
|
.tb-btn { border-radius: 11px; background: linear-gradient(135deg, var(--dsr-panel-2), var(--dsr-bg-2)); border-color: var(--dsr-line); transition: transform .18s ease, border-color .18s ease, background .18s ease; }
|
|
221
243
|
.tb-btn:hover { transform: translateY(-1px); border-color: var(--dsr-accent-line); background: var(--dsr-accent-soft); }
|
|
222
244
|
.tb-btn svg { width: 17px; height: 17px; }
|
|
223
|
-
.login-card, .stat-card, .stats-chart, .table-wrap, .gateway-port-row, .pair-box { background: linear-gradient(145deg, var(--dsr-panel), var(--dsr-bg-2)); border-color: var(--dsr-line); border-radius: 18px; box-shadow: 0 12px 30px var(--dsr-shadow); }
|
|
245
|
+
.login-card, .stat-card, .stats-chart, .table-wrap, .gateway-port-row, .host-ip-card, .pair-box { background: linear-gradient(145deg, var(--dsr-panel), var(--dsr-bg-2)); border-color: var(--dsr-line); border-radius: 18px; box-shadow: 0 12px 30px var(--dsr-shadow); }
|
|
224
246
|
.login-card { padding: 18px; }
|
|
225
247
|
.stat-grid { gap: 12px; }
|
|
226
248
|
.stat-card { padding: 15px 16px; box-shadow: inset 0 1px var(--dsr-divider), 0 10px 26px var(--dsr-shadow); }
|
|
@@ -293,6 +315,9 @@
|
|
|
293
315
|
.token-row { display: flex; flex-direction: column; align-items: stretch; }
|
|
294
316
|
.token-actions { justify-content: stretch; }
|
|
295
317
|
.token-actions .mini-btn { flex: 1 1 auto; }
|
|
318
|
+
.host-ip-card { padding: 15px; }
|
|
319
|
+
.host-ip-table-wrap { overflow-x: auto; }
|
|
320
|
+
.host-ip-table { min-width: 360px; }
|
|
296
321
|
.device-key-toggle { align-items:flex-start; }
|
|
297
322
|
.device-key-toggle-actions { align-items:flex-end; flex-direction:column-reverse; }
|
|
298
323
|
.device-key-grid.head { display:none; }
|
|
@@ -434,6 +459,24 @@
|
|
|
434
459
|
<button id="btn-save-port" class="mini-btn" data-i18n="savePort">保存</button>
|
|
435
460
|
</div>
|
|
436
461
|
|
|
462
|
+
<section id="host-ip-card" class="host-ip-card" aria-live="polite">
|
|
463
|
+
<div class="host-ip-head">
|
|
464
|
+
<div>
|
|
465
|
+
<div class="host-ip-title" data-i18n="hostIPs.title">主机 IP 地址</div>
|
|
466
|
+
<div id="host-ip-summary" class="muted" data-i18n="hostIPs.loading">正在读取主机地址…</div>
|
|
467
|
+
</div>
|
|
468
|
+
<div class="host-ip-badge" data-i18n="hostIPs.badge">手机连接候选</div>
|
|
469
|
+
</div>
|
|
470
|
+
<div class="host-ip-desc" data-i18n="hostIPs.desc">关闭虚拟机、容器或其他无效地址后,它们不会再写入配对二维码,也不会作为诊断和防火墙建议地址。</div>
|
|
471
|
+
<div class="host-ip-table-wrap">
|
|
472
|
+
<table class="host-ip-table">
|
|
473
|
+
<thead><tr><th data-i18n="hostIPs.enabledColumn">启用</th><th data-i18n="hostIPs.addressColumn">主机 IP</th><th data-i18n="hostIPs.statusColumn">状态</th></tr></thead>
|
|
474
|
+
<tbody id="host-ip-rows"></tbody>
|
|
475
|
+
</table>
|
|
476
|
+
<div id="host-ip-empty" class="host-ip-empty hidden" data-i18n="hostIPs.empty">暂无可用的非回环 IPv4 地址</div>
|
|
477
|
+
</div>
|
|
478
|
+
</section>
|
|
479
|
+
|
|
437
480
|
<div id="pair-box" class="pair-box hidden">
|
|
438
481
|
<div class="pair-title" data-i18n="pairTitle">手机 App 扫码配对</div>
|
|
439
482
|
<div class="pair-qr" id="pair-qr"></div>
|
|
@@ -564,6 +607,7 @@
|
|
|
564
607
|
'stat.download': '去下载', 'stat.embedded': 'DSH 内嵌 · 免网关',
|
|
565
608
|
'stat.updateCheck': '更新检查: {error}', 'stat.latest': '已是最新(来源检查)', 'stat.notChecked': '未检查更新',
|
|
566
609
|
'stat.hostIP': '主机 IP · {hostname}', 'stat.ipSep': '、', 'stat.phoneGateway': ' (手机连 {port} 网关)', 'stat.phoneThis': ' (手机连这个地址)',
|
|
610
|
+
'hostIPs.title': '主机 IP 地址', 'hostIPs.badge': '手机连接候选', 'hostIPs.loading': '正在读取主机地址…', 'hostIPs.summary': '已启用 {enabled}/{total} 个地址', 'hostIPs.desc': '关闭虚拟机、容器或其他无效地址后,它们不会再写入配对二维码,也不会作为诊断和防火墙建议地址。', 'hostIPs.enabledColumn': '启用', 'hostIPs.addressColumn': '主机 IP', 'hostIPs.statusColumn': '状态', 'hostIPs.enabled': '已启用 · 会用于配对', 'hostIPs.disabled': '已关闭 · 不用于配对', 'hostIPs.enable': '切换此地址', 'hostIPs.empty': '暂无可用的非回环 IPv4 地址', 'hostIPs.keepOne': '至少保留一个可用地址',
|
|
567
611
|
'stat.reachable': '可达', 'stat.unreachable': '不可达', 'stat.dshUpstream': 'DSH 上游 {url}',
|
|
568
612
|
'stat.devicesOnline': '设备在线 / 累计', 'stat.totalRequests': '总请求数', 'stat.authFailures': '认证失败',
|
|
569
613
|
'stat.uptime': '运行时长 · {host}:{port}',
|
|
@@ -673,6 +717,7 @@
|
|
|
673
717
|
'stat.download': 'Download', 'stat.embedded': 'Embedded in DSH · no gateway',
|
|
674
718
|
'stat.updateCheck': 'Update check: {error}', 'stat.latest': 'Up to date (source check)', 'stat.notChecked': 'Not checked',
|
|
675
719
|
'stat.hostIP': 'Host IP · {hostname}', 'stat.ipSep': ', ', 'stat.phoneGateway': ' (phone connects to gateway {port})', 'stat.phoneThis': ' (phone connects to this address)',
|
|
720
|
+
'hostIPs.title': 'Host IP addresses', 'hostIPs.badge': 'Phone connection candidates', 'hostIPs.loading': 'Reading host addresses…', 'hostIPs.summary': '{enabled}/{total} addresses enabled', 'hostIPs.desc': 'Disabled virtual-machine, container, or other invalid addresses are excluded from pairing QR codes, diagnostics, and firewall suggestions.', 'hostIPs.enabledColumn': 'Enabled', 'hostIPs.addressColumn': 'Host IP', 'hostIPs.statusColumn': 'Status', 'hostIPs.enabled': 'Enabled · used for pairing', 'hostIPs.disabled': 'Disabled · excluded from pairing', 'hostIPs.enable': 'Toggle this address', 'hostIPs.empty': 'No usable non-loopback IPv4 address', 'hostIPs.keepOne': 'Keep at least one usable address enabled',
|
|
676
721
|
'stat.reachable': 'Reachable', 'stat.unreachable': 'Unreachable', 'stat.dshUpstream': 'DSH upstream {url}',
|
|
677
722
|
'stat.devicesOnline': 'Devices online / total', 'stat.totalRequests': 'Total requests', 'stat.authFailures': 'Auth failures',
|
|
678
723
|
'stat.uptime': 'Uptime · {host}:{port}',
|
package/public/admin.js
CHANGED
|
@@ -31,6 +31,71 @@ let gatewayPort = 8787
|
|
|
31
31
|
let gatewayPortLoaded = false
|
|
32
32
|
let doctorExpanded = store.get('dshAdminDoctorCollapsed') !== '1'
|
|
33
33
|
let doctorChecks = []
|
|
34
|
+
const HOST_IP_SELECTION_KEY = 'dshAdminEnabledHostIPsV1'
|
|
35
|
+
|
|
36
|
+
function normalizedHostIPs(st) {
|
|
37
|
+
return Array.isArray(st?.lanIPs)
|
|
38
|
+
? [...new Set(st.lanIPs.map(value => String(value || '').trim()).filter(value => value && value !== '127.0.0.1' && value !== '0.0.0.0'))]
|
|
39
|
+
: []
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hostIPScope(st) {
|
|
43
|
+
return [st?.hostname || location.hostname || 'host', st?.host || ''].join('|')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function hostIPPreferences() {
|
|
47
|
+
try {
|
|
48
|
+
const value = JSON.parse(store.get(HOST_IP_SELECTION_KEY) || '{}')
|
|
49
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
|
50
|
+
} catch {
|
|
51
|
+
return {}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function enabledHostIPs(st) {
|
|
56
|
+
const all = normalizedHostIPs(st)
|
|
57
|
+
if (!all.length) return []
|
|
58
|
+
const saved = hostIPPreferences()[hostIPScope(st)]
|
|
59
|
+
if (!Array.isArray(saved)) return all
|
|
60
|
+
const selected = all.filter(ip => saved.includes(ip))
|
|
61
|
+
return selected.length ? selected : all
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function saveEnabledHostIPs(st, selected) {
|
|
65
|
+
const prefs = hostIPPreferences()
|
|
66
|
+
prefs[hostIPScope(st)] = normalizedHostIPs(st).filter(ip => selected.includes(ip))
|
|
67
|
+
store.set(HOST_IP_SELECTION_KEY, JSON.stringify(prefs))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function renderHostIPs(st) {
|
|
71
|
+
const all = normalizedHostIPs(st)
|
|
72
|
+
const selected = enabledHostIPs(st)
|
|
73
|
+
const rows = $('host-ip-rows')
|
|
74
|
+
const empty = $('host-ip-empty')
|
|
75
|
+
const summary = $('host-ip-summary')
|
|
76
|
+
if (!rows || !empty || !summary) return
|
|
77
|
+
summary.textContent = all.length
|
|
78
|
+
? t('hostIPs.summary', { enabled: selected.length, total: all.length })
|
|
79
|
+
: t('hostIPs.empty')
|
|
80
|
+
rows.innerHTML = all.map(ip => `<tr>
|
|
81
|
+
<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>
|
|
82
|
+
<td class="mono host-ip-value">${esc(ip)}</td>
|
|
83
|
+
<td class="host-ip-use">${esc(t(selected.includes(ip) ? 'hostIPs.enabled' : 'hostIPs.disabled'))}</td>
|
|
84
|
+
</tr>`).join('')
|
|
85
|
+
empty.classList.toggle('hidden', all.length > 0)
|
|
86
|
+
rows.querySelectorAll('[data-host-ip-toggle]').forEach(input => input.addEventListener('change', () => {
|
|
87
|
+
const ip = input.dataset.hostIpToggle
|
|
88
|
+
const next = enabledHostIPs(st).filter(value => value !== ip)
|
|
89
|
+
if (input.checked) next.push(ip)
|
|
90
|
+
if (!next.length) {
|
|
91
|
+
input.checked = true
|
|
92
|
+
toast(t('hostIPs.keepOne'), 'err')
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
saveEnabledHostIPs(st, next)
|
|
96
|
+
render(st)
|
|
97
|
+
}))
|
|
98
|
+
}
|
|
34
99
|
|
|
35
100
|
function onlineClientDevices(st) {
|
|
36
101
|
return (st.devices || []).filter(device => device.online && (device.kind === 'app' || device.kind === 'web'))
|
|
@@ -38,7 +103,7 @@ function onlineClientDevices(st) {
|
|
|
38
103
|
|
|
39
104
|
function firewallCommand(st) {
|
|
40
105
|
const port = Number(st.port || gatewayPort) || 8787
|
|
41
|
-
const ip = (st
|
|
106
|
+
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
107
|
let cidr = 'LocalSubnet'
|
|
43
108
|
if (/^10\./.test(ip || '')) cidr = '10.0.0.0/8'
|
|
44
109
|
else if (/^192\.168\./.test(ip || '')) cidr = '192.168.0.0/16'
|
|
@@ -52,7 +117,7 @@ function firewallCommand(st) {
|
|
|
52
117
|
function buildDoctorChecks(st) {
|
|
53
118
|
const isGateway = st.mode === 'gateway'
|
|
54
119
|
const port = Number(st.port || gatewayPort) || 8787
|
|
55
|
-
const ip = (st
|
|
120
|
+
const ip = enabledHostIPs(st).find(value => value && value !== '127.0.0.1' && value !== '0.0.0.0')
|
|
56
121
|
const base = ip ? `http://${ip}:${port}` : ''
|
|
57
122
|
const clients = onlineClientDevices(st)
|
|
58
123
|
const events = st.events || {}
|
|
@@ -322,6 +387,7 @@ function render(st) {
|
|
|
322
387
|
$('btn-qr').classList.toggle('hidden', isGateway !== true || !shownToken)
|
|
323
388
|
$('btn-rotate').classList.toggle('hidden', isGateway !== true || !shownToken || !!st.tokenFromEnv)
|
|
324
389
|
renderDeviceKeys(st, isGateway)
|
|
390
|
+
renderHostIPs(st)
|
|
325
391
|
renderQr(st)
|
|
326
392
|
renderDoctor(st)
|
|
327
393
|
// 网关开关: 仅插件内嵌页提供, 网关运行/停止两种状态
|
|
@@ -356,14 +422,12 @@ function render(st) {
|
|
|
356
422
|
action.dataset.heroAction = heroState === 'plugin' ? 'start' : heroState === 'offline' ? 'copy' : 'devices'
|
|
357
423
|
}
|
|
358
424
|
}
|
|
359
|
-
const hostIPs = (st.lanIPs || []).join(t('stat.ipSep')) || '127.0.0.1'
|
|
360
425
|
const latestHtml = st.latest?.newer
|
|
361
426
|
? `<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
427
|
: `<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
428
|
$('stats').innerHTML = `
|
|
364
429
|
<div class="stat-card"><div class="v">v${st.version}</div><div class="k">${t(isPlugin ? 'stat.pluginVersion' : 'stat.gatewayVersion')}</div></div>
|
|
365
430
|
<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
431
|
<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
432
|
<div class="stat-card"><div class="v">${st.onlineCount}/${st.deviceCount}</div><div class="k">${t('stat.devicesOnline')}</div></div>
|
|
369
433
|
<div class="stat-card"><div class="v">${st.totalRequests}</div><div class="k">${t('stat.totalRequests')}</div></div>
|
|
@@ -424,13 +488,19 @@ function render(st) {
|
|
|
424
488
|
}
|
|
425
489
|
|
|
426
490
|
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
491
|
const port = st.port || 8787
|
|
430
|
-
const
|
|
492
|
+
const hosts = enabledHostIPs(st).slice()
|
|
493
|
+
if (!hosts.length) {
|
|
494
|
+
const fallback = st.host && st.host !== '0.0.0.0' ? String(st.host).trim() : location.hostname
|
|
495
|
+
if (fallback) hosts.push(fallback)
|
|
496
|
+
}
|
|
497
|
+
const bases = hosts.map(host => `http://${host}:${port}`)
|
|
498
|
+
const query = new URLSearchParams({ token: String(accessToken || '') })
|
|
499
|
+
for (const base of bases) query.append('server', base)
|
|
431
500
|
return {
|
|
432
|
-
url: `dshremote://pair
|
|
433
|
-
base
|
|
501
|
+
url: `dshremote://pair?${query.toString()}`,
|
|
502
|
+
base: bases[0] || '',
|
|
503
|
+
bases
|
|
434
504
|
}
|
|
435
505
|
}
|
|
436
506
|
|
|
@@ -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
|
}
|