@lvxianchao/dsh-web-guard 2.1.1 → 3.0.1
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/CHANGELOG.md +49 -0
- package/README.md +53 -23
- package/README.zh.md +52 -31
- package/lib/auth.js +244 -232
- package/lib/client.js +1071 -0
- package/lib/i18n.js +49 -247
- package/lib/index.js +573 -465
- package/lib/inject.js +20 -69
- package/lib/page.js +52 -62
- package/lib/polyfill.js +9 -8
- package/lib/preferences.js +144 -0
- package/lib/proxy.js +178 -156
- package/lib/shared/api.js +19 -0
- package/lib/shared/theme.js +109 -0
- package/package.json +36 -4
- package/lib/admin.js +0 -190
package/lib/inject.js
CHANGED
|
@@ -3,67 +3,23 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Two additions, both dependency-free and inert until the page loads:
|
|
5
5
|
* 1. the secure-context `crypto.randomUUID` shim (plain-HTTP deployments)
|
|
6
|
-
* 2. a small
|
|
6
|
+
* 2. a small session watchdog that sends the browser to the login page when
|
|
7
|
+
* the guard session is gone (revoked, expired, password changed)
|
|
7
8
|
*
|
|
9
|
+
* The management surface itself is NOT injected any more: v3 registers a native
|
|
10
|
+
* `settings.section` page through the client bundle, which is the only entry.
|
|
8
11
|
* Injection is plain DOM at the end of <body>, outside the React root, so a
|
|
9
12
|
* harness re-render cannot remove it and no harness API is involved.
|
|
10
13
|
*/
|
|
11
|
-
import { injectPolyfill } from './polyfill.js'
|
|
12
|
-
|
|
13
|
-
const FAB = `<style data-dsh-web-guard-fab-style>
|
|
14
|
-
#dsh-web-guard-fab{position:fixed;right:16px;bottom:16px;z-index:2147483000;width:34px;height:34px;border-radius:50%;
|
|
15
|
-
display:flex;align-items:center;justify-content:center;background:rgba(18,21,26,.72);border:1px solid rgba(255,255,255,.12);
|
|
16
|
-
color:#3f6df0;text-decoration:none;backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);opacity:.42;
|
|
17
|
-
transition:opacity .15s,color .15s,transform .15s;box-shadow:0 4px 16px rgba(0,0,0,.35)}
|
|
18
|
-
#dsh-web-guard-fab:hover,#dsh-web-guard-fab:focus-visible{opacity:.95;color:#6f95ff;transform:translateY(-1px)}
|
|
19
|
-
#dsh-web-guard-fab svg{display:block}
|
|
20
|
-
#dsh-web-guard-overlay{position:fixed;inset:0;z-index:2147483001;background:rgba(0,0,0,.62);
|
|
21
|
-
-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);display:flex;align-items:center;justify-content:center;padding:24px}
|
|
22
|
-
#dsh-web-guard-overlay .gwp{position:relative;width:min(780px,94vw);height:86vh;border-radius:16px;overflow:hidden;
|
|
23
|
-
border:1px solid #24282e;background:#0b0d10;box-shadow:0 24px 80px rgba(0,0,0,.6)}
|
|
24
|
-
#dsh-web-guard-overlay iframe{width:100%;height:100%;border:0;display:block;background:#0b0d10}
|
|
25
|
-
@media (max-width:520px){#dsh-web-guard-fab{right:12px;bottom:12px;width:32px;height:32px;opacity:.42}
|
|
26
|
-
#dsh-web-guard-overlay{padding:0}
|
|
27
|
-
#dsh-web-guard-overlay .gwp{width:100vw;height:100vh;border-radius:0;border:0}}
|
|
28
|
-
@media print{#dsh-web-guard-fab{display:none}}
|
|
29
|
-
</style>
|
|
30
|
-
<a id="dsh-web-guard-fab" href="/__guard/admin" title="dsh-web-guard 设置" aria-label="dsh-web-guard 设置">
|
|
31
|
-
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M8 0 2 2.5v5c0 3.4 2.4 6.6 6 7.5 3.6-.9 6-4.1 6-7.5v-5L8 0Zm0 1.6 4.8 2v3.9c0 2.7-1.9 5.3-4.8 6.1-2.9-.8-4.8-3.4-4.8-6.1V3.6l4.8-2Z"/></svg>
|
|
32
|
-
</a>
|
|
33
|
-
<script data-dsh-web-guard-overlay>(function(){
|
|
14
|
+
import { injectPolyfill } from './polyfill.js';
|
|
15
|
+
const WATCHDOG = `<script data-dsh-web-guard-watchdog>(function(){
|
|
34
16
|
"use strict";
|
|
35
|
-
var fab = document.getElementById("dsh-web-guard-fab");
|
|
36
|
-
if (!fab) return;
|
|
37
|
-
var overlay = null;
|
|
38
|
-
function close() {
|
|
39
|
-
if (!overlay) return;
|
|
40
|
-
overlay.remove(); overlay = null;
|
|
41
|
-
document.body.style.overflow = "";
|
|
42
|
-
}
|
|
43
|
-
function open(e) {
|
|
44
|
-
// ⌘/Ctrl/Shift-click or middle-click keeps the native "open in new tab"
|
|
45
|
-
if (e && (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1)) return;
|
|
46
|
-
if (e) e.preventDefault();
|
|
47
|
-
if (overlay) return;
|
|
48
|
-
overlay = document.createElement("div");
|
|
49
|
-
overlay.id = "dsh-web-guard-overlay";
|
|
50
|
-
var panel = document.createElement("div");
|
|
51
|
-
panel.className = "gwp";
|
|
52
|
-
var frame = document.createElement("iframe");
|
|
53
|
-
frame.src = "/__guard/admin";
|
|
54
|
-
frame.title = fab.getAttribute("title") || "dsh-web-guard";
|
|
55
|
-
panel.appendChild(frame);
|
|
56
|
-
overlay.appendChild(panel);
|
|
57
|
-
document.body.appendChild(overlay);
|
|
58
|
-
document.body.style.overflow = "hidden";
|
|
59
|
-
}
|
|
60
|
-
fab.addEventListener("click", open);
|
|
61
17
|
|
|
62
|
-
// ── session watchdog ────────────────────────────────────────────────
|
|
63
18
|
// DSH only ever sees "WebSocket closed"; it does not know this gate exists.
|
|
64
|
-
// When the session is really gone (password change,
|
|
65
|
-
// send the browser to the login page instead of spinning on
|
|
66
|
-
// A 401 is the only trigger: a failed fetch means the network is
|
|
19
|
+
// When the session is really gone (password change, sign-out everywhere,
|
|
20
|
+
// expiry) we send the browser to the login page instead of spinning on
|
|
21
|
+
// reconnect. A 401 is the only trigger: a failed fetch means the network is
|
|
22
|
+
// down.
|
|
67
23
|
var probing = false;
|
|
68
24
|
function probe() {
|
|
69
25
|
if (probing) return;
|
|
@@ -92,20 +48,15 @@ border:1px solid #24282e;background:#0b0d10;box-shadow:0 24px 80px rgba(0,0,0,.6
|
|
|
92
48
|
window.WebSocket[k] = NativeWS[k];
|
|
93
49
|
});
|
|
94
50
|
}
|
|
95
|
-
|
|
96
|
-
if (ev.origin !== location.origin) return;
|
|
97
|
-
if (ev.data === "dsh-web-guard:close") close();
|
|
98
|
-
});
|
|
99
|
-
})();</script>`
|
|
100
|
-
|
|
101
|
-
|
|
51
|
+
})();</script>`;
|
|
102
52
|
/** Apply every index transform this plugin owns. */
|
|
103
|
-
export function injectUi(html) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
53
|
+
export function injectUi(html, options = {}) {
|
|
54
|
+
if (typeof html !== 'string')
|
|
55
|
+
return html;
|
|
56
|
+
let out = options.polyfill === false ? html : injectPolyfill(html);
|
|
57
|
+
if (!out.includes('data-dsh-web-guard-watchdog')) {
|
|
58
|
+
const body = out.lastIndexOf('</body>');
|
|
59
|
+
out = body === -1 ? out + WATCHDOG : out.slice(0, body) + WATCHDOG + out.slice(body);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
111
62
|
}
|
package/lib/page.js
CHANGED
|
@@ -1,79 +1,66 @@
|
|
|
1
1
|
/** Self-contained login/logout pages: no build step, no external assets. Localised. */
|
|
2
|
-
import { t } from './i18n.js'
|
|
3
|
-
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
const GITHUB_ICON = `<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0016 8c0-4.42-3.58-8-8-8z"/></svg
|
|
7
|
-
const SHIELD_ICON = `<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0 2 2.5v5c0 3.4 2.4 6.6 6 7.5 3.6-.9 6-4.1 6-7.5v-5L8 0Zm0 1.6 4.8 2v3.9c0 2.7-1.9 5.3-4.8 6.1-2.9-.8-4.8-3.4-4.8-6.1V3.6l4.8-2Z"/></svg
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
* @param {boolean} [options.locked] - render the lockout notice instead of the form
|
|
16
|
-
* @param {string} [options.repo] - repository URL shown under the card
|
|
17
|
-
*/
|
|
18
|
-
export function loginPage({
|
|
19
|
-
title = 'DSH Web Guard',
|
|
20
|
-
error = '',
|
|
21
|
-
next = '/',
|
|
22
|
-
locked = false,
|
|
23
|
-
repo = 'https://github.com/lvxianchao/dsh-web-guard',
|
|
24
|
-
locale = 'zh',
|
|
25
|
-
} = {}) {
|
|
26
|
-
const L = t(locale)
|
|
27
|
-
const nextField = next && next.startsWith('/') && !next.startsWith('//') ? `<input type="hidden" name="next" value="${esc(next)}">` : ''
|
|
28
|
-
const body = locked
|
|
29
|
-
? `<p class="err locked">${esc(L.errLocked)}</p>`
|
|
30
|
-
: `<form method="post" action="/login" autocomplete="off">
|
|
2
|
+
import { t } from './i18n.js';
|
|
3
|
+
import { themeBootScript, themeCss } from './shared/theme.js';
|
|
4
|
+
const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
5
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESCAPES[c]);
|
|
6
|
+
const GITHUB_ICON = `<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>`;
|
|
7
|
+
const SHIELD_ICON = `<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0 2 2.5v5c0 3.4 2.4 6.6 6 7.5 3.6-.9 6-4.1 6-7.5v-5L8 0Zm0 1.6 4.8 2v3.9c0 2.7-1.9 5.3-4.8 6.1-2.9-.8-4.8-3.4-4.8-6.1V3.6l4.8-2Z"/></svg>`;
|
|
8
|
+
/** Render the login document (light/dark palette from shared/theme). */
|
|
9
|
+
export function loginPage({ title = 'DSH Web Guard', error = '', next = '/', locked = false, repo = 'https://github.com/lvxianchao/dsh-web-guard', locale = 'zh', search = '', themePreference = '', } = {}) {
|
|
10
|
+
const L = t(locale);
|
|
11
|
+
const nextField = next && next.startsWith('/') && !next.startsWith('//') ? `<input type="hidden" name="next" value="${esc(next)}">` : '';
|
|
12
|
+
const body = locked
|
|
13
|
+
? `<p class="err locked">${esc(L.errLocked)}</p>`
|
|
14
|
+
: `<form method="post" action="/login" autocomplete="off">
|
|
31
15
|
${nextField}
|
|
32
16
|
<label for="password">${esc(L.passwordLabel)}</label>
|
|
33
17
|
<input id="password" name="password" type="password" required autofocus
|
|
34
18
|
autocomplete="current-password" placeholder="${esc(L.passwordPh)}">
|
|
35
19
|
${error ? `<p class="err">${esc(error)}</p>` : ''}
|
|
36
20
|
<button type="submit">${esc(L.submit)}</button>
|
|
37
|
-
</form
|
|
38
|
-
|
|
21
|
+
</form>`;
|
|
22
|
+
return `<!doctype html>
|
|
39
23
|
<html lang="${locale === 'zh' ? 'zh-CN' : 'en'}"><head>
|
|
40
24
|
<meta charset="utf-8">
|
|
41
25
|
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
42
26
|
<meta name="robots" content="noindex,nofollow">
|
|
43
|
-
<meta name="color-scheme" content="dark">
|
|
27
|
+
<meta name="color-scheme" content="light dark">
|
|
28
|
+
${themeBootScript(search, themePreference)}
|
|
44
29
|
<title>${esc(title)} · ${locale === 'zh' ? '登录' : 'Sign in'}</title>
|
|
45
30
|
<style>
|
|
46
|
-
|
|
31
|
+
${themeCss()}
|
|
47
32
|
*{box-sizing:border-box}
|
|
48
|
-
html{background:var(--bg)}
|
|
33
|
+
html{background:var(--gws-bg)} /* paint the canvas, not just the body box */
|
|
49
34
|
html,body{height:100%}
|
|
50
35
|
body{margin:0;min-height:100vh;min-height:100dvh; /* dvh: no gap under mobile toolbars */
|
|
51
36
|
display:flex;align-items:center;justify-content:center;
|
|
52
37
|
padding:max(20px,env(safe-area-inset-top)) 20px max(20px,env(safe-area-inset-bottom));
|
|
53
|
-
background:var(--bg);color:var(--text);
|
|
38
|
+
background:var(--gws-bg);color:var(--gws-text);
|
|
54
39
|
font:15px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","PingFang SC","Microsoft YaHei",sans-serif;
|
|
55
40
|
-webkit-text-size-adjust:100%}
|
|
56
|
-
.card{width:min(100%,380px);padding:30px 30px 24px;border:1px solid var(--line);border-radius:16px;
|
|
57
|
-
background:var(--card);box-shadow:0 18px 50px rgba(0,0,0,.
|
|
58
|
-
.mark{width:48px;height:48px;margin:0 auto 14px;color:var(--accent);opacity:.92}
|
|
41
|
+
.card{width:min(100%,380px);padding:30px 30px 24px;border:1px solid var(--gws-line);border-radius:16px;
|
|
42
|
+
background:var(--gws-card);box-shadow:0 18px 50px rgba(0,0,0,.22)}
|
|
43
|
+
.mark{width:48px;height:48px;margin:0 auto 14px;color:var(--gws-accent);opacity:.92}
|
|
59
44
|
.mark svg{display:block;width:100%;height:100%}
|
|
60
45
|
h1{margin:0;font-size:18px;font-weight:600;line-height:1.35;text-align:center}
|
|
61
|
-
p.sub{margin:9px 0 24px;color:var(--muted);font-size:13px;text-align:center}
|
|
62
|
-
label{display:block;font-size:13px;color
|
|
63
|
-
input{width:100%;padding:11px 12px;border-radius:10px;border:1px solid
|
|
46
|
+
p.sub{margin:9px 0 24px;color:var(--gws-muted);font-size:13px;text-align:center}
|
|
47
|
+
label{display:block;font-size:13px;color:var(--gws-muted);margin-bottom:6px}
|
|
48
|
+
input{width:100%;padding:11px 12px;border-radius:10px;border:1px solid var(--gws-line-strong);
|
|
49
|
+
background:var(--gws-inset);color:var(--gws-text);
|
|
64
50
|
font-size:16px; /* 16px stops iOS from zooming on focus */
|
|
65
51
|
line-height:1.3;outline:none;transition:border-color .15s,box-shadow .15s}
|
|
66
|
-
input
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
button:
|
|
71
|
-
|
|
52
|
+
input::placeholder{color:var(--gws-muted);opacity:.8}
|
|
53
|
+
input:focus{border-color:var(--gws-accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--gws-accent) 22%,transparent)}
|
|
54
|
+
button{width:100%;margin-top:16px;padding:11px;border:0;border-radius:10px;background:var(--gws-accent);
|
|
55
|
+
color:var(--gws-accent-text);font-size:15px;font-weight:600;cursor:pointer;transition:background .15s}
|
|
56
|
+
button:hover{background:var(--gws-accent-hover)}
|
|
57
|
+
button:active{filter:brightness(.94)}
|
|
58
|
+
.err{margin:10px 0 0;color:var(--gws-danger);font-size:13px}
|
|
72
59
|
.err.locked{margin:4px 0 0;text-align:center}
|
|
73
|
-
.foot{margin-top:20px;padding-top:14px;border-top:1px solid
|
|
74
|
-
.repo{display:inline-flex;align-items:center;gap:7px;color:var(--muted);font-size:13px;text-decoration:none;
|
|
60
|
+
.foot{margin-top:20px;padding-top:14px;border-top:1px solid var(--gws-line);text-align:center}
|
|
61
|
+
.repo{display:inline-flex;align-items:center;gap:7px;color:var(--gws-muted);font-size:13px;text-decoration:none;
|
|
75
62
|
padding:5px 10px;border-radius:8px;transition:color .15s,background .15s}
|
|
76
|
-
.repo:hover{color:var(--text);background
|
|
63
|
+
.repo:hover{color:var(--gws-text);background:var(--gws-hover)}
|
|
77
64
|
.repo svg{flex:none;opacity:.9}
|
|
78
65
|
@media (max-width:400px){
|
|
79
66
|
.card{padding:24px 20px 20px;border-radius:14px}
|
|
@@ -97,23 +84,26 @@ export function loginPage({
|
|
|
97
84
|
</a>
|
|
98
85
|
</div>
|
|
99
86
|
</main>
|
|
100
|
-
</body></html
|
|
87
|
+
</body></html>`;
|
|
101
88
|
}
|
|
102
|
-
|
|
103
89
|
/** Minimal logout confirmation (no JS needed). */
|
|
104
|
-
export function logoutPage({ title = 'DSH Web Guard', locale = 'zh' } = {}) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"
|
|
108
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
90
|
+
export function logoutPage({ title = 'DSH Web Guard', locale = 'zh', search = '', themePreference = '' } = {}) {
|
|
91
|
+
const L = t(locale);
|
|
92
|
+
return `<!doctype html><html lang="${locale === 'zh' ? 'zh-CN' : 'en'}"><head><meta charset="utf-8">
|
|
93
|
+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
94
|
+
<meta name="color-scheme" content="light dark">
|
|
95
|
+
${themeBootScript(search, themePreference)}
|
|
96
|
+
<title>${esc(title)} · ${esc(L.loggedOut)}</title>
|
|
97
|
+
<style>${themeCss()}
|
|
98
|
+
*{box-sizing:border-box}html{background:var(--gws-bg);height:100%}
|
|
99
|
+
body{margin:0;height:100%;min-height:100dvh;background:var(--gws-bg);color:var(--gws-text);
|
|
100
|
+
display:flex;align-items:center;justify-content:center;
|
|
111
101
|
font:15px system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
|
|
112
102
|
padding:max(20px,env(safe-area-inset-top)) 20px max(20px,env(safe-area-inset-bottom))}
|
|
113
|
-
.mark{width:92px;height:92px;margin:0 auto 22px;color
|
|
103
|
+
.mark{width:92px;height:92px;margin:0 auto 22px;color:var(--gws-accent);opacity:.92}
|
|
114
104
|
.mark svg{display:block;width:100%;height:100%}
|
|
115
|
-
h1{font-size:17px;font-weight:600;margin:0 0 10px}a{color
|
|
105
|
+
h1{font-size:17px;font-weight:600;margin:0 0 10px}a{color:var(--gws-accent);text-decoration:none}
|
|
116
106
|
p{margin:0;font-size:14px}</style></head>
|
|
117
107
|
<body><main style="text-align:center"><div class="mark">${SHIELD_ICON}</div><h1>${esc(L.loggedOut)}</h1>
|
|
118
|
-
<p><a href="/login">${esc(L.signInAgain)}</a></p></main></body></html
|
|
108
|
+
<p><a href="/login">${esc(L.signInAgain)}</a></p></main></body></html>`;
|
|
119
109
|
}
|
package/lib/polyfill.js
CHANGED
|
@@ -19,16 +19,17 @@ const SCRIPT = `<script data-dsh-web-guard-polyfill>(function(){
|
|
|
19
19
|
return h.slice(0,8)+'-'+h.slice(8,12)+'-'+h.slice(12,16)+'-'+h.slice(16,20)+'-'+h.slice(20);
|
|
20
20
|
};
|
|
21
21
|
} catch (e) {}
|
|
22
|
-
})();</script
|
|
23
|
-
|
|
22
|
+
})();</script>`;
|
|
24
23
|
/**
|
|
25
24
|
* Inject the polyfill once, before any app script runs.
|
|
26
|
-
* @param
|
|
27
|
-
* @returns
|
|
25
|
+
* @param html - full index document
|
|
26
|
+
* @returns transformed document
|
|
28
27
|
*/
|
|
29
28
|
export function injectPolyfill(html) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
if (typeof html !== 'string' || html.includes('data-dsh-web-guard-polyfill'))
|
|
30
|
+
return html;
|
|
31
|
+
const head = html.indexOf('</head>');
|
|
32
|
+
if (head === -1)
|
|
33
|
+
return SCRIPT + html;
|
|
34
|
+
return html.slice(0, head) + SCRIPT + html.slice(head);
|
|
34
35
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-plugin preferences the harness persists in the profile patch file.
|
|
3
|
+
*
|
|
4
|
+
* The harness writes each plugin's user settings as a YAML row in
|
|
5
|
+
* `<profile>/cordis.patch.yml`:
|
|
6
|
+
*
|
|
7
|
+
* ```yaml
|
|
8
|
+
* - id: locale
|
|
9
|
+
* name: "@deepseek-ai/dsh-client-locale"
|
|
10
|
+
* config:
|
|
11
|
+
* preference: zh
|
|
12
|
+
* - id: ui-theme
|
|
13
|
+
* name: "@deepseek-ai/dsh-client-ui-theme"
|
|
14
|
+
* config:
|
|
15
|
+
* preference: dark
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* Reading those rows is what lets the guard's standalone pages (login and
|
|
19
|
+
* sign-out, which render before the harness exists) follow the same language
|
|
20
|
+
* and light/dark choice the operator picked inside DSH. Values are read once and
|
|
21
|
+
* refreshed when the file changes; a missing row or unreadable file means "no
|
|
22
|
+
* explicit choice", and the caller falls back to the browser/system.
|
|
23
|
+
*/
|
|
24
|
+
import { readFileSync, statSync, watch } from 'node:fs';
|
|
25
|
+
import { basename, dirname } from 'node:path';
|
|
26
|
+
/**
|
|
27
|
+
* Find `preference:` inside the `- id: <rowId>` row.
|
|
28
|
+
* @param text - patch file contents.
|
|
29
|
+
* @param rowId - plugin row id (`locale`, `ui-theme`, …).
|
|
30
|
+
* @returns the raw scalar value, or '' when absent.
|
|
31
|
+
*/
|
|
32
|
+
export function parseRowPreference(text, rowId) {
|
|
33
|
+
const rowPattern = new RegExp(`^-\\s+id:\\s*['"]?${rowId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]?\\s*$`);
|
|
34
|
+
const lines = text.split('\n');
|
|
35
|
+
let inRow = false;
|
|
36
|
+
let rowIndent = 0;
|
|
37
|
+
for (const line of lines) {
|
|
38
|
+
const trimmed = line.trim();
|
|
39
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
40
|
+
continue;
|
|
41
|
+
const indent = line.length - line.trimStart().length;
|
|
42
|
+
// A new top-level-ish row ends the section we were scanning.
|
|
43
|
+
if (inRow && /^- /.test(trimmed) && indent <= rowIndent)
|
|
44
|
+
break;
|
|
45
|
+
if (!inRow && rowPattern.test(trimmed)) {
|
|
46
|
+
inRow = true;
|
|
47
|
+
rowIndent = indent;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (inRow) {
|
|
51
|
+
const match = trimmed.match(/^preference:\s*['"]?([A-Za-z0-9_-]+)['"]?/);
|
|
52
|
+
if (match)
|
|
53
|
+
return match[1];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return '';
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Read one row's preference straight from disk.
|
|
60
|
+
* @param patchPath - profile patch file path.
|
|
61
|
+
* @param rowId - plugin row id.
|
|
62
|
+
* @returns the raw value, or '' when unavailable.
|
|
63
|
+
*/
|
|
64
|
+
export function readPreference(patchPath, rowId) {
|
|
65
|
+
if (!patchPath)
|
|
66
|
+
return '';
|
|
67
|
+
try {
|
|
68
|
+
return parseRowPreference(readFileSync(patchPath, 'utf8'), rowId);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return '';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Keep one row's preference in memory.
|
|
76
|
+
*
|
|
77
|
+
* Reading the file per request would block the harness event loop (this plugin
|
|
78
|
+
* shares its process), and a fixed poll interval is a guess in both directions.
|
|
79
|
+
* Instead: read once at startup, then re-read only when the directory reports a
|
|
80
|
+
* change — the harness writes atomically via rename, so watching the directory
|
|
81
|
+
* is what actually catches the replacement. If the platform has no usable
|
|
82
|
+
* watcher, fall back to a slow mtime check on a timer.
|
|
83
|
+
*
|
|
84
|
+
* @param patchPath - profile patch file path.
|
|
85
|
+
* @param rowId - plugin row id.
|
|
86
|
+
* @returns a live reader; call `dispose()` when the plugin unloads.
|
|
87
|
+
*/
|
|
88
|
+
export function createPreferenceReader(patchPath, rowId) {
|
|
89
|
+
let value = readPreference(patchPath, rowId);
|
|
90
|
+
let watcher;
|
|
91
|
+
let poller;
|
|
92
|
+
let mtime = 0;
|
|
93
|
+
let debounce;
|
|
94
|
+
const stamp = () => {
|
|
95
|
+
try {
|
|
96
|
+
return statSync(patchPath).mtimeMs;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
mtime = stamp();
|
|
103
|
+
const refresh = () => {
|
|
104
|
+
const next = stamp();
|
|
105
|
+
if (next === mtime)
|
|
106
|
+
return;
|
|
107
|
+
mtime = next;
|
|
108
|
+
value = readPreference(patchPath, rowId);
|
|
109
|
+
};
|
|
110
|
+
const schedule = () => {
|
|
111
|
+
if (debounce)
|
|
112
|
+
clearTimeout(debounce);
|
|
113
|
+
debounce = setTimeout(refresh, 40);
|
|
114
|
+
debounce.unref();
|
|
115
|
+
};
|
|
116
|
+
try {
|
|
117
|
+
watcher = watch(dirname(patchPath), { persistent: false }, (_event, filename) => {
|
|
118
|
+
if (!filename || String(filename) === basename(patchPath))
|
|
119
|
+
schedule();
|
|
120
|
+
});
|
|
121
|
+
watcher.on('error', () => {
|
|
122
|
+
watcher?.close();
|
|
123
|
+
watcher = undefined;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
watcher = undefined;
|
|
128
|
+
}
|
|
129
|
+
if (!watcher) {
|
|
130
|
+
// No usable watcher: an inexpensive mtime check every 30s is the safety net.
|
|
131
|
+
poller = setInterval(refresh, 30_000);
|
|
132
|
+
poller.unref();
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
get: () => value,
|
|
136
|
+
dispose: () => {
|
|
137
|
+
if (debounce)
|
|
138
|
+
clearTimeout(debounce);
|
|
139
|
+
if (poller)
|
|
140
|
+
clearInterval(poller);
|
|
141
|
+
watcher?.close();
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|