@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/package.json CHANGED
@@ -1,68 +1,64 @@
1
1
  {
2
- "name": "@nsnanocat/preference-panes",
3
- "version": "0.5.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
- "./dist/preference-panes.proxy.js": "./dist/preference-panes.proxy.js",
33
- "./dist/settings/*": "./dist/settings/*"
34
- },
35
- "types": "src/index.d.ts",
36
- "scripts": {
37
- "test": "node --test test/*.test.mjs",
38
- "check": "npm run lint && npm run typecheck && npm test",
39
- "lint": "biome check .",
40
- "format": "biome check --write .",
41
- "typecheck": "tsc --noEmit",
42
- "build": "rollup -c",
43
- "prepack": "npm run build",
44
- "apifox:generate": "node apifox/generate.mjs",
45
- "apifox:check": "node apifox/generate.mjs --check"
46
- },
47
- "repository": {
48
- "type": "git",
49
- "url": "git+https://github.com/NSNanoCat/PreferencePanes.git"
50
- },
51
- "files": [
52
- "src",
53
- "dist"
54
- ],
55
- "dependencies": {
56
- "@nsnanocat/url": "^1.2.6",
57
- "@nsnanocat/util": "^2.7.4"
58
- },
59
- "devDependencies": {
60
- "@biomejs/biome": "2.4.6",
61
- "@rollup/plugin-node-resolve": "^16.0.3",
62
- "rollup": "^4.63.1",
63
- "typescript": "^5.9.3"
64
- },
65
- "publishConfig": {
66
- "access": "public"
67
- }
2
+ "name": "@nsnanocat/preference-panes",
3
+ "version": "0.7.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": "./dist/preference-panes.mjs"
28
+ }
29
+ },
30
+ "types": "src/index.d.ts",
31
+ "scripts": {
32
+ "test": "node --test test/*.test.mjs",
33
+ "pretest": "npm run build",
34
+ "check": "npm run lint && npm run typecheck && npm test",
35
+ "lint": "biome check .",
36
+ "format": "biome check --write .",
37
+ "typecheck": "tsc --noEmit",
38
+ "build": "rollup -c",
39
+ "prebuild": "node --input-type=module -e \"import { rm } from 'node:fs/promises'; await rm('dist', { recursive: true, force: true });\"",
40
+ "prepack": "npm run build",
41
+ "preview": "npm run build && node examples/preview.mjs",
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
+ "devDependencies": {
54
+ "@biomejs/biome": "2.1.4",
55
+ "@nsnanocat/url": "^1.2.6",
56
+ "@nsnanocat/util": "^2.7.4",
57
+ "@rollup/plugin-node-resolve": "^16.0.3",
58
+ "rollup": "^4.63.1",
59
+ "typescript": "^5.9.3"
60
+ },
61
+ "publishConfig": {
62
+ "access": "public"
63
+ }
68
64
  }
