@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.
@@ -14,6 +14,7 @@ export class PreferencesClient {
14
14
  #values = {};
15
15
  #warnings = {};
16
16
  #saving = false;
17
+ #arrays = new Map();
17
18
 
18
19
  /**
19
20
  * 创建从 BoxJS 定义读取和持久化设置的页面客户端。
@@ -40,13 +41,22 @@ export class PreferencesClient {
40
41
  if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
41
42
  const values = {};
42
43
  const warnings = {};
44
+ this.#arrays.clear();
43
45
  for (const field of this.#definition.fields) {
46
+ const indexed = /^(.*)\.(\d+)$/.exec(field.key);
47
+ if (indexed && !this.#arrays.has(indexed[1])) {
48
+ const storedArray = indexed[1]
49
+ .split(".")
50
+ .slice(this.#definition.settingsPath.length)
51
+ .reduce((parent, part) => Object(parent)[part], subtree);
52
+ this.#arrays.set(indexed[1], Array.isArray(storedArray) ? [...storedArray] : []);
53
+ }
44
54
  const stored = field.key
45
55
  .split(".")
46
56
  .slice(this.#definition.settingsPath.length)
47
57
  .reduce((parent, part) => Object(parent)[part], subtree);
48
58
  const resolved = resolveStoredValue(field, stored);
49
- if (Object.hasOwn(resolved, "value")) values[field.key] = resolved.value;
59
+ if (Object.prototype.hasOwnProperty.call(resolved, "value")) values[field.key] = resolved.value;
50
60
  if (resolved.warning) warnings[field.key] = resolved.warning;
51
61
  }
52
62
  this.#values = values;
@@ -60,7 +70,7 @@ export class PreferencesClient {
60
70
  * @returns {import("./client.mjs").ModuleSnapshot} 会话快照 / Session snapshot.
61
71
  */
62
72
  snapshot() {
63
- return structuredClone({ definition: this.#definition, values: this.#values, warnings: this.#warnings });
73
+ return JSON.parse(JSON.stringify({ definition: this.#definition, values: this.#values, warnings: this.#warnings }));
64
74
  }
65
75
 
66
76
  /**
@@ -79,22 +89,23 @@ export class PreferencesClient {
79
89
  * @returns {Promise<unknown>} Caches 内容或 undefined / Caches content or undefined.
80
90
  */
81
91
  async readCaches() {
82
- const response = await this.#send("get", `@${this.#definition.storageKey}.${this.#module}.Caches`);
92
+ const response = await this.#send("get", this.#definition.cachePath ?? `@${this.#definition.storageKey}.${this.#module}.Caches`);
83
93
  return response.status === 404 ? undefined : response.json();
84
94
  }
85
95
 
86
96
  /**
87
- * 删除当前模块的 Caches 子树。
88
- * Delete the current module Caches subtree.
97
+ * 删除声明的缓存子树,默认当前模块 Caches。
98
+ * Delete the declared cache subtree, defaulting to the current module Caches.
89
99
  * @returns {Promise<void>} 操作完成 / Operation completion.
90
100
  */
91
101
  clearCaches() {
92
- return this.#change("delete", `${this.#module}.Caches`, undefined, "clearCaches");
102
+ const path = this.#definition.cachePath?.slice(this.#definition.storageKey.length + 2) ?? `${this.#module}.Caches`;
103
+ return this.#change("delete", path, undefined, "clearCaches");
93
104
  }
94
105
 
95
106
  /**
96
- * 删除当前模块数据并恢复页面默认值。
97
- * Delete current module data and restore page defaults.
107
+ * 删除声明的设置子树,默认删除当前模块数据,并恢复页面默认值。
108
+ * Delete declared settings subtrees, defaulting to current module data, and restore page defaults.
98
109
  * @returns {Promise<void>} 操作完成 / Operation completion.
99
110
  */
100
111
  reset() {
@@ -175,30 +186,49 @@ export class PreferencesClient {
175
186
  if (this.#saving) throw new Error("A settings write is already in progress");
176
187
  this.#saving = true;
177
188
  try {
178
- let field;
179
- if (operation === "write" || operation === "delete") {
180
- field = this.#definition.fields.find(candidate => candidate.key === key);
181
- if (!field || (operation === "write" && !validValue(field, value))) throw new TypeError("Invalid setting value");
182
- }
183
- await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
184
189
  switch (operation) {
185
190
  case "write":
186
- this.#values[key] = structuredClone(value);
187
- delete this.#warnings[key];
188
- break;
189
191
  case "delete": {
190
- delete this.#values[key];
191
- delete this.#warnings[key];
192
- if (Object.hasOwn(field, "defaultValue")) this.#values[key] = structuredClone(field.defaultValue);
192
+ const field = this.#definition.fields.find(candidate => candidate.key === key);
193
+ if (!field || (operation === "write" && !validValue(field, value))) throw new TypeError("Invalid setting value");
194
+ const indexed = /^(.*)\.(\d+)$/.exec(key);
195
+ if (indexed) {
196
+ const array = [...(this.#arrays.get(indexed[1]) ?? [])];
197
+ for (const sibling of this.#definition.fields) {
198
+ if (sibling.key.startsWith(`${indexed[1]}.`) && /^\d+$/.test(sibling.key.slice(indexed[1].length + 1))) {
199
+ const index = Number(sibling.key.slice(indexed[1].length + 1));
200
+ if (array[index] === undefined) array[index] = this.#values[sibling.key] ?? sibling.defaultValue;
201
+ }
202
+ }
203
+ array[Number(indexed[2])] = operation === "delete" ? field.defaultValue : value;
204
+ await this.#send("set", `@${this.#definition.storageKey}.${indexed[1]}`, array);
205
+ this.#arrays.set(indexed[1], array);
206
+ } else await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
207
+ switch (operation) {
208
+ case "write":
209
+ this.#values[key] = JSON.parse(JSON.stringify(value));
210
+ delete this.#warnings[key];
211
+ break;
212
+ case "delete":
213
+ delete this.#values[key];
214
+ delete this.#warnings[key];
215
+ if (Object.prototype.hasOwnProperty.call(field, "defaultValue")) this.#values[key] = JSON.parse(JSON.stringify(field.defaultValue));
216
+ break;
217
+ }
193
218
  break;
194
219
  }
195
220
  case "clearCaches":
221
+ await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
196
222
  break;
197
223
  case "reset":
224
+ if (this.#definition.resetPaths) {
225
+ for (const path of this.#definition.resetPaths) await this.#send("delete", path);
226
+ } else await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
227
+ this.#arrays.clear();
198
228
  this.#warnings = {};
199
229
  for (const field of this.#definition.fields) {
200
230
  delete this.#values[field.key];
201
- if (Object.hasOwn(field, "defaultValue")) this.#values[field.key] = structuredClone(field.defaultValue);
231
+ if (Object.prototype.hasOwnProperty.call(field, "defaultValue")) this.#values[field.key] = JSON.parse(JSON.stringify(field.defaultValue));
202
232
  }
203
233
  break;
204
234
  }
@@ -0,0 +1,54 @@
1
+ import type { ActionMenu, MenuAction, Notice } from "./Navigation.mjs";
2
+
3
+ /** 宿主导航状态 / Bridge navigation state. */
4
+ export interface HomeState {
5
+ /** 当前标题 / Current title. */
6
+ title: string;
7
+ /** 可用菜单操作 / Available menu actions. */
8
+ actions: MenuAction[];
9
+ /** 操作是否正在执行 / Whether an operation is in progress. */
10
+ busy: boolean;
11
+ /** 联合历史是否可返回 / Whether joint history can go back. */
12
+ canGoBack: boolean;
13
+ }
14
+ /** 宿主初始化输入 / Bridge initialization inputs. */
15
+ export interface BridgeOptions {
16
+ /** 共用操作菜单 / Shared action menu. */
17
+ menu: ActionMenu;
18
+ /** 沿当前页面历史返回 / Go back through the current page history. */
19
+ back(): void;
20
+ }
21
+ /** 可选 Bridge 模块提供的宿主能力 / Host capabilities supplied by the optional Bridge module. */
22
+ export interface Bridge {
23
+ /** 同步当前导航状态 / Synchronize current navigation state. */
24
+ update(state: HomeState): void | Promise<void>;
25
+ /** 返回确认或取消选择 / Return the confirmation or cancellation choice. */
26
+ confirm(message: string): boolean | Promise<boolean>;
27
+ /** 显示操作结果 / Display an operation result. */
28
+ notice(detail: Notice): void;
29
+ /** 打开完整地址;_blank 保留当前页面,_self 替换当前页面 / Open an absolute URL; _blank retains the current page and _self replaces it. */
30
+ openURL(url: string, target?: "_self" | "_blank"): void | Promise<void>;
31
+ /** 释放界面与监听器 / Release UI and listeners. */
32
+ destroy(): void;
33
+ }
34
+ /** 宿主工厂;默认导出 / Bridge factory; the override's default export. */
35
+ export type BridgeFactory = (options: BridgeOptions) => Bridge | Promise<Bridge>;
36
+ /**
37
+ * 初始化固定路径的 JSON 首页及可选 Bridge。
38
+ * Initialize the JSON homepage and optional Bridge at fixed paths.
39
+ * @returns 退出清理动作;失败显示错误并拒绝 / Cleanup action; failures display an error and reject.
40
+ */
41
+ export function startHome(): Promise<() => void>;
42
+ /** 首页入口图标 / Homepage entry icon. */
43
+ export type HomeIcon = { src: string; text?: never } | { text: string; src?: never };
44
+ /** 外链打开方式:带导航的内部框架、当前页面或新页面 / Link opening mode: an internal frame with navigation, the current page or a new page. */
45
+ export type HomeLinkTarget = "internal" | "_self" | "_blank";
46
+ /** 首页模块或链接入口 / Homepage module or link entry. */
47
+ export type HomeItem = { name: string; icon: HomeIcon; description?: string } & ({ module: string; pageModule?: string; href?: never; target?: never } | { href: string; target?: HomeLinkTarget; module?: never; pageModule?: never });
48
+ /** 运行时导入的首页配置 / Runtime-imported homepage configuration. */
49
+ export interface HomeConfig {
50
+ title: string;
51
+ logo?: string;
52
+ sections: { title: string; layout?: "grid" | "list"; items: HomeItem[] }[];
53
+ footer?: string[];
54
+ }
@@ -0,0 +1,16 @@
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">__PREFERENCE_PANES_HOME_SCRIPT__
14
+ await startHome();</script>
15
+ </body>
16
+ </html>
@@ -0,0 +1,441 @@
1
+ import { ActionMenu, ModuleFrame, ModuleStatus, Navigation } from "./Navigation.mjs";
2
+ import { installDefaultStyles } from "./styles.mjs";
3
+
4
+ /**
5
+ * 校验外部 JSON 中的章节、入口和导航目标,避免生成不可访问的设置入口。
6
+ * Validate sections, entries and navigation targets in external JSON before rendering inaccessible entries.
7
+ * @param {unknown} config 外部首页配置 / External homepage configuration.
8
+ * @returns {void} 非法输入抛出错误 / Invalid input throws.
9
+ */
10
+ function validateHome(config) {
11
+ if (!config || typeof config.title !== "string" || !Array.isArray(config.sections)) throw new Error("首页 JSON 缺少 title 或 sections");
12
+ if (config.logo !== undefined && typeof config.logo !== "string") throw new Error("首页 logo 必须是图片地址");
13
+ if (config.footer !== undefined && (!Array.isArray(config.footer) || config.footer.some(text => typeof text !== "string"))) throw new Error("首页 footer 必须是文字数组");
14
+ const modules = new Set();
15
+ for (const section of config.sections) {
16
+ if (!section || typeof section.title !== "string" || !Array.isArray(section.items) || (section.layout !== undefined && !["grid", "list"].includes(section.layout))) throw new Error("首页章节需要 title、items 和有效的 layout");
17
+ for (const item of section.items) {
18
+ 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"))
19
+ throw new Error("首页入口需要 name 和唯一的图片或文字 icon");
20
+ if (item.description !== undefined && typeof item.description !== "string") throw new Error(`入口 ${item.name} 的 description 必须是文字`);
21
+ if (item.target !== undefined && (item.module !== undefined || !["internal", "_self", "_blank"].includes(item.target))) throw new Error(`入口 ${item.name} 的 target 只允许链接使用 internal、_self 或 _blank`);
22
+ 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`);
23
+ if (item.module !== undefined) {
24
+ 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} 的模块名称无效或重复`);
25
+ modules.add(item.module);
26
+ } else {
27
+ const protocol = new URL(item.href, "https://preference-panes.invalid/").protocol;
28
+ if (item.pageModule !== undefined || ["javascript:", "data:", "vbscript:"].includes(protocol) || (item.target === "internal" && !["http:", "https:"].includes(protocol))) throw new Error(`入口 ${item.name} 的链接无效`);
29
+ }
30
+ }
31
+ }
32
+ }
33
+
34
+ /**
35
+ * 共用网格和列表章节;资源地址相对于 JSON,所有项目文字通过 textContent 绘制。
36
+ * Share grid and list sections, resolve resources against JSON and render project text through textContent.
37
+ * @param {import("./home.mjs").HomeConfig} config 已校验的首页配置 / Validated homepage configuration.
38
+ * @param {URL} url JSON 地址 / JSON URL.
39
+ * @param {HTMLElement} home 首页容器 / Homepage container.
40
+ * @returns {void} 无返回值 / No return value.
41
+ */
42
+ function renderHome(config, url, home) {
43
+ const document = home.ownerDocument;
44
+ home.replaceChildren();
45
+ if (config.logo) {
46
+ const logo = document.createElement("img");
47
+ logo.className = "pp-home-logo";
48
+ logo.src = new URL(config.logo, url).href;
49
+ logo.alt = config.title;
50
+ home.append(logo);
51
+ }
52
+ for (const section of config.sections) {
53
+ const group = document.createElement("section");
54
+ group.className = "pp-group";
55
+ const title = document.createElement("h2");
56
+ title.className = "pp-group-title";
57
+ title.textContent = section.title;
58
+ const entries = document.createElement("div");
59
+ entries.className = "pp-home-entries";
60
+ entries.dataset.layout = section.layout ?? "grid";
61
+ for (const item of section.items) {
62
+ const entry = document.createElement(item.module ? "button" : "a");
63
+ entry.className = "pp-home-item";
64
+ if (item.module) {
65
+ entry.type = "button";
66
+ entry.disabled = true;
67
+ entry.dataset.module = item.module;
68
+ if (item.pageModule) entry.dataset.preferencePanesModule = item.pageModule;
69
+ } else {
70
+ entry.href = new URL(item.href, url).href;
71
+ if (item.target !== undefined) {
72
+ entry.dataset.target = item.target;
73
+ switch (item.target) {
74
+ case "_blank":
75
+ entry.target = item.target;
76
+ entry.rel = "noopener noreferrer";
77
+ break;
78
+ case "_self":
79
+ entry.target = item.target;
80
+ break;
81
+ }
82
+ }
83
+ }
84
+ const icon = document.createElement("span");
85
+ icon.className = "pp-home-icon";
86
+ icon.setAttribute("aria-hidden", "true");
87
+ if (item.icon.src !== undefined) {
88
+ const image = document.createElement("img");
89
+ image.src = new URL(item.icon.src, url).href;
90
+ image.alt = "";
91
+ icon.append(image);
92
+ } else icon.textContent = item.icon.text;
93
+ const content = document.createElement("span");
94
+ content.className = "pp-home-content";
95
+ const name = document.createElement("span");
96
+ name.className = "pp-home-name";
97
+ name.textContent = item.name;
98
+ content.append(name);
99
+ if (item.description !== undefined) {
100
+ const description = document.createElement("span");
101
+ description.className = "pp-description";
102
+ description.textContent = item.description;
103
+ content.append(description);
104
+ }
105
+ if (item.module) {
106
+ const status = document.createElement("span");
107
+ status.className = "pp-description";
108
+ status.dataset.moduleStatus = "";
109
+ status.textContent = "检测中";
110
+ content.append(status);
111
+ }
112
+ entry.append(icon, content);
113
+ if (entries.dataset.layout === "list") {
114
+ const arrow = document.createElement("span");
115
+ arrow.className = "pp-home-arrow";
116
+ arrow.textContent = "›";
117
+ arrow.setAttribute("aria-hidden", "true");
118
+ entry.append(arrow);
119
+ }
120
+ entries.append(entry);
121
+ }
122
+ group.append(title, entries);
123
+ home.append(group);
124
+ }
125
+ const footer = document.createElement("footer");
126
+ footer.className = "pp-home-footer pp-description";
127
+ for (const text of config.footer ?? []) {
128
+ const paragraph = document.createElement("p");
129
+ paragraph.textContent = text;
130
+ footer.append(paragraph);
131
+ }
132
+ home.append(footer);
133
+ }
134
+
135
+ /**
136
+ * 浏览器宿主提供网页导航、通知、主题和可视区域避让。
137
+ * The browser host provides web navigation, notices, theme and viewport clearance.
138
+ * @param {import("./home.mjs").BridgeOptions} options 菜单和返回动作 / Menu and back action.
139
+ * @returns {import("./home.mjs").Bridge} 浏览器宿主 / Browser host.
140
+ */
141
+ function browserHost({ menu, back }) {
142
+ const root = document.documentElement;
143
+ const header = document.createElement("header");
144
+ header.className = "pp-host-header";
145
+ const button = document.createElement("button");
146
+ button.className = "pp-host-back";
147
+ button.type = "button";
148
+ button.textContent = "‹";
149
+ button.setAttribute("aria-label", "返回");
150
+ button.onclick = back;
151
+ const title = document.createElement("span");
152
+ title.className = "pp-host-title";
153
+ header.append(button, title, menu.element);
154
+ document.body.prepend(header);
155
+ const toast = document.createElement("div");
156
+ toast.className = "pp-host-toast";
157
+ toast.setAttribute("role", "status");
158
+ toast.hidden = true;
159
+ document.body.append(toast);
160
+ let timer;
161
+ const theme = window.matchMedia("(prefers-color-scheme: dark)");
162
+ const applyTheme = () => {
163
+ root.dataset.theme = theme.matches ? "dark" : "light";
164
+ };
165
+ const viewport = window.visualViewport;
166
+ const resize = () => {
167
+ root.style.setProperty("--pp-host-height", `${header.getBoundingClientRect().height}px`);
168
+ const clearance = viewport && viewport.scale === 1 ? Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop) : 0;
169
+ root.style.setProperty("--pp-keyboard-height", `${clearance}px`);
170
+ };
171
+ theme.addEventListener("change", applyTheme);
172
+ window.addEventListener("resize", resize);
173
+ window.addEventListener("pageshow", resize);
174
+ viewport?.addEventListener("resize", resize);
175
+ viewport?.addEventListener("scroll", resize);
176
+ applyTheme();
177
+ resize();
178
+ return {
179
+ update(state) {
180
+ title.textContent = state.title;
181
+ button.hidden = !state.canGoBack;
182
+ menu.element.hidden = !state.actions.length;
183
+ },
184
+ confirm: message => window.confirm(message),
185
+ notice(detail) {
186
+ clearTimeout(timer);
187
+ toast.textContent = detail.message;
188
+ toast.hidden = false;
189
+ timer = setTimeout(() => {
190
+ toast.hidden = true;
191
+ }, 2400);
192
+ },
193
+ openURL(url, target) {
194
+ switch (target) {
195
+ case "_blank":
196
+ window.open(url, "_blank", "noopener,noreferrer");
197
+ break;
198
+ default:
199
+ window.location.assign(url);
200
+ break;
201
+ }
202
+ },
203
+ destroy() {
204
+ clearTimeout(timer);
205
+ theme.removeEventListener("change", applyTheme);
206
+ window.removeEventListener("resize", resize);
207
+ window.removeEventListener("pageshow", resize);
208
+ viewport?.removeEventListener("resize", resize);
209
+ viewport?.removeEventListener("scroll", resize);
210
+ root.style.removeProperty("--pp-host-height");
211
+ root.style.removeProperty("--pp-keyboard-height");
212
+ delete root.dataset.theme;
213
+ header.remove();
214
+ toast.remove();
215
+ },
216
+ };
217
+ }
218
+
219
+ /**
220
+ * 读取固定相对路径的首页 JSON 与可选 CSS、Bridge,初始化后共用模块探测与导航。
221
+ * Load homepage JSON and optional CSS and Bridge at fixed relative paths, then share module probes and navigation.
222
+ * @returns {Promise<() => void>} 退出清理动作;加载失败会显示错误并拒绝 / Cleanup action; load failures display an error and reject.
223
+ */
224
+ export async function startHome() {
225
+ let host;
226
+ let menu;
227
+ let navigation;
228
+ let frame;
229
+ let linkTitle;
230
+ let statuses = [];
231
+ let destroyed = false;
232
+ let defaults;
233
+ let stylesheet;
234
+ const controller = new AbortController();
235
+ const destroy = () => {
236
+ if (destroyed) return;
237
+ destroyed = true;
238
+ controller.abort();
239
+ for (const { status } of statuses) status.destroy();
240
+ navigation?.destroy();
241
+ host?.destroy();
242
+ menu?.destroy();
243
+ stylesheet?.remove();
244
+ if (defaults?.owned) defaults.element.remove();
245
+ window.removeEventListener("pagehide", pagehide);
246
+ };
247
+ const pagehide = event => {
248
+ if (!event.persisted) destroy();
249
+ };
250
+ window.addEventListener("pagehide", pagehide);
251
+ try {
252
+ defaults = installDefaultStyles(document);
253
+ const jsonURL = new URL("./home.json", document.baseURI);
254
+ const response = await fetch(jsonURL, { signal: controller.signal });
255
+ if (!response.ok) throw new Error(`首页 JSON 加载失败:HTTP ${response.status}`);
256
+ const config = await response.json();
257
+ validateHome(config);
258
+ if (destroyed) return destroy;
259
+ const cssURL = new URL("./theme.css", document.baseURI);
260
+ const cssResponse = await fetch(cssURL, { method: "HEAD", cache: "no-store", signal: controller.signal });
261
+ if (destroyed) return destroy;
262
+ switch (cssResponse.status) {
263
+ case 200:
264
+ stylesheet = document.createElement("link");
265
+ stylesheet.rel = "stylesheet";
266
+ stylesheet.dataset.preferencePanesStylesheet = "";
267
+ stylesheet.href = cssURL.href;
268
+ await new Promise((resolve, reject) => {
269
+ const abort = () => reject(new DOMException("页面已退出", "AbortError"));
270
+ controller.signal.addEventListener("abort", abort, { once: true });
271
+ stylesheet.onload = () => {
272
+ controller.signal.removeEventListener("abort", abort);
273
+ resolve();
274
+ };
275
+ stylesheet.onerror = () => {
276
+ controller.signal.removeEventListener("abort", abort);
277
+ reject(new Error("项目 CSS 加载失败"));
278
+ };
279
+ document.head.append(stylesheet);
280
+ });
281
+ break;
282
+ case 404:
283
+ break;
284
+ default:
285
+ throw new Error(`项目 CSS 探测失败:HTTP ${cssResponse.status}`);
286
+ }
287
+ if (destroyed) return destroy;
288
+ const bridgeURL = new URL("./bridge.mjs", document.baseURI);
289
+ const bridgeResponse = await fetch(bridgeURL, { method: "HEAD", cache: "no-store", signal: controller.signal });
290
+ if (destroyed) return destroy;
291
+ let factory;
292
+ switch (bridgeResponse.status) {
293
+ case 200:
294
+ factory = (await import(bridgeURL.href)).default;
295
+ break;
296
+ case 404:
297
+ factory = browserHost;
298
+ break;
299
+ default:
300
+ throw new Error(`Bridge 探测失败:HTTP ${bridgeResponse.status}`);
301
+ }
302
+ if (destroyed) return destroy;
303
+ document.title = config.title;
304
+ menu = new ActionMenu(id => frame.perform(id));
305
+ host = await factory({ menu, back: () => (navigation.current && frame ? frame.back() : navigation.back()) });
306
+ if (destroyed) {
307
+ host.destroy();
308
+ menu.destroy();
309
+ return destroy;
310
+ }
311
+ const container = document.querySelector("[data-preference-panes-pages]");
312
+ const home = document.querySelector("[data-preference-panes-home]");
313
+ const template = document.querySelector("template[data-preference-panes-module]");
314
+ renderHome(config, jsonURL, home);
315
+ const buttons = [...home.querySelectorAll("[data-module]")];
316
+ const links = [...home.querySelectorAll("a[href]")];
317
+ const stylesheetURL = stylesheet?.href;
318
+ const update = () => {
319
+ if (destroyed) return;
320
+ const state = navigation.current && frame ? frame.state : { title: navigation.current ? linkTitle : document.title, actions: [], busy: false, canGoBack: navigation.canGoBack };
321
+ menu.update(state.actions, state.busy);
322
+ Promise.resolve(host.update(state)).catch(error => {
323
+ if (!destroyed) host.notice({ kind: "error", message: error.message });
324
+ });
325
+ };
326
+ navigation = new Navigation(container, home, (module, signal) => {
327
+ frame = undefined;
328
+ const link = links.find((link, index) => link.dataset.target === "internal" && module === `link:${index}`);
329
+ if (link) {
330
+ const page = document.createElement("section");
331
+ page.dataset.preferencePanesLinkPage = "";
332
+ const iframe = document.createElement("iframe");
333
+ linkTitle = link.querySelector(".pp-home-name").textContent;
334
+ iframe.title = linkTitle;
335
+ iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox");
336
+ iframe.src = link.href;
337
+ page.append(iframe);
338
+ signal.addEventListener("abort", () => iframe.remove(), { once: true });
339
+ return page;
340
+ }
341
+ const button = buttons.find(button => button.dataset.module === module);
342
+ if (!button) return;
343
+ const page = template.content.firstElementChild.cloneNode(true);
344
+ page.dataset.preferencePanesModulePage = "";
345
+ const message = page.querySelector("[data-module-message]");
346
+ const moduleName = encodeURIComponent(button.dataset.preferencePanesModule ?? module);
347
+ const current = new ModuleFrame(`/settings/${moduleName}`, {
348
+ signal,
349
+ headers: { "X-PreferencePanes-JSON": `/api/${encodeURIComponent(module)}`, ...(stylesheetURL ? { "X-PreferencePanes-CSS": stylesheetURL } : {}) },
350
+ });
351
+ frame = current;
352
+ current.addEventListener("confirm", event => {
353
+ event.preventDefault();
354
+ Promise.resolve()
355
+ .then(() => host.confirm(event.detail.message))
356
+ .then(
357
+ value => {
358
+ if (!signal.aborted) event.detail.resolve(value);
359
+ },
360
+ error => {
361
+ if (!signal.aborted) event.detail.reject(error);
362
+ },
363
+ );
364
+ });
365
+ current.addEventListener("notice", event => {
366
+ event.preventDefault();
367
+ host.notice(event.detail);
368
+ });
369
+ current.addEventListener("open-url", event => {
370
+ event.preventDefault();
371
+ Promise.resolve()
372
+ .then(() => host.openURL(event.detail.url))
373
+ .catch(error => {
374
+ if (!signal.aborted) host.notice({ kind: "error", message: error.message });
375
+ });
376
+ });
377
+ current.addEventListener("change", () => {
378
+ if (!signal.aborted) update();
379
+ });
380
+ current.element.onload = () => {
381
+ if (!signal.aborted) message.hidden = true;
382
+ };
383
+ page.append(current.element);
384
+ current.load().catch(error => {
385
+ if (!signal.aborted) message.textContent = error.message;
386
+ });
387
+ return page;
388
+ });
389
+ for (const [index, link] of links.entries()) {
390
+ link.addEventListener(
391
+ "click",
392
+ event => {
393
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
394
+ event.preventDefault();
395
+ switch (link.dataset.target) {
396
+ case "internal":
397
+ navigation.open(`link:${index}`);
398
+ break;
399
+ default:
400
+ try {
401
+ Promise.resolve(host.openURL(link.href, link.dataset.target)).catch(error => {
402
+ if (!destroyed) host.notice({ kind: "error", message: error.message });
403
+ });
404
+ } catch (error) {
405
+ if (!destroyed) host.notice({ kind: "error", message: error.message });
406
+ }
407
+ break;
408
+ }
409
+ },
410
+ { signal: controller.signal },
411
+ );
412
+ }
413
+ statuses = buttons.map(button => {
414
+ const status = new ModuleStatus(button.querySelector("[data-module-status]"));
415
+ status.addEventListener("change", () => {
416
+ button.disabled = status.state.status !== "installed";
417
+ });
418
+ button.addEventListener("click", () => navigation.open(button.dataset.module), { signal: controller.signal });
419
+ return { button, status };
420
+ });
421
+ const probe = () => {
422
+ update();
423
+ if (navigation.current) return;
424
+ for (const { button, status } of statuses) status.check(`/api/${encodeURIComponent(button.dataset.module)}`);
425
+ };
426
+ navigation.addEventListener("change", probe);
427
+ probe();
428
+ return destroy;
429
+ } catch (error) {
430
+ const display = !destroyed;
431
+ destroy();
432
+ if (display) {
433
+ const message = document.createElement("p");
434
+ message.className = "pp-host-error";
435
+ message.setAttribute("role", "alert");
436
+ message.textContent = `无法打开设置:${error.message}`;
437
+ document.body.prepend(message);
438
+ }
439
+ throw error;
440
+ }
441
+ }
@@ -15,3 +15,5 @@ export interface MountedPreferences {
15
15
  * @returns 模块视图 / Module view.
16
16
  */
17
17
  export function mount(boxjs: BoxJSInput): MountedPreferences;
18
+
19
+ export type { HomeConfig, HomeIcon, HomeItem, HomeLinkTarget, HomeState, Bridge, BridgeOptions, BridgeFactory } from "./home.mjs";