@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/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 fixed "dsh-web-guard" button that opens the admin page
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, revoke-all, expiry) we
65
- // send the browser to the login page instead of spinning on reconnect.
66
- // A 401 is the only trigger: a failed fetch means the network is down.
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
- window.addEventListener("message", function (ev) {
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
- if (typeof html !== 'string') return html
105
- let out = injectPolyfill(html)
106
- if (!out.includes('id="dsh-web-guard-fab"')) {
107
- const body = out.lastIndexOf('</body>')
108
- out = body === -1 ? out + FAB : out.slice(0, body) + FAB + out.slice(body)
109
- }
110
- return out
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 esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
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
- * Render the login document.
11
- * @param {object} options
12
- * @param {string} options.title - product label shown above the form
13
- * @param {string} [options.error] - error line under the form
14
- * @param {string} [options.next] - same-origin relative path to return to
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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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
- return `<!doctype html>
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
- :root{color-scheme:dark;--bg:#0b0d10;--card:#12151a;--line:#24282e;--text:#e6e8eb;--muted:#8b929c;--accent:#3f6df0}
31
+ ${themeCss()}
47
32
  *{box-sizing:border-box}
48
- html{background:var(--bg)} /* paint the canvas, not just the body box */
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,.45)}
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:#a7aeb8;margin-bottom:6px}
63
- input{width:100%;padding:11px 12px;border-radius:10px;border:1px solid #2c3138;background:#0e1116;color:var(--text);
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:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(74,125,255,.18)}
67
- button{width:100%;margin-top:16px;padding:11px;border:0;border-radius:10px;background:var(--accent);color:#fff;
68
- font-size:15px;font-weight:600;cursor:pointer;transition:background .15s}
69
- button:hover{background:#4a7dff}
70
- button:active{background:#3560d8}
71
- .err{margin:10px 0 0;color:#ff7b72;font-size:13px}
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 #1d2126;text-align:center}
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:#191d22}
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
- const L = t(locale)
106
- return `<!doctype html><html lang="${locale === 'zh' ? 'zh-CN' : 'en'}"><head><meta charset="utf-8">
107
- <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>${esc(title)} · ${esc(L.loggedOut)}</title>
108
- <style>:root{color-scheme:dark}*{box-sizing:border-box}html{background:#0b0d10;height:100%}
109
- body{margin:0;height:100%;min-height:100dvh;
110
- display:flex;align-items:center;justify-content:center;background:#0b0d10;color:#e6e8eb;
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:#3f6df0;opacity:.92}
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:#4a7dff;text-decoration:none}
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 {string} html - full index document
27
- * @returns {string} transformed document
25
+ * @param html - full index document
26
+ * @returns transformed document
28
27
  */
29
28
  export function injectPolyfill(html) {
30
- if (typeof html !== 'string' || html.includes('data-dsh-web-guard-polyfill')) return html
31
- const head = html.indexOf('</head>')
32
- if (head === -1) return SCRIPT + html
33
- return html.slice(0, head) + SCRIPT + html.slice(head)
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
+ }