@nsnanocat/preference-panes 1.2.4 → 1.3.0

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/dist/home.html ADDED
@@ -0,0 +1,1032 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN" class="pp-page">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
6
+ <title>设置</title>
7
+ </head>
8
+ <body>
9
+ <div data-preference-panes-pages>
10
+ <main class="pp-home" data-preference-panes-home></main>
11
+ </div>
12
+ <template data-preference-panes-module><section data-preference-panes-module-page><p data-module-message>正在打开设置…</p></section></template>
13
+ <script type="module">/**
14
+ * 独立页面的三点按钮和底部操作菜单;嵌入页面由宿主提供对应界面。
15
+ * Overflow trigger and bottom action sheet for standalone pages; embedded pages use host-provided chrome.
16
+ */
17
+ class ActionMenu {
18
+ #button;
19
+ #layer;
20
+ #items;
21
+ #select;
22
+ #document;
23
+ #disabled = true;
24
+ #key = event => {
25
+ if (event.key === "Escape" && !this.#layer.hidden) {
26
+ event.preventDefault();
27
+ this.close();
28
+ this.#button.focus();
29
+ }
30
+ };
31
+
32
+ /**
33
+ * 创建菜单,操作逻辑由调用方提供。
34
+ * Create a menu whose actions are handled by the caller.
35
+ * @param {(id: string) => void} select 菜单选择回调 / Selection callback.
36
+ */
37
+ constructor(select) {
38
+ this.#document = document;
39
+ this.#select = select;
40
+ this.element = document.createElement("span");
41
+ const triggerRoot = this.element.attachShadow({ mode: "open" });
42
+ triggerRoot.innerHTML = `<style>
43
+ :host{display:inline-flex;width:44px;height:44px;color:inherit}
44
+ :host([hidden]){display:none!important}
45
+ button{width:44px;height:44px;padding:10px;font:inherit;cursor:pointer;border:0;color:inherit;background:none}
46
+ button:disabled{opacity:.4;cursor:default}
47
+ button:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
48
+ svg{display:block;width:24px;height:24px;fill:currentColor}
49
+ </style><button type="button" aria-label="更多操作" aria-haspopup="menu" aria-expanded="false"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="4" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="20" cy="12" r="2"/></svg></button>`;
50
+ this.#button = triggerRoot.querySelector("button");
51
+ this.#layer = document.createElement("span");
52
+ const layerRoot = this.#layer.attachShadow({ mode: "open" });
53
+ layerRoot.innerHTML = `<style>
54
+ :host{position:fixed;inset:0;z-index:2147483647;color:var(--pp-text,CanvasText);font:16px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
55
+ :host([hidden]){display:none!important}
56
+ *,*::before,*::after{box-sizing:border-box}
57
+ button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
58
+ button:focus-visible{outline:2px solid var(--pp-accent,Highlight);outline-offset:-3px}
59
+ #backdrop{position:absolute;inset:0;width:100%;height:100%;padding:0;background:#0008;animation:pp-fade-in .18s ease-out}
60
+ #sheet{position:absolute;z-index:1;left:0;right:0;bottom:0;width:100%;max-width:540px;max-height:calc(100% - 24px);margin:auto;padding:8px 8px calc(8px + env(safe-area-inset-bottom));animation:pp-sheet-in .22s cubic-bezier(.2,.8,.2,1)}
61
+ #items,#cancel{overflow:hidden;background:var(--pp-surface,Canvas);border:1px solid var(--pp-border,#8884);border-radius:14px;box-shadow:0 8px 28px #0004}
62
+ #items{max-height:calc(100vh - 116px - env(safe-area-inset-bottom));overflow-y:auto;-webkit-overflow-scrolling:touch}
63
+ #items button,#cancel{display:block;width:100%;min-height:54px;padding:14px 18px;text-align:center}
64
+ #items button+button{border-top:1px solid var(--pp-border,#8884)}
65
+ #items button[data-danger]{color:var(--pp-danger,#e45656)}
66
+ #cancel{margin-top:8px;color:var(--pp-accent,Highlight);font-weight:600}
67
+ @keyframes pp-fade-in{from{opacity:0}}
68
+ @keyframes pp-sheet-in{from{transform:translateY(100%)}}
69
+ @media (prefers-reduced-motion:reduce){#backdrop,#sheet{animation:none}}
70
+ </style><button id="backdrop" type="button" tabindex="-1" aria-label="关闭菜单"></button><section id="sheet" role="dialog" aria-modal="true" aria-label="更多操作"><div id="items" role="menu"></div><button id="cancel" type="button">取消</button></section>`;
71
+ this.#items = layerRoot.querySelector("#items");
72
+ this.#button.onclick = () => (this.#layer.hidden ? this.open() : this.close());
73
+ layerRoot.querySelector("#backdrop").onclick = () => {
74
+ this.close();
75
+ this.#button.focus();
76
+ };
77
+ layerRoot.querySelector("#cancel").onclick = () => {
78
+ this.close();
79
+ this.#button.focus();
80
+ };
81
+ this.#items.onkeydown = event => {
82
+ const items = [...this.#items.children];
83
+ const index = items.indexOf(layerRoot.activeElement);
84
+ const offsets = { ArrowDown: 1, ArrowUp: -1 };
85
+ if (event.key in offsets) {
86
+ event.preventDefault();
87
+ items[(index + offsets[event.key] + items.length) % items.length].focus();
88
+ }
89
+ };
90
+ document.body.append(this.#layer);
91
+ document.addEventListener("keydown", this.#key);
92
+ this.update([]);
93
+ }
94
+
95
+ /**
96
+ * 同步可用操作和忙碌状态,不重建菜单触发按钮。
97
+ * Update actions and busy state without replacing the trigger button.
98
+ * @param {Array<{id: string, label: string, destructive?: boolean}>} items 操作列表 / Actions.
99
+ * @param {boolean} [disabled] 是否忙碌 / Whether operations are busy.
100
+ * @returns {void} 无返回值 / No return value.
101
+ */
102
+ update(items, disabled = false) {
103
+ this.close();
104
+ this.#disabled = disabled || items.length === 0;
105
+ this.#button.disabled = this.#disabled;
106
+ this.#items.replaceChildren(
107
+ ...items.map(item => {
108
+ const button = this.#document.createElement("button");
109
+ button.type = "button";
110
+ button.setAttribute("role", "menuitem");
111
+ button.textContent = item.label;
112
+ button.toggleAttribute("data-danger", Boolean(item.destructive));
113
+ button.onclick = () => {
114
+ this.close();
115
+ this.#select(item.id);
116
+ };
117
+ return button;
118
+ }),
119
+ );
120
+ }
121
+
122
+ /**
123
+ * 打开当前操作菜单。
124
+ * Open the current action sheet.
125
+ * @returns {void} 无返回值 / No return value.
126
+ */
127
+ open() {
128
+ if (this.#disabled) return;
129
+ const style = getComputedStyle(this.element);
130
+ for (const property of ["--pp-text", "--pp-surface", "--pp-border", "--pp-accent", "--pp-danger"]) {
131
+ const value = style.getPropertyValue(property);
132
+ if (value) this.#layer.style.setProperty(property, value);
133
+ }
134
+ this.#layer.hidden = false;
135
+ this.#button.setAttribute("aria-expanded", "true");
136
+ this.#items.firstElementChild.focus();
137
+ }
138
+
139
+ /**
140
+ * 关闭菜单。
141
+ * Close the menu.
142
+ * @returns {void} 无返回值 / No return value.
143
+ */
144
+ close() {
145
+ this.#layer.hidden = true;
146
+ this.#button.setAttribute("aria-expanded", "false");
147
+ }
148
+
149
+ /**
150
+ * 移除监听器与节点。
151
+ * Remove listeners and elements.
152
+ * @returns {void} 无返回值 / No return value.
153
+ */
154
+ destroy() {
155
+ this.#document.removeEventListener("keydown", this.#key);
156
+ this.#layer.remove();
157
+ this.element.remove();
158
+ }
159
+ }
160
+
161
+ /**
162
+ * 模块文档容器:获取静态 HTML,并通过 iframe 数据属性传递页面资源输入。
163
+ * Module document container: fetch static HTML and pass page resource inputs through iframe data attributes.
164
+ */
165
+ class ModuleFrame extends EventTarget {
166
+ #url;
167
+ #options;
168
+ #controller = new AbortController();
169
+ #abort = () => this.destroy();
170
+ #state;
171
+ #change = event => {
172
+ this.#state = { ...event.detail, actions: event.detail.actions ?? [] };
173
+ this.dispatchEvent(new Event("change"));
174
+ };
175
+ #forward(type, event) {
176
+ if (!this.dispatchEvent(new CustomEvent(type, { cancelable: true, detail: event.detail }))) event.preventDefault();
177
+ }
178
+ #confirmation = event => this.#forward("confirm", event);
179
+ #notice = event => this.#forward("notice", event);
180
+ #openURL = event => this.#forward("open-url", event);
181
+
182
+ /**
183
+ * 建立 iframe;调用方挂载 element 后调用 load。
184
+ * Create the iframe; callers mount element and then call load.
185
+ * @param {string | URL} url 模块请求地址 / Module request URL.
186
+ * @param {{signal?: AbortSignal, headers?: HeadersInit}} [options] 请求选项 / Request options.
187
+ */
188
+ constructor(url, options = {}) {
189
+ super();
190
+ this.#url = new URL(url, document.baseURI);
191
+ const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(this.#url.pathname);
192
+ if (!match) throw new TypeError("Open a concrete module URL");
193
+ this.#options = { signal: options.signal };
194
+ this.element = document.createElement("iframe");
195
+ this.element.title = `${match[1]} 设置`;
196
+ this.element.dataset.preferencePanes = "true";
197
+ this.element.dataset.preferencePanesModule = match[1];
198
+ this.element.dataset.preferencePanesBase = this.#url.href;
199
+ const headers = new Headers(options.headers);
200
+ for (const [header, property, kind] of [
201
+ ["X-PreferencePanes-JSON", "preferencePanesJson", "BoxJS"],
202
+ ["X-PreferencePanes-CSS", "preferencePanesStylesheet", "CSS"],
203
+ ]) {
204
+ if (!headers.has(header)) continue;
205
+ const source = headers.get(header)?.trim();
206
+ if (!source) {
207
+ if (kind === "BoxJS") throw new TypeError("BoxJS resource URL is required");
208
+ continue;
209
+ }
210
+ const resource = new URL(source, this.#url);
211
+ if (!["http:", "https:"].includes(resource.protocol)) throw new TypeError(`${kind} resource must use HTTP(S)`);
212
+ this.element.dataset[property] = resource.href;
213
+ }
214
+ this.element.addEventListener("preferencepanes:change", this.#change);
215
+ this.element.addEventListener("preferencepanes:confirm", this.#confirmation);
216
+ this.element.addEventListener("preferencepanes:notice", this.#notice);
217
+ this.element.addEventListener("preferencepanes:open-url", this.#openURL);
218
+ this.#state = { title: match[1], module: match[1], busy: false, canGoBack: true, actions: [] };
219
+ options.signal?.addEventListener("abort", this.#abort, { once: true });
220
+ }
221
+
222
+ /**
223
+ * 当前模块导航状态。
224
+ * Current module navigation state.
225
+ */
226
+ get state() {
227
+ return { ...this.#state };
228
+ }
229
+
230
+ /**
231
+ * 获取原始 HTML;晚到响应在退出后不得重新挂载。
232
+ * Fetch unmodified HTML; a late response must not remount after departure.
233
+ * @returns {Promise<void>} HTML 已交给 iframe;表单状态通过 change 事件提供 / HTML assigned; form state is reported through change.
234
+ */
235
+ async load() {
236
+ if (this.#options.signal?.aborted) this.destroy();
237
+ const timer = setTimeout(() => this.#controller.abort(), 10000);
238
+ try {
239
+ const response = await fetch(this.#url, { method: "GET", cache: "no-store", credentials: "omit", signal: this.#controller.signal });
240
+ if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
241
+ const html = await response.text();
242
+ if (this.#controller.signal.aborted) throw new DOMException("The operation was aborted", "AbortError");
243
+ this.element.srcdoc = html;
244
+ } finally {
245
+ clearTimeout(timer);
246
+ }
247
+ }
248
+
249
+ /**
250
+ * 使用 iframe 的联合历史返回;写入期间不导航。
251
+ * Navigate joint iframe history back, except while a write is pending.
252
+ * @returns {void} 无返回值 / No return value.
253
+ */
254
+ back() {
255
+ if (!this.#state.busy && this.#state.canGoBack) this.element.contentWindow.history.back();
256
+ }
257
+
258
+ /**
259
+ * 向模块发送菜单操作,不让宿主访问内部 DOM 或存储客户端。
260
+ * Dispatch a menu action without host access to internal DOM or the storage client.
261
+ * @param {string} id 当前可用操作 / Available action identifier.
262
+ * @returns {void} 无返回值 / No return value.
263
+ */
264
+ perform(id) {
265
+ if (this.#state.busy || !this.#state.actions.some(action => action.id === id)) throw new Error("Action is not available");
266
+ this.element.dispatchEvent(new CustomEvent("preferencepanes:action", { detail: id }));
267
+ }
268
+
269
+ /**
270
+ * 取消加载与事件订阅;节点保留到 Navigation 的退出动画结束。
271
+ * Cancel loading and subscriptions; Navigation retains the node until its exit animation ends.
272
+ * @returns {void} 无返回值 / No return value.
273
+ */
274
+ destroy() {
275
+ this.#controller.abort();
276
+ this.#options.signal?.removeEventListener("abort", this.#abort);
277
+ this.element.removeEventListener("preferencepanes:change", this.#change);
278
+ this.element.removeEventListener("preferencepanes:confirm", this.#confirmation);
279
+ this.element.removeEventListener("preferencepanes:notice", this.#notice);
280
+ this.element.removeEventListener("preferencepanes:open-url", this.#openURL);
281
+ }
282
+ }
283
+
284
+ /**
285
+ * 模块探测请求选项。
286
+ * Options for a module probe request.
287
+ * @typedef {object} ModuleProbeOptions
288
+ * @property {typeof globalThis.fetch} [fetch] 可注入的 fetch / Injectable fetch.
289
+ * @property {AbortSignal} [signal] 外部取消信号 / External cancellation signal.
290
+ * @property {number} [timeout] 超时毫秒数,默认 3500 / Timeout in milliseconds, defaults to 3500.
291
+ */
292
+
293
+ /**
294
+ * 通过模块 API 的 HEAD 响应检测安装状态和业务版本。
295
+ * Probe installation and business version from the module API HEAD response.
296
+ * @param {string | URL} url 模块 API 地址 / Module API URL.
297
+ * @param {ModuleProbeOptions} [options] 请求选项 / Request options.
298
+ * @returns {Promise<Response>} 原始 HTTP 响应,可直接读取 status 和响应头 / Native HTTP response; read status and headers directly.
299
+ */
300
+ async function probeModule(url, { fetch: request = globalThis.fetch, signal, timeout = 3500 } = {}) {
301
+ const controller = new AbortController();
302
+ const abort = () => controller.abort();
303
+ if (signal?.aborted) abort();
304
+ signal?.addEventListener("abort", abort, { once: true });
305
+ const timer = setTimeout(() => controller.abort(), timeout);
306
+ try {
307
+ return await request(url, { method: "HEAD", cache: "no-store", credentials: "omit", signal: controller.signal });
308
+ } finally {
309
+ clearTimeout(timer);
310
+ signal?.removeEventListener("abort", abort);
311
+ }
312
+ }
313
+
314
+ /**
315
+ * 模块入口的固定状态行,只通过 HEAD 探测安装状态和业务版本。
316
+ * Fixed module status row, probing installation and business version with HEAD only.
317
+ */
318
+ class ModuleStatus extends EventTarget {
319
+ #element;
320
+ #controller;
321
+ #state = { status: "checking", version: null };
322
+
323
+ /**
324
+ * 绑定调用方提供的状态行。
325
+ * Bind a caller-owned status row.
326
+ * @param {HTMLElement} element 状态文字容器 / Status text container.
327
+ */
328
+ constructor(element) {
329
+ super();
330
+ this.#element = element;
331
+ this.#render("checking");
332
+ }
333
+
334
+ /**
335
+ * 当前安装状态与业务版本。
336
+ * Current installation state and business version.
337
+ */
338
+ get state() {
339
+ return { ...this.#state };
340
+ }
341
+
342
+ /**
343
+ * 每次进入重新探测,取消旧请求并忽略其迟到结果。
344
+ * Reprobe on entry, cancelling old requests and ignoring late results.
345
+ * @param {string | URL} url 模块 API 地址 / Module API URL.
346
+ * @param {ModuleProbeOptions} [options] 请求选项 / Request options.
347
+ * @returns {Promise<Response | undefined>} 原始响应;被取消时无返回值 / Native response; undefined when cancelled.
348
+ */
349
+ async check(url, options = {}) {
350
+ this.#controller?.abort();
351
+ const controller = new AbortController();
352
+ this.#controller = controller;
353
+ const externalSignal = options.signal;
354
+ const abort = () => controller.abort();
355
+ if (externalSignal?.aborted) abort();
356
+ externalSignal?.addEventListener("abort", abort, { once: true });
357
+ this.#render("checking");
358
+ try {
359
+ const response = await probeModule(url, { ...options, signal: controller.signal });
360
+ if (controller !== this.#controller) return response;
361
+ const version = response.status === 200 ? response.headers.get("X-PreferencePanes-Version")?.trim() || null : null;
362
+ this.#render(version ? "installed" : "missing", version);
363
+ return response;
364
+ } catch (error) {
365
+ if (controller !== this.#controller) return;
366
+ if (externalSignal?.aborted) throw error;
367
+ this.#render("missing");
368
+ } finally {
369
+ externalSignal?.removeEventListener("abort", abort);
370
+ }
371
+ }
372
+
373
+ /**
374
+ * 更新状态标签,缺少版本时不伪造版本号。
375
+ * Render the label without inventing a missing version.
376
+ * @param {"checking" | "installed" | "missing"} status 状态 / State.
377
+ * @param {string | null} [version] 业务版本 / Business version.
378
+ * @returns {void} 无返回值 / No return value.
379
+ */
380
+ #render(status, version = null) {
381
+ this.#state = { status, version: status === "installed" ? version : null };
382
+ switch (status) {
383
+ case "checking":
384
+ this.#element.textContent = "检测中";
385
+ break;
386
+ case "installed":
387
+ this.#element.textContent = version ?? "版本未知";
388
+ break;
389
+ case "missing":
390
+ this.#element.textContent = "未安装";
391
+ break;
392
+ }
393
+ this.#element.dataset.state = status;
394
+ this.#element.title = this.#element.textContent;
395
+ this.dispatchEvent(new Event("change"));
396
+ }
397
+
398
+ /**
399
+ * 释放尚未完成的探测。
400
+ * Release pending probes.
401
+ * @returns {void} 无返回值 / No return value.
402
+ */
403
+ destroy() {
404
+ this.#controller?.abort();
405
+ this.#controller = undefined;
406
+ }
407
+ }
408
+
409
+ /**
410
+ * 同一文档内的主页/子页导航;iframe 各自的实例通过浏览器联合历史协作。
411
+ * Navigate home/detail views within a document; iframe instances cooperate through joint browser history.
412
+ */
413
+ class Navigation extends EventTarget {
414
+ #container;
415
+ #home;
416
+ #create;
417
+ #window;
418
+ #key = null;
419
+ #view;
420
+ #retiring;
421
+ #controller;
422
+ #animation;
423
+ #scroll = new WeakMap();
424
+ #onHistory = () => this.#route();
425
+ #onPageShow = event => {
426
+ if (event.persisted) this.#route(true);
427
+ };
428
+
429
+ /**
430
+ * 根视图始终保留;工厂按需提供子页,可用 signal 取消离开后的异步加载。
431
+ * Retain the home view and create details on demand; signal cancels async work after departure.
432
+ * @param {HTMLElement} container 由调用方布局的页面容器 / Caller-styled view container.
433
+ * @param {HTMLElement} home 已创建的主页节点 / Existing home view.
434
+ * @param {(key: string, signal: AbortSignal) => HTMLElement | undefined} create 子页工厂;未知路径返回 undefined / Detail factory; undefined for unknown routes.
435
+ */
436
+ constructor(container, home, create) {
437
+ super();
438
+ this.#container = container;
439
+ this.#home = home;
440
+ this.#create = create;
441
+ this.#window = container.ownerDocument.defaultView;
442
+ container.replaceChildren(home);
443
+ this.#window.addEventListener("popstate", this.#onHistory);
444
+ this.#window.addEventListener("hashchange", this.#onHistory);
445
+ this.#window.addEventListener("pageshow", this.#onPageShow);
446
+ this.#route();
447
+ }
448
+
449
+ /**
450
+ * 当前子页键;空字符串表示主页。
451
+ * Current detail key; empty means home.
452
+ */
453
+ get current() {
454
+ return this.#key;
455
+ }
456
+
457
+ /**
458
+ * 是否可以返回上一级或先前文档。
459
+ * Whether a parent view or previous document is available.
460
+ */
461
+ get canGoBack() {
462
+ return Boolean(this.#key) || this.#window.history.length > 1;
463
+ }
464
+
465
+ /**
466
+ * 加入子页历史;使用文档自身 URL,避免 srcdoc 按宿主 base URL 跳转。
467
+ * Push a detail using the document URL, avoiding srcdoc navigation against the host base URL.
468
+ * @param {string} key 子页键 / Detail key.
469
+ * @returns {void} 无返回值 / No return value.
470
+ */
471
+ open(key) {
472
+ if (key === this.#key) return;
473
+ const url = new URL(this.#window.location.href);
474
+ url.hash = encodeURIComponent(key);
475
+ this.#window.history.pushState({ ...this.#window.history.state, preferencePanesRoute: key }, "", url.href);
476
+ this.#route();
477
+ }
478
+
479
+ /**
480
+ * 沿浏览器联合历史返回,根页可退回宿主或上个文档。
481
+ * Go back through joint history, including a host or previous document from home.
482
+ * @returns {void} 无返回值 / No return value.
483
+ */
484
+ back() {
485
+ if (this.canGoBack) this.#window.history.back();
486
+ }
487
+
488
+ /**
489
+ * 解析 URL 并统一处理页面切换、加载取消与动画结束后的释放。
490
+ * Resolve the URL and coordinate transitions, cancellation and release after animation.
491
+ * @param {boolean} [reload] 从页面缓存恢复时重新创建子页 / Recreate a detail after bfcache restoration.
492
+ * @returns {void} 无返回值 / No return value.
493
+ */
494
+ #route(reload = false) {
495
+ const url = new URL(this.#window.location.href);
496
+ let key;
497
+ try {
498
+ key = decodeURIComponent(url.hash.slice(1));
499
+ } catch (error) {
500
+ if (!(error instanceof URIError)) throw error;
501
+ key = "";
502
+ }
503
+ if (!reload && key === this.#key) return;
504
+ this.#controller?.abort();
505
+ this.#controller = new AbortController();
506
+ const next = key ? this.#create(key, this.#controller.signal) : undefined;
507
+ if (!next) key = "";
508
+ const history = this.#window.history;
509
+ // 直接打开子页时建立一次主页历史;刷新不重复堆叠。
510
+ // Seed home history once for direct details, without stacking entries on reload.
511
+ if (url.hash && history.state?.preferencePanesRoute !== key) {
512
+ url.hash = "";
513
+ history.replaceState({ ...history.state, preferencePanesRoute: "" }, "", url.href);
514
+ if (key) {
515
+ url.hash = encodeURIComponent(key);
516
+ history.pushState({ ...history.state, preferencePanesRoute: key }, "", url.href);
517
+ }
518
+ }
519
+ const previous = this.#view;
520
+ const position = previous ? this.#window.getComputedStyle(previous).transform : "none";
521
+ this.#animation?.cancel();
522
+ this.#retiring?.remove();
523
+ this.#retiring = previous;
524
+ if (previous) {
525
+ this.#scroll.set(previous, previous.scrollTop);
526
+ previous.inert = true;
527
+ }
528
+ this.#key = key;
529
+ this.#view = next;
530
+ this.#home.inert = Boolean(next);
531
+ if (next) {
532
+ next.inert = false;
533
+ this.#container.append(next);
534
+ next.scrollTop = this.#scroll.get(next) ?? 0;
535
+ }
536
+ const moving = next ?? previous;
537
+ if (moving) {
538
+ const animation = moving.animate([{ transform: next ? "translateX(100%)" : position }, { transform: next ? "translateX(0)" : "translateX(100%)" }], { duration: this.#window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 280, easing: "cubic-bezier(.22,.61,.36,1)", fill: "forwards" });
539
+ this.#animation = animation;
540
+ animation.onfinish = () => {
541
+ if (this.#animation !== animation) return;
542
+ this.#retiring?.remove();
543
+ this.#retiring = undefined;
544
+ animation.cancel();
545
+ this.#animation = undefined;
546
+ };
547
+ }
548
+ this.dispatchEvent(new Event("change"));
549
+ }
550
+
551
+ /**
552
+ * 释放监听器、加载、动画和节点;调用方可重新创建导航。
553
+ * Release listeners, loads, animations and nodes so callers can recreate navigation.
554
+ * @returns {void} 无返回值 / No return value.
555
+ */
556
+ destroy() {
557
+ this.#window.removeEventListener("popstate", this.#onHistory);
558
+ this.#window.removeEventListener("hashchange", this.#onHistory);
559
+ this.#window.removeEventListener("pageshow", this.#onPageShow);
560
+ this.#controller?.abort();
561
+ this.#animation?.cancel();
562
+ this.#retiring?.remove();
563
+ this.#view?.remove();
564
+ this.#home.remove();
565
+ }
566
+ }
567
+
568
+ var defaults = "/* 通用默认样式只使用 pp 命名空间;项目可通过 CSS 输入覆盖变量和组件。\n * Generic defaults use only the pp namespace; projects may override variables and components through CSS input. */\n:root {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-field: #f1f2f3;\n --pp-border: #e3e5e7;\n --pp-muted: #797f87;\n --pp-accent: #1677ff;\n --pp-warning: #9a6700;\n --pp-host-height: 0px;\n --pp-keyboard-height: 0px;\n}\n.pp-panel {\n font:\n 15px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n color: var(--pp-text);\n background: var(--pp-background);\n position: relative;\n display: flex;\n flex-direction: column;\n width: 100%;\n max-width: 100vw;\n min-width: 0;\n height: 100vh;\n overflow: hidden;\n}\n\n:root[data-theme=\"dark\"] {\n --pp-text: #f1f2f3;\n --pp-background: #0d0e0f;\n --pp-surface: #18191c;\n --pp-field: #2f3238;\n --pp-border: #2f3238;\n --pp-muted: #9499a0;\n --pp-warning: #f0b849;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\n flex: none;\n height: calc(52px + env(safe-area-inset-top));\n padding: env(safe-area-inset-top) 12px 0;\n display: flex;\n align-items: center;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n position: relative;\n z-index: 2;\n}\n.pp-title {\n flex: 1;\n text-align: center;\n font-size: 17px;\n font-weight: 500;\n margin: 0;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-nav-spacer {\n width: 44px;\n flex: none;\n}\n.pp-panel button {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel .pp-back {\n width: 44px;\n height: 44px;\n flex: none;\n font-size: 34px;\n line-height: 32px;\n padding: 0;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n position: relative;\n flex: 1;\n min-width: 0;\n min-height: 0;\n overflow: hidden;\n}\n@supports (height: 100dvh) {\n .pp-panel {\n height: 100dvh;\n }\n}\n.pp-fields,\n.pp-choice-page,\n.pp-settings-page,\n.pp-cache-page {\n position: absolute;\n inset: 0;\n min-width: 0;\n overflow-x: hidden;\n overflow-y: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom) + var(--pp-keyboard-height, 0px));\n scroll-padding-bottom: var(--pp-keyboard-height, 0px);\n background: var(--pp-background);\n}\n.pp-choice-link {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n max-width: 45%;\n min-width: 44px;\n min-height: 44px;\n padding: 0;\n text-align: right;\n flex: 1;\n}\n.pp-panel a.pp-choice-link {\n color: var(--pp-accent);\n text-decoration: none;\n}\n.pp-summary {\n color: var(--pp-muted);\n font-size: 13px;\n line-height: 18px;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n overflow-wrap: anywhere;\n}\n.pp-chevron {\n color: var(--pp-muted);\n font-size: 22px;\n flex: none;\n}\n.pp-editor {\n flex: none;\n width: 45%;\n min-width: 0;\n min-height: 36px;\n padding: 8px 10px;\n font: inherit;\n font-size: 16px;\n color: var(--pp-text);\n background: var(--pp-field);\n border: 0;\n border-radius: 6px;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-editor {\n width: 100%;\n margin-top: 10px;\n}\n.pp-panel [hidden] {\n display: none !important;\n}\n.pp-label {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n margin-right: 16px;\n}\n.pp-field-name {\n color: var(--pp-text);\n font-size: 15px;\n}\n.pp-field-description {\n margin-top: 2px;\n color: var(--pp-muted);\n font-size: 12px;\n}\n.pp-field-warning {\n margin-top: 4px;\n color: var(--pp-warning);\n font-size: 12px;\n overflow-wrap: anywhere;\n}\n.pp-group {\n margin-top: 16px;\n}\n.pp-group-title {\n margin: 0 0 8px;\n color: var(--pp-muted);\n font-size: 15px;\n font-weight: 400;\n}\n.pp-row {\n min-width: 0;\n min-height: 48px;\n padding: 16px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n}\n.pp-rows > :last-child {\n border-bottom: 0 !important;\n}\n.pp-switch {\n flex: none;\n accent-color: var(--pp-accent);\n}\n.pp-choice {\n justify-content: space-between;\n cursor: pointer;\n}\n.pp-choice input {\n width: 20px;\n height: 20px;\n flex: none;\n accent-color: var(--pp-accent);\n margin: 0;\n}\n.pp-description {\n font-size: 12px;\n line-height: 1.6;\n color: var(--pp-muted);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-module-info {\n display: flex;\n gap: 12px;\n margin: 12px 0;\n}\n.pp-module-details {\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-module-source {\n color: inherit;\n text-decoration: underline;\n}\n.pp-status {\n position: fixed;\n inset: 0;\n display: grid;\n place-content: center;\n justify-items: center;\n gap: 12px;\n min-width: 0;\n min-height: 0;\n margin: 0;\n padding: 24px;\n color: var(--pp-muted, GrayText);\n text-align: center;\n background: var(--pp-background, Canvas);\n}\n.pp-viewport > .pp-status {\n position: absolute;\n}\n.pp-status-spinner {\n box-sizing: border-box;\n width: 28px;\n height: 28px;\n border: 3px solid var(--pp-border, #8884);\n border-top-color: var(--pp-accent, AccentColor);\n border-radius: 50%;\n animation: pp-status-spin 0.8s linear infinite;\n}\n.pp-status-message {\n max-width: 100%;\n margin: 0;\n overflow-wrap: anywhere;\n}\n.pp-status-action {\n min-width: 96px;\n min-height: 44px;\n padding: 8px 16px;\n border: 0;\n border-radius: 6px;\n color: var(--pp-text, ButtonText);\n font: inherit;\n cursor: pointer;\n background: var(--pp-surface, ButtonFace);\n}\n@keyframes pp-status-spin {\n to {\n transform: rotate(1turn);\n }\n}\n.pp-cache {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n\n/* 首页沿用表单的分组与说明样式,入口按屏幕宽度自动换行。\n * The landing page reuses form groups and descriptions, with entries wrapping by viewport width. */\n:root.pp-page {\n color-scheme: light dark;\n}\n.pp-page body {\n margin: 0;\n font:\n 16px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n background: var(--pp-background, Canvas);\n color: var(--pp-text, CanvasText);\n}\n[data-preference-panes-pages] {\n position: relative;\n min-height: calc(100vh - var(--pp-host-height));\n background: inherit;\n}\n[data-preference-panes-module-page],\n[data-preference-panes-link-page] {\n position: fixed;\n inset: var(--pp-host-height) 0 0;\n background: inherit;\n z-index: 1;\n box-shadow: -8px 0 24px #00000014;\n}\n[data-preference-panes-module-page] iframe,\n[data-preference-panes-link-page] iframe {\n display: block;\n border: 0;\n width: 100%;\n height: 100%;\n background: inherit;\n}\n[data-module-message] {\n position: absolute;\n inset: 0;\n margin: 0;\n padding: 20px;\n background: inherit;\n}\n[data-module-message][hidden] {\n display: none;\n}\n.pp-host-header {\n position: sticky;\n top: 0;\n z-index: 2;\n display: grid;\n grid-template-columns: 44px minmax(0, 1fr) 44px;\n align-items: center;\n min-height: 44px;\n padding: env(safe-area-inset-top) max(12px, env(safe-area-inset-right)) 0 max(12px, env(safe-area-inset-left));\n border-bottom: 1px solid var(--pp-border, #8884);\n background: var(--pp-surface, Canvas);\n color: var(--pp-accent, Highlight);\n font-size: 17px;\n}\n.pp-host-title {\n text-align: center;\n color: var(--pp-text, CanvasText);\n font-weight: 600;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.pp-host-back {\n border: 0;\n background: none;\n color: inherit;\n width: 44px;\n height: 44px;\n padding: 8px;\n font:\n 32px / 1 -apple-system,\n sans-serif;\n cursor: pointer;\n}\n.pp-host-back[hidden] {\n visibility: hidden;\n display: block;\n}\n.pp-host-toast {\n position: fixed;\n z-index: 3;\n bottom: calc(24px + env(safe-area-inset-bottom) + var(--pp-keyboard-height));\n left: 50%;\n transform: translateX(-50%);\n max-width: calc(100% - 48px);\n padding: 10px 18px;\n border-radius: 12px;\n background: #222e;\n color: #fff;\n font-size: 14px;\n text-align: center;\n}\n.pp-host-toast[hidden] {\n display: none;\n}\n.pp-host-error {\n margin: 24px;\n padding: 16px;\n border: 1px solid var(--pp-danger, #e45656);\n border-radius: 12px;\n font-size: 16px;\n overflow-wrap: anywhere;\n}\n\n.pp-home {\n max-width: 1000px;\n margin: auto;\n padding: 24px max(16px, env(safe-area-inset-right)) calc(24px + env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));\n}\n.pp-home-logo {\n display: block;\n width: 64px;\n height: 64px;\n margin: 0 auto 24px;\n object-fit: contain;\n}\n.pp-home-entries {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-around;\n row-gap: 16px;\n padding: 16px 0;\n background: var(--pp-surface);\n}\n.pp-home-item {\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n align-items: center;\n width: 25%;\n min-width: 0;\n padding: 0 4px;\n border: 0;\n background: none;\n color: var(--pp-text);\n font:\n 12px / 1.5 -apple-system,\n sans-serif;\n cursor: pointer;\n text-decoration: none;\n}\n@media (min-width: 500px) {\n .pp-home-item {\n width: calc(100% / 6);\n }\n}\n@media (min-width: 700px) {\n .pp-home-item {\n width: 12.5%;\n }\n}\n.pp-home-icon {\n box-sizing: border-box;\n width: 56px;\n height: 56px;\n padding: 8px;\n border-radius: 50%;\n background: var(--pp-field);\n font-size: 32px;\n line-height: 40px;\n}\n.pp-home-icon img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n.pp-home-name {\n margin-top: 8px;\n}\n.pp-home-content {\n min-width: 0;\n width: 100%;\n}\n.pp-home-content > span {\n display: block;\n}\n.pp-home-name,\n.pp-home-item [data-module-status] {\n width: 100%;\n overflow: hidden;\n text-align: center;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.pp-home-item [data-module-status] {\n min-height: 20px;\n}\n.pp-home-item:disabled {\n opacity: 0.45;\n cursor: default;\n}\n.pp-home-entries[data-layout=\"list\"] {\n display: block;\n padding: 0;\n}\n.pp-home-entries[data-layout=\"list\"] .pp-home-item {\n flex-direction: row;\n gap: 12px;\n width: 100%;\n padding: 12px 16px;\n font-size: 15px;\n}\n.pp-home-entries[data-layout=\"list\"] .pp-home-item + .pp-home-item {\n border-top: 1px solid var(--pp-border);\n}\n.pp-home-entries[data-layout=\"list\"] .pp-home-icon {\n flex: none;\n width: 40px;\n height: 40px;\n padding: 4px;\n font-size: 24px;\n line-height: 32px;\n}\n.pp-home-entries[data-layout=\"list\"] .pp-home-name {\n margin-top: 0;\n text-align: left;\n}\n.pp-home-arrow {\n color: var(--pp-muted);\n font-size: 24px;\n}\n.pp-home-footer {\n margin-top: 16px;\n text-align: center;\n white-space: normal;\n}\n.pp-home-footer p {\n margin: 8px 0 0;\n}\n";
569
+
570
+ const selector = "style[data-preference-panes-defaults]";
571
+ const stylesheetSelector = "link[data-preference-panes-stylesheet]";
572
+
573
+ /**
574
+ * 在文档中安装一次默认样式,并标记当前调用方是否拥有该节点。
575
+ * Install default styles once and report whether the current caller owns the node.
576
+ * @param {Document} document 目标文档 / Target document.
577
+ * @returns {{element: HTMLStyleElement, owned: boolean}} 样式节点及所有权 / Style node and ownership.
578
+ */
579
+ function installDefaultStyles(document) {
580
+ const existing = document.head.querySelector(selector);
581
+ if (existing) return { element: existing, owned: false };
582
+ const element = document.createElement("style");
583
+ element.dataset.preferencePanesDefaults = "";
584
+ element.textContent = defaults;
585
+ document.head.insertBefore(element, document.head.querySelector(stylesheetSelector));
586
+ return { element, owned: true };
587
+ }
588
+
589
+ /**
590
+ * 校验外部 JSON 中的章节、入口和导航目标,避免生成不可访问的设置入口。
591
+ * Validate sections, entries and navigation targets in external JSON before rendering inaccessible entries.
592
+ * @param {unknown} config 外部首页配置 / External homepage configuration.
593
+ * @returns {void} 非法输入抛出错误 / Invalid input throws.
594
+ */
595
+ function validateHome(config) {
596
+ if (!config || typeof config.title !== "string" || !Array.isArray(config.sections)) throw new Error("首页 JSON 缺少 title 或 sections");
597
+ if (config.logo !== undefined && typeof config.logo !== "string") throw new Error("首页 logo 必须是图片地址");
598
+ if (config.footer !== undefined && (!Array.isArray(config.footer) || config.footer.some(text => typeof text !== "string"))) throw new Error("首页 footer 必须是文字数组");
599
+ const modules = new Set();
600
+ for (const section of config.sections) {
601
+ if (!section || typeof section.title !== "string" || !Array.isArray(section.items) || (section.layout !== undefined && !["grid", "list"].includes(section.layout))) throw new Error("首页章节需要 title、items 和有效的 layout");
602
+ for (const item of section.items) {
603
+ if (!item || typeof item.name !== "string" || !item.icon || (typeof item.icon.src === "string") === (typeof item.icon.text === "string") || (item.icon.src !== undefined && typeof item.icon.src !== "string") || (item.icon.text !== undefined && typeof item.icon.text !== "string"))
604
+ throw new Error("首页入口需要 name 和唯一的图片或文字 icon");
605
+ if (item.description !== undefined && typeof item.description !== "string") throw new Error(`入口 ${item.name} 的 description 必须是文字`);
606
+ if (item.target !== undefined && (item.module !== undefined || !["internal", "_self", "_blank"].includes(item.target))) throw new Error(`入口 ${item.name} 的 target 只允许链接使用 internal、_self 或 _blank`);
607
+ if ((typeof item.module === "string") === (typeof item.href === "string") || (item.module !== undefined && typeof item.module !== "string") || (item.href !== undefined && typeof item.href !== "string")) throw new Error(`入口 ${item.name} 必须选择 module 或 href`);
608
+ if (item.module !== undefined) {
609
+ if (!/^[A-Za-z0-9_-]+$/.test(item.module) || (item.pageModule !== undefined && (typeof item.pageModule !== "string" || !/^[A-Za-z0-9_-]+$/.test(item.pageModule))) || modules.has(item.module)) throw new Error(`入口 ${item.name} 的模块名称无效或重复`);
610
+ modules.add(item.module);
611
+ } else {
612
+ const protocol = new URL(item.href, "https://preference-panes.invalid/").protocol;
613
+ if (item.pageModule !== undefined || ["javascript:", "data:", "vbscript:"].includes(protocol) || (item.target === "internal" && !["http:", "https:"].includes(protocol))) throw new Error(`入口 ${item.name} 的链接无效`);
614
+ }
615
+ }
616
+ }
617
+ }
618
+
619
+ /**
620
+ * 共用网格和列表章节;资源地址相对于 JSON,所有项目文字通过 textContent 绘制。
621
+ * Share grid and list sections, resolve resources against JSON and render project text through textContent.
622
+ * @param {import("./home.mjs").HomeConfig} config 已校验的首页配置 / Validated homepage configuration.
623
+ * @param {URL} url JSON 地址 / JSON URL.
624
+ * @param {HTMLElement} home 首页容器 / Homepage container.
625
+ * @returns {void} 无返回值 / No return value.
626
+ */
627
+ function renderHome(config, url, home) {
628
+ const document = home.ownerDocument;
629
+ home.replaceChildren();
630
+ if (config.logo) {
631
+ const logo = document.createElement("img");
632
+ logo.className = "pp-home-logo";
633
+ logo.src = new URL(config.logo, url).href;
634
+ logo.alt = config.title;
635
+ home.append(logo);
636
+ }
637
+ for (const section of config.sections) {
638
+ const group = document.createElement("section");
639
+ group.className = "pp-group";
640
+ const title = document.createElement("h2");
641
+ title.className = "pp-group-title";
642
+ title.textContent = section.title;
643
+ const entries = document.createElement("div");
644
+ entries.className = "pp-home-entries";
645
+ entries.dataset.layout = section.layout ?? "grid";
646
+ for (const item of section.items) {
647
+ const entry = document.createElement(item.module ? "button" : "a");
648
+ entry.className = "pp-home-item";
649
+ if (item.module) {
650
+ entry.type = "button";
651
+ entry.disabled = true;
652
+ entry.dataset.module = item.module;
653
+ if (item.pageModule) entry.dataset.preferencePanesModule = item.pageModule;
654
+ } else {
655
+ entry.href = new URL(item.href, url).href;
656
+ if (item.target !== undefined) {
657
+ entry.dataset.target = item.target;
658
+ switch (item.target) {
659
+ case "_blank":
660
+ entry.target = item.target;
661
+ entry.rel = "noopener noreferrer";
662
+ break;
663
+ case "_self":
664
+ entry.target = item.target;
665
+ break;
666
+ }
667
+ }
668
+ }
669
+ const icon = document.createElement("span");
670
+ icon.className = "pp-home-icon";
671
+ icon.setAttribute("aria-hidden", "true");
672
+ if (item.icon.src !== undefined) {
673
+ const image = document.createElement("img");
674
+ image.src = new URL(item.icon.src, url).href;
675
+ image.alt = "";
676
+ icon.append(image);
677
+ } else icon.textContent = item.icon.text;
678
+ const content = document.createElement("span");
679
+ content.className = "pp-home-content";
680
+ const name = document.createElement("span");
681
+ name.className = "pp-home-name";
682
+ name.textContent = item.name;
683
+ content.append(name);
684
+ if (item.description !== undefined) {
685
+ const description = document.createElement("span");
686
+ description.className = "pp-description";
687
+ description.textContent = item.description;
688
+ content.append(description);
689
+ }
690
+ if (item.module) {
691
+ const status = document.createElement("span");
692
+ status.className = "pp-description";
693
+ status.dataset.moduleStatus = "";
694
+ status.textContent = "检测中";
695
+ content.append(status);
696
+ }
697
+ entry.append(icon, content);
698
+ if (entries.dataset.layout === "list") {
699
+ const arrow = document.createElement("span");
700
+ arrow.className = "pp-home-arrow";
701
+ arrow.textContent = "›";
702
+ arrow.setAttribute("aria-hidden", "true");
703
+ entry.append(arrow);
704
+ }
705
+ entries.append(entry);
706
+ }
707
+ group.append(title, entries);
708
+ home.append(group);
709
+ }
710
+ const footer = document.createElement("footer");
711
+ footer.className = "pp-home-footer pp-description";
712
+ for (const text of config.footer ?? []) {
713
+ const paragraph = document.createElement("p");
714
+ paragraph.textContent = text;
715
+ footer.append(paragraph);
716
+ }
717
+ home.append(footer);
718
+ }
719
+
720
+ /**
721
+ * 浏览器宿主提供网页导航、通知、主题和可视区域避让。
722
+ * The browser host provides web navigation, notices, theme and viewport clearance.
723
+ * @param {import("./home.mjs").BridgeOptions} options 菜单和返回动作 / Menu and back action.
724
+ * @returns {import("./home.mjs").Bridge} 浏览器宿主 / Browser host.
725
+ */
726
+ function browserHost({ menu, back }) {
727
+ const root = document.documentElement;
728
+ const header = document.createElement("header");
729
+ header.className = "pp-host-header";
730
+ const button = document.createElement("button");
731
+ button.className = "pp-host-back";
732
+ button.type = "button";
733
+ button.textContent = "‹";
734
+ button.setAttribute("aria-label", "返回");
735
+ button.onclick = back;
736
+ const title = document.createElement("span");
737
+ title.className = "pp-host-title";
738
+ header.append(button, title, menu.element);
739
+ document.body.prepend(header);
740
+ const toast = document.createElement("div");
741
+ toast.className = "pp-host-toast";
742
+ toast.setAttribute("role", "status");
743
+ toast.hidden = true;
744
+ document.body.append(toast);
745
+ let timer;
746
+ const theme = window.matchMedia("(prefers-color-scheme: dark)");
747
+ const applyTheme = () => {
748
+ root.dataset.theme = theme.matches ? "dark" : "light";
749
+ };
750
+ const viewport = window.visualViewport;
751
+ const resize = () => {
752
+ root.style.setProperty("--pp-host-height", `${header.getBoundingClientRect().height}px`);
753
+ const clearance = viewport && viewport.scale === 1 ? Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop) : 0;
754
+ root.style.setProperty("--pp-keyboard-height", `${clearance}px`);
755
+ };
756
+ theme.addEventListener("change", applyTheme);
757
+ window.addEventListener("resize", resize);
758
+ window.addEventListener("pageshow", resize);
759
+ viewport?.addEventListener("resize", resize);
760
+ viewport?.addEventListener("scroll", resize);
761
+ applyTheme();
762
+ resize();
763
+ return {
764
+ update(state) {
765
+ title.textContent = state.title;
766
+ button.hidden = !state.canGoBack;
767
+ menu.element.hidden = !state.actions.length;
768
+ },
769
+ confirm: message => window.confirm(message),
770
+ notice(detail) {
771
+ clearTimeout(timer);
772
+ toast.textContent = detail.message;
773
+ toast.hidden = false;
774
+ timer = setTimeout(() => {
775
+ toast.hidden = true;
776
+ }, 2400);
777
+ },
778
+ openURL(url, target) {
779
+ switch (target) {
780
+ case "_blank":
781
+ window.open(url, "_blank", "noopener,noreferrer");
782
+ break;
783
+ default:
784
+ window.location.assign(url);
785
+ break;
786
+ }
787
+ },
788
+ destroy() {
789
+ clearTimeout(timer);
790
+ theme.removeEventListener("change", applyTheme);
791
+ window.removeEventListener("resize", resize);
792
+ window.removeEventListener("pageshow", resize);
793
+ viewport?.removeEventListener("resize", resize);
794
+ viewport?.removeEventListener("scroll", resize);
795
+ root.style.removeProperty("--pp-host-height");
796
+ root.style.removeProperty("--pp-keyboard-height");
797
+ delete root.dataset.theme;
798
+ header.remove();
799
+ toast.remove();
800
+ },
801
+ };
802
+ }
803
+
804
+ /**
805
+ * 读取固定相对路径的首页 JSON 与可选 CSS、Bridge,初始化后共用模块探测与导航。
806
+ * Load homepage JSON and optional CSS and Bridge at fixed relative paths, then share module probes and navigation.
807
+ * @returns {Promise<() => void>} 退出清理动作;加载失败会显示错误并拒绝 / Cleanup action; load failures display an error and reject.
808
+ */
809
+ async function startHome() {
810
+ let host;
811
+ let menu;
812
+ let navigation;
813
+ let frame;
814
+ let linkTitle;
815
+ let statuses = [];
816
+ let destroyed = false;
817
+ let defaults;
818
+ let stylesheet;
819
+ const controller = new AbortController();
820
+ const destroy = () => {
821
+ if (destroyed) return;
822
+ destroyed = true;
823
+ controller.abort();
824
+ for (const { status } of statuses) status.destroy();
825
+ navigation?.destroy();
826
+ host?.destroy();
827
+ menu?.destroy();
828
+ stylesheet?.remove();
829
+ if (defaults?.owned) defaults.element.remove();
830
+ window.removeEventListener("pagehide", pagehide);
831
+ };
832
+ const pagehide = event => {
833
+ if (!event.persisted) destroy();
834
+ };
835
+ window.addEventListener("pagehide", pagehide);
836
+ try {
837
+ defaults = installDefaultStyles(document);
838
+ const jsonURL = new URL("./home.json", document.baseURI);
839
+ const response = await fetch(jsonURL, { signal: controller.signal });
840
+ if (!response.ok) throw new Error(`首页 JSON 加载失败:HTTP ${response.status}`);
841
+ const config = await response.json();
842
+ validateHome(config);
843
+ if (destroyed) return destroy;
844
+ const cssURL = new URL("./theme.css", document.baseURI);
845
+ const cssResponse = await fetch(cssURL, { method: "HEAD", cache: "no-store", signal: controller.signal });
846
+ if (destroyed) return destroy;
847
+ switch (cssResponse.status) {
848
+ case 200:
849
+ stylesheet = document.createElement("link");
850
+ stylesheet.rel = "stylesheet";
851
+ stylesheet.dataset.preferencePanesStylesheet = "";
852
+ stylesheet.href = cssURL.href;
853
+ await new Promise((resolve, reject) => {
854
+ const abort = () => reject(new DOMException("页面已退出", "AbortError"));
855
+ controller.signal.addEventListener("abort", abort, { once: true });
856
+ stylesheet.onload = () => {
857
+ controller.signal.removeEventListener("abort", abort);
858
+ resolve();
859
+ };
860
+ stylesheet.onerror = () => {
861
+ controller.signal.removeEventListener("abort", abort);
862
+ reject(new Error("项目 CSS 加载失败"));
863
+ };
864
+ document.head.append(stylesheet);
865
+ });
866
+ break;
867
+ case 404:
868
+ break;
869
+ default:
870
+ throw new Error(`项目 CSS 探测失败:HTTP ${cssResponse.status}`);
871
+ }
872
+ if (destroyed) return destroy;
873
+ const bridgeURL = new URL("./bridge.mjs", document.baseURI);
874
+ const bridgeResponse = await fetch(bridgeURL, { method: "HEAD", cache: "no-store", signal: controller.signal });
875
+ if (destroyed) return destroy;
876
+ let factory;
877
+ switch (bridgeResponse.status) {
878
+ case 200:
879
+ factory = (await import(bridgeURL.href)).default;
880
+ break;
881
+ case 404:
882
+ factory = browserHost;
883
+ break;
884
+ default:
885
+ throw new Error(`Bridge 探测失败:HTTP ${bridgeResponse.status}`);
886
+ }
887
+ if (destroyed) return destroy;
888
+ document.title = config.title;
889
+ menu = new ActionMenu(id => frame.perform(id));
890
+ host = await factory({ menu, back: () => (navigation.current && frame ? frame.back() : navigation.back()) });
891
+ if (destroyed) {
892
+ host.destroy();
893
+ menu.destroy();
894
+ return destroy;
895
+ }
896
+ const container = document.querySelector("[data-preference-panes-pages]");
897
+ const home = document.querySelector("[data-preference-panes-home]");
898
+ const template = document.querySelector("template[data-preference-panes-module]");
899
+ renderHome(config, jsonURL, home);
900
+ const buttons = [...home.querySelectorAll("[data-module]")];
901
+ const links = [...home.querySelectorAll("a[href]")];
902
+ const stylesheetURL = stylesheet?.href;
903
+ const update = () => {
904
+ if (destroyed) return;
905
+ const state = navigation.current && frame ? frame.state : { title: navigation.current ? linkTitle : document.title, actions: [], busy: false, canGoBack: navigation.canGoBack };
906
+ menu.update(state.actions, state.busy);
907
+ Promise.resolve(host.update(state)).catch(error => {
908
+ if (!destroyed) host.notice({ kind: "error", message: error.message });
909
+ });
910
+ };
911
+ navigation = new Navigation(container, home, (module, signal) => {
912
+ frame = undefined;
913
+ const link = links.find((link, index) => link.dataset.target === "internal" && module === `link:${index}`);
914
+ if (link) {
915
+ const page = document.createElement("section");
916
+ page.dataset.preferencePanesLinkPage = "";
917
+ const iframe = document.createElement("iframe");
918
+ linkTitle = link.querySelector(".pp-home-name").textContent;
919
+ iframe.title = linkTitle;
920
+ iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox");
921
+ iframe.src = link.href;
922
+ page.append(iframe);
923
+ signal.addEventListener("abort", () => iframe.remove(), { once: true });
924
+ return page;
925
+ }
926
+ const button = buttons.find(button => button.dataset.module === module);
927
+ if (!button) return;
928
+ const page = template.content.firstElementChild.cloneNode(true);
929
+ page.dataset.preferencePanesModulePage = "";
930
+ const message = page.querySelector("[data-module-message]");
931
+ const moduleName = encodeURIComponent(button.dataset.preferencePanesModule ?? module);
932
+ const current = new ModuleFrame(`/settings/${moduleName}`, {
933
+ signal,
934
+ headers: { "X-PreferencePanes-JSON": `/api/${encodeURIComponent(module)}`, ...(stylesheetURL ? { "X-PreferencePanes-CSS": stylesheetURL } : {}) },
935
+ });
936
+ frame = current;
937
+ current.addEventListener("confirm", event => {
938
+ event.preventDefault();
939
+ Promise.resolve()
940
+ .then(() => host.confirm(event.detail.message))
941
+ .then(
942
+ value => {
943
+ if (!signal.aborted) event.detail.resolve(value);
944
+ },
945
+ error => {
946
+ if (!signal.aborted) event.detail.reject(error);
947
+ },
948
+ );
949
+ });
950
+ current.addEventListener("notice", event => {
951
+ event.preventDefault();
952
+ host.notice(event.detail);
953
+ });
954
+ current.addEventListener("open-url", event => {
955
+ event.preventDefault();
956
+ Promise.resolve()
957
+ .then(() => host.openURL(event.detail.url))
958
+ .catch(error => {
959
+ if (!signal.aborted) host.notice({ kind: "error", message: error.message });
960
+ });
961
+ });
962
+ current.addEventListener("change", () => {
963
+ if (!signal.aborted) update();
964
+ });
965
+ current.element.onload = () => {
966
+ if (!signal.aborted) message.hidden = true;
967
+ };
968
+ page.append(current.element);
969
+ current.load().catch(error => {
970
+ if (!signal.aborted) message.textContent = error.message;
971
+ });
972
+ return page;
973
+ });
974
+ for (const [index, link] of links.entries()) {
975
+ link.addEventListener(
976
+ "click",
977
+ event => {
978
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
979
+ event.preventDefault();
980
+ switch (link.dataset.target) {
981
+ case "internal":
982
+ navigation.open(`link:${index}`);
983
+ break;
984
+ default:
985
+ try {
986
+ Promise.resolve(host.openURL(link.href, link.dataset.target)).catch(error => {
987
+ if (!destroyed) host.notice({ kind: "error", message: error.message });
988
+ });
989
+ } catch (error) {
990
+ if (!destroyed) host.notice({ kind: "error", message: error.message });
991
+ }
992
+ break;
993
+ }
994
+ },
995
+ { signal: controller.signal },
996
+ );
997
+ }
998
+ statuses = buttons.map(button => {
999
+ const status = new ModuleStatus(button.querySelector("[data-module-status]"));
1000
+ status.addEventListener("change", () => {
1001
+ button.disabled = status.state.status !== "installed";
1002
+ });
1003
+ button.addEventListener("click", () => navigation.open(button.dataset.module), { signal: controller.signal });
1004
+ return { button, status };
1005
+ });
1006
+ const probe = () => {
1007
+ update();
1008
+ if (navigation.current) return;
1009
+ for (const { button, status } of statuses) status.check(`/api/${encodeURIComponent(button.dataset.module)}`);
1010
+ };
1011
+ navigation.addEventListener("change", probe);
1012
+ probe();
1013
+ return destroy;
1014
+ } catch (error) {
1015
+ const display = !destroyed;
1016
+ destroy();
1017
+ if (display) {
1018
+ const message = document.createElement("p");
1019
+ message.className = "pp-host-error";
1020
+ message.setAttribute("role", "alert");
1021
+ message.textContent = `无法打开设置:${error.message}`;
1022
+ document.body.prepend(message);
1023
+ }
1024
+ throw error;
1025
+ }
1026
+ }
1027
+
1028
+ export { startHome };
1029
+
1030
+ await startHome();</script>
1031
+ </body>
1032
+ </html>