@nsnanocat/preference-panes 0.1.0 → 0.2.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 +45 -14
- package/dist/preference-panes.mjs +435 -9
- package/dist/preference-panes.request.js +2174 -2126
- package/package.json +12 -15
- package/{lib/settings-handler.mjs → src/SettingsHandler.mjs} +43 -18
- package/{browser → src/browser}/client.mjs +2 -2
- package/{types/browser.d.ts → src/browser/index.d.ts} +1 -1
- package/{browser → src/browser}/panel.css +23 -0
- package/{browser → src/browser}/panel.mjs +51 -3
- package/{types → src}/index.d.ts +23 -3
- package/{index.mjs → src/index.mjs} +1 -1
- package/{lib → src/lib}/boxjs.mjs +42 -4
- package/{lib → src/lib}/settings-path.mjs +2 -0
- package/src/proxy/request.mjs +21 -0
- package/proxy/request.mjs +0 -36
- /package/{browser → src/browser}/index.mjs +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nsnanocat/preference-panes",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Shared settings API runtime for JavaScript proxy modules",
|
|
5
5
|
"author": "VirgilClyne <Virgil@nanocat.me>",
|
|
6
6
|
"homepage": "https://NSNanoCat.github.io/preference-panes",
|
|
@@ -16,21 +16,21 @@
|
|
|
16
16
|
"license": "Apache-2.0",
|
|
17
17
|
"bugs": "https://github.com/NSNanoCat/PreferencePanes/issues",
|
|
18
18
|
"type": "module",
|
|
19
|
-
"main": "index.mjs",
|
|
19
|
+
"main": "src/index.mjs",
|
|
20
20
|
"exports": {
|
|
21
21
|
".": {
|
|
22
|
-
"types": "./
|
|
23
|
-
"import": "./index.mjs"
|
|
22
|
+
"types": "./src/index.d.ts",
|
|
23
|
+
"import": "./src/index.mjs"
|
|
24
24
|
},
|
|
25
25
|
"./browser": {
|
|
26
|
-
"types": "./
|
|
27
|
-
"import": "./browser/index.mjs"
|
|
26
|
+
"types": "./src/browser/index.d.ts",
|
|
27
|
+
"import": "./src/browser/index.mjs"
|
|
28
28
|
},
|
|
29
|
-
"./browser/panel.css": "./browser/panel.css",
|
|
29
|
+
"./browser/panel.css": "./src/browser/panel.css",
|
|
30
30
|
"./dist/preference-panes.mjs": "./dist/preference-panes.mjs",
|
|
31
31
|
"./dist/preference-panes.request.js": "./dist/preference-panes.request.js"
|
|
32
32
|
},
|
|
33
|
-
"types": "
|
|
33
|
+
"types": "src/index.d.ts",
|
|
34
34
|
"scripts": {
|
|
35
35
|
"test": "node --test test/*.test.mjs",
|
|
36
36
|
"check": "npm run lint && npm run typecheck && npm test",
|
|
@@ -39,19 +39,16 @@
|
|
|
39
39
|
"typecheck": "tsc --noEmit",
|
|
40
40
|
"build": "rollup -c",
|
|
41
41
|
"prepack": "npm run build",
|
|
42
|
-
"apifox:generate": "node
|
|
42
|
+
"apifox:generate": "node apifox/generate.mjs",
|
|
43
|
+
"apifox:check": "node apifox/generate.mjs --check"
|
|
43
44
|
},
|
|
44
45
|
"repository": {
|
|
45
46
|
"type": "git",
|
|
46
47
|
"url": "git+https://github.com/NSNanoCat/PreferencePanes.git"
|
|
47
48
|
},
|
|
48
49
|
"files": [
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"browser",
|
|
52
|
-
"types",
|
|
53
|
-
"dist",
|
|
54
|
-
"proxy"
|
|
50
|
+
"src",
|
|
51
|
+
"dist"
|
|
55
52
|
],
|
|
56
53
|
"dependencies": {
|
|
57
54
|
"@nsnanocat/url": "^1.2.6",
|
|
@@ -1,23 +1,48 @@
|
|
|
1
|
+
import { URL } from "@nsnanocat/url";
|
|
2
|
+
import { fetch } from "@nsnanocat/util/polyfill/fetch";
|
|
1
3
|
import { Lodash as _ } from "@nsnanocat/util/polyfill/Lodash.mjs";
|
|
2
4
|
import { Storage } from "@nsnanocat/util/polyfill/Storage";
|
|
3
|
-
import { normalizeBoxJs, validValue } from "./boxjs.mjs";
|
|
4
|
-
import { parseSettingsPath } from "./settings-path.mjs";
|
|
5
|
+
import { normalizeBoxJs, validValue } from "./lib/boxjs.mjs";
|
|
6
|
+
import { parseSettingsPath } from "./lib/settings-path.mjs";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* @param {import("../types/index.js").SettingsHandlerOptions} options 路由与配置加载器 / Routing and config loader.
|
|
10
|
-
* @returns {(request: import("../types/index.js").SettingsRequest) => Promise<import("../types/index.js").SettingsResponse | undefined>} 异步处理器 / Async handler.
|
|
9
|
+
* 使用 util 下载 BoxJS、校验字段并读写持久化存储。
|
|
10
|
+
* Download BoxJS through util, validate fields and handle persistent storage.
|
|
11
11
|
*/
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
export class SettingsHandler {
|
|
13
|
+
#origin;
|
|
14
|
+
#configURL;
|
|
15
|
+
#requestHeader;
|
|
16
|
+
#resolveSettings;
|
|
17
|
+
|
|
18
|
+
/** @param {import("./index.js").SettingsHandlerOptions} options 来源、配置地址与 GET 解析器 / Origin, config source and GET resolver. */
|
|
19
|
+
constructor({ origin, configURL, requestHeader = "X-Settings-Client", resolveSettings }) {
|
|
20
|
+
const target = new URL(origin);
|
|
21
|
+
if (target.protocol !== "https:" || target.pathname !== "/" || target.search || target.hash || target.username || target.password)
|
|
22
|
+
throw new TypeError("origin must be an HTTPS origin");
|
|
23
|
+
const source = new URL(configURL);
|
|
24
|
+
if (source.protocol !== "https:" || source.username || source.password || source.hash)
|
|
25
|
+
throw new TypeError("configURL must be an HTTPS URL without credentials or fragment");
|
|
26
|
+
if (!/^[a-z][a-z0-9-]*$/i.test(requestHeader)) throw new TypeError("Invalid requestHeader");
|
|
27
|
+
this.#origin = target.origin;
|
|
28
|
+
this.#configURL = source.href;
|
|
29
|
+
this.#requestHeader = requestHeader;
|
|
30
|
+
this.#resolveSettings = resolveSettings;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async #loadConfig(module) {
|
|
34
|
+
const response = await fetch({ url: this.#configURL, method: "GET", headers: { "Cache-Control": "no-cache" }, timeout: 5000 });
|
|
35
|
+
if (response.status !== 200) throw new Error(`BoxJS source HTTP ${response.status}`);
|
|
36
|
+
return normalizeBoxJs(JSON.parse(response.body), module);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
|
|
41
|
+
* @returns {Promise<import("./index.js").SettingsResponse | undefined>} API 响应,非本来源 API 则不处理 / API response, or undefined outside the configured API origin.
|
|
42
|
+
*/
|
|
43
|
+
async handle(request) {
|
|
19
44
|
const url = new URL(request.url);
|
|
20
|
-
if (url.origin !==
|
|
45
|
+
if (url.origin !== this.#origin || !url.pathname.startsWith("/api/")) return;
|
|
21
46
|
const headers = { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" };
|
|
22
47
|
const reply = (status, data) => ({ status, headers, body: request.method === "HEAD" ? "" : JSON.stringify(data) });
|
|
23
48
|
let parts;
|
|
@@ -27,13 +52,13 @@ export function createSettingsHandler({ origin, loadConfig, requestHeader = "X-S
|
|
|
27
52
|
return reply(400, { error: error.message });
|
|
28
53
|
}
|
|
29
54
|
const requestHeaders = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
|
|
30
|
-
if (requestHeaders[requestHeader.toLowerCase()] !== "1" || (requestHeaders.origin && requestHeaders.origin !==
|
|
55
|
+
if (requestHeaders[this.#requestHeader.toLowerCase()] !== "1" || (requestHeaders.origin && requestHeaders.origin !== this.#origin))
|
|
31
56
|
return reply(403, { error: "Forbidden settings client" });
|
|
32
57
|
if (!["HEAD", "GET", "POST", "DELETE"].includes(request.method))
|
|
33
58
|
return { ...reply(405, { error: "Method not allowed" }), headers: { ...headers, Allow: "HEAD, GET, POST, DELETE" } };
|
|
34
59
|
let definition;
|
|
35
60
|
try {
|
|
36
|
-
definition =
|
|
61
|
+
definition = await this.#loadConfig(parts[0]);
|
|
37
62
|
} catch (error) {
|
|
38
63
|
return reply(502, { error: `Module configuration unavailable: ${error.message}` });
|
|
39
64
|
}
|
|
@@ -44,7 +69,7 @@ export function createSettingsHandler({ origin, loadConfig, requestHeader = "X-S
|
|
|
44
69
|
if (request.method === "HEAD") return reply(200, undefined);
|
|
45
70
|
if (request.method === "GET") {
|
|
46
71
|
const stored = Storage.getItem(definition.storageKey, {});
|
|
47
|
-
const effective = resolveSettings ? resolveSettings(stored, definition) : stored;
|
|
72
|
+
const effective = this.#resolveSettings ? this.#resolveSettings(stored, definition) : stored;
|
|
48
73
|
if (!isRecord(effective)) throw new TypeError("resolved settings must be a synchronous object");
|
|
49
74
|
if (field) {
|
|
50
75
|
const value = pathValue(effective, parts);
|
|
@@ -83,7 +108,7 @@ export function createSettingsHandler({ origin, loadConfig, requestHeader = "X-S
|
|
|
83
108
|
}
|
|
84
109
|
if (!Storage.setItem(definition.storageKey, saved)) return reply(500, { error: "Settings storage write failed" });
|
|
85
110
|
return reply(200, request.method === "DELETE" ? { deleted: true } : { saved: true });
|
|
86
|
-
}
|
|
111
|
+
}
|
|
87
112
|
}
|
|
88
113
|
|
|
89
114
|
function isRecord(value) {
|
|
@@ -5,8 +5,8 @@ import { parseSettingsPath } from "../lib/settings-path.mjs";
|
|
|
5
5
|
/**
|
|
6
6
|
* 创建页面会话缓存;打开时重读,选项操作仅在 HTTP 200 后更新缓存。
|
|
7
7
|
* Create a page-session cache; reload on open and mutate cache only after HTTP 200.
|
|
8
|
-
* @param {import("
|
|
9
|
-
* @returns {import("
|
|
8
|
+
* @param {import("./index.js").PreferencesClientOptions} options 请求与通知 / Requests and notifications.
|
|
9
|
+
* @returns {import("./index.js").PreferencesClient} 通用客户端 / Generic client.
|
|
10
10
|
*/
|
|
11
11
|
export function createPreferencesClient({ fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 } = {}) {
|
|
12
12
|
const sessions = new Map();
|
|
@@ -21,6 +21,27 @@
|
|
|
21
21
|
.pp-title {
|
|
22
22
|
font-size: 18px;
|
|
23
23
|
margin: 0;
|
|
24
|
+
overflow-wrap: anywhere;
|
|
25
|
+
min-width: 0;
|
|
26
|
+
}
|
|
27
|
+
.pp-module-info {
|
|
28
|
+
display: flex;
|
|
29
|
+
gap: 12px;
|
|
30
|
+
margin-bottom: 16px;
|
|
31
|
+
}
|
|
32
|
+
.pp-module-icon {
|
|
33
|
+
width: 48px;
|
|
34
|
+
height: 48px;
|
|
35
|
+
flex: none;
|
|
36
|
+
object-fit: contain;
|
|
37
|
+
}
|
|
38
|
+
.pp-module-details {
|
|
39
|
+
min-width: 0;
|
|
40
|
+
overflow-wrap: anywhere;
|
|
41
|
+
}
|
|
42
|
+
.pp-module-source {
|
|
43
|
+
color: inherit;
|
|
44
|
+
text-decoration: underline;
|
|
24
45
|
}
|
|
25
46
|
.pp-viewport {
|
|
26
47
|
max-height: 80vh;
|
|
@@ -52,6 +73,8 @@
|
|
|
52
73
|
font-size: 13px;
|
|
53
74
|
opacity: 0.7;
|
|
54
75
|
margin: 0 0 8px;
|
|
76
|
+
white-space: pre-wrap;
|
|
77
|
+
overflow-wrap: anywhere;
|
|
55
78
|
}
|
|
56
79
|
.pp-input:not([type="checkbox"]) {
|
|
57
80
|
width: 100%;
|
|
@@ -3,7 +3,7 @@ import { createPreferencesClient } from "./client.mjs";
|
|
|
3
3
|
/**
|
|
4
4
|
* 挂载从 BoxJS 实时生成的设置面板和短暂通知。
|
|
5
5
|
* Mount runtime-generated BoxJS controls and transient notifications.
|
|
6
|
-
* @param {import("
|
|
6
|
+
* @param {import("./index.js").PreferencesPanelOptions} options 容器与请求;页面路径 /settings/{module} 对应配置 / Container and requests; /settings/{module} selects config.
|
|
7
7
|
* @returns {{destroy(): void}} 清理接口 / Cleanup handle.
|
|
8
8
|
*/
|
|
9
9
|
export function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
|
|
@@ -78,7 +78,38 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
|
|
|
78
78
|
}
|
|
79
79
|
function controls() {
|
|
80
80
|
const { definition, values } = client.snapshot(active);
|
|
81
|
+
heading.textContent = definition.metadata?.name || active;
|
|
81
82
|
const view = node("section", "pp-fields");
|
|
83
|
+
const growingInputs = [];
|
|
84
|
+
const metadata = definition.metadata;
|
|
85
|
+
if (metadata) {
|
|
86
|
+
const info = node("div", "pp-module-info");
|
|
87
|
+
const iconURL = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
|
|
88
|
+
const resourceURL = (value) => {
|
|
89
|
+
const url = new window.URL(value, window.location.href);
|
|
90
|
+
if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Module metadata URLs must use HTTP or HTTPS");
|
|
91
|
+
return url.href;
|
|
92
|
+
};
|
|
93
|
+
if (iconURL) {
|
|
94
|
+
const image = node("img", "pp-module-icon");
|
|
95
|
+
image.src = resourceURL(iconURL);
|
|
96
|
+
image.alt = "";
|
|
97
|
+
info.append(image);
|
|
98
|
+
}
|
|
99
|
+
const details = node("div", "pp-module-details");
|
|
100
|
+
if (metadata.author) details.append(node("p", "pp-description", metadata.author));
|
|
101
|
+
for (const description of [metadata.desc ?? metadata.description, ...(metadata.descs ?? [])])
|
|
102
|
+
if (description) details.append(node("p", "pp-description", description));
|
|
103
|
+
if (metadata.repo) {
|
|
104
|
+
const link = node("a", "pp-module-source", "项目主页");
|
|
105
|
+
link.href = resourceURL(metadata.repo);
|
|
106
|
+
link.target = "_blank";
|
|
107
|
+
link.rel = "noopener noreferrer";
|
|
108
|
+
details.append(link);
|
|
109
|
+
}
|
|
110
|
+
info.append(details);
|
|
111
|
+
view.append(info);
|
|
112
|
+
}
|
|
82
113
|
for (const field of definition.fields) {
|
|
83
114
|
const row = node("fieldset", "pp-field");
|
|
84
115
|
row.append(node("legend", "", field.name));
|
|
@@ -113,8 +144,23 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
|
|
|
113
144
|
for (const option of inputs) option.input.checked = Array.isArray(value) && value.includes(option.key);
|
|
114
145
|
};
|
|
115
146
|
} else {
|
|
116
|
-
const
|
|
147
|
+
const multiline = field.control === "textarea" || field.type === "array";
|
|
148
|
+
const input = node(multiline ? "textarea" : "input", "pp-input");
|
|
117
149
|
input.setAttribute("aria-label", field.name);
|
|
150
|
+
if (field.placeholder) input.placeholder = field.placeholder;
|
|
151
|
+
if (multiline && field.rows) input.rows = field.rows;
|
|
152
|
+
const grow = () => {
|
|
153
|
+
if (!multiline || !field.autoGrow || !input.isConnected) return;
|
|
154
|
+
input.style.height = "auto";
|
|
155
|
+
const baseline = input.getBoundingClientRect().height;
|
|
156
|
+
const style = window.getComputedStyle(input);
|
|
157
|
+
const borders = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
|
|
158
|
+
input.style.height = `${Math.max(baseline, input.scrollHeight + borders)}px`;
|
|
159
|
+
};
|
|
160
|
+
if (multiline && field.autoGrow) {
|
|
161
|
+
input.addEventListener("input", grow);
|
|
162
|
+
growingInputs.push(grow);
|
|
163
|
+
}
|
|
118
164
|
if (field.type === "boolean") {
|
|
119
165
|
input.type = "checkbox";
|
|
120
166
|
write = (value) => {
|
|
@@ -122,9 +168,10 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
|
|
|
122
168
|
};
|
|
123
169
|
read = () => input.checked;
|
|
124
170
|
} else {
|
|
125
|
-
input.type = field.type === "number" ? "number" : "text";
|
|
171
|
+
if (!multiline) input.type = field.type === "number" ? "number" : "text";
|
|
126
172
|
write = (value) => {
|
|
127
173
|
input.value = field.type === "array" ? JSON.stringify(value ?? []) : (value ?? "");
|
|
174
|
+
grow();
|
|
128
175
|
};
|
|
129
176
|
read = () =>
|
|
130
177
|
field.type === "array"
|
|
@@ -188,6 +235,7 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
|
|
|
188
235
|
view.append(row);
|
|
189
236
|
}
|
|
190
237
|
viewport.replaceChildren(view);
|
|
238
|
+
for (const grow of growingInputs) grow();
|
|
191
239
|
}
|
|
192
240
|
function route() {
|
|
193
241
|
if (saving) {
|
package/{types → src}/index.d.ts
RENAMED
|
@@ -8,6 +8,10 @@ interface FieldBase {
|
|
|
8
8
|
key: string;
|
|
9
9
|
name: string;
|
|
10
10
|
description?: string;
|
|
11
|
+
control?: "boolean" | "checkboxes" | "selects" | "text" | "textarea" | "number";
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
rows?: number;
|
|
14
|
+
autoGrow?: boolean;
|
|
11
15
|
}
|
|
12
16
|
/** 对齐 argument 配置字段 / Argument-compatible field. */
|
|
13
17
|
export type SettingsField = FieldBase &
|
|
@@ -32,8 +36,8 @@ export interface SettingsResponse {
|
|
|
32
36
|
export interface SettingsHandlerOptions {
|
|
33
37
|
/** 接管 /api/ 路径的 HTTPS 来源 / HTTPS origin serving /api/ paths. */
|
|
34
38
|
origin: string;
|
|
35
|
-
/**
|
|
36
|
-
|
|
39
|
+
/** BoxJS JSON 的 HTTPS 下载地址;类内部使用 util fetch 加载 / HTTPS BoxJS source fetched by the class through util. */
|
|
40
|
+
configURL: string;
|
|
37
41
|
/** 页面专用请求头,值为 1 / Dedicated header, value 1. */
|
|
38
42
|
requestHeader?: string;
|
|
39
43
|
/** 每次 GET 解析有效设置,默认读取持久化值;优先级由调用方决定。
|
|
@@ -45,8 +49,24 @@ export interface ModuleDefinition {
|
|
|
45
49
|
storageKey: string;
|
|
46
50
|
fields: SettingsField[];
|
|
47
51
|
settingsPath: string[];
|
|
52
|
+
metadata?: {
|
|
53
|
+
id?: string;
|
|
54
|
+
name?: string;
|
|
55
|
+
author?: string;
|
|
56
|
+
repo?: string;
|
|
57
|
+
/** 仅保留来源信息,不执行脚本 / Source metadata only; never executed. */
|
|
58
|
+
script?: string;
|
|
59
|
+
icon?: string;
|
|
60
|
+
icons?: string[];
|
|
61
|
+
descs?: string[];
|
|
62
|
+
description?: string;
|
|
63
|
+
desc?: string;
|
|
64
|
+
};
|
|
48
65
|
}
|
|
49
66
|
export function normalizeBoxJs(config: unknown, module: string): ModuleDefinition;
|
|
50
|
-
export
|
|
67
|
+
export class SettingsHandler {
|
|
68
|
+
constructor(options: SettingsHandlerOptions);
|
|
69
|
+
handle(request: SettingsRequest): Promise<SettingsResponse | undefined>;
|
|
70
|
+
}
|
|
51
71
|
/** 解析 /api/ 后的 database 路径;非法路径抛错 / Parse database path after /api/; throws on unsafe paths. */
|
|
52
72
|
export function parseSettingsPath(url: string): string[] | undefined;
|
|
@@ -5,11 +5,23 @@ import { parseSettingsPath } from "./settings-path.mjs";
|
|
|
5
5
|
* Normalize a BoxJS array, app or subscription using the source JSON as the field authority.
|
|
6
6
|
* @param {unknown} config BoxJS JSON / BoxJS document.
|
|
7
7
|
* @param {string} module API 第一段模块名 / First API path segment.
|
|
8
|
-
* @returns {import("../
|
|
8
|
+
* @returns {import("../index.js").ModuleDefinition} 存储根和字段 / Storage root and fields.
|
|
9
9
|
*/
|
|
10
10
|
export function normalizeBoxJs(config, module) {
|
|
11
11
|
parseSettingsPath(`https://example.invalid/api/${module}`);
|
|
12
|
-
const
|
|
12
|
+
const apps = Array.isArray(config) ? [] : (config?.apps ?? [config]);
|
|
13
|
+
if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
|
|
14
|
+
for (const candidate of apps) {
|
|
15
|
+
if (!candidate || typeof candidate !== "object") throw new TypeError("Expected BoxJS app object");
|
|
16
|
+
if (candidate.settings !== undefined && !Array.isArray(candidate.settings)) throw new TypeError("Expected BoxJS settings array");
|
|
17
|
+
}
|
|
18
|
+
const owners = apps.filter((candidate) =>
|
|
19
|
+
candidate.settings?.some(
|
|
20
|
+
(entry) => typeof entry.id === "string" && entry.id.startsWith("@") && entry.id.slice(1).split(".")[1] === module,
|
|
21
|
+
),
|
|
22
|
+
);
|
|
23
|
+
const entries = Array.isArray(config) ? config : owners.flatMap((candidate) => candidate.settings);
|
|
24
|
+
const app = owners.length === 1 ? owners[0] : undefined;
|
|
13
25
|
if (!Array.isArray(entries)) throw new TypeError("Expected BoxJS settings array, app or subscription");
|
|
14
26
|
let storageKey;
|
|
15
27
|
const fields = [];
|
|
@@ -30,6 +42,10 @@ export function normalizeBoxJs(config, module) {
|
|
|
30
42
|
name: entry.name,
|
|
31
43
|
type: type === "select" ? typeof entry.val : type,
|
|
32
44
|
description: entry.desc ?? "",
|
|
45
|
+
control: entry.type,
|
|
46
|
+
...(entry.placeholder === undefined ? {} : { placeholder: entry.placeholder }),
|
|
47
|
+
...(entry.rows === undefined ? {} : { rows: entry.rows }),
|
|
48
|
+
...(entry.autoGrow === undefined ? {} : { autoGrow: entry.autoGrow }),
|
|
33
49
|
};
|
|
34
50
|
if (type === "select" && !["string", "number", "boolean"].includes(field.type))
|
|
35
51
|
throw new TypeError(`Select requires a scalar val: ${entry.id}`);
|
|
@@ -37,6 +53,9 @@ export function normalizeBoxJs(config, module) {
|
|
|
37
53
|
if (Object.hasOwn(entry, "val")) field.defaultValue = normalizeStoredValue(field, entry.val);
|
|
38
54
|
if (
|
|
39
55
|
typeof field.name !== "string" ||
|
|
56
|
+
(field.placeholder !== undefined && typeof field.placeholder !== "string") ||
|
|
57
|
+
(field.rows !== undefined && (!Number.isInteger(field.rows) || field.rows < 1)) ||
|
|
58
|
+
(field.autoGrow !== undefined && typeof field.autoGrow !== "boolean") ||
|
|
40
59
|
fields.some((other) => other.key === field.key || other.key.startsWith(`${field.key}.`) || field.key.startsWith(`${other.key}.`))
|
|
41
60
|
)
|
|
42
61
|
throw new TypeError(`Invalid or overlapping BoxJS field: ${entry.id}`);
|
|
@@ -53,13 +72,32 @@ export function normalizeBoxJs(config, module) {
|
|
|
53
72
|
if (!fields.length) throw new TypeError(`No BoxJS settings for module: ${module}`);
|
|
54
73
|
const common = fields[0].key.split(".").slice(0, -1);
|
|
55
74
|
for (const field of fields) while (!field.key.startsWith(`${common.join(".")}.`)) common.pop();
|
|
56
|
-
|
|
75
|
+
const metadata = {};
|
|
76
|
+
if (app) {
|
|
77
|
+
for (const key of ["id", "name", "author", "repo", "script", "icon", "description", "desc"]) {
|
|
78
|
+
if (app[key] === undefined) continue;
|
|
79
|
+
if (typeof app[key] !== "string") throw new TypeError(`Invalid BoxJS app ${key}`);
|
|
80
|
+
metadata[key] = app[key];
|
|
81
|
+
}
|
|
82
|
+
for (const key of ["icons", "descs"]) {
|
|
83
|
+
if (app[key] === undefined) continue;
|
|
84
|
+
if (!Array.isArray(app[key]) || app[key].some((item) => typeof item !== "string")) throw new TypeError(`Invalid BoxJS app ${key}`);
|
|
85
|
+
metadata[key] = [...app[key]];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
module,
|
|
90
|
+
storageKey,
|
|
91
|
+
fields,
|
|
92
|
+
settingsPath: common,
|
|
93
|
+
...(Object.keys(metadata).length ? { metadata } : {}),
|
|
94
|
+
};
|
|
57
95
|
}
|
|
58
96
|
|
|
59
97
|
/**
|
|
60
98
|
* 归一化 BoxJS 的字符串存储值,不改变普通文本内容。
|
|
61
99
|
* Normalize BoxJS string persistence without changing free-text values.
|
|
62
|
-
* @param {import("../
|
|
100
|
+
* @param {import("../index.js").SettingsField} field 字段 / Field.
|
|
63
101
|
* @param {unknown} value 存储值 / Stored value.
|
|
64
102
|
* @returns {unknown} 控件值 / Control value.
|
|
65
103
|
*/
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
(async () => {
|
|
7
|
+
let response;
|
|
8
|
+
try {
|
|
9
|
+
const { origin, configURL } = qs.parse(globalThis.$argument);
|
|
10
|
+
const handler = new SettingsHandler({ origin, configURL });
|
|
11
|
+
response = await handler.handle(globalThis.$request);
|
|
12
|
+
} catch (error) {
|
|
13
|
+
console.error(`PreferencePanes: ${error.message}`);
|
|
14
|
+
response = {
|
|
15
|
+
status: 500,
|
|
16
|
+
headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" },
|
|
17
|
+
body: globalThis.$request.method === "HEAD" ? "" : JSON.stringify({ error: "Settings execution failed" }),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
done(response ? ($app === "Quantumult X" ? response : { response }) : {});
|
|
21
|
+
})();
|
package/proxy/request.mjs
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { URL } from "@nsnanocat/url";
|
|
2
|
-
import { $app } from "@nsnanocat/util/lib/app.mjs";
|
|
3
|
-
import { done } from "@nsnanocat/util/lib/done.mjs";
|
|
4
|
-
import { fetch } from "@nsnanocat/util/polyfill/fetch";
|
|
5
|
-
import { qs } from "@nsnanocat/util/polyfill/qs.mjs";
|
|
6
|
-
import { createSettingsHandler } from "../lib/settings-handler.mjs";
|
|
7
|
-
|
|
8
|
-
// JavaScriptCore does not provide the browser URL global.
|
|
9
|
-
// JavaScriptCore 不提供浏览器的 URL 全局对象。
|
|
10
|
-
globalThis.URL ??= URL;
|
|
11
|
-
|
|
12
|
-
(async () => {
|
|
13
|
-
let response;
|
|
14
|
-
try {
|
|
15
|
-
const { origin, configURL } = qs.parse(globalThis.$argument);
|
|
16
|
-
const source = new globalThis.URL(configURL);
|
|
17
|
-
if (source.protocol !== "https:") throw new TypeError("configURL must use HTTPS");
|
|
18
|
-
const handle = createSettingsHandler({
|
|
19
|
-
origin,
|
|
20
|
-
loadConfig: async () => {
|
|
21
|
-
const response = await fetch({ url: source.href, method: "GET", headers: { "Cache-Control": "no-cache" }, timeout: 5000 });
|
|
22
|
-
if (response.status !== 200) throw new Error(`BoxJS source HTTP ${response.status}`);
|
|
23
|
-
return JSON.parse(response.body);
|
|
24
|
-
},
|
|
25
|
-
});
|
|
26
|
-
response = await handle(globalThis.$request);
|
|
27
|
-
} catch (error) {
|
|
28
|
-
console.error(`PreferencePanes: ${error.message}`);
|
|
29
|
-
response = {
|
|
30
|
-
status: 500,
|
|
31
|
-
headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" },
|
|
32
|
-
body: globalThis.$request.method === "HEAD" ? "" : JSON.stringify({ error: "Settings execution failed" }),
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
done(response ? ($app === "Quantumult X" ? response : { response }) : {});
|
|
36
|
-
})();
|
|
File without changes
|