@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/browser/panel.mjs
DELETED
|
@@ -1,233 +0,0 @@
|
|
|
1
|
-
import { createPreferencesClient } from "./client.mjs";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* 挂载从 BoxJS 实时生成的设置面板和短暂通知。
|
|
5
|
-
* Mount runtime-generated BoxJS controls and transient notifications.
|
|
6
|
-
* @param {import("../types/browser.js").PreferencesPanelOptions} options 容器与请求;页面路径 /settings/{module} 对应配置 / Container and requests; /settings/{module} selects config.
|
|
7
|
-
* @returns {{destroy(): void}} 清理接口 / Cleanup handle.
|
|
8
|
-
*/
|
|
9
|
-
export function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
|
|
10
|
-
const document = root.ownerDocument;
|
|
11
|
-
const window = document.defaultView;
|
|
12
|
-
const node = (tag, className, text) => {
|
|
13
|
-
const el = document.createElement(tag);
|
|
14
|
-
el.className = className;
|
|
15
|
-
if (text !== undefined) el.textContent = text;
|
|
16
|
-
return el;
|
|
17
|
-
};
|
|
18
|
-
const shell = node("div", "pp-panel");
|
|
19
|
-
const header = node("header", "pp-header");
|
|
20
|
-
const back = node("button", "pp-back", "返回");
|
|
21
|
-
back.type = "button";
|
|
22
|
-
const heading = node("h1", "pp-title", title);
|
|
23
|
-
const viewport = node("div", "pp-viewport");
|
|
24
|
-
const toast = node("div", "pp-toast");
|
|
25
|
-
toast.setAttribute("role", "status");
|
|
26
|
-
toast.hidden = true;
|
|
27
|
-
header.append(back, heading);
|
|
28
|
-
shell.append(header, viewport, toast);
|
|
29
|
-
root.append(shell);
|
|
30
|
-
let timer,
|
|
31
|
-
routedPath,
|
|
32
|
-
generation = 0,
|
|
33
|
-
active = null,
|
|
34
|
-
saving = false,
|
|
35
|
-
pendingRoute = false,
|
|
36
|
-
destroyed = false;
|
|
37
|
-
const notify = (event) => {
|
|
38
|
-
if (destroyed) return;
|
|
39
|
-
toast.textContent = event.kind === "error" ? `操作失败:${event.message}` : event.operation === "delete" ? "删除成功" : "修改成功";
|
|
40
|
-
toast.dataset.kind = event.kind;
|
|
41
|
-
toast.hidden = false;
|
|
42
|
-
clearTimeout(timer);
|
|
43
|
-
timer = setTimeout(() => {
|
|
44
|
-
toast.hidden = true;
|
|
45
|
-
}, 2400);
|
|
46
|
-
};
|
|
47
|
-
const client = createPreferencesClient({ ...(fetch ? { fetch } : {}), notify });
|
|
48
|
-
function replace(view, direction) {
|
|
49
|
-
const old = viewport.firstElementChild;
|
|
50
|
-
viewport.replaceChildren(view);
|
|
51
|
-
if (old && !document.defaultView.matchMedia("(prefers-reduced-motion: reduce)").matches)
|
|
52
|
-
view.animate(
|
|
53
|
-
[
|
|
54
|
-
{ opacity: 0.4, transform: `translateX(${direction * 24}px)` },
|
|
55
|
-
{ opacity: 1, transform: "translateX(0)" },
|
|
56
|
-
],
|
|
57
|
-
{ duration: 180, easing: "ease-out" },
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
async function open(module) {
|
|
61
|
-
const version = ++generation;
|
|
62
|
-
active = module;
|
|
63
|
-
back.disabled = window.history.length <= 1;
|
|
64
|
-
heading.textContent = module;
|
|
65
|
-
replace(node("p", "pp-loading", "读取设置…"), 1);
|
|
66
|
-
try {
|
|
67
|
-
await client.open(module);
|
|
68
|
-
if (version === generation) controls();
|
|
69
|
-
} catch (error) {
|
|
70
|
-
if (version !== generation) return;
|
|
71
|
-
const view = node("section", "pp-error");
|
|
72
|
-
view.append(node("p", "", `加载失败:${error.message}`));
|
|
73
|
-
const retry = node("button", "", "重新读取");
|
|
74
|
-
retry.onclick = () => open(module);
|
|
75
|
-
view.append(retry);
|
|
76
|
-
replace(view, 1);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
function controls() {
|
|
80
|
-
const { definition, values } = client.snapshot(active);
|
|
81
|
-
const view = node("section", "pp-fields");
|
|
82
|
-
for (const field of definition.fields) {
|
|
83
|
-
const row = node("fieldset", "pp-field");
|
|
84
|
-
row.append(node("legend", "", field.name));
|
|
85
|
-
if (field.description) row.append(node("p", "pp-description", field.description));
|
|
86
|
-
const value = values[field.key];
|
|
87
|
-
let read, write;
|
|
88
|
-
if (field.options && field.type !== "array") {
|
|
89
|
-
const select = node("select", "pp-input");
|
|
90
|
-
select.setAttribute("aria-label", field.name);
|
|
91
|
-
field.options.forEach((option, index) => {
|
|
92
|
-
const item = node("option", "", option.label);
|
|
93
|
-
item.value = String(index);
|
|
94
|
-
select.append(item);
|
|
95
|
-
});
|
|
96
|
-
write = (value) => {
|
|
97
|
-
select.selectedIndex = field.options.findIndex((option) => option.key === value);
|
|
98
|
-
};
|
|
99
|
-
row.append(select);
|
|
100
|
-
read = () => field.options[select.selectedIndex]?.key;
|
|
101
|
-
} else if (field.type === "array" && field.options) {
|
|
102
|
-
const inputs = field.options.map((option) => {
|
|
103
|
-
const label = node("label", "pp-choice", option.label);
|
|
104
|
-
const input = node("input", "");
|
|
105
|
-
input.type = "checkbox";
|
|
106
|
-
input.checked = Array.isArray(value) && value.includes(option.key);
|
|
107
|
-
label.prepend(input);
|
|
108
|
-
row.append(label);
|
|
109
|
-
return { input, key: option.key };
|
|
110
|
-
});
|
|
111
|
-
read = () => inputs.filter((option) => option.input.checked).map((option) => option.key);
|
|
112
|
-
write = (value) => {
|
|
113
|
-
for (const option of inputs) option.input.checked = Array.isArray(value) && value.includes(option.key);
|
|
114
|
-
};
|
|
115
|
-
} else {
|
|
116
|
-
const input = node(field.type === "array" ? "textarea" : "input", "pp-input");
|
|
117
|
-
input.setAttribute("aria-label", field.name);
|
|
118
|
-
if (field.type === "boolean") {
|
|
119
|
-
input.type = "checkbox";
|
|
120
|
-
write = (value) => {
|
|
121
|
-
input.checked = value === true;
|
|
122
|
-
};
|
|
123
|
-
read = () => input.checked;
|
|
124
|
-
} else {
|
|
125
|
-
input.type = field.type === "number" ? "number" : "text";
|
|
126
|
-
write = (value) => {
|
|
127
|
-
input.value = field.type === "array" ? JSON.stringify(value ?? []) : (value ?? "");
|
|
128
|
-
};
|
|
129
|
-
read = () =>
|
|
130
|
-
field.type === "array"
|
|
131
|
-
? JSON.parse(input.value)
|
|
132
|
-
: field.type === "number"
|
|
133
|
-
? input.value === ""
|
|
134
|
-
? Number.NaN
|
|
135
|
-
: Number(input.value)
|
|
136
|
-
: input.value;
|
|
137
|
-
}
|
|
138
|
-
row.append(input);
|
|
139
|
-
}
|
|
140
|
-
write(value);
|
|
141
|
-
const actions = node("div", "pp-actions");
|
|
142
|
-
for (const [operation, label] of [
|
|
143
|
-
["write", "保存"],
|
|
144
|
-
["delete", "删除覆盖值"],
|
|
145
|
-
]) {
|
|
146
|
-
const button = node("button", "", label);
|
|
147
|
-
button.type = "button";
|
|
148
|
-
button.onclick = async () => {
|
|
149
|
-
if (saving) return;
|
|
150
|
-
saving = true;
|
|
151
|
-
back.disabled = true;
|
|
152
|
-
view.querySelectorAll("button,input,select,textarea").forEach((input) => {
|
|
153
|
-
input.disabled = true;
|
|
154
|
-
});
|
|
155
|
-
let success = false;
|
|
156
|
-
try {
|
|
157
|
-
if (operation === "delete") await client.remove(active, field.key);
|
|
158
|
-
else {
|
|
159
|
-
let value;
|
|
160
|
-
try {
|
|
161
|
-
value = read();
|
|
162
|
-
} catch (error) {
|
|
163
|
-
notify({ kind: "error", message: error.message });
|
|
164
|
-
throw error;
|
|
165
|
-
}
|
|
166
|
-
await client.set(active, field.key, value);
|
|
167
|
-
}
|
|
168
|
-
success = true;
|
|
169
|
-
} catch {
|
|
170
|
-
/* 客户端已显示错误通知 / Client already displayed an error notification. */
|
|
171
|
-
} finally {
|
|
172
|
-
saving = false;
|
|
173
|
-
back.disabled = window.history.length <= 1;
|
|
174
|
-
view.querySelectorAll("button,input,select,textarea").forEach((input) => {
|
|
175
|
-
input.disabled = false;
|
|
176
|
-
});
|
|
177
|
-
if (success && !destroyed) {
|
|
178
|
-
// 只更新当前控件,保留其它尚未保存的输入。
|
|
179
|
-
// Update this control without discarding other unsaved inputs.
|
|
180
|
-
write(client.snapshot(active).values[field.key]);
|
|
181
|
-
}
|
|
182
|
-
if (!destroyed && pendingRoute) route();
|
|
183
|
-
}
|
|
184
|
-
};
|
|
185
|
-
actions.append(button);
|
|
186
|
-
}
|
|
187
|
-
row.append(actions);
|
|
188
|
-
view.append(row);
|
|
189
|
-
}
|
|
190
|
-
viewport.replaceChildren(view);
|
|
191
|
-
}
|
|
192
|
-
function route() {
|
|
193
|
-
if (saving) {
|
|
194
|
-
pendingRoute = true;
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
pendingRoute = false;
|
|
198
|
-
if (active) client.leave(active);
|
|
199
|
-
routedPath = window.location.pathname;
|
|
200
|
-
const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(routedPath);
|
|
201
|
-
if (!match) {
|
|
202
|
-
generation++;
|
|
203
|
-
active = null;
|
|
204
|
-
heading.textContent = title;
|
|
205
|
-
replace(node("p", "pp-error", "页面地址应为 /settings/模块标识。"), 1);
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
open(match[1]);
|
|
209
|
-
}
|
|
210
|
-
const onPopState = () => {
|
|
211
|
-
if (window.location.pathname !== routedPath) route();
|
|
212
|
-
};
|
|
213
|
-
const onPageShow = (event) => {
|
|
214
|
-
if (event.persisted) route();
|
|
215
|
-
};
|
|
216
|
-
back.onclick = () => {
|
|
217
|
-
if (!saving) window.history.back();
|
|
218
|
-
};
|
|
219
|
-
window.addEventListener("popstate", onPopState);
|
|
220
|
-
window.addEventListener("pageshow", onPageShow);
|
|
221
|
-
route();
|
|
222
|
-
return {
|
|
223
|
-
destroy() {
|
|
224
|
-
destroyed = true;
|
|
225
|
-
window.removeEventListener("popstate", onPopState);
|
|
226
|
-
window.removeEventListener("pageshow", onPageShow);
|
|
227
|
-
generation++;
|
|
228
|
-
if (active) client.leave(active);
|
|
229
|
-
clearTimeout(timer);
|
|
230
|
-
shell.remove();
|
|
231
|
-
},
|
|
232
|
-
};
|
|
233
|
-
}
|
package/index.mjs
DELETED
package/lib/boxjs.mjs
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { parseSettingsPath } 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("../types/index.js").ModuleDefinition} 存储根和字段 / Storage root and fields.
|
|
9
|
-
*/
|
|
10
|
-
export function normalizeBoxJs(config, module) {
|
|
11
|
-
parseSettingsPath(`https://example.invalid/api/${module}`);
|
|
12
|
-
const entries = Array.isArray(config) ? config : config?.apps ? config.apps.flatMap((app) => app.settings ?? []) : config?.settings;
|
|
13
|
-
if (!Array.isArray(entries)) throw new TypeError("Expected BoxJS settings array, app or subscription");
|
|
14
|
-
let storageKey;
|
|
15
|
-
const fields = [];
|
|
16
|
-
for (const entry of entries) {
|
|
17
|
-
if (typeof entry.id !== "string" || !entry.id.startsWith("@")) throw new TypeError("BoxJS settings require @root.path IDs");
|
|
18
|
-
const [root, ...parts] = entry.id.slice(1).split(".");
|
|
19
|
-
if (parts[0] !== module) continue;
|
|
20
|
-
if (parts.length < 2) throw new TypeError("A BoxJS setting must be below the module root");
|
|
21
|
-
parseSettingsPath(`https://example.invalid/api/${parts.map(encodeURIComponent).join("/")}`);
|
|
22
|
-
if (!root || (storageKey && root !== storageKey)) throw new TypeError("A module must use one storage root");
|
|
23
|
-
storageKey = root;
|
|
24
|
-
const type = { boolean: "boolean", checkboxes: "array", selects: "select", text: "string", textarea: "string", number: "number" }[
|
|
25
|
-
entry.type
|
|
26
|
-
];
|
|
27
|
-
if (!type) throw new TypeError(`Unsupported BoxJS control: ${entry.type}`);
|
|
28
|
-
const field = {
|
|
29
|
-
key: parts.join("."),
|
|
30
|
-
name: entry.name,
|
|
31
|
-
type: type === "select" ? typeof entry.val : type,
|
|
32
|
-
description: entry.desc ?? "",
|
|
33
|
-
};
|
|
34
|
-
if (type === "select" && !["string", "number", "boolean"].includes(field.type))
|
|
35
|
-
throw new TypeError(`Select requires a scalar val: ${entry.id}`);
|
|
36
|
-
if (entry.items) field.options = entry.items.map((item) => ({ key: item.key, label: item.label }));
|
|
37
|
-
if (Object.hasOwn(entry, "val")) field.defaultValue = normalizeStoredValue(field, entry.val);
|
|
38
|
-
if (
|
|
39
|
-
typeof field.name !== "string" ||
|
|
40
|
-
fields.some((other) => other.key === field.key || other.key.startsWith(`${field.key}.`) || field.key.startsWith(`${other.key}.`))
|
|
41
|
-
)
|
|
42
|
-
throw new TypeError(`Invalid or overlapping BoxJS field: ${entry.id}`);
|
|
43
|
-
if (
|
|
44
|
-
field.options &&
|
|
45
|
-
(new Set(field.options.map((item) => item.key)).size !== field.options.length ||
|
|
46
|
-
field.options.some((item) => !scalar(item.key) || typeof item.label !== "string"))
|
|
47
|
-
)
|
|
48
|
-
throw new TypeError(`Invalid options: ${entry.id}`);
|
|
49
|
-
if (Object.hasOwn(field, "defaultValue") && !validValue(field, field.defaultValue))
|
|
50
|
-
throw new TypeError(`Invalid BoxJS val: ${entry.id}`);
|
|
51
|
-
fields.push(field);
|
|
52
|
-
}
|
|
53
|
-
if (!fields.length) throw new TypeError(`No BoxJS settings for module: ${module}`);
|
|
54
|
-
const common = fields[0].key.split(".").slice(0, -1);
|
|
55
|
-
for (const field of fields) while (!field.key.startsWith(`${common.join(".")}.`)) common.pop();
|
|
56
|
-
return { module, storageKey, fields, settingsPath: common };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* 归一化 BoxJS 的字符串存储值,不改变普通文本内容。
|
|
61
|
-
* Normalize BoxJS string persistence without changing free-text values.
|
|
62
|
-
* @param {import("../types/index.js").SettingsField} field 字段 / Field.
|
|
63
|
-
* @param {unknown} value 存储值 / Stored value.
|
|
64
|
-
* @returns {unknown} 控件值 / Control value.
|
|
65
|
-
*/
|
|
66
|
-
export function normalizeStoredValue(field, value) {
|
|
67
|
-
if (field.type === "boolean" && (value === "true" || value === "false")) return value === "true";
|
|
68
|
-
if (field.type === "number" && typeof value === "string" && value.trim() !== "") return Number(value);
|
|
69
|
-
if (field.type === "array" && typeof value === "string") value = value === "" || value === "[]" ? [] : value.split(",");
|
|
70
|
-
if (field.options) {
|
|
71
|
-
const match = (item) => field.options.find((option) => String(option.key) === String(item))?.key ?? item;
|
|
72
|
-
return field.type === "array" && Array.isArray(value) ? value.map(match) : match(value);
|
|
73
|
-
}
|
|
74
|
-
return value;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function scalar(value) {
|
|
78
|
-
return (
|
|
79
|
-
typeof value === "boolean" ||
|
|
80
|
-
(typeof value === "string" && value.length <= 2048) ||
|
|
81
|
-
(typeof value === "number" && Number.isFinite(value))
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function validValue(field, value) {
|
|
86
|
-
if (field.type === "array") {
|
|
87
|
-
if (!Array.isArray(value) || value.some((item) => !scalar(item)) || new Set(value).size !== value.length) return false;
|
|
88
|
-
} else if (typeof value !== field.type || !scalar(value)) return false;
|
|
89
|
-
return !field.options || (field.type === "array" ? value : [value]).every((item) => field.options.some((option) => option.key === item));
|
|
90
|
-
}
|
package/lib/settings-handler.mjs
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import { Lodash as _ } from "@nsnanocat/util/polyfill/Lodash.mjs";
|
|
2
|
-
import { Storage } from "@nsnanocat/util/polyfill/Storage";
|
|
3
|
-
import { normalizeBoxJs, validValue } from "./boxjs.mjs";
|
|
4
|
-
import { parseSettingsPath } from "./settings-path.mjs";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* 创建通用读写处理器;字段通过 loadConfig 在运行时加载,不固化在脚本中。
|
|
8
|
-
* Create a generic endpoint using runtime-loaded config, never compiled-in fields.
|
|
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.
|
|
11
|
-
*/
|
|
12
|
-
export function createSettingsHandler({ origin, loadConfig, requestHeader = "X-Settings-Client", resolveSettings }) {
|
|
13
|
-
const target = new URL(origin);
|
|
14
|
-
if (target.protocol !== "https:" || target.pathname !== "/" || target.search || target.hash || target.username || target.password)
|
|
15
|
-
throw new TypeError("origin must be an HTTPS origin");
|
|
16
|
-
if (typeof loadConfig !== "function") throw new TypeError("loadConfig is required");
|
|
17
|
-
if (!/^[a-z][a-z0-9-]*$/i.test(requestHeader)) throw new TypeError("Invalid requestHeader");
|
|
18
|
-
return async function handle(request) {
|
|
19
|
-
const url = new URL(request.url);
|
|
20
|
-
if (url.origin !== target.origin || !url.pathname.startsWith("/api/")) return;
|
|
21
|
-
const headers = { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" };
|
|
22
|
-
const reply = (status, data) => ({ status, headers, body: request.method === "HEAD" ? "" : JSON.stringify(data) });
|
|
23
|
-
let parts;
|
|
24
|
-
try {
|
|
25
|
-
parts = parseSettingsPath(request.url);
|
|
26
|
-
} catch (error) {
|
|
27
|
-
return reply(400, { error: error.message });
|
|
28
|
-
}
|
|
29
|
-
const requestHeaders = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
|
|
30
|
-
if (requestHeaders[requestHeader.toLowerCase()] !== "1" || (requestHeaders.origin && requestHeaders.origin !== target.origin))
|
|
31
|
-
return reply(403, { error: "Forbidden settings client" });
|
|
32
|
-
if (!["HEAD", "GET", "POST", "DELETE"].includes(request.method))
|
|
33
|
-
return { ...reply(405, { error: "Method not allowed" }), headers: { ...headers, Allow: "HEAD, GET, POST, DELETE" } };
|
|
34
|
-
let definition;
|
|
35
|
-
try {
|
|
36
|
-
definition = normalizeBoxJs(await loadConfig(parts[0]), parts[0]);
|
|
37
|
-
} catch (error) {
|
|
38
|
-
return reply(502, { error: `Module configuration unavailable: ${error.message}` });
|
|
39
|
-
}
|
|
40
|
-
const key = parts.join(".");
|
|
41
|
-
const field = definition.fields.find((field) => field.key === key);
|
|
42
|
-
const descendants = definition.fields.filter((field) => field.key.startsWith(`${key}.`));
|
|
43
|
-
if (!field && !descendants.length) return reply(404, { error: `Unknown setting path: ${key}` });
|
|
44
|
-
if (request.method === "HEAD") return reply(200, undefined);
|
|
45
|
-
if (request.method === "GET") {
|
|
46
|
-
const stored = Storage.getItem(definition.storageKey, {});
|
|
47
|
-
const effective = resolveSettings ? resolveSettings(stored, definition) : stored;
|
|
48
|
-
if (!isRecord(effective)) throw new TypeError("resolved settings must be a synchronous object");
|
|
49
|
-
if (field) {
|
|
50
|
-
const value = pathValue(effective, parts);
|
|
51
|
-
return value === undefined ? reply(404, { error: `Setting has no stored value: ${key}` }) : reply(200, value);
|
|
52
|
-
}
|
|
53
|
-
const subtree = {};
|
|
54
|
-
// 只返回配置文件公开的字段;默认值由浏览器用 BoxJS val 生成。
|
|
55
|
-
// Expose only declared fields; the browser renders defaults from BoxJS val.
|
|
56
|
-
for (const descendant of descendants) {
|
|
57
|
-
const fullPath = descendant.key.split(".");
|
|
58
|
-
const value = pathValue(effective, fullPath);
|
|
59
|
-
if (value !== undefined) _.set(subtree, fullPath.slice(parts.length), value);
|
|
60
|
-
}
|
|
61
|
-
return reply(200, subtree);
|
|
62
|
-
}
|
|
63
|
-
if (!field) return reply(405, { error: "Only individual declared keys can be modified" });
|
|
64
|
-
let value;
|
|
65
|
-
if (request.method === "POST") {
|
|
66
|
-
if (requestHeaders["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/json")
|
|
67
|
-
return reply(415, { error: "Expected application/json" });
|
|
68
|
-
if (typeof request.body !== "string") return reply(400, { error: "Expected a JSON string body" });
|
|
69
|
-
if (request.body.length > 65536) return reply(413, { error: "Body exceeds 65536 UTF-16 code units" });
|
|
70
|
-
try {
|
|
71
|
-
value = JSON.parse(request.body);
|
|
72
|
-
} catch {
|
|
73
|
-
return reply(400, { error: "Invalid JSON" });
|
|
74
|
-
}
|
|
75
|
-
if (!validValue(field, value)) return reply(400, { error: `Invalid setting value: ${key}` });
|
|
76
|
-
}
|
|
77
|
-
const saved = Storage.getItem(definition.storageKey, {});
|
|
78
|
-
if (!isRecord(saved)) throw new TypeError("stored settings must be an object");
|
|
79
|
-
const parent = settingsParent(saved, parts, request.method === "POST");
|
|
80
|
-
if (parent) {
|
|
81
|
-
if (request.method === "DELETE") _.unset(parent, [parts.at(-1)]);
|
|
82
|
-
else _.set(parent, [parts.at(-1)], value);
|
|
83
|
-
}
|
|
84
|
-
if (!Storage.setItem(definition.storageKey, saved)) return reply(500, { error: "Settings storage write failed" });
|
|
85
|
-
return reply(200, request.method === "DELETE" ? { deleted: true } : { saved: true });
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function isRecord(value) {
|
|
90
|
-
return value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function pathValue(root, parts) {
|
|
94
|
-
const parent = settingsParent(root, parts, false);
|
|
95
|
-
return parent ? _.get(parent, [parts.at(-1)]) : undefined;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// util 的 @root.path 允许序列化中间对象;统一解码并保留相邻键。
|
|
99
|
-
// Decode util-serialized intermediate objects while retaining sibling keys.
|
|
100
|
-
function settingsParent(root, parts, create) {
|
|
101
|
-
let parent = root;
|
|
102
|
-
for (const part of parts.slice(0, -1)) {
|
|
103
|
-
let next = _.get(parent, [part]);
|
|
104
|
-
if (next === undefined) {
|
|
105
|
-
if (!create) return;
|
|
106
|
-
next = {};
|
|
107
|
-
} else if (typeof next === "string") next = JSON.parse(next);
|
|
108
|
-
if (!isRecord(next)) throw new TypeError(`Stored path is not an object: ${part}`);
|
|
109
|
-
_.set(parent, [part], next);
|
|
110
|
-
parent = next;
|
|
111
|
-
}
|
|
112
|
-
return parent;
|
|
113
|
-
}
|
package/lib/settings-path.mjs
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 将 /api/ 后的 URL 路径转换为 util 的路径片段;非 API 路径不处理。
|
|
3
|
-
* Convert URL segments after /api/ to util path segments; ignore non-API paths.
|
|
4
|
-
* @param {string} url 请求完整 URL / Absolute request URL.
|
|
5
|
-
* @returns {string[] | undefined} 键路径片段 / Key path segments.
|
|
6
|
-
* @throws {TypeError} API 路径无效或包含危险片段 / Invalid or unsafe API path.
|
|
7
|
-
*/
|
|
8
|
-
export function parseSettingsPath(url) {
|
|
9
|
-
const pathname = new URL(url).pathname;
|
|
10
|
-
if (!pathname.startsWith("/api/")) return;
|
|
11
|
-
let parts;
|
|
12
|
-
try {
|
|
13
|
-
parts = pathname.slice(5).replace(/\/$/, "").split("/").map(decodeURIComponent);
|
|
14
|
-
} catch {
|
|
15
|
-
throw new TypeError("Invalid encoded key path");
|
|
16
|
-
}
|
|
17
|
-
if (!parts.every((part) => /^[a-zA-Z0-9_-]+$/.test(part) && !["__proto__", "prototype", "constructor"].includes(part)))
|
|
18
|
-
throw new TypeError("Invalid key path");
|
|
19
|
-
return parts;
|
|
20
|
-
}
|
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
|
-
})();
|
package/types/browser.d.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import type { ModuleDefinition, SettingsScalar } from "./index.js";
|
|
2
|
-
export interface Notification {
|
|
3
|
-
kind: "success" | "error";
|
|
4
|
-
operation: "write" | "delete";
|
|
5
|
-
module: string;
|
|
6
|
-
key: string;
|
|
7
|
-
message?: string;
|
|
8
|
-
}
|
|
9
|
-
export interface PreferencesClientOptions {
|
|
10
|
-
fetch?: typeof globalThis.fetch;
|
|
11
|
-
notify?: (notification: Notification) => void;
|
|
12
|
-
timeout?: number;
|
|
13
|
-
}
|
|
14
|
-
export interface ModuleSnapshot {
|
|
15
|
-
definition: ModuleDefinition;
|
|
16
|
-
values: Record<string, SettingsScalar | SettingsScalar[]>;
|
|
17
|
-
}
|
|
18
|
-
export interface PreferencesClient {
|
|
19
|
-
probe(module: string): Promise<boolean>;
|
|
20
|
-
open(module: string): Promise<ModuleSnapshot>;
|
|
21
|
-
snapshot(module: string): ModuleSnapshot;
|
|
22
|
-
leave(module: string): void;
|
|
23
|
-
set(module: string, key: string, value: SettingsScalar | SettingsScalar[]): Promise<void>;
|
|
24
|
-
remove(module: string, key: string): Promise<void>;
|
|
25
|
-
}
|
|
26
|
-
export interface PreferencesPanelOptions {
|
|
27
|
-
element: HTMLElement;
|
|
28
|
-
title?: string;
|
|
29
|
-
fetch?: typeof globalThis.fetch;
|
|
30
|
-
}
|
|
31
|
-
export function createPreferencesClient(options?: PreferencesClientOptions): PreferencesClient;
|
|
32
|
-
export function mountPreferencePanes(options: PreferencesPanelOptions): { destroy(): void };
|
package/types/index.d.ts
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
/** 字段标量 / Setting scalar. */
|
|
2
|
-
export type SettingsScalar = string | number | boolean;
|
|
3
|
-
export interface SettingsOption<T extends SettingsScalar = SettingsScalar> {
|
|
4
|
-
key: T;
|
|
5
|
-
label: string;
|
|
6
|
-
}
|
|
7
|
-
interface FieldBase {
|
|
8
|
-
key: string;
|
|
9
|
-
name: string;
|
|
10
|
-
description?: string;
|
|
11
|
-
}
|
|
12
|
-
/** 对齐 argument 配置字段 / Argument-compatible field. */
|
|
13
|
-
export type SettingsField = FieldBase &
|
|
14
|
-
(
|
|
15
|
-
| { type: "boolean"; defaultValue?: boolean; options?: SettingsOption<boolean>[] }
|
|
16
|
-
| { type: "number"; defaultValue?: number; options?: SettingsOption<number>[] }
|
|
17
|
-
| { type: "string"; defaultValue?: string; options?: SettingsOption<string>[] }
|
|
18
|
-
| { type: "array"; defaultValue?: SettingsScalar[]; options?: SettingsOption[] }
|
|
19
|
-
);
|
|
20
|
-
export interface SettingsRequest {
|
|
21
|
-
url: string;
|
|
22
|
-
method: string;
|
|
23
|
-
headers?: Record<string, string | undefined>;
|
|
24
|
-
/** POST 的正文为 JSON 值本身;DELETE 无正文 / POST contains the JSON value itself; DELETE has no body. */
|
|
25
|
-
body?: string;
|
|
26
|
-
}
|
|
27
|
-
export interface SettingsResponse {
|
|
28
|
-
status: number;
|
|
29
|
-
headers: Record<string, string>;
|
|
30
|
-
body: string;
|
|
31
|
-
}
|
|
32
|
-
export interface SettingsHandlerOptions {
|
|
33
|
-
/** 接管 /api/ 路径的 HTTPS 来源 / HTTPS origin serving /api/ paths. */
|
|
34
|
-
origin: string;
|
|
35
|
-
/** 运行时加载 BoxJS JSON / Load BoxJS JSON at runtime. */
|
|
36
|
-
loadConfig: (module: string) => unknown | Promise<unknown>;
|
|
37
|
-
/** 页面专用请求头,值为 1 / Dedicated header, value 1. */
|
|
38
|
-
requestHeader?: string;
|
|
39
|
-
/** 每次 GET 解析有效设置,默认读取持久化值;优先级由调用方决定。
|
|
40
|
-
* Resolve effective settings per GET; defaults to persisted values. Caller owns precedence. */
|
|
41
|
-
resolveSettings?: (stored: Record<string, unknown>, definition: ModuleDefinition) => Record<string, unknown>;
|
|
42
|
-
}
|
|
43
|
-
export interface ModuleDefinition {
|
|
44
|
-
module: string;
|
|
45
|
-
storageKey: string;
|
|
46
|
-
fields: SettingsField[];
|
|
47
|
-
settingsPath: string[];
|
|
48
|
-
}
|
|
49
|
-
export function normalizeBoxJs(config: unknown, module: string): ModuleDefinition;
|
|
50
|
-
export function createSettingsHandler(options: SettingsHandlerOptions): (request: SettingsRequest) => Promise<SettingsResponse | undefined>;
|
|
51
|
-
/** 解析 /api/ 后的 database 路径;非法路径抛错 / Parse database path after /api/; throws on unsafe paths. */
|
|
52
|
-
export function parseSettingsPath(url: string): string[] | undefined;
|