@nsnanocat/preference-panes 1.1.0 → 1.1.2

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.
@@ -147,26 +147,8 @@ class ActionMenu {
147
147
  }
148
148
 
149
149
  /**
150
- * 统一解析模块页的资源地址:Header 优先于查询参数,再使用模块约定。
151
- * Resolve module resource locations: headers override query parameters and module conventions.
152
- * @param {URL} url 已解析的页面请求地址 / Parsed page request URL.
153
- * @param {Record<string, string | undefined>} [headers] 请求头,名称不区分大小写 / Case-insensitive request headers.
154
- * @returns {{url: string, module: string, json: string, css: string}} 页面上下文与两个资源输入 / Page context and two resource inputs.
155
- */
156
- function pageInputs(url, headers = {}) {
157
- const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(url.pathname);
158
- if (!match) throw new TypeError("Open a concrete module URL");
159
- const module = match[1];
160
- const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
161
- const json = values["x-preferencepanes-json"] ?? url.searchParams.get("json") ?? `/configs/${module}`;
162
- const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? "";
163
- if (!json.trim()) throw new TypeError("JSON resource URL is required");
164
- return { url: url.href, module, json, css };
165
- }
166
-
167
- /**
168
- * 模块文档容器:原始 HTML 不改写,请求上下文随 iframe 元素传递。
169
- * Module document container: preserve HTML verbatim and carry request context on the iframe element.
150
+ * 模块文档容器:原始 HTML 不改写,只向 iframe 标记模块身份。
151
+ * Module document container: preserve HTML verbatim and mark only the module identity on the iframe.
170
152
  */