package/src/BoxJS.mjs ADDED
@@ -0,0 +1,86 @@
1
+ import { validatePathParts } from "./lib/settings-path.mjs";
2
+
3
+ /**
4
+ * BoxJS 的共同目录:模块、存储根和展示元数据都来自同一份 JSON。
5
+ * Shared BoxJS catalog deriving modules, storage roots and metadata from one JSON document.
6
+ */
7
+ export class BoxJS {
8
+ /**
9
+ * 建立路径索引,不解析控件类型,也不读写持久化存储。
10
+ * Index field paths without interpreting controls or accessing persistence.
11
+ * @param {unknown} input 字段数组、单个 app 或 apps 订阅 / Field array, app or apps subscription.
12
+ */
13
+ constructor(input) {
14
+ if (!input || typeof input !== "object") throw new TypeError("Expected BoxJS JSON");
15
+ this.document = JSON.parse(JSON.stringify(input));
16
+ const apps = Array.isArray(this.document) ? [{ settings: this.document }] : (this.document.apps ?? [this.document]);
17
+ if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
18
+ this.modules = new Map();
19
+ for (const app of apps) {
20
+ if (!app || !Array.isArray(app.settings)) throw new TypeError("Expected BoxJS settings array");
21
+ for (const entry of app.settings) {
22
+ if (typeof entry.id !== "string") throw new TypeError("BoxJS settings require string IDs");
23
+ if (!entry.id.startsWith("@")) {
24
+ if (Array.isArray(this.document)) throw new TypeError("BoxJS settings require @root.path IDs");
25
+ continue;
26
+ }
27
+ const [storageKey, ...parts] = entry.id.slice(1).split(".");
28
+ if (!storageKey || storageKey.startsWith("@") || parts.length < 2) throw new TypeError("A BoxJS setting must be below a literal storage root and module");
29
+ validatePathParts(parts);
30
+ const module = parts[0];
31
+ let target = this.modules.get(module);
32
+ if (!target) {
33
+ target = { module, storageKey, entries: [], owners: new Set() };
34
+ this.modules.set(module, target);
35
+ }
36
+ if (target.storageKey !== storageKey) throw new TypeError(`A module must use one storage root: ${module}`);
37
+ target.entries.push(entry);
38
+ target.owners.add(app);
39
+ }
40
+ }
41
+ this.metadata = metadata(Array.isArray(this.document) ? {} : this.document);
42
+ for (const target of this.modules.values()) target.metadata = target.owners.size === 1 ? metadata([...target.owners][0]) : {};
43
+ }
44
+
45
+ /**
46
+ * 提取一个模块的原生 BoxJS,保留所属 app 的元数据。
47
+ * Select a module's native BoxJS while retaining owning-app metadata.
48
+ * @param {string} module 模块标识 / Module identifier.
49
+ * @returns {unknown} 可直接用作配置 Mock 的 JSON / JSON suitable for a configuration Mock.
50
+ */
51
+ select(module) {
52
+ const target = this.modules.get(module);
53
+ if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
54
+ if (Array.isArray(this.document)) return target.entries;
55
+ const apps = [...target.owners].map(app => ({ ...app, settings: app.settings.filter(entry => target.entries.includes(entry)) }));
56
+ return this.document.apps ? { ...this.document, apps } : apps[0];
57
+ }
58
+
59
+ /**
60
+ * 取得本次导入的唯一模块,避免把模块数据变成项目目录。
61
+ * Get the single imported module without turning module data into a project directory.
62
+ * @returns {object} 唯一模块的目录项 / The single module entry.
63
+ */
64
+ get module() {
65
+ if (this.modules.size !== 1) throw new TypeError("Import BoxJS JSON for exactly one module");
66
+ return this.modules.values().next().value;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * 保留标准 BoxJS 展示信息;script 仅为元数据,不执行。
72
+ * Retain standard BoxJS presentation data; script is metadata only and never executed.
73
+ * @param {object} source BoxJS app 或订阅 / BoxJS app or subscription.
74
+ * @returns {object} 经过类型检查的展示信息 / Type-checked presentation metadata.
75
+ */
76
+ function metadata(source) {
77
+ const result = {};
78
+ for (const key of ["id", "name", "author", "repo", "script", "icon", "description", "desc", "icons", "descs"]) {
79
+ if (source[key] === undefined) continue;
80
+ const multiple = key === "icons" || key === "descs";
81
+ const values = multiple ? source[key] : [source[key]];
82
+ if (!Array.isArray(values) || values.some(item => typeof item !== "string")) throw new TypeError(`Invalid BoxJS app ${key}`);
83
+ result[key] = multiple ? [...values] : source[key];
84
+ }
85
+ return result;
86
+ }
package/src/Store.mjs ADDED
@@ -0,0 +1,127 @@
1
+ import { URL } from "@nsnanocat/url";
2
+ import { Lodash as _ } from "@nsnanocat/util/polyfill/Lodash.mjs";
3
+ import { Storage } from "@nsnanocat/util/polyfill/Storage";
4
+ import { response } from "./lib/response.mjs";
5
+ import { parseSettingsPathname } from "./lib/settings-path.mjs";
6
+
7
+ /**
8
+ * 根据 BoxJS 目录桥接持久化存储,不下载配置或解析控件。
9
+ * Bridge persistence using the BoxJS catalog without downloading configuration or interpreting controls.
10
+ */
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
+ /**
24
+ * GET 返回指定值,POST 替换指定值,DELETE 删除指定键或整个模块。
25
+ * GET returns a value, POST replaces it, and DELETE removes a key or the entire module.
26
+ * @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
27
+ * @param {URL} [url] 包内复用的已解析地址 / Parsed URL reused within the package.
28
+ * @returns {Promise<import("./index.js").SettingsResponse | undefined>} 响应或非接管请求 / Response, or undefined for an unhandled request.
29
+ */
30
+ async handle(request, url = new URL(request.url)) {
31
+ if (!url.pathname.startsWith("/api/")) return;
32
+ const reply = (status, data) => response(request, status, data);
33
+ let parts;
34
+ try {
35
+ parts = parseSettingsPathname(url.pathname);
36
+ } catch (error) {
37
+ return reply(400, { error: error.message });
38
+ }
39
+ const binding = this.#catalog.modules.get(parts[0]);
40
+ if (!binding) return reply(404, { error: "Module is not declared in BoxJS" });
41
+ const requestHeaders = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
42
+ if (requestHeaders["x-settings-client"] !== "1" || (requestHeaders.origin && requestHeaders.origin !== url.origin)) return reply(403, { error: "Forbidden settings client" });
43
+ let value;
44
+ switch (request.method) {
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" } };
62
+ }
63
+ try {
64
+ const root = Storage.getItem(binding.storageKey, {});
65
+ if (!isRecord(root)) throw new TypeError("stored root must be an object");
66
+ const parent = storageParent(root, parts, request.method === "POST");
67
+ const key = parts.at(-1);
68
+ switch (request.method) {
69
+ case "GET": {
70
+ const result = parent ? _.get(parent, [key]) : undefined;
71
+ return result === undefined ? reply(404, { error: "Stored path does not exist" }) : reply(200, result);
72
+ }
73
+ case "POST":
74
+ _.set(parent, [key], value);
75
+ break;
76
+ case "DELETE":
77
+ if (parent) _.unset(parent, [key]);
78
+ break;
79
+ }
80
+ if (!Storage.setItem(binding.storageKey, root)) throw new Error("Storage write failed");
81
+ return reply(200, request.method === "POST" ? { saved: true } : { deleted: true });
82
+ } catch (error) {
83
+ return reply(500, { error: error.message });
84
+ }
85
+ }
86
+ }
87
+
88
+ /**
89
+ * 判断根节点是否为普通对象。
90
+ * Determine whether a root node is a plain object.
91
+ * @param {unknown} value 待检查值 / Value to inspect.
92
+ * @returns {boolean} 是否为普通对象 / Whether this is a plain object.
93
+ */
94
+ function isRecord(value) {
95
+ return value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype;
96
+ }
97
+
98
+ /**
99
+ * 遍历父路径,兼容旧存储中 JSON 字符串形式的中间节点。
100
+ * Traverse parents, supporting legacy intermediate nodes serialized as JSON strings.
101
+ * @param {Record<string, unknown>} root 存储根 / Storage root.
102
+ * @param {string[]} parts 完整路径 / Complete path.
103
+ * @param {boolean} create 是否创建缺失节点 / Whether to create missing parents.
104
+ * @returns {object | undefined} 父节点,缺失且不创建时为 undefined / Parent, or undefined when absent and not creating.
105
+ * @throws {TypeError} 无法继续遍历标量节点 / A scalar node cannot be traversed.
106
+ */
107
+ function storageParent(root, parts, create) {
108
+ let parent = root;
109
+ for (const part of parts.slice(0, -1)) {
110
+ let next = _.get(parent, [part]);
111
+ switch (typeof next) {
112
+ case "undefined":
113
+ if (!create) return;
114
+ next = {};
115
+ break;
116
+ case "string":
117
+ next = JSON.parse(next);
118
+ break;
119
+ default:
120
+ break;
121
+ }
122
+ if (!isRecord(next) && !Array.isArray(next)) throw new TypeError("Stored parent is not an object or array");
123
+ _.set(parent, [part], next);
124
+ parent = next;
125
+ }
126
+ return parent;
127
+ }
@@ -1,154 +1,30 @@
1
- import { validatePathParts } from "../lib/settings-path.mjs";
2
- import { createPreferencesClient } from "./client.mjs";
3
- import { mountPreferencePanes } from "./panel.mjs";
1
+ import { BoxJS } from "../BoxJS.mjs";
2
+ import { errorView } from "./components.mjs";
3
+ import { mount } from "./index.mjs";
4
4
 
5
+ let view;
5
6
  /**
6
- * 自包含页面入口,业务名称、图标和入口全部从站点 BoxJS JSON 读取。
7
- * Self-contained page entry reading all branding, icons and navigation from site BoxJS JSON.
8
- * @module @nsnanocat/preference-panes/site
7
+ * 在具体模块地址导入 JSON 与 CSS,再交给模块渲染器。
8
+ * Import JSON and CSS at a concrete module URL and pass them to the module renderer.
9
+ * @returns {Promise<void>} 启动完成 / Startup completion.
9
10
  */
10
- const root = document.querySelector("#preferences");
11
- const client = createPreferencesClient({ timeout: 3500 });
12
- const theme = navigator.userAgent.match(/themeId\/(\d+)/)?.[1];
13
- if (theme) document.documentElement.dataset.theme = theme === "2" ? "dark" : "light";
14
- let panel,
15
- menu,
16
- revision = 0,
17
- routedPath;
18
-
19
- /**
20
- * 创建只含纯文本的元素。
21
- * Create an element containing plain text only.
22
- * @param {string} tag 标签 / Tag.
23
- * @param {string} className 样式 / CSS class.
24
- * @param {string} [text] 文本 / Text.
25
- * @returns {HTMLElement} 元素 / Element.
26
- */
27
- function node(tag, className, text) {
28
- const element = document.createElement(tag);
29
- element.className = className;
30
- if (text !== undefined) element.textContent = text;
31
- return element;
11
+ async function start() {
12
+ try {
13
+ view?.destroy();
14
+ view = undefined;
15
+ const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(location.pathname);
16
+ if (!match) throw new Error("Open a concrete module URL");
17
+ const module = match[1];
18
+ const [data, style] = await Promise.all([fetch(`/configs/${module}`, { cache: "no-store" }), fetch(`/settings/assets/${module}.css`, { cache: "no-store" })]);
19
+ if (data.status !== 200 || style.status !== 200) throw new Error(`HTTP ${data.status !== 200 ? data.status : style.status}`);
20
+ const boxjs = await data.json();
21
+ if (new BoxJS(boxjs).module.module !== module) throw new Error("Imported JSON does not match the module URL");
22
+ view = mount(boxjs, await style.text());
23
+ } catch (error) {
24
+ document.querySelector("#preferences").replaceChildren(errorView(error, start));
25
+ }
32
26
  }
33
-
34
- /**
35
- * 按站点 JSON 显示图标;iconDark 是明确的暗色扩展,不改变 BoxJS icons 语义。
36
- * Display configured icons; iconDark is an explicit dark variant without changing BoxJS icons semantics.
37
- * @param {{icon?:string,iconDark?:string,icons?:string[]}} metadata 图标数据 / Icon data.
38
- * @param {string} className 样式 / CSS class.
39
- * @returns {HTMLPictureElement} 图片元素 / Picture element.
40
- */
41
- function icon(metadata, className) {
42
- const picture = node("picture", className),
43
- image = node("img", "");
44
- if (metadata.iconDark) {
45
- const source = node("source", "");
46
- source.media = theme ? (theme === "2" ? "all" : "not all") : "(prefers-color-scheme: dark)";
47
- source.srcset = resourceURL(metadata.iconDark);
48
- picture.append(source);
49
- }
50
- const selected = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
51
- if (selected) image.src = resourceURL(selected);
52
- image.alt = "";
53
- picture.append(image);
54
- return picture;
55
- }
56
-
57
- /**
58
- * 校验配置中的资源 URL。
59
- * Validate resource URLs in site configuration.
60
- * @param {string} value 地址 / Address.
61
- * @returns {string} 完整 URL / Absolute URL.
62
- */
63
- function resourceURL(value) {
64
- const url = new URL(value, location.href);
65
- if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Resources must use HTTP(S)");
66
- return url.href;
67
- }
68
-
69
- /**
70
- * 挂载通用设置或站点菜单,每次进入菜单都重新 HEAD 探测。
71
- * Mount settings or the site menu, repeating HEAD probes on every menu entry.
72
- * @returns {Promise<void>} 挂载完成 / Mount completion.
73
- */
74
- async function render() {
75
- routedPath = location.pathname;
76
- const version = ++revision;
77
- panel?.destroy();
78
- panel = undefined;
79
- root.replaceChildren();
80
- if (routedPath !== "/settings/") {
81
- document.title = routedPath.split("/")[2] || "Preferences";
82
- panel = mountPreferencePanes({ element: root });
83
- return;
84
- }
85
- try {
86
- if (!menu) {
87
- const response = await fetch("/settings/assets/site.boxjs.json", { cache: "no-store", credentials: "omit" });
88
- if (response.status !== 200) throw new Error(`Site config HTTP ${response.status}`);
89
- const data = await response.json();
90
- if (typeof data.name !== "string" || !Array.isArray(data.apps)) throw new TypeError("Invalid site BoxJS JSON");
91
- for (const app of data.apps) validatePathParts([app.module]);
92
- const localStyles = document.head.querySelector('link[rel="stylesheet"]');
93
- for (const href of data.stylesheets ?? []) {
94
- const link = document.createElement("link");
95
- link.rel = "stylesheet";
96
- link.href = resourceURL(href);
97
- document.head.insertBefore(link, localStyles);
98
- }
99
- menu = data;
100
- }
101
- if (version !== revision) return;
102
- document.title = menu.name;
103
- const home = node("section", "pp-home");
104
- home.append(icon(menu, "brand-logo"), node("h1", "", menu.name));
105
- const section = node("section", "self-panel is-zh");
106
- section.append(node("h2", "header", menu.sectionTitle ?? "模块"));
107
- const container = node("div", "container"),
108
- scrollView = node("div", "scroll-view"),
109
- rows = node("div", "scroll");
110
- scrollView.append(rows);
111
- container.append(scrollView);
112
- section.append(container);
113
- home.append(section);
114
- if (menu.desc) home.append(node("p", "settings-note", menu.desc));
115
- root.append(home);
116
- for (const app of menu.apps) {
117
- const button = node("button", "self-item is-zh"),
118
- status = node("span", "module-status", "检测中");
119
- button.type = "button";
120
- button.disabled = true;
121
- button.dataset.module = app.module;
122
- button.append(icon(app, "logo"), node("span", "name", app.name ?? app.module), status);
123
- rows.append(button);
124
- button.onclick = () => {
125
- history.pushState(null, "", `/settings/${app.module}`);
126
- render();
127
- if (!matchMedia("(prefers-reduced-motion: reduce)").matches) root.animate([{ transform: "translateX(100%)" }, { transform: "translateX(0)" }], { duration: 260, easing: "ease-out" });
128
- };
129
- client.probe(app.module).then(available => {
130
- if (revision !== version) return;
131
- button.disabled = !available;
132
- status.textContent = available ? "" : "未响应";
133
- });
134
- }
135
- } catch (error) {
136
- if (version === revision) {
137
- const retry = node("button", "", "重新读取");
138
- retry.type = "button";
139
- retry.onclick = render;
140
- root.replaceChildren(node("p", "pp-site-error", `加载失败:${error.message}`), retry);
141
- }
142
- }
143
- }
144
-
145
- window.addEventListener("popstate", () => {
146
- if (routedPath !== location.pathname) {
147
- render();
148
- if (location.pathname === "/settings/" && !matchMedia("(prefers-reduced-motion: reduce)").matches) root.animate([{ transform: "translateX(-100%)" }, { transform: "translateX(0)" }], { duration: 260, easing: "ease-out" });
149
- }
150
- });
27
+ start();
151
28
  window.addEventListener("pageshow", event => {
152
- if (event.persisted && location.pathname === "/settings/") render();
29
+ if (event.persisted) start();
153
30
  });
154
- render();