@nsnanocat/preference-panes 0.1.0 → 0.3.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 +63 -77
- package/dist/preference-panes.mjs +766 -678
- package/dist/preference-panes.request.js +1771 -2126
- package/package.json +64 -67
- package/src/SettingsHandler.mjs +142 -0
- package/src/browser/client.mjs +228 -0
- package/src/browser/index.d.ts +153 -0
- package/src/browser/index.mjs +7 -0
- package/src/browser/panel.css +140 -0
- package/src/browser/panel.mjs +432 -0
- package/src/index.d.ts +161 -0
- package/src/index.mjs +8 -0
- package/src/lib/boxjs.mjs +141 -0
- package/src/lib/settings-path.mjs +42 -0
- package/src/proxy/request.mjs +30 -0
- package/browser/client.mjs +0 -112
- package/browser/index.mjs +0 -2
- package/browser/panel.css +0 -103
- package/browser/panel.mjs +0 -233
- package/index.mjs +0 -3
- package/lib/boxjs.mjs +0 -90
- package/lib/settings-handler.mjs +0 -113
- package/lib/settings-path.mjs +0 -20
- package/proxy/request.mjs +0 -36
- package/types/browser.d.ts +0 -32
- package/types/index.d.ts +0 -52
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设置允许的标量值,不包含 null 或对象。
|
|
3
|
+
* Supported setting scalars, excluding null and objects.
|
|
4
|
+
*/
|
|
5
|
+
export type SettingsScalar = string | number | boolean;
|
|
6
|
+
/**
|
|
7
|
+
* 单个选择项的存储值与显示标签。
|
|
8
|
+
* Stored value and display label of a choice.
|
|
9
|
+
*/
|
|
10
|
+
export interface SettingsOption<T extends SettingsScalar = SettingsScalar> {
|
|
11
|
+
/** 持久化值,保留原始标量类型 / Persisted value with its original scalar type. */
|
|
12
|
+
key: T;
|
|
13
|
+
/** 纯文本选项名称 / Plain-text option label. */
|
|
14
|
+
label: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 控件共同的路径与展示属性。
|
|
18
|
+
* Shared path and presentation attributes of a control.
|
|
19
|
+
*/
|
|
20
|
+
interface FieldBase {
|
|
21
|
+
/** 不含存储根的点分路径,例如 Module.Settings.key / Dotted path without the storage root, such as Module.Settings.key. */
|
|
22
|
+
key: string;
|
|
23
|
+
/** 纯文本标题 / Plain-text title. */
|
|
24
|
+
name: string;
|
|
25
|
+
/** 纯文本说明 / Plain-text description. */
|
|
26
|
+
description?: string;
|
|
27
|
+
/** 原始 BoxJS 控件类型,与持久化值类型区分 / Original BoxJS control kind, separate from the stored value type. */
|
|
28
|
+
control?: "boolean" | "checkboxes" | "selects" | "text" | "textarea" | "number";
|
|
29
|
+
/** 输入占位文字 / Input placeholder. */
|
|
30
|
+
placeholder?: string;
|
|
31
|
+
/** 多行控件的基础行数,必须为正整数 / Positive baseline row count for a textarea. */
|
|
32
|
+
rows?: number;
|
|
33
|
+
/** 是否随多行内容自动调整高度 / Whether textarea height follows its contents. */
|
|
34
|
+
autoGrow?: boolean;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 归一化字段;默认值和选项必须符合对应 type,缺失默认值保持缺失。
|
|
38
|
+
* Normalized field; defaults and choices match type, and absent defaults stay absent.
|
|
39
|
+
*/
|
|
40
|
+
export type SettingsField = FieldBase &
|
|
41
|
+
(
|
|
42
|
+
| { type: "boolean"; defaultValue?: boolean; options?: SettingsOption<boolean>[] }
|
|
43
|
+
| { type: "number"; defaultValue?: number; options?: SettingsOption<number>[] }
|
|
44
|
+
| { type: "string"; defaultValue?: string; options?: SettingsOption<string>[] }
|
|
45
|
+
| { type: "array"; defaultValue?: SettingsScalar[]; options?: SettingsOption[] }
|
|
46
|
+
);
|
|
47
|
+
/**
|
|
48
|
+
* 代理宿主提供的请求;保留字符串方法以便拒绝不支持的方法。
|
|
49
|
+
* Request provided by the proxy host; string methods allow unsupported methods to be rejected.
|
|
50
|
+
*/
|
|
51
|
+
export interface SettingsRequest {
|
|
52
|
+
/** 请求的完整 URL / Absolute request URL. */
|
|
53
|
+
url: string;
|
|
54
|
+
/** 区分大小写的 HTTP 方法 / Case-sensitive HTTP method. */
|
|
55
|
+
method: string;
|
|
56
|
+
/** 头名称在处理器中统一转为小写 / Header names are normalized to lowercase by the handler. */
|
|
57
|
+
headers?: Record<string, string | undefined>;
|
|
58
|
+
/** POST 的正文为 JSON 值本身;DELETE 无正文 / POST contains the JSON value itself; DELETE has no body. */
|
|
59
|
+
body?: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* 通用 HTTP 响应,调用方负责转换为代理宿主的 done 格式。
|
|
63
|
+
* Generic HTTP response; the caller adapts it to the host's done format.
|
|
64
|
+
*/
|
|
65
|
+
export interface SettingsResponse {
|
|
66
|
+
/** 数字状态码 / Numeric status code. */
|
|
67
|
+
status: number;
|
|
68
|
+
/** 响应头 / Response headers. */
|
|
69
|
+
headers: Record<string, string>;
|
|
70
|
+
/** JSON 文本;HEAD 始终为空字符串 / JSON text; always an empty string for HEAD. */
|
|
71
|
+
body: string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 由插件安装配置提供的固定存储映射,不接受浏览器指定存储根。
|
|
75
|
+
* Fixed storage mapping provided by plugin installation, never a browser-selected root.
|
|
76
|
+
*/
|
|
77
|
+
export interface SettingsHandlerOptions {
|
|
78
|
+
/** 接管 /api/ 路径的 HTTPS 来源 / HTTPS origin serving /api/ paths. */
|
|
79
|
+
origin: string;
|
|
80
|
+
/** 顶层持久化键,不能使用 @ 路径语法 / Literal top-level storage key, without @ path syntax. */
|
|
81
|
+
storageKey: string;
|
|
82
|
+
/** /api/ 后的模块段,只能访问该模块内的数据 / Module segment following /api/; access stays within this module. */
|
|
83
|
+
module: string;
|
|
84
|
+
/** 默认 X-Settings-Client,值必须为 1;不是认证凭据 / Defaults to X-Settings-Client with value 1; not an authentication credential. */
|
|
85
|
+
requestHeader?: string;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* 属于单个模块的字段、存储根及可选展示元数据。
|
|
89
|
+
* Fields, storage root and optional display metadata belonging to one module.
|
|
90
|
+
*/
|
|
91
|
+
export interface ModuleDefinition {
|
|
92
|
+
/** 字段 ID 的模块段,不能由 app 名称推断 / Module segment from field IDs, never inferred from app names. */
|
|
93
|
+
module: string;
|
|
94
|
+
/** 由 @root.module.path 中的 root 提取 / Root extracted from @root.module.path. */
|
|
95
|
+
storageKey: string;
|
|
96
|
+
/** 保留配置文件中的字段顺序 / Fields in configuration order. */
|
|
97
|
+
fields: SettingsField[];
|
|
98
|
+
/** 含模块名的公共父路径片段 / Common parent path segments including the module name. */
|
|
99
|
+
settingsPath: string[];
|
|
100
|
+
/** 只有字段来自唯一 app 时提供 / Present only when all fields belong to one app. */
|
|
101
|
+
metadata?: {
|
|
102
|
+
/** 应用标识,仅用于展示或溯源 / App identifier for display or provenance only. */
|
|
103
|
+
id?: string;
|
|
104
|
+
/** 显示名称 / Display name. */
|
|
105
|
+
name?: string;
|
|
106
|
+
/** 作者纯文本 / Plain-text author. */
|
|
107
|
+
author?: string;
|
|
108
|
+
/** 项目地址;页面仅接受 HTTP(S) 或相对链接 / Project address; the page accepts only HTTP(S) or relative URLs. */
|
|
109
|
+
repo?: string;
|
|
110
|
+
/** 仅保留来源信息,不执行脚本 / Source metadata only; never executed. */
|
|
111
|
+
script?: string;
|
|
112
|
+
/** 优先使用的显式图标地址 / Explicit preferred icon URL. */
|
|
113
|
+
icon?: string;
|
|
114
|
+
/** 原版透明/彩色顺序,不是亮暗顺序 / Original transparent/color order, not light/dark order. */
|
|
115
|
+
icons?: string[];
|
|
116
|
+
/** 多段纯文本说明 / Multiple plain-text paragraphs. */
|
|
117
|
+
descs?: string[];
|
|
118
|
+
/** desc 未提供时的说明 / Description used when desc is absent. */
|
|
119
|
+
description?: string;
|
|
120
|
+
/** 优先使用的纯文本说明 / Preferred plain-text description. */
|
|
121
|
+
desc?: string;
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* 将 BoxJS 字段数组、app 或订阅解析为模块定义,不执行脚本或 HTML。
|
|
126
|
+
* Parse a BoxJS field array, app or subscription into a module definition without executing scripts or HTML.
|
|
127
|
+
* @param config 外部 JSON 数据,在运行时校验 / External JSON validated at runtime.
|
|
128
|
+
* @param module 请求路径中的模块标识 / Module identifier from the request path.
|
|
129
|
+
* @returns 字段、公共路径与元数据 / Fields, common path and metadata.
|
|
130
|
+
* @throws {TypeError} 配置格式、类型、路径或选项无效 / Invalid configuration shape, types, paths or options.
|
|
131
|
+
*/
|
|
132
|
+
export function normalizeBoxJs(config: unknown, module: string): ModuleDefinition;
|
|
133
|
+
/**
|
|
134
|
+
* 使用 util 桥接指定模块的持久化存储,不下载或校验 BoxJS。
|
|
135
|
+
* Bridge module persistence through util without downloading or validating BoxJS.
|
|
136
|
+
*/
|
|
137
|
+
export class SettingsHandler {
|
|
138
|
+
/**
|
|
139
|
+
* 创建实例,不发送请求或读取存储。
|
|
140
|
+
* Construct an instance without network requests or storage reads.
|
|
141
|
+
* @param options 来源、存储根与模块 / Origin, storage root and module.
|
|
142
|
+
* @throws {TypeError} 来源、存储根、模块或头名称无效 / Invalid origin, storage root, module or header name.
|
|
143
|
+
*/
|
|
144
|
+
constructor(options: SettingsHandlerOptions);
|
|
145
|
+
/**
|
|
146
|
+
* HEAD 不读存储;GET 返回任意指定值,POST/DELETE 对键或子树读改写一次。
|
|
147
|
+
* HEAD avoids storage; GET returns any requested value, and POST/DELETE mutate a key or subtree in one read-modify-write.
|
|
148
|
+
* @param request 代理请求 / Proxy request.
|
|
149
|
+
* @returns HTTP 响应;非目标来源或非 API 路径返回 undefined / HTTP response, or undefined outside the configured API origin and path.
|
|
150
|
+
* @throws {Error} 请求 URL 无效;存储失败以 HTTP 500 返回 / Invalid request URL; storage failures return HTTP 500.
|
|
151
|
+
*/
|
|
152
|
+
handle(request: SettingsRequest): Promise<SettingsResponse | undefined>;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* 从完整 URL 解析 /api/ 后的 database 路径。
|
|
156
|
+
* Parse database path segments following /api/ from an absolute URL.
|
|
157
|
+
* @param url 完整请求地址 / Absolute request URL.
|
|
158
|
+
* @returns 已解码的路径片段;非 API 路径返回 undefined / Decoded segments, or undefined for non-API paths.
|
|
159
|
+
* @throws {TypeError} URL、编码或路径片段非法 / Invalid URL, encoding or path segment.
|
|
160
|
+
*/
|
|
161
|
+
export function parseSettingsPath(url: string): string[] | undefined;
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { normalizeBoxJs } from "./lib/boxjs.mjs";
|
|
2
|
+
export { parseSettingsPath } from "./lib/settings-path.mjs";
|
|
3
|
+
/**
|
|
4
|
+
* 通用代理处理器与配置解析的公开入口。
|
|
5
|
+
* Public entry for the proxy handler and configuration parsing.
|
|
6
|
+
* @module @nsnanocat/preference-panes
|
|
7
|
+
*/
|
|
8
|
+
export { SettingsHandler } from "./SettingsHandler.mjs";
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { validatePathParts } from "./settings-path.mjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 将 BoxJS 数组、app 或订阅转换为模块字段,保留原文件为唯一字段来源。
|
|
5
|
+
* Normalize a BoxJS array, app or subscription using the source JSON as the field authority.
|
|
6
|
+
* @param {unknown} config BoxJS JSON / BoxJS document.
|
|
7
|
+
* @param {string} module API 第一段模块名 / First API path segment.
|
|
8
|
+
* @returns {import("../index.js").ModuleDefinition} 存储根和字段 / Storage root and fields.
|
|
9
|
+
* @throws {TypeError} 配置结构、字段路径、默认值或展示属性无效 / Invalid configuration, field path, default or presentation attribute.
|
|
10
|
+
*/
|
|
11
|
+
export function normalizeBoxJs(config, module) {
|
|
12
|
+
validatePathParts([module]);
|
|
13
|
+
const apps = Array.isArray(config) ? [] : (config?.apps ?? [config]);
|
|
14
|
+
if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
|
|
15
|
+
for (const candidate of apps) {
|
|
16
|
+
if (!candidate || typeof candidate !== "object") throw new TypeError("Expected BoxJS app object");
|
|
17
|
+
if (candidate.settings !== undefined && !Array.isArray(candidate.settings)) throw new TypeError("Expected BoxJS settings array");
|
|
18
|
+
}
|
|
19
|
+
const owners = apps.filter(candidate => candidate.settings?.some(entry => typeof entry.id === "string" && entry.id.startsWith("@") && entry.id.slice(1).split(".")[1] === module));
|
|
20
|
+
const entries = Array.isArray(config) ? config : owners.flatMap(candidate => candidate.settings);
|
|
21
|
+
const app = owners.length === 1 ? owners[0] : undefined;
|
|
22
|
+
let storageKey;
|
|
23
|
+
const fields = [];
|
|
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,
|
|
37
|
+
|
|
38
|
+
name: entry.name,
|
|
39
|
+
description: entry.desc ?? "",
|
|
40
|
+
control: entry.type,
|
|
41
|
+
...(entry.placeholder === undefined ? {} : { placeholder: entry.placeholder }),
|
|
42
|
+
...(entry.rows === undefined ? {} : { rows: entry.rows }),
|
|
43
|
+
...(entry.autoGrow === undefined ? {} : { autoGrow: entry.autoGrow }),
|
|
44
|
+
};
|
|
45
|
+
if (type === "select" && !["string", "number", "boolean"].includes(field.type)) throw new TypeError(`Select requires a scalar val: ${entry.id}`);
|
|
46
|
+
if (entry.items) field.options = entry.items.map(item => ({ key: item.key, label: item.label }));
|
|
47
|
+
if (Object.hasOwn(entry, "val")) field.defaultValue = normalizeStoredValue(field, entry.val);
|
|
48
|
+
if (
|
|
49
|
+
typeof field.name !== "string" ||
|
|
50
|
+
(field.placeholder !== undefined && typeof field.placeholder !== "string") ||
|
|
51
|
+
(field.rows !== undefined && (!Number.isInteger(field.rows) || field.rows < 1)) ||
|
|
52
|
+
(field.autoGrow !== undefined && typeof field.autoGrow !== "boolean") ||
|
|
53
|
+
fields.some(other => other.key === field.key || other.key.startsWith(`${field.key}.`) || field.key.startsWith(`${other.key}.`))
|
|
54
|
+
)
|
|
55
|
+
throw new TypeError(`Invalid or overlapping BoxJS field: ${entry.id}`);
|
|
56
|
+
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}`);
|
|
57
|
+
if (Object.hasOwn(field, "defaultValue") && !validValue(field, field.defaultValue)) throw new TypeError(`Invalid BoxJS val: ${entry.id}`);
|
|
58
|
+
fields.push(field);
|
|
59
|
+
}
|
|
60
|
+
if (!fields.length) throw new TypeError(`No BoxJS settings for module: ${module}`);
|
|
61
|
+
const common = fields[0].key.split(".").slice(0, -1);
|
|
62
|
+
for (const field of fields) while (!field.key.startsWith(`${common.join(".")}.`)) common.pop();
|
|
63
|
+
const metadata = {};
|
|
64
|
+
if (app) {
|
|
65
|
+
for (const key of ["id", "name", "author", "repo", "script", "icon", "description", "desc", "icons", "descs"]) {
|
|
66
|
+
if (app[key] === undefined) continue;
|
|
67
|
+
const multiple = key === "icons" || key === "descs";
|
|
68
|
+
const values = multiple ? app[key] : [app[key]];
|
|
69
|
+
if (!Array.isArray(values) || values.some(item => typeof item !== "string")) throw new TypeError(`Invalid BoxJS app ${key}`);
|
|
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
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 归一化 BoxJS 的字符串存储值,不改变普通文本内容。
|
|
84
|
+
* Normalize BoxJS string persistence without changing free-text values.
|
|
85
|
+
* @param {import("../index.js").SettingsField} field 前端字段约束 / Frontend field constraints.
|
|
86
|
+
* @param {unknown} value 存储值 / Stored value.
|
|
87
|
+
* @returns {unknown} 转换后的控件值;是否允许写入由 validValue 单独校验 / Converted control value; write eligibility is checked separately by validValue.
|
|
88
|
+
*/
|
|
89
|
+
export function normalizeStoredValue(field, value) {
|
|
90
|
+
switch (field.type) {
|
|
91
|
+
case "boolean":
|
|
92
|
+
if (value === "true" || value === "false") return value === "true";
|
|
93
|
+
break;
|
|
94
|
+
case "number":
|
|
95
|
+
if (typeof value === "string" && value.trim() !== "") return Number(value);
|
|
96
|
+
break;
|
|
97
|
+
case "array":
|
|
98
|
+
if (typeof value === "string") value = value === "" || value === "[]" ? [] : value.split(",");
|
|
99
|
+
break;
|
|
100
|
+
default:
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
if (field.options) {
|
|
104
|
+
const match = item => field.options.find(option => String(option.key) === String(item))?.key ?? item;
|
|
105
|
+
return field.type === "array" && Array.isArray(value) ? value.map(match) : match(value);
|
|
106
|
+
}
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 校验支持的标量范围,包括文本长度与数值有限性。
|
|
112
|
+
* Validate supported scalar bounds, including text length and numeric finiteness.
|
|
113
|
+
* @param {unknown} value 待检查值 / Value to inspect.
|
|
114
|
+
* @returns {boolean} 是否为有效标量 / Whether the scalar is valid.
|
|
115
|
+
*/
|
|
116
|
+
function scalar(value) {
|
|
117
|
+
switch (typeof value) {
|
|
118
|
+
case "boolean":
|
|
119
|
+
return true;
|
|
120
|
+
case "string":
|
|
121
|
+
return value.length <= 2048;
|
|
122
|
+
case "number":
|
|
123
|
+
return Number.isFinite(value);
|
|
124
|
+
default:
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 检查值类型、数组唯一性及声明的选项,不进行转换。
|
|
131
|
+
* Check value type, array uniqueness and declared choices without coercion.
|
|
132
|
+
* @param {import("../index.js").SettingsField} field 前端归一化字段 / Normalized frontend field.
|
|
133
|
+
* @param {unknown} value 待写入的 JSON 值 / JSON value to write.
|
|
134
|
+
* @returns {boolean} 是否符合字段约束 / Whether the value satisfies field constraints.
|
|
135
|
+
*/
|
|
136
|
+
export function validValue(field, value) {
|
|
137
|
+
if (field.type === "array") {
|
|
138
|
+
if (!Array.isArray(value) || value.some(item => !scalar(item)) || new Set(value).size !== value.length) return false;
|
|
139
|
+
} else if (typeof value !== field.type || !scalar(value)) return false;
|
|
140
|
+
return !field.options || (field.type === "array" ? value : [value]).every(item => field.options.some(option => option.key === item));
|
|
141
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
/**
|
|
15
|
+
* 解析已经取得的 pathname,避免重复构造 URL。
|
|
16
|
+
* Parse an existing pathname without constructing another URL.
|
|
17
|
+
* @param {string} pathname 以 / 开头的 URL pathname / URL pathname beginning with /.
|
|
18
|
+
* @returns {string[] | undefined} 解码后的路径,非 API 路径不处理 / Decoded path, or undefined outside /api/.
|
|
19
|
+
* @throws {TypeError} 转义编码或路径片段非法 / Invalid percent encoding or path segments.
|
|
20
|
+
*/
|
|
21
|
+
export function parseSettingsPathname(pathname) {
|
|
22
|
+
if (!pathname.startsWith("/api/")) return;
|
|
23
|
+
let parts;
|
|
24
|
+
try {
|
|
25
|
+
parts = pathname.slice(5).replace(/\/$/, "").split("/").map(decodeURIComponent);
|
|
26
|
+
} catch {
|
|
27
|
+
throw new TypeError("Invalid encoded key path");
|
|
28
|
+
}
|
|
29
|
+
return validatePathParts(parts);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 校验原始路径片段,不进行 URL 编码转换。
|
|
34
|
+
* Validate raw path segments without URL encoding conversion.
|
|
35
|
+
* @param {string[]} parts 原始路径片段 / Raw path segments.
|
|
36
|
+
* @returns {string[]} 同一数组,不复制或修改 / The same array without copying or mutation.
|
|
37
|
+
* @throws {TypeError} 空片段、非法字符或原型属性名 / Empty segments, invalid characters or prototype property names.
|
|
38
|
+
*/
|
|
39
|
+
export function validatePathParts(parts) {
|
|
40
|
+
if (!parts.every(part => typeof part === "string" && /^[a-zA-Z0-9_-]+$/.test(part) && !["__proto__", "prototype", "constructor"].includes(part))) throw new TypeError("Invalid key path");
|
|
41
|
+
return parts;
|
|
42
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { $app } from "@nsnanocat/util/lib/app.mjs";
|
|
2
|
+
import { done } from "@nsnanocat/util/lib/done.mjs";
|
|
3
|
+
import { qs } from "@nsnanocat/util/polyfill/qs.mjs";
|
|
4
|
+
import { SettingsHandler } from "../SettingsHandler.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 读取代理参数、执行处理器并将响应交给宿主 done。
|
|
8
|
+
* Read proxy arguments, execute the handler and pass the response to the host's done function.
|
|
9
|
+
* @returns {Promise<void>} 代理脚本执行结束 / Proxy script execution completion.
|
|
10
|
+
*/
|
|
11
|
+
(async () => {
|
|
12
|
+
let response;
|
|
13
|
+
try {
|
|
14
|
+
const { origin, storageKey, module } = qs.parse(globalThis.$argument);
|
|
15
|
+
const handler = new SettingsHandler({ origin, storageKey, module });
|
|
16
|
+
response = await handler.handle(globalThis.$request);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
console.error(`PreferencePanes: ${error.message}`);
|
|
19
|
+
response = {
|
|
20
|
+
status: 500,
|
|
21
|
+
headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" },
|
|
22
|
+
body: globalThis.$request.method === "HEAD" ? "" : JSON.stringify({ error: "Settings execution failed" }),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
if (!response) {
|
|
26
|
+
done({});
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
done($app === "Quantumult X" ? response : { response });
|
|
30
|
+
})();
|
package/browser/client.mjs
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import { Lodash as _ } from "@nsnanocat/util/polyfill/Lodash.mjs";
|
|
2
|
-
import { normalizeBoxJs, normalizeStoredValue, validValue } from "../lib/boxjs.mjs";
|
|
3
|
-
import { parseSettingsPath } from "../lib/settings-path.mjs";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* 创建页面会话缓存;打开时重读,选项操作仅在 HTTP 200 后更新缓存。
|
|
7
|
-
* Create a page-session cache; reload on open and mutate cache only after HTTP 200.
|
|
8
|
-
* @param {import("../types/browser.js").PreferencesClientOptions} options 请求与通知 / Requests and notifications.
|
|
9
|
-
* @returns {import("../types/browser.js").PreferencesClient} 通用客户端 / Generic client.
|
|
10
|
-
*/
|
|
11
|
-
export function createPreferencesClient({ fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 } = {}) {
|
|
12
|
-
const sessions = new Map();
|
|
13
|
-
async function send(path, method, body, signal, resource = false) {
|
|
14
|
-
const controller = new AbortController();
|
|
15
|
-
const abort = () => controller.abort();
|
|
16
|
-
if (signal?.aborted) abort();
|
|
17
|
-
signal?.addEventListener("abort", abort, { once: true });
|
|
18
|
-
const timer = setTimeout(abort, timeout);
|
|
19
|
-
try {
|
|
20
|
-
const response = await request(path, {
|
|
21
|
-
method,
|
|
22
|
-
credentials: "omit",
|
|
23
|
-
cache: "no-store",
|
|
24
|
-
signal: controller.signal,
|
|
25
|
-
headers: resource ? {} : { "X-Settings-Client": "1", ...(method === "POST" ? { "Content-Type": "application/json" } : {}) },
|
|
26
|
-
...(method === "POST" ? { body: JSON.stringify(body) } : {}),
|
|
27
|
-
});
|
|
28
|
-
if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
|
|
29
|
-
return response;
|
|
30
|
-
} finally {
|
|
31
|
-
clearTimeout(timer);
|
|
32
|
-
signal?.removeEventListener("abort", abort);
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
const configPath = (module) => {
|
|
36
|
-
if (typeof module !== "string" || !module) throw new TypeError("module is required");
|
|
37
|
-
const parts = parseSettingsPath(`https://example.invalid/api/${encodeURIComponent(module)}/`);
|
|
38
|
-
if (parts.length !== 1) throw new TypeError("Expected a module name");
|
|
39
|
-
return `/configs/${encodeURIComponent(module)}`;
|
|
40
|
-
};
|
|
41
|
-
const snapshot = (module) => {
|
|
42
|
-
const state = sessions.get(module);
|
|
43
|
-
if (!state?.definition) throw new Error("Open the module first");
|
|
44
|
-
return structuredClone({ definition: state.definition, values: state.values });
|
|
45
|
-
};
|
|
46
|
-
async function change(module, key, method, value) {
|
|
47
|
-
const state = sessions.get(module);
|
|
48
|
-
if (!state?.definition) throw new Error("Open the module first");
|
|
49
|
-
if (state.saving) throw new Error("A settings write is already in progress");
|
|
50
|
-
const field = state.definition.fields.find((field) => field.key === key);
|
|
51
|
-
state.saving = true;
|
|
52
|
-
try {
|
|
53
|
-
if (!field || (method === "POST" && !validValue(field, value))) throw new TypeError("Invalid setting value");
|
|
54
|
-
await send(`/api/${key.split(".").map(encodeURIComponent).join("/")}`, method, value);
|
|
55
|
-
if (sessions.get(module) === state) {
|
|
56
|
-
if (method === "DELETE") {
|
|
57
|
-
delete state.values[key];
|
|
58
|
-
if (Object.hasOwn(field, "defaultValue")) state.values[key] = structuredClone(field.defaultValue);
|
|
59
|
-
} else state.values[key] = structuredClone(value);
|
|
60
|
-
}
|
|
61
|
-
notify({ kind: "success", operation: method === "DELETE" ? "delete" : "write", module, key });
|
|
62
|
-
} catch (error) {
|
|
63
|
-
notify({ kind: "error", operation: method === "DELETE" ? "delete" : "write", module, key, message: error.message });
|
|
64
|
-
throw error;
|
|
65
|
-
} finally {
|
|
66
|
-
state.saving = false;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
return {
|
|
70
|
-
async probe(module) {
|
|
71
|
-
try {
|
|
72
|
-
await send(configPath(module), "HEAD", undefined, undefined, true);
|
|
73
|
-
return true;
|
|
74
|
-
} catch {
|
|
75
|
-
return false;
|
|
76
|
-
}
|
|
77
|
-
},
|
|
78
|
-
async open(module) {
|
|
79
|
-
const previous = sessions.get(module);
|
|
80
|
-
if (previous?.saving) throw new Error("Cannot refresh while saving");
|
|
81
|
-
previous?.controller.abort();
|
|
82
|
-
const state = { controller: new AbortController(), definition: null, values: {}, saving: false };
|
|
83
|
-
sessions.set(module, state);
|
|
84
|
-
try {
|
|
85
|
-
const resource = configPath(module);
|
|
86
|
-
const definition = normalizeBoxJs(await (await send(resource, "GET", undefined, state.controller.signal, true)).json(), module);
|
|
87
|
-
if (definition.settingsPath.length < 2) throw new TypeError("BoxJS fields must share a settings subtree below the module root");
|
|
88
|
-
const subtree = await (
|
|
89
|
-
await send(`/api/${definition.settingsPath.map(encodeURIComponent).join("/")}/`, "GET", undefined, state.controller.signal)
|
|
90
|
-
).json();
|
|
91
|
-
if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
|
|
92
|
-
if (sessions.get(module) !== state) throw new Error("Module session was replaced");
|
|
93
|
-
state.definition = definition;
|
|
94
|
-
for (const field of definition.fields) {
|
|
95
|
-
const value = _.get(subtree, field.key.split(".").slice(definition.settingsPath.length), field.defaultValue);
|
|
96
|
-
if (value !== undefined) state.values[field.key] = normalizeStoredValue(field, value);
|
|
97
|
-
}
|
|
98
|
-
return snapshot(module);
|
|
99
|
-
} catch (error) {
|
|
100
|
-
if (sessions.get(module) === state) sessions.delete(module);
|
|
101
|
-
throw error;
|
|
102
|
-
}
|
|
103
|
-
},
|
|
104
|
-
snapshot,
|
|
105
|
-
leave(module) {
|
|
106
|
-
sessions.get(module)?.controller.abort();
|
|
107
|
-
sessions.delete(module);
|
|
108
|
-
},
|
|
109
|
-
set: (module, key, value) => change(module, key, "POST", value),
|
|
110
|
-
remove: (module, key) => change(module, key, "DELETE"),
|
|
111
|
-
};
|
|
112
|
-
}
|
package/browser/index.mjs
DELETED
package/browser/panel.css
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
.pp-panel {
|
|
2
|
-
font:
|
|
3
|
-
15px / 1.5 system-ui,
|
|
4
|
-
sans-serif;
|
|
5
|
-
color: var(--pp-text, #18191c);
|
|
6
|
-
background: var(--pp-background, #f6f7f8);
|
|
7
|
-
max-width: 760px;
|
|
8
|
-
margin: auto;
|
|
9
|
-
position: relative;
|
|
10
|
-
}
|
|
11
|
-
.pp-panel * {
|
|
12
|
-
box-sizing: border-box;
|
|
13
|
-
}
|
|
14
|
-
.pp-header {
|
|
15
|
-
display: flex;
|
|
16
|
-
align-items: center;
|
|
17
|
-
gap: 12px;
|
|
18
|
-
padding: 12px 16px;
|
|
19
|
-
background: var(--pp-surface, #fff);
|
|
20
|
-
}
|
|
21
|
-
.pp-title {
|
|
22
|
-
font-size: 18px;
|
|
23
|
-
margin: 0;
|
|
24
|
-
}
|
|
25
|
-
.pp-viewport {
|
|
26
|
-
max-height: 80vh;
|
|
27
|
-
overflow: auto;
|
|
28
|
-
padding: 16px;
|
|
29
|
-
}
|
|
30
|
-
.pp-panel button {
|
|
31
|
-
font: inherit;
|
|
32
|
-
cursor: pointer;
|
|
33
|
-
border: 1px solid var(--pp-border, #d8dce0);
|
|
34
|
-
border-radius: 8px;
|
|
35
|
-
padding: 8px 12px;
|
|
36
|
-
color: inherit;
|
|
37
|
-
background: var(--pp-surface, #fff);
|
|
38
|
-
}
|
|
39
|
-
.pp-panel button:disabled {
|
|
40
|
-
opacity: 0.45;
|
|
41
|
-
cursor: default;
|
|
42
|
-
}
|
|
43
|
-
.pp-field {
|
|
44
|
-
margin: 0 0 16px;
|
|
45
|
-
padding: 16px;
|
|
46
|
-
border: 1px solid var(--pp-border, #d8dce0);
|
|
47
|
-
border-radius: 10px;
|
|
48
|
-
background: var(--pp-surface, #fff);
|
|
49
|
-
min-width: 0;
|
|
50
|
-
}
|
|
51
|
-
.pp-description {
|
|
52
|
-
font-size: 13px;
|
|
53
|
-
opacity: 0.7;
|
|
54
|
-
margin: 0 0 8px;
|
|
55
|
-
}
|
|
56
|
-
.pp-input:not([type="checkbox"]) {
|
|
57
|
-
width: 100%;
|
|
58
|
-
font: inherit;
|
|
59
|
-
padding: 8px;
|
|
60
|
-
border: 1px solid var(--pp-border, #d8dce0);
|
|
61
|
-
border-radius: 6px;
|
|
62
|
-
background: var(--pp-surface, #fff);
|
|
63
|
-
color: inherit;
|
|
64
|
-
}
|
|
65
|
-
.pp-choice {
|
|
66
|
-
display: block;
|
|
67
|
-
margin: 6px 0;
|
|
68
|
-
}
|
|
69
|
-
.pp-choice input {
|
|
70
|
-
margin-right: 8px;
|
|
71
|
-
}
|
|
72
|
-
.pp-actions {
|
|
73
|
-
display: flex;
|
|
74
|
-
gap: 8px;
|
|
75
|
-
margin-top: 12px;
|
|
76
|
-
}
|
|
77
|
-
.pp-toast {
|
|
78
|
-
position: fixed;
|
|
79
|
-
bottom: 32px;
|
|
80
|
-
left: 50%;
|
|
81
|
-
transform: translateX(-50%);
|
|
82
|
-
padding: 10px 18px;
|
|
83
|
-
border-radius: 10px;
|
|
84
|
-
background: #252525;
|
|
85
|
-
color: #fff;
|
|
86
|
-
max-width: 90vw;
|
|
87
|
-
z-index: 1000;
|
|
88
|
-
}
|
|
89
|
-
.pp-toast[data-kind="error"] {
|
|
90
|
-
background: #8d2424;
|
|
91
|
-
}
|
|
92
|
-
.pp-panel :focus-visible {
|
|
93
|
-
outline: 2px solid #008ac5;
|
|
94
|
-
outline-offset: 2px;
|
|
95
|
-
}
|
|
96
|
-
@media (prefers-color-scheme: dark) {
|
|
97
|
-
.pp-panel {
|
|
98
|
-
--pp-text: #eee;
|
|
99
|
-
--pp-background: #18191c;
|
|
100
|
-
--pp-surface: #25262a;
|
|
101
|
-
--pp-border: #47494e;
|
|
102
|
-
}
|
|
103
|
-
}
|