@nsnanocat/preference-panes 0.2.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 +58 -103
- package/dist/preference-panes.mjs +762 -1100
- package/dist/preference-panes.request.js +168 -571
- package/package.json +64 -64
- package/src/SettingsHandler.mjs +127 -123
- package/src/browser/client.mjs +218 -102
- package/src/browser/index.d.ts +141 -20
- package/src/browser/index.mjs +5 -0
- package/src/browser/panel.css +98 -84
- package/src/browser/panel.mjs +423 -272
- package/src/index.d.ts +143 -54
- package/src/index.mjs +5 -0
- package/src/lib/boxjs.mjs +117 -104
- package/src/lib/settings-path.mjs +31 -11
- package/src/proxy/request.mjs +23 -14
package/package.json
CHANGED
|
@@ -1,66 +1,66 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
2
|
+
"name": "@nsnanocat/preference-panes",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Shared settings API runtime for JavaScript proxy modules",
|
|
5
|
+
"author": "VirgilClyne <Virgil@nanocat.me>",
|
|
6
|
+
"homepage": "https://NSNanoCat.github.io/preference-panes",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"loon",
|
|
9
|
+
"quantumult",
|
|
10
|
+
"surge",
|
|
11
|
+
"shadowrocket",
|
|
12
|
+
"stash",
|
|
13
|
+
"egern",
|
|
14
|
+
"settings"
|
|
15
|
+
],
|
|
16
|
+
"license": "Apache-2.0",
|
|
17
|
+
"bugs": "https://github.com/NSNanoCat/PreferencePanes/issues",
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "src/index.mjs",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./src/index.d.ts",
|
|
23
|
+
"import": "./src/index.mjs"
|
|
24
|
+
},
|
|
25
|
+
"./browser": {
|
|
26
|
+
"types": "./src/browser/index.d.ts",
|
|
27
|
+
"import": "./src/browser/index.mjs"
|
|
28
|
+
},
|
|
29
|
+
"./browser/panel.css": "./src/browser/panel.css",
|
|
30
|
+
"./dist/preference-panes.mjs": "./dist/preference-panes.mjs",
|
|
31
|
+
"./dist/preference-panes.request.js": "./dist/preference-panes.request.js"
|
|
32
|
+
},
|
|
33
|
+
"types": "src/index.d.ts",
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "node --test test/*.test.mjs",
|
|
36
|
+
"check": "npm run lint && npm run typecheck && npm test",
|
|
37
|
+
"lint": "biome check .",
|
|
38
|
+
"format": "biome check --write .",
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"build": "rollup -c",
|
|
41
|
+
"prepack": "npm run build",
|
|
42
|
+
"apifox:generate": "node apifox/generate.mjs",
|
|
43
|
+
"apifox:check": "node apifox/generate.mjs --check"
|
|
44
|
+
},
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/NSNanoCat/PreferencePanes.git"
|
|
48
|
+
},
|
|
49
|
+
"files": [
|
|
50
|
+
"src",
|
|
51
|
+
"dist"
|
|
52
|
+
],
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@nsnanocat/url": "^1.2.6",
|
|
55
|
+
"@nsnanocat/util": "^2.7.4"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@biomejs/biome": "2.4.6",
|
|
59
|
+
"@rollup/plugin-node-resolve": "^16.0.3",
|
|
60
|
+
"rollup": "^4.63.1",
|
|
61
|
+
"typescript": "^5.9.3"
|
|
62
|
+
},
|
|
63
|
+
"publishConfig": {
|
|
64
|
+
"access": "public"
|
|
65
|
+
}
|
|
66
66
|
}
|
package/src/SettingsHandler.mjs
CHANGED
|
@@ -1,138 +1,142 @@
|
|
|
1
1
|
import { URL } from "@nsnanocat/url";
|
|
2
|
-
import { fetch } from "@nsnanocat/util/polyfill/fetch";
|
|
3
2
|
import { Lodash as _ } from "@nsnanocat/util/polyfill/Lodash.mjs";
|
|
4
3
|
import { Storage } from "@nsnanocat/util/polyfill/Storage";
|
|
5
|
-
import {
|
|
6
|
-
import { parseSettingsPath } from "./lib/settings-path.mjs";
|
|
4
|
+
import { parseSettingsPathname, validatePathParts } from "./lib/settings-path.mjs";
|
|
7
5
|
|
|
8
6
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* 按插件声明的根和模块桥接持久化存储,不下载或解析 BoxJS。
|
|
8
|
+
* Bridge persistence within the installed root and module without downloading or parsing BoxJS.
|
|
11
9
|
*/
|
|
12
10
|
export class SettingsHandler {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
/** @type {string} 接管来源 / Handled origin. */
|
|
12
|
+
#origin;
|
|
13
|
+
/** @type {string} 安装配置中的存储根 / Storage root from installation config. */
|
|
14
|
+
#storageKey;
|
|
15
|
+
/** @type {string} 安装配置中的模块 / Module from installation config. */
|
|
16
|
+
#module;
|
|
17
|
+
/** @type {string} 页面标记头 / Page marker header. */
|
|
18
|
+
#requestHeader;
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
20
|
+
/**
|
|
21
|
+
* 固定来源、存储根和模块,构造时不访问网络或存储。
|
|
22
|
+
* Fix the origin, storage root and module without network or storage access at construction.
|
|
23
|
+
* @param {import("./index.js").SettingsHandlerOptions} options 插件安装配置 / Plugin installation config.
|
|
24
|
+
* @throws {TypeError} 安装配置无效 / Invalid installation config.
|
|
25
|
+
*/
|
|
26
|
+
constructor({ origin, storageKey, module, requestHeader = "X-Settings-Client" }) {
|
|
27
|
+
const target = new URL(origin);
|
|
28
|
+
if (target.protocol !== "https:" || target.pathname !== "/" || target.search || target.hash || target.username || target.password) throw new TypeError("origin must be an HTTPS origin");
|
|
29
|
+
if (typeof storageKey !== "string" || !storageKey || storageKey.startsWith("@")) throw new TypeError("storageKey must be a literal root key");
|
|
30
|
+
validatePathParts([module]);
|
|
31
|
+
if (!/^[a-z][a-z0-9-]*$/i.test(requestHeader)) throw new TypeError("Invalid requestHeader");
|
|
32
|
+
this.#origin = target.origin;
|
|
33
|
+
this.#storageKey = storageKey;
|
|
34
|
+
this.#module = module;
|
|
35
|
+
this.#requestHeader = requestHeader;
|
|
36
|
+
}
|
|
32
37
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
value = JSON.parse(request.body);
|
|
97
|
-
} catch {
|
|
98
|
-
return reply(400, { error: "Invalid JSON" });
|
|
99
|
-
}
|
|
100
|
-
if (!validValue(field, value)) return reply(400, { error: `Invalid setting value: ${key}` });
|
|
101
|
-
}
|
|
102
|
-
const saved = Storage.getItem(definition.storageKey, {});
|
|
103
|
-
if (!isRecord(saved)) throw new TypeError("stored settings must be an object");
|
|
104
|
-
const parent = settingsParent(saved, parts, request.method === "POST");
|
|
105
|
-
if (parent) {
|
|
106
|
-
if (request.method === "DELETE") _.unset(parent, [parts.at(-1)]);
|
|
107
|
-
else _.set(parent, [parts.at(-1)], value);
|
|
108
|
-
}
|
|
109
|
-
if (!Storage.setItem(definition.storageKey, saved)) return reply(500, { error: "Settings storage write failed" });
|
|
110
|
-
return reply(200, request.method === "DELETE" ? { deleted: true } : { saved: true });
|
|
111
|
-
}
|
|
38
|
+
/**
|
|
39
|
+
* GET 返回指定值,POST 替换指定值,DELETE 删除指定键或整个模块。
|
|
40
|
+
* GET returns a value, POST replaces it, and DELETE removes a key or the entire module.
|
|
41
|
+
* @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
|
|
42
|
+
* @returns {Promise<import("./index.js").SettingsResponse | undefined>} 响应或非接管请求 / Response, or undefined for an unhandled request.
|
|
43
|
+
*/
|
|
44
|
+
async handle(request) {
|
|
45
|
+
const url = new URL(request.url);
|
|
46
|
+
if (url.origin !== this.#origin || !url.pathname.startsWith("/api/")) return;
|
|
47
|
+
const headers = { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" };
|
|
48
|
+
const reply = (status, data) => ({ status, headers, body: request.method === "HEAD" ? "" : JSON.stringify(data) });
|
|
49
|
+
let parts;
|
|
50
|
+
try {
|
|
51
|
+
parts = parseSettingsPathname(url.pathname);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return reply(400, { error: error.message });
|
|
54
|
+
}
|
|
55
|
+
if (parts[0] !== this.#module) return reply(404, { error: "Module is not handled" });
|
|
56
|
+
const requestHeaders = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
|
|
57
|
+
if (requestHeaders[this.#requestHeader.toLowerCase()] !== "1" || (requestHeaders.origin && requestHeaders.origin !== this.#origin)) return reply(403, { error: "Forbidden settings client" });
|
|
58
|
+
let value;
|
|
59
|
+
switch (request.method) {
|
|
60
|
+
case "HEAD":
|
|
61
|
+
return reply(200, undefined);
|
|
62
|
+
case "GET":
|
|
63
|
+
case "DELETE":
|
|
64
|
+
break;
|
|
65
|
+
case "POST":
|
|
66
|
+
if (requestHeaders["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/json") return reply(415, { error: "Expected application/json" });
|
|
67
|
+
if (typeof request.body !== "string") return reply(400, { error: "Expected a JSON string body" });
|
|
68
|
+
if (request.body.length > 65536) return reply(413, { error: "Body exceeds 65536 UTF-16 code units" });
|
|
69
|
+
try {
|
|
70
|
+
value = JSON.parse(request.body);
|
|
71
|
+
} catch {
|
|
72
|
+
return reply(400, { error: "Invalid JSON" });
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
default:
|
|
76
|
+
return { ...reply(405, { error: "Method not allowed" }), headers: { ...headers, Allow: "HEAD, GET, POST, DELETE" } };
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const root = Storage.getItem(this.#storageKey, {});
|
|
80
|
+
if (!isRecord(root)) throw new TypeError("stored root must be an object");
|
|
81
|
+
const parent = storageParent(root, parts, request.method === "POST");
|
|
82
|
+
const key = parts.at(-1);
|
|
83
|
+
switch (request.method) {
|
|
84
|
+
case "GET": {
|
|
85
|
+
const result = parent ? _.get(parent, [key]) : undefined;
|
|
86
|
+
return result === undefined ? reply(404, { error: "Stored path does not exist" }) : reply(200, result);
|
|
87
|
+
}
|
|
88
|
+
case "POST":
|
|
89
|
+
_.set(parent, [key], value);
|
|
90
|
+
break;
|
|
91
|
+
case "DELETE":
|
|
92
|
+
if (parent) _.unset(parent, [key]);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
if (!Storage.setItem(this.#storageKey, root)) throw new Error("Storage write failed");
|
|
96
|
+
return reply(200, request.method === "POST" ? { saved: true } : { deleted: true });
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return reply(500, { error: error.message });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
112
101
|
}
|
|
113
102
|
|
|
103
|
+
/**
|
|
104
|
+
* 判断根节点是否为普通对象。
|
|
105
|
+
* Determine whether a root node is a plain object.
|
|
106
|
+
* @param {unknown} value 待检查值 / Value to inspect.
|
|
107
|
+
* @returns {boolean} 是否为普通对象 / Whether this is a plain object.
|
|
108
|
+
*/
|
|
114
109
|
function isRecord(value) {
|
|
115
|
-
|
|
110
|
+
return value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype;
|
|
116
111
|
}
|
|
117
112
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
113
|
+
/**
|
|
114
|
+
* 遍历父路径,兼容旧存储中 JSON 字符串形式的中间节点。
|
|
115
|
+
* Traverse parents, supporting legacy intermediate nodes serialized as JSON strings.
|
|
116
|
+
* @param {Record<string, unknown>} root 存储根 / Storage root.
|
|
117
|
+
* @param {string[]} parts 完整路径 / Complete path.
|
|
118
|
+
* @param {boolean} create 是否创建缺失节点 / Whether to create missing parents.
|
|
119
|
+
* @returns {object | undefined} 父节点,缺失且不创建时为 undefined / Parent, or undefined when absent and not creating.
|
|
120
|
+
* @throws {TypeError} 无法继续遍历标量节点 / A scalar node cannot be traversed.
|
|
121
|
+
*/
|
|
122
|
+
function storageParent(root, parts, create) {
|
|
123
|
+
let parent = root;
|
|
124
|
+
for (const part of parts.slice(0, -1)) {
|
|
125
|
+
let next = _.get(parent, [part]);
|
|
126
|
+
switch (typeof next) {
|
|
127
|
+
case "undefined":
|
|
128
|
+
if (!create) return;
|
|
129
|
+
next = {};
|
|
130
|
+
break;
|
|
131
|
+
case "string":
|
|
132
|
+
next = JSON.parse(next);
|
|
133
|
+
break;
|
|
134
|
+
default:
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
if (!isRecord(next) && !Array.isArray(next)) throw new TypeError("Stored parent is not an object or array");
|
|
138
|
+
_.set(parent, [part], next);
|
|
139
|
+
parent = next;
|
|
140
|
+
}
|
|
141
|
+
return parent;
|
|
138
142
|
}
|