@nsnanocat/preference-panes 0.7.1 → 0.8.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 +31 -68
- package/dist/api.js +1622 -0
- package/dist/module/app.mjs +52 -32
- package/dist/module/index.html +1 -1
- package/dist/module/navigation.mjs +101 -1
- package/dist/preference-panes.mjs +38 -30
- package/package.json +1 -1
- package/src/Store.mjs +40 -53
- package/src/browser/ModuleFrame.mjs +83 -0
- package/src/browser/Navigation.d.mts +42 -0
- package/src/browser/Navigation.mjs +2 -0
- package/src/browser/app.mjs +13 -1
- package/src/browser/client.d.mts +2 -2
- package/src/browser/client.mjs +21 -21
- package/src/browser/panel.css +9 -0
- package/src/browser/panel.mjs +16 -0
- package/src/build.mjs +3 -13
- package/src/index.d.ts +6 -6
- package/src/lib/page-inputs.mjs +1 -1
- package/src/lib/settings-path.mjs +0 -18
- package/src/proxy/handler.mjs +9 -22
- package/dist/preference-panes.config.js +0 -842
- package/dist/preference-panes.proxy.js +0 -1754
- package/src/proxy/config.mjs +0 -14
package/src/Store.mjs
CHANGED
|
@@ -2,83 +2,70 @@ import { URL } from "@nsnanocat/url";
|
|
|
2
2
|
import { Lodash as _ } from "@nsnanocat/util/polyfill/Lodash.mjs";
|
|
3
3
|
import { Storage } from "@nsnanocat/util/polyfill/Storage";
|
|
4
4
|
import { response } from "./lib/response.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { validatePathParts } from "./lib/settings-path.mjs";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* 无配置绑定的本地存储桥接;form 字段名就是完整 @root.path。
|
|
9
|
+
* Unbound local storage bridge; the form field name is the complete @root.path.
|
|
10
10
|
*/
|
|
11
11
|
export class Store {
|
|
12
|
-
#catalog;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* 复用包内已解析的目录,构造时不访问网络或存储。
|
|
16
|
-
* Reuse the parsed internal catalog without network or persistence access during construction.
|
|
17
|
-
* @param {import("./BoxJS.mjs").BoxJS} catalog BoxJS 路径目录 / BoxJS path catalog.
|
|
18
|
-
*/
|
|
19
|
-
constructor(catalog) {
|
|
20
|
-
this.#catalog = catalog;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
12
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
13
|
+
* POST /api/get、set、delete;不下载配置、不解析控件、不鉴权。
|
|
14
|
+
* POST /api/get, set or delete without config downloads, control parsing or authentication.
|
|
26
15
|
* @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
|
|
27
|
-
* @param {URL} [url]
|
|
28
|
-
* @returns {Promise<import("./index.js").SettingsResponse | undefined>}
|
|
16
|
+
* @param {URL} [url] 已解析地址 / Parsed URL.
|
|
17
|
+
* @returns {Promise<import("./index.js").SettingsResponse | undefined>} 操作结果 / Operation result.
|
|
29
18
|
*/
|
|
30
19
|
async handle(request, url = new URL(request.url)) {
|
|
31
20
|
if (!url.pathname.startsWith("/api/")) return;
|
|
32
21
|
const reply = (status, data) => response(request, status, data);
|
|
33
|
-
|
|
22
|
+
const action = url.pathname.slice(5);
|
|
23
|
+
if (!["get", "set", "delete"].includes(action)) return reply(404, { error: "Unknown action" });
|
|
24
|
+
if (request.method !== "POST") return reply(405, { error: "Use POST with a form body" });
|
|
25
|
+
const headers = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
|
|
26
|
+
if (headers["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/x-www-form-urlencoded") return reply(415, { error: "Expected application/x-www-form-urlencoded" });
|
|
27
|
+
if (typeof request.body !== "string" || request.body.length > 65536) return reply(400, { error: "Expected a form body up to 65536 characters" });
|
|
28
|
+
let parts, value;
|
|
34
29
|
try {
|
|
35
|
-
|
|
30
|
+
const fields = request.body.split("&");
|
|
31
|
+
if (fields.length !== 1) throw new TypeError("Send exactly one storage key");
|
|
32
|
+
const separator = fields[0].indexOf("=");
|
|
33
|
+
if (separator < 0) throw new TypeError("Expected @root.path=value");
|
|
34
|
+
const key = decodeURIComponent(fields[0].slice(0, separator).replace(/\+/g, " "));
|
|
35
|
+
value = decodeURIComponent(fields[0].slice(separator + 1).replace(/\+/g, " "));
|
|
36
|
+
if (!key.startsWith("@")) throw new TypeError("Storage keys must start with @");
|
|
37
|
+
parts = validatePathParts(key.slice(1).split("."));
|
|
38
|
+
if (parts.length < 2) throw new TypeError("Specify a storage root and child path");
|
|
36
39
|
} catch (error) {
|
|
37
40
|
return reply(400, { error: error.message });
|
|
38
41
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
case "HEAD":
|
|
46
|
-
return reply(200, undefined);
|
|
47
|
-
case "GET":
|
|
48
|
-
case "DELETE":
|
|
49
|
-
break;
|
|
50
|
-
case "POST":
|
|
51
|
-
if (requestHeaders["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/json") return reply(415, { error: "Expected application/json" });
|
|
52
|
-
if (typeof request.body !== "string") return reply(400, { error: "Expected a JSON string body" });
|
|
53
|
-
if (request.body.length > 65536) return reply(413, { error: "Body exceeds 65536 UTF-16 code units" });
|
|
54
|
-
try {
|
|
55
|
-
value = JSON.parse(request.body);
|
|
56
|
-
} catch {
|
|
57
|
-
return reply(400, { error: "Invalid JSON" });
|
|
58
|
-
}
|
|
59
|
-
break;
|
|
60
|
-
default:
|
|
61
|
-
return { ...reply(405, { error: "Method not allowed" }), headers: { ...reply(405).headers, Allow: "HEAD, GET, POST, DELETE" } };
|
|
42
|
+
if (action === "set") {
|
|
43
|
+
try {
|
|
44
|
+
value = JSON.parse(value);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
47
|
+
}
|
|
62
48
|
}
|
|
49
|
+
const [storageKey, ...path] = parts;
|
|
63
50
|
try {
|
|
64
|
-
const root = Storage.getItem(
|
|
65
|
-
if (!isRecord(root)) throw new TypeError("
|
|
66
|
-
const parent = storageParent(root,
|
|
67
|
-
const key =
|
|
68
|
-
switch (
|
|
69
|
-
case "
|
|
51
|
+
const root = Storage.getItem(storageKey, {});
|
|
52
|
+
if (!isRecord(root)) throw new TypeError("Stored root must be an object");
|
|
53
|
+
const parent = storageParent(root, path, action === "set");
|
|
54
|
+
const key = path.at(-1);
|
|
55
|
+
switch (action) {
|
|
56
|
+
case "get": {
|
|
70
57
|
const result = parent ? _.get(parent, [key]) : undefined;
|
|
71
58
|
return result === undefined ? reply(404, { error: "Stored path does not exist" }) : reply(200, result);
|
|
72
59
|
}
|
|
73
|
-
case "
|
|
60
|
+
case "set":
|
|
74
61
|
_.set(parent, [key], value);
|
|
75
62
|
break;
|
|
76
|
-
case "
|
|
63
|
+
case "delete":
|
|
77
64
|
if (parent) _.unset(parent, [key]);
|
|
78
65
|
break;
|
|
79
66
|
}
|
|
80
|
-
if (!Storage.setItem(
|
|
81
|
-
return reply(200,
|
|
67
|
+
if (!Storage.setItem(storageKey, root)) throw new Error("Storage write failed");
|
|
68
|
+
return reply(200, action === "set" ? { saved: true } : { deleted: true });
|
|
82
69
|
} catch (error) {
|
|
83
70
|
return reply(500, { error: error.message });
|
|
84
71
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { pageInputs } from "../lib/page-inputs.mjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 模块文档容器:原始 HTML 不改写,请求上下文随 iframe 元素传递。
|
|
5
|
+
* Module document container: preserve HTML verbatim and carry request context on the iframe element.
|
|
6
|
+
*/
|
|
7
|
+
export class ModuleFrame extends EventTarget {
|
|
8
|
+
#url;
|
|
9
|
+
#options;
|
|
10
|
+
#controller = new AbortController();
|
|
11
|
+
#abort = () => this.destroy();
|
|
12
|
+
#state;
|
|
13
|
+
#change = event => {
|
|
14
|
+
this.#state = event.detail;
|
|
15
|
+
this.dispatchEvent(new Event("change"));
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 建立 iframe 与请求输入;调用方挂载 element 后调用 load。
|
|
20
|
+
* Create the iframe and request inputs; callers mount element and then call load.
|
|
21
|
+
* @param {string | URL} url 模块请求地址 / Module request URL.
|
|
22
|
+
* @param {RequestInit} [options] 原生请求头和取消信号 / Native headers and cancellation signal.
|
|
23
|
+
*/
|
|
24
|
+
constructor(url, options = {}) {
|
|
25
|
+
super();
|
|
26
|
+
this.#url = new URL(url, document.baseURI);
|
|
27
|
+
this.#options = { ...options, headers: new Headers(options.headers) };
|
|
28
|
+
const inputs = pageInputs(this.#url, Object.fromEntries(this.#options.headers));
|
|
29
|
+
this.element = document.createElement("iframe");
|
|
30
|
+
this.element.title = `${inputs.module} 设置`;
|
|
31
|
+
this.element.dataset.preferencePanes = JSON.stringify(inputs);
|
|
32
|
+
this.element.addEventListener("preferencepanes:change", this.#change);
|
|
33
|
+
this.#state = { title: inputs.module, module: inputs.module, busy: false, canGoBack: true };
|
|
34
|
+
options.signal?.addEventListener("abort", this.#abort, { once: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 当前模块导航状态。
|
|
39
|
+
* Current module navigation state.
|
|
40
|
+
*/
|
|
41
|
+
get state() {
|
|
42
|
+
return { ...this.#state };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 获取原始 HTML;晚到响应在退出后不得重新挂载。
|
|
47
|
+
* Fetch unmodified HTML; a late response must not remount after departure.
|
|
48
|
+
* @returns {Promise<void>} HTML 已交给 iframe;表单状态通过 change 事件提供 / HTML assigned; form state is reported through change.
|
|
49
|
+
*/
|
|
50
|
+
async load() {
|
|
51
|
+
if (this.#options.signal?.aborted) this.destroy();
|
|
52
|
+
const timer = setTimeout(() => this.#controller.abort(), 10000);
|
|
53
|
+
try {
|
|
54
|
+
const response = await fetch(this.#url, { cache: "no-store", credentials: "omit", ...this.#options, signal: this.#controller.signal });
|
|
55
|
+
if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
|
|
56
|
+
const html = await response.text();
|
|
57
|
+
this.#controller.signal.throwIfAborted();
|
|
58
|
+
this.element.srcdoc = html;
|
|
59
|
+
} finally {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 使用 iframe 的联合历史返回;写入期间不导航。
|
|
66
|
+
* Navigate joint iframe history back, except while a write is pending.
|
|
67
|
+
* @returns {void} 无返回值 / No return value.
|
|
68
|
+
*/
|
|
69
|
+
back() {
|
|
70
|
+
if (!this.#state.busy && this.#state.canGoBack) this.element.contentWindow.history.back();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 取消加载与事件订阅;节点保留到 Navigation 的退出动画结束。
|
|
75
|
+
* Cancel loading and subscriptions; Navigation retains the node until its exit animation ends.
|
|
76
|
+
* @returns {void} 无返回值 / No return value.
|
|
77
|
+
*/
|
|
78
|
+
destroy() {
|
|
79
|
+
this.#controller.abort();
|
|
80
|
+
this.#options.signal?.removeEventListener("abort", this.#abort);
|
|
81
|
+
this.element.removeEventListener("preferencepanes:change", this.#change);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -1,3 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 原始 HTML 的模块 iframe 容器;通过元素传递请求上下文。
|
|
3
|
+
* Iframe container preserving module HTML and carrying context on the element.
|
|
4
|
+
*/
|
|
5
|
+
export class ModuleFrame extends EventTarget {
|
|
6
|
+
/**
|
|
7
|
+
* 创建容器。
|
|
8
|
+
* Create a container.
|
|
9
|
+
* @param url 模块地址 / Module URL.
|
|
10
|
+
* @param options 原生请求参数 / Native request options.
|
|
11
|
+
*/
|
|
12
|
+
constructor(url: string | URL, options?: RequestInit);
|
|
13
|
+
/**
|
|
14
|
+
* 宿主挂载节点。
|
|
15
|
+
* Host-mounted element.
|
|
16
|
+
*/
|
|
17
|
+
readonly element: HTMLIFrameElement;
|
|
18
|
+
/**
|
|
19
|
+
* change 事件对应的导航状态。
|
|
20
|
+
* Navigation state exposed with change events.
|
|
21
|
+
*/
|
|
22
|
+
readonly state: { title: string; module: string; busy: boolean; canGoBack: boolean };
|
|
23
|
+
/**
|
|
24
|
+
* 获取原始 HTML。
|
|
25
|
+
* Fetch unmodified HTML.
|
|
26
|
+
* @returns 加载完成 / Load completion.
|
|
27
|
+
*/
|
|
28
|
+
load(): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* 沿模块历史返回。
|
|
31
|
+
* Go back through module history.
|
|
32
|
+
* @returns 无返回值 / No return value.
|
|
33
|
+
*/
|
|
34
|
+
back(): void;
|
|
35
|
+
/**
|
|
36
|
+
* 取消请求与事件订阅。
|
|
37
|
+
* Cancel requests and subscriptions.
|
|
38
|
+
* @returns 无返回值 / No return value.
|
|
39
|
+
*/
|
|
40
|
+
destroy(): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
1
43
|
/**
|
|
2
44
|
* 同一文档的根页/子页导航,不定义页面布局或模块业务。
|
|
3
45
|
* Home/detail navigation within a document, without layout or module business rules.
|
package/src/browser/app.mjs
CHANGED
|
@@ -14,7 +14,19 @@ async function start() {
|
|
|
14
14
|
view?.destroy();
|
|
15
15
|
view = undefined;
|
|
16
16
|
const context = document.querySelector('meta[name="preference-panes-inputs"]');
|
|
17
|
-
const
|
|
17
|
+
const embedded = window.frameElement?.dataset.preferencePanes;
|
|
18
|
+
let inputs;
|
|
19
|
+
switch (true) {
|
|
20
|
+
case embedded !== undefined:
|
|
21
|
+
inputs = JSON.parse(embedded);
|
|
22
|
+
document.documentElement.dataset.preferencePanesEmbedded = "";
|
|
23
|
+
break;
|
|
24
|
+
case context !== null:
|
|
25
|
+
inputs = JSON.parse(decodeURIComponent(context.content));
|
|
26
|
+
break;
|
|
27
|
+
default:
|
|
28
|
+
inputs = pageInputs(new URL(location.href));
|
|
29
|
+
}
|
|
18
30
|
const resources = [inputs.json, inputs.css].map(source => {
|
|
19
31
|
if (!source) return null;
|
|
20
32
|
const url = new URL(source, inputs.url);
|
package/src/browser/client.d.mts
CHANGED
|
@@ -111,8 +111,8 @@ export interface PreferencesClient {
|
|
|
111
111
|
*/
|
|
112
112
|
set(module: string, key: string, value: SettingsScalar | SettingsScalar[]): Promise<void>;
|
|
113
113
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
114
|
+
* POST /api/delete 删除覆盖值,200 后显示默认值,不追加读取。
|
|
115
|
+
* POST /api/delete removes an override and displays its default after 200, without rereading.
|
|
116
116
|
* @param module 已打开的模块 / Open module.
|
|
117
117
|
* @param key 完整点分字段路径 / Complete dotted field path.
|
|
118
118
|
* @returns 操作完成 / Completion of the operation.
|
package/src/browser/client.mjs
CHANGED
|
@@ -24,31 +24,31 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
|
|
|
24
24
|
*/
|
|
25
25
|
const sessions = new Map();
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
28
|
-
* Send a
|
|
29
|
-
* @param {string} path
|
|
30
|
-
* @param {"
|
|
31
|
-
* @param {unknown} body
|
|
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
32
|
* @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
|
|
33
33
|
* @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
|
|
34
34
|
* @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
|
|
35
35
|
*/
|
|
36
|
-
async function send(path,
|
|
36
|
+
async function send(path, action, body, signal) {
|
|
37
37
|
const controller = new AbortController();
|
|
38
38
|
const abort = () => controller.abort();
|
|
39
39
|
if (signal?.aborted) abort();
|
|
40
40
|
signal?.addEventListener("abort", abort, { once: true });
|
|
41
41
|
const timer = setTimeout(abort, timeout);
|
|
42
42
|
try {
|
|
43
|
-
const response = await request(
|
|
44
|
-
method,
|
|
43
|
+
const response = await request(`/api/${action}`, {
|
|
44
|
+
method: "POST",
|
|
45
45
|
credentials: "omit",
|
|
46
46
|
cache: "no-store",
|
|
47
47
|
signal: controller.signal,
|
|
48
|
-
headers: { "
|
|
49
|
-
|
|
48
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
49
|
+
body: new URLSearchParams([[path, action === "set" ? JSON.stringify(body) : ""]]).toString(),
|
|
50
50
|
});
|
|
51
|
-
if (response.status !== 200 && !(
|
|
51
|
+
if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
|
|
52
52
|
return response;
|
|
53
53
|
} finally {
|
|
54
54
|
clearTimeout(timer);
|
|
@@ -72,21 +72,21 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
|
|
|
72
72
|
* Serialize single-key mutations and update a still-active session only after success.
|
|
73
73
|
* @param {string} module 已打开模块 / Open module.
|
|
74
74
|
* @param {string} key 完整点分字段路径 / Complete dotted field path.
|
|
75
|
-
* @param {"
|
|
75
|
+
* @param {"set" | "delete"} action 写入或删除 / Write or delete.
|
|
76
76
|
* @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
|
|
77
77
|
* @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
|
|
78
78
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
79
79
|
* @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
|
|
80
80
|
*/
|
|
81
|
-
async function change(module, key,
|
|
81
|
+
async function change(module, key, action, value, operation = action === "set" ? "write" : "delete") {
|
|
82
82
|
const state = sessions.get(module);
|
|
83
83
|
if (!state?.definition) throw new Error("Open the module first");
|
|
84
84
|
if (state.saving) throw new Error("A settings write is already in progress");
|
|
85
85
|
const field = state.definition.fields.find(field => field.key === key);
|
|
86
86
|
state.saving = true;
|
|
87
87
|
try {
|
|
88
|
-
if ((operation === "write" || operation === "delete") && (!field || (
|
|
89
|
-
await send(
|
|
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
90
|
if (sessions.get(module) === state) {
|
|
91
91
|
switch (operation) {
|
|
92
92
|
case "write":
|
|
@@ -129,7 +129,7 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
|
|
|
129
129
|
sessions.set(module, state);
|
|
130
130
|
try {
|
|
131
131
|
const definition = normalizeBoxJs(catalog.select(module), module);
|
|
132
|
-
const response = await send(
|
|
132
|
+
const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
|
|
133
133
|
let subtree = response.status === 404 ? {} : await response.json();
|
|
134
134
|
if (typeof subtree === "string") subtree = JSON.parse(subtree);
|
|
135
135
|
if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
|
|
@@ -159,7 +159,7 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
|
|
|
159
159
|
async readCaches(module) {
|
|
160
160
|
const state = sessions.get(module);
|
|
161
161
|
if (!state?.definition) throw new Error("Open the module first");
|
|
162
|
-
const response = await send(
|
|
162
|
+
const response = await send(`@${state.definition.storageKey}.${module}.Caches`, "get", undefined, state.controller.signal);
|
|
163
163
|
return response.status === 404 ? undefined : response.json();
|
|
164
164
|
},
|
|
165
165
|
/**
|
|
@@ -168,14 +168,14 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
|
|
|
168
168
|
* @param {string} module 已打开模块 / Open module.
|
|
169
169
|
* @returns {Promise<void>} 清理完成 / Cleanup completion.
|
|
170
170
|
*/
|
|
171
|
-
clearCaches: module => change(module, `${module}.Caches`, "
|
|
171
|
+
clearCaches: module => change(module, `${module}.Caches`, "delete", undefined, "clearCaches"),
|
|
172
172
|
/**
|
|
173
173
|
* 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
|
|
174
174
|
* Delete module persistence and reset the page cache using current BoxJS defaults.
|
|
175
175
|
* @param {string} module 已打开模块 / Open module.
|
|
176
176
|
* @returns {Promise<void>} 重置完成 / Reset completion.
|
|
177
177
|
*/
|
|
178
|
-
reset: module => change(module, module, "
|
|
178
|
+
reset: module => change(module, module, "delete", undefined, "reset"),
|
|
179
179
|
/**
|
|
180
180
|
* 取消读取并清除会话,不撤销已发送的写入。
|
|
181
181
|
* Abort reads and clear the session without undoing dispatched writes.
|
|
@@ -194,7 +194,7 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
|
|
|
194
194
|
* @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
|
|
195
195
|
* @returns {Promise<void>} 写入完成 / Write completion.
|
|
196
196
|
*/
|
|
197
|
-
set: (module, key, value) => change(module, key, "
|
|
197
|
+
set: (module, key, value) => change(module, key, "set", value),
|
|
198
198
|
/**
|
|
199
199
|
* 删除单键覆盖值并显示默认值。
|
|
200
200
|
* Delete one override and display its default value.
|
|
@@ -202,6 +202,6 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
|
|
|
202
202
|
* @param {string} key 点分字段路径 / Dotted field path.
|
|
203
203
|
* @returns {Promise<void>} 删除完成 / Delete completion.
|
|
204
204
|
*/
|
|
205
|
-
remove: (module, key) => change(module, key, "
|
|
205
|
+
remove: (module, key) => change(module, key, "delete"),
|
|
206
206
|
};
|
|
207
207
|
}
|
package/src/browser/panel.css
CHANGED
|
@@ -91,10 +91,19 @@
|
|
|
91
91
|
height: calc(100vh - 52px - env(safe-area-inset-top));
|
|
92
92
|
overflow: hidden;
|
|
93
93
|
}
|
|
94
|
+
:root[data-preference-panes-embedded] .pp-header {
|
|
95
|
+
display: none;
|
|
96
|
+
}
|
|
97
|
+
:root[data-preference-panes-embedded] .pp-viewport {
|
|
98
|
+
height: 100vh;
|
|
99
|
+
}
|
|
94
100
|
@supports (height: 100dvh) {
|
|
95
101
|
.pp-viewport {
|
|
96
102
|
height: calc(100dvh - 52px - env(safe-area-inset-top));
|
|
97
103
|
}
|
|
104
|
+
:root[data-preference-panes-embedded] .pp-viewport {
|
|
105
|
+
height: 100dvh;
|
|
106
|
+
}
|
|
98
107
|
}
|
|
99
108
|
.pp-fields,
|
|
100
109
|
.pp-choice-page {
|
package/src/browser/panel.mjs
CHANGED
|
@@ -33,6 +33,17 @@ export function mountPanel(root, catalog) {
|
|
|
33
33
|
header.append(back, brand, node("span", "pp-nav-spacer"));
|
|
34
34
|
shell.append(header, viewport, toast);
|
|
35
35
|
root.append(shell);
|
|
36
|
+
// 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
|
|
37
|
+
// Embedded mode publishes navigation state without host reads or mutations of the module DOM.
|
|
38
|
+
const publishNavigation = () => {
|
|
39
|
+
const frame = window.frameElement;
|
|
40
|
+
if (!frame?.dataset.preferencePanes) return;
|
|
41
|
+
frame.dispatchEvent(
|
|
42
|
+
new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
|
|
43
|
+
detail: { title: heading.textContent, module: catalog.module.module, busy: saving, canGoBack: !back.disabled },
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
};
|
|
36
47
|
let timer,
|
|
37
48
|
navigation,
|
|
38
49
|
generation = 0,
|
|
@@ -83,6 +94,7 @@ export function mountPanel(root, catalog) {
|
|
|
83
94
|
active = module;
|
|
84
95
|
back.disabled = window.history.length <= 1;
|
|
85
96
|
heading.textContent = module;
|
|
97
|
+
publishNavigation();
|
|
86
98
|
viewport.replaceChildren(node("p", "pp-loading", "读取设置…"));
|
|
87
99
|
try {
|
|
88
100
|
await client.open(module);
|
|
@@ -90,6 +102,7 @@ export function mountPanel(root, catalog) {
|
|
|
90
102
|
} catch (error) {
|
|
91
103
|
if (version !== generation) return;
|
|
92
104
|
viewport.replaceChildren(errorView(error, () => open(module)));
|
|
105
|
+
publishNavigation();
|
|
93
106
|
}
|
|
94
107
|
}
|
|
95
108
|
/**
|
|
@@ -121,6 +134,7 @@ export function mountPanel(root, catalog) {
|
|
|
121
134
|
const editor = editors.get(navigation.current);
|
|
122
135
|
heading.textContent = editor?.title ?? definition.metadata?.name ?? active;
|
|
123
136
|
back.disabled = saving || !navigation.canGoBack;
|
|
137
|
+
publishNavigation();
|
|
124
138
|
};
|
|
125
139
|
/**
|
|
126
140
|
* 串行执行模块操作,保持输入可编辑。
|
|
@@ -134,6 +148,7 @@ export function mountPanel(root, catalog) {
|
|
|
134
148
|
pendingWrites++;
|
|
135
149
|
saving = true;
|
|
136
150
|
back.disabled = true;
|
|
151
|
+
publishNavigation();
|
|
137
152
|
return (queue = queue
|
|
138
153
|
.then(action)
|
|
139
154
|
.then(() => {
|
|
@@ -149,6 +164,7 @@ export function mountPanel(root, catalog) {
|
|
|
149
164
|
saving = pendingWrites > 0;
|
|
150
165
|
if (destroyed && !saving) client.leave(active);
|
|
151
166
|
back.disabled = saving || !navigation.canGoBack;
|
|
167
|
+
publishNavigation();
|
|
152
168
|
}));
|
|
153
169
|
}
|
|
154
170
|
const metadata = definition.metadata;
|
package/src/build.mjs
CHANGED
|
@@ -2,8 +2,8 @@ import { readFile } from "node:fs/promises";
|
|
|
2
2
|
import { BoxJS } from "./BoxJS.mjs";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
* Build
|
|
5
|
+
* 仅生成模块前端文件,不复制配置或生成绑定业务的读写脚本。
|
|
6
|
+
* Build module frontend files without copying configuration or generating bound storage scripts.
|
|
7
7
|
* @param {unknown} boxjs 仅包含一个模块的 BoxJS JSON / BoxJS JSON containing exactly one module.
|
|
8
8
|
* @param {string} [css] 可选自定义 CSS 正文 / Optional custom CSS text.
|
|
9
9
|
* @returns {Promise<Record<string, string>>} 相对路径到文件正文的映射 / Relative file paths mapped to file contents.
|
|
@@ -12,22 +12,12 @@ export async function build(boxjs, css = "") {
|
|
|
12
12
|
if (typeof css !== "string") throw new TypeError("CSS must be a string");
|
|
13
13
|
const catalog = new BoxJS(boxjs);
|
|
14
14
|
const module = catalog.module.module;
|
|
15
|
-
const [html, app, navigation
|
|
16
|
-
readFile(new URL("../dist/module/index.html", import.meta.url), "utf8"),
|
|
17
|
-
readFile(new URL("../dist/module/app.mjs", import.meta.url), "utf8"),
|
|
18
|
-
readFile(new URL("../dist/module/navigation.mjs", import.meta.url), "utf8"),
|
|
19
|
-
readFile(new URL("../dist/preference-panes.proxy.js", import.meta.url), "utf8"),
|
|
20
|
-
readFile(new URL("../dist/preference-panes.config.js", import.meta.url), "utf8"),
|
|
21
|
-
]);
|
|
22
|
-
const config = JSON.stringify(catalog.select(module));
|
|
15
|
+
const [html, app, navigation] = 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"), readFile(new URL("../dist/module/navigation.mjs", import.meta.url), "utf8")]);
|
|
23
16
|
return {
|
|
24
17
|
[`settings/${module}/index.html`]: html,
|
|
25
18
|
[`settings/assets/${module}.html`]: html,
|
|
26
19
|
"settings/assets/app.mjs": app,
|
|
27
20
|
"settings/assets/navigation.mjs": navigation,
|
|
28
|
-
[`settings/assets/${module}.boxjs.json`]: config,
|
|
29
21
|
[`settings/assets/${module}.css`]: css,
|
|
30
|
-
[`settings/assets/${module}.request.js`]: `${proxy}\nPreferencePanes.run(${config},${JSON.stringify(css)});\n`,
|
|
31
|
-
[`settings/assets/${module}.config.js`]: `${mock}\nPreferencePanes.mock(${config});\n`,
|
|
32
22
|
};
|
|
33
23
|
}
|
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
|
|
96
|
-
* POST contains
|
|
95
|
+
* POST 的正文为一个 form 字段,字段名是完整 @root.path
|
|
96
|
+
* POST contains one form field whose name is the complete @root.path.
|
|
97
97
|
*/
|
|
98
98
|
body?: string;
|
|
99
99
|
}
|
|
@@ -113,8 +113,8 @@ export interface SettingsResponse {
|
|
|
113
113
|
*/
|
|
114
114
|
headers: Record<string, string>;
|
|
115
115
|
/**
|
|
116
|
-
* JSON
|
|
117
|
-
* JSON
|
|
116
|
+
* JSON 或页面资源正文
|
|
117
|
+
* JSON or page resource body.
|
|
118
118
|
*/
|
|
119
119
|
body: string;
|
|
120
120
|
}
|
|
@@ -269,8 +269,8 @@ export interface BoxJSSubscription extends BoxJSMetadata {
|
|
|
269
269
|
*/
|
|
270
270
|
export type BoxJSInput = BoxJSSetting[] | BoxJSApp | BoxJSSubscription;
|
|
271
271
|
/**
|
|
272
|
-
*
|
|
273
|
-
* Build
|
|
272
|
+
* 生成模块前端文件,不复制配置、不生成绑定模块的代理脚本。
|
|
273
|
+
* Build module frontend files without copying configuration or producing bound proxy scripts.
|
|
274
274
|
* @param boxjs 恰好包含一个模块的 BoxJS JSON / BoxJS JSON describing exactly one module.
|
|
275
275
|
* @param css 可选 CSS 正文 / Optional CSS text.
|
|
276
276
|
* @returns 相对路径到文件内容的映射 / Relative paths mapped to file contents.
|
package/src/lib/page-inputs.mjs
CHANGED
|
@@ -11,7 +11,7 @@ export function pageInputs(url, headers = {}) {
|
|
|
11
11
|
const module = match[1];
|
|
12
12
|
const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
|
|
13
13
|
const json = values["x-preferencepanes-json"] ?? url.searchParams.get("json") ?? `/configs/${module}`;
|
|
14
|
-
const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ??
|
|
14
|
+
const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? "";
|
|
15
15
|
if (!json.trim()) throw new TypeError("JSON resource URL is required");
|
|
16
16
|
return { url: url.href, module, json, css };
|
|
17
17
|
}
|
|
@@ -1,21 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 解析已经取得的 pathname,避免重复构造 URL。
|
|
3
|
-
* Parse an existing pathname without constructing another URL.
|
|
4
|
-
* @param {string} pathname 以 / 开头的 URL pathname / URL pathname beginning with /.
|
|
5
|
-
* @returns {string[] | undefined} 解码后的路径,非 API 路径不处理 / Decoded path, or undefined outside /api/.
|
|
6
|
-
* @throws {TypeError} 转义编码或路径片段非法 / Invalid percent encoding or path segments.
|
|
7
|
-
*/
|
|
8
|
-
export function parseSettingsPathname(pathname) {
|
|
9
|
-
if (!pathname.startsWith("/api/")) return;
|
|
10
|
-
let parts;
|
|
11
|
-
try {
|
|
12
|
-
parts = pathname.slice(5).replace(/\/$/, "").split("/").map(decodeURIComponent);
|
|
13
|
-
} catch {
|
|
14
|
-
throw new TypeError("Invalid encoded key path");
|
|
15
|
-
}
|
|
16
|
-
return validatePathParts(parts);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
1
|
/**
|
|
20
2
|
* 校验原始路径片段,不进行 URL 编码转换。
|
|
21
3
|
* Validate raw path segments without URL encoding conversion.
|