@dshn/agent 0.1.2
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/LICENSE +21 -0
- package/README.md +155 -0
- package/client.js +811 -0
- package/cordis.patch.yml +25 -0
- package/lib/index.js +5407 -0
- package/package.json +51 -0
package/client.js
ADDED
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
// Browser half of dshn-agent, hand-authored in the factory format
|
|
2
|
+
// dsh-client-modules serves (the same shape tsdown emits, no build step).
|
|
3
|
+
//
|
|
4
|
+
// ONE panel does everything (the "connection control"):
|
|
5
|
+
// - No saved credentials → registration: subdomain + password + confirm +
|
|
6
|
+
// strength meter, to claim a fresh subdomain. Pops up as a prominent modal
|
|
7
|
+
// the first time an unconfigured dsh is opened locally.
|
|
8
|
+
// - Saved credentials → the same panel pre-filled with the previous subdomain
|
|
9
|
+
// and password, as a connect/reconnect control (no confirm — not a claim).
|
|
10
|
+
// The saved password is always one click to copy (the cloud keeps only a hash;
|
|
11
|
+
// this local copy is the only recovery). A bottom-left pill reflects status and
|
|
12
|
+
// opens the panel; the panel has an explicit ✕ to close.
|
|
13
|
+
window.__ModuleLoader__.load({
|
|
14
|
+
id: '@dshn/agent',
|
|
15
|
+
factory: (require) => {
|
|
16
|
+
var module = { exports: {} }
|
|
17
|
+
var exports = module.exports
|
|
18
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
19
|
+
const react = require('react')
|
|
20
|
+
const h = react.createElement
|
|
21
|
+
|
|
22
|
+
const ID = 'dshn'
|
|
23
|
+
const POLL_MS = 2500
|
|
24
|
+
const MIN_PW = 8
|
|
25
|
+
const E2E_HEADER = 'x-dshn-e2e'
|
|
26
|
+
const E2E_PUB_PATH = '/dshn-e2e'
|
|
27
|
+
const E2E_ITERS = 210000
|
|
28
|
+
|
|
29
|
+
// ── end-to-end decryption shim ────────────────────────────────────────────
|
|
30
|
+
// Runs only when the page is opened THROUGH the tunnel (a public host, not
|
|
31
|
+
// loopback) and the agent reports E2E on. It patches fetch + WebSocket so
|
|
32
|
+
// /api request bodies are sealed and responses / event messages are decrypted
|
|
33
|
+
// with a key derived from an e2e password the visitor types — a password that
|
|
34
|
+
// never reaches the relay. dsh's own traffic is gated until that key is ready.
|
|
35
|
+
;(function installE2E() {
|
|
36
|
+
window.__dshnE2E = { stage: 'entered', host: (typeof location !== 'undefined' ? location.hostname : '?') }
|
|
37
|
+
if (typeof window === 'undefined' || !window.crypto || !window.crypto.subtle) { window.__dshnE2E.stage = 'no-subtle'; return }
|
|
38
|
+
const host = location.hostname
|
|
39
|
+
const loopback = host === 'localhost' || host === '::1' || /^127\./.test(host)
|
|
40
|
+
window.__dshnE2E.remote = !loopback
|
|
41
|
+
if (loopback) { window.__dshnE2E.stage = 'loopback-skip'; return } // local access talks straight to dsh; nothing is encrypted
|
|
42
|
+
|
|
43
|
+
const realFetch = window.fetch.bind(window)
|
|
44
|
+
const RealWS = window.WebSocket
|
|
45
|
+
const enc = new TextEncoder()
|
|
46
|
+
let key = null // CryptoKey once the visitor unlocks; null = pass-through
|
|
47
|
+
let active = false // agent reports E2E on
|
|
48
|
+
let resolveReady
|
|
49
|
+
const ready = new Promise((r) => { resolveReady = r })
|
|
50
|
+
const hexToBytes = (hx) => { const a = new Uint8Array(hx.length / 2); for (let i = 0; i < a.length; i++) a[i] = parseInt(hx.substr(i * 2, 2), 16); return a }
|
|
51
|
+
const isApi = (url) => { try { const u = new URL(url, location.href); return u.origin === location.origin && u.pathname.startsWith('/api') } catch { return false } }
|
|
52
|
+
|
|
53
|
+
async function deriveKey(password, saltHex) {
|
|
54
|
+
const base = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey'])
|
|
55
|
+
return crypto.subtle.deriveKey({ name: 'PBKDF2', salt: hexToBytes(saltHex), iterations: E2E_ITERS, hash: 'SHA-256' },
|
|
56
|
+
base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'])
|
|
57
|
+
}
|
|
58
|
+
async function sealBytes(bytes) {
|
|
59
|
+
const iv = crypto.getRandomValues(new Uint8Array(12))
|
|
60
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, bytes))
|
|
61
|
+
const out = new Uint8Array(iv.length + ct.length); out.set(iv); out.set(ct, iv.length); return out
|
|
62
|
+
}
|
|
63
|
+
async function openBytes(k, blob) {
|
|
64
|
+
const iv = blob.subarray(0, 12)
|
|
65
|
+
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, k, blob.subarray(12))
|
|
66
|
+
return new Uint8Array(pt)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// fetch: seal /api request bodies, decrypt marked responses. Non-/api and
|
|
70
|
+
// (once ready) the E2E-off case pass straight through.
|
|
71
|
+
window.fetch = async function (input, init) {
|
|
72
|
+
const url = typeof input === 'string' ? input : (input && input.url) || String(input)
|
|
73
|
+
if (!isApi(url)) return realFetch(input, init)
|
|
74
|
+
await ready
|
|
75
|
+
if (!key) return realFetch(input, init)
|
|
76
|
+
const req = new Request(input, init)
|
|
77
|
+
const headers = new Headers(req.headers)
|
|
78
|
+
let body = null
|
|
79
|
+
const buf = await req.clone().arrayBuffer()
|
|
80
|
+
if (buf.byteLength > 0) { body = await sealBytes(new Uint8Array(buf)); headers.set(E2E_HEADER, '1') }
|
|
81
|
+
else headers.set(E2E_HEADER, '1')
|
|
82
|
+
const res = await realFetch(url, { method: req.method, headers, body, credentials: 'include', mode: req.mode, cache: req.cache })
|
|
83
|
+
if (res.headers.get(E2E_HEADER) !== '1') return res
|
|
84
|
+
const sealed = new Uint8Array(await res.arrayBuffer())
|
|
85
|
+
let plain
|
|
86
|
+
try { plain = await openBytes(key, sealed) } catch { return new Response(null, { status: 502, statusText: 'e2e decrypt failed' }) }
|
|
87
|
+
const outH = new Headers(res.headers); outH.delete(E2E_HEADER); outH.delete('content-length')
|
|
88
|
+
return new Response(plain, { status: res.status, statusText: res.statusText, headers: outH })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// WebSocket: connect normally, but decrypt the sealed downlink messages in
|
|
92
|
+
// arrival order once the key is ready. Everything else proxies through.
|
|
93
|
+
const E2EWebSocket = class extends EventTarget {
|
|
94
|
+
constructor(url, protocols) {
|
|
95
|
+
super()
|
|
96
|
+
this._ws = new RealWS(url, protocols)
|
|
97
|
+
this._q = Promise.resolve()
|
|
98
|
+
this._seal = active && isApi(String(url))
|
|
99
|
+
for (const t of ['open', 'error']) this._ws.addEventListener(t, (e) => this._emit(t, e))
|
|
100
|
+
this._ws.addEventListener('close', (e) => this._emit('close', e))
|
|
101
|
+
this._ws.addEventListener('message', (e) => { this._q = this._q.then(() => this._msg(e)) })
|
|
102
|
+
}
|
|
103
|
+
get url() { return this._ws.url }
|
|
104
|
+
get readyState() { return this._ws.readyState }
|
|
105
|
+
get bufferedAmount() { return this._ws.bufferedAmount }
|
|
106
|
+
get protocol() { return this._ws.protocol }
|
|
107
|
+
get extensions() { return this._ws.extensions }
|
|
108
|
+
get binaryType() { return this._ws.binaryType }
|
|
109
|
+
set binaryType(v) { this._ws.binaryType = v }
|
|
110
|
+
set onopen(f) { this._onopen = f } get onopen() { return this._onopen }
|
|
111
|
+
set onclose(f) { this._onclose = f } get onclose() { return this._onclose }
|
|
112
|
+
set onerror(f) { this._onerror = f } get onerror() { return this._onerror }
|
|
113
|
+
set onmessage(f) { this._onmessage = f } get onmessage() { return this._onmessage }
|
|
114
|
+
send(d) { this._ws.send(d) }
|
|
115
|
+
close(c, r) { this._ws.close(c, r) }
|
|
116
|
+
_emit(type, orig) {
|
|
117
|
+
const ev = type === 'close' ? new CloseEvent('close', { code: orig.code, reason: orig.reason, wasClean: orig.wasClean }) : new Event(type)
|
|
118
|
+
const on = this['_on' + type]; if (on) try { on.call(this, ev) } catch {}
|
|
119
|
+
this.dispatchEvent(ev)
|
|
120
|
+
}
|
|
121
|
+
async _msg(e) {
|
|
122
|
+
let data = e.data
|
|
123
|
+
if (this._seal) {
|
|
124
|
+
await ready
|
|
125
|
+
if (key) {
|
|
126
|
+
try {
|
|
127
|
+
const raw = data instanceof ArrayBuffer ? new Uint8Array(data)
|
|
128
|
+
: data instanceof Blob ? new Uint8Array(await data.arrayBuffer())
|
|
129
|
+
: new Uint8Array(await new Blob([data]).arrayBuffer())
|
|
130
|
+
const opened = await openBytes(key, raw)
|
|
131
|
+
data = opened[0] === 0 ? new TextDecoder().decode(opened.subarray(1)) : opened.subarray(1).buffer
|
|
132
|
+
} catch { return } // drop messages we can't decrypt
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const ev = new MessageEvent('message', { data })
|
|
136
|
+
if (this._onmessage) try { this._onmessage.call(this, ev) } catch {}
|
|
137
|
+
this.dispatchEvent(ev)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (const [k, v] of [['CONNECTING', 0], ['OPEN', 1], ['CLOSING', 2], ['CLOSED', 3]]) {
|
|
141
|
+
E2EWebSocket[k] = v; E2EWebSocket.prototype[k] = v
|
|
142
|
+
}
|
|
143
|
+
window.WebSocket = E2EWebSocket
|
|
144
|
+
|
|
145
|
+
// Discover E2E state, then (if on) show the unlock gate and derive the key.
|
|
146
|
+
// If E2E is OFF (the default), restore the native fetch/WebSocket entirely
|
|
147
|
+
// so nothing here sits in the normal path — the feature is truly opt-in.
|
|
148
|
+
;(async () => {
|
|
149
|
+
try {
|
|
150
|
+
window.__dshnE2E.stage = 'checking'
|
|
151
|
+
const info = await realFetch(E2E_PUB_PATH, { cache: 'no-store' }).then((r) => r.json())
|
|
152
|
+
window.__dshnE2E.stage = 'checked'; window.__dshnE2E.enabled = info && info.enabled
|
|
153
|
+
if (!info || !info.enabled || !info.salt) { window.fetch = realFetch; window.WebSocket = RealWS; window.__dshnE2E.stage = 'off-restored'; resolveReady(); return }
|
|
154
|
+
active = true
|
|
155
|
+
window.__dshnE2E.stage = 'gating'
|
|
156
|
+
await unlockGate(info.salt)
|
|
157
|
+
window.__dshnE2E.stage = 'unlocked'
|
|
158
|
+
} catch (e) { window.fetch = realFetch; window.WebSocket = RealWS; window.__dshnE2E.stage = 'error'; window.__dshnE2E.error = String(e && e.message || e) }
|
|
159
|
+
resolveReady()
|
|
160
|
+
})()
|
|
161
|
+
|
|
162
|
+
// A blocking DOM overlay (not React — must appear before the app mounts)
|
|
163
|
+
// asking for the e2e password; verified by a sealed probe to /api.
|
|
164
|
+
function unlockGate(salt) {
|
|
165
|
+
return new Promise((resolve) => {
|
|
166
|
+
const zh = String(document.documentElement.lang || navigator.language || 'en').toLowerCase().indexOf('zh') === 0
|
|
167
|
+
const L = zh
|
|
168
|
+
? { t: '端到端加密', s: '本页内容已端到端加密。输入端到端密码解锁——密码不会发送到云端。', p: '端到端密码', u: '解锁', bad: '密码错误,无法解密。',
|
|
169
|
+
save: '在此设备记住密码', stale: '已保存的密码无法解锁(可能已被更改),请重新输入。' }
|
|
170
|
+
: { t: 'End-to-end encrypted', s: 'This session is end-to-end encrypted. Enter the e2e password to unlock — it is never sent to the cloud.', p: 'E2E password', u: 'Unlock', bad: 'Wrong password — cannot decrypt.',
|
|
171
|
+
save: 'Remember on this device', stale: 'The saved password no longer works (it may have been changed). Enter it again.' }
|
|
172
|
+
// Remembered password lives in localStorage, per public host, on THIS
|
|
173
|
+
// device only — never transmitted (E2E is intact). Keyed by host (not
|
|
174
|
+
// salt) so a changed e2e password is detected and re-prompted.
|
|
175
|
+
const STORE_KEY = 'dshn:e2e:' + location.hostname
|
|
176
|
+
const readSaved = () => { try { return localStorage.getItem(STORE_KEY) } catch { return null } }
|
|
177
|
+
const writeSaved = (v) => { try { if (v == null) localStorage.removeItem(STORE_KEY); else localStorage.setItem(STORE_KEY, v) } catch { /* storage may be blocked */ } }
|
|
178
|
+
|
|
179
|
+
// Derive from a password string and probe /api with a sealed body; on a
|
|
180
|
+
// correct key set the live key and return true. A wrong key → agent 400
|
|
181
|
+
// (or the response fails to open), so return false.
|
|
182
|
+
const attempt = async (pwStr) => {
|
|
183
|
+
try {
|
|
184
|
+
const cand = await deriveKey(pwStr, salt)
|
|
185
|
+
const iv = crypto.getRandomValues(new Uint8Array(12))
|
|
186
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cand, enc.encode('{}')))
|
|
187
|
+
const probeBody = new Uint8Array(iv.length + ct.length); probeBody.set(iv); probeBody.set(ct, iv.length)
|
|
188
|
+
const r = await realFetch('/api/host.describe', { method: 'POST', headers: { [E2E_HEADER]: '1', 'content-type': 'application/json' }, body: probeBody, credentials: 'include' })
|
|
189
|
+
if (r.status === 400) return false
|
|
190
|
+
if (r.headers.get(E2E_HEADER) === '1') { await openBytes(cand, new Uint8Array(await r.arrayBuffer())) }
|
|
191
|
+
key = cand
|
|
192
|
+
return true
|
|
193
|
+
} catch { return false }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
;(async () => {
|
|
197
|
+
// 1. A remembered password unlocks silently — the gate never appears.
|
|
198
|
+
let stale = false
|
|
199
|
+
const saved = readSaved()
|
|
200
|
+
if (saved) {
|
|
201
|
+
if (await attempt(saved)) { window.__dshnE2E.autounlock = true; resolve(); return }
|
|
202
|
+
writeSaved(null); stale = true // the saved one no longer works → drop it and tell the user
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 2. Otherwise show the unlock gate, themed with dsh's own tokens so it
|
|
206
|
+
// matches the app (light/dark aware; dark fallbacks if vars are absent).
|
|
207
|
+
const V = {
|
|
208
|
+
mask: 'var(--dsw-alias-bg-mask-1, rgba(8,10,14,.55))', blur: 'var(--dsw-mask-blur, blur(4px))',
|
|
209
|
+
card: 'var(--dsw-alias-bg-layer-2, #171a1f)', fg: 'var(--dsw-alias-label-primary, #e8eaed)',
|
|
210
|
+
sub: 'var(--dsw-alias-label-tertiary, #9aa0aa)', bd: 'var(--dsw-alias-border-l1, rgba(128,134,142,.35))',
|
|
211
|
+
shadow: 'var(--dsw-shadow-lv3, 0 24px 64px rgba(0,0,0,.5))',
|
|
212
|
+
accent: 'var(--dsw-alias-button-primary-fill, #4176e6)', accentFg: 'var(--dsw-alias-label-primary-foreground, #fff)',
|
|
213
|
+
err: 'var(--dsw-alias-state-error-primary, #e5484d)', warn: 'var(--dsw-alias-state-warn-primary, #d98324)',
|
|
214
|
+
focus: 'var(--dsw-alias-label-primary-bluish, #4176e6)',
|
|
215
|
+
}
|
|
216
|
+
const ov = document.createElement('div')
|
|
217
|
+
ov.setAttribute('style', 'position:fixed;inset:0;z-index:2147483000;display:grid;place-items:center;background:' + V.mask + ';backdrop-filter:' + V.blur + ';font-family:var(--dsw-font-family, system-ui, -apple-system, sans-serif)')
|
|
218
|
+
ov.innerHTML =
|
|
219
|
+
'<form style="width:min(360px,92vw);box-sizing:border-box;padding:22px;border-radius:16px;background:' + V.card + ';color:' + V.fg + ';border:1px solid ' + V.bd + ';box-shadow:' + V.shadow + '">'
|
|
220
|
+
+ '<div style="font-size:15px;font-weight:600;margin-bottom:6px">🔒 ' + L.t + '</div>'
|
|
221
|
+
+ '<div style="font-size:12.5px;color:' + V.sub + ';margin-bottom:16px;line-height:1.5">' + L.s + '</div>'
|
|
222
|
+
+ '<div id="dshn-e2e-err" style="display:none;font-size:12px;margin-bottom:10px;line-height:1.5"></div>'
|
|
223
|
+
+ '<input id="dshn-e2e-pw" type="password" placeholder="' + L.p + '" autocomplete="off" style="width:100%;box-sizing:border-box;padding:10px 12px;border-radius:10px;border:1px solid ' + V.bd + ';background:transparent;color:inherit;font-size:14.5px;outline:none">'
|
|
224
|
+
+ '<label style="display:flex;align-items:center;gap:8px;margin-top:12px;font-size:12.5px;color:' + V.sub + ';cursor:pointer;user-select:none">'
|
|
225
|
+
+ '<input id="dshn-e2e-remember" type="checkbox" checked style="width:15px;height:15px;margin:0;accent-color:' + V.accent + ';cursor:pointer">' + L.save + '</label>'
|
|
226
|
+
+ '<button type="submit" style="width:100%;margin-top:16px;padding:10px;border:0;border-radius:10px;background:' + V.accent + ';color:' + V.accentFg + ';font-size:14.5px;font-weight:500;cursor:pointer">' + L.u + '</button></form>'
|
|
227
|
+
const mount = () => document.body.appendChild(ov)
|
|
228
|
+
if (document.body) mount(); else document.addEventListener('DOMContentLoaded', mount)
|
|
229
|
+
const form = ov.querySelector('form'), pw = ov.querySelector('#dshn-e2e-pw'), err = ov.querySelector('#dshn-e2e-err'), remember = ov.querySelector('#dshn-e2e-remember')
|
|
230
|
+
pw.addEventListener('focus', () => { pw.style.borderColor = V.focus })
|
|
231
|
+
pw.addEventListener('blur', () => { pw.style.borderColor = V.bd })
|
|
232
|
+
const showErr = (msg, color) => { err.textContent = msg; err.style.color = color; err.style.display = 'block' }
|
|
233
|
+
if (stale) showErr(L.stale, V.warn) // the "saved password no longer works" notice
|
|
234
|
+
form.addEventListener('submit', async (e) => {
|
|
235
|
+
e.preventDefault()
|
|
236
|
+
const btn = form.querySelector('button'); btn.disabled = true
|
|
237
|
+
if (await attempt(pw.value)) {
|
|
238
|
+
writeSaved(remember.checked ? pw.value : null)
|
|
239
|
+
ov.remove()
|
|
240
|
+
resolve()
|
|
241
|
+
} else { showErr(L.bad, V.err); btn.disabled = false; pw.select() }
|
|
242
|
+
})
|
|
243
|
+
setTimeout(() => pw.focus(), 50)
|
|
244
|
+
})()
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
})()
|
|
248
|
+
|
|
249
|
+
const CSS = `
|
|
250
|
+
.dshn-root.dshn-root { position: fixed; left: 12px; bottom: 12px; z-index: 40;
|
|
251
|
+
font-size: 12px; line-height: 1.4; color: var(--dsw-alias-label-primary, #1c1e21); pointer-events: auto; }
|
|
252
|
+
.dshn-pill { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px;
|
|
253
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(128,134,142,.3)); border-radius: 999px;
|
|
254
|
+
background: var(--dsw-alias-bg-layer-2, #f4f5f7); cursor: pointer; user-select: none; }
|
|
255
|
+
.dshn-pill svg { display: block; }
|
|
256
|
+
/* A full-width footer row that mirrors dsh's own "设置" entry (42px, 16px icon,
|
|
257
|
+
8px gap, radius 12px), sitting directly above it: globe + label on the left,
|
|
258
|
+
the live latency (or connection state) as a muted mono value on the right. */
|
|
259
|
+
.dshn-frow { display: flex; flex-direction: row; align-items: center; gap: 8px; flex: 1 1 auto; box-sizing: border-box;
|
|
260
|
+
height: 42px; margin: 2px -2px 0; padding: 0 10px 0 8px; border: 0; border-radius: 12px; background: transparent; cursor: pointer;
|
|
261
|
+
color: var(--dsw-alias-label-primary, #1c1e21); font-size: 14px; font-weight: 400; text-align: left; }
|
|
262
|
+
.dshn-frow:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,134,142,.12)); }
|
|
263
|
+
.dshn-frow svg { display: block; }
|
|
264
|
+
.dshn-frow-ic { display: inline-flex; flex: 0 0 auto; }
|
|
265
|
+
.dshn-frow-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
266
|
+
.dshn-frow-trail { flex: 0 0 auto; font-size: 11px; line-height: 1; font-variant-numeric: tabular-nums; font-family: ui-monospace, Menlo, monospace; }
|
|
267
|
+
.dshn-section { max-width: 460px; }
|
|
268
|
+
.dshn-section-intro { color: var(--dsw-alias-label-tertiary, #8b9099); font-size: 12.5px; margin-bottom: 16px; line-height: 1.5; }
|
|
269
|
+
.dshn-panel[data-mode="section"] { border: 0; box-shadow: none; width: 100%; padding: 0; background: transparent; }
|
|
270
|
+
.dshn-pill-lat { display: inline-flex; align-items: center; gap: 5px; font-variant-numeric: tabular-nums; }
|
|
271
|
+
.dshn-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--dsw-alias-label-tertiary, #8b9099); }
|
|
272
|
+
.dshn-dot[data-on="1"] { background: var(--dsw-alias-state-success-primary, #3aa675); }
|
|
273
|
+
.dshn-dot[data-warn="1"] { background: var(--dsw-alias-state-warn-primary, #d98324); }
|
|
274
|
+
.dshn-dot[data-err="1"] { background: var(--dsw-alias-state-error-primary, #e5484d); }
|
|
275
|
+
|
|
276
|
+
.dshn-backdrop { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center;
|
|
277
|
+
background: rgba(8,10,14,.52); backdrop-filter: blur(2px); }
|
|
278
|
+
.dshn-panel { box-sizing: border-box; border-radius: 13px; background: var(--dsw-alias-bg-layer-3, #fff);
|
|
279
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(128,134,142,.25)); }
|
|
280
|
+
.dshn-panel[data-mode="modal"] { width: min(400px, 92vw); padding: 20px 22px 18px; box-shadow: 0 24px 64px rgba(0,0,0,.34); }
|
|
281
|
+
.dshn-panel[data-mode="card"] { width: 308px; margin-top: 8px; padding: 14px 15px 13px; box-shadow: 0 8px 24px rgba(0,0,0,.16); }
|
|
282
|
+
.dshn-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
|
|
283
|
+
.dshn-htitle { font-size: 14.5px; font-weight: 650; }
|
|
284
|
+
.dshn-hsub { color: var(--dsw-alias-label-tertiary, #8b9099); font-size: 11.5px; margin-top: 2px; }
|
|
285
|
+
.dshn-x { border: 0; background: transparent; cursor: pointer; font-size: 17px; line-height: 1; padding: 2px 4px;
|
|
286
|
+
color: var(--dsw-alias-label-tertiary, #8b9099); flex: none; }
|
|
287
|
+
.dshn-x:hover { color: var(--dsw-alias-label-primary, #1c1e21); }
|
|
288
|
+
|
|
289
|
+
.dshn-status { display: flex; align-items: center; gap: 7px; margin-bottom: 11px; font-size: 12px; }
|
|
290
|
+
.dshn-url { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
291
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
292
|
+
.dshn-addr { display: flex; align-items: center; gap: 6px; margin-bottom: 12px; padding: 8px 8px 8px 11px;
|
|
293
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(128,134,142,.25)); border-radius: 10px;
|
|
294
|
+
background: var(--dsw-alias-bg-layer-2, #f4f5f7); }
|
|
295
|
+
.dshn-addr-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--dsw-alias-label-tertiary, #8b9099); }
|
|
296
|
+
.dshn-addr-dot[data-on="1"] { background: var(--dsw-alias-state-success-primary, #3aa675); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-success-primary, #3aa675) 20%, transparent); }
|
|
297
|
+
.dshn-addr-url { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
298
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; }
|
|
299
|
+
.dshn-addr-scheme { color: var(--dsw-alias-label-tertiary, #8b9099); }
|
|
300
|
+
.dshn-addr-host { color: var(--dsw-alias-label-primary, #1c1e21); font-weight: 500; }
|
|
301
|
+
.dshn-addr-btn { display: inline-flex; align-items: center; justify-content: center; flex: none;
|
|
302
|
+
width: 26px; height: 26px; border: 0; border-radius: 7px; background: transparent; cursor: pointer;
|
|
303
|
+
color: var(--dsw-alias-label-tertiary, #8b9099); text-decoration: none; }
|
|
304
|
+
.dshn-addr-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,134,142,.16)); color: var(--dsw-alias-label-primary, #1c1e21); }
|
|
305
|
+
|
|
306
|
+
.dshn-field { display: block; margin-bottom: 11px; }
|
|
307
|
+
.dshn-field > span { display: block; color: var(--dsw-alias-label-tertiary, #8b9099); font-size: 11px; margin-bottom: 4px; }
|
|
308
|
+
.dshn-prefixwrap { display: flex; align-items: stretch; }
|
|
309
|
+
.dshn-input { width: 100%; box-sizing: border-box; padding: 8px 10px; font-size: 13.5px;
|
|
310
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(128,134,142,.35)); border-radius: 8px; background: transparent; color: inherit; }
|
|
311
|
+
.dshn-input:focus { outline: 2px solid var(--dsw-alias-label-primary-bluish, #4176e6); outline-offset: -1px; }
|
|
312
|
+
.dshn-input[data-bad="1"] { border-color: var(--dsw-alias-state-error-primary, #e5484d); }
|
|
313
|
+
.dshn-prefixwrap .dshn-input { border-radius: 8px 0 0 8px; }
|
|
314
|
+
.dshn-apex { display: flex; align-items: center; padding: 0 10px; font-size: 12.5px; color: var(--dsw-alias-label-tertiary, #8b9099);
|
|
315
|
+
white-space: nowrap; border: 1px solid var(--dsw-alias-border-l1, rgba(128,134,142,.35)); border-left: 0; border-radius: 0 8px 8px 0;
|
|
316
|
+
background: var(--dsw-alias-bg-layer-2, #f4f5f7); font-family: ui-monospace, Menlo, monospace; }
|
|
317
|
+
.dshn-pwwrap { position: relative; display: flex; align-items: stretch; }
|
|
318
|
+
.dshn-pwwrap .dshn-input { padding-right: 84px; }
|
|
319
|
+
.dshn-pwbtns { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); display: flex; gap: 2px; }
|
|
320
|
+
.dshn-mini { border: 0; background: transparent; cursor: pointer; font-size: 11px; padding: 3px 5px; border-radius: 5px;
|
|
321
|
+
color: var(--dsw-alias-label-tertiary, #8b9099); }
|
|
322
|
+
.dshn-mini:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,134,142,.14)); color: var(--dsw-alias-label-primary, #1c1e21); }
|
|
323
|
+
.dshn-meter { height: 5px; border-radius: 3px; margin-top: 6px; background: var(--dsw-alias-border-l3, rgba(128,134,142,.25)); overflow: hidden; }
|
|
324
|
+
.dshn-meter > i { display: block; height: 100%; width: 0; transition: width .18s ease, background .18s ease; }
|
|
325
|
+
.dshn-strength { font-size: 10.5px; margin-top: 3px; }
|
|
326
|
+
|
|
327
|
+
.dshn-actions { display: flex; gap: 8px; align-items: center; margin-top: 3px; }
|
|
328
|
+
.dshn-primary { flex: 1; padding: 9px; border: 0; border-radius: 8px; cursor: pointer; background: #4176e6; color: #fff; font-size: 13.5px; }
|
|
329
|
+
.dshn-primary:disabled { opacity: .5; cursor: default; }
|
|
330
|
+
.dshn-ghost { border: 0; background: transparent; cursor: pointer; color: var(--dsw-alias-label-tertiary, #8b9099); font-size: 12.5px; padding: 9px 8px; }
|
|
331
|
+
.dshn-err { color: var(--dsw-alias-state-error-primary, #e5484d); font-size: 11.5px; margin-bottom: 9px; }
|
|
332
|
+
.dshn-hint { color: var(--dsw-alias-label-tertiary, #8b9099); font-size: 10.5px; margin-top: 3px; }
|
|
333
|
+
.dshn-note { color: var(--dsw-alias-state-warn-primary, #d98324); font-size: 10.5px; margin: 2px 0 10px; }
|
|
334
|
+
.dshn-e2e-box { margin: 4px 0 12px; padding: 11px 12px; border-radius: 10px;
|
|
335
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(128,134,142,.25)); background: var(--dsw-alias-bg-layer-2, #f4f5f7); }
|
|
336
|
+
.dshn-e2e-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px;
|
|
337
|
+
color: var(--dsw-alias-label-tertiary, #8b9099); font-size: 11px; }
|
|
338
|
+
.dshn-e2e-actions { display: flex; gap: 8px; align-items: center; margin-top: 9px; }
|
|
339
|
+
.dshn-btn-sm { padding: 6px 12px; border: 0; border-radius: 7px; cursor: pointer; font-size: 12px;
|
|
340
|
+
background: #4176e6; color: #fff; }
|
|
341
|
+
.dshn-btn-sm:disabled { opacity: .5; cursor: default; }
|
|
342
|
+
.dshn-btn-warn { background: transparent; color: var(--dsw-alias-state-error-primary, #e5484d);
|
|
343
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(128,134,142,.3)); }
|
|
344
|
+
.dshn-btn-warn:hover:not(:disabled) { background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #e5484d) 10%, transparent); }
|
|
345
|
+
.dshn-info { border: 1px solid var(--dsw-alias-border-l2, rgba(128,134,142,.22)); border-radius: 9px;
|
|
346
|
+
padding: 8px 10px; margin-bottom: 12px; }
|
|
347
|
+
.dshn-info-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; font-size: 11.5px; padding: 3px 0; }
|
|
348
|
+
.dshn-info-k { display: inline-flex; align-items: center; gap: 7px; color: var(--dsw-alias-label-tertiary, #8b9099); }
|
|
349
|
+
.dshn-info-k svg { flex: none; opacity: .85; }
|
|
350
|
+
.dshn-info-v { font-family: ui-monospace, Menlo, monospace; text-align: right; font-variant-numeric: tabular-nums; }
|
|
351
|
+
.dshn-dcwarn { border: 1px solid var(--dsw-alias-state-error-primary, #e5484d); background: rgba(229,72,77,.08);
|
|
352
|
+
border-radius: 9px; padding: 10px 11px; margin-bottom: 11px; }
|
|
353
|
+
.dshn-dcwarn-title { font-weight: 640; font-size: 12.5px; margin-bottom: 5px; }
|
|
354
|
+
.dshn-dcwarn-body { font-size: 11.5px; color: var(--dsw-alias-label-secondary, #4a4f57); }
|
|
355
|
+
.dshn-danger { flex: 1; padding: 9px; border: 0; border-radius: 8px; cursor: pointer;
|
|
356
|
+
background: var(--dsw-alias-state-error-primary, #e5484d); color: #fff; font-size: 13px; }
|
|
357
|
+
`
|
|
358
|
+
const cssId = ID + '/widget.css'
|
|
359
|
+
if (typeof document !== 'undefined'
|
|
360
|
+
&& document.querySelector('style[data-plugin-css=' + JSON.stringify(cssId) + ']') === null) {
|
|
361
|
+
const tag = document.createElement('style')
|
|
362
|
+
tag.dataset.plugin = ID; tag.dataset.pluginCss = cssId; tag.textContent = CSS
|
|
363
|
+
document.head.appendChild(tag)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const zh = String(document.documentElement.lang || navigator.language || 'en').toLowerCase().indexOf('zh') === 0
|
|
367
|
+
const T = zh
|
|
368
|
+
? { brand: '公网转发 · ds.hn', connecting: '连接中…', live: '已上线', off: '未连接', notset: '未配置',
|
|
369
|
+
setupTitle: '开启公网转发', setupSub: '设置域名前缀和访问密码——这两项就是你的凭据。',
|
|
370
|
+
connTitle: '公网转发', prefix: '域名前缀', password: '访问密码', confirm: '再次输入密码',
|
|
371
|
+
connect: '连接', reconnect: '重新连接', connecting2: '连接中…', later: '稍后', disconnect: '断开并重设',
|
|
372
|
+
show: '显示', hide: '隐藏', copy: '复制', copied: '已复制', mismatch: '两次输入的密码不一致。',
|
|
373
|
+
prefixHint: '只能小写字母、数字、连字符,4–32 位。',
|
|
374
|
+
pwHint: '至少 8 位。云端只存哈希、无法找回,请记牢或用密码管理器保存。',
|
|
375
|
+
recoverNote: '⚠ 云端不保存明文密码、也无法找回,请务必自行保存。',
|
|
376
|
+
savedHint: '手机访问用这个密码登录。忘记时点“复制/显示”取回。',
|
|
377
|
+
weak: '弱', fair: '一般', good: '较强', strong: '强',
|
|
378
|
+
infoRelay: '线路', infoMode: { direct: '直连源站', cloudflare: '经 Cloudflare' },
|
|
379
|
+
infoUptime: '在线时长', infoServed: '已转发请求', infoPort: '本地端口', infoLatency: '延迟',
|
|
380
|
+
e2eLabel: '端到端密码(可选)', e2eHint: '设置后,会话内容用它加密,云端也看不到;密码不出本机。访问时需在网页再输一次。',
|
|
381
|
+
e2eApply: '设置端到端密码', e2eUpdate: '更新端到端密码', e2eDisable: '关闭加密', e2eApplied: '✓ 端到端加密已开启', e2eOff2: '✓ 端到端加密已关闭', e2eIndep: '独立设置,不影响上面的连接。',
|
|
382
|
+
infoE2E: '端到端加密', e2eOn: '已开启', e2eOff: '未开启',
|
|
383
|
+
navLabel: '公网转发', sectionIntro: '把本机的 dsh 转发到公网。前缀 + 访问密码即凭据;可选设置端到端密码进一步加密内容。',
|
|
384
|
+
localOnly: '公网转发的配置只能在本机(打开 dsh 的这台机器)进行。', loading: '加载中…',
|
|
385
|
+
openSettings: '打开设置', open: '打开', addrLabel: '公网地址',
|
|
386
|
+
dcTitle: '确认断开公网转发?', dcWarn: '断开会立即切断公网访问。云端不保存你的密码、无法找回;若你没有另存密码,之后可能无法用同一前缀重新连接。',
|
|
387
|
+
dcConfirm: '确认断开', dcCancel: '取消', dcCopyFirst: '先复制密码' }
|
|
388
|
+
: { brand: 'Public forwarding · ds.hn', connecting: 'connecting…', live: 'live', off: 'off', notset: 'not set up',
|
|
389
|
+
setupTitle: 'Set up public forwarding', setupSub: 'Pick a subdomain prefix and an access password — the two are your credential.',
|
|
390
|
+
connTitle: 'Public forwarding', prefix: 'Subdomain prefix', password: 'Access password', confirm: 'Confirm password',
|
|
391
|
+
connect: 'Connect', reconnect: 'Reconnect', connecting2: 'Connecting…', later: 'Later', disconnect: 'Disconnect & reset',
|
|
392
|
+
show: 'show', hide: 'hide', copy: 'copy', copied: 'copied', mismatch: 'Passwords do not match.',
|
|
393
|
+
prefixHint: 'Lowercase letters, digits, hyphens — 4–32 chars.',
|
|
394
|
+
pwHint: 'At least 8 characters. The cloud stores only a hash — no recovery, so save it.',
|
|
395
|
+
recoverNote: '⚠ The cloud never stores your password and cannot recover it — save it yourself.',
|
|
396
|
+
savedHint: 'Log in from a phone with this password. Copy/show it here if you forget.',
|
|
397
|
+
weak: 'weak', fair: 'fair', good: 'good', strong: 'strong',
|
|
398
|
+
infoRelay: 'Link', infoMode: { direct: 'direct to origin', cloudflare: 'via Cloudflare' },
|
|
399
|
+
infoUptime: 'Uptime', infoServed: 'Requests served', infoPort: 'Local port', infoLatency: 'Latency',
|
|
400
|
+
e2eLabel: 'End-to-end password (optional)', e2eHint: 'If set, session content is encrypted with it — even the cloud cannot read it, and it never leaves this machine. Visitors enter it again in the browser.',
|
|
401
|
+
e2eApply: 'Set e2e password', e2eUpdate: 'Update e2e password', e2eDisable: 'Turn off', e2eApplied: '✓ End-to-end encryption on', e2eOff2: '✓ End-to-end encryption off', e2eIndep: 'Applied on its own — does not affect the connection above.',
|
|
402
|
+
infoE2E: 'End-to-end encryption', e2eOn: 'on', e2eOff: 'off',
|
|
403
|
+
navLabel: 'Public forwarding', sectionIntro: 'Forward this machine’s dsh to the public internet. The subdomain + access password are your credential; an optional end-to-end password further encrypts the content.',
|
|
404
|
+
localOnly: 'Public-forwarding settings can only be changed on this machine (where dsh is running).', loading: 'Loading…',
|
|
405
|
+
openSettings: 'open settings', open: 'open', addrLabel: 'Public address',
|
|
406
|
+
dcTitle: 'Disconnect public forwarding?', dcWarn: 'This immediately cuts off public access. The cloud does not store your password and cannot recover it — if you have not saved it elsewhere, you may not be able to reconnect with the same prefix.',
|
|
407
|
+
dcConfirm: 'Disconnect', dcCancel: 'Cancel', dcCopyFirst: 'Copy password first' }
|
|
408
|
+
|
|
409
|
+
function strength(pw) {
|
|
410
|
+
if (pw.length < MIN_PW) return { score: 0, ok: false }
|
|
411
|
+
let s = 1
|
|
412
|
+
if (pw.length >= 12) s++
|
|
413
|
+
const classes = (/[a-z]/.test(pw) ? 1 : 0) + (/[A-Z]/.test(pw) ? 1 : 0) + (/\d/.test(pw) ? 1 : 0) + (/[^a-zA-Z0-9]/.test(pw) ? 1 : 0)
|
|
414
|
+
if (classes >= 2) s++
|
|
415
|
+
if (classes >= 3 && pw.length >= 10) s++
|
|
416
|
+
return { score: Math.min(s, 4), ok: s >= 2 }
|
|
417
|
+
}
|
|
418
|
+
const SC = ['#e5484d', '#e5484d', '#d98324', '#3aa675', '#3aa675']
|
|
419
|
+
const slabel = (n) => [T.weak, T.weak, T.fair, T.good, T.strong][n]
|
|
420
|
+
|
|
421
|
+
// ── inline line-icons (no external assets; inherit currentColor) ──────────
|
|
422
|
+
const SVG = { width: 13, height: 13, viewBox: '0 0 14 14', fill: 'none', stroke: 'currentColor',
|
|
423
|
+
strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round' }
|
|
424
|
+
const P = (d) => h('path', { key: d, d })
|
|
425
|
+
const ICONS = {
|
|
426
|
+
globe: () => [h('circle', { key: 'c', cx: 7, cy: 7, r: 5.3 }), P('M1.7 7h10.6'),
|
|
427
|
+
P('M7 1.7c2.3 2.3 2.3 8.3 0 10.6'), P('M7 1.7c-2.3 2.3-2.3 8.3 0 10.6')],
|
|
428
|
+
cloud: () => [P('M4.4 10.6a2.6 2.6 0 01.2-5.2 3.4 3.4 0 016.5.9 2.2 2.2 0 01-.4 4.3z')],
|
|
429
|
+
plug: () => [P('M5 2.3v2.2M9 2.3v2.2'), P('M4 4.6h6v1.9a3 3 0 01-6 0z'), P('M7 9.4v2.3')],
|
|
430
|
+
gauge: () => [P('M2.2 10.4a5 5 0 019.6 0'), P('M7 10.4l2.4-2.7'), h('circle', { key: 'd', cx: 7, cy: 10.4, r: .5, fill: 'currentColor' })],
|
|
431
|
+
clock: () => [h('circle', { key: 'c', cx: 7, cy: 7, r: 5.3 }), P('M7 4.1v3.1l2 1.2')],
|
|
432
|
+
swap: () => [P('M3.4 5h7.2l-2-2'), P('M10.6 9H3.4l2 2')],
|
|
433
|
+
server: () => [h('rect', { key: 'a', x: 2.3, y: 2.4, width: 9.4, height: 3.7, rx: 1 }),
|
|
434
|
+
h('rect', { key: 'b', x: 2.3, y: 7.4, width: 9.4, height: 3.7, rx: 1 }),
|
|
435
|
+
P('M4.4 4.25h.01'), P('M4.4 9.25h.01')],
|
|
436
|
+
lock: () => [h('rect', { key: 'a', x: 2.8, y: 6.3, width: 8.4, height: 5.4, rx: 1.2 }), P('M4.7 6.3V4.6a2.3 2.3 0 014.6 0v1.7')],
|
|
437
|
+
unlock: () => [h('rect', { key: 'a', x: 2.8, y: 6.3, width: 8.4, height: 5.4, rx: 1.2 }), P('M4.7 6.3V4.6a2.3 2.3 0 014.5-.5')],
|
|
438
|
+
external: () => [P('M8 2.6h3.4V6'), P('M11.4 2.6L6.4 7.6'), P('M9.6 8.4v2.2a1 1 0 01-1 1H3.4a1 1 0 01-1-1V5.4a1 1 0 011-1h2.2')],
|
|
439
|
+
copy: () => [h('rect', { key: 'a', x: 4.6, y: 4.6, width: 6.8, height: 6.8, rx: 1.3 }), P('M9.4 4.6V3.4a1 1 0 00-1-1H3.4a1 1 0 00-1 1v5a1 1 0 001 1h1.2')],
|
|
440
|
+
}
|
|
441
|
+
const Icon = (name, extra) => h('svg', Object.assign({}, SVG, extra), ICONS[name]())
|
|
442
|
+
|
|
443
|
+
function latColor(ms) { return ms == null ? 'var(--dsw-alias-label-tertiary, #8b9099)' : ms <= 90 ? '#3aa675' : ms <= 200 ? '#d98324' : '#e5484d' }
|
|
444
|
+
|
|
445
|
+
// Signal-strength bars, lit count + colour from latency; shown on the pill.
|
|
446
|
+
function SignalBars(props) {
|
|
447
|
+
const lit = props.ms == null ? 0 : props.ms <= 45 ? 4 : props.ms <= 90 ? 3 : props.ms <= 200 ? 2 : 1
|
|
448
|
+
const hs = [4, 6.5, 9, 11.5]
|
|
449
|
+
return h('svg', { width: 15, height: 13, viewBox: '0 0 15 13' },
|
|
450
|
+
hs.map((hh, i) => h('rect', { key: i, x: 0.5 + i * 3.6, y: 12.5 - hh, width: 2.4, height: hh, rx: .7,
|
|
451
|
+
fill: i < lit ? props.color : 'var(--dsw-alias-label-tertiary, #8b9099)', opacity: i < lit ? 1 : .28 })))
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function uptime(since) {
|
|
455
|
+
if (!since) return '—'
|
|
456
|
+
let s = Math.max(0, Math.floor((Date.now() - since) / 1000))
|
|
457
|
+
const d = Math.floor(s / 86400); s -= d * 86400
|
|
458
|
+
const hh = Math.floor(s / 3600); s -= hh * 3600
|
|
459
|
+
const mm = Math.floor(s / 60); s -= mm * 60
|
|
460
|
+
if (d > 0) return `${d}d ${hh}h`
|
|
461
|
+
if (hh > 0) return `${hh}h ${mm}m`
|
|
462
|
+
if (mm > 0) return `${mm}m ${s}s`
|
|
463
|
+
return `${s}s`
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// The one panel. `mode` is 'modal' or 'card' (styling + which dismiss it shows).
|
|
467
|
+
function Panel(props) {
|
|
468
|
+
const s = props.status
|
|
469
|
+
const configured = s.configured
|
|
470
|
+
const apex = s.apex || 'ds.hn'
|
|
471
|
+
const [prefix, setPrefix] = react.useState(configured ? (s.subdomain || '') : '')
|
|
472
|
+
const [pw, setPw] = react.useState(configured ? (s.password || '') : '')
|
|
473
|
+
const [confirm, setConfirm] = react.useState('')
|
|
474
|
+
const [showPw, setShowPw] = react.useState(false)
|
|
475
|
+
const [busy, setBusy] = react.useState(false)
|
|
476
|
+
const [err, setErr] = react.useState('')
|
|
477
|
+
const [copied, setCopied] = react.useState('')
|
|
478
|
+
const [confirmDc, setConfirmDc] = react.useState(false)
|
|
479
|
+
const [e2e, setE2e] = react.useState(configured ? (s.e2ePassword || '') : '')
|
|
480
|
+
const [e2eBusy, setE2eBusy] = react.useState(false)
|
|
481
|
+
const [e2eErr, setE2eErr] = react.useState('')
|
|
482
|
+
const [e2eMsg, setE2eMsg] = react.useState('')
|
|
483
|
+
|
|
484
|
+
// When the saved passwords arrive from a later status poll, fill them once.
|
|
485
|
+
react.useEffect(() => {
|
|
486
|
+
if (configured && s.password && pw === '') setPw(s.password)
|
|
487
|
+
}, [s.password])
|
|
488
|
+
react.useEffect(() => {
|
|
489
|
+
if (configured && s.e2ePassword && e2e === '') setE2e(s.e2ePassword)
|
|
490
|
+
}, [s.e2ePassword])
|
|
491
|
+
|
|
492
|
+
const copyText = (text, tag) => {
|
|
493
|
+
if (!text || !navigator.clipboard) return
|
|
494
|
+
navigator.clipboard.writeText(text).then(() => { setCopied(tag); setTimeout(() => setCopied(''), 1200) }).catch(() => {})
|
|
495
|
+
}
|
|
496
|
+
const copyPw = () => copyText(pw, 'pw')
|
|
497
|
+
const disconnect = () => { fetch('/dshn/disconnect', { method: 'POST' }).catch(() => {}) }
|
|
498
|
+
// Apply the e2e password on its own — a dedicated endpoint that never
|
|
499
|
+
// touches the connection or the main credentials.
|
|
500
|
+
const applyE2E = (value) => {
|
|
501
|
+
setE2eBusy(true); setE2eErr(''); setE2eMsg('')
|
|
502
|
+
fetch('/dshn/e2e', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ e2ePassword: value }) })
|
|
503
|
+
.then((r) => r.json().then((j) => ({ ok: r.ok, j })))
|
|
504
|
+
.then(({ ok, j }) => { setE2eBusy(false); if (ok) setE2eMsg(j.enabled ? T.e2eApplied : T.e2eOff2); else setE2eErr((j && j.error) || 'failed') })
|
|
505
|
+
.catch((e) => { setE2eBusy(false); setE2eErr(String(e)) })
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const st = strength(pw)
|
|
509
|
+
const matches = confirm === pw
|
|
510
|
+
// First-time setup can also set the e2e password inline (optional), so the
|
|
511
|
+
// whole thing is one flow. Once configured, e2e moves to its own dedicated
|
|
512
|
+
// control below (its own apply button, independent of the connection).
|
|
513
|
+
const e2eRegOk = configured || e2e.trim() === '' || e2e.trim().length >= MIN_PW
|
|
514
|
+
const canSubmit = prefix.trim().length > 0 && !busy && e2eRegOk
|
|
515
|
+
&& (configured ? pw.length >= MIN_PW : (st.ok && confirm.length > 0 && matches))
|
|
516
|
+
|
|
517
|
+
const submit = () => {
|
|
518
|
+
if (!canSubmit) return
|
|
519
|
+
setBusy(true); setErr('')
|
|
520
|
+
const e2eVal = e2e.trim()
|
|
521
|
+
fetch('/dshn/configure', { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
522
|
+
body: JSON.stringify({ subdomain: prefix.trim().toLowerCase(), password: pw }) })
|
|
523
|
+
.then((r) => r.json().then((j) => ({ ok: r.ok, j })))
|
|
524
|
+
.then(async ({ ok, j }) => {
|
|
525
|
+
if (!ok) { setBusy(false); setErr((j && j.error) || 'failed'); return }
|
|
526
|
+
// Same flow: if setting up for the first time and an e2e password was
|
|
527
|
+
// entered, enable it now too — no separate trip to a second control.
|
|
528
|
+
if (!configured && e2eVal !== '') {
|
|
529
|
+
try { await fetch('/dshn/e2e', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ e2ePassword: e2eVal }) }) } catch { /* non-fatal: the tunnel is up; e2e can be set later */ }
|
|
530
|
+
}
|
|
531
|
+
setBusy(false)
|
|
532
|
+
if (props.onClose) props.onClose()
|
|
533
|
+
})
|
|
534
|
+
.catch((e) => { setBusy(false); setErr(String(e)) })
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const liveErr = err || (s.lastError && !s.connected ? s.lastError : '')
|
|
538
|
+
|
|
539
|
+
return h('div', { className: 'dshn-panel', 'data-mode': props.mode },
|
|
540
|
+
// In a settings section the nav label + intro already title the page, so
|
|
541
|
+
// the panel's own header is dropped there.
|
|
542
|
+
props.mode === 'section' ? null : h('div', { className: 'dshn-head' },
|
|
543
|
+
h('div', null,
|
|
544
|
+
h('div', { className: 'dshn-htitle' }, configured ? T.connTitle : T.setupTitle),
|
|
545
|
+
h('div', { className: 'dshn-hsub' }, configured
|
|
546
|
+
? (s.connected ? T.live : T.connecting)
|
|
547
|
+
: T.setupSub)),
|
|
548
|
+
props.onClose ? h('button', { className: 'dshn-x', title: 'close', onClick: props.onClose }, '×') : null),
|
|
549
|
+
|
|
550
|
+
configured && s.publicUrl ? (() => {
|
|
551
|
+
const host = s.publicUrl.replace(/^https?:\/\//, '').replace(/\/$/, '')
|
|
552
|
+
return h('div', { className: 'dshn-addr' },
|
|
553
|
+
h('span', { className: 'dshn-addr-dot', 'data-on': s.connected ? '1' : '0' }),
|
|
554
|
+
h('div', { className: 'dshn-addr-url' },
|
|
555
|
+
h('span', { className: 'dshn-addr-scheme' }, 'https://'),
|
|
556
|
+
h('span', { className: 'dshn-addr-host' }, host)),
|
|
557
|
+
h('a', { className: 'dshn-addr-btn', href: s.publicUrl, target: '_blank', rel: 'noreferrer', title: T.open }, Icon('external', { width: 14, height: 14 })),
|
|
558
|
+
h('button', { className: 'dshn-addr-btn', title: T.copy, onClick: () => copyText(s.publicUrl, 'url') }, copied === 'url' ? h('span', { style: { fontSize: '10px' } }, T.copied) : Icon('copy', { width: 14, height: 14 })))
|
|
559
|
+
})() : null,
|
|
560
|
+
|
|
561
|
+
configured && s.connected ? h('div', { className: 'dshn-info' },
|
|
562
|
+
h('div', { className: 'dshn-info-row' },
|
|
563
|
+
h('span', { className: 'dshn-info-k' }, Icon(s.mode === 'direct' ? 'plug' : 'cloud'), T.infoRelay),
|
|
564
|
+
h('span', { className: 'dshn-info-v' }, (T.infoMode[s.mode] || s.mode || '') + (s.relayHost ? ' · ' + s.relayHost : ''))),
|
|
565
|
+
h('div', { className: 'dshn-info-row' },
|
|
566
|
+
h('span', { className: 'dshn-info-k' }, Icon('gauge'), T.infoLatency),
|
|
567
|
+
h('span', { className: 'dshn-info-v', style: { color: latColor(s.latencyMs) } }, s.latencyMs == null ? '—' : s.latencyMs + ' ms')),
|
|
568
|
+
h('div', { className: 'dshn-info-row' },
|
|
569
|
+
h('span', { className: 'dshn-info-k' }, Icon('clock'), T.infoUptime),
|
|
570
|
+
h('span', { className: 'dshn-info-v' }, uptime(s.connectedSince))),
|
|
571
|
+
h('div', { className: 'dshn-info-row' },
|
|
572
|
+
h('span', { className: 'dshn-info-k' }, Icon('swap'), T.infoServed),
|
|
573
|
+
h('span', { className: 'dshn-info-v' }, String(s.served == null ? '—' : s.served))),
|
|
574
|
+
s.localPort ? h('div', { className: 'dshn-info-row' },
|
|
575
|
+
h('span', { className: 'dshn-info-k' }, Icon('server'), T.infoPort),
|
|
576
|
+
h('span', { className: 'dshn-info-v' }, String(s.localPort))) : null,
|
|
577
|
+
h('div', { className: 'dshn-info-row' },
|
|
578
|
+
h('span', { className: 'dshn-info-k' }, Icon(s.e2eEnabled ? 'lock' : 'unlock'), T.infoE2E),
|
|
579
|
+
h('span', { className: 'dshn-info-v', style: { color: s.e2eEnabled ? '#3aa675' : undefined } }, s.e2eEnabled ? T.e2eOn : T.e2eOff))) : null,
|
|
580
|
+
|
|
581
|
+
liveErr ? h('div', { className: 'dshn-err' }, liveErr) : null,
|
|
582
|
+
|
|
583
|
+
h('label', { className: 'dshn-field' },
|
|
584
|
+
h('span', null, T.prefix),
|
|
585
|
+
h('div', { className: 'dshn-prefixwrap' },
|
|
586
|
+
h('input', { className: 'dshn-input', value: prefix, placeholder: 'alice', autoFocus: !configured,
|
|
587
|
+
onChange: (e) => setPrefix(e.target.value.toLowerCase()) }),
|
|
588
|
+
h('span', { className: 'dshn-apex' }, '.' + apex)),
|
|
589
|
+
!configured ? h('div', { className: 'dshn-hint' }, T.prefixHint) : null),
|
|
590
|
+
|
|
591
|
+
h('label', { className: 'dshn-field' },
|
|
592
|
+
h('span', null, T.password),
|
|
593
|
+
h('div', { className: 'dshn-pwwrap' },
|
|
594
|
+
h('input', { className: 'dshn-input', type: showPw ? 'text' : 'password', value: pw,
|
|
595
|
+
autoComplete: configured ? 'off' : 'new-password', placeholder: '••••••••',
|
|
596
|
+
onChange: (e) => setPw(e.target.value),
|
|
597
|
+
onKeyDown: (e) => { if (e.key === 'Enter' && configured) submit() } }),
|
|
598
|
+
h('div', { className: 'dshn-pwbtns' },
|
|
599
|
+
h('button', { className: 'dshn-mini', type: 'button', onClick: () => setShowPw(!showPw) }, showPw ? T.hide : T.show),
|
|
600
|
+
h('button', { className: 'dshn-mini', type: 'button', onClick: copyPw }, copied === 'pw' ? T.copied : T.copy))),
|
|
601
|
+
!configured && pw.length > 0 ? h('div', { className: 'dshn-meter' },
|
|
602
|
+
h('i', { style: { width: ((st.score + 1) * 20) + '%', background: SC[st.score] } })) : null,
|
|
603
|
+
!configured && pw.length > 0 ? h('div', { className: 'dshn-strength', style: { color: SC[st.score] } }, slabel(st.score)) : null,
|
|
604
|
+
h('div', { className: 'dshn-hint' }, configured ? T.savedHint : T.pwHint)),
|
|
605
|
+
|
|
606
|
+
!configured ? h('label', { className: 'dshn-field' },
|
|
607
|
+
h('span', null, T.confirm),
|
|
608
|
+
h('input', { className: 'dshn-input', type: showPw ? 'text' : 'password', value: confirm,
|
|
609
|
+
'data-bad': confirm.length > 0 && !matches ? '1' : '0', autoComplete: 'new-password', placeholder: '••••••••',
|
|
610
|
+
onChange: (e) => setConfirm(e.target.value), onKeyDown: (e) => { if (e.key === 'Enter') submit() } }),
|
|
611
|
+
confirm.length > 0 && !matches ? h('div', { className: 'dshn-err', style: { marginTop: '5px', marginBottom: 0 } }, T.mismatch) : null) : null,
|
|
612
|
+
|
|
613
|
+
!configured ? h('div', { className: 'dshn-note' }, T.recoverNote) : null,
|
|
614
|
+
|
|
615
|
+
// First-time setup: an OPTIONAL e2e password inline, so setup is one flow
|
|
616
|
+
// rather than "connect, then hunt for a second control". Left blank → e2e
|
|
617
|
+
// stays off (the default). Once configured this collapses and the
|
|
618
|
+
// dedicated control below takes over (with its own apply/disable).
|
|
619
|
+
!configured ? h('label', { className: 'dshn-field' },
|
|
620
|
+
h('span', null, '🔒 ' + T.e2eLabel),
|
|
621
|
+
h('div', { className: 'dshn-pwwrap' },
|
|
622
|
+
h('input', { className: 'dshn-input', type: showPw ? 'text' : 'password', value: e2e,
|
|
623
|
+
autoComplete: 'new-password', placeholder: '••••••••',
|
|
624
|
+
onChange: (ev) => setE2e(ev.target.value), onKeyDown: (ev) => { if (ev.key === 'Enter') submit() } }),
|
|
625
|
+
h('div', { className: 'dshn-pwbtns' },
|
|
626
|
+
h('button', { className: 'dshn-mini', type: 'button', onClick: () => setShowPw(!showPw) }, showPw ? T.hide : T.show))),
|
|
627
|
+
e2e.trim() !== '' && e2e.trim().length < MIN_PW ? h('div', { className: 'dshn-err', style: { marginTop: '5px', marginBottom: 0 } }, T.pwHint) : null,
|
|
628
|
+
h('div', { className: 'dshn-hint' }, T.e2eHint)) : null,
|
|
629
|
+
|
|
630
|
+
// End-to-end password: a SELF-CONTAINED control with its own apply/disable
|
|
631
|
+
// buttons, only once the tunnel is configured. Changing it never touches
|
|
632
|
+
// the connection or the main credentials above.
|
|
633
|
+
configured ? (() => {
|
|
634
|
+
const saved = s.e2ePassword || ''
|
|
635
|
+
const trimmed = e2e.trim()
|
|
636
|
+
const changed = trimmed !== saved
|
|
637
|
+
const valid = trimmed === '' || trimmed.length >= MIN_PW
|
|
638
|
+
const canApply = changed && valid && !e2eBusy
|
|
639
|
+
return h('div', { className: 'dshn-e2e-box' },
|
|
640
|
+
h('div', { className: 'dshn-e2e-head' },
|
|
641
|
+
h('span', null, '🔒 ' + T.e2eLabel),
|
|
642
|
+
h('span', { className: 'dshn-info-v', style: { color: s.e2eEnabled ? '#3aa675' : 'var(--dsw-alias-label-tertiary, #8b9099)', fontSize: '11px' } }, s.e2eEnabled ? T.e2eOn : T.e2eOff)),
|
|
643
|
+
h('div', { className: 'dshn-pwwrap' },
|
|
644
|
+
h('input', { className: 'dshn-input', type: showPw ? 'text' : 'password', value: e2e,
|
|
645
|
+
autoComplete: 'off', placeholder: '••••••••',
|
|
646
|
+
onChange: (ev) => { setE2e(ev.target.value); setE2eMsg(''); setE2eErr('') } }),
|
|
647
|
+
h('div', { className: 'dshn-pwbtns' },
|
|
648
|
+
h('button', { className: 'dshn-mini', type: 'button', onClick: () => setShowPw(!showPw) }, showPw ? T.hide : T.show),
|
|
649
|
+
e2e ? h('button', { className: 'dshn-mini', type: 'button', onClick: () => copyText(e2e, 'e2e') }, copied === 'e2e' ? T.copied : T.copy) : null)),
|
|
650
|
+
trimmed !== '' && trimmed.length < MIN_PW ? h('div', { className: 'dshn-err', style: { marginTop: '5px', marginBottom: 0 } }, T.pwHint) : null,
|
|
651
|
+
e2eErr ? h('div', { className: 'dshn-err', style: { marginTop: '6px', marginBottom: 0 } }, e2eErr) : null,
|
|
652
|
+
e2eMsg ? h('div', { className: 'dshn-hint', style: { marginTop: '6px', color: '#3aa675' } }, e2eMsg) : null,
|
|
653
|
+
h('div', { className: 'dshn-e2e-actions' },
|
|
654
|
+
h('button', { className: 'dshn-btn-sm', disabled: !canApply, onClick: () => applyE2E(trimmed) },
|
|
655
|
+
e2eBusy ? T.connecting2 : (s.e2eEnabled ? T.e2eUpdate : T.e2eApply)),
|
|
656
|
+
s.e2eEnabled ? h('button', { className: 'dshn-btn-sm dshn-btn-warn', disabled: e2eBusy, onClick: () => { setE2e(''); applyE2E('') } }, T.e2eDisable) : null),
|
|
657
|
+
h('div', { className: 'dshn-hint', style: { marginTop: '7px' } }, T.e2eHint + ' ' + T.e2eIndep))
|
|
658
|
+
})() : null,
|
|
659
|
+
|
|
660
|
+
// Disconnecting severs public access and the password is unrecoverable
|
|
661
|
+
// from the cloud — so it takes an explicit, spelled-out confirmation.
|
|
662
|
+
confirmDc ? h('div', { className: 'dshn-dcwarn' },
|
|
663
|
+
h('div', { className: 'dshn-dcwarn-title' }, '⚠ ' + T.dcTitle),
|
|
664
|
+
h('div', { className: 'dshn-dcwarn-body' }, T.dcWarn),
|
|
665
|
+
h('div', { style: { marginTop: '8px' } },
|
|
666
|
+
h('button', { className: 'dshn-mini', onClick: copyPw }, copied === 'pw' ? T.copied : T.dcCopyFirst))) : null,
|
|
667
|
+
|
|
668
|
+
h('div', { className: 'dshn-actions' },
|
|
669
|
+
confirmDc
|
|
670
|
+
? h(react.Fragment, null,
|
|
671
|
+
h('button', { className: 'dshn-danger', onClick: () => { setConfirmDc(false); disconnect() } }, T.dcConfirm),
|
|
672
|
+
h('button', { className: 'dshn-ghost', onClick: () => setConfirmDc(false) }, T.dcCancel))
|
|
673
|
+
: h(react.Fragment, null,
|
|
674
|
+
h('button', { className: 'dshn-primary', disabled: !canSubmit, onClick: submit },
|
|
675
|
+
busy ? T.connecting2 : (configured ? T.reconnect : T.connect)),
|
|
676
|
+
configured
|
|
677
|
+
? h('button', { className: 'dshn-ghost', onClick: () => setConfirmDc(true) }, T.disconnect)
|
|
678
|
+
: (props.mode === 'modal' ? h('button', { className: 'dshn-ghost', onClick: props.onClose }, T.later) : null))))
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Is THIS page loaded over loopback (i.e. locally)? The management widget is
|
|
682
|
+
// a local tool: over the public tunnel /dshn/* is blocked and there is
|
|
683
|
+
// nothing to configure, so the widget hides entirely — that also removes the
|
|
684
|
+
// cloud/local inconsistency of a dead pill appearing remotely.
|
|
685
|
+
const pageLoopback = (() => { const hn = location.hostname; return hn === 'localhost' || hn === '::1' || /^127\./.test(hn) })()
|
|
686
|
+
|
|
687
|
+
// One poller feeds both slot entries (the footer button and the overlay),
|
|
688
|
+
// kept in a tiny shared store so they never fight over state.
|
|
689
|
+
const store = {
|
|
690
|
+
status: null, open: false, dismissed: false, started: false, subs: new Set(),
|
|
691
|
+
set(patch) { Object.assign(this, patch); this.subs.forEach((f) => f()) },
|
|
692
|
+
sub(f) { this.subs.add(f); return () => this.subs.delete(f) },
|
|
693
|
+
start() {
|
|
694
|
+
if (this.started) return
|
|
695
|
+
this.started = true
|
|
696
|
+
const tick = () => fetch('/dshn/status', { cache: 'no-store' })
|
|
697
|
+
.then((r) => (r.ok ? r.json() : null)).then((s) => this.set({ status: s })).catch(() => {})
|
|
698
|
+
tick(); setInterval(tick, POLL_MS)
|
|
699
|
+
},
|
|
700
|
+
}
|
|
701
|
+
function useStore() {
|
|
702
|
+
const [, force] = react.useReducer((x) => x + 1, 0)
|
|
703
|
+
react.useEffect(() => store.sub(force), [])
|
|
704
|
+
return store
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Open dsh's Settings and land on our section. The settings trigger is a
|
|
708
|
+
// stable `button[aria-haspopup="dialog"]` (class names are hashed); once it
|
|
709
|
+
// is open, click our section's nav entry by its label.
|
|
710
|
+
function openDshSettings() {
|
|
711
|
+
const trigger = document.querySelector('button[aria-haspopup="dialog"]')
|
|
712
|
+
if (!trigger) return
|
|
713
|
+
trigger.click()
|
|
714
|
+
const goToSection = (tries) => {
|
|
715
|
+
scheduleIconPatch()
|
|
716
|
+
const item = Array.from(document.querySelectorAll('button,a,li,[role="tab"],[role="option"]'))
|
|
717
|
+
.find((e) => (e.textContent || '').trim() === T.navLabel)
|
|
718
|
+
if (item) { (item.closest('button,a,li,[role="tab"]') || item).click(); return }
|
|
719
|
+
if (tries > 0) setTimeout(() => goToSection(tries - 1), 120)
|
|
720
|
+
}
|
|
721
|
+
setTimeout(() => goToSection(10), 160)
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// dsh chooses each settings-nav icon from a hardcoded switch on the section
|
|
725
|
+
// id and falls back to its gear glyph for any id it doesn't know — including
|
|
726
|
+
// ours, so "公网转发" would otherwise share the 通用设置 gear. There is no
|
|
727
|
+
// registration field for a custom icon, so we swap the rendered glyph for our
|
|
728
|
+
// globe (matching the footer). Idempotent, and re-applied by an observer since
|
|
729
|
+
// dsh may re-render the cell. Built as raw SVG to mirror dsh's 16px navIcon.
|
|
730
|
+
function globeSvgEl(cls) {
|
|
731
|
+
const NS = 'http://www.w3.org/2000/svg'
|
|
732
|
+
const svg = document.createElementNS(NS, 'svg')
|
|
733
|
+
const set = (el, a) => { for (const k in a) el.setAttribute(k, a[k]) }
|
|
734
|
+
set(svg, { width: '16', height: '16', viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor',
|
|
735
|
+
'stroke-width': '1.4', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' })
|
|
736
|
+
if (cls) svg.setAttribute('class', cls)
|
|
737
|
+
const add = (tag, a) => { const e = document.createElementNS(NS, tag); set(e, a); svg.appendChild(e) }
|
|
738
|
+
add('circle', { cx: '8', cy: '8', r: '6' })
|
|
739
|
+
add('path', { d: 'M2 8h12' })
|
|
740
|
+
add('path', { d: 'M8 2c2.6 2.6 2.6 9.4 0 12' })
|
|
741
|
+
add('path', { d: 'M8 2c-2.6 2.6-2.6 9.4 0 12' })
|
|
742
|
+
return svg
|
|
743
|
+
}
|
|
744
|
+
function patchNavIcon() {
|
|
745
|
+
const dlg = document.querySelector('[role="dialog"], [aria-modal="true"]')
|
|
746
|
+
if (!dlg) return
|
|
747
|
+
dlg.querySelectorAll('button').forEach((b) => {
|
|
748
|
+
if (b.getAttribute('data-dshn-globe') === '1') return
|
|
749
|
+
if ((b.textContent || '').trim() !== T.navLabel) return
|
|
750
|
+
const svg = b.querySelector('svg')
|
|
751
|
+
if (!svg) return
|
|
752
|
+
svg.replaceWith(globeSvgEl(svg.getAttribute('class') || ''))
|
|
753
|
+
b.setAttribute('data-dshn-globe', '1')
|
|
754
|
+
})
|
|
755
|
+
}
|
|
756
|
+
let iconPatchScheduled = false
|
|
757
|
+
function scheduleIconPatch() {
|
|
758
|
+
if (iconPatchScheduled) return
|
|
759
|
+
iconPatchScheduled = true
|
|
760
|
+
const run = () => { iconPatchScheduled = false; try { patchNavIcon() } catch { /* ignore */ } }
|
|
761
|
+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run); else setTimeout(run, 16)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// At-a-glance status in dsh's own sidebar footer (beside settings, laid out
|
|
765
|
+
// by dsh — no overlap). Globe + the live latency value; colour tracks quality.
|
|
766
|
+
// Configuration itself lives in the Settings page, not here.
|
|
767
|
+
function FooterButton() {
|
|
768
|
+
useStore()
|
|
769
|
+
if (!pageLoopback) return null
|
|
770
|
+
const s = store.status
|
|
771
|
+
const connected = s && s.connected
|
|
772
|
+
const configured = s && s.configured
|
|
773
|
+
const err = s && s.lastError && !connected
|
|
774
|
+
const color = !configured ? 'var(--dsw-alias-label-tertiary, #8b9099)'
|
|
775
|
+
: connected ? latColor(s.latencyMs) : err ? '#e5484d' : '#d98324'
|
|
776
|
+
const lat = connected && s.latencyMs != null ? s.latencyMs + ' ms' : null
|
|
777
|
+
// Trailing value: the live latency when connected, otherwise the state word.
|
|
778
|
+
const trail = !s ? '' : !configured ? T.notset : connected ? (lat || T.live) : (err ? T.off : T.connecting)
|
|
779
|
+
return h('button', { className: 'dshn-frow', title: 'ds.hn · ' + trail + ' — ' + T.openSettings, 'aria-label': T.navLabel, onClick: openDshSettings },
|
|
780
|
+
h('span', { className: 'dshn-frow-ic', style: { color } }, Icon('globe', { width: 16, height: 16 })),
|
|
781
|
+
h('span', { className: 'dshn-frow-label' }, T.navLabel),
|
|
782
|
+
h('span', { className: 'dshn-frow-trail', style: { color } }, trail))
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// The whole configuration, as a page in dsh's Settings (settings.section).
|
|
786
|
+
// No separate floating form — this IS the form, laid out by dsh's settings
|
|
787
|
+
// shell. Local machine only; a remote visitor just sees a note.
|
|
788
|
+
function DshnSection() {
|
|
789
|
+
useStore()
|
|
790
|
+
const s = store.status
|
|
791
|
+
if (!pageLoopback) return h('div', { className: 'dshn-section' }, h('p', { className: 'dshn-section-intro' }, T.localOnly))
|
|
792
|
+
return h('div', { className: 'dshn-section' },
|
|
793
|
+
h('p', { className: 'dshn-section-intro' }, T.sectionIntro),
|
|
794
|
+
s ? h(Panel, { status: s, mode: 'section' }) : h('p', { className: 'dshn-hint' }, T.loading))
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const inject = ['slots']
|
|
798
|
+
function apply(ctx) {
|
|
799
|
+
store.start()
|
|
800
|
+
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ name: 'sidebar.footer.action', id: 'dshn-footer', order: 50 }, FooterButton))
|
|
801
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'dshn', order: 40, label: () => T.navLabel }, DshnSection))
|
|
802
|
+
// Keep our settings-nav globe applied however the panel is opened (dsh's own
|
|
803
|
+
// gear, not just our footer) and re-applied if dsh re-renders the cell.
|
|
804
|
+
if (pageLoopback && typeof MutationObserver !== 'undefined' && document.body) {
|
|
805
|
+
new MutationObserver(scheduleIconPatch).observe(document.body, { childList: true, subtree: true })
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
exports.apply = apply; exports.inject = inject
|
|
809
|
+
return module.exports
|
|
810
|
+
},
|
|
811
|
+
})
|