@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,219 +1,101 @@
1
- import { normalizeBoxJs, normalizeStoredValue, validValue } from "../lib/boxjs.mjs";
2
-
3
1
  /**
4
- * 单个模块的临时会话;离开页面后丢弃。
5
- * Transient module session discarded when leaving the page.
6
- * @typedef {object} ModuleSession
7
- * @property {AbortController} controller 读取请求的取消控制器 / Abort controller for reads.
8
- * @property {import("../index.js").ModuleDefinition | null} definition 加载完成的配置,加载中为 null / Loaded configuration, or null while loading.
9
- * @property {import("./client.mjs").ModuleSnapshot["values"]} values 当前显示值 / Current display values.
10
- * @property {boolean} saving 是否正在写入 / Whether a mutation is in progress.
2
+ * 创建单模块页面客户端;只调用模块 API,不读取或解析 BoxJS。
3
+ * Create a single-module page client that only calls the module API and never reads or parses BoxJS.
4
+ * @param {import("./client.mjs").PreferencesClientOptions} options API 模型、请求与通知 / API model, requests and notifications.
5
+ * @returns {import("./client.mjs").PreferencesClient} 页面客户端 / Page client.
11
6
  */
7
+ export function createPreferencesClient({ model, definition, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
8
+ const { module, configURL } = model;
9
+ const session = new AbortController();
10
+ const values = structuredClone(model.values);
11
+ let saving = false;
12
12
 
13
- /**
14
- * 创建页面会话缓存;打开时重读,选项操作仅在 HTTP 200 后更新缓存。
15
- * Create a page-session cache; reload on open and mutate cache only after HTTP 200.
16
- * @param {import("./client.mjs").PreferencesClientOptions} options 包内目录、请求与通知 / Internal catalog, requests and notifications.
17
- * @returns {import("./client.mjs").PreferencesClient} 通用客户端 / Generic client.
18
- */
19
- export function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
20
13
  /**
21
- * 模块会话表
22
- * Module session map.
23
- * @type {Map<string, ModuleSession>}
14
+ * 向模块 API 发送 JSON 动作。
15
+ * Send a JSON action to the module API.
16
+ * @param {"get" | "set" | "delete"} action 模块动作 / Module action.
17
+ * @param {unknown} payload JSON 请求体 / JSON request body.
18
+ * @returns {Promise<Response>} 原始响应 / Raw response.
24
19
  */
25
- const sessions = new Map();
26
- /**
27
- * 用 form 发送完整存储键;读取 404 交给调用方处理。
28
- * Send a complete storage key as form data; callers handle missing reads.
29
- * @param {string} path 完整 @root.path / Complete @root.path.
30
- * @param {"get" | "set" | "delete"} action 存储操作 / Storage operation.
31
- * @param {unknown} body set 值,其它操作忽略 / Set value, ignored by other operations.
32
- * @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
33
- * @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
34
- * @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
35
- */
36
- async function send(path, action, body, signal) {
20
+ async function send(action, payload) {
37
21
  const controller = new AbortController();
38
22
  const abort = () => controller.abort();
39
- if (signal?.aborted) abort();
40
- signal?.addEventListener("abort", abort, { once: true });
23
+ if (session.signal.aborted) abort();
24
+ session.signal.addEventListener("abort", abort, { once: true });
41
25
  const timer = setTimeout(abort, timeout);
42
26
  try {
43
- const response = await request(`/api/${action}`, {
27
+ const response = await request(`/api/${encodeURIComponent(module)}/${action}`, {
44
28
  method: "POST",
45
29
  credentials: "omit",
46
30
  cache: "no-store",
47
31
  signal: controller.signal,
48
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
49
- body: new URLSearchParams([[path, action === "set" ? JSON.stringify(body) : ""]]).toString(),
32
+ headers: { "Content-Type": "application/json", "X-PreferencePanes-JSON": configURL },
33
+ body: JSON.stringify(payload),
50
34
  });
51
35
  if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
52
36
  return response;
53
37
  } finally {
54
38
  clearTimeout(timer);
55
- signal?.removeEventListener("abort", abort);
39
+ session.signal.removeEventListener("abort", abort);
56
40
  }
57
41
  }
42
+
58
43
  /**
59
- * 获取独立快照,避免调用方修改内部缓存。
60
- * Return an independent snapshot so callers cannot mutate the cache.
61
- * @param {string} module 已打开模块 / Open module.
62
- * @returns {import("./client.mjs").ModuleSnapshot} 会话快照 / Session snapshot.
63
- * @throws {Error} 模块未完成加载 / Module has not finished loading.
64
- */
65
- const snapshot = module => {
66
- const state = sessions.get(module);
67
- if (!state?.definition) throw new Error("Open the module first");
68
- return structuredClone({ definition: state.definition, values: state.values });
69
- };
70
- /**
71
- * 串行修改单键,仅成功后更新仍存活的会话。
72
- * Serialize single-key mutations and update a still-active session only after success.
73
- * @param {string} module 已打开模块 / Open module.
74
- * @param {string} key 完整点分字段路径 / Complete dotted field path.
75
- * @param {"set" | "delete"} action 写入或删除 / Write or delete.
76
- * @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
77
- * @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
44
+ * 执行写入动作;成功后只更新当前页面值。
45
+ * Execute a mutation and update only the current page values after success.
46
+ * @param {"set" | "delete"} action API 动作 / API action.
47
+ * @param {unknown} payload JSON 请求体 / JSON request body.
48
+ * @param {"write" | "delete" | "clearCaches" | "reset"} operation 通知操作 / Notification operation.
49
+ * @param {string} [key] 字段路径 / Field path.
78
50
  * @returns {Promise<void>} 操作完成 / Operation completion.
79
- * @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
80
51
  */
81
- async function change(module, key, action, value, operation = action === "set" ? "write" : "delete") {
82
- const state = sessions.get(module);
83
- if (!state?.definition) throw new Error("Open the module first");
84
- if (state.saving) throw new Error("A settings write is already in progress");
85
- const field = state.definition.fields.find(field => field.key === key);
86
- state.saving = true;
52
+ async function change(action, payload, operation, key) {
53
+ if (saving) throw new Error("A settings write is already in progress");
54
+ saving = true;
87
55
  try {
88
- if ((operation === "write" || operation === "delete") && (!field || (action === "set" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
89
- await send(`@${state.definition.storageKey}.${key}`, action, value);
90
- if (sessions.get(module) === state) {
91
- switch (operation) {
92
- case "write":
93
- state.values[key] = structuredClone(value);
94
- break;
95
- case "delete":
96
- case "clearCaches":
97
- case "reset":
98
- for (const candidate of state.definition.fields) {
99
- if (candidate.key !== key && !candidate.key.startsWith(`${key}.`)) continue;
100
- delete state.values[candidate.key];
101
- if (Object.hasOwn(candidate, "defaultValue")) state.values[candidate.key] = structuredClone(candidate.defaultValue);
102
- }
103
- break;
56
+ await send(action, payload);
57
+ switch (operation) {
58
+ case "write":
59
+ values[key] = structuredClone(payload.value);
60
+ break;
61
+ case "delete": {
62
+ const field = definition.fields.find(candidate => candidate.key === key);
63
+ delete values[key];
64
+ if (field && Object.hasOwn(field, "defaultValue")) values[key] = structuredClone(field.defaultValue);
65
+ break;
104
66
  }
67
+ case "clearCaches":
68
+ break;
69
+ case "reset":
70
+ for (const field of definition.fields) {
71
+ delete values[field.key];
72
+ if (Object.hasOwn(field, "defaultValue")) values[field.key] = structuredClone(field.defaultValue);
73
+ }
74
+ break;
105
75
  }
106
76
  notify({ kind: "success", operation, module, key });
107
77
  } catch (error) {
108
78
  notify({ kind: "error", operation, module, key, message: error.message });
109
79
  throw error;
110
80
  } finally {
111
- state.saving = false;
81
+ saving = false;
112
82
  }
113
83
  }
84
+
114
85
  return {
115
- /**
116
- * 从已导入的 JSON 创建新会话,只读取一次设置值。
117
- * Create a session from imported JSON and read stored settings once.
118
- * @param {string} module 模块标识 / Module identifier.
119
- * @returns {Promise<import("./client.mjs").ModuleSnapshot>} 新快照 / New snapshot.
120
- * @throws {Error} 读取失败、会话被替换或写入尚未完成 / Read failure, replaced session or unfinished write.
121
- */
122
- async open(module) {
123
- const binding = catalog.modules.get(module);
124
- if (!binding) throw new TypeError(`No BoxJS settings for module: ${module}`);
125
- const previous = sessions.get(module);
126
- if (previous?.saving) throw new Error("Cannot refresh while saving");
127
- previous?.controller.abort();
128
- const state = { controller: new AbortController(), definition: null, values: {}, saving: false };
129
- sessions.set(module, state);
130
- try {
131
- const definition = normalizeBoxJs(catalog, module);
132
- const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
133
- let subtree = response.status === 404 ? {} : await response.json();
134
- if (typeof subtree === "string") subtree = JSON.parse(subtree);
135
- if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
136
- if (sessions.get(module) !== state) throw new Error("Module session was replaced");
137
- state.definition = definition;
138
- for (const field of definition.fields) {
139
- const stored = field.key
140
- .split(".")
141
- .slice(definition.settingsPath.length)
142
- .reduce((parent, part) => Object(parent)[part], subtree);
143
- const value = stored === undefined ? field.defaultValue : stored;
144
- if (value !== undefined) state.values[field.key] = normalizeStoredValue(field, value);
145
- }
146
- return snapshot(module);
147
- } catch (error) {
148
- if (sessions.get(module) === state) sessions.delete(module);
149
- throw error;
150
- }
151
- },
152
- snapshot,
153
- /**
154
- * 按需重新读取模块 Settings,不更新页面会话缓存。
155
- * Reread module Settings on demand without updating the page-session cache.
156
- * @param {string} module 已打开的模块 / Open module.
157
- * @returns {Promise<unknown>} 设置值,缺失为 undefined / Settings value, or undefined when absent.
158
- */
159
- async readSettings(module) {
160
- const state = sessions.get(module);
161
- if (!state?.definition) throw new Error("Open the module first");
162
- const response = await send(`@${state.definition.storageKey}.${state.definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
86
+ snapshot: () => structuredClone({ definition, values }),
87
+ async readSettings() {
88
+ const response = await send("get", { scope: "settings" });
163
89
  return response.status === 404 ? undefined : response.json();
164
90
  },
165
- /**
166
- * 按需读取模块 Caches,不自动读取其它设置。
167
- * Read module Caches on demand without refreshing other settings.
168
- * @param {string} module 已打开的模块 / Open module.
169
- * @returns {Promise<unknown>} 缓存值,缺失为 undefined / Cache value, or undefined when absent.
170
- */
171
- async readCaches(module) {
172
- const state = sessions.get(module);
173
- if (!state?.definition) throw new Error("Open the module first");
174
- const response = await send(`@${state.definition.storageKey}.${module}.Caches`, "get", undefined, state.controller.signal);
91
+ async readCaches() {
92
+ const response = await send("get", { scope: "caches" });
175
93
  return response.status === 404 ? undefined : response.json();
176
94
  },
177
- /**
178
- * 删除整个 Caches 节点,成功后不追加 GET。
179
- * Delete the entire Caches node without a follow-up GET.
180
- * @param {string} module 已打开模块 / Open module.
181
- * @returns {Promise<void>} 清理完成 / Cleanup completion.
182
- */
183
- clearCaches: module => change(module, `${module}.Caches`, "delete", undefined, "clearCaches"),
184
- /**
185
- * 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
186
- * Delete module persistence and reset the page cache using current BoxJS defaults.
187
- * @param {string} module 已打开模块 / Open module.
188
- * @returns {Promise<void>} 重置完成 / Reset completion.
189
- */
190
- reset: module => change(module, module, "delete", undefined, "reset"),
191
- /**
192
- * 取消读取并清除会话,不撤销已发送的写入。
193
- * Abort reads and clear the session without undoing dispatched writes.
194
- * @param {string} module 模块标识 / Module identifier.
195
- * @returns {void} 无返回值 / No return value.
196
- */
197
- leave(module) {
198
- sessions.get(module)?.controller.abort();
199
- sessions.delete(module);
200
- },
201
- /**
202
- * 写入单键并更新当前会话。
203
- * Write one key and update the current session.
204
- * @param {string} module 已打开模块 / Open module.
205
- * @param {string} key 点分字段路径 / Dotted field path.
206
- * @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
207
- * @returns {Promise<void>} 写入完成 / Write completion.
208
- */
209
- set: (module, key, value) => change(module, key, "set", value),
210
- /**
211
- * 删除单键覆盖值并显示默认值。
212
- * Delete one override and display its default value.
213
- * @param {string} module 已打开模块 / Open module.
214
- * @param {string} key 点分字段路径 / Dotted field path.
215
- * @returns {Promise<void>} 删除完成 / Delete completion.
216
- */
217
- remove: (module, key) => change(module, key, "delete"),
95
+ clearCaches: () => change("delete", { scope: "caches" }, "clearCaches"),
96
+ reset: () => change("delete", { scope: "module" }, "reset"),
97
+ leave: () => session.abort(),
98
+ set: (key, value) => change("set", { key, value }, "write", key),
99
+ remove: key => change("delete", { key }, "delete", key),
218
100
  };
219
101
  }
@@ -1,22 +1,18 @@
1
- import type { BoxJSInput } from "../index.js";
1
+ import type { ModuleModel } from "../index.js";
2
2
 
3
3
  /**
4
4
  * 具体模块设置页的生命周期句柄。
5
5
  * Lifecycle handle for a concrete module settings page.
6
6
  */
7
7
  export interface MountedPreferences {
8
- /**
9
- * 移除页面、样式、监听器和临时会话。
10
- * Remove the page, styles, listeners and transient sessions.
11
- * @returns 无返回值 / No return value.
12
- */
8
+ /** 移除页面、样式和监听器 / Remove page, styles and listeners. */
13
9
  destroy(): void;
14
10
  }
15
11
  /**
16
- * 仅以 BoxJS 和可选 CSS 挂载一个模块页,不生成项目主页。
17
- * Mount one module page using BoxJS and optional CSS, without a project landing page.
18
- * @param boxjs 恰好包含一个模块的 BoxJS JSON / BoxJS JSON describing exactly one module.
19
- * @param css 可选 CSS 正文;默认样式始终内置 / Optional CSS text; default styles are built in.
12
+ * 使用模块 API 返回的模型挂载设置页;CSS 仅覆盖当前模块。
13
+ * Mount a settings page from a module API model; CSS only overrides this module.
14
+ * @param model 模块 API 模型 / Module API model.
15
+ * @param css 可选 CSS 正文 / Optional CSS text.
20
16
  * @returns 生命周期句柄 / Lifecycle handle.
21
17
  */
22
- export function mount(boxjs: BoxJSInput, css?: string): MountedPreferences;
18
+ export function mount(model: ModuleModel, css?: string): MountedPreferences;
@@ -1,4 +1,4 @@
1
- import { BoxJS } from "../BoxJS.mjs";
1
+ import { normalizeBoxJs, normalizeStoredValue, validValue } from "./boxjs.mjs";
2
2
  import { element, resourceURL } from "./components.mjs";
3
3
  import { mountPanel } from "./panel.mjs";
4
4
  import { installDefaultStyles } from "./styles.mjs";
@@ -6,14 +6,22 @@ import { installDefaultStyles } from "./styles.mjs";
6
6
  /**
7
7
  * 挂载模块设置页;默认样式由包提供,可选 CSS 仅作用于当前模块。
8
8
  * Mount a module page with package defaults and optional module-scoped CSS.
9
- * @param {import("../index.js").BoxJSInput} boxjs 单个模块的 BoxJS JSON / BoxJS JSON for one module.
9
+ * @param {import("../index.js").ModuleModel} model API 返回的模块模型 / Module model returned by the API.
10
10
  * @param {string} [css] 可选 CSS 正文 / Optional CSS text.
11
11
  * @returns {import("./index.js").MountedPreferences} 模块生命周期句柄 / Module lifecycle handle.
12
12
  */
13
- export function mount(boxjs, css = "") {
13
+ export function mount(model, css = "") {
14
14
  if (typeof css !== "string") throw new TypeError("CSS must be a string");
15
- const catalog = boxjs instanceof BoxJS ? boxjs : new BoxJS(boxjs);
16
- const metadata = catalog.module.metadata;
15
+ const definition = normalizeBoxJs(model.boxjs, model.module);
16
+ const values = { ...model.values };
17
+ for (const field of definition.fields) {
18
+ if (values[field.key] === undefined) continue;
19
+ values[field.key] = normalizeStoredValue(field, values[field.key]);
20
+ if (!validValue(field, values[field.key])) throw new TypeError(`Invalid stored value: ${field.key}`);
21
+ }
22
+ for (const field of definition.fields) if (values[field.key] === undefined && Object.hasOwn(field, "defaultValue")) values[field.key] = structuredClone(field.defaultValue);
23
+ const rendered = { ...model, definition, values };
24
+ const metadata = definition.metadata ?? {};
17
25
  const image = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
18
26
  if (image) resourceURL(image);
19
27
  if (metadata.repo) resourceURL(metadata.repo);
@@ -49,7 +57,7 @@ export function mount(boxjs, css = "") {
49
57
  observer = new MutationObserver(syncAppearance);
50
58
  observer.observe(host, { attributes: true, attributeFilter: ["data-theme", "style"] });
51
59
  }
52
- document.title = metadata.name ?? catalog.module.module;
60
+ document.title = metadata.name ?? definition.module;
53
61
  let panel;
54
62
  const view = {
55
63
  /**
@@ -73,7 +81,7 @@ export function mount(boxjs, css = "") {
73
81
  };
74
82
  try {
75
83
  root.replaceChildren();
76
- panel = mountPanel(root, catalog);
84
+ panel = mountPanel(root, rendered);
77
85
  return view;
78
86
  } catch (error) {
79
87
  view.destroy();
@@ -1,21 +1,23 @@
1
1
  import { ActionMenu } from "./ActionMenu.mjs";
2
+ import { validValue } from "./boxjs.mjs";
2
3
  import { createPreferencesClient } from "./client.mjs";
3
4
  import { fieldControl, element as node, requestConfirmation, resourceURL, settingRow, statusView } from "./components.mjs";
4
5
  import { Navigation } from "./Navigation.mjs";
5
6
 
6
7
  /**
7
- * 挂载已导入 BoxJS 对应的模块表单和短暂通知。
8
- * Mount the imported BoxJS module form and transient notifications.
8
+ * 挂载 API 返回的模块模型表单和短暂通知。
9
+ * Mount the module model returned by the API and transient notifications.
9
10
  * @param {HTMLElement} root 包内挂载元素 / Internal mount element.
10
- * @param {import("../BoxJS.mjs").BoxJS} catalog 包内 BoxJS 目录 / Internal BoxJS catalog.
11
+ * @param {import("../index.js").ModuleModel} model API 返回的模块模型 / Module model returned by the API.
11
12
  * @returns {import("./index.js").MountedPreferences} 面板生命周期句柄 / Panel lifecycle handle.
12
13
  */
13
- export function mountPanel(root, catalog) {
14
- const title = catalog.module.metadata.name ?? catalog.module.module;
14
+ export function mountPanel(root, model) {
15
+ const { definition } = model;
16
+ const title = definition.metadata?.name ?? definition.module;
15
17
  const document = root.ownerDocument;
16
18
  const window = document.defaultView;
17
19
  const shell = node("div", "pp-panel");
18
- shell.dataset.module = catalog.module.module;
20
+ shell.dataset.module = definition.module;
19
21
  const header = node("header", "pp-header");
20
22
  const back = node("button", "pp-back", "‹");
21
23
  back.setAttribute("aria-label", "返回");
@@ -45,7 +47,7 @@ export function mountPanel(root, catalog) {
45
47
  if (!frame?.dataset.preferencePanes) return;
46
48
  frame.dispatchEvent(
47
49
  new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
48
- detail: { title: heading.textContent, module: catalog.module.module, busy: saving, canGoBack: !back.disabled, actions },
50
+ detail: { title: heading.textContent, module: definition.module, busy: saving, canGoBack: !back.disabled, actions },
49
51
  }),
50
52
  );
51
53
  };
@@ -102,7 +104,7 @@ export function mountPanel(root, catalog) {
102
104
  toast.hidden = true;
103
105
  }, 2400);
104
106
  };
105
- const client = createPreferencesClient({ catalog, notify });
107
+ const client = createPreferencesClient({ model, definition, notify });
106
108
  /**
107
109
  * 两种菜单入口共用异步错误处理,包含宿主确认框错误。
108
110
  * Share async error handling between both menus, including host-dialog errors.
@@ -130,7 +132,6 @@ export function mountPanel(root, catalog) {
130
132
  publishNavigation();
131
133
  viewport.replaceChildren(statusView("读取设置…"));
132
134
  try {
133
- await client.open(module);
134
135
  if (version === generation) controls();
135
136
  } catch (error) {
136
137
  if (version !== generation) return;
@@ -144,7 +145,7 @@ export function mountPanel(root, catalog) {
144
145
  * @returns {void} 无返回值 / No return value.
145
146
  */
146
147
  function controls() {
147
- const { definition, values } = client.snapshot(active);
148
+ const { definition, values } = client.snapshot();
148
149
  heading.textContent = definition.metadata?.name || active;
149
150
  const view = node("section", "pp-fields");
150
151
  /**
@@ -182,7 +183,7 @@ export function mountPanel(root, catalog) {
182
183
  saving = true;
183
184
  back.disabled = true;
184
185
  publishNavigation();
185
- return (queue = queue
186
+ queue = queue
186
187
  .then(action)
187
188
  .then(() => {
188
189
  if (!destroyed) success();
@@ -195,10 +196,11 @@ export function mountPanel(root, catalog) {
195
196
  .finally(() => {
196
197
  pendingWrites--;
197
198
  saving = pendingWrites > 0;
198
- if (destroyed && !saving) client.leave(active);
199
+ if (destroyed && !saving) client.leave();
199
200
  back.disabled = saving || !navigation.canGoBack;
200
201
  publishNavigation();
201
- }));
202
+ });
203
+ return queue;
202
204
  }
203
205
  const metadata = definition.metadata;
204
206
  if (metadata) {
@@ -276,7 +278,7 @@ export function mountPanel(root, catalog) {
276
278
  link.append(summary, node("span", "pp-chevron", "›"));
277
279
  row.append(link);
278
280
  const refresh = () => {
279
- const value = client.snapshot(active).values[field.key];
281
+ const value = client.snapshot().values[field.key];
280
282
  summary.textContent =
281
283
  field.options
282
284
  .filter(option => Array.isArray(value) && value.includes(option.key))
@@ -377,10 +379,17 @@ export function mountPanel(root, catalog) {
377
379
  return;
378
380
  }
379
381
  const restore = () => {
380
- if (version === inputVersion) write(client.snapshot(module).values[field.key]);
382
+ if (version === inputVersion) write(client.snapshot().values[field.key]);
381
383
  };
382
384
  perform(
383
- () => client.set(module, field.key, value),
385
+ () => {
386
+ if (!validValue(field, value)) {
387
+ const error = new TypeError("Invalid setting value");
388
+ notify({ kind: "error", operation: "write", module, key: field.key, message: error.message });
389
+ throw error;
390
+ }
391
+ return client.set(field.key, value);
392
+ },
384
393
  () => {
385
394
  for (const refresh of summaries) refresh();
386
395
  },
@@ -401,7 +410,7 @@ export function mountPanel(root, catalog) {
401
410
  return perform(
402
411
  async () => {
403
412
  try {
404
- value = await client.readSettings(active);
413
+ value = await client.readSettings();
405
414
  } catch (error) {
406
415
  notify({ kind: "error", message: error.message });
407
416
  throw error;
@@ -425,7 +434,7 @@ export function mountPanel(root, catalog) {
425
434
  return perform(
426
435
  async () => {
427
436
  try {
428
- value = await client.readCaches(active);
437
+ value = await client.readCaches();
429
438
  } catch (error) {
430
439
  notify({ kind: "error", message: error.message });
431
440
  throw error;
@@ -441,7 +450,7 @@ export function mountPanel(root, catalog) {
441
450
  if (saving) return;
442
451
  if (!(await requestConfirmation(window, `清空 ${active} 的全部 Caches?`)) || destroyed || saving) return;
443
452
  return perform(
444
- () => client.clearCaches(active),
453
+ () => client.clearCaches(),
445
454
  () => {
446
455
  output.textContent = "暂无缓存";
447
456
  },
@@ -450,7 +459,7 @@ export function mountPanel(root, catalog) {
450
459
  handlers.set("reset", async () => {
451
460
  if (saving) return;
452
461
  if (!(await requestConfirmation(window, `重置 ${active} 的设置?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) || destroyed || saving) return;
453
- return perform(() => client.reset(active), controls);
462
+ return perform(() => client.reset(), controls);
454
463
  });
455
464
  navigation?.destroy();
456
465
  navigation = new Navigation(viewport, view, key => editors.get(key)?.node);
@@ -468,7 +477,7 @@ export function mountPanel(root, catalog) {
468
477
  if (navigation) navigation.back();
469
478
  else window.history.back();
470
479
  };
471
- open(catalog.module.module);
480
+ open(definition.module);
472
481
  return {
473
482
  /**
474
483
  * 移除监听器、定时器、会话和挂载内容。
@@ -481,7 +490,7 @@ export function mountPanel(root, catalog) {
481
490
  window.frameElement?.removeEventListener("preferencepanes:action", onAction);
482
491
  navigation?.destroy();
483
492
  generation++;
484
- if (active && !saving) client.leave(active);
493
+ if (active && !saving) client.leave();
485
494
  clearTimeout(timer);
486
495
  shell.remove();
487
496
  },
package/src/build.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import { BoxJS } from "./BoxJS.mjs";
2
+ import { normalizeBoxJs } from "./browser/boxjs.mjs";
3
3
 
4
4
  /**
5
5
  * 仅生成模块前端文件,不复制配置或生成绑定业务的读写脚本。
@@ -10,8 +10,7 @@ import { BoxJS } from "./BoxJS.mjs";
10
10
  */
11
11
  export async function build(boxjs, css = "") {
12
12
  if (typeof css !== "string") throw new TypeError("CSS must be a string");
13
- const catalog = new BoxJS(boxjs);
14
- const module = catalog.module.module;
13
+ const module = normalizeBoxJs(boxjs).module;
15
14
  const [html, app] = await Promise.all([readFile(new URL("../dist/module/index.html", import.meta.url), "utf8"), readFile(new URL("../dist/module/app.mjs", import.meta.url), "utf8")]);
16
15
  return {
17
16
  [`settings/${module}/index.html`]: html,
package/src/index.d.ts CHANGED
@@ -92,8 +92,8 @@ export interface SettingsRequest {
92
92
  */
93
93
  headers?: Record<string, string | undefined>;
94
94
  /**
95
- * POST 的正文为一个 form 字段,字段名是完整 @root.path
96
- * POST contains one form field whose name is the complete @root.path.
95
+ * 模块动作的 JSON 正文
96
+ * JSON body for a module action.
97
97
  */
98
98
  body?: string;
99
99
  }
@@ -268,6 +268,20 @@ export interface BoxJSSubscription extends BoxJSMetadata {
268
268
  * The sole data configuration input.
269
269
  */
270
270
  export type BoxJSInput = BoxJSSetting[] | BoxJSApp | BoxJSSubscription;
271
+ /**
272
+ * 模块 API 返回的原始 BoxJS 与当前值模型。
273
+ * Raw BoxJS and current-value model returned by the module API.
274
+ */
275
+ export interface ModuleModel {
276
+ /** 模块路径段 / Module path segment. */
277
+ module: string;
278
+ /** API 获取的原始 BoxJS JSON / Raw BoxJS JSON fetched by the API. */
279
+ boxjs: BoxJSInput;
280
+ /** API 读取到的原始已保存字段值 / Raw persisted field values read by the API. */
281
+ values: Record<string, JsonValue>;
282
+ /** 后续 API 动作使用的 BoxJS JSON 地址 / BoxJS JSON URL used by later API actions. */
283
+ configURL: string;
284
+ }
271
285
  /**
272
286
  * 生成模块前端文件,不复制配置、不生成绑定模块的代理脚本。
273
287
  * Build module frontend files without copying configuration or producing bound proxy scripts.
package/src/web.mjs ADDED
@@ -0,0 +1,52 @@
1
+ import { URL } from "@nsnanocat/url";
2
+ import { $app } from "@nsnanocat/util/lib/app.mjs";
3
+ import { done } from "@nsnanocat/util/lib/done.mjs";
4
+ import assets from "#assets";
5
+ import { pageInputs } from "./lib/page-inputs.mjs";
6
+
7
+ /**
8
+ * 返回模块页面及其公共浏览器资源,不处理 API、网络或持久化。
9
+ * Serve module pages and common browser assets without handling APIs, network access or persistence.
10
+ * @returns {void} 响应已交给代理宿主 / Response delivered to the proxy host.
11
+ */
12
+ function run() {
13
+ const request = globalThis.$request;
14
+ let result;
15
+ try {
16
+ const url = new URL(request.url);
17
+ if (/^\/settings\/[a-zA-Z0-9_-]+\/?$/.test(url.pathname)) {
18
+ if (!["GET", "HEAD"].includes(request.method)) result = response(request, 405, { error: "Method not allowed" });
19
+ else {
20
+ const inputs = encodeURIComponent(JSON.stringify(pageInputs(url, request.headers)));
21
+ result = response(request, 200, assets.page.body.replace("</head>", `<meta name="preference-panes-inputs" content="${inputs}"></head>`), "text/html");
22
+ }
23
+ } else {
24
+ const asset = assets[url.pathname];
25
+ if (asset) result = ["GET", "HEAD"].includes(request.method) ? response(request, 200, asset.body, asset.type) : response(request, 405, { error: "Method not allowed" });
26
+ }
27
+ } catch (error) {
28
+ console.error(`PreferencePanes Web: ${error.message}`);
29
+ result = response(request, 500, { error: error.message });
30
+ }
31
+ if (!result) done({});
32
+ else done($app === "Quantumult X" ? result : { response: result });
33
+ }
34
+
35
+ /**
36
+ * 构造静态资源响应,HEAD 请求不返回正文。
37
+ * Build a static resource response without a body for HEAD requests.
38
+ * @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
39
+ * @param {number} status HTTP 状态 / HTTP status.
40
+ * @param {unknown} body 响应正文 / Response body.
41
+ * @param {string} [type] 媒体类型 / Media type.
42
+ * @returns {import("./index.js").SettingsResponse} 静态资源响应 / Static resource response.
43
+ */
44
+ function response(request, status, body, type = "application/json") {
45
+ return {
46
+ status,
47
+ headers: { "Content-Type": `${type}; charset=utf-8`, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" },
48
+ body: request.method === "HEAD" ? "" : type === "application/json" ? JSON.stringify(body) : body,
49
+ };
50
+ }
51
+
52
+ run();