@nsnanocat/preference-panes 1.1.0 → 1.1.1
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 +64 -46
- package/dist/api.js +11 -31
- package/dist/module/index.html +1 -1
- package/dist/module/index.mjs +76 -109
- package/dist/module/navigation.mjs +15 -32
- package/dist/preference-panes.mjs +62 -52
- package/dist/web.js +2 -23
- package/package.json +2 -2
- package/src/api.mjs +11 -31
- package/src/browser/ModuleFrame.mjs +13 -13
- package/src/browser/ModuleStatus.mjs +2 -3
- package/src/browser/Navigation.d.mts +2 -4
- package/src/browser/client.d.mts +5 -5
- package/src/browser/client.mjs +38 -10
- package/src/browser/index.d.ts +5 -21
- package/src/browser/index.mjs +13 -48
- package/src/browser/mount.mjs +16 -32
- package/src/browser/panel.mjs +9 -9
- package/src/index.d.ts +0 -22
- package/src/index.mjs +3 -3
- package/src/web.mjs +1 -5
- package/src/build.mjs +0 -20
- package/src/lib/page-inputs.mjs +0 -17
package/README.md
CHANGED
|
@@ -1,76 +1,94 @@
|
|
|
1
1
|
# @nsnanocat/preference-panes
|
|
2
2
|
|
|
3
|
-
PreferencePanes
|
|
3
|
+
PreferencePanes 提供一个由 BoxJS JSON 驱动的通用设置前端,以及一个独立的代理持久化 API。业务模块只发布自己的 `/configs/{module}` 和 `/api/{module}`;通用 `/settings/**` 前端只需要安装一次。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
在 Biliverse 中,Enhanced 是唯一安装 `web.js` 的模块。Global、Redirect、ADBlock 不携带 `web.js`,它们的设置页仍由同一份通用前端读取各自 BoxJS 后渲染。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## 浏览器入口
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
浏览器包只公开一个入口:
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
```ts
|
|
12
|
+
function mount(boxjs: BoxJSInput): MountedPreferences;
|
|
13
|
+
interface MountedPreferences { destroy(): void }
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```js
|
|
17
|
+
import { mount } from "@nsnanocat/preference-panes/browser";
|
|
18
|
+
|
|
19
|
+
const boxjs = await fetch("/configs/Module", {
|
|
20
|
+
cache: "no-store",
|
|
21
|
+
credentials: "omit",
|
|
22
|
+
}).then(response => response.json());
|
|
23
|
+
|
|
24
|
+
const preferences = mount(boxjs);
|
|
25
|
+
preferences.destroy();
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`mount()` 同步建立页面生命周期。BoxJS 规范化、默认值、已存值校验、加载状态和失败重试都由内部流程管理;不公开 API Model、视图 class、构建器或 CSS 第二参数。
|
|
29
|
+
|
|
30
|
+
前端支持 BoxJS 字段数组、单个 app 和 apps 订阅,并要求输入恰好包含一个模块。模块名、存储根和 Settings 路径都从 `@Root.Module.Settings.key` 字段 ID 推导,不从 app 名称或调用参数补充。
|
|
31
|
+
|
|
32
|
+
默认样式随包内置。嵌入页面只跟随宿主根元素的 `data-theme` 和 `--pp-keyboard-height`,不识别客户端 User-Agent,也不加载项目 CSS 或客户端 SDK。
|
|
33
|
+
|
|
34
|
+
## 页面与 API
|
|
35
|
+
|
|
36
|
+
`web.js` 只返回三类通用资源:
|
|
37
|
+
|
|
38
|
+
- `GET /settings/{module}`
|
|
39
|
+
- `GET /settings/assets/index.mjs`
|
|
40
|
+
- `GET /settings/assets/navigation.mjs`
|
|
12
41
|
|
|
13
|
-
|
|
42
|
+
模块页面从 URL 或 `ModuleFrame` 的模块标记取得模块名,直接读取同源 `/configs/{module}`,然后调用 `mount(boxjs)`。页面不接受 JSON/CSS 查询参数、私有请求头或兼容资源别名。
|
|
14
43
|
|
|
15
|
-
|
|
44
|
+
`api.js` 只处理:
|
|
16
45
|
|
|
17
|
-
|
|
46
|
+
- `HEAD /api/{module}`:探测同源 `/configs/{module}`,透传状态与 `X-PreferencePanes-Version`。
|
|
47
|
+
- `POST /api/{module}/get`:读取声明字段或 Settings、Caches、module 子树。
|
|
48
|
+
- `POST /api/{module}/set`:写入当前 BoxJS 声明的字段。
|
|
49
|
+
- `POST /api/{module}/delete`:删除声明字段或模块子树。
|
|
50
|
+
|
|
51
|
+
`GET /api/{module}` 和其它未规定方法返回 `405`。API 每次动作都从 `/configs/{module}` 建立字段目录,只负责字段授权与 util `Storage` 读写,不解释控件、选项或默认值。
|
|
18
52
|
|
|
19
53
|
```js
|
|
20
54
|
await fetch("/api/Enhanced/set", {
|
|
21
55
|
method: "POST",
|
|
22
|
-
headers: {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
56
|
+
headers: { "Content-Type": "application/json" },
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
key: "Enhanced.Settings.Home.Top_left",
|
|
59
|
+
value: "mine",
|
|
60
|
+
}),
|
|
27
61
|
});
|
|
28
62
|
```
|
|
29
63
|
|
|
30
|
-
|
|
64
|
+
页面初始化时只执行一次 `POST /api/{module}/get` 读取 Settings 子树。写入成功后仅更新当前页面快照;查看 Settings/Caches 时按需读取,清空和重置通过 delete 动作完成。
|
|
31
65
|
|
|
32
|
-
##
|
|
66
|
+
## 宿主集成
|
|
33
67
|
|
|
34
68
|
```js
|
|
35
|
-
import {
|
|
36
|
-
const model = await fetch("/api/Module", {
|
|
37
|
-
headers: { "X-PreferencePanes-JSON": "/configs/Module" },
|
|
38
|
-
}).then(response => response.json());
|
|
39
|
-
const page = new PreferencesView(model, ".pp-panel { --pp-accent: #16866a; }");
|
|
40
|
-
page.destroy();
|
|
41
|
-
```
|
|
69
|
+
import { ModuleFrame, ModuleStatus } from "@nsnanocat/preference-panes/navigation";
|
|
42
70
|
|
|
43
|
-
|
|
71
|
+
const status = new ModuleStatus(statusElement);
|
|
72
|
+
await status.check("/api/Module");
|
|
44
73
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
`/settings/{module}` 由独立 `web.js` 返回模块文档。页面可通过 json/css 查询参数或 X-PreferencePanes-JSON/CSS Header 指定资源 URL;默认 JSON 是 /configs/{module},CSS 默认空。Header 分别优先。`web.js` 不访问网络或存储,浏览器执行其中的 `index.mjs` 后才调用 `api.js`。
|
|
48
|
-
|
|
49
|
-
```js
|
|
50
|
-
import { ModuleFrame, Navigation } from "@nsnanocat/preference-panes/navigation";
|
|
51
|
-
const frame = new ModuleFrame("/settings/Module", {
|
|
52
|
-
headers: { "X-PreferencePanes-JSON": "/configs/Module", "X-PreferencePanes-CSS": "/theme.css" },
|
|
53
|
-
signal,
|
|
54
|
-
});
|
|
74
|
+
const frame = new ModuleFrame("/settings/Module", { signal });
|
|
55
75
|
container.append(frame.element);
|
|
56
76
|
await frame.load();
|
|
57
|
-
frame.addEventListener("change", () => { title.textContent = frame.state.title; });
|
|
58
|
-
back.onclick = () => frame.back();
|
|
59
|
-
frame.destroy();
|
|
60
77
|
```
|
|
61
78
|
|
|
62
|
-
ModuleFrame
|
|
63
|
-
|
|
64
|
-
项目主页可按需使用导出的 `probeModule`、ModuleStatus、ModuleFrame 和 Navigation,也可以自行实现入口。`probeModule("/api/Module", { json: "/configs/Module" })` 只对模块 API 发送一次 HEAD,并原样返回浏览器 `Response`;代理 API 负责探测 BoxJS 上游并透传状态和 `X-PreferencePanes-Version`。ModuleStatus 仅负责把同一响应渲染到状态行。PreferencePanes 不提供主页运行脚本,不识别具体 App,不加载或调用任何客户端 Bridge SDK。调用方若运行在原生 WebView,应在自己的 HTML/页面脚本中直接接入该客户端的官方 SDK,再把 ModuleFrame 事件映射到原生界面。
|
|
65
|
-
|
|
66
|
-
每次进入模块通过 API 取得 JSON 和设置一次,同时读取可选 CSS。二级多选返回复用内存缓存,修改经 Web 校验后提交 API;成功提示、失败回滚由公共组件处理。Caches 按需通过 API 查看/清空,重置只删除指定模块子树。
|
|
67
|
-
|
|
68
|
-
标题栏只显示文字;嵌入模式隐藏模块自身标题栏,由宿主显示原生标题或自己的导航。模块数据操作位于标题栏右侧三点菜单,页面不再平铺维护按钮。`ActionMenu` 是共用的底部操作菜单:独立网页由其三点按钮打开;网页宿主或只有原生按钮、没有原生菜单的 WebView 宿主,将 `frame.state.actions` 传给 `menu.update(actions, busy)`,在宿主按钮点击时调用 `menu.open()`,选择后调用 `frame.perform(id)`。查看设置和缓存都会通过 `POST /api/{module}/get` 按需读取最新子树,再进入可返回的 JSON 详情子页;返回详情页不会触发额外读取,清空和重置设置通过 `/delete` 且仍要求确认。菜单组件提供遮罩、取消按钮、外部点击、Escape 与方向键操作,弹层挂载在文档根部,不依赖模块标题栏是否显示;宿主不访问 iframe 内部 DOM。
|
|
79
|
+
`ModuleFrame` 只携带模块身份和取消信号。宿主通过 `change`、`confirm`、`notice` 事件同步标题、操作菜单、确认框和提示,不读取或改写 iframe 内部 DOM。项目主页、Bilibili JSBridge、原生导航和视觉样式仍由宿主负责。
|
|
69
80
|
|
|
70
81
|
## 构建与验证
|
|
71
82
|
|
|
72
|
-
|
|
83
|
+
```sh
|
|
84
|
+
npm run build
|
|
85
|
+
npm run check
|
|
86
|
+
npm run apifox:check
|
|
87
|
+
npm pack --dry-run
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
构建生成 `dist/api.js`、`dist/web.js`、`dist/preference-panes.mjs` 和 `dist/module/` 通用页面资源。包不再提供按模块生成 HTML/CSS 的公开 `build()`。
|
|
73
91
|
|
|
74
|
-
`npm run preview`
|
|
92
|
+
`npm run preview` 启动 JSON 文件导入测试台,使用同一通用页面、同源配置和内存存储验证模块行为。
|
|
75
93
|
|
|
76
|
-
[接口规范](apifox/Specification.md) · [Apifox JSON](apifox/preference-panes.apifox.json)
|
|
94
|
+
[接口规范](apifox/Specification.md) · [宿主集成](apifox/HostIntegration.md) · [Apifox JSON](apifox/preference-panes.apifox.json)
|
package/dist/api.js
CHANGED
|
@@ -1957,31 +1957,21 @@
|
|
|
1957
1957
|
const match = /^\/api\/([a-zA-Z0-9_-]+)(?:\/(get|set|delete))?\/?$/.exec(url.pathname);
|
|
1958
1958
|
if (!match) return;
|
|
1959
1959
|
const [, module, action] = match;
|
|
1960
|
-
const
|
|
1960
|
+
const configuration = `${url.origin}/configs/${module}`;
|
|
1961
1961
|
switch (true) {
|
|
1962
1962
|
case !action && request.method === "HEAD":
|
|
1963
|
-
return this.#probe(request,
|
|
1964
|
-
case !action && request.method === "GET":
|
|
1965
|
-
return this.#model(request, module, configURL);
|
|
1963
|
+
return this.#probe(request, configuration);
|
|
1966
1964
|
case Boolean(action) && request.method === "POST":
|
|
1967
|
-
return this.#action(request, module, action,
|
|
1965
|
+
return this.#action(request, module, action, configuration);
|
|
1968
1966
|
default:
|
|
1969
|
-
return this.#response(request, 405, { error: "Use
|
|
1967
|
+
return this.#response(request, 405, { error: "Use HEAD for module probes and POST for module actions" });
|
|
1970
1968
|
}
|
|
1971
1969
|
}
|
|
1972
1970
|
|
|
1973
|
-
#
|
|
1974
|
-
const headers = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
|
|
1975
|
-
const source = headers["x-preferencepanes-json"] ?? `/configs/${module}`;
|
|
1976
|
-
if (/^https?:\/\//i.test(source)) return source;
|
|
1977
|
-
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(source)) throw Object.assign(new TypeError("BoxJS resources must use HTTP(S) URLs"), { status: 400 });
|
|
1978
|
-
return `${url.origin}/${source.replace(/^\/+/, "")}`;
|
|
1979
|
-
}
|
|
1980
|
-
|
|
1981
|
-
async #probe(request, configURL) {
|
|
1971
|
+
async #probe(request, configuration) {
|
|
1982
1972
|
let result;
|
|
1983
1973
|
try {
|
|
1984
|
-
result = await fetch({ url:
|
|
1974
|
+
result = await fetch({ url: configuration, method: "HEAD", timeout: 5000, headers: { Accept: "application/json" } });
|
|
1985
1975
|
} catch (error) {
|
|
1986
1976
|
return this.#response(request, 502, { error: error.message });
|
|
1987
1977
|
}
|
|
@@ -1989,19 +1979,9 @@
|
|
|
1989
1979
|
return this.#response(request, result.statusCode ?? result.status, undefined, version ? { "X-PreferencePanes-Version": version } : {});
|
|
1990
1980
|
}
|
|
1991
1981
|
|
|
1992
|
-
async #
|
|
1993
|
-
const loaded = await this.#load(module, configURL);
|
|
1994
|
-
const values = {};
|
|
1995
|
-
for (const entry of loaded.entries) {
|
|
1996
|
-
const value = Storage.getItem(entry.id, MISSING);
|
|
1997
|
-
if (value !== MISSING) values[entry.id.slice(loaded.storageKey.length + 2)] = value;
|
|
1998
|
-
}
|
|
1999
|
-
return this.#response(request, 200, { module, boxjs: loaded.boxjs, values, configURL }, loaded.version ? { "X-PreferencePanes-Version": loaded.version } : {});
|
|
2000
|
-
}
|
|
2001
|
-
|
|
2002
|
-
async #action(request, module, action, configURL) {
|
|
1982
|
+
async #action(request, module, action, configuration) {
|
|
2003
1983
|
const payload = this.#jsonBody(request);
|
|
2004
|
-
const target = await this.#load(module,
|
|
1984
|
+
const target = await this.#load(module, configuration);
|
|
2005
1985
|
switch (action) {
|
|
2006
1986
|
case "get": {
|
|
2007
1987
|
const value = Storage.getItem(payload?.scope ? this.#scopePath(target, payload.scope) : this.#storagePath(target, payload?.key), MISSING);
|
|
@@ -2019,10 +1999,10 @@
|
|
|
2019
1999
|
}
|
|
2020
2000
|
}
|
|
2021
2001
|
|
|
2022
|
-
async #load(module,
|
|
2002
|
+
async #load(module, configuration) {
|
|
2023
2003
|
let result;
|
|
2024
2004
|
try {
|
|
2025
|
-
result = await fetch({ url:
|
|
2005
|
+
result = await fetch({ url: configuration, method: "GET", timeout: 5000, headers: { Accept: "application/json" } });
|
|
2026
2006
|
} catch (error) {
|
|
2027
2007
|
throw Object.assign(new Error(`Configuration request failed: ${error.message}`), { status: 502 });
|
|
2028
2008
|
}
|
|
@@ -2053,7 +2033,7 @@
|
|
|
2053
2033
|
}
|
|
2054
2034
|
}
|
|
2055
2035
|
if (!entries.length) throw new TypeError(`No BoxJS settings for module: ${module}`);
|
|
2056
|
-
return {
|
|
2036
|
+
return { entries, module, storageKey, version: this.#header(result.headers, "x-preferencepanes-version") };
|
|
2057
2037
|
} catch (error) {
|
|
2058
2038
|
throw Object.assign(new Error(`Invalid BoxJS: ${error.message}`), { status: 422 });
|
|
2059
2039
|
}
|
package/dist/module/index.html
CHANGED
package/dist/module/index.mjs
CHANGED
|
@@ -1,21 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 统一解析模块页的资源地址:Header 优先于查询参数,再使用模块约定。
|
|
3
|
-
* Resolve module resource locations: headers override query parameters and module conventions.
|
|
4
|
-
* @param {URL} url 已解析的页面请求地址 / Parsed page request URL.
|
|
5
|
-
* @param {Record<string, string | undefined>} [headers] 请求头,名称不区分大小写 / Case-insensitive request headers.
|
|
6
|
-
* @returns {{url: string, module: string, json: string, css: string}} 页面上下文与两个资源输入 / Page context and two resource inputs.
|
|
7
|
-
*/
|
|
8
|
-
function pageInputs(url, headers = {}) {
|
|
9
|
-
const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(url.pathname);
|
|
10
|
-
if (!match) throw new TypeError("Open a concrete module URL");
|
|
11
|
-
const module = match[1];
|
|
12
|
-
const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
|
|
13
|
-
const json = values["x-preferencepanes-json"] ?? url.searchParams.get("json") ?? `/configs/${module}`;
|
|
14
|
-
const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? "";
|
|
15
|
-
if (!json.trim()) throw new TypeError("JSON resource URL is required");
|
|
16
|
-
return { url: url.href, module, json, css };
|
|
17
|
-
}
|
|
18
|
-
|
|
19
1
|
/**
|
|
20
2
|
* 创建元素,所有展示文本通过 textContent 写入。
|
|
21
3
|
* Create elements and assign display text through textContent only.
|
|
@@ -154,8 +136,8 @@ function normalizeBoxJs(config, module) {
|
|
|
154
136
|
target.owners.add(app);
|
|
155
137
|
}
|
|
156
138
|
}
|
|
157
|
-
if (
|
|
158
|
-
const target =
|
|
139
|
+
if (modules.size !== 1) throw new TypeError("Import BoxJS JSON for exactly one module");
|
|
140
|
+
const target = modules.values().next().value ;
|
|
159
141
|
if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
|
|
160
142
|
const metadata = normalizeMetadata(target.owners.size === 1 ? presentation([...target.owners][0]) : {});
|
|
161
143
|
const fields = [];
|
|
@@ -446,28 +428,50 @@ class ActionMenu {
|
|
|
446
428
|
*/
|
|
447
429
|
class PreferencesClient {
|
|
448
430
|
#module;
|
|
449
|
-
#configURL;
|
|
450
431
|
#definition;
|
|
451
432
|
#request;
|
|
452
433
|
#notify;
|
|
453
434
|
#timeout;
|
|
454
435
|
#session = new AbortController();
|
|
455
|
-
#values;
|
|
436
|
+
#values = {};
|
|
456
437
|
#saving = false;
|
|
457
438
|
|
|
458
439
|
/**
|
|
459
|
-
*
|
|
460
|
-
* Create a page client that
|
|
461
|
-
* @param {import("./client.mjs").PreferencesClientOptions} options
|
|
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.
|
|
462
443
|
*/
|
|
463
|
-
constructor({
|
|
464
|
-
this.#module =
|
|
465
|
-
this.#configURL = model.configURL;
|
|
444
|
+
constructor({ definition, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
|
|
445
|
+
this.#module = definition.module;
|
|
466
446
|
this.#definition = definition;
|
|
467
447
|
this.#request = request;
|
|
468
448
|
this.#notify = notify;
|
|
469
449
|
this.#timeout = timeout;
|
|
470
|
-
|
|
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();
|
|
471
475
|
}
|
|
472
476
|
|
|
473
477
|
/**
|
|
@@ -566,7 +570,7 @@ class PreferencesClient {
|
|
|
566
570
|
credentials: "omit",
|
|
567
571
|
cache: "no-store",
|
|
568
572
|
signal: controller.signal,
|
|
569
|
-
headers: { "Content-Type": "application/json"
|
|
573
|
+
headers: { "Content-Type": "application/json" },
|
|
570
574
|
body: JSON.stringify(payload),
|
|
571
575
|
});
|
|
572
576
|
if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
|
|
@@ -590,6 +594,10 @@ class PreferencesClient {
|
|
|
590
594
|
if (this.#saving) throw new Error("A settings write is already in progress");
|
|
591
595
|
this.#saving = true;
|
|
592
596
|
try {
|
|
597
|
+
if (operation === "write") {
|
|
598
|
+
const field = this.#definition.fields.find(candidate => candidate.key === key);
|
|
599
|
+
if (!field || !validValue(field, payload.value)) throw new TypeError("Invalid setting value");
|
|
600
|
+
}
|
|
593
601
|
await this.#send(action, payload);
|
|
594
602
|
switch (operation) {
|
|
595
603
|
case "write":
|
|
@@ -787,24 +795,23 @@ class PreferencesPanel {
|
|
|
787
795
|
#release;
|
|
788
796
|
|
|
789
797
|
/**
|
|
790
|
-
* 挂载
|
|
791
|
-
* Mount the module form
|
|
798
|
+
* 挂载 BoxJS 定义对应的模块表单。
|
|
799
|
+
* Mount the module form described by a BoxJS definition.
|
|
792
800
|
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
793
|
-
* @param {import("../index.js").
|
|
801
|
+
* @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
|
|
794
802
|
*/
|
|
795
|
-
constructor(root,
|
|
796
|
-
this.#release = this.#mount(root,
|
|
803
|
+
constructor(root, definition) {
|
|
804
|
+
this.#release = this.#mount(root, definition);
|
|
797
805
|
}
|
|
798
806
|
|
|
799
807
|
/**
|
|
800
808
|
* 建立面板 DOM、交互和会话,并返回其释放操作。
|
|
801
809
|
* Build panel DOM, interactions, and session, then return its release operation.
|
|
802
810
|
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
803
|
-
* @param {import("../index.js").
|
|
811
|
+
* @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
|
|
804
812
|
* @returns {() => void} 释放操作 / Release operation.
|
|
805
813
|
*/
|
|
806
|
-
#mount(root,
|
|
807
|
-
const { definition } = model;
|
|
814
|
+
#mount(root, definition) {
|
|
808
815
|
const title = definition.metadata?.name ?? definition.module;
|
|
809
816
|
const document = root.ownerDocument;
|
|
810
817
|
const window = document.defaultView;
|
|
@@ -896,7 +903,7 @@ class PreferencesPanel {
|
|
|
896
903
|
toast.hidden = true;
|
|
897
904
|
}, 2400);
|
|
898
905
|
};
|
|
899
|
-
const client = new PreferencesClient({
|
|
906
|
+
const client = new PreferencesClient({ definition, notify });
|
|
900
907
|
/**
|
|
901
908
|
* 两种菜单入口共用异步错误处理,包含宿主确认框错误。
|
|
902
909
|
* Share async error handling between both menus, including host-dialog errors.
|
|
@@ -924,6 +931,7 @@ class PreferencesPanel {
|
|
|
924
931
|
publishNavigation();
|
|
925
932
|
viewport.replaceChildren(statusView("读取设置…"));
|
|
926
933
|
try {
|
|
934
|
+
await client.open();
|
|
927
935
|
if (version === generation) controls();
|
|
928
936
|
} catch (error) {
|
|
929
937
|
if (version !== generation) return;
|
|
@@ -1312,15 +1320,14 @@ function installDefaultStyles(document) {
|
|
|
1312
1320
|
}
|
|
1313
1321
|
|
|
1314
1322
|
/**
|
|
1315
|
-
*
|
|
1316
|
-
* Manage
|
|
1323
|
+
* 管理 BoxJS 规范化、主题同步和面板生命周期。
|
|
1324
|
+
* Manage BoxJS normalization, theme synchronization, and panel lifecycle.
|
|
1317
1325
|
*/
|
|
1318
1326
|
class PreferencesView {
|
|
1319
1327
|
#existing;
|
|
1320
1328
|
#root;
|
|
1321
1329
|
#base;
|
|
1322
1330
|
#ownsBase;
|
|
1323
|
-
#custom;
|
|
1324
1331
|
#previousTitle;
|
|
1325
1332
|
#previousTheme;
|
|
1326
1333
|
#systemTheme;
|
|
@@ -1330,22 +1337,12 @@ class PreferencesView {
|
|
|
1330
1337
|
#panel;
|
|
1331
1338
|
|
|
1332
1339
|
/**
|
|
1333
|
-
*
|
|
1334
|
-
* Mount a settings page from
|
|
1335
|
-
* @param {import("../index.js").
|
|
1336
|
-
* @param {string} [css] 可选 CSS 正文 / Optional module-scoped CSS text.
|
|
1340
|
+
* 使用原始 BoxJS JSON 挂载设置页。
|
|
1341
|
+
* Mount a settings page from raw BoxJS JSON.
|
|
1342
|
+
* @param {import("../index.js").BoxJSInput} boxjs 单模块 BoxJS JSON / Single-module BoxJS JSON.
|
|
1337
1343
|
*/
|
|
1338
|
-
constructor(
|
|
1339
|
-
|
|
1340
|
-
const definition = normalizeBoxJs(model.boxjs, model.module);
|
|
1341
|
-
const values = { ...model.values };
|
|
1342
|
-
for (const field of definition.fields) {
|
|
1343
|
-
if (values[field.key] === undefined) continue;
|
|
1344
|
-
values[field.key] = normalizeStoredValue(field, values[field.key]);
|
|
1345
|
-
if (!validValue(field, values[field.key])) throw new TypeError(`Invalid stored value: ${field.key}`);
|
|
1346
|
-
}
|
|
1347
|
-
for (const field of definition.fields) if (values[field.key] === undefined && Object.hasOwn(field, "defaultValue")) values[field.key] = structuredClone(field.defaultValue);
|
|
1348
|
-
const rendered = { ...model, definition, values };
|
|
1344
|
+
constructor(boxjs) {
|
|
1345
|
+
const definition = normalizeBoxJs(boxjs);
|
|
1349
1346
|
const metadata = definition.metadata ?? {};
|
|
1350
1347
|
const image = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
|
|
1351
1348
|
if (image) resourceURL(image);
|
|
@@ -1360,9 +1357,6 @@ class PreferencesView {
|
|
|
1360
1357
|
const styles = installDefaultStyles(document);
|
|
1361
1358
|
this.#base = styles.element;
|
|
1362
1359
|
this.#ownsBase = styles.owned;
|
|
1363
|
-
this.#custom = element("style", "");
|
|
1364
|
-
this.#custom.textContent = css;
|
|
1365
|
-
document.head.append(this.#custom);
|
|
1366
1360
|
this.#previousTitle = document.title;
|
|
1367
1361
|
this.#previousTheme = document.documentElement.dataset.theme;
|
|
1368
1362
|
this.#systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
|
|
@@ -1377,7 +1371,7 @@ class PreferencesView {
|
|
|
1377
1371
|
document.title = metadata.name ?? definition.module;
|
|
1378
1372
|
try {
|
|
1379
1373
|
this.#root.replaceChildren();
|
|
1380
|
-
this.#panel = new PreferencesPanel(this.#root,
|
|
1374
|
+
this.#panel = new PreferencesPanel(this.#root, definition);
|
|
1381
1375
|
} catch (error) {
|
|
1382
1376
|
this.destroy();
|
|
1383
1377
|
throw error;
|
|
@@ -1405,7 +1399,6 @@ class PreferencesView {
|
|
|
1405
1399
|
this.#systemTheme.removeEventListener("change", this.#syncAppearance);
|
|
1406
1400
|
this.#panel?.destroy();
|
|
1407
1401
|
if (this.#ownsBase) this.#base.remove();
|
|
1408
|
-
this.#custom.remove();
|
|
1409
1402
|
if (this.#existing) this.#root.replaceChildren();
|
|
1410
1403
|
else this.#root.remove();
|
|
1411
1404
|
document.title = this.#previousTitle;
|
|
@@ -1416,11 +1409,20 @@ class PreferencesView {
|
|
|
1416
1409
|
}
|
|
1417
1410
|
|
|
1418
1411
|
/**
|
|
1419
|
-
*
|
|
1420
|
-
*
|
|
1412
|
+
* 使用原始 BoxJS JSON 挂载设置页。
|
|
1413
|
+
* Mount a settings page from raw BoxJS JSON.
|
|
1414
|
+
* @param {import("../index.js").BoxJSInput} boxjs 单模块 BoxJS JSON / Single-module BoxJS JSON.
|
|
1415
|
+
* @returns {import("./index.js").MountedPreferences} 模块视图 / Module view.
|
|
1416
|
+
*/
|
|
1417
|
+
function mount(boxjs) {
|
|
1418
|
+
return new PreferencesView(boxjs);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/**
|
|
1422
|
+
* 管理模块文档的配置请求、重载和错误状态。
|
|
1423
|
+
* Manage configuration requests, reloads, and error states for a module document.
|
|
1421
1424
|
*/
|
|
1422
1425
|
class ModulePage {
|
|
1423
|
-
#document;
|
|
1424
1426
|
#window;
|
|
1425
1427
|
#root;
|
|
1426
1428
|
#view;
|
|
@@ -1431,7 +1433,6 @@ class ModulePage {
|
|
|
1431
1433
|
* @param {Document} document 模块文档 / Module document.
|
|
1432
1434
|
*/
|
|
1433
1435
|
constructor(document) {
|
|
1434
|
-
this.#document = document;
|
|
1435
1436
|
this.#window = document.defaultView;
|
|
1436
1437
|
this.#root = document.querySelector("#preferences");
|
|
1437
1438
|
installDefaultStyles(document);
|
|
@@ -1439,8 +1440,8 @@ class ModulePage {
|
|
|
1439
1440
|
}
|
|
1440
1441
|
|
|
1441
1442
|
/**
|
|
1442
|
-
*
|
|
1443
|
-
*
|
|
1443
|
+
* 从规范模块路径读取 BoxJS JSON 并挂载通用前端。
|
|
1444
|
+
* Read BoxJS JSON from the conventional module path and mount the generic frontend.
|
|
1444
1445
|
* @returns {Promise<void>} 启动完成 / Startup completion.
|
|
1445
1446
|
*/
|
|
1446
1447
|
async start() {
|
|
@@ -1448,12 +1449,13 @@ class ModulePage {
|
|
|
1448
1449
|
this.#view?.destroy();
|
|
1449
1450
|
this.#view = undefined;
|
|
1450
1451
|
this.#root.replaceChildren(statusView("读取设置…"));
|
|
1451
|
-
const
|
|
1452
|
-
const
|
|
1453
|
-
const
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1452
|
+
const embedded = this.#window.frameElement?.dataset.preferencePanesModule;
|
|
1453
|
+
const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(this.#window.location.pathname);
|
|
1454
|
+
const module = embedded ?? match?.[1];
|
|
1455
|
+
if (!module) throw new TypeError("Open a concrete module URL");
|
|
1456
|
+
const response = await fetch(`/configs/${encodeURIComponent(module)}`, { cache: "no-store", credentials: "omit", headers: { Accept: "application/json" } });
|
|
1457
|
+
if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
|
|
1458
|
+
this.#view = mount(await response.json());
|
|
1457
1459
|
} catch (error) {
|
|
1458
1460
|
this.#root.replaceChildren(statusView(`加载失败:${error.message}`, () => this.start()));
|
|
1459
1461
|
}
|
|
@@ -1470,39 +1472,6 @@ class ModulePage {
|
|
|
1470
1472
|
this.#view = undefined;
|
|
1471
1473
|
}
|
|
1472
1474
|
|
|
1473
|
-
/**
|
|
1474
|
-
* 读取嵌入参数、文档元数据或当前 URL 输入。
|
|
1475
|
-
* Read embedded parameters, document metadata, or current URL inputs.
|
|
1476
|
-
* @returns {ReturnType<typeof pageInputs>} 页面输入 / Page inputs.
|
|
1477
|
-
*/
|
|
1478
|
-
#readInputs() {
|
|
1479
|
-
const context = this.#document.querySelector('meta[name="preference-panes-inputs"]');
|
|
1480
|
-
const embedded = this.#window.frameElement?.dataset.preferencePanes;
|
|
1481
|
-
switch (true) {
|
|
1482
|
-
case embedded !== undefined:
|
|
1483
|
-
this.#document.documentElement.dataset.preferencePanesEmbedded = "";
|
|
1484
|
-
return JSON.parse(embedded);
|
|
1485
|
-
case context !== null:
|
|
1486
|
-
return JSON.parse(decodeURIComponent(context.content));
|
|
1487
|
-
default:
|
|
1488
|
-
return pageInputs(new URL(this.#window.location.href));
|
|
1489
|
-
}
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
/**
|
|
1493
|
-
* 将可选页面资源限制为 HTTP(S) 地址。
|
|
1494
|
-
* Restrict an optional page resource to an HTTP(S) URL.
|
|
1495
|
-
* @param {string | undefined} source 资源地址 / Resource location.
|
|
1496
|
-
* @param {string} baseURL 页面基准地址 / Page base URL.
|
|
1497
|
-
* @returns {string | null} 绝对资源地址 / Absolute resource URL.
|
|
1498
|
-
*/
|
|
1499
|
-
#resourceURL(source, baseURL) {
|
|
1500
|
-
if (!source) return null;
|
|
1501
|
-
const url = new URL(source, baseURL);
|
|
1502
|
-
if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Resources must use HTTP(S) URLs");
|
|
1503
|
-
return url.href;
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
1475
|
/**
|
|
1507
1476
|
* 从前进后退缓存恢复时重新加载模块。
|
|
1508
1477
|
* Reload the module when restored from the back-forward cache.
|
|
@@ -1515,5 +1484,3 @@ class ModulePage {
|
|
|
1515
1484
|
}
|
|
1516
1485
|
|
|
1517
1486
|
new ModulePage(document).start();
|
|
1518
|
-
|
|
1519
|
-
export { ModulePage };
|