@nsnanocat/preference-panes 0.5.0 → 0.7.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 -124
- package/dist/module/app.mjs +1030 -0
- package/dist/module/index.html +14 -0
- package/dist/preference-panes.config.js +842 -0
- package/dist/preference-panes.mjs +935 -825
- package/dist/preference-panes.proxy.js +1726 -2121
- package/package.json +62 -66
- package/src/BoxJS.mjs +86 -0
- package/src/Store.mjs +127 -0
- package/src/browser/app.mjs +24 -148
- package/src/browser/client.d.mts +150 -0
- package/src/browser/client.mjs +191 -212
- package/src/browser/components.mjs +59 -0
- package/src/browser/index.d.ts +19 -152
- package/src/browser/index.mjs +60 -5
- package/src/browser/module.html +14 -0
- package/src/browser/panel.css +221 -221
- package/src/browser/panel.mjs +455 -514
- package/src/build.mjs +31 -0
- package/src/index.d.ts +227 -133
- package/src/index.mjs +3 -6
- package/src/lib/boxjs.mjs +77 -99
- package/src/lib/response.mjs +16 -0
- package/src/lib/settings-path.mjs +10 -23
- package/src/proxy/config.mjs +14 -0
- package/src/proxy/handler.mjs +40 -27
- package/src/proxy/response.mjs +16 -0
- package/dist/preference-panes.request.js +0 -2124
- package/dist/settings/app.mjs +0 -1044
- package/dist/settings/home.css +0 -134
- package/dist/settings/index.html +0 -15
- package/dist/settings/panel.css +0 -325
- package/src/PreferencesHandler.mjs +0 -50
- package/src/SettingsHandler.mjs +0 -142
- package/src/browser/home.css +0 -134
- package/src/browser/site.html +0 -15
- package/src/proxy/request.mjs +0 -5
package/src/lib/boxjs.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BoxJS } from "../BoxJS.mjs";
|
|
1
2
|
import { validatePathParts } from "./settings-path.mjs";
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -9,74 +10,51 @@ import { validatePathParts } from "./settings-path.mjs";
|
|
|
9
10
|
* @throws {TypeError} 配置结构、字段路径、默认值或展示属性无效 / Invalid configuration, field path, default or presentation attribute.
|
|
10
11
|
*/
|
|
11
12
|
export function normalizeBoxJs(config, module) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
for (const entry of entries) {
|
|
25
|
-
if (typeof entry.id !== "string" || !entry.id.startsWith("@")) throw new TypeError("BoxJS settings require @root.path IDs");
|
|
26
|
-
const [root, ...parts] = entry.id.slice(1).split(".");
|
|
27
|
-
if (parts[0] !== module) continue;
|
|
28
|
-
if (parts.length < 2) throw new TypeError("A BoxJS setting must be below the module root");
|
|
29
|
-
validatePathParts(parts);
|
|
30
|
-
if (!root || (storageKey && root !== storageKey)) throw new TypeError("A module must use one storage root");
|
|
31
|
-
storageKey = root;
|
|
32
|
-
const type = { boolean: "boolean", checkboxes: "array", selects: "select", text: "string", textarea: "string", number: "number" }[entry.type];
|
|
33
|
-
if (!type) throw new TypeError(`Unsupported BoxJS control: ${entry.type}`);
|
|
34
|
-
const field = {
|
|
35
|
-
key: parts.join("."),
|
|
36
|
-
type: type === "select" ? typeof entry.val : type,
|
|
13
|
+
validatePathParts([module]);
|
|
14
|
+
const target = new BoxJS(config).modules.get(module);
|
|
15
|
+
if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
|
|
16
|
+
const { entries, storageKey, metadata } = target;
|
|
17
|
+
const fields = [];
|
|
18
|
+
for (const entry of entries) {
|
|
19
|
+
const parts = entry.id.slice(1).split(".").slice(1);
|
|
20
|
+
const type = { boolean: "boolean", checkboxes: "array", selects: "select", text: "string", textarea: "string", number: "number" }[entry.type];
|
|
21
|
+
if (!type) throw new TypeError(`Unsupported BoxJS control: ${entry.type}`);
|
|
22
|
+
const field = {
|
|
23
|
+
key: parts.join("."),
|
|
24
|
+
type: type === "select" ? typeof entry.val : type,
|
|
37
25
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
metadata[key] = multiple ? [...values] : app[key];
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
return {
|
|
74
|
-
module,
|
|
75
|
-
storageKey,
|
|
76
|
-
fields,
|
|
77
|
-
settingsPath: common,
|
|
78
|
-
...(Object.keys(metadata).length ? { metadata } : {}),
|
|
79
|
-
};
|
|
26
|
+
name: entry.name,
|
|
27
|
+
description: entry.desc ?? "",
|
|
28
|
+
control: entry.type,
|
|
29
|
+
...(entry.placeholder === undefined ? {} : { placeholder: entry.placeholder }),
|
|
30
|
+
...(entry.rows === undefined ? {} : { rows: entry.rows }),
|
|
31
|
+
...(entry.autoGrow === undefined ? {} : { autoGrow: entry.autoGrow }),
|
|
32
|
+
};
|
|
33
|
+
if (type === "select" && !["string", "number", "boolean"].includes(field.type)) throw new TypeError(`Select requires a scalar val: ${entry.id}`);
|
|
34
|
+
if (entry.items) field.options = entry.items.map(item => ({ key: item.key, label: item.label }));
|
|
35
|
+
if (Object.hasOwn(entry, "val")) field.defaultValue = normalizeStoredValue(field, entry.val);
|
|
36
|
+
if (
|
|
37
|
+
typeof field.name !== "string" ||
|
|
38
|
+
(field.placeholder !== undefined && typeof field.placeholder !== "string") ||
|
|
39
|
+
(field.rows !== undefined && (!Number.isInteger(field.rows) || field.rows < 1)) ||
|
|
40
|
+
(field.autoGrow !== undefined && typeof field.autoGrow !== "boolean") ||
|
|
41
|
+
fields.some(other => other.key === field.key || other.key.startsWith(`${field.key}.`) || field.key.startsWith(`${other.key}.`))
|
|
42
|
+
)
|
|
43
|
+
throw new TypeError(`Invalid or overlapping BoxJS field: ${entry.id}`);
|
|
44
|
+
if (field.options && (new Set(field.options.map(item => item.key)).size !== field.options.length || field.options.some(item => !scalar(item.key) || typeof item.label !== "string"))) throw new TypeError(`Invalid options: ${entry.id}`);
|
|
45
|
+
if (Object.hasOwn(field, "defaultValue") && !validValue(field, field.defaultValue)) throw new TypeError(`Invalid BoxJS val: ${entry.id}`);
|
|
46
|
+
fields.push(field);
|
|
47
|
+
}
|
|
48
|
+
if (!fields.length) throw new TypeError(`No BoxJS settings for module: ${module}`);
|
|
49
|
+
const common = fields[0].key.split(".").slice(0, -1);
|
|
50
|
+
for (const field of fields) while (!field.key.startsWith(`${common.join(".")}.`)) common.pop();
|
|
51
|
+
return {
|
|
52
|
+
module,
|
|
53
|
+
storageKey,
|
|
54
|
+
fields,
|
|
55
|
+
settingsPath: common,
|
|
56
|
+
...(Object.keys(metadata).length ? { metadata } : {}),
|
|
57
|
+
};
|
|
80
58
|
}
|
|
81
59
|
|
|
82
60
|
/**
|
|
@@ -87,24 +65,24 @@ export function normalizeBoxJs(config, module) {
|
|
|
87
65
|
* @returns {unknown} 转换后的控件值;是否允许写入由 validValue 单独校验 / Converted control value; write eligibility is checked separately by validValue.
|
|
88
66
|
*/
|
|
89
67
|
export function normalizeStoredValue(field, value) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
68
|
+
switch (field.type) {
|
|
69
|
+
case "boolean":
|
|
70
|
+
if (value === "true" || value === "false") return value === "true";
|
|
71
|
+
break;
|
|
72
|
+
case "number":
|
|
73
|
+
if (typeof value === "string" && value.trim() !== "") return Number(value);
|
|
74
|
+
break;
|
|
75
|
+
case "array":
|
|
76
|
+
if (typeof value === "string") value = value === "" || value === "[]" ? [] : value.split(",");
|
|
77
|
+
break;
|
|
78
|
+
default:
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
if (field.options) {
|
|
82
|
+
const match = item => field.options.find(option => String(option.key) === String(item))?.key ?? item;
|
|
83
|
+
return field.type === "array" && Array.isArray(value) ? value.map(match) : match(value);
|
|
84
|
+
}
|
|
85
|
+
return value;
|
|
108
86
|
}
|
|
109
87
|
|
|
110
88
|
/**
|
|
@@ -114,16 +92,16 @@ export function normalizeStoredValue(field, value) {
|
|
|
114
92
|
* @returns {boolean} 是否为有效标量 / Whether the scalar is valid.
|
|
115
93
|
*/
|
|
116
94
|
function scalar(value) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
95
|
+
switch (typeof value) {
|
|
96
|
+
case "boolean":
|
|
97
|
+
return true;
|
|
98
|
+
case "string":
|
|
99
|
+
return value.length <= 2048;
|
|
100
|
+
case "number":
|
|
101
|
+
return Number.isFinite(value);
|
|
102
|
+
default:
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
127
105
|
}
|
|
128
106
|
|
|
129
107
|
/**
|
|
@@ -134,8 +112,8 @@ function scalar(value) {
|
|
|
134
112
|
* @returns {boolean} 是否符合字段约束 / Whether the value satisfies field constraints.
|
|
135
113
|
*/
|
|
136
114
|
export function validValue(field, value) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
115
|
+
if (field.type === "array") {
|
|
116
|
+
if (!Array.isArray(value) || value.some(item => !scalar(item)) || new Set(value).size !== value.length) return false;
|
|
117
|
+
} else if (typeof value !== field.type || !scalar(value)) return false;
|
|
118
|
+
return !field.options || (field.type === "array" ? value : [value]).every(item => field.options.some(option => option.key === item));
|
|
141
119
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一生成不可缓存的响应,HEAD 始终省略正文。
|
|
3
|
+
* Create an uncached response, always omitting the body for HEAD.
|
|
4
|
+
* @param {import("../index.js").SettingsRequest} request 宿主请求 / Host request.
|
|
5
|
+
* @param {number} status HTTP 状态 / HTTP status.
|
|
6
|
+
* @param {unknown} body JSON 数据或资源正文 / JSON data or resource body.
|
|
7
|
+
* @param {string} [type] 媒体类型 / Media type.
|
|
8
|
+
* @returns {import("../index.js").SettingsResponse} 通用响应 / Common response.
|
|
9
|
+
*/
|
|
10
|
+
export function response(request, status, body, type = "application/json") {
|
|
11
|
+
return {
|
|
12
|
+
status,
|
|
13
|
+
headers: { "Content-Type": `${type}; charset=utf-8`, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" },
|
|
14
|
+
body: request.method === "HEAD" ? "" : type === "application/json" ? JSON.stringify(body) : body,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
import { URL } from "@nsnanocat/url";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* 将 /api/ 后的 URL 路径转换为 util 的路径片段;非 API 路径不处理。
|
|
5
|
-
* Convert URL segments after /api/ to util path segments; ignore non-API paths.
|
|
6
|
-
* @param {string} url 请求完整 URL / Absolute request URL.
|
|
7
|
-
* @returns {string[] | undefined} 键路径片段 / Key path segments.
|
|
8
|
-
* @throws {TypeError} API 路径无效或包含危险片段 / Invalid or unsafe API path.
|
|
9
|
-
*/
|
|
10
|
-
export function parseSettingsPath(url) {
|
|
11
|
-
return parseSettingsPathname(new URL(url).pathname);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
1
|
/**
|
|
15
2
|
* 解析已经取得的 pathname,避免重复构造 URL。
|
|
16
3
|
* Parse an existing pathname without constructing another URL.
|
|
@@ -19,14 +6,14 @@ export function parseSettingsPath(url) {
|
|
|
19
6
|
* @throws {TypeError} 转义编码或路径片段非法 / Invalid percent encoding or path segments.
|
|
20
7
|
*/
|
|
21
8
|
export function parseSettingsPathname(pathname) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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);
|
|
30
17
|
}
|
|
31
18
|
|
|
32
19
|
/**
|
|
@@ -37,6 +24,6 @@ export function parseSettingsPathname(pathname) {
|
|
|
37
24
|
* @throws {TypeError} 空片段、非法字符或原型属性名 / Empty segments, invalid characters or prototype property names.
|
|
38
25
|
*/
|
|
39
26
|
export function validatePathParts(parts) {
|
|
40
|
-
|
|
41
|
-
|
|
27
|
+
if (!parts.every(part => typeof part === "string" && /^[a-zA-Z0-9_-]+$/.test(part) && !["__proto__", "prototype", "constructor"].includes(part))) throw new TypeError("Invalid key path");
|
|
28
|
+
return parts;
|
|
42
29
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { response } from "../lib/response.mjs";
|
|
2
|
+
import { complete } from "./response.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 为没有原生远程 Mock 的代理返回 BoxJS JSON,不提供存储或页面处理。
|
|
6
|
+
* Return BoxJS JSON on proxies without native remote Mock, without storage or page handling.
|
|
7
|
+
* @param {unknown} config 构建时嵌入的 BoxJS JSON / BoxJS JSON embedded at build time.
|
|
8
|
+
* @returns {void} 将响应交给宿主 / Deliver the response to the host.
|
|
9
|
+
*/
|
|
10
|
+
export function mock(config) {
|
|
11
|
+
const method = globalThis.$request.method;
|
|
12
|
+
const allowed = method === "GET" || method === "HEAD";
|
|
13
|
+
complete(response(globalThis.$request, allowed ? 200 : 405, allowed ? config : { error: "Method not allowed" }));
|
|
14
|
+
}
|
package/src/proxy/handler.mjs
CHANGED
|
@@ -1,31 +1,44 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import { URL } from "@nsnanocat/url";
|
|
2
|
+
import assets from "#assets";
|
|
3
|
+
import { BoxJS } from "../BoxJS.mjs";
|
|
4
|
+
import { response } from "../lib/response.mjs";
|
|
5
|
+
import { Store } from "../Store.mjs";
|
|
6
|
+
import { complete } from "./response.mjs";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* @param {
|
|
10
|
-
* @
|
|
9
|
+
* 仅为导入的模块提供页面与存储服务,项目主页由调用方自行托管。
|
|
10
|
+
* Serve only the imported module's page and persistence; callers host their own project landing pages.
|
|
11
|
+
* @param {unknown} boxjs BoxJS JSON / BoxJS JSON.
|
|
12
|
+
* @param {string} [css] 自定义 CSS 正文 / Custom CSS text.
|
|
13
|
+
* @returns {Promise<void>} 已提交宿主响应 / Delivered host response.
|
|
11
14
|
*/
|
|
12
|
-
export async function
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
15
|
+
export async function run(boxjs, css = "") {
|
|
16
|
+
const request = globalThis.$request;
|
|
17
|
+
let result;
|
|
18
|
+
try {
|
|
19
|
+
if (typeof css !== "string") throw new TypeError("CSS must be a string");
|
|
20
|
+
const catalog = new BoxJS(boxjs);
|
|
21
|
+
const module = catalog.module.module;
|
|
22
|
+
const url = new URL(request.url);
|
|
23
|
+
switch (true) {
|
|
24
|
+
case url.pathname.startsWith("/api/"):
|
|
25
|
+
result = await new Store(catalog).handle(request, url);
|
|
26
|
+
break;
|
|
27
|
+
case url.pathname.startsWith("/configs/"):
|
|
28
|
+
break;
|
|
29
|
+
case url.pathname === `/settings/assets/${module}.css`:
|
|
30
|
+
result = response(request, 200, css, "text/css");
|
|
31
|
+
break;
|
|
32
|
+
default: {
|
|
33
|
+
const path = url.pathname === `/settings/${module}` || url.pathname === `/settings/${module}/` ? "page" : url.pathname;
|
|
34
|
+
const asset = assets[path];
|
|
35
|
+
if (asset) result = response(request, 200, asset.body, asset.type);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (result && !url.pathname.startsWith("/api/") && !["GET", "HEAD"].includes(request.method)) result = response(request, 405, { error: "Method not allowed" });
|
|
39
|
+
} catch (error) {
|
|
40
|
+
console.error(`PreferencePanes: ${error.message}`);
|
|
41
|
+
result = response(request, 500, { error: "Settings execution failed" });
|
|
42
|
+
}
|
|
43
|
+
complete(result);
|
|
31
44
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { $app } from "@nsnanocat/util/lib/app.mjs";
|
|
2
|
+
import { done } from "@nsnanocat/util/lib/done.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 统一适配代理的完成格式;未接管的请求原样继续。
|
|
6
|
+
* Adapt the host completion format and pass through unhandled requests.
|
|
7
|
+
* @param {import("../index.js").SettingsResponse | undefined} result 通用响应 / Common response.
|
|
8
|
+
* @returns {void} 响应已交给宿主 / Response delivered to the host.
|
|
9
|
+
*/
|
|
10
|
+
export function complete(result) {
|
|
11
|
+
if (!result) {
|
|
12
|
+
done({});
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
done($app === "Quantumult X" ? result : { response: result });
|
|
16
|
+
}
|