@nsnanocat/preference-panes 1.1.1 → 1.1.3

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/dist/api.js CHANGED
@@ -960,201 +960,6 @@
960
960
  }
961
961
  }
962
962
 
963
- /* https://github.com/ljharb/qs */
964
- /**
965
- * 轻量 `qs` 查询字符串工具。
966
- * Lightweight `qs` query-string utilities.
967
- *
968
- * 说明:
969
- * Notes:
970
- * - 参考 `qs` 的 `parse` / `stringify` 接口设计
971
- * - Modeled after the `qs` `parse` / `stringify` API
972
- * - `parse` 保持当前项目原有 `$argument` 字符串解析语义
973
- * - `parse` preserves the existing `$argument` string parsing semantics
974
- * - `stringify` 基于项目内 `Lodash` 路径能力展开对象
975
- * - `stringify` expands objects via the in-project `Lodash` path helpers
976
- *
977
- * 参考:
978
- * Reference:
979
- * - https://github.com/ljharb/qs
980
- * - https://www.npmjs.com/package/qs
981
- */
982
- class qs {
983
- /**
984
- * 将查询字符串解析为对象。
985
- * Parse a query string into an object.
986
- *
987
- * @param {string | Record<string, unknown> | null | undefined} [query=""] 查询字符串或对象 / Query string or object.
988
- * @returns {Record<string, unknown>}
989
- */
990
- static parse(query) {
991
- let result = {};
992
- switch (typeof query) {
993
- case "string": {
994
- const source = query.replace(/^\?/, "");
995
- if (!source) break;
996
- const obj = Object.fromEntries(
997
- source
998
- .split("&")
999
- .filter(Boolean)
1000
- .map(item => {
1001
- const [rawKey = "", rawValue = ""] = item.split("=", 2);
1002
- const key = qs.#decode(rawKey).replace(/\[([^\[\]]+)\]/g, ".$1");
1003
- return [key, qs.#decode(rawValue).replace(/\"/g, "")];
1004
- }),
1005
- );
1006
- Object.keys(obj).forEach(key => Lodash.set(result, key, obj[key]));
1007
- break;
1008
- }
1009
- case "object": {
1010
- switch (query) {
1011
- case null:
1012
- break;
1013
- default: {
1014
- const obj = {};
1015
- Object.keys(query).forEach(key => Lodash.set(obj, key, query[key]));
1016
- result = obj;
1017
- break;
1018
- }
1019
- }
1020
- break;
1021
- }
1022
- case "undefined":
1023
- result = {};
1024
- break;
1025
- }
1026
- return result;
1027
- }
1028
-
1029
- /**
1030
- * 将对象序列化为查询字符串。
1031
- * Serialize an object into a query string.
1032
- *
1033
- * @param {Record<string, unknown>} [object={}] 输入对象 / Input object.
1034
- * @returns {string}
1035
- */
1036
- static stringify(object = {}) {
1037
- if (!object || typeof object !== "object") return "";
1038
-
1039
- const entries = [];
1040
- Object.keys(object).forEach(key => qs.#collect(object, key, entries));
1041
-
1042
- if (entries.length === 0) return "";
1043
- return entries
1044
- .map(([key, value]) => `${qs.#encode(qs.#formatPath(key))}=${qs.#encode(value)}`)
1045
- .join("&");
1046
- }
1047
-
1048
- /**
1049
- * 收集待序列化的键值对。
1050
- * Collect key-value pairs for stringification.
1051
- *
1052
- * @param {Record<string, unknown>} object 输入对象 / Input object.
1053
- * @param {string} path 当前路径 / Current path.
1054
- * @param {[string, string][]} entries 输出数组 / Output entries.
1055
- * @returns {void}
1056
- */
1057
- static #collect(object, path, entries) {
1058
- const value = Lodash.get(object, path);
1059
- if (value === undefined) return;
1060
- if (value === null) {
1061
- entries.push([path, ""]);
1062
- return;
1063
- }
1064
- if (Array.isArray(value)) {
1065
- value.forEach((item, index) => {
1066
- if (item === undefined) return;
1067
- qs.#collect(object, `${path}[${index}]`, entries);
1068
- });
1069
- return;
1070
- }
1071
- if (qs.#isPlainObject(value)) {
1072
- Object.keys(value).forEach(key => qs.#collect(object, `${path}.${key}`, entries));
1073
- return;
1074
- }
1075
- entries.push([path, String(value)]);
1076
- }
1077
-
1078
- /**
1079
- * 使用 `Lodash.toPath` 规范化输出路径。
1080
- * Normalize output path via `Lodash.toPath`.
1081
- *
1082
- * @param {string} path 原始路径 / Raw path.
1083
- * @returns {string}
1084
- */
1085
- static #formatPath(path) {
1086
- const [head, ...tail] = Lodash.toPath(path);
1087
- return tail.reduce((result, segment) => (/^\d+$/.test(segment) ? `${result}[${segment}]` : `${result}.${segment}`), head);
1088
- }
1089
-
1090
- /**
1091
- * 判断值是否为普通对象。
1092
- * Check whether a value is a plain object.
1093
- *
1094
- * @param {unknown} value 输入值 / Input value.
1095
- * @returns {boolean}
1096
- */
1097
- static #isPlainObject(value) {
1098
- if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
1099
- const proto = Object.getPrototypeOf(value);
1100
- return proto === null || proto === Object.prototype;
1101
- }
1102
-
1103
- /**
1104
- * 编码查询字符串片段。
1105
- * Encode a query-string fragment.
1106
- *
1107
- * @param {string} value 原始值 / Raw value.
1108
- * @returns {string}
1109
- */
1110
- static #encode(value) {
1111
- return encodeURIComponent(value);
1112
- }
1113
-
1114
- /**
1115
- * 解码查询字符串片段。
1116
- * Decode a query-string fragment.
1117
- *
1118
- * @param {string} value 编码值 / Encoded value.
1119
- * @returns {string}
1120
- */
1121
- static #decode(value) {
1122
- return decodeURIComponent(value.replace(/\+/g, " "));
1123
- }
1124
- }
1125
-
1126
- /**
1127
- * 统一 `$argument` 输入格式并展开深路径。
1128
- * Normalize `$argument` input format and expand deep paths.
1129
- *
1130
- * 平台差异:
1131
- * Platform differences:
1132
- * - Surge / Stash / Egern 常见为字符串参数: `a=1&b=2`
1133
- * - Surge / Stash / Egern usually pass string args: `a=1&b=2`
1134
- * - Loon 支持字符串和对象两种形态
1135
- * - Loon supports both string and object forms
1136
- * - Quantumult X / Shadowrocket 一般不提供 `$argument`
1137
- * - Quantumult X / Shadowrocket usually do not expose `$argument`
1138
- *
1139
- * 执行时机:
1140
- * Execution timing:
1141
- * - 该模块为即时执行模块,`import` 时立即处理全局 `$argument`
1142
- * - This module executes immediately and mutates global `$argument` on import
1143
- *
1144
- * 归一化规则补充:
1145
- * Normalization details:
1146
- * - 使用 `globalThis.$argument` 读写,避免运行环境下未声明变量引用问题
1147
- * - Read/write via `globalThis.$argument` to avoid undeclared variable access
1148
- * - 当 `$argument` 为 `null` 或 `undefined` 时,会重置为 `{}`
1149
- * - When `$argument` is `null` or `undefined`, it is normalized to `{}`
1150
- */
1151
- (() => {
1152
- Console.debug("☑️ $argument");
1153
- globalThis.$argument = qs.parse(globalThis.$argument);
1154
- if (globalThis.$argument.LogLevel) Console.logLevel = globalThis.$argument.LogLevel;
1155
- Console.debug("✅ $argument", `$argument: ${JSON.stringify(globalThis.$argument)}`);
1156
- })();
1157
-
1158
963
  /**
1159
964
  * HTTP 状态码文本映射表。
1160
965
  * HTTP status code to status text map.
@@ -1327,304 +1132,6 @@
1327
1132
  }
1328
1133
  }
1329
1134
 
1330
- /**
1331
- * 统一请求参数。
1332
- * Unified request payload.
1333
- *
1334
- * @typedef {object} FetchRequest
1335
- * @property {string} url 请求地址 / Request URL.
1336
- * @property {string} [method] 请求方法 / HTTP method.
1337
- * @property {Record<string, any>} [headers] 请求头 / Request headers.
1338
- * @property {string|ArrayBuffer|ArrayBufferView|object} [body] 请求体 / Request body.
1339
- * @property {ArrayBuffer} [bodyBytes] 二进制请求体 / Binary request body.
1340
- * @property {number|string} [timeout] 超时(秒或毫秒)/ Timeout (seconds or milliseconds).
1341
- * @property {string} [policy] 指定策略 / Preferred policy.
1342
- * @property {boolean} [redirection] 是否跟随重定向 / Whether to follow redirects.
1343
- * @property {boolean} ["auto-redirect"] 平台重定向字段 / Platform redirect flag.
1344
- * @property {boolean|number|string} ["auto-cookie"] Worker / Node.js Cookie 开关 / Worker / Node.js Cookie toggle.
1345
- * @property {Record<string, any>} [opts] 平台扩展字段 / Platform extension fields.
1346
- */
1347
-
1348
- /**
1349
- * 统一响应结构。
1350
- * Unified response payload.
1351
- *
1352
- * @typedef {object} FetchResponse
1353
- * @property {boolean} ok 请求是否成功 / Whether request is successful.
1354
- * @property {number} status 状态码 / HTTP status code.
1355
- * @property {number} [statusCode] 状态码别名 / Status code alias.
1356
- * @property {string} [statusText] 状态文本 / HTTP status text.
1357
- * @property {Record<string, any>} [headers] 响应头 / Response headers.
1358
- * @property {string|ArrayBuffer} [body] 响应体 / Response body.
1359
- * @property {ArrayBuffer} [bodyBytes] 二进制响应体 / Binary response body.
1360
- */
1361
-
1362
- /**
1363
- * 跨平台 `fetch` 适配层。
1364
- * Cross-platform `fetch` adapter.
1365
- *
1366
- * 设计目标:
1367
- * Design goal:
1368
- * - 仿照 Web API `fetch`(`Window.fetch`)接口设计
1369
- * - Modeled after Web API `fetch` (`Window.fetch`)
1370
- * - 统一 VPN App、Worker 与 Node.js 环境中的请求调用
1371
- * - Unify request calls across VPN apps, Worker, and Node.js
1372
- *
1373
- * 功能:
1374
- * Features:
1375
- * - 统一 Quantumult X / Loon / Surge / Stash / Egern / Shadowrocket / Worker / Node.js 请求接口
1376
- * - Normalize request APIs across Quantumult X / Loon / Surge / Stash / Egern / Shadowrocket / Worker / Node.js
1377
- * - 统一返回体字段(`ok/status/statusText/body/bodyBytes`)
1378
- * - Normalize response fields (`ok/status/statusText/body/bodyBytes`)
1379
- *
1380
- * 与 Web `fetch` 的已知差异:
1381
- * Known differences from Web `fetch`:
1382
- * - 支持 `policy`、`auto-redirect` 等平台扩展字段
1383
- * - Supports platform extension fields like `policy` and `auto-redirect`
1384
- * - Worker / Node.js 共享基于 `fetch` 的请求分支
1385
- * - Worker / Node.js share the `fetch`-based request branch
1386
- * - Node.js ESM 的 `auto-cookie` 由 `fetch.node.mjs` 处理,本文件只使用宿主 `fetch`
1387
- * - Node.js ESM `auto-cookie` is handled by `fetch.node.mjs`; this module only uses the host `fetch`
1388
- * - 非浏览器平台通过 `$httpClient/$task` 实现,不是原生 Fetch 实现
1389
- * - Non-browser platforms use `$httpClient/$task` instead of native Fetch engine
1390
- * - 返回结构包含 `statusCode/bodyBytes` 等兼容字段
1391
- * - Response includes compatibility fields like `statusCode/bodyBytes`
1392
- *
1393
- * @link https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch
1394
- * @link https://developer.mozilla.org/zh-CN/docs/Web/API/Window/fetch
1395
- * @async
1396
- * @param {FetchRequest|string} resource 请求对象或 URL / Request object or URL string.
1397
- * @param {Partial<FetchRequest>} [options={}] 追加参数 / Extra options.
1398
- * @returns {Promise<FetchResponse>}
1399
- */
1400
- async function fetch(resource, options = {}) {
1401
- // 初始化参数。
1402
- // Initialize request input.
1403
- switch (typeof resource) {
1404
- case "object":
1405
- resource = { ...options, ...resource };
1406
- break;
1407
- case "string":
1408
- resource = { ...options, url: resource };
1409
- break;
1410
- case "undefined":
1411
- default:
1412
- throw new TypeError(`${Function.name}: 参数类型错误, resource 必须为对象或字符串`);
1413
- }
1414
- // 自动判断请求方法。
1415
- // Infer the HTTP method automatically.
1416
- if (!resource.method) {
1417
- resource.method = "GET";
1418
- if (resource.body ?? resource.bodyBytes) resource.method = "POST";
1419
- }
1420
- // 移除需要由底层实现自动生成的请求头。
1421
- // Remove headers that should be generated by the underlying runtime.
1422
- delete resource.headers?.Host;
1423
- delete resource.headers?.[":authority"];
1424
- delete resource.headers?.["Content-Length"];
1425
- delete resource.headers?.["content-length"];
1426
- // 统一请求方法为小写,方便后续索引平台 API。
1427
- // Normalize the method to lowercase for platform API lookups.
1428
- const method = resource.method.toLocaleLowerCase();
1429
- // 默认请求超时时间为 5 秒。
1430
- // Default request timeout to 5 seconds.
1431
- if (!resource.timeout) resource.timeout = 5;
1432
- if (resource.timeout) {
1433
- resource.timeout = Number.parseInt(resource.timeout, 10);
1434
- // 统一先转换为秒,大于 500 视为毫秒输入。
1435
- // Convert to seconds first and treat values above 500 as milliseconds.
1436
- if (resource.timeout > 500) resource.timeout = Math.round(resource.timeout / 1000);
1437
- }
1438
- if (resource.timeout) {
1439
- switch ($app) {
1440
- case "Loon":
1441
- case "Quantumult X":
1442
- case "Worker":
1443
- case "Node.js":
1444
- // 这些平台要求毫秒,因此把秒重新换算为毫秒。
1445
- // These platforms expect milliseconds, so convert seconds back to milliseconds.
1446
- resource.timeout = resource.timeout * 1000;
1447
- break;
1448
- }
1449
- }
1450
- // 根据当前平台选择请求实现。
1451
- // Select the request engine for the current platform.
1452
- switch ($app) {
1453
- case "Loon":
1454
- case "Surge":
1455
- case "Stash":
1456
- case "Egern":
1457
- case "Shadowrocket":
1458
- // 转换通用请求参数到 `$httpClient` 语义。
1459
- // Map shared request fields to `$httpClient` semantics.
1460
- if (resource.policy) {
1461
- switch ($app) {
1462
- case "Loon":
1463
- resource.node = resource.policy;
1464
- break;
1465
- case "Stash":
1466
- Lodash.set(resource, "headers.X-Stash-Selected-Proxy", encodeURI(resource.policy));
1467
- break;
1468
- case "Shadowrocket":
1469
- Lodash.set(resource, "headers.X-Surge-Proxy", resource.policy);
1470
- break;
1471
- }
1472
- }
1473
- if (typeof resource.redirection === "boolean") resource["auto-redirect"] = resource.redirection;
1474
- // 优先把 `bodyBytes` 映射回 `$httpClient` 能接受的 `body`。
1475
- // Prefer mapping `bodyBytes` back to the `body` field expected by `$httpClient`.
1476
- if (resource.bodyBytes && !resource.body) {
1477
- resource.body = resource.bodyBytes;
1478
- resource.bodyBytes = undefined;
1479
- }
1480
- // 根据 `Accept` 推断是否需要二进制响应体。
1481
- // Infer whether the response should be treated as binary from `Accept`.
1482
- switch ((resource.headers?.Accept || resource.headers?.accept)?.split(";")?.[0]) {
1483
- case "application/protobuf":
1484
- case "application/x-protobuf":
1485
- case "application/vnd.google.protobuf":
1486
- case "application/vnd.apple.flatbuffer":
1487
- case "application/grpc":
1488
- case "application/grpc-web":
1489
- case "application/grpc+proto":
1490
- case "application/octet-stream":
1491
- resource["binary-mode"] = true;
1492
- break;
1493
- }
1494
- // 发送 `$httpClient` 请求并归一化返回结构。
1495
- // Send the `$httpClient` request and normalize the response payload.
1496
- return new Promise((resolve, reject) => {
1497
- globalThis.$httpClient[method](resource, (error, response, body) => {
1498
- if (error) reject(error);
1499
- else {
1500
- response.ok = /^2\d\d$/.test(response.status);
1501
- response.statusCode = response.status;
1502
- response.statusText = StatusTexts[response.status];
1503
- if (body) {
1504
- response.body = body;
1505
- if (resource["binary-mode"] == true) response.bodyBytes = body;
1506
- }
1507
- resolve(response);
1508
- }
1509
- });
1510
- });
1511
- case "Quantumult X":
1512
- // 转换 Quantumult X 专有请求参数。
1513
- // Map request fields to Quantumult X specific options.
1514
- if (resource.policy) Lodash.set(resource, "opts.policy", resource.policy);
1515
- if (typeof resource["auto-redirect"] === "boolean") Lodash.set(resource, "opts.redirection", resource["auto-redirect"]);
1516
- // Quantumult X 使用 `bodyBytes` 传输二进制请求体。
1517
- // Quantumult X uses `bodyBytes` for binary request payloads.
1518
- if (resource.body instanceof ArrayBuffer) {
1519
- resource.bodyBytes = resource.body;
1520
- resource.body = undefined;
1521
- } else if (ArrayBuffer.isView(resource.body)) {
1522
- resource.bodyBytes = resource.body.buffer.slice(resource.body.byteOffset, resource.body.byteLength + resource.body.byteOffset);
1523
- resource.body = undefined;
1524
- } else if (resource.body) resource.bodyBytes = undefined;
1525
- // 发送请求,并用 `Promise.race` 提供统一超时保护。
1526
- // Send the request and enforce timeout with `Promise.race`.
1527
- return Promise.race([
1528
- globalThis.$task.fetch(resource).then(
1529
- response => {
1530
- response.ok = /^2\d\d$/.test(response.statusCode);
1531
- response.status = response.statusCode;
1532
- response.statusText = StatusTexts[response.status];
1533
- switch ((response.headers?.["Content-Type"] ?? response.headers?.["content-type"])?.split(";")?.[0]) {
1534
- case "application/protobuf":
1535
- case "application/x-protobuf":
1536
- case "application/vnd.google.protobuf":
1537
- case "application/vnd.apple.flatbuffer":
1538
- case "application/grpc":
1539
- case "application/grpc-web":
1540
- case "application/grpc+proto":
1541
- case "application/octet-stream":
1542
- response.body = response.bodyBytes;
1543
- break;
1544
- }
1545
- response.bodyBytes = undefined;
1546
- return response;
1547
- },
1548
- reason => Promise.reject(reason.error),
1549
- ),
1550
- new Promise((resolve, reject) => {
1551
- setTimeout(() => {
1552
- reject(new Error(`${Function.name}: 请求超时, 请检查网络后重试`));
1553
- }, resource.timeout);
1554
- }),
1555
- ]);
1556
- case "Worker":
1557
- case "Node.js":
1558
- default: {
1559
- let request;
1560
- let timeout;
1561
- let shouldWrapError = false;
1562
- switch ($app) {
1563
- case "Worker":
1564
- case "Node.js":
1565
- switch (typeof globalThis.fetch) {
1566
- case "function":
1567
- break;
1568
- default:
1569
- throw new Error(`${Function.name}: 当前运行环境不支持 Fetch API`);
1570
- }
1571
- // 将通用字段映射到 Worker / Node.js Fetch 语义。
1572
- // Map shared fields to Worker / Node.js Fetch semantics.
1573
- resource.redirect = resource.redirection ? "follow" : "manual";
1574
- request = resource;
1575
- timeout = resource.timeout;
1576
- shouldWrapError = true;
1577
- break;
1578
- default: {
1579
- // 未识别宿主也可使用完整标准 Fetch API;不将能力推断为宿主类型。
1580
- // An unrecognized host may still use the complete standard Fetch API; capability does not imply a host type.
1581
- if (typeof globalThis.fetch !== "function" || typeof globalThis.Headers !== "function" || typeof globalThis.Request !== "function" || typeof globalThis.Response !== "function") {
1582
- throw new Error(`${Function.name}: 当前运行环境不支持 Fetch API`);
1583
- }
1584
- const { url, bodyBytes, redirection, timeout: _timeout, policy: _policy, "auto-redirect": _autoRedirect, "auto-cookie": _autoCookie, opts: _opts, ...fetchOptions } = resource;
1585
- if (bodyBytes !== undefined && fetchOptions.body === undefined) fetchOptions.body = bodyBytes;
1586
- fetchOptions.redirect = redirection ? "follow" : "manual";
1587
- request = { url, ...fetchOptions };
1588
- timeout = resource.timeout * 1000;
1589
- break;
1590
- }
1591
- }
1592
- const { url, ...options } = request;
1593
- // 发起请求并归一化响应头、文本与二进制响应体。
1594
- // Send the request and normalize headers, text, and binary response data.
1595
- const responsePromise = globalThis.fetch(url, options).then(async response => {
1596
- const bodyBytes = await response.arrayBuffer();
1597
- let headers;
1598
- try {
1599
- headers = response.headers.raw();
1600
- } catch {
1601
- headers = Array.from(response.headers.entries()).reduce((acc, [key, value]) => {
1602
- acc[key] = acc[key] ? [...acc[key], value] : [value];
1603
- return acc;
1604
- }, {});
1605
- }
1606
- return {
1607
- ok: response.ok ?? /^2\d\d$/.test(response.status),
1608
- status: response.status,
1609
- statusCode: response.status,
1610
- statusText: response.statusText,
1611
- body: new TextDecoder("utf-8").decode(bodyBytes),
1612
- bodyBytes: bodyBytes,
1613
- headers: Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, key.toLowerCase() !== "set-cookie" ? value.toString() : value])),
1614
- };
1615
- });
1616
- return Promise.race([
1617
- shouldWrapError ? responsePromise.catch(error => Promise.reject(error.message)) : responsePromise,
1618
- new Promise((_resolve, reject) => {
1619
- setTimeout(() => {
1620
- reject(new Error(`${Function.name}: 请求超时, 请检查网络后重试`));
1621
- }, timeout);
1622
- }),
1623
- ]);
1624
- }
1625
- }
1626
- }
1627
-
1628
1135
  /**
1629
1136
  * 跨平台持久化存储适配器。
1630
1137
  * Cross-platform persistent storage adapter.
@@ -1921,11 +1428,9 @@
1921
1428
  return parts;
1922
1429
  }
1923
1430
 
1924
- const MISSING = Symbol("missing");
1925
-
1926
1431
  /**
1927
- * PreferencePanes 后端 API,只处理模块数据和持久化请求。
1928
- * PreferencePanes backend API handling only module data and persistence requests.
1432
+ * PreferencePanes 后端 API,只提供通用持久化操作。
1433
+ * PreferencePanes backend API providing generic persistence operations only.
1929
1434
  */
