@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/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
+ })