@nsnanocat/preference-panes 0.9.15 → 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.
- package/README.md +21 -15
- package/dist/api.js +1454 -967
- package/dist/module/app.mjs +296 -412
- package/dist/module/index.html +1 -1
- package/dist/module/navigation.mjs +17 -28
- package/dist/preference-panes.mjs +259 -374
- package/dist/web.js +1202 -0
- package/package.json +1 -1
- package/src/api.mjs +191 -0
- package/src/browser/ModuleStatus.mjs +17 -28
- package/src/browser/Navigation.d.mts +12 -32
- package/src/browser/app.mjs +5 -7
- package/src/{lib → browser}/boxjs.mjs +72 -16
- package/src/browser/client.d.mts +44 -129
- package/src/browser/client.mjs +61 -179
- package/src/browser/index.d.ts +7 -11
- package/src/browser/index.mjs +15 -7
- package/src/browser/panel.mjs +31 -22
- package/src/build.mjs +2 -3
- package/src/index.d.ts +16 -2
- package/src/web.mjs +52 -0
- package/src/BoxJS.mjs +0 -72
- package/src/Store.mjs +0 -114
- package/src/lib/response.mjs +0 -16
- package/src/proxy/handler.mjs +0 -39
- package/src/proxy/response.mjs +0 -16
|
@@ -11,76 +11,178 @@ function validatePathParts(parts) {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* BoxJS
|
|
15
|
-
*
|
|
14
|
+
* 将 BoxJS 数组、app 或订阅转换为浏览器字段定义。
|
|
15
|
+
* Normalize a BoxJS array, app or subscription into browser field definitions.
|
|
16
|
+
* @param {unknown} config 原始 BoxJS JSON / Raw BoxJS JSON.
|
|
17
|
+
* @param {string} [module] API 模块路径段;省略时要求输入只有一个模块 / API module path segment; omission requires exactly one module.
|
|
18
|
+
* @returns {import("../index.js").ModuleDefinition} 浏览器字段定义 / Browser field definition.
|
|
16
19
|
*/
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (!
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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);
|
|
20
|
+
function normalizeBoxJs(config, module) {
|
|
21
|
+
if (!config || typeof config !== "object") throw new TypeError("Expected BoxJS JSON");
|
|
22
|
+
const document = JSON.parse(JSON.stringify(config));
|
|
23
|
+
const apps = Array.isArray(document) ? [{ settings: document }] : (document.apps ?? [document]);
|
|
24
|
+
if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
|
|
25
|
+
const modules = new Map();
|
|
26
|
+
for (const app of apps) {
|
|
27
|
+
if (!app || !Array.isArray(app.settings)) throw new TypeError("Expected BoxJS settings array");
|
|
28
|
+
for (const entry of app.settings) {
|
|
29
|
+
if (typeof entry.id !== "string") throw new TypeError("BoxJS settings require string IDs");
|
|
30
|
+
if (!entry.id.startsWith("@")) {
|
|
31
|
+
if (Array.isArray(document)) throw new TypeError("BoxJS settings require @root.path IDs");
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const [storageKey, ...parts] = entry.id.slice(1).split(".");
|
|
35
|
+
if (!storageKey || storageKey.startsWith("@") || parts.length < 2) throw new TypeError("A BoxJS setting must be below a literal storage root and module");
|
|
36
|
+
validatePathParts(parts);
|
|
37
|
+
const name = parts[0];
|
|
38
|
+
let target = modules.get(name);
|
|
39
|
+
if (!target) {
|
|
40
|
+
target = { module: name, storageKey, entries: [], owners: new Set() };
|
|
41
|
+
modules.set(name, target);
|
|
49
42
|
}
|
|
43
|
+
if (target.storageKey !== storageKey) throw new TypeError(`A module must use one storage root: ${name}`);
|
|
44
|
+
target.entries.push(entry);
|
|
45
|
+
target.owners.add(app);
|
|
50
46
|
}
|
|
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
47
|
}
|
|
48
|
+
if (module === undefined && modules.size !== 1) throw new TypeError("Import BoxJS JSON for exactly one module");
|
|
49
|
+
const target = module === undefined ? modules.values().next().value : modules.get(module);
|
|
50
|
+
if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
|
|
51
|
+
const metadata = normalizeMetadata(target.owners.size === 1 ? presentation([...target.owners][0]) : {});
|
|
52
|
+
const fields = [];
|
|
53
|
+
for (const entry of target.entries) {
|
|
54
|
+
const parts = entry.id.slice(1).split(".").slice(1);
|
|
55
|
+
const type = { boolean: "boolean", checkboxes: "array", selects: "select", text: "string", textarea: "string", number: "number" }[entry.type];
|
|
56
|
+
if (!type) throw new TypeError(`Unsupported BoxJS control: ${entry.type}`);
|
|
57
|
+
const field = {
|
|
58
|
+
key: parts.join("."),
|
|
59
|
+
type: type === "select" ? typeof entry.val : type,
|
|
54
60
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
name: entry.name,
|
|
62
|
+
description: entry.desc ?? "",
|
|
63
|
+
control: entry.type,
|
|
64
|
+
...(entry.placeholder === undefined ? {} : { placeholder: entry.placeholder }),
|
|
65
|
+
...(entry.rows === undefined ? {} : { rows: entry.rows }),
|
|
66
|
+
...(entry.autoGrow === undefined ? {} : { autoGrow: entry.autoGrow }),
|
|
67
|
+
};
|
|
68
|
+
if (type === "select" && !["string", "number", "boolean"].includes(field.type)) throw new TypeError(`Select requires a scalar val: ${entry.id}`);
|
|
69
|
+
if (entry.items) field.options = entry.items.map(item => ({ key: item.key, label: item.label }));
|
|
70
|
+
if (Object.hasOwn(entry, "val")) field.defaultValue = normalizeStoredValue(field, entry.val);
|
|
71
|
+
if (
|
|
72
|
+
typeof field.name !== "string" ||
|
|
73
|
+
(field.placeholder !== undefined && typeof field.placeholder !== "string") ||
|
|
74
|
+
(field.rows !== undefined && (!Number.isInteger(field.rows) || field.rows < 1)) ||
|
|
75
|
+
(field.autoGrow !== undefined && typeof field.autoGrow !== "boolean") ||
|
|
76
|
+
fields.some(other => other.key === field.key || other.key.startsWith(`${field.key}.`) || field.key.startsWith(`${other.key}.`))
|
|
77
|
+
)
|
|
78
|
+
throw new TypeError(`Invalid or overlapping BoxJS field: ${entry.id}`);
|
|
79
|
+
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}`);
|
|
80
|
+
if (Object.hasOwn(field, "defaultValue") && !validValue(field, field.defaultValue)) throw new TypeError(`Invalid BoxJS val: ${entry.id}`);
|
|
81
|
+
fields.push(field);
|
|
63
82
|
}
|
|
83
|
+
if (!fields.length) throw new TypeError(`No BoxJS settings for module: ${target.module}`);
|
|
84
|
+
const common = fields[0].key.split(".").slice(0, -1);
|
|
85
|
+
for (const field of fields) while (!field.key.startsWith(`${common.join(".")}.`)) common.pop();
|
|
86
|
+
return {
|
|
87
|
+
module: target.module,
|
|
88
|
+
storageKey: target.storageKey,
|
|
89
|
+
fields,
|
|
90
|
+
settingsPath: common,
|
|
91
|
+
...(Object.keys(metadata).length ? { metadata } : {}),
|
|
92
|
+
};
|
|
64
93
|
}
|
|
65
94
|
|
|
66
95
|
/**
|
|
67
|
-
*
|
|
68
|
-
* Retain
|
|
69
|
-
* @param {object} source BoxJS app
|
|
70
|
-
* @returns {
|
|
96
|
+
* 保留字段所属 app 的原始展示信息。
|
|
97
|
+
* Retain raw presentation metadata from the app owning the fields.
|
|
98
|
+
* @param {object} source BoxJS app / BoxJS app.
|
|
99
|
+
* @returns {Record<string, unknown>} 原始展示信息 / Raw presentation metadata.
|
|
71
100
|
*/
|
|
72
|
-
function
|
|
101
|
+
function presentation(source) {
|
|
73
102
|
const result = {};
|
|
74
103
|
for (const key of ["id", "name", "author", "repo", "script", "icon", "description", "desc", "icons", "descs"]) {
|
|
75
104
|
if (source[key] === undefined) continue;
|
|
105
|
+
result[key] = source[key];
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 校验供浏览器展示的标准 BoxJS 元数据。
|
|
112
|
+
* Validate standard BoxJS metadata used by the browser renderer.
|
|
113
|
+
* @param {Record<string, unknown>} source 原始展示元数据 / Raw presentation metadata.
|
|
114
|
+
* @returns {import("../index.js").BoxJSMetadata} 规范化展示元数据 / Normalized presentation metadata.
|
|
115
|
+
*/
|
|
116
|
+
function normalizeMetadata(source) {
|
|
117
|
+
const result = {};
|
|
118
|
+
for (const [key, value] of Object.entries(source)) {
|
|
76
119
|
const multiple = key === "icons" || key === "descs";
|
|
77
|
-
const values = multiple ?
|
|
120
|
+
const values = multiple ? value : [value];
|
|
78
121
|
if (!Array.isArray(values) || values.some(item => typeof item !== "string")) throw new TypeError(`Invalid BoxJS app ${key}`);
|
|
79
|
-
result[key] = multiple ? [...values] :
|
|
122
|
+
result[key] = multiple ? [...values] : value;
|
|
80
123
|
}
|
|
81
124
|
return result;
|
|
82
125
|
}
|
|
83
126
|
|
|
127
|
+
/**
|
|
128
|
+
* 归一化 BoxJS 的字符串存储值,不改变普通文本内容。
|
|
129
|
+
* Normalize BoxJS string persistence without changing free-text values.
|
|
130
|
+
* @param {import("../index.js").SettingsField} field 前端字段约束 / Frontend field constraints.
|
|
131
|
+
* @param {unknown} value 存储值 / Stored value.
|
|
132
|
+
* @returns {unknown} 转换后的控件值;是否允许写入由 validValue 单独校验 / Converted control value; write eligibility is checked separately by validValue.
|
|
133
|
+
*/
|
|
134
|
+
function normalizeStoredValue(field, value) {
|
|
135
|
+
switch (field.type) {
|
|
136
|
+
case "boolean":
|
|
137
|
+
if (value === "true" || value === "false") return value === "true";
|
|
138
|
+
break;
|
|
139
|
+
case "number":
|
|
140
|
+
if (typeof value === "string" && value.trim() !== "") return Number(value);
|
|
141
|
+
break;
|
|
142
|
+
case "array":
|
|
143
|
+
if (typeof value === "string") value = value === "" || value === "[]" ? [] : value.split(",");
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
if (field.options) {
|
|
147
|
+
const match = item => field.options.find(option => String(option.key) === String(item))?.key ?? item;
|
|
148
|
+
return field.type === "array" && Array.isArray(value) ? value.map(match) : match(value);
|
|
149
|
+
}
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 校验支持的标量范围,包括文本长度与数值有限性。
|
|
155
|
+
* Validate supported scalar bounds, including text length and numeric finiteness.
|
|
156
|
+
* @param {unknown} value 待检查值 / Value to inspect.
|
|
157
|
+
* @returns {boolean} 是否为有效标量 / Whether the scalar is valid.
|
|
158
|
+
*/
|
|
159
|
+
function scalar(value) {
|
|
160
|
+
switch (typeof value) {
|
|
161
|
+
case "boolean":
|
|
162
|
+
return true;
|
|
163
|
+
case "string":
|
|
164
|
+
return value.length <= 2048;
|
|
165
|
+
case "number":
|
|
166
|
+
return Number.isFinite(value);
|
|
167
|
+
default:
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 检查值类型、数组唯一性及声明的选项,不进行转换。
|
|
174
|
+
* Check value type, array uniqueness and declared choices without coercion.
|
|
175
|
+
* @param {import("../index.js").SettingsField} field 前端归一化字段 / Normalized frontend field.
|
|
176
|
+
* @param {unknown} value 待写入的 JSON 值 / JSON value to write.
|
|
177
|
+
* @returns {boolean} 是否符合字段约束 / Whether the value satisfies field constraints.
|
|
178
|
+
*/
|
|
179
|
+
function validValue(field, value) {
|
|
180
|
+
if (field.type === "array") {
|
|
181
|
+
if (!Array.isArray(value) || value.some(item => !scalar(item)) || new Set(value).size !== value.length) return false;
|
|
182
|
+
} else if (typeof value !== field.type || !scalar(value)) return false;
|
|
183
|
+
return !field.options || (field.type === "array" ? value : [value]).every(item => field.options.some(option => option.key === item));
|
|
184
|
+
}
|
|
185
|
+
|
|
84
186
|
/**
|
|
85
187
|
* 创建元素,所有展示文本通过 textContent 写入。
|
|
86
188
|
* Create elements and assign display text through textContent only.
|
|
@@ -321,336 +423,104 @@ class ActionMenu {
|
|
|
321
423
|
}
|
|
322
424
|
|
|
323
425
|
/**
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
* @param {
|
|
327
|
-
* @
|
|
328
|
-
* @returns {import("../index.js").ModuleDefinition} 存储根和字段 / Storage root and fields.
|
|
329
|
-
* @throws {TypeError} 配置结构、字段路径、默认值或展示属性无效 / Invalid configuration, field path, default or presentation attribute.
|
|
330
|
-
*/
|
|
331
|
-
function normalizeBoxJs(config, module) {
|
|
332
|
-
validatePathParts([module]);
|
|
333
|
-
const catalog = config instanceof BoxJS ? config : new BoxJS(config);
|
|
334
|
-
const target = catalog.modules.get(module);
|
|
335
|
-
if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
|
|
336
|
-
const { entries, storageKey, metadata } = target;
|
|
337
|
-
const fields = [];
|
|
338
|
-
for (const entry of entries) {
|
|
339
|
-
const parts = entry.id.slice(1).split(".").slice(1);
|
|
340
|
-
const type = { boolean: "boolean", checkboxes: "array", selects: "select", text: "string", textarea: "string", number: "number" }[entry.type];
|
|
341
|
-
if (!type) throw new TypeError(`Unsupported BoxJS control: ${entry.type}`);
|
|
342
|
-
const field = {
|
|
343
|
-
key: parts.join("."),
|
|
344
|
-
type: type === "select" ? typeof entry.val : type,
|
|
345
|
-
|
|
346
|
-
name: entry.name,
|
|
347
|
-
description: entry.desc ?? "",
|
|
348
|
-
control: entry.type,
|
|
349
|
-
...(entry.placeholder === undefined ? {} : { placeholder: entry.placeholder }),
|
|
350
|
-
...(entry.rows === undefined ? {} : { rows: entry.rows }),
|
|
351
|
-
...(entry.autoGrow === undefined ? {} : { autoGrow: entry.autoGrow }),
|
|
352
|
-
};
|
|
353
|
-
if (type === "select" && !["string", "number", "boolean"].includes(field.type)) throw new TypeError(`Select requires a scalar val: ${entry.id}`);
|
|
354
|
-
if (entry.items) field.options = entry.items.map(item => ({ key: item.key, label: item.label }));
|
|
355
|
-
if (Object.hasOwn(entry, "val")) field.defaultValue = normalizeStoredValue(field, entry.val);
|
|
356
|
-
if (
|
|
357
|
-
typeof field.name !== "string" ||
|
|
358
|
-
(field.placeholder !== undefined && typeof field.placeholder !== "string") ||
|
|
359
|
-
(field.rows !== undefined && (!Number.isInteger(field.rows) || field.rows < 1)) ||
|
|
360
|
-
(field.autoGrow !== undefined && typeof field.autoGrow !== "boolean") ||
|
|
361
|
-
fields.some(other => other.key === field.key || other.key.startsWith(`${field.key}.`) || field.key.startsWith(`${other.key}.`))
|
|
362
|
-
)
|
|
363
|
-
throw new TypeError(`Invalid or overlapping BoxJS field: ${entry.id}`);
|
|
364
|
-
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}`);
|
|
365
|
-
if (Object.hasOwn(field, "defaultValue") && !validValue(field, field.defaultValue)) throw new TypeError(`Invalid BoxJS val: ${entry.id}`);
|
|
366
|
-
fields.push(field);
|
|
367
|
-
}
|
|
368
|
-
if (!fields.length) throw new TypeError(`No BoxJS settings for module: ${module}`);
|
|
369
|
-
const common = fields[0].key.split(".").slice(0, -1);
|
|
370
|
-
for (const field of fields) while (!field.key.startsWith(`${common.join(".")}.`)) common.pop();
|
|
371
|
-
return {
|
|
372
|
-
module,
|
|
373
|
-
storageKey,
|
|
374
|
-
fields,
|
|
375
|
-
settingsPath: common,
|
|
376
|
-
...(Object.keys(metadata).length ? { metadata } : {}),
|
|
377
|
-
};
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* 归一化 BoxJS 的字符串存储值,不改变普通文本内容。
|
|
382
|
-
* Normalize BoxJS string persistence without changing free-text values.
|
|
383
|
-
* @param {import("../index.js").SettingsField} field 前端字段约束 / Frontend field constraints.
|
|
384
|
-
* @param {unknown} value 存储值 / Stored value.
|
|
385
|
-
* @returns {unknown} 转换后的控件值;是否允许写入由 validValue 单独校验 / Converted control value; write eligibility is checked separately by validValue.
|
|
386
|
-
*/
|
|
387
|
-
function normalizeStoredValue(field, value) {
|
|
388
|
-
switch (field.type) {
|
|
389
|
-
case "boolean":
|
|
390
|
-
if (value === "true" || value === "false") return value === "true";
|
|
391
|
-
break;
|
|
392
|
-
case "number":
|
|
393
|
-
if (typeof value === "string" && value.trim() !== "") return Number(value);
|
|
394
|
-
break;
|
|
395
|
-
case "array":
|
|
396
|
-
if (typeof value === "string") value = value === "" || value === "[]" ? [] : value.split(",");
|
|
397
|
-
break;
|
|
398
|
-
}
|
|
399
|
-
if (field.options) {
|
|
400
|
-
const match = item => field.options.find(option => String(option.key) === String(item))?.key ?? item;
|
|
401
|
-
return field.type === "array" && Array.isArray(value) ? value.map(match) : match(value);
|
|
402
|
-
}
|
|
403
|
-
return value;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/**
|
|
407
|
-
* 校验支持的标量范围,包括文本长度与数值有限性。
|
|
408
|
-
* Validate supported scalar bounds, including text length and numeric finiteness.
|
|
409
|
-
* @param {unknown} value 待检查值 / Value to inspect.
|
|
410
|
-
* @returns {boolean} 是否为有效标量 / Whether the scalar is valid.
|
|
411
|
-
*/
|
|
412
|
-
function scalar(value) {
|
|
413
|
-
switch (typeof value) {
|
|
414
|
-
case "boolean":
|
|
415
|
-
return true;
|
|
416
|
-
case "string":
|
|
417
|
-
return value.length <= 2048;
|
|
418
|
-
case "number":
|
|
419
|
-
return Number.isFinite(value);
|
|
420
|
-
default:
|
|
421
|
-
return false;
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/**
|
|
426
|
-
* 检查值类型、数组唯一性及声明的选项,不进行转换。
|
|
427
|
-
* Check value type, array uniqueness and declared choices without coercion.
|
|
428
|
-
* @param {import("../index.js").SettingsField} field 前端归一化字段 / Normalized frontend field.
|
|
429
|
-
* @param {unknown} value 待写入的 JSON 值 / JSON value to write.
|
|
430
|
-
* @returns {boolean} 是否符合字段约束 / Whether the value satisfies field constraints.
|
|
431
|
-
*/
|
|
432
|
-
function validValue(field, value) {
|
|
433
|
-
if (field.type === "array") {
|
|
434
|
-
if (!Array.isArray(value) || value.some(item => !scalar(item)) || new Set(value).size !== value.length) return false;
|
|
435
|
-
} else if (typeof value !== field.type || !scalar(value)) return false;
|
|
436
|
-
return !field.options || (field.type === "array" ? value : [value]).every(item => field.options.some(option => option.key === item));
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/**
|
|
440
|
-
* 单个模块的临时会话;离开页面后丢弃。
|
|
441
|
-
* Transient module session discarded when leaving the page.
|
|
442
|
-
* @typedef {object} ModuleSession
|
|
443
|
-
* @property {AbortController} controller 读取请求的取消控制器 / Abort controller for reads.
|
|
444
|
-
* @property {import("../index.js").ModuleDefinition | null} definition 加载完成的配置,加载中为 null / Loaded configuration, or null while loading.
|
|
445
|
-
* @property {import("./client.mjs").ModuleSnapshot["values"]} values 当前显示值 / Current display values.
|
|
446
|
-
* @property {boolean} saving 是否正在写入 / Whether a mutation is in progress.
|
|
426
|
+
* 创建单模块页面客户端;只调用模块 API,不读取或解析 BoxJS。
|
|
427
|
+
* Create a single-module page client that only calls the module API and never reads or parses BoxJS.
|
|
428
|
+
* @param {import("./client.mjs").PreferencesClientOptions} options API 模型、请求与通知 / API model, requests and notifications.
|
|
429
|
+
* @returns {import("./client.mjs").PreferencesClient} 页面客户端 / Page client.
|
|
447
430
|
*/
|
|
431
|
+
function createPreferencesClient({ model, definition, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
|
|
432
|
+
const { module, configURL } = model;
|
|
433
|
+
const session = new AbortController();
|
|
434
|
+
const values = structuredClone(model.values);
|
|
435
|
+
let saving = false;
|
|
448
436
|
|
|
449
|
-
/**
|
|
450
|
-
* 创建页面会话缓存;打开时重读,选项操作仅在 HTTP 200 后更新缓存。
|
|
451
|
-
* Create a page-session cache; reload on open and mutate cache only after HTTP 200.
|
|
452
|
-
* @param {import("./client.mjs").PreferencesClientOptions} options 包内目录、请求与通知 / Internal catalog, requests and notifications.
|
|
453
|
-
* @returns {import("./client.mjs").PreferencesClient} 通用客户端 / Generic client.
|
|
454
|
-
*/
|
|
455
|
-
function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
|
|
456
|
-
/**
|
|
457
|
-
* 模块会话表
|
|
458
|
-
* Module session map.
|
|
459
|
-
* @type {Map<string, ModuleSession>}
|
|
460
|
-
*/
|
|
461
|
-
const sessions = new Map();
|
|
462
437
|
/**
|
|
463
|
-
*
|
|
464
|
-
* Send a
|
|
465
|
-
* @param {
|
|
466
|
-
* @param {
|
|
467
|
-
* @
|
|
468
|
-
* @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
|
|
469
|
-
* @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
|
|
470
|
-
* @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
|
|
438
|
+
* 向模块 API 发送 JSON 动作。
|
|
439
|
+
* Send a JSON action to the module API.
|
|
440
|
+
* @param {"get" | "set" | "delete"} action 模块动作 / Module action.
|
|
441
|
+
* @param {unknown} payload JSON 请求体 / JSON request body.
|
|
442
|
+
* @returns {Promise<Response>} 原始响应 / Raw response.
|
|
471
443
|
*/
|
|
472
|
-
async function send(
|
|
444
|
+
async function send(action, payload) {
|
|
473
445
|
const controller = new AbortController();
|
|
474
446
|
const abort = () => controller.abort();
|
|
475
|
-
if (signal
|
|
476
|
-
signal
|
|
447
|
+
if (session.signal.aborted) abort();
|
|
448
|
+
session.signal.addEventListener("abort", abort, { once: true });
|
|
477
449
|
const timer = setTimeout(abort, timeout);
|
|
478
450
|
try {
|
|
479
|
-
const response = await request(`/api/${action}`, {
|
|
451
|
+
const response = await request(`/api/${encodeURIComponent(module)}/${action}`, {
|
|
480
452
|
method: "POST",
|
|
481
453
|
credentials: "omit",
|
|
482
454
|
cache: "no-store",
|
|
483
455
|
signal: controller.signal,
|
|
484
|
-
headers: { "Content-Type": "application/
|
|
485
|
-
body:
|
|
456
|
+
headers: { "Content-Type": "application/json", "X-PreferencePanes-JSON": configURL },
|
|
457
|
+
body: JSON.stringify(payload),
|
|
486
458
|
});
|
|
487
459
|
if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
|
|
488
460
|
return response;
|
|
489
461
|
} finally {
|
|
490
462
|
clearTimeout(timer);
|
|
491
|
-
signal
|
|
463
|
+
session.signal.removeEventListener("abort", abort);
|
|
492
464
|
}
|
|
493
465
|
}
|
|
466
|
+
|
|
494
467
|
/**
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
* @param {
|
|
498
|
-
* @
|
|
499
|
-
* @
|
|
500
|
-
|
|
501
|
-
const snapshot = module => {
|
|
502
|
-
const state = sessions.get(module);
|
|
503
|
-
if (!state?.definition) throw new Error("Open the module first");
|
|
504
|
-
return structuredClone({ definition: state.definition, values: state.values });
|
|
505
|
-
};
|
|
506
|
-
/**
|
|
507
|
-
* 串行修改单键,仅成功后更新仍存活的会话。
|
|
508
|
-
* Serialize single-key mutations and update a still-active session only after success.
|
|
509
|
-
* @param {string} module 已打开模块 / Open module.
|
|
510
|
-
* @param {string} key 完整点分字段路径 / Complete dotted field path.
|
|
511
|
-
* @param {"set" | "delete"} action 写入或删除 / Write or delete.
|
|
512
|
-
* @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
|
|
513
|
-
* @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
|
|
468
|
+
* 执行写入动作;成功后只更新当前页面值。
|
|
469
|
+
* Execute a mutation and update only the current page values after success.
|
|
470
|
+
* @param {"set" | "delete"} action API 动作 / API action.
|
|
471
|
+
* @param {unknown} payload JSON 请求体 / JSON request body.
|
|
472
|
+
* @param {"write" | "delete" | "clearCaches" | "reset"} operation 通知操作 / Notification operation.
|
|
473
|
+
* @param {string} [key] 字段路径 / Field path.
|
|
514
474
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
515
|
-
* @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
|
|
516
475
|
*/
|
|
517
|
-
async function change(
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
if (state.saving) throw new Error("A settings write is already in progress");
|
|
521
|
-
const field = state.definition.fields.find(field => field.key === key);
|
|
522
|
-
state.saving = true;
|
|
476
|
+
async function change(action, payload, operation, key) {
|
|
477
|
+
if (saving) throw new Error("A settings write is already in progress");
|
|
478
|
+
saving = true;
|
|
523
479
|
try {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
for (const candidate of state.definition.fields) {
|
|
535
|
-
if (candidate.key !== key && !candidate.key.startsWith(`${key}.`)) continue;
|
|
536
|
-
delete state.values[candidate.key];
|
|
537
|
-
if (Object.hasOwn(candidate, "defaultValue")) state.values[candidate.key] = structuredClone(candidate.defaultValue);
|
|
538
|
-
}
|
|
539
|
-
break;
|
|
480
|
+
await send(action, payload);
|
|
481
|
+
switch (operation) {
|
|
482
|
+
case "write":
|
|
483
|
+
values[key] = structuredClone(payload.value);
|
|
484
|
+
break;
|
|
485
|
+
case "delete": {
|
|
486
|
+
const field = definition.fields.find(candidate => candidate.key === key);
|
|
487
|
+
delete values[key];
|
|
488
|
+
if (field && Object.hasOwn(field, "defaultValue")) values[key] = structuredClone(field.defaultValue);
|
|
489
|
+
break;
|
|
540
490
|
}
|
|
491
|
+
case "clearCaches":
|
|
492
|
+
break;
|
|
493
|
+
case "reset":
|
|
494
|
+
for (const field of definition.fields) {
|
|
495
|
+
delete values[field.key];
|
|
496
|
+
if (Object.hasOwn(field, "defaultValue")) values[field.key] = structuredClone(field.defaultValue);
|
|
497
|
+
}
|
|
498
|
+
break;
|
|
541
499
|
}
|
|
542
500
|
notify({ kind: "success", operation, module, key });
|
|
543
501
|
} catch (error) {
|
|
544
502
|
notify({ kind: "error", operation, module, key, message: error.message });
|
|
545
503
|
throw error;
|
|
546
504
|
} finally {
|
|
547
|
-
|
|
505
|
+
saving = false;
|
|
548
506
|
}
|
|
549
507
|
}
|
|
508
|
+
|
|
550
509
|
return {
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
* @param {string} module 模块标识 / Module identifier.
|
|
555
|
-
* @returns {Promise<import("./client.mjs").ModuleSnapshot>} 新快照 / New snapshot.
|
|
556
|
-
* @throws {Error} 读取失败、会话被替换或写入尚未完成 / Read failure, replaced session or unfinished write.
|
|
557
|
-
*/
|
|
558
|
-
async open(module) {
|
|
559
|
-
const binding = catalog.modules.get(module);
|
|
560
|
-
if (!binding) throw new TypeError(`No BoxJS settings for module: ${module}`);
|
|
561
|
-
const previous = sessions.get(module);
|
|
562
|
-
if (previous?.saving) throw new Error("Cannot refresh while saving");
|
|
563
|
-
previous?.controller.abort();
|
|
564
|
-
const state = { controller: new AbortController(), definition: null, values: {}, saving: false };
|
|
565
|
-
sessions.set(module, state);
|
|
566
|
-
try {
|
|
567
|
-
const definition = normalizeBoxJs(catalog, module);
|
|
568
|
-
const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
|
|
569
|
-
let subtree = response.status === 404 ? {} : await response.json();
|
|
570
|
-
if (typeof subtree === "string") subtree = JSON.parse(subtree);
|
|
571
|
-
if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
|
|
572
|
-
if (sessions.get(module) !== state) throw new Error("Module session was replaced");
|
|
573
|
-
state.definition = definition;
|
|
574
|
-
for (const field of definition.fields) {
|
|
575
|
-
const stored = field.key
|
|
576
|
-
.split(".")
|
|
577
|
-
.slice(definition.settingsPath.length)
|
|
578
|
-
.reduce((parent, part) => Object(parent)[part], subtree);
|
|
579
|
-
const value = stored === undefined ? field.defaultValue : stored;
|
|
580
|
-
if (value !== undefined) state.values[field.key] = normalizeStoredValue(field, value);
|
|
581
|
-
}
|
|
582
|
-
return snapshot(module);
|
|
583
|
-
} catch (error) {
|
|
584
|
-
if (sessions.get(module) === state) sessions.delete(module);
|
|
585
|
-
throw error;
|
|
586
|
-
}
|
|
587
|
-
},
|
|
588
|
-
snapshot,
|
|
589
|
-
/**
|
|
590
|
-
* 按需重新读取模块 Settings,不更新页面会话缓存。
|
|
591
|
-
* Reread module Settings on demand without updating the page-session cache.
|
|
592
|
-
* @param {string} module 已打开的模块 / Open module.
|
|
593
|
-
* @returns {Promise<unknown>} 设置值,缺失为 undefined / Settings value, or undefined when absent.
|
|
594
|
-
*/
|
|
595
|
-
async readSettings(module) {
|
|
596
|
-
const state = sessions.get(module);
|
|
597
|
-
if (!state?.definition) throw new Error("Open the module first");
|
|
598
|
-
const response = await send(`@${state.definition.storageKey}.${state.definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
|
|
510
|
+
snapshot: () => structuredClone({ definition, values }),
|
|
511
|
+
async readSettings() {
|
|
512
|
+
const response = await send("get", { scope: "settings" });
|
|
599
513
|
return response.status === 404 ? undefined : response.json();
|
|
600
514
|
},
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
* Read module Caches on demand without refreshing other settings.
|
|
604
|
-
* @param {string} module 已打开的模块 / Open module.
|
|
605
|
-
* @returns {Promise<unknown>} 缓存值,缺失为 undefined / Cache value, or undefined when absent.
|
|
606
|
-
*/
|
|
607
|
-
async readCaches(module) {
|
|
608
|
-
const state = sessions.get(module);
|
|
609
|
-
if (!state?.definition) throw new Error("Open the module first");
|
|
610
|
-
const response = await send(`@${state.definition.storageKey}.${module}.Caches`, "get", undefined, state.controller.signal);
|
|
515
|
+
async readCaches() {
|
|
516
|
+
const response = await send("get", { scope: "caches" });
|
|
611
517
|
return response.status === 404 ? undefined : response.json();
|
|
612
518
|
},
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
*/
|
|
619
|
-
clearCaches: module => change(module, `${module}.Caches`, "delete", undefined, "clearCaches"),
|
|
620
|
-
/**
|
|
621
|
-
* 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
|
|
622
|
-
* Delete module persistence and reset the page cache using current BoxJS defaults.
|
|
623
|
-
* @param {string} module 已打开模块 / Open module.
|
|
624
|
-
* @returns {Promise<void>} 重置完成 / Reset completion.
|
|
625
|
-
*/
|
|
626
|
-
reset: module => change(module, module, "delete", undefined, "reset"),
|
|
627
|
-
/**
|
|
628
|
-
* 取消读取并清除会话,不撤销已发送的写入。
|
|
629
|
-
* Abort reads and clear the session without undoing dispatched writes.
|
|
630
|
-
* @param {string} module 模块标识 / Module identifier.
|
|
631
|
-
* @returns {void} 无返回值 / No return value.
|
|
632
|
-
*/
|
|
633
|
-
leave(module) {
|
|
634
|
-
sessions.get(module)?.controller.abort();
|
|
635
|
-
sessions.delete(module);
|
|
636
|
-
},
|
|
637
|
-
/**
|
|
638
|
-
* 写入单键并更新当前会话。
|
|
639
|
-
* Write one key and update the current session.
|
|
640
|
-
* @param {string} module 已打开模块 / Open module.
|
|
641
|
-
* @param {string} key 点分字段路径 / Dotted field path.
|
|
642
|
-
* @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
|
|
643
|
-
* @returns {Promise<void>} 写入完成 / Write completion.
|
|
644
|
-
*/
|
|
645
|
-
set: (module, key, value) => change(module, key, "set", value),
|
|
646
|
-
/**
|
|
647
|
-
* 删除单键覆盖值并显示默认值。
|
|
648
|
-
* Delete one override and display its default value.
|
|
649
|
-
* @param {string} module 已打开模块 / Open module.
|
|
650
|
-
* @param {string} key 点分字段路径 / Dotted field path.
|
|
651
|
-
* @returns {Promise<void>} 删除完成 / Delete completion.
|
|
652
|
-
*/
|
|
653
|
-
remove: (module, key) => change(module, key, "delete"),
|
|
519
|
+
clearCaches: () => change("delete", { scope: "caches" }, "clearCaches"),
|
|
520
|
+
reset: () => change("delete", { scope: "module" }, "reset"),
|
|
521
|
+
leave: () => session.abort(),
|
|
522
|
+
set: (key, value) => change("set", { key, value }, "write", key),
|
|
523
|
+
remove: key => change("delete", { key }, "delete", key),
|
|
654
524
|
};
|
|
655
525
|
}
|
|
656
526
|
|
|
@@ -814,18 +684,19 @@ class Navigation extends EventTarget {
|
|
|
814
684
|
}
|
|
815
685
|
|
|
816
686
|
/**
|
|
817
|
-
*
|
|
818
|
-
* Mount the
|
|
687
|
+
* 挂载 API 返回的模块模型表单和短暂通知。
|
|
688
|
+
* Mount the module model returned by the API and transient notifications.
|
|
819
689
|
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
820
|
-
* @param {import("../
|
|
690
|
+
* @param {import("../index.js").ModuleModel} model API 返回的模块模型 / Module model returned by the API.
|
|
821
691
|
* @returns {import("./index.js").MountedPreferences} 面板生命周期句柄 / Panel lifecycle handle.
|
|
822
692
|
*/
|
|
823
|
-
function mountPanel(root,
|
|
824
|
-
const
|
|
693
|
+
function mountPanel(root, model) {
|
|
694
|
+
const { definition } = model;
|
|
695
|
+
const title = definition.metadata?.name ?? definition.module;
|
|
825
696
|
const document = root.ownerDocument;
|
|
826
697
|
const window = document.defaultView;
|
|
827
698
|
const shell = element("div", "pp-panel");
|
|
828
|
-
shell.dataset.module =
|
|
699
|
+
shell.dataset.module = definition.module;
|
|
829
700
|
const header = element("header", "pp-header");
|
|
830
701
|
const back = element("button", "pp-back", "‹");
|
|
831
702
|
back.setAttribute("aria-label", "返回");
|
|
@@ -855,7 +726,7 @@ function mountPanel(root, catalog) {
|
|
|
855
726
|
if (!frame?.dataset.preferencePanes) return;
|
|
856
727
|
frame.dispatchEvent(
|
|
857
728
|
new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
|
|
858
|
-
detail: { title: heading.textContent, module:
|
|
729
|
+
detail: { title: heading.textContent, module: definition.module, busy: saving, canGoBack: !back.disabled, actions },
|
|
859
730
|
}),
|
|
860
731
|
);
|
|
861
732
|
};
|
|
@@ -912,7 +783,7 @@ function mountPanel(root, catalog) {
|
|
|
912
783
|
toast.hidden = true;
|
|
913
784
|
}, 2400);
|
|
914
785
|
};
|
|
915
|
-
const client = createPreferencesClient({
|
|
786
|
+
const client = createPreferencesClient({ model, definition, notify });
|
|
916
787
|
/**
|
|
917
788
|
* 两种菜单入口共用异步错误处理,包含宿主确认框错误。
|
|
918
789
|
* Share async error handling between both menus, including host-dialog errors.
|
|
@@ -940,7 +811,6 @@ function mountPanel(root, catalog) {
|
|
|
940
811
|
publishNavigation();
|
|
941
812
|
viewport.replaceChildren(statusView("读取设置…"));
|
|
942
813
|
try {
|
|
943
|
-
await client.open(module);
|
|
944
814
|
if (version === generation) controls();
|
|
945
815
|
} catch (error) {
|
|
946
816
|
if (version !== generation) return;
|
|
@@ -954,7 +824,7 @@ function mountPanel(root, catalog) {
|
|
|
954
824
|
* @returns {void} 无返回值 / No return value.
|
|
955
825
|
*/
|
|
956
826
|
function controls() {
|
|
957
|
-
const { definition, values } = client.snapshot(
|
|
827
|
+
const { definition, values } = client.snapshot();
|
|
958
828
|
heading.textContent = definition.metadata?.name || active;
|
|
959
829
|
const view = element("section", "pp-fields");
|
|
960
830
|
/**
|
|
@@ -992,7 +862,7 @@ function mountPanel(root, catalog) {
|
|
|
992
862
|
saving = true;
|
|
993
863
|
back.disabled = true;
|
|
994
864
|
publishNavigation();
|
|
995
|
-
|
|
865
|
+
queue = queue
|
|
996
866
|
.then(action)
|
|
997
867
|
.then(() => {
|
|
998
868
|
if (!destroyed) success();
|
|
@@ -1005,10 +875,11 @@ function mountPanel(root, catalog) {
|
|
|
1005
875
|
.finally(() => {
|
|
1006
876
|
pendingWrites--;
|
|
1007
877
|
saving = pendingWrites > 0;
|
|
1008
|
-
if (destroyed && !saving) client.leave(
|
|
878
|
+
if (destroyed && !saving) client.leave();
|
|
1009
879
|
back.disabled = saving || !navigation.canGoBack;
|
|
1010
880
|
publishNavigation();
|
|
1011
|
-
})
|
|
881
|
+
});
|
|
882
|
+
return queue;
|
|
1012
883
|
}
|
|
1013
884
|
const metadata = definition.metadata;
|
|
1014
885
|
if (metadata) {
|
|
@@ -1086,7 +957,7 @@ function mountPanel(root, catalog) {
|
|
|
1086
957
|
link.append(summary, element("span", "pp-chevron", "›"));
|
|
1087
958
|
row.append(link);
|
|
1088
959
|
const refresh = () => {
|
|
1089
|
-
const value = client.snapshot(
|
|
960
|
+
const value = client.snapshot().values[field.key];
|
|
1090
961
|
summary.textContent =
|
|
1091
962
|
field.options
|
|
1092
963
|
.filter(option => Array.isArray(value) && value.includes(option.key))
|
|
@@ -1177,8 +1048,7 @@ function mountPanel(root, catalog) {
|
|
|
1177
1048
|
let inputVersion = 0;
|
|
1178
1049
|
inputContainer.addEventListener(eventName, event => {
|
|
1179
1050
|
if (event.isComposing) return;
|
|
1180
|
-
const version = ++inputVersion
|
|
1181
|
-
module = active;
|
|
1051
|
+
const version = ++inputVersion;
|
|
1182
1052
|
let value;
|
|
1183
1053
|
try {
|
|
1184
1054
|
value = read();
|
|
@@ -1187,10 +1057,17 @@ function mountPanel(root, catalog) {
|
|
|
1187
1057
|
return;
|
|
1188
1058
|
}
|
|
1189
1059
|
const restore = () => {
|
|
1190
|
-
if (version === inputVersion) write(client.snapshot(
|
|
1060
|
+
if (version === inputVersion) write(client.snapshot().values[field.key]);
|
|
1191
1061
|
};
|
|
1192
1062
|
perform(
|
|
1193
|
-
() =>
|
|
1063
|
+
() => {
|
|
1064
|
+
if (!validValue(field, value)) {
|
|
1065
|
+
const error = new TypeError("Invalid setting value");
|
|
1066
|
+
notify({ kind: "error", operation: "write", key: field.key, message: error.message });
|
|
1067
|
+
throw error;
|
|
1068
|
+
}
|
|
1069
|
+
return client.set(field.key, value);
|
|
1070
|
+
},
|
|
1194
1071
|
() => {
|
|
1195
1072
|
for (const refresh of summaries) refresh();
|
|
1196
1073
|
},
|
|
@@ -1211,7 +1088,7 @@ function mountPanel(root, catalog) {
|
|
|
1211
1088
|
return perform(
|
|
1212
1089
|
async () => {
|
|
1213
1090
|
try {
|
|
1214
|
-
value = await client.readSettings(
|
|
1091
|
+
value = await client.readSettings();
|
|
1215
1092
|
} catch (error) {
|
|
1216
1093
|
notify({ kind: "error", message: error.message });
|
|
1217
1094
|
throw error;
|
|
@@ -1235,7 +1112,7 @@ function mountPanel(root, catalog) {
|
|
|
1235
1112
|
return perform(
|
|
1236
1113
|
async () => {
|
|
1237
1114
|
try {
|
|
1238
|
-
value = await client.readCaches(
|
|
1115
|
+
value = await client.readCaches();
|
|
1239
1116
|
} catch (error) {
|
|
1240
1117
|
notify({ kind: "error", message: error.message });
|
|
1241
1118
|
throw error;
|
|
@@ -1251,7 +1128,7 @@ function mountPanel(root, catalog) {
|
|
|
1251
1128
|
if (saving) return;
|
|
1252
1129
|
if (!(await requestConfirmation(window, `清空 ${active} 的全部 Caches?`)) || destroyed || saving) return;
|
|
1253
1130
|
return perform(
|
|
1254
|
-
() => client.clearCaches(
|
|
1131
|
+
() => client.clearCaches(),
|
|
1255
1132
|
() => {
|
|
1256
1133
|
output.textContent = "暂无缓存";
|
|
1257
1134
|
},
|
|
@@ -1260,7 +1137,7 @@ function mountPanel(root, catalog) {
|
|
|
1260
1137
|
handlers.set("reset", async () => {
|
|
1261
1138
|
if (saving) return;
|
|
1262
1139
|
if (!(await requestConfirmation(window, `重置 ${active} 的设置?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) || destroyed || saving) return;
|
|
1263
|
-
return perform(() => client.reset(
|
|
1140
|
+
return perform(() => client.reset(), controls);
|
|
1264
1141
|
});
|
|
1265
1142
|
navigation?.destroy();
|
|
1266
1143
|
navigation = new Navigation(viewport, view, key => editors.get(key)?.node);
|
|
@@ -1278,7 +1155,7 @@ function mountPanel(root, catalog) {
|
|
|
1278
1155
|
if (navigation) navigation.back();
|
|
1279
1156
|
else window.history.back();
|
|
1280
1157
|
};
|
|
1281
|
-
open(
|
|
1158
|
+
open(definition.module);
|
|
1282
1159
|
return {
|
|
1283
1160
|
/**
|
|
1284
1161
|
* 移除监听器、定时器、会话和挂载内容。
|
|
@@ -1291,7 +1168,7 @@ function mountPanel(root, catalog) {
|
|
|
1291
1168
|
window.frameElement?.removeEventListener("preferencepanes:action", onAction);
|
|
1292
1169
|
navigation?.destroy();
|
|
1293
1170
|
generation++;
|
|
1294
|
-
if (active && !saving) client.leave(
|
|
1171
|
+
if (active && !saving) client.leave();
|
|
1295
1172
|
clearTimeout(timer);
|
|
1296
1173
|
shell.remove();
|
|
1297
1174
|
},
|
|
@@ -1321,14 +1198,22 @@ function installDefaultStyles(document) {
|
|
|
1321
1198
|
/**
|
|
1322
1199
|
* 挂载模块设置页;默认样式由包提供,可选 CSS 仅作用于当前模块。
|
|
1323
1200
|
* Mount a module page with package defaults and optional module-scoped CSS.
|
|
1324
|
-
* @param {import("../index.js").
|
|
1201
|
+
* @param {import("../index.js").ModuleModel} model API 返回的模块模型 / Module model returned by the API.
|
|
1325
1202
|
* @param {string} [css] 可选 CSS 正文 / Optional CSS text.
|
|
1326
1203
|
* @returns {import("./index.js").MountedPreferences} 模块生命周期句柄 / Module lifecycle handle.
|
|
1327
1204
|
*/
|
|
1328
|
-
function mount(
|
|
1205
|
+
function mount(model, css = "") {
|
|
1329
1206
|
if (typeof css !== "string") throw new TypeError("CSS must be a string");
|
|
1330
|
-
const
|
|
1331
|
-
const
|
|
1207
|
+
const definition = normalizeBoxJs(model.boxjs, model.module);
|
|
1208
|
+
const values = { ...model.values };
|
|
1209
|
+
for (const field of definition.fields) {
|
|
1210
|
+
if (values[field.key] === undefined) continue;
|
|
1211
|
+
values[field.key] = normalizeStoredValue(field, values[field.key]);
|
|
1212
|
+
if (!validValue(field, values[field.key])) throw new TypeError(`Invalid stored value: ${field.key}`);
|
|
1213
|
+
}
|
|
1214
|
+
for (const field of definition.fields) if (values[field.key] === undefined && Object.hasOwn(field, "defaultValue")) values[field.key] = structuredClone(field.defaultValue);
|
|
1215
|
+
const rendered = { ...model, definition, values };
|
|
1216
|
+
const metadata = definition.metadata ?? {};
|
|
1332
1217
|
const image = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
|
|
1333
1218
|
if (image) resourceURL(image);
|
|
1334
1219
|
if (metadata.repo) resourceURL(metadata.repo);
|
|
@@ -1364,7 +1249,7 @@ function mount(boxjs, css = "") {
|
|
|
1364
1249
|
observer = new MutationObserver(syncAppearance);
|
|
1365
1250
|
observer.observe(host, { attributes: true, attributeFilter: ["data-theme", "style"] });
|
|
1366
1251
|
}
|
|
1367
|
-
document.title = metadata.name ??
|
|
1252
|
+
document.title = metadata.name ?? definition.module;
|
|
1368
1253
|
let panel;
|
|
1369
1254
|
const view = {
|
|
1370
1255
|
/**
|
|
@@ -1388,7 +1273,7 @@ function mount(boxjs, css = "") {
|
|
|
1388
1273
|
};
|
|
1389
1274
|
try {
|
|
1390
1275
|
root.replaceChildren();
|
|
1391
|
-
panel = mountPanel(root,
|
|
1276
|
+
panel = mountPanel(root, rendered);
|
|
1392
1277
|
return view;
|
|
1393
1278
|
} catch (error) {
|
|
1394
1279
|
view.destroy();
|