@nsnanocat/preference-panes 0.9.16 → 1.0.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.
@@ -1,86 +1,3 @@
1
- /**
2
- * 校验原始路径片段,不进行 URL 编码转换。
3
- * Validate raw path segments without URL encoding conversion.
4
- * @param {string[]} parts 原始路径片段 / Raw path segments.
5
- * @returns {string[]} 同一数组,不复制或修改 / The same array without copying or mutation.
6
- * @throws {TypeError} 空片段、非法字符或原型属性名 / Empty segments, invalid characters or prototype property names.
7
- */
8
- function validatePathParts(parts) {
9
- if (!parts.every(part => typeof part === "string" && /^[a-zA-Z0-9_-]+$/.test(part) && !["__proto__", "prototype", "constructor"].includes(part))) throw new TypeError("Invalid key path");
10
- return parts;
11
- }
12
-
13
- /**
14
- * BoxJS 的共同目录:模块、存储根和展示元数据都来自同一份 JSON。
15
- * Shared BoxJS catalog deriving modules, storage roots and metadata from one JSON document.
16
- */
17
- class BoxJS {
18
- /**
19
- * 建立路径索引,不解析控件类型,也不读写持久化存储。
20
- * Index field paths without interpreting controls or accessing persistence.
21
- * @param {unknown} input 字段数组、单个 app 或 apps 订阅 / Field array, app or apps subscription.
22
- */
23
- constructor(input) {
24
- if (!input || typeof input !== "object") throw new TypeError("Expected BoxJS JSON");
25
- const document = JSON.parse(JSON.stringify(input));
26
- const apps = Array.isArray(document) ? [{ settings: document }] : (document.apps ?? [document]);
27
- if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
28
- this.modules = new Map();
29
- for (const app of apps) {
30
- if (!app || !Array.isArray(app.settings)) throw new TypeError("Expected BoxJS settings array");
31
- for (const entry of app.settings) {
32
- if (typeof entry.id !== "string") throw new TypeError("BoxJS settings require string IDs");
33
- if (!entry.id.startsWith("@")) {
34
- if (Array.isArray(document)) throw new TypeError("BoxJS settings require @root.path IDs");
35
- continue;
36
- }
37
- const [storageKey, ...parts] = entry.id.slice(1).split(".");
38
- if (!storageKey || storageKey.startsWith("@") || parts.length < 2) throw new TypeError("A BoxJS setting must be below a literal storage root and module");
39
- validatePathParts(parts);
40
- const module = parts[0];
41
- let target = this.modules.get(module);
42
- if (!target) {
43
- target = { module, storageKey, entries: [], owners: new Set() };
44
- this.modules.set(module, target);
45
- }
46
- if (target.storageKey !== storageKey) throw new TypeError(`A module must use one storage root: ${module}`);
47
- target.entries.push(entry);
48
- target.owners.add(app);
49
- }
50
- }
51
- this.metadata = metadata(Array.isArray(document) ? {} : document);
52
- for (const target of this.modules.values()) target.metadata = target.owners.size === 1 ? metadata([...target.owners][0]) : {};
53
- }
54
-
55
- /**
56
- * 取得本次导入的唯一模块,避免把模块数据变成项目目录。
57
- * Get the single imported module without turning module data into a project directory.
58
- * @returns {object} 唯一模块的目录项 / The single module entry.
59
- */
60
- get module() {
61
- if (this.modules.size !== 1) throw new TypeError("Import BoxJS JSON for exactly one module");
62
- return this.modules.values().next().value;
63
- }
64
- }
65
-
66
- /**
67
- * 保留标准 BoxJS 展示信息;script 仅为元数据,不执行。
68
- * Retain standard BoxJS presentation data; script is metadata only and never executed.
69
- * @param {object} source BoxJS app 或订阅 / BoxJS app or subscription.
70
- * @returns {object} 经过类型检查的展示信息 / Type-checked presentation metadata.
71
- */
72
- function metadata(source) {
73
- const result = {};
74
- for (const key of ["id", "name", "author", "repo", "script", "icon", "description", "desc", "icons", "descs"]) {
75
- if (source[key] === undefined) continue;
76
- const multiple = key === "icons" || key === "descs";
77
- const values = multiple ? source[key] : [source[key]];
78
- if (!Array.isArray(values) || values.some(item => typeof item !== "string")) throw new TypeError(`Invalid BoxJS app ${key}`);
79
- result[key] = multiple ? [...values] : source[key];
80
- }
81
- return result;
82
- }
83
-
84
1
  /**
85
2
  * 统一解析模块页的资源地址:Header 优先于查询参数,再使用模块约定。
86
3
  * Resolve module resource locations: headers override query parameters and module conventions.
@@ -190,6 +107,191 @@ function requestConfirmation(host, message) {
190
107
  });
191
108
  }
192
109
 
110
+ /**
111
+ * 校验原始路径片段,不进行 URL 编码转换。
112
+ * Validate raw path segments without URL encoding conversion.
113
+ * @param {string[]} parts 原始路径片段 / Raw path segments.
114
+ * @returns {string[]} 同一数组,不复制或修改 / The same array without copying or mutation.
115
+ * @throws {TypeError} 空片段、非法字符或原型属性名 / Empty segments, invalid characters or prototype property names.
116
+ */
117
+ function validatePathParts(parts) {
118
+ if (!parts.every(part => typeof part === "string" && /^[a-zA-Z0-9_-]+$/.test(part) && !["__proto__", "prototype", "constructor"].includes(part))) throw new TypeError("Invalid key path");
119
+ return parts;
120
+ }
121
+
122
+ /**
123
+ * 将 BoxJS 数组、app 或订阅转换为浏览器字段定义。
124
+ * Normalize a BoxJS array, app or subscription into browser field definitions.
125
+ * @param {unknown} config 原始 BoxJS JSON / Raw BoxJS JSON.
126
+ * @param {string} [module] API 模块路径段;省略时要求输入只有一个模块 / API module path segment; omission requires exactly one module.
127
+ * @returns {import("../index.js").ModuleDefinition} 浏览器字段定义 / Browser field definition.
128
+ */
129
+ function normalizeBoxJs(config, module) {
130
+ if (!config || typeof config !== "object") throw new TypeError("Expected BoxJS JSON");
131
+ const document = JSON.parse(JSON.stringify(config));
132
+ const apps = Array.isArray(document) ? [{ settings: document }] : (document.apps ?? [document]);
133
+ if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
134
+ const modules = new Map();
135
+ for (const app of apps) {
136
+ if (!app || !Array.isArray(app.settings)) throw new TypeError("Expected BoxJS settings array");
137
+ for (const entry of app.settings) {
138
+ if (typeof entry.id !== "string") throw new TypeError("BoxJS settings require string IDs");
139
+ if (!entry.id.startsWith("@")) {
140
+ if (Array.isArray(document)) throw new TypeError("BoxJS settings require @root.path IDs");
141
+ continue;
142
+ }
143
+ const [storageKey, ...parts] = entry.id.slice(1).split(".");
144
+ if (!storageKey || storageKey.startsWith("@") || parts.length < 2) throw new TypeError("A BoxJS setting must be below a literal storage root and module");
145
+ validatePathParts(parts);
146
+ const name = parts[0];
147
+ let target = modules.get(name);
148
+ if (!target) {
149
+ target = { module: name, storageKey, entries: [], owners: new Set() };
150
+ modules.set(name, target);
151
+ }
152
+ if (target.storageKey !== storageKey) throw new TypeError(`A module must use one storage root: ${name}`);
153
+ target.entries.push(entry);
154
+ target.owners.add(app);
155
+ }
156
+ }
157
+ if (module === undefined && modules.size !== 1) throw new TypeError("Import BoxJS JSON for exactly one module");
158
+ const target = module === undefined ? modules.values().next().value : modules.get(module);
159
+ if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
160
+ const metadata = normalizeMetadata(target.owners.size === 1 ? presentation([...target.owners][0]) : {});
161
+ const fields = [];
162
+ for (const entry of target.entries) {
163
+ const parts = entry.id.slice(1).split(".").slice(1);
164
+ const type = { boolean: "boolean", checkboxes: "array", selects: "select", text: "string", textarea: "string", number: "number" }[entry.type];
165
+ if (!type) throw new TypeError(`Unsupported BoxJS control: ${entry.type}`);
166
+ const field = {
167
+ key: parts.join("."),
168
+ type: type === "select" ? typeof entry.val : type,
169
+
170
+ name: entry.name,
171
+ description: entry.desc ?? "",
172
+ control: entry.type,
173
+ ...(entry.placeholder === undefined ? {} : { placeholder: entry.placeholder }),
174
+ ...(entry.rows === undefined ? {} : { rows: entry.rows }),
175
+ ...(entry.autoGrow === undefined ? {} : { autoGrow: entry.autoGrow }),
176
+ };
177
+ if (type === "select" && !["string", "number", "boolean"].includes(field.type)) throw new TypeError(`Select requires a scalar val: ${entry.id}`);
178
+ if (entry.items) field.options = entry.items.map(item => ({ key: item.key, label: item.label }));
179
+ if (Object.hasOwn(entry, "val")) field.defaultValue = normalizeStoredValue(field, entry.val);
180
+ if (
181
+ typeof field.name !== "string" ||
182
+ (field.placeholder !== undefined && typeof field.placeholder !== "string") ||
183
+ (field.rows !== undefined && (!Number.isInteger(field.rows) || field.rows < 1)) ||
184
+ (field.autoGrow !== undefined && typeof field.autoGrow !== "boolean") ||
185
+ fields.some(other => other.key === field.key || other.key.startsWith(`${field.key}.`) || field.key.startsWith(`${other.key}.`))
186
+ )
187
+ throw new TypeError(`Invalid or overlapping BoxJS field: ${entry.id}`);
188
+ if (field.options && (new Set(field.options.map(item => item.key)).size !== field.options.length || field.options.some(item => !scalar(item.key) || typeof item.label !== "string"))) throw new TypeError(`Invalid options: ${entry.id}`);
189
+ if (Object.hasOwn(field, "defaultValue") && !validValue(field, field.defaultValue)) throw new TypeError(`Invalid BoxJS val: ${entry.id}`);
190
+ fields.push(field);
191
+ }
192
+ if (!fields.length) throw new TypeError(`No BoxJS settings for module: ${target.module}`);
193
+ const common = fields[0].key.split(".").slice(0, -1);
194
+ for (const field of fields) while (!field.key.startsWith(`${common.join(".")}.`)) common.pop();
195
+ return {
196
+ module: target.module,
197
+ storageKey: target.storageKey,
198
+ fields,
199
+ settingsPath: common,
200
+ ...(Object.keys(metadata).length ? { metadata } : {}),
201
+ };
202
+ }
203
+
204
+ /**
205
+ * 保留字段所属 app 的原始展示信息。
206
+ * Retain raw presentation metadata from the app owning the fields.
207
+ * @param {object} source BoxJS app / BoxJS app.
208
+ * @returns {Record<string, unknown>} 原始展示信息 / Raw presentation metadata.
209
+ */
210
+ function presentation(source) {
211
+ const result = {};
212
+ for (const key of ["id", "name", "author", "repo", "script", "icon", "description", "desc", "icons", "descs"]) {
213
+ if (source[key] === undefined) continue;
214
+ result[key] = source[key];
215
+ }
216
+ return result;
217
+ }
218
+
219
+ /**
220
+ * 校验供浏览器展示的标准 BoxJS 元数据。
221
+ * Validate standard BoxJS metadata used by the browser renderer.
222
+ * @param {Record<string, unknown>} source 原始展示元数据 / Raw presentation metadata.
223
+ * @returns {import("../index.js").BoxJSMetadata} 规范化展示元数据 / Normalized presentation metadata.
224
+ */
225
+ function normalizeMetadata(source) {
226
+ const result = {};
227
+ for (const [key, value] of Object.entries(source)) {
228
+ const multiple = key === "icons" || key === "descs";
229
+ const values = multiple ? value : [value];
230
+ if (!Array.isArray(values) || values.some(item => typeof item !== "string")) throw new TypeError(`Invalid BoxJS app ${key}`);
231
+ result[key] = multiple ? [...values] : value;
232
+ }
233
+ return result;
234
+ }
235
+
236
+ /**
237
+ * 归一化 BoxJS 的字符串存储值,不改变普通文本内容。
238
+ * Normalize BoxJS string persistence without changing free-text values.
239
+ * @param {import("../index.js").SettingsField} field 前端字段约束 / Frontend field constraints.
240
+ * @param {unknown} value 存储值 / Stored value.
241
+ * @returns {unknown} 转换后的控件值;是否允许写入由 validValue 单独校验 / Converted control value; write eligibility is checked separately by validValue.
242
+ */
243
+ function normalizeStoredValue(field, value) {
244
+ switch (field.type) {
245
+ case "boolean":
246
+ if (value === "true" || value === "false") return value === "true";
247
+ break;
248
+ case "number":
249
+ if (typeof value === "string" && value.trim() !== "") return Number(value);
250
+ break;
251
+ case "array":
252
+ if (typeof value === "string") value = value === "" || value === "[]" ? [] : value.split(",");
253
+ break;
254
+ }
255
+ if (field.options) {
256
+ const match = item => field.options.find(option => String(option.key) === String(item))?.key ?? item;
257
+ return field.type === "array" && Array.isArray(value) ? value.map(match) : match(value);
258
+ }
259
+ return value;
260
+ }
261
+
262
+ /**
263
+ * 校验支持的标量范围,包括文本长度与数值有限性。
264
+ * Validate supported scalar bounds, including text length and numeric finiteness.
265
+ * @param {unknown} value 待检查值 / Value to inspect.
266
+ * @returns {boolean} 是否为有效标量 / Whether the scalar is valid.
267
+ */
268
+ function scalar(value) {
269
+ switch (typeof value) {
270
+ case "boolean":
271
+ return true;
272
+ case "string":
273
+ return value.length <= 2048;
274
+ case "number":
275
+ return Number.isFinite(value);
276
+ default:
277
+ return false;
278
+ }
279
+ }
280
+
281
+ /**
282
+ * 检查值类型、数组唯一性及声明的选项,不进行转换。
283
+ * Check value type, array uniqueness and declared choices without coercion.
284
+ * @param {import("../index.js").SettingsField} field 前端归一化字段 / Normalized frontend field.
285
+ * @param {unknown} value 待写入的 JSON 值 / JSON value to write.
286
+ * @returns {boolean} 是否符合字段约束 / Whether the value satisfies field constraints.
287
+ */
288
+ function validValue(field, value) {
289
+ if (field.type === "array") {
290
+ if (!Array.isArray(value) || value.some(item => !scalar(item)) || new Set(value).size !== value.length) return false;
291
+ } else if (typeof value !== field.type || !scalar(value)) return false;
292
+ return !field.options || (field.type === "array" ? value : [value]).every(item => field.options.some(option => option.key === item));
293
+ }
294
+
193
295
  /**
194
296
  * 共用三点按钮和底部操作菜单;弹层挂载到文档根部,不受标题栏显示状态影响。
195
297
  * Shared overflow trigger and bottom action sheet; the layer is mounted at document level and remains independent of header visibility.
@@ -339,336 +441,104 @@ class ActionMenu {
339
441
  }
340
442
 
341
443
  /**
342
- * BoxJS 数组、app 或订阅转换为模块字段,保留原文件为唯一字段来源。
343
- * Normalize a BoxJS array, app or subscription using the source JSON as the field authority.
344
- * @param {unknown | BoxJS} config BoxJS JSON 或已解析目录 / BoxJS document or parsed catalog.
345
- * @param {string} module API 第一段模块名 / First API path segment.
346
- * @returns {import("../index.js").ModuleDefinition} 存储根和字段 / Storage root and fields.
347
- * @throws {TypeError} 配置结构、字段路径、默认值或展示属性无效 / Invalid configuration, field path, default or presentation attribute.
348
- */
349
- function normalizeBoxJs(config, module) {
350
- validatePathParts([module]);
351
- const catalog = config instanceof BoxJS ? config : new BoxJS(config);
352
- const target = catalog.modules.get(module);
353
- if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
354
- const { entries, storageKey, metadata } = target;
355
- const fields = [];
356
- for (const entry of entries) {
357
- const parts = entry.id.slice(1).split(".").slice(1);
358
- const type = { boolean: "boolean", checkboxes: "array", selects: "select", text: "string", textarea: "string", number: "number" }[entry.type];
359
- if (!type) throw new TypeError(`Unsupported BoxJS control: ${entry.type}`);
360
- const field = {
361
- key: parts.join("."),
362
- type: type === "select" ? typeof entry.val : type,
363
-
364
- name: entry.name,
365
- description: entry.desc ?? "",
366
- control: entry.type,
367
- ...(entry.placeholder === undefined ? {} : { placeholder: entry.placeholder }),
368
- ...(entry.rows === undefined ? {} : { rows: entry.rows }),
369
- ...(entry.autoGrow === undefined ? {} : { autoGrow: entry.autoGrow }),
370
- };
371
- if (type === "select" && !["string", "number", "boolean"].includes(field.type)) throw new TypeError(`Select requires a scalar val: ${entry.id}`);
372
- if (entry.items) field.options = entry.items.map(item => ({ key: item.key, label: item.label }));
373
- if (Object.hasOwn(entry, "val")) field.defaultValue = normalizeStoredValue(field, entry.val);
374
- if (
375
- typeof field.name !== "string" ||
376
- (field.placeholder !== undefined && typeof field.placeholder !== "string") ||
377
- (field.rows !== undefined && (!Number.isInteger(field.rows) || field.rows < 1)) ||
378
- (field.autoGrow !== undefined && typeof field.autoGrow !== "boolean") ||
379
- fields.some(other => other.key === field.key || other.key.startsWith(`${field.key}.`) || field.key.startsWith(`${other.key}.`))
380
- )
381
- throw new TypeError(`Invalid or overlapping BoxJS field: ${entry.id}`);
382
- if (field.options && (new Set(field.options.map(item => item.key)).size !== field.options.length || field.options.some(item => !scalar(item.key) || typeof item.label !== "string"))) throw new TypeError(`Invalid options: ${entry.id}`);
383
- if (Object.hasOwn(field, "defaultValue") && !validValue(field, field.defaultValue)) throw new TypeError(`Invalid BoxJS val: ${entry.id}`);
384
- fields.push(field);
385
- }
386
- if (!fields.length) throw new TypeError(`No BoxJS settings for module: ${module}`);
387
- const common = fields[0].key.split(".").slice(0, -1);
388
- for (const field of fields) while (!field.key.startsWith(`${common.join(".")}.`)) common.pop();
389
- return {
390
- module,
391
- storageKey,
392
- fields,
393
- settingsPath: common,
394
- ...(Object.keys(metadata).length ? { metadata } : {}),
395
- };
396
- }
397
-
398
- /**
399
- * 归一化 BoxJS 的字符串存储值,不改变普通文本内容。
400
- * Normalize BoxJS string persistence without changing free-text values.
401
- * @param {import("../index.js").SettingsField} field 前端字段约束 / Frontend field constraints.
402
- * @param {unknown} value 存储值 / Stored value.
403
- * @returns {unknown} 转换后的控件值;是否允许写入由 validValue 单独校验 / Converted control value; write eligibility is checked separately by validValue.
404
- */
405
- function normalizeStoredValue(field, value) {
406
- switch (field.type) {
407
- case "boolean":
408
- if (value === "true" || value === "false") return value === "true";
409
- break;
410
- case "number":
411
- if (typeof value === "string" && value.trim() !== "") return Number(value);
412
- break;
413
- case "array":
414
- if (typeof value === "string") value = value === "" || value === "[]" ? [] : value.split(",");
415
- break;
416
- }
417
- if (field.options) {
418
- const match = item => field.options.find(option => String(option.key) === String(item))?.key ?? item;
419
- return field.type === "array" && Array.isArray(value) ? value.map(match) : match(value);
420
- }
421
- return value;
422
- }
423
-
424
- /**
425
- * 校验支持的标量范围,包括文本长度与数值有限性。
426
- * Validate supported scalar bounds, including text length and numeric finiteness.
427
- * @param {unknown} value 待检查值 / Value to inspect.
428
- * @returns {boolean} 是否为有效标量 / Whether the scalar is valid.
429
- */
430
- function scalar(value) {
431
- switch (typeof value) {
432
- case "boolean":
433
- return true;
434
- case "string":
435
- return value.length <= 2048;
436
- case "number":
437
- return Number.isFinite(value);
438
- default:
439
- return false;
440
- }
441
- }
442
-
443
- /**
444
- * 检查值类型、数组唯一性及声明的选项,不进行转换。
445
- * Check value type, array uniqueness and declared choices without coercion.
446
- * @param {import("../index.js").SettingsField} field 前端归一化字段 / Normalized frontend field.
447
- * @param {unknown} value 待写入的 JSON 值 / JSON value to write.
448
- * @returns {boolean} 是否符合字段约束 / Whether the value satisfies field constraints.
449
- */
450
- function validValue(field, value) {
451
- if (field.type === "array") {
452
- if (!Array.isArray(value) || value.some(item => !scalar(item)) || new Set(value).size !== value.length) return false;
453
- } else if (typeof value !== field.type || !scalar(value)) return false;
454
- return !field.options || (field.type === "array" ? value : [value]).every(item => field.options.some(option => option.key === item));
455
- }
456
-
457
- /**
458
- * 单个模块的临时会话;离开页面后丢弃。
459
- * Transient module session discarded when leaving the page.
460
- * @typedef {object} ModuleSession
461
- * @property {AbortController} controller 读取请求的取消控制器 / Abort controller for reads.
462
- * @property {import("../index.js").ModuleDefinition | null} definition 加载完成的配置,加载中为 null / Loaded configuration, or null while loading.
463
- * @property {import("./client.mjs").ModuleSnapshot["values"]} values 当前显示值 / Current display values.
464
- * @property {boolean} saving 是否正在写入 / Whether a mutation is in progress.
444
+ * 创建单模块页面客户端;只调用模块 API,不读取或解析 BoxJS
445
+ * Create a single-module page client that only calls the module API and never reads or parses BoxJS.
446
+ * @param {import("./client.mjs").PreferencesClientOptions} options API 模型、请求与通知 / API model, requests and notifications.
447
+ * @returns {import("./client.mjs").PreferencesClient} 页面客户端 / Page client.
465
448
  */
