@nsnanocat/preference-panes 0.9.15 → 1.0.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 +21 -15
- package/dist/api.js +1454 -967
- package/dist/module/app.mjs +296 -412
- package/dist/module/index.html +1 -1
- package/dist/module/navigation.mjs +17 -28
- package/dist/preference-panes.mjs +259 -374
- package/dist/web.js +1202 -0
- package/package.json +1 -1
- package/src/api.mjs +191 -0
- package/src/browser/ModuleStatus.mjs +17 -28
- package/src/browser/Navigation.d.mts +12 -32
- package/src/browser/app.mjs +5 -7
- package/src/{lib → browser}/boxjs.mjs +72 -16
- package/src/browser/client.d.mts +44 -129
- package/src/browser/client.mjs +61 -179
- package/src/browser/index.d.ts +7 -11
- package/src/browser/index.mjs +15 -7
- package/src/browser/panel.mjs +31 -22
- package/src/build.mjs +2 -3
- package/src/index.d.ts +16 -2
- package/src/web.mjs +52 -0
- package/src/BoxJS.mjs +0 -72
- package/src/Store.mjs +0 -114
- package/src/lib/response.mjs +0 -16
- package/src/proxy/handler.mjs +0 -39
- package/src/proxy/response.mjs +0 -16
package/src/browser/panel.mjs
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
import { ActionMenu } from "./ActionMenu.mjs";
|
|
2
|
+
import { validValue } from "./boxjs.mjs";
|
|
2
3
|
import { createPreferencesClient } from "./client.mjs";
|
|
3
4
|
import { fieldControl, element as node, requestConfirmation, resourceURL, settingRow, statusView } from "./components.mjs";
|
|
4
5
|
import { Navigation } from "./Navigation.mjs";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
*
|
|
8
|
-
* Mount the
|
|
8
|
+
* 挂载 API 返回的模块模型表单和短暂通知。
|
|
9
|
+
* Mount the module model returned by the API and transient notifications.
|
|
9
10
|
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
10
|
-
* @param {import("../
|
|
11
|
+
* @param {import("../index.js").ModuleModel} model API 返回的模块模型 / Module model returned by the API.
|
|
11
12
|
* @returns {import("./index.js").MountedPreferences} 面板生命周期句柄 / Panel lifecycle handle.
|
|
12
13
|
*/
|
|
13
|
-
export function mountPanel(root,
|
|
14
|
-
const
|
|
14
|
+
export function mountPanel(root, model) {
|
|
15
|
+
const { definition } = model;
|
|
16
|
+
const title = definition.metadata?.name ?? definition.module;
|
|
15
17
|
const document = root.ownerDocument;
|
|
16
18
|
const window = document.defaultView;
|
|
17
19
|
const shell = node("div", "pp-panel");
|
|
18
|
-
shell.dataset.module =
|
|
20
|
+
shell.dataset.module = definition.module;
|
|
19
21
|
const header = node("header", "pp-header");
|
|
20
22
|
const back = node("button", "pp-back", "‹");
|
|
21
23
|
back.setAttribute("aria-label", "返回");
|
|
@@ -45,7 +47,7 @@ export function mountPanel(root, catalog) {
|
|
|
45
47
|
if (!frame?.dataset.preferencePanes) return;
|
|
46
48
|
frame.dispatchEvent(
|
|
47
49
|
new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
|
|
48
|
-
detail: { title: heading.textContent, module:
|
|
50
|
+
detail: { title: heading.textContent, module: definition.module, busy: saving, canGoBack: !back.disabled, actions },
|
|
49
51
|
}),
|
|
50
52
|
);
|
|
51
53
|
};
|
|
@@ -102,7 +104,7 @@ export function mountPanel(root, catalog) {
|
|
|
102
104
|
toast.hidden = true;
|
|
103
105
|
}, 2400);
|
|
104
106
|
};
|
|
105
|
-
const client = createPreferencesClient({
|
|
107
|
+
const client = createPreferencesClient({ model, definition, notify });
|
|
106
108
|
/**
|
|
107
109
|
* 两种菜单入口共用异步错误处理,包含宿主确认框错误。
|
|
108
110
|
* Share async error handling between both menus, including host-dialog errors.
|
|
@@ -130,7 +132,6 @@ export function mountPanel(root, catalog) {
|
|
|
130
132
|
publishNavigation();
|
|
131
133
|
viewport.replaceChildren(statusView("读取设置…"));
|
|
132
134
|
try {
|
|
133
|
-
await client.open(module);
|
|
134
135
|
if (version === generation) controls();
|
|
135
136
|
} catch (error) {
|
|
136
137
|
if (version !== generation) return;
|
|
@@ -144,7 +145,7 @@ export function mountPanel(root, catalog) {
|
|
|
144
145
|
* @returns {void} 无返回值 / No return value.
|
|
145
146
|
*/
|
|
146
147
|
function controls() {
|
|
147
|
-
const { definition, values } = client.snapshot(
|
|
148
|
+
const { definition, values } = client.snapshot();
|
|
148
149
|
heading.textContent = definition.metadata?.name || active;
|
|
149
150
|
const view = node("section", "pp-fields");
|
|
150
151
|
/**
|
|
@@ -182,7 +183,7 @@ export function mountPanel(root, catalog) {
|
|
|
182
183
|
saving = true;
|
|
183
184
|
back.disabled = true;
|
|
184
185
|
publishNavigation();
|
|
185
|
-
|
|
186
|
+
queue = queue
|
|
186
187
|
.then(action)
|
|
187
188
|
.then(() => {
|
|
188
189
|
if (!destroyed) success();
|
|
@@ -195,10 +196,11 @@ export function mountPanel(root, catalog) {
|
|
|
195
196
|
.finally(() => {
|
|
196
197
|
pendingWrites--;
|
|
197
198
|
saving = pendingWrites > 0;
|
|
198
|
-
if (destroyed && !saving) client.leave(
|
|
199
|
+
if (destroyed && !saving) client.leave();
|
|
199
200
|
back.disabled = saving || !navigation.canGoBack;
|
|
200
201
|
publishNavigation();
|
|
201
|
-
})
|
|
202
|
+
});
|
|
203
|
+
return queue;
|
|
202
204
|
}
|
|
203
205
|
const metadata = definition.metadata;
|
|
204
206
|
if (metadata) {
|
|
@@ -276,7 +278,7 @@ export function mountPanel(root, catalog) {
|
|
|
276
278
|
link.append(summary, node("span", "pp-chevron", "›"));
|
|
277
279
|
row.append(link);
|
|
278
280
|
const refresh = () => {
|
|
279
|
-
const value = client.snapshot(
|
|
281
|
+
const value = client.snapshot().values[field.key];
|
|
280
282
|
summary.textContent =
|
|
281
283
|
field.options
|
|
282
284
|
.filter(option => Array.isArray(value) && value.includes(option.key))
|
|
@@ -377,10 +379,17 @@ export function mountPanel(root, catalog) {
|
|
|
377
379
|
return;
|
|
378
380
|
}
|
|
379
381
|
const restore = () => {
|
|
380
|
-
if (version === inputVersion) write(client.snapshot(
|
|
382
|
+
if (version === inputVersion) write(client.snapshot().values[field.key]);
|
|
381
383
|
};
|
|
382
384
|
perform(
|
|
383
|
-
() =>
|
|
385
|
+
() => {
|
|
386
|
+
if (!validValue(field, value)) {
|
|
387
|
+
const error = new TypeError("Invalid setting value");
|
|
388
|
+
notify({ kind: "error", operation: "write", module, key: field.key, message: error.message });
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
return client.set(field.key, value);
|
|
392
|
+
},
|
|
384
393
|
() => {
|
|
385
394
|
for (const refresh of summaries) refresh();
|
|
386
395
|
},
|
|
@@ -401,7 +410,7 @@ export function mountPanel(root, catalog) {
|
|
|
401
410
|
return perform(
|
|
402
411
|
async () => {
|
|
403
412
|
try {
|
|
404
|
-
value = await client.readSettings(
|
|
413
|
+
value = await client.readSettings();
|
|
405
414
|
} catch (error) {
|
|
406
415
|
notify({ kind: "error", message: error.message });
|
|
407
416
|
throw error;
|
|
@@ -425,7 +434,7 @@ export function mountPanel(root, catalog) {
|
|
|
425
434
|
return perform(
|
|
426
435
|
async () => {
|
|
427
436
|
try {
|
|
428
|
-
value = await client.readCaches(
|
|
437
|
+
value = await client.readCaches();
|
|
429
438
|
} catch (error) {
|
|
430
439
|
notify({ kind: "error", message: error.message });
|
|
431
440
|
throw error;
|
|
@@ -441,7 +450,7 @@ export function mountPanel(root, catalog) {
|
|
|
441
450
|
if (saving) return;
|
|
442
451
|
if (!(await requestConfirmation(window, `清空 ${active} 的全部 Caches?`)) || destroyed || saving) return;
|
|
443
452
|
return perform(
|
|
444
|
-
() => client.clearCaches(
|
|
453
|
+
() => client.clearCaches(),
|
|
445
454
|
() => {
|
|
446
455
|
output.textContent = "暂无缓存";
|
|
447
456
|
},
|
|
@@ -450,7 +459,7 @@ export function mountPanel(root, catalog) {
|
|
|
450
459
|
handlers.set("reset", async () => {
|
|
451
460
|
if (saving) return;
|
|
452
461
|
if (!(await requestConfirmation(window, `重置 ${active} 的设置?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) || destroyed || saving) return;
|
|
453
|
-
return perform(() => client.reset(
|
|
462
|
+
return perform(() => client.reset(), controls);
|
|
454
463
|
});
|
|
455
464
|
navigation?.destroy();
|
|
456
465
|
navigation = new Navigation(viewport, view, key => editors.get(key)?.node);
|
|
@@ -468,7 +477,7 @@ export function mountPanel(root, catalog) {
|
|
|
468
477
|
if (navigation) navigation.back();
|
|
469
478
|
else window.history.back();
|
|
470
479
|
};
|
|
471
|
-
open(
|
|
480
|
+
open(definition.module);
|
|
472
481
|
return {
|
|
473
482
|
/**
|
|
474
483
|
* 移除监听器、定时器、会话和挂载内容。
|
|
@@ -481,7 +490,7 @@ export function mountPanel(root, catalog) {
|
|
|
481
490
|
window.frameElement?.removeEventListener("preferencepanes:action", onAction);
|
|
482
491
|
navigation?.destroy();
|
|
483
492
|
generation++;
|
|
484
|
-
if (active && !saving) client.leave(
|
|
493
|
+
if (active && !saving) client.leave();
|
|
485
494
|
clearTimeout(timer);
|
|
486
495
|
shell.remove();
|
|
487
496
|
},
|
package/src/build.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
-
import {
|
|
2
|
+
import { normalizeBoxJs } from "./browser/boxjs.mjs";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* 仅生成模块前端文件,不复制配置或生成绑定业务的读写脚本。
|
|
@@ -10,8 +10,7 @@ import { BoxJS } from "./BoxJS.mjs";
|
|
|
10
10
|
*/
|
|
11
11
|
export async function build(boxjs, css = "") {
|
|
12
12
|
if (typeof css !== "string") throw new TypeError("CSS must be a string");
|
|
13
|
-
const
|
|
14
|
-
const module = catalog.module.module;
|
|
13
|
+
const module = normalizeBoxJs(boxjs).module;
|
|
15
14
|
const [html, app] = await Promise.all([readFile(new URL("../dist/module/index.html", import.meta.url), "utf8"), readFile(new URL("../dist/module/app.mjs", import.meta.url), "utf8")]);
|
|
16
15
|
return {
|
|
17
16
|
[`settings/${module}/index.html`]: html,
|
package/src/index.d.ts
CHANGED
|
@@ -92,8 +92,8 @@ export interface SettingsRequest {
|
|
|
92
92
|
*/
|
|
93
93
|
headers?: Record<string, string | undefined>;
|
|
94
94
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
95
|
+
* 模块动作的 JSON 正文
|
|
96
|
+
* JSON body for a module action.
|
|
97
97
|
*/
|
|
98
98
|
body?: string;
|
|
99
99
|
}
|
|
@@ -268,6 +268,20 @@ export interface BoxJSSubscription extends BoxJSMetadata {
|
|
|
268
268
|
* The sole data configuration input.
|
|
269
269
|
*/
|
|
270
270
|
export type BoxJSInput = BoxJSSetting[] | BoxJSApp | BoxJSSubscription;
|
|
271
|
+
/**
|
|
272
|
+
* 模块 API 返回的原始 BoxJS 与当前值模型。
|
|
273
|
+
* Raw BoxJS and current-value model returned by the module API.
|
|
274
|
+
*/
|
|
275
|
+
export interface ModuleModel {
|
|
276
|
+
/** 模块路径段 / Module path segment. */
|
|
277
|
+
module: string;
|
|
278
|
+
/** API 获取的原始 BoxJS JSON / Raw BoxJS JSON fetched by the API. */
|
|
279
|
+
boxjs: BoxJSInput;
|
|
280
|
+
/** API 读取到的原始已保存字段值 / Raw persisted field values read by the API. */
|
|
281
|
+
values: Record<string, JsonValue>;
|
|
282
|
+
/** 后续 API 动作使用的 BoxJS JSON 地址 / BoxJS JSON URL used by later API actions. */
|
|
283
|
+
configURL: string;
|
|
284
|
+
}
|
|
271
285
|
/**
|
|
272
286
|
* 生成模块前端文件,不复制配置、不生成绑定模块的代理脚本。
|
|
273
287
|
* Build module frontend files without copying configuration or producing bound proxy scripts.
|
package/src/web.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
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 assets from "#assets";
|
|
5
|
+
import { pageInputs } from "./lib/page-inputs.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 返回模块页面及其公共浏览器资源,不处理 API、网络或持久化。
|
|
9
|
+
* Serve module pages and common browser assets without handling APIs, network access or persistence.
|
|
10
|
+
* @returns {void} 响应已交给代理宿主 / Response delivered to the proxy host.
|
|
11
|
+
*/
|
|
12
|
+
function run() {
|
|
13
|
+
const request = globalThis.$request;
|
|
14
|
+
let result;
|
|
15
|
+
try {
|
|
16
|
+
const url = new URL(request.url);
|
|
17
|
+
if (/^\/settings\/[a-zA-Z0-9_-]+\/?$/.test(url.pathname)) {
|
|
18
|
+
if (!["GET", "HEAD"].includes(request.method)) result = response(request, 405, { error: "Method not allowed" });
|
|
19
|
+
else {
|
|
20
|
+
const inputs = encodeURIComponent(JSON.stringify(pageInputs(url, request.headers)));
|
|
21
|
+
result = response(request, 200, assets.page.body.replace("</head>", `<meta name="preference-panes-inputs" content="${inputs}"></head>`), "text/html");
|
|
22
|
+
}
|
|
23
|
+
} else {
|
|
24
|
+
const asset = assets[url.pathname];
|
|
25
|
+
if (asset) result = ["GET", "HEAD"].includes(request.method) ? response(request, 200, asset.body, asset.type) : response(request, 405, { error: "Method not allowed" });
|
|
26
|
+
}
|
|
27
|
+
} catch (error) {
|
|
28
|
+
console.error(`PreferencePanes Web: ${error.message}`);
|
|
29
|
+
result = response(request, 500, { error: error.message });
|
|
30
|
+
}
|
|
31
|
+
if (!result) done({});
|
|
32
|
+
else done($app === "Quantumult X" ? result : { response: result });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 构造静态资源响应,HEAD 请求不返回正文。
|
|
37
|
+
* Build a static resource response without a body for HEAD requests.
|
|
38
|
+
* @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
|
|
39
|
+
* @param {number} status HTTP 状态 / HTTP status.
|
|
40
|
+
* @param {unknown} body 响应正文 / Response body.
|
|
41
|
+
* @param {string} [type] 媒体类型 / Media type.
|
|
42
|
+
* @returns {import("./index.js").SettingsResponse} 静态资源响应 / Static resource response.
|
|
43
|
+
*/
|
|
44
|
+
function response(request, status, body, type = "application/json") {
|
|
45
|
+
return {
|
|
46
|
+
status,
|
|
47
|
+
headers: { "Content-Type": `${type}; charset=utf-8`, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" },
|
|
48
|
+
body: request.method === "HEAD" ? "" : type === "application/json" ? JSON.stringify(body) : body,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
run();
|
package/src/BoxJS.mjs
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
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
|
-
const document = JSON.parse(JSON.stringify(input));
|
|
16
|
-
const apps = Array.isArray(document) ? [{ settings: document }] : (document.apps ?? [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(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(document) ? {} : document);
|
|
42
|
-
for (const target of this.modules.values()) target.metadata = target.owners.size === 1 ? metadata([...target.owners][0]) : {};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* 取得本次导入的唯一模块,避免把模块数据变成项目目录。
|
|
47
|
-
* Get the single imported module without turning module data into a project directory.
|
|
48
|
-
* @returns {object} 唯一模块的目录项 / The single module entry.
|
|
49
|
-
*/
|
|
50
|
-
get module() {
|
|
51
|
-
if (this.modules.size !== 1) throw new TypeError("Import BoxJS JSON for exactly one module");
|
|
52
|
-
return this.modules.values().next().value;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* 保留标准 BoxJS 展示信息;script 仅为元数据,不执行。
|
|
58
|
-
* Retain standard BoxJS presentation data; script is metadata only and never executed.
|
|
59
|
-
* @param {object} source BoxJS app 或订阅 / BoxJS app or subscription.
|
|
60
|
-
* @returns {object} 经过类型检查的展示信息 / Type-checked presentation metadata.
|
|
61
|
-
*/
|
|
62
|
-
function metadata(source) {
|
|
63
|
-
const result = {};
|
|
64
|
-
for (const key of ["id", "name", "author", "repo", "script", "icon", "description", "desc", "icons", "descs"]) {
|
|
65
|
-
if (source[key] === undefined) continue;
|
|
66
|
-
const multiple = key === "icons" || key === "descs";
|
|
67
|
-
const values = multiple ? source[key] : [source[key]];
|
|
68
|
-
if (!Array.isArray(values) || values.some(item => typeof item !== "string")) throw new TypeError(`Invalid BoxJS app ${key}`);
|
|
69
|
-
result[key] = multiple ? [...values] : source[key];
|
|
70
|
-
}
|
|
71
|
-
return result;
|
|
72
|
-
}
|
package/src/Store.mjs
DELETED
|
@@ -1,114 +0,0 @@
|
|
|
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 { validatePathParts } from "./lib/settings-path.mjs";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* 无配置绑定的本地存储桥接;form 字段名就是完整 @root.path。
|
|
9
|
-
* Unbound local storage bridge; the form field name is the complete @root.path.
|
|
10
|
-
*/
|
|
11
|
-
export class Store {
|
|
12
|
-
/**
|
|
13
|
-
* POST /api/get、set、delete;不下载配置、不解析控件、不鉴权。
|
|
14
|
-
* POST /api/get, set or delete without config downloads, control parsing or authentication.
|
|
15
|
-
* @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
|
|
16
|
-
* @param {URL} [url] 已解析地址 / Parsed URL.
|
|
17
|
-
* @returns {Promise<import("./index.js").SettingsResponse | undefined>} 操作结果 / Operation result.
|
|
18
|
-
*/
|
|
19
|
-
async handle(request, url = new URL(request.url)) {
|
|
20
|
-
if (!url.pathname.startsWith("/api/")) return;
|
|
21
|
-
const reply = (status, data) => response(request, status, data);
|
|
22
|
-
const action = url.pathname.slice(5);
|
|
23
|
-
if (!["get", "set", "delete"].includes(action)) return reply(404, { error: "Unknown action" });
|
|
24
|
-
if (request.method !== "POST") return reply(405, { error: "Use POST with a form body" });
|
|
25
|
-
const headers = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
|
|
26
|
-
if (headers["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/x-www-form-urlencoded") return reply(415, { error: "Expected application/x-www-form-urlencoded" });
|
|
27
|
-
if (typeof request.body !== "string" || request.body.length > 65536) return reply(400, { error: "Expected a form body up to 65536 characters" });
|
|
28
|
-
let parts, value;
|
|
29
|
-
try {
|
|
30
|
-
const fields = request.body.split("&");
|
|
31
|
-
if (fields.length !== 1) throw new TypeError("Send exactly one storage key");
|
|
32
|
-
const separator = fields[0].indexOf("=");
|
|
33
|
-
if (separator < 0) throw new TypeError("Expected @root.path=value");
|
|
34
|
-
const key = decodeURIComponent(fields[0].slice(0, separator).replace(/\+/g, " "));
|
|
35
|
-
value = decodeURIComponent(fields[0].slice(separator + 1).replace(/\+/g, " "));
|
|
36
|
-
if (!key.startsWith("@")) throw new TypeError("Storage keys must start with @");
|
|
37
|
-
parts = validatePathParts(key.slice(1).split("."));
|
|
38
|
-
if (parts.length < 2) throw new TypeError("Specify a storage root and child path");
|
|
39
|
-
} catch (error) {
|
|
40
|
-
return reply(400, { error: error.message });
|
|
41
|
-
}
|
|
42
|
-
if (action === "set") {
|
|
43
|
-
try {
|
|
44
|
-
value = JSON.parse(value);
|
|
45
|
-
} catch (error) {
|
|
46
|
-
if (!(error instanceof SyntaxError)) throw error;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
const [storageKey, ...path] = parts;
|
|
50
|
-
try {
|
|
51
|
-
const root = Storage.getItem(storageKey, {});
|
|
52
|
-
if (!isRecord(root)) throw new TypeError("Stored root must be an object");
|
|
53
|
-
const parent = storageParent(root, path, action === "set");
|
|
54
|
-
const key = path.at(-1);
|
|
55
|
-
switch (action) {
|
|
56
|
-
case "get": {
|
|
57
|
-
const result = parent ? _.get(parent, [key]) : undefined;
|
|
58
|
-
return result === undefined ? reply(404, { error: "Stored path does not exist" }) : reply(200, result);
|
|
59
|
-
}
|
|
60
|
-
case "set":
|
|
61
|
-
_.set(parent, [key], value);
|
|
62
|
-
break;
|
|
63
|
-
case "delete":
|
|
64
|
-
if (parent) _.unset(parent, [key]);
|
|
65
|
-
break;
|
|
66
|
-
}
|
|
67
|
-
if (!Storage.setItem(storageKey, root)) throw new Error("Storage write failed");
|
|
68
|
-
return reply(200, action === "set" ? { saved: true } : { deleted: true });
|
|
69
|
-
} catch (error) {
|
|
70
|
-
return reply(500, { error: error.message });
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* 判断根节点是否为普通对象。
|
|
77
|
-
* Determine whether a root node is a plain object.
|
|
78
|
-
* @param {unknown} value 待检查值 / Value to inspect.
|
|
79
|
-
* @returns {boolean} 是否为普通对象 / Whether this is a plain object.
|
|
80
|
-
*/
|
|
81
|
-
function isRecord(value) {
|
|
82
|
-
return value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* 遍历父路径,兼容旧存储中 JSON 字符串形式的中间节点。
|
|
87
|
-
* Traverse parents, supporting legacy intermediate nodes serialized as JSON strings.
|
|
88
|
-
* @param {Record<string, unknown>} root 存储根 / Storage root.
|
|
89
|
-
* @param {string[]} parts 完整路径 / Complete path.
|
|
90
|
-
* @param {boolean} create 是否创建缺失节点 / Whether to create missing parents.
|
|
91
|
-
* @returns {object | undefined} 父节点,缺失且不创建时为 undefined / Parent, or undefined when absent and not creating.
|
|
92
|
-
* @throws {TypeError} 无法继续遍历标量节点 / A scalar node cannot be traversed.
|
|
93
|
-
*/
|
|
94
|
-
function storageParent(root, parts, create) {
|
|
95
|
-
let parent = root;
|
|
96
|
-
for (const part of parts.slice(0, -1)) {
|
|
97
|
-
let next = _.get(parent, [part]);
|
|
98
|
-
switch (typeof next) {
|
|
99
|
-
case "undefined":
|
|
100
|
-
if (!create) return;
|
|
101
|
-
next = {};
|
|
102
|
-
break;
|
|
103
|
-
case "string":
|
|
104
|
-
next = JSON.parse(next);
|
|
105
|
-
break;
|
|
106
|
-
default:
|
|
107
|
-
break;
|
|
108
|
-
}
|
|
109
|
-
if (!isRecord(next) && !Array.isArray(next)) throw new TypeError("Stored parent is not an object or array");
|
|
110
|
-
_.set(parent, [part], next);
|
|
111
|
-
parent = next;
|
|
112
|
-
}
|
|
113
|
-
return parent;
|
|
114
|
-
}
|
package/src/lib/response.mjs
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
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
|
-
}
|
package/src/proxy/handler.mjs
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { URL } from "@nsnanocat/url";
|
|
2
|
-
import assets from "#assets";
|
|
3
|
-
import { pageInputs } from "../lib/page-inputs.mjs";
|
|
4
|
-
import { response } from "../lib/response.mjs";
|
|
5
|
-
import { Store } from "../Store.mjs";
|
|
6
|
-
import { complete } from "./response.mjs";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* 通用代理入口:提供无鉴权 form 存储 API 及模块页面,不含业务配置。
|
|
10
|
-
* Generic proxy entry serving unauthenticated form storage and module pages without business configuration.
|
|
11
|
-
* @returns {Promise<void>} 已交给代理宿主的响应 / Response delivered to the proxy host.
|
|
12
|
-
*/
|
|
13
|
-
async function run() {
|
|
14
|
-
const request = globalThis.$request;
|
|
15
|
-
let result;
|
|
16
|
-
try {
|
|
17
|
-
const url = new URL(request.url);
|
|
18
|
-
switch (true) {
|
|
19
|
-
case url.pathname.startsWith("/api/"):
|
|
20
|
-
result = await new Store().handle(request, url);
|
|
21
|
-
break;
|
|
22
|
-
case /^\/settings\/[a-zA-Z0-9_-]+\/?$/.test(url.pathname): {
|
|
23
|
-
const inputs = encodeURIComponent(JSON.stringify(pageInputs(url, request.headers)));
|
|
24
|
-
result = response(request, 200, assets.page.body.replace("</head>", `<meta name="preference-panes-inputs" content="${inputs}"></head>`), "text/html");
|
|
25
|
-
break;
|
|
26
|
-
}
|
|
27
|
-
default: {
|
|
28
|
-
const asset = assets[url.pathname];
|
|
29
|
-
if (asset) result = response(request, 200, asset.body, asset.type);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
if (result && !url.pathname.startsWith("/api/") && !["GET", "HEAD"].includes(request.method)) result = response(request, 405, { error: "Method not allowed" });
|
|
33
|
-
} catch (error) {
|
|
34
|
-
console.error(`PreferencePanes: ${error.message}`);
|
|
35
|
-
result = response(request, 500, { error: error.message });
|
|
36
|
-
}
|
|
37
|
-
complete(result);
|
|
38
|
-
}
|
|
39
|
-
run();
|
package/src/proxy/response.mjs
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
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
|
-
}
|