@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.
- 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 +7 -6
- 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 +7 -6
- package/src/browser/Navigation.d.mts +8 -6
- 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
package/src/browser/client.mjs
CHANGED
|
@@ -1,219 +1,101 @@
|
|
|
1
|
-
import { normalizeBoxJs, normalizeStoredValue, validValue } from "../lib/boxjs.mjs";
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* @
|
|
7
|
-
* @
|
|
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
|
-
*
|
|
23
|
-
* @
|
|
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
|
-
|
|
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
|
|
40
|
-
signal
|
|
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/
|
|
49
|
-
body:
|
|
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
|
|
39
|
+
session.signal.removeEventListener("abort", abort);
|
|
56
40
|
}
|
|
57
41
|
}
|
|
42
|
+
|
|
58
43
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* @param {
|
|
62
|
-
* @
|
|
63
|
-
* @
|
|
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(
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
81
|
+
saving = false;
|
|
112
82
|
}
|
|
113
83
|
}
|
|
84
|
+
|
|
114
85
|
return {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
}
|
package/src/browser/index.d.ts
CHANGED
|
@@ -1,22 +1,18 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
*
|
|
17
|
-
* Mount
|
|
18
|
-
* @param
|
|
19
|
-
* @param css 可选 CSS
|
|
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(
|
|
18
|
+
export function mount(model: ModuleModel, css?: string): MountedPreferences;
|
package/src/browser/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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").
|
|
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(
|
|
13
|
+
export function mount(model, css = "") {
|
|
14
14
|
if (typeof css !== "string") throw new TypeError("CSS must be a string");
|
|
15
|
-
const
|
|
16
|
-
const
|
|
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 ??
|
|
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,
|
|
84
|
+
panel = mountPanel(root, rendered);
|
|
77
85
|
return view;
|
|
78
86
|
} catch (error) {
|
|
79
87
|
view.destroy();
|
package/src/browser/panel.mjs
CHANGED
|
@@ -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
|
-
*
|
|
8
|
-
* Mount the
|
|
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("../
|
|
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,
|
|
14
|
-
const
|
|
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 =
|
|
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:
|
|
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({
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
382
|
+
if (version === inputVersion) write(client.snapshot().values[field.key]);
|
|
381
383
|
};
|
|
382
384
|
perform(
|
|
383
|
-
() =>
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 {
|
|
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
|
|
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
|
-
*
|
|
96
|
-
*
|
|
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();
|