1930
1435
  class API {
1931
1436
  /**
@@ -1947,141 +1452,122 @@
1947
1452
  }
1948
1453
 
1949
1454
  /**
1950
- * 处理 `/api/{module}` 及其动作,不接管页面或静态资源。
1951
- * Handle `/api/{module}` and its actions without intercepting pages or static assets.
1455
+ * 处理固定存储动作,不接管模块配置、页面或静态资源。
1456
+ * Handle fixed storage actions without intercepting module configurations, pages, or static assets.
1952
1457
  * @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
1953
1458
  * @returns {Promise<import("./index.js").SettingsResponse | undefined>} API 响应或未接管 / API response or pass-through.
1954
1459
  */
1955
1460
  async handle(request) {
1956
1461
  const url = new URL(request.url);
1957
- const match = /^\/api\/([a-zA-Z0-9_-]+)(?:\/(get|set|delete))?\/?$/.exec(url.pathname);
1958
- if (!match) return;
1959
- const [, module, action] = match;
1960
- const configuration = `${url.origin}/configs/${module}`;
1961
- switch (true) {
1962
- case !action && request.method === "HEAD":
1963
- return this.#probe(request, configuration);
1964
- case Boolean(action) && request.method === "POST":
1965
- return this.#action(request, module, action, configuration);
1966
- default:
1967
- return this.#response(request, 405, { error: "Use HEAD for module probes and POST for module actions" });
1968
- }
1462
+ const action = /^\/api\/(get|set|delete)$/.exec(url.pathname)?.[1];
1463
+ if (action) return this.#store(request, action);
1464
+ return;
1969
1465
  }
1970
1466
 
1971
- async #probe(request, configuration) {
1972
- let result;
1467
+ /**
1468
+ * 使用唯一 form 字段中的完整 @root.path 执行存储操作。
1469
+ * Execute a storage operation using the complete @root.path from the sole form field.
1470
+ * @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
1471
+ * @param {"get" | "set" | "delete"} action 存储动作 / Storage action.
1472
+ * @returns {import("./index.js").SettingsResponse} 操作响应 / Operation response.
1473
+ */
1474
+ #store(request, action) {
1475
+ const reply = (status, data) => this.#response(request, status, data);
1476
+ if (request.method !== "POST") return reply(405, { error: "Use POST with a form body" });
1477
+ const headers = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
1478
+ if (headers["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/x-www-form-urlencoded") return reply(415, { error: "Expected application/x-www-form-urlencoded" });
1479
+ if (typeof request.body !== "string" || request.body.length > 65536) return reply(400, { error: "Expected a form body up to 65536 characters" });
1480
+ let parts, value;
1973
1481
  try {
1974
- result = await fetch({ url: configuration, method: "HEAD", timeout: 5000, headers: { Accept: "application/json" } });
1482
+ const fields = request.body.split("&");
1483
+ if (fields.length !== 1) throw new TypeError("Send exactly one storage key");
1484
+ const separator = fields[0].indexOf("=");
1485
+ if (separator < 0) throw new TypeError("Expected @root.path=value");
1486
+ const key = decodeURIComponent(fields[0].slice(0, separator).replace(/\+/g, " "));
1487
+ value = decodeURIComponent(fields[0].slice(separator + 1).replace(/\+/g, " "));
1488
+ if (!key.startsWith("@")) throw new TypeError("Storage keys must start with @");
1489
+ parts = validatePathParts(key.slice(1).split("."));
1490
+ if (parts.length < 2) throw new TypeError("Specify a storage root and child path");
1975
1491
  } catch (error) {
1976
- return this.#response(request, 502, { error: error.message });
1492
+ return reply(400, { error: error.message });
1977
1493
  }
1978
- const version = this.#header(result.headers, "x-preferencepanes-version");
1979
- return this.#response(request, result.statusCode ?? result.status, undefined, version ? { "X-PreferencePanes-Version": version } : {});
1980
- }
1981
-
1982
- async #action(request, module, action, configuration) {
1983
- const payload = this.#jsonBody(request);
1984
- const target = await this.#load(module, configuration);
1985
- switch (action) {
1986
- case "get": {
1987
- const value = Storage.getItem(payload?.scope ? this.#scopePath(target, payload.scope) : this.#storagePath(target, payload?.key), MISSING);
1988
- return value === MISSING ? this.#response(request, 404, { error: "Stored path does not exist" }) : this.#response(request, 200, value);
1989
- }
1990
- case "set":
1991
- if (!Object.hasOwn(payload ?? {}, "value")) throw Object.assign(new TypeError("A value is required"), { status: 400 });
1992
- if (!Storage.setItem(this.#storagePath(target, payload.key), payload.value)) throw new Error("Storage write failed");
1993
- return this.#response(request, 200, { saved: true });
1994
- case "delete": {
1995
- const path = payload?.scope ? this.#scopePath(target, payload.scope) : this.#storagePath(target, payload?.key);
1996
- if (!Storage.removeItem(path)) throw new Error("Storage write failed");
1997
- return this.#response(request, 200, { deleted: true });
1494
+ if (action === "set") {
1495
+ try {
1496
+ value = JSON.parse(value);
1497
+ } catch (error) {
1498
+ if (!(error instanceof SyntaxError)) throw error;
1998
1499
  }
1999
1500
  }
2000
- }
2001
-
2002
- async #load(module, configuration) {
2003
- let result;
1501
+ const [storageKey, ...path] = parts;
2004
1502
  try {
2005
- result = await fetch({ url: configuration, method: "GET", timeout: 5000, headers: { Accept: "application/json" } });
2006
- } catch (error) {
2007
- throw Object.assign(new Error(`Configuration request failed: ${error.message}`), { status: 502 });
2008
- }
2009
- const status = result.statusCode ?? result.status;
2010
- if (status !== 200) throw Object.assign(new Error(`Configuration HTTP ${status}`), { status });
2011
- try {
2012
- const body = typeof result.body === "string" ? result.body : new TextDecoder().decode(result.body);
2013
- const boxjs = JSON.parse(body);
2014
- const apps = Array.isArray(boxjs) ? [{ settings: boxjs }] : (boxjs.apps ?? [boxjs]);
2015
- if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
2016
- const entries = [];
2017
- let storageKey;
2018
- for (const app of apps) {
2019
- if (!app || !Array.isArray(app.settings)) throw new TypeError("Expected BoxJS settings array");
2020
- for (const entry of app.settings) {
2021
- if (typeof entry.id !== "string") throw new TypeError("BoxJS settings require string IDs");
2022
- if (!entry.id.startsWith("@")) {
2023
- if (Array.isArray(boxjs)) throw new TypeError("BoxJS settings require @root.path IDs");
2024
- continue;
2025
- }
2026
- const [root, ...parts] = entry.id.slice(1).split(".");
2027
- if (!root || root.startsWith("@") || parts.length < 2) throw new TypeError("A BoxJS setting must be below a literal storage root and module");
2028
- validatePathParts(parts);
2029
- if (parts[0] !== module) continue;
2030
- if (storageKey && storageKey !== root) throw new TypeError(`A module must use one storage root: ${module}`);
2031
- storageKey = root;
2032
- entries.push(entry);
1503
+ const root = Storage.getItem(storageKey, {});
1504
+ if (!isRecord(root)) throw new TypeError("Stored root must be an object");
1505
+ const parent = storageParent(root, path, action === "set");
1506
+ const key = path.at(-1);
1507
+ switch (action) {
1508
+ case "get": {
1509
+ const result = parent ? Lodash.get(parent, [key]) : undefined;
1510
+ return result === undefined ? reply(404, { error: "Stored path does not exist" }) : reply(200, result);
2033
1511
  }
1512
+ case "set":
1513
+ Lodash.set(parent, [key], value);
1514
+ break;
1515
+ case "delete":
1516
+ if (parent) Lodash.unset(parent, [key]);
1517
+ break;
2034
1518
  }
2035
- if (!entries.length) throw new TypeError(`No BoxJS settings for module: ${module}`);
2036
- return { entries, module, storageKey, version: this.#header(result.headers, "x-preferencepanes-version") };
2037
- } catch (error) {
2038
- throw Object.assign(new Error(`Invalid BoxJS: ${error.message}`), { status: 422 });
2039
- }
2040
- }
2041
-
2042
- #jsonBody(request) {
2043
- const headers = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
2044
- if (headers["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/json") throw Object.assign(new TypeError("Expected application/json"), { status: 415 });
2045
- if (typeof request.body !== "string" || request.body.length > 65536) throw Object.assign(new TypeError("Expected a JSON body up to 65536 characters"), { status: 400 });
2046
- try {
2047
- return JSON.parse(request.body);
1519
+ if (!Storage.setItem(storageKey, root)) throw new Error("Storage write failed");
1520
+ return reply(200, action === "set" ? { saved: true } : { deleted: true });
2048
1521
  } catch (error) {
2049
- throw Object.assign(error, { status: 400 });
1522
+ return reply(500, { error: error.message });
2050
1523
  }
2051
1524
  }
2052
1525
 
2053
- #storagePath(target, key) {
2054
- if (typeof key !== "string") throw Object.assign(new TypeError("A BoxJS field path is required"), { status: 400 });
2055
- const path = `@${target.storageKey}.${key}`;
2056
- if (!target.entries.some(entry => entry.id === path)) throw Object.assign(new TypeError(`Unknown BoxJS field: ${key}`), { status: 400 });
2057
- return path;
2058
- }
2059
-
2060
- #scopePath(target, scope) {
2061
- switch (scope) {
2062
- case "settings":
2063
- return `@${target.storageKey}.${target.module}.Settings`;
2064
- case "caches":
2065
- return `@${target.storageKey}.${target.module}.Caches`;
2066
- case "module":
2067
- return `@${target.storageKey}.${target.module}`;
2068
- default:
2069
- throw Object.assign(new TypeError("Scope must be settings, caches or module"), { status: 400 });
2070
- }
2071
- }
2072
-
2073
- #response(request, status, body, extraHeaders = {}) {
1526
+ #response(request, status, body) {
2074
1527
  return {
2075
1528
  status,
2076
- headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", ...extraHeaders },
1529
+ headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" },
2077
1530
  body: request.method === "HEAD" ? "" : JSON.stringify(body),
2078
1531
  };
2079
1532
  }
1533
+ }
1534
+
1535
+ /**
1536
+ * 判断存储根是否为普通对象。
1537
+ * Determine whether a storage root is a plain object.
1538
+ * @param {unknown} value 待检查值 / Value to inspect.
1539
+ * @returns {boolean} 是否为普通对象 / Whether this is a plain object.
1540
+ */
1541
+ function isRecord(value) {
1542
+ return value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype;
1543
+ }
2080
1544
 
2081
- #header(headers, name) {
2082
- const entry = Object.entries(headers ?? {}).find(([key]) => key.toLowerCase() === name);
2083
- return entry?.[1] === undefined ? undefined : String(entry[1]).trim();
1545
+ /**
1546
+ * 遍历父路径,并解码旧存储中的 JSON 字符串中间节点。
1547
+ * Traverse parent paths and decode legacy intermediate nodes stored as JSON strings.
1548
+ * @param {Record<string, unknown>} root 存储根 / Storage root.
1549
+ * @param {string[]} parts 完整路径 / Complete path.
1550
+ * @param {boolean} create 是否创建缺失节点 / Whether to create missing parents.
1551
+ * @returns {object | undefined} 父节点或 undefined / Parent node or undefined.
1552
+ */
1553
+ function storageParent(root, parts, create) {
1554
+ let parent = root;
1555
+ for (const part of parts.slice(0, -1)) {
1556
+ let next = Lodash.get(parent, [part]);
1557
+ switch (typeof next) {
1558
+ case "undefined":
1559
+ if (!create) return;
1560
+ next = {};
1561
+ break;
1562
+ case "string":
1563
+ next = JSON.parse(next);
1564
+ break;
1565
+ }
1566
+ if (!isRecord(next) && !Array.isArray(next)) throw new TypeError("Stored parent is not an object or array");
1567
+ Lodash.set(parent, [part], next);
1568
+ parent = next;
2084
1569
  }
1570
+ return parent;
2085
1571
  }
2086
1572
 
2087
1573
  new API().run();