449
+ function createPreferencesClient({ model, definition, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
450
+ const { module, configURL } = model;
451
+ const session = new AbortController();
452
+ const values = structuredClone(model.values);
453
+ let saving = false;
466
454
 
467
- /**
468
- * 创建页面会话缓存;打开时重读,选项操作仅在 HTTP 200 后更新缓存。
469
- * Create a page-session cache; reload on open and mutate cache only after HTTP 200.
470
- * @param {import("./client.mjs").PreferencesClientOptions} options 包内目录、请求与通知 / Internal catalog, requests and notifications.
471
- * @returns {import("./client.mjs").PreferencesClient} 通用客户端 / Generic client.
472
- */
473
- function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
474
- /**
475
- * 模块会话表
476
- * Module session map.
477
- * @type {Map<string, ModuleSession>}
478
- */
479
- const sessions = new Map();
480
455
  /**
481
- * form 发送完整存储键;读取 404 交给调用方处理。
482
- * Send a complete storage key as form data; callers handle missing reads.
483
- * @param {string} path 完整 @root.path / Complete @root.path.
484
- * @param {"get" | "set" | "delete"} action 存储操作 / Storage operation.
485
- * @param {unknown} body set 值,其它操作忽略 / Set value, ignored by other operations.
486
- * @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
487
- * @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
488
- * @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
456
+ * 向模块 API 发送 JSON 动作。
457
+ * Send a JSON action to the module API.
458
+ * @param {"get" | "set" | "delete"} action 模块动作 / Module action.
459
+ * @param {unknown} payload JSON 请求体 / JSON request body.
460
+ * @returns {Promise<Response>} 原始响应 / Raw response.
489
461
  */
490
- async function send(path, action, body, signal) {
462
+ async function send(action, payload) {
491
463
  const controller = new AbortController();
492
464
  const abort = () => controller.abort();
493
- if (signal?.aborted) abort();
494
- signal?.addEventListener("abort", abort, { once: true });
465
+ if (session.signal.aborted) abort();
466
+ session.signal.addEventListener("abort", abort, { once: true });
495
467
  const timer = setTimeout(abort, timeout);
496
468
  try {
497
- const response = await request(`/api/${action}`, {
469
+ const response = await request(`/api/${encodeURIComponent(module)}/${action}`, {
498
470
  method: "POST",
499
471
  credentials: "omit",
500
472
  cache: "no-store",
501
473
  signal: controller.signal,
502
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
503
- body: new URLSearchParams([[path, action === "set" ? JSON.stringify(body) : ""]]).toString(),
474
+ headers: { "Content-Type": "application/json", "X-PreferencePanes-JSON": configURL },
475
+ body: JSON.stringify(payload),
504
476
  });
505
477
  if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
506
478
  return response;
507
479
  } finally {
508
480
  clearTimeout(timer);
509
- signal?.removeEventListener("abort", abort);
481
+ session.signal.removeEventListener("abort", abort);
510
482
  }
511
483
  }
484
+
512
485
  /**
513
- * 获取独立快照,避免调用方修改内部缓存。
514
- * Return an independent snapshot so callers cannot mutate the cache.
515
- * @param {string} module 已打开模块 / Open module.
516
- * @returns {import("./client.mjs").ModuleSnapshot} 会话快照 / Session snapshot.
517
- * @throws {Error} 模块未完成加载 / Module has not finished loading.
518
- */
519
- const snapshot = module => {
520
- const state = sessions.get(module);
521
- if (!state?.definition) throw new Error("Open the module first");
522
- return structuredClone({ definition: state.definition, values: state.values });
523
- };
524
- /**
525
- * 串行修改单键,仅成功后更新仍存活的会话。
526
- * Serialize single-key mutations and update a still-active session only after success.
527
- * @param {string} module 已打开模块 / Open module.
528
- * @param {string} key 完整点分字段路径 / Complete dotted field path.
529
- * @param {"set" | "delete"} action 写入或删除 / Write or delete.
530
- * @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
531
- * @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
486
+ * 执行写入动作;成功后只更新当前页面值。
487
+ * Execute a mutation and update only the current page values after success.
488
+ * @param {"set" | "delete"} action API 动作 / API action.
489
+ * @param {unknown} payload JSON 请求体 / JSON request body.
490
+ * @param {"write" | "delete" | "clearCaches" | "reset"} operation 通知操作 / Notification operation.
491
+ * @param {string} [key] 字段路径 / Field path.
532
492
  * @returns {Promise<void>} 操作完成 / Operation completion.
533
- * @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
534
493
  */
535
- async function change(module, key, action, value, operation = action === "set" ? "write" : "delete") {
536
- const state = sessions.get(module);
537
- if (!state?.definition) throw new Error("Open the module first");
538
- if (state.saving) throw new Error("A settings write is already in progress");
539
- const field = state.definition.fields.find(field => field.key === key);
540
- state.saving = true;
494
+ async function change(action, payload, operation, key) {
495
+ if (saving) throw new Error("A settings write is already in progress");
496
+ saving = true;
541
497
  try {
542
- if ((operation === "write" || operation === "delete") && (!field || (action === "set" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
543
- await send(`@${state.definition.storageKey}.${key}`, action, value);
544
- if (sessions.get(module) === state) {
545
- switch (operation) {
546
- case "write":
547
- state.values[key] = structuredClone(value);
548
- break;
549
- case "delete":
550
- case "clearCaches":
551
- case "reset":
552
- for (const candidate of state.definition.fields) {
553
- if (candidate.key !== key && !candidate.key.startsWith(`${key}.`)) continue;
554
- delete state.values[candidate.key];
555
- if (Object.hasOwn(candidate, "defaultValue")) state.values[candidate.key] = structuredClone(candidate.defaultValue);
556
- }
557
- break;
498
+ await send(action, payload);
499
+ switch (operation) {
500
+ case "write":
501
+ values[key] = structuredClone(payload.value);
502
+ break;
503
+ case "delete": {
504
+ const field = definition.fields.find(candidate => candidate.key === key);
505
+ delete values[key];
506
+ if (field && Object.hasOwn(field, "defaultValue")) values[key] = structuredClone(field.defaultValue);
507
+ break;
558
508
  }
509
+ case "clearCaches":
510
+ break;
511
+ case "reset":
512
+ for (const field of definition.fields) {
513
+ delete values[field.key];
514
+ if (Object.hasOwn(field, "defaultValue")) values[field.key] = structuredClone(field.defaultValue);
515
+ }
516
+ break;
559
517
  }
560
518
  notify({ kind: "success", operation, module, key });
561
519
  } catch (error) {
562
520
  notify({ kind: "error", operation, module, key, message: error.message });
563
521
  throw error;
564
522
  } finally {
565
- state.saving = false;
523
+ saving = false;
566
524
  }
567
525
  }
526
+
568
527
  return {
569
- /**
570
- * 从已导入的 JSON 创建新会话,只读取一次设置值。
571
- * Create a session from imported JSON and read stored settings once.
572
- * @param {string} module 模块标识 / Module identifier.
573
- * @returns {Promise<import("./client.mjs").ModuleSnapshot>} 新快照 / New snapshot.
574
- * @throws {Error} 读取失败、会话被替换或写入尚未完成 / Read failure, replaced session or unfinished write.
575
- */
576
- async open(module) {
577
- const binding = catalog.modules.get(module);
578
- if (!binding) throw new TypeError(`No BoxJS settings for module: ${module}`);
579
- const previous = sessions.get(module);
580
- if (previous?.saving) throw new Error("Cannot refresh while saving");
581
- previous?.controller.abort();
582
- const state = { controller: new AbortController(), definition: null, values: {}, saving: false };
583
- sessions.set(module, state);
584
- try {
585
- const definition = normalizeBoxJs(catalog, module);
586
- const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
587
- let subtree = response.status === 404 ? {} : await response.json();
588
- if (typeof subtree === "string") subtree = JSON.parse(subtree);
589
- if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
590
- if (sessions.get(module) !== state) throw new Error("Module session was replaced");
591
- state.definition = definition;
592
- for (const field of definition.fields) {
593
- const stored = field.key
594
- .split(".")
595
- .slice(definition.settingsPath.length)
596
- .reduce((parent, part) => Object(parent)[part], subtree);
597
- const value = stored === undefined ? field.defaultValue : stored;
598
- if (value !== undefined) state.values[field.key] = normalizeStoredValue(field, value);
599
- }
600
- return snapshot(module);
601
- } catch (error) {
602
- if (sessions.get(module) === state) sessions.delete(module);
603
- throw error;
604
- }
605
- },
606
- snapshot,
607
- /**
608
- * 按需重新读取模块 Settings,不更新页面会话缓存。
609
- * Reread module Settings on demand without updating the page-session cache.
610
- * @param {string} module 已打开的模块 / Open module.
611
- * @returns {Promise<unknown>} 设置值,缺失为 undefined / Settings value, or undefined when absent.
612
- */
613
- async readSettings(module) {
614
- const state = sessions.get(module);
615
- if (!state?.definition) throw new Error("Open the module first");
616
- const response = await send(`@${state.definition.storageKey}.${state.definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
528
+ snapshot: () => structuredClone({ definition, values }),
529
+ async readSettings() {
530
+ const response = await send("get", { scope: "settings" });
617
531
  return response.status === 404 ? undefined : response.json();
618
532
  },
619
- /**
620
- * 按需读取模块 Caches,不自动读取其它设置。
621
- * Read module Caches on demand without refreshing other settings.
622
- * @param {string} module 已打开的模块 / Open module.
623
- * @returns {Promise<unknown>} 缓存值,缺失为 undefined / Cache value, or undefined when absent.
624
- */
625
- async readCaches(module) {
626
- const state = sessions.get(module);
627
- if (!state?.definition) throw new Error("Open the module first");
628
- const response = await send(`@${state.definition.storageKey}.${module}.Caches`, "get", undefined, state.controller.signal);
533
+ async readCaches() {
534
+ const response = await send("get", { scope: "caches" });
629
535
  return response.status === 404 ? undefined : response.json();
630
536
  },
631
- /**
632
- * 删除整个 Caches 节点,成功后不追加 GET。
633
- * Delete the entire Caches node without a follow-up GET.
634
- * @param {string} module 已打开模块 / Open module.
635
- * @returns {Promise<void>} 清理完成 / Cleanup completion.
636
- */
637
- clearCaches: module => change(module, `${module}.Caches`, "delete", undefined, "clearCaches"),
638
- /**
639
- * 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
640
- * Delete module persistence and reset the page cache using current BoxJS defaults.
641
- * @param {string} module 已打开模块 / Open module.
642
- * @returns {Promise<void>} 重置完成 / Reset completion.
643
- */
644
- reset: module => change(module, module, "delete", undefined, "reset"),
645
- /**
646
- * 取消读取并清除会话,不撤销已发送的写入。
647
- * Abort reads and clear the session without undoing dispatched writes.
648
- * @param {string} module 模块标识 / Module identifier.
649
- * @returns {void} 无返回值 / No return value.
650
- */
651
- leave(module) {
652
- sessions.get(module)?.controller.abort();
653
- sessions.delete(module);
654
- },
655
- /**
656
- * 写入单键并更新当前会话。
657
- * Write one key and update the current session.
658
- * @param {string} module 已打开模块 / Open module.
659
- * @param {string} key 点分字段路径 / Dotted field path.
660
- * @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
661
- * @returns {Promise<void>} 写入完成 / Write completion.
662
- */
663
- set: (module, key, value) => change(module, key, "set", value),
664
- /**
665
- * 删除单键覆盖值并显示默认值。
666
- * Delete one override and display its default value.
667
- * @param {string} module 已打开模块 / Open module.
668
- * @param {string} key 点分字段路径 / Dotted field path.
669
- * @returns {Promise<void>} 删除完成 / Delete completion.
670
- */
671
- remove: (module, key) => change(module, key, "delete"),
537
+ clearCaches: () => change("delete", { scope: "caches" }, "clearCaches"),
538
+ reset: () => change("delete", { scope: "module" }, "reset"),
539
+ leave: () => session.abort(),
540
+ set: (key, value) => change("set", { key, value }, "write", key),
541
+ remove: key => change("delete", { key }, "delete", key),
672
542
  };
673
543
  }
674
544
 
@@ -832,18 +702,19 @@ class Navigation extends EventTarget {
832
702
  }
833
703
 
834
704
  /**
835
- * 挂载已导入 BoxJS 对应的模块表单和短暂通知。
836
- * Mount the imported BoxJS module form and transient notifications.
705
+ * 挂载 API 返回的模块模型表单和短暂通知。
706
+ * Mount the module model returned by the API and transient notifications.
837
707
  * @param {HTMLElement} root 包内挂载元素 / Internal mount element.
838
- * @param {import("../BoxJS.mjs").BoxJS} catalog 包内 BoxJS 目录 / Internal BoxJS catalog.
708
+ * @param {import("../index.js").ModuleModel} model API 返回的模块模型 / Module model returned by the API.
839
709
  * @returns {import("./index.js").MountedPreferences} 面板生命周期句柄 / Panel lifecycle handle.
840
710
  */
841
- function mountPanel(root, catalog) {
842
- const title = catalog.module.metadata.name ?? catalog.module.module;
711
+ function mountPanel(root, model) {
712
+ const { definition } = model;
713
+ const title = definition.metadata?.name ?? definition.module;
843
714
  const document = root.ownerDocument;
844
715
  const window = document.defaultView;
845
716
  const shell = element("div", "pp-panel");
846
- shell.dataset.module = catalog.module.module;
717
+ shell.dataset.module = definition.module;
847
718
  const header = element("header", "pp-header");
848
719
  const back = element("button", "pp-back", "‹");
849
720
  back.setAttribute("aria-label", "返回");
@@ -873,7 +744,7 @@ function mountPanel(root, catalog) {
873
744
  if (!frame?.dataset.preferencePanes) return;
874
745
  frame.dispatchEvent(
875
746
  new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
876
- detail: { title: heading.textContent, module: catalog.module.module, busy: saving, canGoBack: !back.disabled, actions },
747
+ detail: { title: heading.textContent, module: definition.module, busy: saving, canGoBack: !back.disabled, actions },
877
748
  }),
878
749
  );
879
750
  };
@@ -930,7 +801,7 @@ function mountPanel(root, catalog) {
930
801
  toast.hidden = true;
931
802
  }, 2400);
932
803
  };
933
- const client = createPreferencesClient({ catalog, notify });
804
+ const client = createPreferencesClient({ model, definition, notify });
934
805
  /**
935
806
  * 两种菜单入口共用异步错误处理,包含宿主确认框错误。
936
807
  * Share async error handling between both menus, including host-dialog errors.
@@ -958,7 +829,6 @@ function mountPanel(root, catalog) {
958
829
  publishNavigation();
959
830
  viewport.replaceChildren(statusView("读取设置…"));
960
831
  try {
961
- await client.open(module);
962
832
  if (version === generation) controls();
963
833
  } catch (error) {
964
834
  if (version !== generation) return;
@@ -972,7 +842,7 @@ function mountPanel(root, catalog) {
972
842
  * @returns {void} 无返回值 / No return value.
973
843
  */
974
844
  function controls() {
975
- const { definition, values } = client.snapshot(active);
845
+ const { definition, values } = client.snapshot();
976
846
  heading.textContent = definition.metadata?.name || active;
977
847
  const view = element("section", "pp-fields");
978
848
  /**
@@ -1010,7 +880,7 @@ function mountPanel(root, catalog) {
1010
880
  saving = true;
1011
881
  back.disabled = true;
1012
882
  publishNavigation();
1013
- return (queue = queue
883
+ queue = queue
1014
884
  .then(action)
1015
885
  .then(() => {
1016
886
  if (!destroyed) success();
@@ -1023,10 +893,11 @@ function mountPanel(root, catalog) {
1023
893
  .finally(() => {
1024
894
  pendingWrites--;
1025
895
  saving = pendingWrites > 0;
1026
- if (destroyed && !saving) client.leave(active);
896
+ if (destroyed && !saving) client.leave();
1027
897
  back.disabled = saving || !navigation.canGoBack;
1028
898
  publishNavigation();
1029
- }));
899
+ });
900
+ return queue;
1030
901
  }
1031
902
  const metadata = definition.metadata;
1032
903
  if (metadata) {
@@ -1104,7 +975,7 @@ function mountPanel(root, catalog) {
1104
975
  link.append(summary, element("span", "pp-chevron", "›"));
1105
976
  row.append(link);
1106
977
  const refresh = () => {
1107
- const value = client.snapshot(active).values[field.key];
978
+ const value = client.snapshot().values[field.key];
1108
979
  summary.textContent =
1109
980
  field.options
1110
981
  .filter(option => Array.isArray(value) && value.includes(option.key))
@@ -1195,8 +1066,7 @@ function mountPanel(root, catalog) {
1195
1066
  let inputVersion = 0;
1196
1067
  inputContainer.addEventListener(eventName, event => {
1197
1068
  if (event.isComposing) return;
1198
- const version = ++inputVersion,
1199
- module = active;
1069
+ const version = ++inputVersion;
1200
1070
  let value;
1201
1071
  try {
1202
1072
  value = read();
@@ -1205,10 +1075,17 @@ function mountPanel(root, catalog) {
1205
1075
  return;
1206
1076
  }
1207
1077
  const restore = () => {
1208
- if (version === inputVersion) write(client.snapshot(module).values[field.key]);
1078
+ if (version === inputVersion) write(client.snapshot().values[field.key]);
1209
1079
  };
1210
1080
  perform(
1211
- () => client.set(module, field.key, value),
1081
+ () => {
1082
+ if (!validValue(field, value)) {
1083
+ const error = new TypeError("Invalid setting value");
1084
+ notify({ kind: "error", operation: "write", key: field.key, message: error.message });
1085
+ throw error;
1086
+ }
1087
+ return client.set(field.key, value);
1088
+ },
1212
1089
  () => {
1213
1090
  for (const refresh of summaries) refresh();
1214
1091
  },
@@ -1229,7 +1106,7 @@ function mountPanel(root, catalog) {
1229
1106
  return perform(
1230
1107
  async () => {
1231
1108
  try {
1232
- value = await client.readSettings(active);
1109
+ value = await client.readSettings();
1233
1110
  } catch (error) {
1234
1111
  notify({ kind: "error", message: error.message });
1235
1112
  throw error;
@@ -1253,7 +1130,7 @@ function mountPanel(root, catalog) {
1253
1130
  return perform(
1254
1131
  async () => {
1255
1132
  try {
1256
- value = await client.readCaches(active);
1133
+ value = await client.readCaches();
1257
1134
  } catch (error) {
1258
1135
  notify({ kind: "error", message: error.message });
1259
1136
  throw error;
@@ -1269,7 +1146,7 @@ function mountPanel(root, catalog) {
1269
1146
  if (saving) return;
1270
1147
  if (!(await requestConfirmation(window, `清空 ${active} 的全部 Caches?`)) || destroyed || saving) return;
1271
1148
  return perform(
1272
- () => client.clearCaches(active),
1149
+ () => client.clearCaches(),
1273
1150
  () => {
1274
1151
  output.textContent = "暂无缓存";
1275
1152
  },
@@ -1278,7 +1155,7 @@ function mountPanel(root, catalog) {
1278
1155
  handlers.set("reset", async () => {
1279
1156
  if (saving) return;
1280
1157
  if (!(await requestConfirmation(window, `重置 ${active} 的设置?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) || destroyed || saving) return;
1281
- return perform(() => client.reset(active), controls);
1158
+ return perform(() => client.reset(), controls);
1282
1159
  });
1283
1160
  navigation?.destroy();
1284
1161
  navigation = new Navigation(viewport, view, key => editors.get(key)?.node);
@@ -1296,7 +1173,7 @@ function mountPanel(root, catalog) {
1296
1173
  if (navigation) navigation.back();
1297
1174
  else window.history.back();
1298
1175
  };
1299
- open(catalog.module.module);
1176
+ open(definition.module);
1300
1177
  return {
1301
1178
  /**
1302
1179
  * 移除监听器、定时器、会话和挂载内容。
@@ -1309,7 +1186,7 @@ function mountPanel(root, catalog) {
1309
1186
  window.frameElement?.removeEventListener("preferencepanes:action", onAction);
1310
1187
  navigation?.destroy();
1311
1188
  generation++;
1312
- if (active && !saving) client.leave(active);
1189
+ if (active && !saving) client.leave();
1313
1190
  clearTimeout(timer);
1314
1191
  shell.remove();
1315
1192
  },
@@ -1339,14 +1216,22 @@ function installDefaultStyles(document) {
1339
1216
  /**
1340
1217
  * 挂载模块设置页;默认样式由包提供,可选 CSS 仅作用于当前模块。
1341
1218
  * Mount a module page with package defaults and optional module-scoped CSS.
1342
- * @param {import("../index.js").BoxJSInput} boxjs 单个模块的 BoxJS JSON / BoxJS JSON for one module.
1219
+ * @param {import("../index.js").ModuleModel} model API 返回的模块模型 / Module model returned by the API.
1343
1220
  * @param {string} [css] 可选 CSS 正文 / Optional CSS text.
1344
1221
  * @returns {import("./index.js").MountedPreferences} 模块生命周期句柄 / Module lifecycle handle.
1345
1222
  */
1346
- function mount(boxjs, css = "") {
1223
+ function mount(model, css = "") {
1347
1224
  if (typeof css !== "string") throw new TypeError("CSS must be a string");
1348
- const catalog = boxjs instanceof BoxJS ? boxjs : new BoxJS(boxjs);
1349
- const metadata = catalog.module.metadata;
1225
+ const definition = normalizeBoxJs(model.boxjs, model.module);
1226
+ const values = { ...model.values };
1227
+ for (const field of definition.fields) {
1228
+ if (values[field.key] === undefined) continue;
1229
+ values[field.key] = normalizeStoredValue(field, values[field.key]);
1230
+ if (!validValue(field, values[field.key])) throw new TypeError(`Invalid stored value: ${field.key}`);
1231
+ }
1232
+ for (const field of definition.fields) if (values[field.key] === undefined && Object.hasOwn(field, "defaultValue")) values[field.key] = structuredClone(field.defaultValue);
1233
+ const rendered = { ...model, definition, values };
1234
+ const metadata = definition.metadata ?? {};
1350
1235
  const image = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
1351
1236
  if (image) resourceURL(image);
1352
1237
  if (metadata.repo) resourceURL(metadata.repo);
@@ -1382,7 +1267,7 @@ function mount(boxjs, css = "") {
1382
1267
  observer = new MutationObserver(syncAppearance);
1383
1268
  observer.observe(host, { attributes: true, attributeFilter: ["data-theme", "style"] });
1384
1269
  }
1385
- document.title = metadata.name ?? catalog.module.module;
1270
+ document.title = metadata.name ?? definition.module;
1386
1271
  let panel;
1387
1272
  const view = {
1388
1273
  /**
@@ -1406,7 +1291,7 @@ function mount(boxjs, css = "") {
1406
1291
  };
1407
1292
  try {
1408
1293
  root.replaceChildren();
1409
- panel = mountPanel(root, catalog);
1294
+ panel = mountPanel(root, rendered);
1410
1295
  return view;
1411
1296
  } catch (error) {
1412
1297
  view.destroy();
@@ -1440,17 +1325,16 @@ async function start() {
1440
1325
  default:
1441
1326
  inputs = pageInputs(new URL(location.href));
1442
1327
  }
1443
- const resources = [inputs.json, inputs.css].map(source => {
1328
+ const apiURL = new URL(`/api/${encodeURIComponent(inputs.module)}`, inputs.url).href;
1329
+ const resources = [inputs.css].map(source => {
1444
1330
  if (!source) return null;
1445
1331
  const url = new URL(source, inputs.url);
1446
1332
  if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Resources must use HTTP(S) URLs");
1447
1333
  return url.href;
1448
1334
  });
1449
- const [data, style] = await Promise.all(resources.map(url => (url ? fetch(url, { cache: "no-store", credentials: "omit" }) : null)));
1450
- if (data.status !== 200 || (style && style.status !== 200)) throw new Error(`HTTP ${data.status !== 200 ? data.status : style.status}`);
1451
- const catalog = new BoxJS(await data.json());
1452
- if (catalog.module.module !== inputs.module) throw new Error("Imported JSON does not match the module URL");
1453
- view = mount(catalog, style ? await style.text() : "");
1335
+ const [style, modelResponse] = await Promise.all([...resources.map(url => (url ? fetch(url, { cache: "no-store", credentials: "omit" }) : null)), fetch(apiURL, { cache: "no-store", credentials: "omit", headers: { Accept: "application/json", "X-PreferencePanes-JSON": inputs.json } })]);
1336
+ if ((style && style.status !== 200) || modelResponse.status !== 200) throw new Error(`HTTP ${modelResponse.status !== 200 ? modelResponse.status : style.status}`);
1337
+ view = mount(await modelResponse.json(), style ? await style.text() : "");
1454
1338
  } catch (error) {
1455
1339
  document.querySelector("#preferences").replaceChildren(statusView(`加载失败:${error.message}`, start));
1456
1340
  }