@nsnanocat/preference-panes 1.0.0 → 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nsnanocat/preference-panes",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Shared settings API runtime for JavaScript proxy modules",
5
5
  "author": "VirgilClyne <Virgil@nanocat.me>",
6
6
  "homepage": "https://NSNanoCat.github.io/preference-panes",
@@ -42,10 +42,12 @@ export interface ModuleSnapshot {
42
42
  values: Record<string, SettingsScalar | SettingsScalar[] | null>;
43
43
  }
44
44
  /**
45
- * 只调用模块 API 的页面客户端。
46
- * Page client that only calls the module API.
45
+ * 管理单模块页面的 API 请求、值快照和会话终止。
46
+ * Manage API requests, value snapshots, and session termination for one module page.
47
47
  */
48
- export interface PreferencesClient {
48
+ export class PreferencesClient {
49
+ /** 创建页面客户端 / Create the page client. */
50
+ constructor(options: PreferencesClientOptions);
49
51
  /** 获取页面快照,不发请求 / Get a page snapshot without a request. */
50
52
  snapshot(): ModuleSnapshot;
51
53
  /** 读取 Settings 子树 / Read the Settings subtree. */
@@ -63,10 +65,3 @@ export interface PreferencesClient {
63
65
  /** 删除单个字段覆盖值 / Delete one field override. */
64
66
  remove(key: string): Promise<void>;
65
67
  }
66
- /**
67
- * 创建只调用模块 API 的页面客户端。
68
- * Create a page client that only calls the module API.
69
- * @param options API 模型与运行环境 / API model and runtime.
70
- * @returns 页面客户端 / Page client.
71
- */
72
- export function createPreferencesClient(options: PreferencesClientOptions): PreferencesClient;
@@ -1,14 +1,109 @@
1
1
  /**
2
- * 创建单模块页面客户端;只调用模块 API,不读取或解析 BoxJS。
3
- * Create a single-module page client that only calls the module API and never reads or parses BoxJS.
4
- * @param {import("./client.mjs").PreferencesClientOptions} options API 模型、请求与通知 / API model, requests and notifications.
5
- * @returns {import("./client.mjs").PreferencesClient} 页面客户端 / Page client.
2
+ * 管理单模块页面的 API 请求、值快照和会话终止。
3
+ * Manage API requests, value snapshots, and session termination for one module page.
6
4
  */
7
- export function createPreferencesClient({ model, definition, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
8
- const { module, configURL } = model;
9
- const session = new AbortController();
10
- const values = structuredClone(model.values);
11
- let saving = false;
5
+ export class PreferencesClient {
6
+ #module;
7
+ #configURL;
8
+ #definition;
9
+ #request;
10
+ #notify;
11
+ #timeout;
12
+ #session = new AbortController();
13
+ #values;
14
+ #saving = false;
15
+
16
+ /**
17
+ * 创建只调用模块 API、不读取或解析 BoxJS 的页面客户端。
18
+ * Create a page client that only calls the module API and never reads or parses BoxJS.
19
+ * @param {import("./client.mjs").PreferencesClientOptions} options API 模型、请求与通知 / API model, requests, and notifications.
20
+ */
21
+ constructor({ model, definition, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
22
+ this.#module = model.module;
23
+ this.#configURL = model.configURL;
24
+ this.#definition = definition;
25
+ this.#request = request;
26
+ this.#notify = notify;
27
+ this.#timeout = timeout;
28
+ this.#values = structuredClone(model.values);
29
+ }
30
+
31
+ /**
32
+ * 获取当前字段定义和值的深拷贝,不发起网络请求。
33
+ * Return a deep copy of the current field definition and values without a network request.
34
+ * @returns {import("./client.mjs").ModuleSnapshot} 会话快照 / Session snapshot.
35
+ */
36
+ snapshot() {
37
+ return structuredClone({ definition: this.#definition, values: this.#values });
38
+ }
39
+
40
+ /**
41
+ * 读取 Settings 子树。
42
+ * Read the Settings subtree.
43
+ * @returns {Promise<unknown>} Settings 内容或 undefined / Settings content or undefined.
44
+ */
45
+ async readSettings() {
46
+ const response = await this.#send("get", { scope: "settings" });
47
+ return response.status === 404 ? undefined : response.json();
48
+ }
49
+
50
+ /**
51
+ * 读取 Caches 子树。
52
+ * Read the Caches subtree.
53
+ * @returns {Promise<unknown>} Caches 内容或 undefined / Caches content or undefined.
54
+ */
55
+ async readCaches() {
56
+ const response = await this.#send("get", { scope: "caches" });
57
+ return response.status === 404 ? undefined : response.json();
58
+ }
59
+
60
+ /**
61
+ * 删除当前模块的 Caches 子树。
62
+ * Delete the current module Caches subtree.
63
+ * @returns {Promise<void>} 操作完成 / Operation completion.
64
+ */
65
+ clearCaches() {
66
+ return this.#change("delete", { scope: "caches" }, "clearCaches");
67
+ }
68
+
69
+ /**
70
+ * 删除当前模块数据并恢复页面默认值。
71
+ * Delete current module data and restore page defaults.
72
+ * @returns {Promise<void>} 操作完成 / Operation completion.
73
+ */
74
+ reset() {
75
+ return this.#change("delete", { scope: "module" }, "reset");
76
+ }
77
+
78
+ /**
79
+ * 终止当前页面仍在进行的请求。
80
+ * Abort requests still owned by the current page.
81
+ * @returns {void} 无返回值 / No return value.
82
+ */
83
+ leave() {
84
+ this.#session.abort();
85
+ }
86
+
87
+ /**
88
+ * 写入单个字段。
89
+ * Write one field.
90
+ * @param {string} key 字段路径 / Field path.
91
+ * @param {unknown} value 已校验值 / Validated value.
92
+ * @returns {Promise<void>} 操作完成 / Operation completion.
93
+ */
94
+ set(key, value) {
95
+ return this.#change("set", { key, value }, "write", key);
96
+ }
97
+
98
+ /**
99
+ * 删除单个字段覆盖值。
100
+ * Delete one field override.
101
+ * @param {string} key 字段路径 / Field path.
102
+ * @returns {Promise<void>} 操作完成 / Operation completion.
103
+ */
104
+ remove(key) {
105
+ return this.#change("delete", { key }, "delete", key);
106
+ }
12
107
 
13
108
  /**
14
109
  * 向模块 API 发送 JSON 动作。
@@ -17,26 +112,26 @@ export function createPreferencesClient({ model, definition, fetch: request = gl
17
112
  * @param {unknown} payload JSON 请求体 / JSON request body.
18
113
  * @returns {Promise<Response>} 原始响应 / Raw response.
19
114
  */
20
- async function send(action, payload) {
115
+ async #send(action, payload) {
21
116
  const controller = new AbortController();
22
117
  const abort = () => controller.abort();
23
- if (session.signal.aborted) abort();
24
- session.signal.addEventListener("abort", abort, { once: true });
25
- const timer = setTimeout(abort, timeout);
118
+ if (this.#session.signal.aborted) abort();
119
+ this.#session.signal.addEventListener("abort", abort, { once: true });
120
+ const timer = setTimeout(abort, this.#timeout);
26
121
  try {
27
- const response = await request(`/api/${encodeURIComponent(module)}/${action}`, {
122
+ const response = await this.#request(`/api/${encodeURIComponent(this.#module)}/${action}`, {
28
123
  method: "POST",
29
124
  credentials: "omit",
30
125
  cache: "no-store",
31
126
  signal: controller.signal,
32
- headers: { "Content-Type": "application/json", "X-PreferencePanes-JSON": configURL },
127
+ headers: { "Content-Type": "application/json", "X-PreferencePanes-JSON": this.#configURL },
33
128
  body: JSON.stringify(payload),
34
129
  });
35
130
  if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
36
131
  return response;
37
132
  } finally {
38
133
  clearTimeout(timer);
39
- session.signal.removeEventListener("abort", abort);
134
+ this.#session.signal.removeEventListener("abort", abort);
40
135
  }
41
136
  }
42
137
 
@@ -44,58 +139,41 @@ export function createPreferencesClient({ model, definition, fetch: request = gl
44
139
  * 执行写入动作;成功后只更新当前页面值。
45
140
  * Execute a mutation and update only the current page values after success.
46
141
  * @param {"set" | "delete"} action API 动作 / API action.
47
- * @param {unknown} payload JSON 请求体 / JSON request body.
142
+ * @param {Record<string, unknown>} payload JSON 请求体 / JSON request body.
48
143
  * @param {"write" | "delete" | "clearCaches" | "reset"} operation 通知操作 / Notification operation.
49
144
  * @param {string} [key] 字段路径 / Field path.
50
145
  * @returns {Promise<void>} 操作完成 / Operation completion.
51
146
  */
52
- async function change(action, payload, operation, key) {
53
- if (saving) throw new Error("A settings write is already in progress");
54
- saving = true;
147
+ async #change(action, payload, operation, key) {
148
+ if (this.#saving) throw new Error("A settings write is already in progress");
149
+ this.#saving = true;
55
150
  try {
56
- await send(action, payload);
151
+ await this.#send(action, payload);
57
152
  switch (operation) {
58
153
  case "write":
59
- values[key] = structuredClone(payload.value);
154
+ this.#values[key] = structuredClone(payload.value);
60
155
  break;
61
156
  case "delete": {
62
- const field = definition.fields.find(candidate => candidate.key === key);
63
- delete values[key];
64
- if (field && Object.hasOwn(field, "defaultValue")) values[key] = structuredClone(field.defaultValue);
157
+ const field = this.#definition.fields.find(candidate => candidate.key === key);
158
+ delete this.#values[key];
159
+ if (field && Object.hasOwn(field, "defaultValue")) this.#values[key] = structuredClone(field.defaultValue);
65
160
  break;
66
161
  }
67
162
  case "clearCaches":
68
163
  break;
69
164
  case "reset":
70
- for (const field of definition.fields) {
71
- delete values[field.key];
72
- if (Object.hasOwn(field, "defaultValue")) values[field.key] = structuredClone(field.defaultValue);
165
+ for (const field of this.#definition.fields) {
166
+ delete this.#values[field.key];
167
+ if (Object.hasOwn(field, "defaultValue")) this.#values[field.key] = structuredClone(field.defaultValue);
73
168
  }
74
169
  break;
75
170
  }
76
- notify({ kind: "success", operation, module, key });
171
+ this.#notify({ kind: "success", operation, module: this.#module, key });
77
172
  } catch (error) {
78
- notify({ kind: "error", operation, module, key, message: error.message });
173
+ this.#notify({ kind: "error", operation, module: this.#module, key, message: error.message });
79
174
  throw error;
80
175
  } finally {
81
- saving = false;
176
+ this.#saving = false;
82
177
  }
83
178
  }
84
-
85
- return {
86
- snapshot: () => structuredClone({ definition, values }),
87
- async readSettings() {
88
- const response = await send("get", { scope: "settings" });
89
- return response.status === 404 ? undefined : response.json();
90
- },
91
- async readCaches() {
92
- const response = await send("get", { scope: "caches" });
93
- return response.status === 404 ? undefined : response.json();
94
- },
95
- clearCaches: () => change("delete", { scope: "caches" }, "clearCaches"),
96
- reset: () => change("delete", { scope: "module" }, "reset"),
97
- leave: () => session.abort(),
98
- set: (key, value) => change("set", { key, value }, "write", key),
99
- remove: key => change("delete", { key }, "delete", key),
100
- };
101
179
  }
@@ -8,11 +8,26 @@ export interface MountedPreferences {
8
8
  /** 移除页面、样式和监听器 / Remove page, styles and listeners. */
9
9
  destroy(): void;
10
10
  }
11
+ /**
12
+ * 管理模块设置视图的模型、样式、主题和面板生命周期。
13
+ * Manage model, styles, theme, and panel lifecycle for a module settings view.
14
+ */
15
+ export class PreferencesView implements MountedPreferences {
16
+ /**
17
+ * 使用模块 API 返回的模型挂载设置页。
18
+ * Mount a settings page from the model returned by the module API.
19
+ * @param model 模块 API 模型 / Module API model.
20
+ * @param css 可选 CSS 正文 / Optional CSS text.
21
+ */
22
+ constructor(model: ModuleModel, css?: string);
23
+ /** 移除页面、样式和监听器 / Remove page, styles and listeners. */
24
+ destroy(): void;
25
+ }
11
26
  /**
12
27
  * 使用模块 API 返回的模型挂载设置页;CSS 仅覆盖当前模块。
13
28
  * Mount a settings page from a module API model; CSS only overrides this module.
14
29
  * @param model 模块 API 模型 / Module API model.
15
30
  * @param css 可选 CSS 正文 / Optional CSS text.
16
- * @returns 生命周期句柄 / Lifecycle handle.
31
+ * @returns 模块视图 / Module view.
17
32
  */
18
- export function mount(model: ModuleModel, css?: string): MountedPreferences;
33
+ export function mount(model: ModuleModel, css?: string): PreferencesView;
@@ -1,90 +1,105 @@
1
- import { normalizeBoxJs, normalizeStoredValue, validValue } from "./boxjs.mjs";
2
- import { element, resourceURL } from "./components.mjs";
3
- import { mountPanel } from "./panel.mjs";
1
+ import { pageInputs } from "../lib/page-inputs.mjs";
2
+ import { statusView } from "./components.mjs";
3
+ import { PreferencesView } from "./mount.mjs";
4
4
  import { installDefaultStyles } from "./styles.mjs";
5
5
 
6
6
  /**
7
- * 挂载模块设置页;默认样式由包提供,可选 CSS 仅作用于当前模块。
8
- * Mount a module page with package defaults and optional module-scoped CSS.
9
- * @param {import("../index.js").ModuleModel} model API 返回的模块模型 / Module model returned by the API.
10
- * @param {string} [css] 可选 CSS 正文 / Optional CSS text.
11
- * @returns {import("./index.js").MountedPreferences} 模块生命周期句柄 / Module lifecycle handle.
7
+ * 管理模块文档的页面输入、初始请求、重载和错误状态。
8
+ * Manage page inputs, initial requests, reloads, and error states for a module document.
12
9
  */
13
- export function mount(model, css = "") {
14
- if (typeof css !== "string") throw new TypeError("CSS must be a string");
15
- const definition = normalizeBoxJs(model.boxjs, model.module);
16
- const values = { ...model.values };
17
- for (const field of definition.fields) {
18
- if (values[field.key] === undefined) continue;
19
- values[field.key] = normalizeStoredValue(field, values[field.key]);
20
- if (!validValue(field, values[field.key])) throw new TypeError(`Invalid stored value: ${field.key}`);
10
+ export class ModulePage {
11
+ #document;
12
+ #window;
13
+ #root;
14
+ #view;
15
+
16
+ /**
17
+ * 创建模块页面控制器并安装基础样式。
18
+ * Create the module page controller and install base styles.
19
+ * @param {Document} document 模块文档 / Module document.
20
+ */
21
+ constructor(document) {
22
+ this.#document = document;
23
+ this.#window = document.defaultView;
24
+ this.#root = document.querySelector("#preferences");
25
+ installDefaultStyles(document);
26
+ this.#window.addEventListener("pageshow", this.#show);
21
27
  }
22
- for (const field of definition.fields) if (values[field.key] === undefined && Object.hasOwn(field, "defaultValue")) values[field.key] = structuredClone(field.defaultValue);
23
- const rendered = { ...model, definition, values };
24
- const metadata = definition.metadata ?? {};
25
- const image = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
26
- if (image) resourceURL(image);
27
- if (metadata.repo) resourceURL(metadata.repo);
28
- const existing = document.querySelector("#preferences");
29
- const root = existing ?? element("main", "");
30
- if (!existing) {
31
- root.id = "preferences";
32
- document.body.append(root);
28
+
29
+ /**
30
+ * URL 或代理传递的 Header 导入 JSON/CSS,支持独立文档与 srcdoc。
31
+ * Import JSON/CSS from the URL or proxy-carried headers in standalone and srcdoc documents.
32
+ * @returns {Promise<void>} 启动完成 / Startup completion.
33
+ */
34
+ async start() {
35
+ try {
36
+ this.#view?.destroy();
37
+ this.#view = undefined;
38
+ this.#root.replaceChildren(statusView("读取设置…"));
39
+ const inputs = this.#readInputs();
40
+ const apiURL = new URL(`/api/${encodeURIComponent(inputs.module)}`, inputs.url).href;
41
+ const styleURL = this.#resourceURL(inputs.css, inputs.url);
42
+ const [style, modelResponse] = await Promise.all([styleURL ? fetch(styleURL, { cache: "no-store", credentials: "omit" }) : null, fetch(apiURL, { cache: "no-store", credentials: "omit", headers: { Accept: "application/json", "X-PreferencePanes-JSON": inputs.json } })]);
43
+ if ((style && style.status !== 200) || modelResponse.status !== 200) throw new Error(`HTTP ${modelResponse.status !== 200 ? modelResponse.status : style.status}`);
44
+ this.#view = new PreferencesView(await modelResponse.json(), style ? await style.text() : "");
45
+ } catch (error) {
46
+ this.#root.replaceChildren(statusView(`加载失败:${error.message}`, () => this.start()));
47
+ }
33
48
  }
34
- const { element: base, owned: ownsBase } = installDefaultStyles(document);
35
- const custom = element("style", "");
36
- custom.textContent = css;
37
- document.head.append(custom);
38
- const previousTitle = document.title;
39
- const previousTheme = document.documentElement.dataset.theme;
40
- const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
41
- const previousKeyboard = document.documentElement.style.getPropertyValue("--pp-keyboard-height");
42
- const host = window.frameElement?.ownerDocument.documentElement;
49
+
43
50
  /**
44
- * 跟随嵌入宿主的通用环境状态,不识别业务 App 或解析其 UA。
45
- * Follow generic host appearance without detecting a business app or parsing its user agent.
46
- * @returns {void} 已同步主题与键盘避让 / Theme and keyboard clearance synchronized.
51
+ * 释放页面视图和页面级监听器。
52
+ * Release the page view and page-level listener.
53
+ * @returns {void} 无返回值 / No return value.
47
54
  */
48
- const syncAppearance = () => {
49
- const theme = host?.dataset.theme ?? previousTheme ?? (systemTheme.matches ? "dark" : "light");
50
- document.documentElement.dataset.theme = theme;
51
- if (host) document.documentElement.style.setProperty("--pp-keyboard-height", host.style.getPropertyValue("--pp-keyboard-height"));
52
- };
53
- let observer;
54
- syncAppearance();
55
- systemTheme.addEventListener("change", syncAppearance);
56
- if (host) {
57
- observer = new MutationObserver(syncAppearance);
58
- observer.observe(host, { attributes: true, attributeFilter: ["data-theme", "style"] });
55
+ destroy() {
56
+ this.#window.removeEventListener("pageshow", this.#show);
57
+ this.#view?.destroy();
58
+ this.#view = undefined;
59
59
  }
60
- document.title = metadata.name ?? definition.module;
61
- let panel;
62
- const view = {
63
- /**
64
- * 释放模块视图、样式与会话,不操作项目入口页。
65
- * Release the module view, styles and session without operating a project landing page.
66
- * @returns {void} 无返回值 / No return value.
67
- */
68
- destroy() {
69
- observer?.disconnect();
70
- systemTheme.removeEventListener("change", syncAppearance);
71
- panel?.destroy();
72
- if (ownsBase) base.remove();
73
- custom.remove();
74
- if (existing) root.replaceChildren();
75
- else root.remove();
76
- document.title = previousTitle;
77
- if (previousTheme === undefined) delete document.documentElement.dataset.theme;
78
- else document.documentElement.dataset.theme = previousTheme;
79
- document.documentElement.style.setProperty("--pp-keyboard-height", previousKeyboard);
80
- },
81
- };
82
- try {
83
- root.replaceChildren();
84
- panel = mountPanel(root, rendered);
85
- return view;
86
- } catch (error) {
87
- view.destroy();
88
- throw error;
60
+
61
+ /**
62
+ * 读取嵌入参数、文档元数据或当前 URL 输入。
63
+ * Read embedded parameters, document metadata, or current URL inputs.
64
+ * @returns {ReturnType<typeof pageInputs>} 页面输入 / Page inputs.
65
+ */
66
+ #readInputs() {
67
+ const context = this.#document.querySelector('meta[name="preference-panes-inputs"]');
68
+ const embedded = this.#window.frameElement?.dataset.preferencePanes;
69
+ switch (true) {
70
+ case embedded !== undefined:
71
+ this.#document.documentElement.dataset.preferencePanesEmbedded = "";
72
+ return JSON.parse(embedded);
73
+ case context !== null:
74
+ return JSON.parse(decodeURIComponent(context.content));
75
+ default:
76
+ return pageInputs(new URL(this.#window.location.href));
77
+ }
78
+ }
79
+
80
+ /**
81
+ * 将可选页面资源限制为 HTTP(S) 地址。
82
+ * Restrict an optional page resource to an HTTP(S) URL.
83
+ * @param {string | undefined} source 资源地址 / Resource location.
84
+ * @param {string} baseURL 页面基准地址 / Page base URL.
85
+ * @returns {string | null} 绝对资源地址 / Absolute resource URL.
86
+ */
87
+ #resourceURL(source, baseURL) {
88
+ if (!source) return null;
89
+ const url = new URL(source, baseURL);
90
+ if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Resources must use HTTP(S) URLs");
91
+ return url.href;
89
92
  }
93
+
94
+ /**
95
+ * 从前进后退缓存恢复时重新加载模块。
96
+ * Reload the module when restored from the back-forward cache.
97
+ * @param {PageTransitionEvent} event 页面显示事件 / Page show event.
98
+ * @returns {void} 无返回值 / No return value.
99
+ */
100
+ #show = event => {
101
+ if (event.persisted) this.start();
102
+ };
90
103
  }
104
+
105
+ new ModulePage(document).start();
@@ -9,6 +9,6 @@
9
9
  </head>
10
10
  <body>
11
11
  <main id="preferences"></main>
12
- <script type="module" src="/settings/assets/app.mjs?v=__VERSION__"></script>
12
+ <script type="module" src="/settings/assets/index.mjs?v=__VERSION__"></script>
13
13
  </body>
14
14
  </html>
@@ -0,0 +1,119 @@
1
+ import { normalizeBoxJs, normalizeStoredValue, validValue } from "./boxjs.mjs";
2
+ import { element, resourceURL } from "./components.mjs";
3
+ import { PreferencesPanel } from "./panel.mjs";
4
+ import { installDefaultStyles } from "./styles.mjs";
5
+
6
+ /**
7
+ * 管理模块设置视图的模型规范化、样式、主题同步和面板生命周期。
8
+ * Manage model normalization, styles, theme synchronization, and panel lifecycle for a module settings view.
9
+ */
10
+ export class PreferencesView {
11
+ #existing;
12
+ #root;
13
+ #base;
14
+ #ownsBase;
15
+ #custom;
16
+ #previousTitle;
17
+ #previousTheme;
18
+ #systemTheme;
19
+ #previousKeyboard;
20
+ #host;
21
+ #observer;
22
+ #panel;
23
+
24
+ /**
25
+ * 使用模块 API 返回的模型挂载设置页。
26
+ * Mount a settings page from the model returned by the module API.
27
+ * @param {import("../index.js").ModuleModel} model API 返回的模块模型 / Module model returned by the API.
28
+ * @param {string} [css] 可选 CSS 正文 / Optional module-scoped CSS text.
29
+ */
30
+ constructor(model, css = "") {
31
+ if (typeof css !== "string") throw new TypeError("CSS must be a string");
32
+ const definition = normalizeBoxJs(model.boxjs, model.module);
33
+ const values = { ...model.values };
34
+ for (const field of definition.fields) {
35
+ if (values[field.key] === undefined) continue;
36
+ values[field.key] = normalizeStoredValue(field, values[field.key]);
37
+ if (!validValue(field, values[field.key])) throw new TypeError(`Invalid stored value: ${field.key}`);
38
+ }
39
+ for (const field of definition.fields) if (values[field.key] === undefined && Object.hasOwn(field, "defaultValue")) values[field.key] = structuredClone(field.defaultValue);
40
+ const rendered = { ...model, definition, values };
41
+ const metadata = definition.metadata ?? {};
42
+ const image = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
43
+ if (image) resourceURL(image);
44
+ if (metadata.repo) resourceURL(metadata.repo);
45
+
46
+ this.#existing = document.querySelector("#preferences");
47
+ this.#root = this.#existing ?? element("main", "");
48
+ if (!this.#existing) {
49
+ this.#root.id = "preferences";
50
+ document.body.append(this.#root);
51
+ }
52
+ const styles = installDefaultStyles(document);
53
+ this.#base = styles.element;
54
+ this.#ownsBase = styles.owned;
55
+ this.#custom = element("style", "");
56
+ this.#custom.textContent = css;
57
+ document.head.append(this.#custom);
58
+ this.#previousTitle = document.title;
59
+ this.#previousTheme = document.documentElement.dataset.theme;
60
+ this.#systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
61
+ this.#previousKeyboard = document.documentElement.style.getPropertyValue("--pp-keyboard-height");
62
+ this.#host = window.frameElement?.ownerDocument.documentElement;
63
+ this.#syncAppearance();
64
+ this.#systemTheme.addEventListener("change", this.#syncAppearance);
65
+ if (this.#host) {
66
+ this.#observer = new MutationObserver(this.#syncAppearance);
67
+ this.#observer.observe(this.#host, { attributes: true, attributeFilter: ["data-theme", "style"] });
68
+ }
69
+ document.title = metadata.name ?? definition.module;
70
+ try {
71
+ this.#root.replaceChildren();
72
+ this.#panel = new PreferencesPanel(this.#root, rendered);
73
+ } catch (error) {
74
+ this.destroy();
75
+ throw error;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * 跟随嵌入宿主的通用环境状态,不识别业务 App 或解析其 UA。
81
+ * Follow generic host appearance without detecting a business App or parsing its UA.
82
+ * @returns {void} 已同步主题与键盘避让 / Theme and keyboard clearance synchronized.
83
+ */
84
+ #syncAppearance = () => {
85
+ const theme = this.#host?.dataset.theme ?? this.#previousTheme ?? (this.#systemTheme.matches ? "dark" : "light");
86
+ document.documentElement.dataset.theme = theme;
87
+ if (this.#host) document.documentElement.style.setProperty("--pp-keyboard-height", this.#host.style.getPropertyValue("--pp-keyboard-height"));
88
+ };
89
+
90
+ /**
91
+ * 释放模块视图、样式与会话,不操作项目入口页。
92
+ * Release the module view, styles, and session without operating a project landing page.
93
+ * @returns {void} 无返回值 / No return value.
94
+ */
95
+ destroy() {
96
+ this.#observer?.disconnect();
97
+ this.#systemTheme.removeEventListener("change", this.#syncAppearance);
98
+ this.#panel?.destroy();
99
+ if (this.#ownsBase) this.#base.remove();
100
+ this.#custom.remove();
101
+ if (this.#existing) this.#root.replaceChildren();
102
+ else this.#root.remove();
103
+ document.title = this.#previousTitle;
104
+ if (this.#previousTheme === undefined) delete document.documentElement.dataset.theme;
105
+ else document.documentElement.dataset.theme = this.#previousTheme;
106
+ document.documentElement.style.setProperty("--pp-keyboard-height", this.#previousKeyboard);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * 使用模块 API 返回的模型挂载设置页;CSS 仅覆盖当前模块。
112
+ * Mount a settings page from a module API model; CSS only overrides this module.
113
+ * @param {import("../index.js").ModuleModel} model API 返回的模块模型 / Module model returned by the API.
114
+ * @param {string} [css] 可选 CSS 正文 / Optional module-scoped CSS text.
115
+ * @returns {PreferencesView} 模块视图 / Module view.
116
+ */
117
+ export function mount(model, css = "") {
118
+ return new PreferencesView(model, css);
119
+ }