171
153
  class ModuleFrame extends EventTarget {
172
154
  #url;
@@ -188,23 +170,25 @@ class ModuleFrame extends EventTarget {
188
170
  };
189
171
 
190
172
  /**
191
- * 建立 iframe 与请求输入;调用方挂载 element 后调用 load。
192
- * Create the iframe and request inputs; callers mount element and then call load.
173
+ * 建立 iframe;调用方挂载 element 后调用 load。
174
+ * Create the iframe; callers mount element and then call load.
193
175
  * @param {string | URL} url 模块请求地址 / Module request URL.
194
- * @param {RequestInit} [options] 原生请求头和取消信号 / Native headers and cancellation signal.
176
+ * @param {{signal?: AbortSignal}} [options] 外部取消信号 / External cancellation signal.
195
177
  */
196
178
  constructor(url, options = {}) {
197
179
  super();
198
180
  this.#url = new URL(url, document.baseURI);
199
- this.#options = { ...options, headers: new Headers(options.headers) };
200
- const inputs = pageInputs(this.#url, Object.fromEntries(this.#options.headers));
181
+ const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(this.#url.pathname);
182
+ if (!match) throw new TypeError("Open a concrete module URL");
183
+ this.#options = options;
201
184
  this.element = document.createElement("iframe");
202
- this.element.title = `${inputs.module} 设置`;
203
- this.element.dataset.preferencePanes = JSON.stringify(inputs);
185
+ this.element.title = `${match[1]} 设置`;
186
+ this.element.dataset.preferencePanes = "true";
187
+ this.element.dataset.preferencePanesModule = match[1];
204
188
  this.element.addEventListener("preferencepanes:change", this.#change);
205
189
  this.element.addEventListener("preferencepanes:confirm", this.#confirmation);
206
190
  this.element.addEventListener("preferencepanes:notice", this.#notice);
207
- this.#state = { title: inputs.module, module: inputs.module, busy: false, canGoBack: true, actions: [] };
191
+ this.#state = { title: match[1], module: match[1], busy: false, canGoBack: true, actions: [] };
208
192
  options.signal?.addEventListener("abort", this.#abort, { once: true });
209
193
  }
210
194
 
@@ -225,7 +209,7 @@ class ModuleFrame extends EventTarget {
225
209
  if (this.#options.signal?.aborted) this.destroy();
226
210
  const timer = setTimeout(() => this.#controller.abort(), 10000);
227
211
  try {
228
- const response = await fetch(this.#url, { cache: "no-store", credentials: "omit", ...this.#options, signal: this.#controller.signal });
212
+ const response = await fetch(this.#url, { cache: "no-store", credentials: "omit", signal: this.#controller.signal });
229
213
  if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
230
214
  const html = await response.text();
231
215
  this.#controller.signal.throwIfAborted();
@@ -275,7 +259,6 @@ class ModuleFrame extends EventTarget {
275
259
  * @typedef {object} ModuleProbeOptions
276
260
  * @property {typeof globalThis.fetch} [fetch] 可注入的 fetch / Injectable fetch.
277
261
  * @property {AbortSignal} [signal] 外部取消信号 / External cancellation signal.
278
- * @property {string} [json] BoxJS JSON 来源,将随探测请求头传递 / BoxJS JSON source sent in the probe header.
279
262
  * @property {number} [timeout] 超时毫秒数,默认 3500 / Timeout in milliseconds, defaults to 3500.
280
263
  */
281
264
 
@@ -286,14 +269,14 @@ class ModuleFrame extends EventTarget {
286
269
  * @param {ModuleProbeOptions} [options] 请求选项 / Request options.
287
270
  * @returns {Promise<Response>} 原始 HTTP 响应,可直接读取 status 和响应头 / Native HTTP response; read status and headers directly.
288
271
  */
289
- async function probeModule(url, { fetch: request = globalThis.fetch, json, signal, timeout = 3500 } = {}) {
272
+ async function probeModule(url, { fetch: request = globalThis.fetch, signal, timeout = 3500 } = {}) {
290
273
  const controller = new AbortController();
291
274
  const abort = () => controller.abort();
292
275
  if (signal?.aborted) abort();
293
276
  signal?.addEventListener("abort", abort, { once: true });
294
277
  const timer = setTimeout(() => controller.abort(), timeout);
295
278
  try {
296
- return await request(url, { method: "HEAD", cache: "no-store", credentials: "omit", signal: controller.signal, headers: json ? { "X-PreferencePanes-JSON": json } : undefined });
279
+ return await request(url, { method: "HEAD", cache: "no-store", credentials: "omit", signal: controller.signal });
297
280
  } finally {
298
281
  clearTimeout(timer);
299
282
  signal?.removeEventListener("abort", abort);
@@ -45,8 +45,8 @@ function normalizeBoxJs(config, module) {
45
45
  target.owners.add(app);
46
46
  }
47
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);
48
+ if (modules.size !== 1) throw new TypeError("Import BoxJS JSON for exactly one module");
49
+ const target = modules.values().next().value ;
50
50
  if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
51
51
  const metadata = normalizeMetadata(target.owners.size === 1 ? presentation([...target.owners][0]) : {});
52
52
  const fields = [];
@@ -428,28 +428,50 @@ class ActionMenu {
428
428
  */
429
429
  class PreferencesClient {
430
430
  #module;
431
- #configURL;
432
431
  #definition;
433
432
  #request;
434
433
  #notify;
435
434
  #timeout;
436
435
  #session = new AbortController();
437
- #values;
436
+ #values = {};
438
437
  #saving = false;
439
438
 
440
439
  /**
441
- * 创建只调用模块 API、不读取或解析 BoxJS 的页面客户端。
442
- * Create a page client that only calls the module API and never reads or parses BoxJS.
443
- * @param {import("./client.mjs").PreferencesClientOptions} options API 模型、请求与通知 / API model, requests, and notifications.
440
+ * 创建从 BoxJS 定义读取和持久化设置的页面客户端。
441
+ * Create a page client that reads and persists settings from a BoxJS definition.
442
+ * @param {import("./client.mjs").PreferencesClientOptions} options 字段定义、请求与通知 / Field definition, requests, and notifications.
444
443
  */
445
- constructor({ model, definition, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
446
- this.#module = model.module;
447
- this.#configURL = model.configURL;
444
+ constructor({ definition, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
445
+ this.#module = definition.module;
448
446
  this.#definition = definition;
449
447
  this.#request = request;
450
448
  this.#notify = notify;
451
449
  this.#timeout = timeout;
452
- this.#values = structuredClone(model.values);
450
+ }
451
+
452
+ /**
453
+ * 读取一次 Settings 子树并建立页面值快照。
454
+ * Read the Settings subtree once and establish the page value snapshot.
455
+ * @returns {Promise<import("./client.mjs").ModuleSnapshot>} 页面快照 / Page snapshot.
456
+ */
457
+ async open() {
458
+ let subtree = await this.readSettings();
459
+ if (subtree === undefined) subtree = {};
460
+ if (typeof subtree === "string") subtree = JSON.parse(subtree);
461
+ if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
462
+ const values = {};
463
+ for (const field of this.#definition.fields) {
464
+ const stored = field.key
465
+ .split(".")
466
+ .slice(this.#definition.settingsPath.length)
467
+ .reduce((parent, part) => Object(parent)[part], subtree);
468
+ const value = normalizeStoredValue(field, stored === undefined ? field.defaultValue : stored);
469
+ if (value === undefined) continue;
470
+ if (!validValue(field, value)) throw new TypeError(`Invalid stored value: ${field.key}`);
471
+ values[field.key] = value;
472
+ }
473
+ this.#values = values;
474
+ return this.snapshot();
453
475
  }
454
476
 
455
477
  /**
@@ -467,7 +489,7 @@ class PreferencesClient {
467
489
  * @returns {Promise<unknown>} Settings 内容或 undefined / Settings content or undefined.
468
490
  */
469
491
  async readSettings() {
470
- const response = await this.#send("get", { scope: "settings" });
492
+ const response = await this.#send("get", `@${this.#definition.storageKey}.${this.#definition.settingsPath.join(".")}`);
471
493
  return response.status === 404 ? undefined : response.json();
472
494
  }
473
495
 
@@ -477,7 +499,7 @@ class PreferencesClient {
477
499
  * @returns {Promise<unknown>} Caches 内容或 undefined / Caches content or undefined.
478
500
  */
479
501
  async readCaches() {
480
- const response = await this.#send("get", { scope: "caches" });
502
+ const response = await this.#send("get", `@${this.#definition.storageKey}.${this.#module}.Caches`);
481
503
  return response.status === 404 ? undefined : response.json();
482
504
  }
483
505
 
@@ -487,7 +509,7 @@ class PreferencesClient {
487
509
  * @returns {Promise<void>} 操作完成 / Operation completion.
488
510
  */
489
511
  clearCaches() {
490
- return this.#change("delete", { scope: "caches" }, "clearCaches");
512
+ return this.#change("delete", `${this.#module}.Caches`, undefined, "clearCaches");
491
513
  }
492
514
 
493
515
  /**
@@ -496,7 +518,7 @@ class PreferencesClient {
496
518
  * @returns {Promise<void>} 操作完成 / Operation completion.
497
519
  */
498
520
  reset() {
499
- return this.#change("delete", { scope: "module" }, "reset");
521
+ return this.#change("delete", this.#module, undefined, "reset");
500
522
  }
501
523
 
502
524
  /**
@@ -516,7 +538,7 @@ class PreferencesClient {
516
538
  * @returns {Promise<void>} 操作完成 / Operation completion.
517
539
  */
518
540
  set(key, value) {
519
- return this.#change("set", { key, value }, "write", key);
541
+ return this.#change("set", key, value, "write");
520
542
  }
521
543
 
522
544
  /**
@@ -526,30 +548,31 @@ class PreferencesClient {
526
548
  * @returns {Promise<void>} 操作完成 / Operation completion.
527
549
  */
528
550
  remove(key) {
529
- return this.#change("delete", { key }, "delete", key);
551
+ return this.#change("delete", key, undefined, "delete");
530
552
  }
531
553
 
532
554
  /**
533
- * 向模块 API 发送 JSON 动作。
534
- * Send a JSON action to the module API.
535
- * @param {"get" | "set" | "delete"} action 模块动作 / Module action.
536
- * @param {unknown} payload JSON 请求体 / JSON request body.
555
+ * 向固定存储 API 发送完整路径的 form 动作。
556
+ * Send a complete-path form action to the fixed storage API.
557
+ * @param {"get" | "set" | "delete"} action 存储动作 / Storage action.
558
+ * @param {string} path 完整 @root.path / Complete @root.path.
559
+ * @param {unknown} [value] set 写入值 / Value written by set.
537
560
  * @returns {Promise<Response>} 原始响应 / Raw response.
538
561
  */
539
- async #send(action, payload) {
562
+ async #send(action, path, value) {
540
563
  const controller = new AbortController();
541
564
  const abort = () => controller.abort();
542
565
  if (this.#session.signal.aborted) abort();
543
566
  this.#session.signal.addEventListener("abort", abort, { once: true });
544
567
  const timer = setTimeout(abort, this.#timeout);
545
568
  try {
546
- const response = await this.#request(`/api/${encodeURIComponent(this.#module)}/${action}`, {
569
+ const response = await this.#request(`/api/${action}`, {
547
570
  method: "POST",
548
571
  credentials: "omit",
549
572
  cache: "no-store",
550
573
  signal: controller.signal,
551
- headers: { "Content-Type": "application/json", "X-PreferencePanes-JSON": this.#configURL },
552
- body: JSON.stringify(payload),
574
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
575
+ body: new URLSearchParams([[path, action === "set" ? JSON.stringify(value) : ""]]).toString(),
553
576
  });
554
577
  if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
555
578
  return response;
@@ -563,24 +586,28 @@ class PreferencesClient {
563
586
  * 执行写入动作;成功后只更新当前页面值。
564
587
  * Execute a mutation and update only the current page values after success.
565
588
  * @param {"set" | "delete"} action API 动作 / API action.
566
- * @param {Record<string, unknown>} payload JSON 请求体 / JSON request body.
589
+ * @param {string} key 不含存储根的路径 / Path without the storage root.
590
+ * @param {unknown} value set 写入值 / Value written by set.
567
591
  * @param {"write" | "delete" | "clearCaches" | "reset"} operation 通知操作 / Notification operation.
568
- * @param {string} [key] 字段路径 / Field path.
569
592
  * @returns {Promise<void>} 操作完成 / Operation completion.
570
593
  */
571
- async #change(action, payload, operation, key) {
594
+ async #change(action, key, value, operation) {
572
595
  if (this.#saving) throw new Error("A settings write is already in progress");
573
596
  this.#saving = true;
574
597
  try {
575
- await this.#send(action, payload);
598
+ let field;
599
+ if (operation === "write" || operation === "delete") {
600
+ field = this.#definition.fields.find(candidate => candidate.key === key);
601
+ if (!field || (operation === "write" && !validValue(field, value))) throw new TypeError("Invalid setting value");
602
+ }
603
+ await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
576
604
  switch (operation) {
577
605
  case "write":
578
- this.#values[key] = structuredClone(payload.value);
606
+ this.#values[key] = structuredClone(value);
579
607
  break;
580
608
  case "delete": {
581
- const field = this.#definition.fields.find(candidate => candidate.key === key);
582
609
  delete this.#values[key];
583
- if (field && Object.hasOwn(field, "defaultValue")) this.#values[key] = structuredClone(field.defaultValue);
610
+ if (Object.hasOwn(field, "defaultValue")) this.#values[key] = structuredClone(field.defaultValue);
584
611
  break;
585
612
  }
586
613
  case "clearCaches":
@@ -769,24 +796,23 @@ class PreferencesPanel {
769
796
  #release;
770
797
 
771
798
  /**
772
- * 挂载 API 返回的模块模型表单。
773
- * Mount the module form returned by the API.
799
+ * 挂载 BoxJS 定义对应的模块表单。
800
+ * Mount the module form described by a BoxJS definition.
774
801
  * @param {HTMLElement} root 包内挂载元素 / Internal mount element.
775
- * @param {import("../index.js").ModuleModel & {definition: import("../index.js").ModuleDefinition}} model 已规范化模块模型 / Normalized module model.
802
+ * @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
776
803
  */
777
- constructor(root, model) {
778
- this.#release = this.#mount(root, model);
804
+ constructor(root, definition) {
805
+ this.#release = this.#mount(root, definition);
779
806
  }
780
807
 
781
808
  /**
782
809
  * 建立面板 DOM、交互和会话,并返回其释放操作。
783
810
  * Build panel DOM, interactions, and session, then return its release operation.
784
811
  * @param {HTMLElement} root 包内挂载元素 / Internal mount element.
785
- * @param {import("../index.js").ModuleModel & {definition: import("../index.js").ModuleDefinition}} model 已规范化模块模型 / Normalized module model.
812
+ * @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
786
813
  * @returns {() => void} 释放操作 / Release operation.
787
814
  */
788
- #mount(root, model) {
789
- const { definition } = model;
815
+ #mount(root, definition) {
790
816
  const title = definition.metadata?.name ?? definition.module;
791
817
  const document = root.ownerDocument;
792
818
  const window = document.defaultView;
@@ -878,7 +904,7 @@ class PreferencesPanel {
878
904
  toast.hidden = true;
879
905
  }, 2400);
880
906
  };
881
- const client = new PreferencesClient({ model, definition, notify });
907
+ const client = new PreferencesClient({ definition, notify });
882
908
  /**
883
909
  * 两种菜单入口共用异步错误处理,包含宿主确认框错误。
884
910
  * Share async error handling between both menus, including host-dialog errors.
@@ -906,6 +932,7 @@ class PreferencesPanel {
906
932
  publishNavigation();
907
933
  viewport.replaceChildren(statusView("读取设置…"));
908
934
  try {
935
+ await client.open();
909
936
  if (version === generation) controls();
910
937
  } catch (error) {
911
938
  if (version !== generation) return;
@@ -1294,15 +1321,14 @@ function installDefaultStyles(document) {
1294
1321
  }
1295
1322
 
1296
1323
  /**
1297
- * 管理模块设置视图的模型规范化、样式、主题同步和面板生命周期。
1298
- * Manage model normalization, styles, theme synchronization, and panel lifecycle for a module settings view.
1324
+ * 管理 BoxJS 规范化、主题同步和面板生命周期。
1325
+ * Manage BoxJS normalization, theme synchronization, and panel lifecycle.
1299
1326
  */
1300
1327
  class PreferencesView {
1301
1328
  #existing;
1302
1329
  #root;
1303
1330
  #base;
1304
1331
  #ownsBase;
1305
- #custom;
1306
1332
  #previousTitle;
1307
1333
  #previousTheme;
1308
1334
  #systemTheme;
@@ -1312,22 +1338,12 @@ class PreferencesView {
1312
1338
  #panel;
1313
1339
 
1314
1340
  /**
1315
- * 使用模块 API 返回的模型挂载设置页。
1316
- * Mount a settings page from the model returned by the module API.
1317
- * @param {import("../index.js").ModuleModel} model API 返回的模块模型 / Module model returned by the API.
1318
- * @param {string} [css] 可选 CSS 正文 / Optional module-scoped CSS text.
1341
+ * 使用原始 BoxJS JSON 挂载设置页。
1342
+ * Mount a settings page from raw BoxJS JSON.
1343
+ * @param {import("../index.js").BoxJSInput} boxjs 单模块 BoxJS JSON / Single-module BoxJS JSON.
1319
1344
  */
1320
- constructor(model, css = "") {
1321
- if (typeof css !== "string") throw new TypeError("CSS must be a string");
1322
- const definition = normalizeBoxJs(model.boxjs, model.module);
1323
- const values = { ...model.values };
1324
- for (const field of definition.fields) {
1325
- if (values[field.key] === undefined) continue;
1326
- values[field.key] = normalizeStoredValue(field, values[field.key]);
1327
- if (!validValue(field, values[field.key])) throw new TypeError(`Invalid stored value: ${field.key}`);
1328
- }
1329
- for (const field of definition.fields) if (values[field.key] === undefined && Object.hasOwn(field, "defaultValue")) values[field.key] = structuredClone(field.defaultValue);
1330
- const rendered = { ...model, definition, values };
1345
+ constructor(boxjs) {
1346
+ const definition = normalizeBoxJs(boxjs);
1331
1347
  const metadata = definition.metadata ?? {};
1332
1348
  const image = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
1333
1349
  if (image) resourceURL(image);
@@ -1342,9 +1358,6 @@ class PreferencesView {
1342
1358
  const styles = installDefaultStyles(document);
1343
1359
  this.#base = styles.element;
1344
1360
  this.#ownsBase = styles.owned;
1345
- this.#custom = element("style", "");
1346
- this.#custom.textContent = css;
1347
- document.head.append(this.#custom);
1348
1361
  this.#previousTitle = document.title;
1349
1362
  this.#previousTheme = document.documentElement.dataset.theme;
1350
1363
  this.#systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
@@ -1359,7 +1372,7 @@ class PreferencesView {
1359
1372
  document.title = metadata.name ?? definition.module;
1360
1373
  try {
1361
1374
  this.#root.replaceChildren();
1362
- this.#panel = new PreferencesPanel(this.#root, rendered);
1375
+ this.#panel = new PreferencesPanel(this.#root, definition);
1363
1376
  } catch (error) {
1364
1377
  this.destroy();
1365
1378
  throw error;
@@ -1387,7 +1400,6 @@ class PreferencesView {
1387
1400
  this.#systemTheme.removeEventListener("change", this.#syncAppearance);
1388
1401
  this.#panel?.destroy();
1389
1402
  if (this.#ownsBase) this.#base.remove();
1390
- this.#custom.remove();
1391
1403
  if (this.#existing) this.#root.replaceChildren();
1392
1404
  else this.#root.remove();
1393
1405
  document.title = this.#previousTitle;
@@ -1398,14 +1410,13 @@ class PreferencesView {
1398
1410
  }
1399
1411
 
1400
1412
  /**
1401
- * 使用模块 API 返回的模型挂载设置页;CSS 仅覆盖当前模块。
1402
- * Mount a settings page from a module API model; CSS only overrides this module.
1403
- * @param {import("../index.js").ModuleModel} model API 返回的模块模型 / Module model returned by the API.
1404
- * @param {string} [css] 可选 CSS 正文 / Optional module-scoped CSS text.
1405
- * @returns {PreferencesView} 模块视图 / Module view.
1413
+ * 使用原始 BoxJS JSON 挂载设置页。
1414
+ * Mount a settings page from raw BoxJS JSON.
1415
+ * @param {import("../index.js").BoxJSInput} boxjs 单模块 BoxJS JSON / Single-module BoxJS JSON.
1416
+ * @returns {import("./index.js").MountedPreferences} 模块视图 / Module view.
1406
1417
  */
1407
- function mount(model, css = "") {
1408
- return new PreferencesView(model, css);
1418
+ function mount(boxjs) {
1419
+ return new PreferencesView(boxjs);
1409
1420
  }
1410
1421
 
1411
- export { PreferencesView, mount };
1422
+ export { mount };