@nsnanocat/preference-panes 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2124 +1,1729 @@
1
1
  var PreferencePanes = (function (exports) {
2
- 'use strict';
3
-
4
- /**
5
- * 当前运行平台名称(脚本平台优先,模块系统次之)。
6
- * Current runtime platform name (script platform first, module system second).
7
- *
8
- * 识别顺序:
9
- * Detection order:
10
- * 1) `$task` -> Quantumult X
11
- * 2) `$loon` -> Loon
12
- * 3) `$rocket` -> Shadowrocket
13
- * 4) `Egern` -> Egern
14
- * 5) `$environment["surge-version"]` -> Surge
15
- * 6) `$environment["stash-version"]` -> Stash
16
- * 7) `Cloudflare` -> Worker
17
- * 8) `process.versions.node` -> Node.js
18
- * 9) 默认回落 -> undefined
19
- * default fallback -> undefined
20
- *
21
- * 说明:
22
- * Notes:
23
- * - 使用 `'key' in globalThis`,避免 `Object.keys` 对不可枚举全局变量漏检。
24
- * - Use `'key' in globalThis` to avoid missing non-enumerable globals with `Object.keys`.
25
- *
26
- * @type {("Quantumult X" | "Loon" | "Shadowrocket" | "Egern" | "Surge" | "Stash" | "Worker" | "Node.js" | undefined)}
27
- */
28
- const $app = (() => {
29
- const has = key => key in globalThis;
30
- switch (true) {
31
- case has("$task"):
32
- return "Quantumult X";
33
- case has("$loon"):
34
- return "Loon";
35
- case has("$rocket"):
36
- return "Shadowrocket";
37
- case has("Egern"):
38
- return "Egern";
39
- case Boolean(globalThis.$environment?.["surge-version"]):
40
- return "Surge";
41
- case Boolean(globalThis.$environment?.["stash-version"]):
42
- return "Stash";
43
- case has("Cloudflare"):
44
- //case has("ServiceWorkerGlobalScope") && has("self") && has("caches") && has("scheduler"):
45
- return "Worker";
46
- case Boolean(globalThis.process?.versions?.node):
47
- return "Node.js";
48
- default:
49
- return undefined;
50
- }
51
- })();
52
-
53
- /**
54
- * 统一日志工具,兼容各脚本平台、Worker 与 Node.js。
55
- * Unified logger compatible with script platforms, Worker, and Node.js.
56
- *
57
- * logLevel 用法:
58
- * logLevel usage:
59
- * - 可读: `Console.logLevel` 返回 `OFF|ERROR|WARN|INFO|DEBUG|ALL`
60
- * - Read: `Console.logLevel` returns `OFF|ERROR|WARN|INFO|DEBUG|ALL`
61
- * - 可写: 数字 `0~5` 或字符串 `off/error/warn/info/debug/all`
62
- * - Write: number `0~5` or string `off/error/warn/info/debug/all`
63
- *
64
- * @example
65
- * Console.logLevel = "debug";
66
- * Console.debug("only shown when level >= DEBUG");
67
- * Console.logLevel = 2; // WARN
68
- */
69
- class Console {
70
- static #counts = new Map([]);
71
- static #groups = [];
72
- static #times = new Map([]);
73
-
74
- /**
75
- * 清空控制台(当前为空实现)。
76
- * Clear console (currently a no-op).
77
- *
78
- * @returns {void}
79
- */
80
- static clear = () => {};
81
-
82
- /**
83
- * 增加计数器并打印当前值。
84
- * Increment counter and print the current value.
85
- *
86
- * @param {string} [label="default"] 计数器名称 / Counter label.
87
- * @returns {void}
88
- */
89
- static count = (label = "default") => {
90
- switch (Console.#counts.has(label)) {
91
- case true:
92
- Console.#counts.set(label, Console.#counts.get(label) + 1);
93
- break;
94
- case false:
95
- Console.#counts.set(label, 0);
96
- break;
97
- }
98
- Console.log(`${label}: ${Console.#counts.get(label)}`);
99
- };
100
-
101
- /**
102
- * 重置计数器。
103
- * Reset a counter.
104
- *
105
- * @param {string} [label="default"] 计数器名称 / Counter label.
106
- * @returns {void}
107
- */
108
- static countReset = (label = "default") => {
109
- switch (Console.#counts.has(label)) {
110
- case true:
111
- Console.#counts.set(label, 0);
112
- Console.log(`${label}: ${Console.#counts.get(label)}`);
113
- break;
114
- case false:
115
- Console.warn(`Counter "${label}" doesn’t exist`);
116
- break;
117
- }
118
- };
119
-
120
- /**
121
- * 输出调试日志。
122
- * Print debug logs.
123
- *
124
- * @param {...any} msg 日志内容 / Log messages.
125
- * @returns {void}
126
- */
127
- static debug = (...msg) => {
128
- if (Console.#level < 4) return;
129
- msg = msg.map(m => `🅱️ ${m}`);
130
- Console.log(...msg);
131
- };
132
-
133
- /**
134
- * 输出错误日志。
135
- * Print error logs.
136
- *
137
- * @param {...any} msg 日志内容 / Log messages.
138
- * @returns {void}
139
- */
140
- static error(...msg) {
141
- if (Console.#level < 1) return;
142
- switch ($app) {
143
- case "Surge":
144
- case "Loon":
145
- case "Stash":
146
- case "Egern":
147
- case "Shadowrocket":
148
- case "Quantumult X":
149
- default:
150
- msg = msg.map(m => `❌ ${m}`);
151
- break;
152
- case "Worker":
153
- case "Node.js":
154
- msg = msg.map(m => `❌ ${m?.stack ?? m}`);
155
- break;
156
- }
157
- Console.log(...msg);
158
- }
159
-
160
- /**
161
- * `error` 的别名。
162
- * Alias of `error`.
163
- *
164
- * @param {...any} msg 日志内容 / Log messages.
165
- * @returns {void}
166
- */
167
- static exception = (...msg) => Console.error(...msg);
168
-
169
- /**
170
- * 进入日志分组。
171
- * Enter a log group.
172
- *
173
- * @param {string} label 分组名 / Group label.
174
- * @returns {number}
175
- */
176
- static group = label => Console.#groups.unshift(label);
177
-
178
- /**
179
- * 退出日志分组。
180
- * Exit the latest log group.
181
- *
182
- * @returns {*}
183
- */
184
- static groupEnd = () => Console.#groups.shift();
185
-
186
- /**
187
- * 输出信息日志。
188
- * Print info logs.
189
- *
190
- * @param {...any} msg 日志内容 / Log messages.
191
- * @returns {void}
192
- */
193
- static info(...msg) {
194
- if (Console.#level < 3) return;
195
- msg = msg.map(m => `ℹ️ ${m}`);
196
- Console.log(...msg);
197
- }
198
-
199
- static #level = 3;
200
-
201
- /**
202
- * 获取日志级别文本。
203
- * Get current log level text.
204
- *
205
- * @returns {"OFF"|"ERROR"|"WARN"|"INFO"|"DEBUG"|"ALL"}
206
- */
207
- static get logLevel() {
208
- switch (Console.#level) {
209
- case 0:
210
- return "OFF";
211
- case 1:
212
- return "ERROR";
213
- case 2:
214
- return "WARN";
215
- case 3:
216
- default:
217
- return "INFO";
218
- case 4:
219
- return "DEBUG";
220
- case 5:
221
- return "ALL";
222
- }
223
- }
224
-
225
- /**
226
- * 设置日志级别。
227
- * Set current log level.
228
- *
229
- * @param {number|string} level 级别值 / Level value.
230
- */
231
- static set logLevel(level) {
232
- switch (typeof level) {
233
- case "string":
234
- level = level.toLowerCase();
235
- break;
236
- case "number":
237
- break;
238
- case "undefined":
239
- default:
240
- level = "warn";
241
- break;
242
- }
243
- switch (level) {
244
- case 0:
245
- case "off":
246
- Console.#level = 0;
247
- break;
248
- case 1:
249
- case "error":
250
- Console.#level = 1;
251
- break;
252
- case 2:
253
- case "warn":
254
- case "warning":
255
- default:
256
- Console.#level = 2;
257
- break;
258
- case 3:
259
- case "info":
260
- Console.#level = 3;
261
- break;
262
- case 4:
263
- case "debug":
264
- Console.#level = 4;
265
- break;
266
- case 5:
267
- case "all":
268
- Console.#level = 5;
269
- break;
270
- }
271
- }
272
-
273
- /**
274
- * 输出通用日志。
275
- * Print generic logs.
276
- *
277
- * 说明:
278
- * Notes:
279
- * - 多行字符串参数会按换行拆分为多个独立日志项。
280
- * - Multi-line string arguments are split into multiple log entries by line breaks.
281
- *
282
- * @param {...any} msg 日志内容 / Log messages.
283
- * @returns {void}
284
- */
285
- static log = (...msg) => {
286
- if (Console.#level === 0) return;
287
- msg = msg.flatMap(log => {
288
- switch (typeof log) {
289
- case "object":
290
- return [JSON.stringify(log)];
291
- case "bigint":
292
- case "number":
293
- case "boolean":
294
- return [log.toString()];
295
- case "string":
296
- return log.split(/\r?\n/u);
297
- case "undefined":
298
- default:
299
- return [log];
300
- }
301
- });
302
- Console.#groups.forEach(group => {
303
- msg = msg.map(log => ` ${log}`);
304
- msg.unshift(`▼ ${group}:`);
305
- });
306
- msg = ["", ...msg];
307
- console.log(msg.join("\n"));
308
- };
309
-
310
- /**
311
- * 开始计时。
312
- * Start timer.
313
- *
314
- * @param {string} [label="default"] 计时器名称 / Timer label.
315
- * @returns {Map<string, number>}
316
- */
317
- static time = (label = "default") => Console.#times.set(label, Date.now());
318
-
319
- /**
320
- * 结束计时并移除计时器。
321
- * End timer and remove it.
322
- *
323
- * @param {string} [label="default"] 计时器名称 / Timer label.
324
- * @returns {boolean}
325
- */
326
- static timeEnd = (label = "default") => Console.#times.delete(label);
327
-
328
- /**
329
- * 输出当前计时器耗时。
330
- * Print elapsed time for a timer.
331
- *
332
- * @param {string} [label="default"] 计时器名称 / Timer label.
333
- * @returns {void}
334
- */
335
- static timeLog = (label = "default") => {
336
- const time = Console.#times.get(label);
337
- if (time) Console.log(`${label}: ${Date.now() - time}ms`);
338
- else Console.warn(`Timer "${label}" doesn’t exist`);
339
- };
340
-
341
- /**
342
- * 输出警告日志。
343
- * Print warning logs.
344
- *
345
- * @param {...any} msg 日志内容 / Log messages.
346
- * @returns {void}
347
- */
348
- static warn(...msg) {
349
- if (Console.#level < 2) return;
350
- msg = msg.map(m => `⚠️ ${m}`);
351
- Console.log(...msg);
352
- }
353
- }
354
-
355
- /* https://www.lodashjs.com */
356
- /**
357
- * 轻量 Lodash 工具集。
358
- * Lightweight Lodash-like utilities.
359
- *
360
- * 说明:
361
- * Notes:
362
- * - 这是 Lodash 的“部分方法”简化实现,不等价于完整 Lodash
363
- * - This is a simplified subset, not a full Lodash implementation
364
- * - 各方法语义可参考 Lodash 官方文档
365
- * - Method semantics can be referenced from official Lodash docs
366
- * - 导入时建议使用 `Lodash as _`,遵循 lodash 官方示例惯例
367
- * - Use `Lodash as _` when importing, following official lodash example convention
368
- *
369
- * 参考:
370
- * Reference:
371
- * - https://www.lodashjs.com
372
- * - https://lodash.com
373
- */
374
- class Lodash {
375
- /**
376
- * HTML 特殊字符转义。
377
- * Escape HTML special characters.
378
- *
379
- * @param {string} string 输入文本 / Input text.
380
- * @returns {string}
381
- * @see {@link https://lodash.com/docs/#escape lodash.escape}
382
- * @see {@link https://www.lodashjs.com/docs/lodash.escape lodash.escape (中文)}
383
- */
384
- static escape(string) {
385
- const map = {
386
- "&": "&amp;",
387
- "<": "&lt;",
388
- ">": "&gt;",
389
- '"': "&quot;",
390
- "'": "&#39;",
391
- };
392
- return string.replace(/[&<>"']/g, m => map[m]);
393
- }
394
-
395
- /**
396
- * 按路径读取对象值。
397
- * Get object value by path.
398
- *
399
- * @param {object} [object={}] 目标对象 / Target object.
400
- * @param {string|string[]} [path=""] 路径 / Path.
401
- * @param {*} [defaultValue=undefined] 默认值 / Default value.
402
- * @returns {*}
403
- * @see {@link https://lodash.com/docs/#get lodash.get}
404
- * @see {@link https://www.lodashjs.com/docs/lodash.get lodash.get (中文)}
405
- */
406
- static get(object = {}, path = "", defaultValue = undefined) {
407
- // translate array case to dot case, then split with .
408
- // a[0].b -> a.0.b -> ['a', '0', 'b']
409
- if (!Array.isArray(path)) path = Lodash.toPath(path);
410
-
411
- const result = path.reduce((previousValue, currentValue) => {
412
- return Object(previousValue)[currentValue]; // null undefined get attribute will throwError, Object() can return a object
413
- }, object);
414
- return result === undefined ? defaultValue : result;
415
- }
416
-
417
- /**
418
- * 递归合并源对象的自身可枚举属性到目标对象
419
- * Recursively merge source enumerable properties into target object.
420
- * @description 简化版 lodash.merge,用于合并配置对象
421
- * @description A simplified lodash.merge for config merging.
422
- *
423
- * 适用情况:
424
- * - 合并嵌套的配置/设置对象
425
- * - 需要深度合并而非浅层覆盖的场景
426
- * - 多个源对象依次合并到目标对象
427
- *
428
- * 限制:
429
- * - 仅处理普通对象 (Plain Object),不处理 Date/RegExp 等特殊对象
430
- * - Map/Set 仅支持同类型合并,不递归内部值
431
- * - 数组会被直接覆盖,不会合并数组元素
432
- * - 不处理循环引用,可能导致栈溢出
433
- * - 不复制 Symbol 属性和不可枚举属性
434
- * - 不保留原型链,仅处理自身属性
435
- * - 会修改原始目标对象 (mutates target)
436
- *
437
- * @param {object} object - 目标对象
438
- * @param {object} object - Target object.
439
- * @param {...object} sources - 源对象(可多个)
440
- * @param {...object} sources - Source objects.
441
- * @returns {object} 返回合并后的目标对象
442
- * @returns {object} Merged target object.
443
- * @see {@link https://lodash.com/docs/#merge lodash.merge}
444
- * @see {@link https://www.lodashjs.com/docs/lodash.merge lodash.merge (中文)}
445
- * @example
446
- * const target = { a: { b: 1 }, c: 2 };
447
- * const source = { a: { d: 3 }, e: 4 };
448
- * Lodash.merge(target, source);
449
- * // => { a: { b: 1, d: 3 }, c: 2, e: 4 }
450
- */
451
- static merge(object, ...sources) {
452
- if (object === null || object === undefined) return object;
453
-
454
- for (const source of sources) {
455
- if (source === null || source === undefined) continue;
456
-
457
- for (const key of Object.keys(source)) {
458
- const sourceValue = source[key];
459
- const targetValue = object[key];
460
-
461
- switch (true) {
462
- case Lodash.#isPlainObject(sourceValue) && Lodash.#isPlainObject(targetValue):
463
- // 递归合并对象
464
- object[key] = Lodash.merge(targetValue, sourceValue);
465
- break;
466
- case sourceValue instanceof Map && targetValue instanceof Map:
467
- // 合并 Map(空 Map 跳过)
468
- if (sourceValue.size > 0) {
469
- for (const [k, v] of sourceValue) {
470
- targetValue.set(k, v);
471
- }
472
- }
473
- break;
474
- case sourceValue instanceof Set && targetValue instanceof Set:
475
- // 合并 Set(空 Set 跳过)
476
- if (sourceValue.size > 0) {
477
- for (const v of sourceValue) {
478
- targetValue.add(v);
479
- }
480
- }
481
- break;
482
- case Array.isArray(sourceValue) && sourceValue.length === 0 && targetValue !== undefined:
483
- // 空数组不覆盖已有值
484
- break;
485
- case (sourceValue instanceof Map && sourceValue.size === 0 && targetValue !== undefined):
486
- case (sourceValue instanceof Set && sourceValue.size === 0 && targetValue !== undefined):
487
- // Map/Set 不覆盖已有值
488
- break;
489
- case sourceValue !== undefined:
490
- object[key] = sourceValue;
491
- break;
492
- }
493
- }
494
- }
495
-
496
- return object;
497
- }
498
-
499
- /**
500
- * 判断值是否为普通对象 (Plain Object)
501
- * Check whether a value is a plain object.
502
- * @param {*} value - 要检查的值
503
- * @param {*} value - Value to check.
504
- * @returns {boolean} 如果是普通对象返回 true
505
- * @returns {boolean} Returns true when value is a plain object.
506
- * @see {@link https://lodash.com/docs/#isPlainObject lodash.isPlainObject}
507
- * @see {@link https://www.lodashjs.com/docs/lodash.isPlainObject lodash.isPlainObject (中文)}
508
- */
509
- static #isPlainObject(value) {
510
- if (value === null || typeof value !== "object") return false;
511
- const proto = Object.getPrototypeOf(value);
512
- return proto === null || proto === Object.prototype;
513
- }
514
-
515
- /**
516
- * 删除对象指定路径并返回对象。
517
- * Omit paths from object and return the same object.
518
- *
519
- * @param {object} [object={}] 目标对象 / Target object.
520
- * @param {string|string[]} [paths=[]] 要删除的路径 / Paths to remove.
521
- * @returns {object}
522
- * @see {@link https://lodash.com/docs/#omit lodash.omit}
523
- * @see {@link https://www.lodashjs.com/docs/lodash.omit lodash.omit (中文)}
524
- */
525
- static omit(object = {}, paths = []) {
526
- if (!Array.isArray(paths)) paths = [paths.toString()];
527
- paths.forEach(path => Lodash.unset(object, path));
528
- return object;
529
- }
530
-
531
- /**
532
- * 仅保留对象指定键(第一层)。
533
- * Pick selected keys from object (top level only).
534
- *
535
- * @param {object} [object={}] 目标对象 / Target object.
536
- * @param {string|string[]} [paths=[]] 需要保留的键 / Keys to keep.
537
- * @returns {object}
538
- * @see {@link https://lodash.com/docs/#pick lodash.pick}
539
- * @see {@link https://www.lodashjs.com/docs/lodash.pick lodash.pick (中文)}
540
- */
541
- static pick(object = {}, paths = []) {
542
- if (!Array.isArray(paths)) paths = [paths.toString()];
543
- const filteredEntries = Object.entries(object).filter(([key, value]) => paths.includes(key));
544
- return Object.fromEntries(filteredEntries);
545
- }
546
-
547
- /**
548
- * 按路径写入对象值。
549
- * Set object value by path.
550
- *
551
- * @param {object} object 目标对象 / Target object.
552
- * @param {string|string[]} path 路径 / Path.
553
- * @param {*} value 写入值 / Value.
554
- * @returns {object}
555
- * @see {@link https://lodash.com/docs/#set lodash.set}
556
- * @see {@link https://www.lodashjs.com/docs/lodash.set lodash.set (中文)}
557
- */
558
- static set(object, path, value) {
559
- if (!Array.isArray(path)) path = Lodash.toPath(path);
560
- path.slice(0, -1).reduce((previousValue, currentValue, currentIndex) => (Object(previousValue[currentValue]) === previousValue[currentValue] ? previousValue[currentValue] : (previousValue[currentValue] = /^\d+$/.test(path[currentIndex + 1]) ? [] : {})), object)[path[path.length - 1]] = value;
561
- return object;
562
- }
563
-
564
- /**
565
- * 将点路径或数组下标路径转换为数组。
566
- * Convert dot/array-index path string into path segments.
567
- *
568
- * @param {string} value 路径字符串 / Path string.
569
- * @returns {string[]}
570
- * @see {@link https://lodash.com/docs/#toPath lodash.toPath}
571
- * @see {@link https://www.lodashjs.com/docs/lodash.toPath lodash.toPath (中文)}
572
- */
573
- static toPath(value) {
574
- return value
575
- .replace(/\[(\d+)\]/g, ".$1")
576
- .split(".")
577
- .filter(Boolean);
578
- }
579
-
580
- /**
581
- * HTML 实体反转义。
582
- * Unescape HTML entities.
583
- *
584
- * @param {string} string 输入文本 / Input text.
585
- * @returns {string}
586
- * @see {@link https://lodash.com/docs/#unescape lodash.unescape}
587
- * @see {@link https://www.lodashjs.com/docs/lodash.unescape lodash.unescape (中文)}
588
- */
589
- static unescape(string) {
590
- const map = {
591
- "&amp;": "&",
592
- "&lt;": "<",
593
- "&gt;": ">",
594
- "&quot;": '"',
595
- "&#39;": "'",
596
- };
597
- return string.replace(/&amp;|&lt;|&gt;|&quot;|&#39;/g, m => map[m]);
598
- }
599
-
600
- /**
601
- * 删除对象路径对应的值。
602
- * Remove value by object path.
603
- *
604
- * @param {object} [object={}] 目标对象 / Target object.
605
- * @param {string|string[]} [path=""] 路径 / Path.
606
- * @returns {boolean}
607
- * @see {@link https://lodash.com/docs/#unset lodash.unset}
608
- * @see {@link https://www.lodashjs.com/docs/lodash.unset lodash.unset (中文)}
609
- */
610
- static unset(object = {}, path = "") {
611
- if (!Array.isArray(path)) path = Lodash.toPath(path);
612
- const result = path.reduce((previousValue, currentValue, currentIndex) => {
613
- if (currentIndex === path.length - 1) {
614
- delete previousValue[currentValue];
615
- return true;
616
- }
617
- return Object(previousValue)[currentValue];
618
- }, object);
619
- return result;
620
- }
621
- }
622
-
623
- /**
624
- * HTTP 状态码文本映射表。
625
- * HTTP status code to status text map.
626
- *
627
- * 主要用途:
628
- * Primary usage:
629
- * - Quantumult X 的 `$done` 状态行拼接提供状态文本
630
- * - Provide status text for Quantumult X `$done` status-line composition
631
- * - QX 在部分场景要求 `status` 为完整状态行(如 `HTTP/1.1 200 OK`)
632
- * - QX may require full status line (e.g. `HTTP/1.1 200 OK`) in some cases
633
- *
634
- * 参考:
635
- * Reference:
636
- * - https://github.com/crossutility/Quantumult-X/raw/refs/heads/master/sample-rewrite-response-header.js
637
- *
638
- * @type {Record<number, string>}
639
- */
640
- const StatusTexts = {
641
- 100: "Continue",
642
- 101: "Switching Protocols",
643
- 102: "Processing",
644
- 103: "Early Hints",
645
- 200: "OK",
646
- 201: "Created",
647
- 202: "Accepted",
648
- 203: "Non-Authoritative Information",
649
- 204: "No Content",
650
- 205: "Reset Content",
651
- 206: "Partial Content",
652
- 207: "Multi-Status",
653
- 208: "Already Reported",
654
- 226: "IM Used",
655
- 300: "Multiple Choices",
656
- 301: "Moved Permanently",
657
- 302: "Found",
658
- 304: "Not Modified",
659
- 307: "Temporary Redirect",
660
- 308: "Permanent Redirect",
661
- 400: "Bad Request",
662
- 401: "Unauthorized",
663
- 402: "Payment Required",
664
- 403: "Forbidden",
665
- 404: "Not Found",
666
- 405: "Method Not Allowed",
667
- 406: "Not Acceptable",
668
- 407: "Proxy Authentication Required",
669
- 408: "Request Timeout",
670
- 409: "Conflict",
671
- 410: "Gone",
672
- 411: "Length Required",
673
- 412: "Precondition Failed",
674
- 413: "Content Too Large",
675
- 414: "URI Too Long",
676
- 415: "Unsupported Media Type",
677
- 416: "Range Not Satisfiable",
678
- 417: "Expectation Failed",
679
- 418: "I'm a teapot",
680
- 421: "Misdirected Request",
681
- 422: "Unprocessable Entity",
682
- 423: "Locked",
683
- 424: "Failed Dependency",
684
- 425: "Too Early",
685
- 426: "Upgrade Required",
686
- 428: "Precondition Required",
687
- 429: "Too Many Requests",
688
- 431: "Request Header Fields Too Large",
689
- 451: "Unavailable For Legal Reasons",
690
- 500: "Internal Server Error",
691
- 501: "Not Implemented",
692
- 502: "Bad Gateway",
693
- 503: "Service Unavailable",
694
- 504: "Gateway Timeout",
695
- 505: "HTTP Version Not Supported",
696
- 506: "Variant Also Negotiates",
697
- 507: "Insufficient Storage",
698
- 508: "Loop Detected",
699
- 510: "Not Extended",
700
- 511: "Network Authentication Required",
701
- };
702
-
703
- /**
704
- * `done` 的统一入参结构。
705
- * Unified `done` input payload.
706
- *
707
- * @typedef {object} DonePayload
708
- * @property {number|string} [status] 响应状态码或状态行 / Response status code or status line.
709
- * @property {string} [url] 响应 URL / Response URL.
710
- * @property {Record<string, any>} [headers] 响应头 / Response headers.
711
- * @property {string|ArrayBuffer|ArrayBufferView} [body] 响应体 / Response body.
712
- * @property {ArrayBuffer} [bodyBytes] 二进制响应体 / Binary response body.
713
- * @property {string} [policy] 指定策略名 / Preferred policy name.
714
- */
715
-
716
- /**
717
- * 结束脚本执行并按平台转换参数。
718
- * Complete script execution with platform-specific parameter mapping.
719
- *
720
- * 说明:
721
- * Notes:
722
- * - 这是调用入口,平台原生 `$done` 差异在内部处理
723
- * - This is the call entry and native `$done` differences are handled internally
724
- * - Worker 不调用 `$done` 或退出进程,仅记录日志
725
- * - Worker neither calls `$done` nor exits the process; it only logs
726
- * - Node.js 不调用 `$done`,而是直接退出进程
727
- * - Node.js does not call `$done`; it exits the process directly
728
- * - 未识别平台仅记录结束日志,不会强制退出
729
- * - Unknown runtimes only log completion and do not force an exit
730
- *
731
- * @param {DonePayload} [object={}] 统一响应对象 / Unified response object.
732
- * @returns {void}
733
- */
734
- function done(object = {}) {
735
- switch ($app) {
736
- case "Surge":
737
- if (object.policy) Lodash.set(object, "headers.X-Surge-Policy", object.policy);
738
- Console.log("🚩 执行结束!", `🕛 ${new Date().getTime() / 1000 - $script.startTime} 秒`);
739
- $done(object);
740
- break;
741
- case "Loon":
742
- if (object.policy) object.node = object.policy;
743
- Console.log("🚩 执行结束!", `🕛 ${(new Date() - $script.startTime) / 1000} 秒`);
744
- $done(object);
745
- break;
746
- case "Stash":
747
- if (object.policy) Lodash.set(object, "headers.X-Stash-Selected-Proxy", encodeURI(object.policy));
748
- Console.log("🚩 执行结束!", `🕛 ${(new Date() - $script.startTime) / 1000} 秒`);
749
- $done(object);
750
- break;
751
- case "Egern":
752
- Console.log("🚩 执行结束!");
753
- $done(object);
754
- break;
755
- case "Shadowrocket":
756
- Console.log("🚩 执行结束!");
757
- $done(object);
758
- break;
759
- case "Quantumult X":
760
- if (object.policy) Lodash.set(object, "opts.policy", object.policy);
761
- object = Lodash.pick(object, ["status", "url", "headers", "body", "bodyBytes"]);
762
- switch (typeof object.status) {
763
- case "number":
764
- object.status = `HTTP/1.1 ${object.status} ${StatusTexts[object.status]}`;
765
- break;
766
- case "string":
767
- case "undefined":
768
- break;
769
- default:
770
- throw new TypeError(`${Function.name}: 参数类型错误, status 必须为数字或字符串`);
771
- }
772
- if (object.body instanceof ArrayBuffer) {
773
- object.bodyBytes = object.body;
774
- object.body = undefined;
775
- } else if (ArrayBuffer.isView(object.body)) {
776
- object.bodyBytes = object.body.buffer.slice(object.body.byteOffset, object.body.byteLength + object.body.byteOffset);
777
- object.body = undefined;
778
- } else if (object.body) object.bodyBytes = undefined;
779
- Console.log("🚩 执行结束!");
780
- $done(object);
781
- break;
782
- case "Worker":
783
- Console.log("🚩 执行结束!");
784
- break;
785
- case "Node.js":
786
- Console.log("🚩 执行结束!");
787
- process.exit(1);
788
- break;
789
- default:
790
- Console.log("🚩 执行结束!");
791
- break;
792
- }
793
- }
794
-
795
- /* https://github.com/ljharb/qs */
796
- /**
797
- * 轻量 `qs` 查询字符串工具。
798
- * Lightweight `qs` query-string utilities.
799
- *
800
- * 说明:
801
- * Notes:
802
- * - 参考 `qs` `parse` / `stringify` 接口设计
803
- * - Modeled after the `qs` `parse` / `stringify` API
804
- * - `parse` 保持当前项目原有 `$argument` 字符串解析语义
805
- * - `parse` preserves the existing `$argument` string parsing semantics
806
- * - `stringify` 基于项目内 `Lodash` 路径能力展开对象
807
- * - `stringify` expands objects via the in-project `Lodash` path helpers
808
- *
809
- * 参考:
810
- * Reference:
811
- * - https://github.com/ljharb/qs
812
- * - https://www.npmjs.com/package/qs
813
- */
814
- class qs {
815
- /**
816
- * 将查询字符串解析为对象。
817
- * Parse a query string into an object.
818
- *
819
- * @param {string | Record<string, unknown> | null | undefined} [query=""] 查询字符串或对象 / Query string or object.
820
- * @returns {Record<string, unknown>}
821
- */
822
- static parse(query) {
823
- let result = {};
824
- switch (typeof query) {
825
- case "string": {
826
- const source = query.replace(/^\?/, "");
827
- if (!source) break;
828
- const obj = Object.fromEntries(
829
- source
830
- .split("&")
831
- .filter(Boolean)
832
- .map(item => {
833
- const [rawKey = "", rawValue = ""] = item.split("=", 2);
834
- const key = qs.#decode(rawKey).replace(/\[([^\[\]]+)\]/g, ".$1");
835
- return [key, qs.#decode(rawValue).replace(/\"/g, "")];
836
- }),
837
- );
838
- Object.keys(obj).forEach(key => Lodash.set(result, key, obj[key]));
839
- break;
840
- }
841
- case "object": {
842
- switch (query) {
843
- case null:
844
- break;
845
- default: {
846
- const obj = {};
847
- Object.keys(query).forEach(key => Lodash.set(obj, key, query[key]));
848
- result = obj;
849
- break;
850
- }
851
- }
852
- break;
853
- }
854
- case "undefined":
855
- result = {};
856
- break;
857
- }
858
- return result;
859
- }
860
-
861
- /**
862
- * 将对象序列化为查询字符串。
863
- * Serialize an object into a query string.
864
- *
865
- * @param {Record<string, unknown>} [object={}] 输入对象 / Input object.
866
- * @returns {string}
867
- */
868
- static stringify(object = {}) {
869
- if (!object || typeof object !== "object") return "";
870
-
871
- const entries = [];
872
- Object.keys(object).forEach(key => qs.#collect(object, key, entries));
873
-
874
- if (entries.length === 0) return "";
875
- return entries
876
- .map(([key, value]) => `${qs.#encode(qs.#formatPath(key))}=${qs.#encode(value)}`)
877
- .join("&");
878
- }
879
-
880
- /**
881
- * 收集待序列化的键值对。
882
- * Collect key-value pairs for stringification.
883
- *
884
- * @param {Record<string, unknown>} object 输入对象 / Input object.
885
- * @param {string} path 当前路径 / Current path.
886
- * @param {[string, string][]} entries 输出数组 / Output entries.
887
- * @returns {void}
888
- */
889
- static #collect(object, path, entries) {
890
- const value = Lodash.get(object, path);
891
- if (value === undefined) return;
892
- if (value === null) {
893
- entries.push([path, ""]);
894
- return;
895
- }
896
- if (Array.isArray(value)) {
897
- value.forEach((item, index) => {
898
- if (item === undefined) return;
899
- qs.#collect(object, `${path}[${index}]`, entries);
900
- });
901
- return;
902
- }
903
- if (qs.#isPlainObject(value)) {
904
- Object.keys(value).forEach(key => qs.#collect(object, `${path}.${key}`, entries));
905
- return;
906
- }
907
- entries.push([path, String(value)]);
908
- }
909
-
910
- /**
911
- * 使用 `Lodash.toPath` 规范化输出路径。
912
- * Normalize output path via `Lodash.toPath`.
913
- *
914
- * @param {string} path 原始路径 / Raw path.
915
- * @returns {string}
916
- */
917
- static #formatPath(path) {
918
- const [head, ...tail] = Lodash.toPath(path);
919
- return tail.reduce((result, segment) => (/^\d+$/.test(segment) ? `${result}[${segment}]` : `${result}.${segment}`), head);
920
- }
921
-
922
- /**
923
- * 判断值是否为普通对象。
924
- * Check whether a value is a plain object.
925
- *
926
- * @param {unknown} value 输入值 / Input value.
927
- * @returns {boolean}
928
- */
929
- static #isPlainObject(value) {
930
- if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
931
- const proto = Object.getPrototypeOf(value);
932
- return proto === null || proto === Object.prototype;
933
- }
934
-
935
- /**
936
- * 编码查询字符串片段。
937
- * Encode a query-string fragment.
938
- *
939
- * @param {string} value 原始值 / Raw value.
940
- * @returns {string}
941
- */
942
- static #encode(value) {
943
- return encodeURIComponent(value);
944
- }
945
-
946
- /**
947
- * 解码查询字符串片段。
948
- * Decode a query-string fragment.
949
- *
950
- * @param {string} value 编码值 / Encoded value.
951
- * @returns {string}
952
- */
953
- static #decode(value) {
954
- return decodeURIComponent(value.replace(/\+/g, " "));
955
- }
956
- }
957
-
958
- class URLSearchParams {
959
- constructor(params, onUpdate) {
960
- switch (typeof params) {
961
- case "string": {
962
- if (params.length === 0)
963
- break;
964
- if (params.startsWith("?"))
965
- params = params.slice(1);
966
- const pairs = params.split("&").map(pair => {
967
- const separator = pair.indexOf("=");
968
- return separator < 0 ? [pair, ""] : [pair.slice(0, separator), pair.slice(separator + 1)];
969
- });
970
- pairs.forEach(([key, value]) => {
971
- this.#params.push(key ? this.#decodeQueryComponent(key) : key);
972
- this.#values.push(this.#decodeQueryComponent(value));
973
- });
974
- break;
975
- }
976
- case "object":
977
- if (Array.isArray(params)) {
978
- Object.entries(params).forEach(([key, value]) => {
979
- this.#params.push(key);
980
- this.#values.push(value);
981
- });
982
- }
983
- else if (Symbol.iterator in Object(params)) {
984
- for (const [key, value] of params) {
985
- this.#params.push(key);
986
- this.#values.push(value);
987
- }
988
- }
989
- break;
990
- }
991
- this.#updateSearchString(this.#params, this.#values);
992
- this.#onUpdate = onUpdate;
993
- }
994
- // Create 2 seperate arrays for the params and values to make management and lookup easier.
995
- #param = "";
996
- #params = [];
997
- #values = [];
998
- #onUpdate;
999
- #decodeQueryComponent(str) {
1000
- return decodeURIComponent(str.replace(/\+/g, " "));
1001
- }
1002
- #encodeQueryComponent(str) {
1003
- return encodeURIComponent(str)
1004
- .replace(/%20/g, "+")
1005
- .replace(/[!'()~]/g, character => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
1006
- }
1007
- // Update the search property of the URL instance with the new params and values.
1008
- #updateSearchString(params, values) {
1009
- if (params.length === 0)
1010
- this.#param = "";
1011
- else
1012
- this.#param = params
1013
- .map((param, index) => {
1014
- switch (typeof values[index]) {
1015
- case "object":
1016
- return `${this.#encodeQueryComponent(param)}=${this.#encodeQueryComponent(JSON.stringify(values[index]))}`;
1017
- case "boolean":
1018
- case "number":
1019
- case "string":
1020
- return `${this.#encodeQueryComponent(param)}=${this.#encodeQueryComponent(values[index])}`;
1021
- case "undefined":
1022
- default:
1023
- return this.#encodeQueryComponent(param);
1024
- }
1025
- })
1026
- .join("&");
1027
- this.#onUpdate?.(this.#param);
1028
- }
1029
- // Add a given param with a given value to the end.
1030
- append(name, value) {
1031
- this.#params.push(name);
1032
- this.#values.push(value);
1033
- this.#updateSearchString(this.#params, this.#values);
1034
- }
1035
- // Remove all occurances of a given param
1036
- delete(name, value) {
1037
- while (this.#params.indexOf(name) > -1) {
1038
- this.#values.splice(this.#params.indexOf(name), 1);
1039
- this.#params.splice(this.#params.indexOf(name), 1);
1040
- }
1041
- this.#updateSearchString(this.#params, this.#values);
1042
- }
1043
- // Return an array to be structured in this way: [[param1, value1], [param2, value2]] to mimic the native method's ES6 iterator.
1044
- entries() {
1045
- return this.#params.map((param, index) => [param, this.#values[index]]);
1046
- }
1047
- // Return the value matched to the first occurance of a given param.
1048
- get(name) {
1049
- return this.#values[this.#params.indexOf(name)];
1050
- }
1051
- // Return all values matched to all occurances of a given param.
1052
- getAll(name) {
1053
- return this.#values.filter((value, index) => this.#params[index] === name);
1054
- }
1055
- // Return a boolean to indicate whether a given param exists.
1056
- has(name, value) {
1057
- return this.#params.indexOf(name) > -1;
1058
- }
1059
- // Return an array of the param names to mimic the native method's ES6 iterator.
1060
- keys() {
1061
- return this.#params;
1062
- }
1063
- // Set a given param to a given value.
1064
- set(name, value) {
1065
- if (this.#params.indexOf(name) === -1) {
1066
- this.append(name, value); // If the given param doesn't already exist, append it.
1067
- }
1068
- else {
1069
- let first = true;
1070
- const newValues = [];
1071
- // If the param already exists, change the value of the first occurance and remove any remaining occurances.
1072
- this.#params = this.#params.filter((currentParam, index) => {
1073
- if (currentParam !== name) {
1074
- newValues.push(this.#values[index]);
1075
- return true;
1076
- // If the currentParam matches the one being changed and it's the first one, keep the param and change its value to the given one.
1077
- }
1078
- else if (first) {
1079
- first = false;
1080
- newValues.push(value);
1081
- return true;
1082
- }
1083
- // If the currentParam matches the one being changed, but it's not the first, remove it.
1084
- return false;
1085
- });
1086
- this.#values = newValues;
1087
- this.#updateSearchString(this.#params, this.#values);
1088
- }
1089
- }
1090
- // Sort all key/value pairs, if any, by their keys then by their values.
1091
- sort() {
1092
- // Call entries to make sorting easier, then rewrite the params and values in the new order.
1093
- const sortedPairs = this.entries().sort();
1094
- this.#params = [];
1095
- this.#values = [];
1096
- sortedPairs.forEach(pair => {
1097
- this.#params.push(pair[0]);
1098
- this.#values.push(pair[1]);
1099
- });
1100
- this.#updateSearchString(this.#params, this.#values);
1101
- }
1102
- // Return the search string without the '?'.
1103
- toString = () => this.#param;
1104
- // Return and array of the param values to mimic the native method's ES6 iterator..
1105
- values = () => this.#values.values();
1106
- }
1107
-
1108
- class URL {
1109
- constructor(url, base) {
1110
- switch (typeof url) {
1111
- case "string": {
1112
- const urlIsValid = /^(blob:|file:)?[a-zA-z]+:\/\/.*/.test(url);
1113
- const baseIsValid = base ? /^(blob:|file:)?[a-zA-z]+:\/\/.*/.test(base) : false;
1114
- // If a string is passed for url instead of location or link, then set the properties of the URL instance.
1115
- if (urlIsValid)
1116
- this.href = url;
1117
- // If the url isn't valid, but the base is, then prepend the base to the url.
1118
- else if (baseIsValid)
1119
- this.href = base + url;
1120
- // If no valid url or base is given, then throw a type error.
1121
- else
1122
- throw new TypeError('URL string is not valid. If using a relative url, a second argument needs to be passed representing the base URL. Example: new URL("relative/path", "http://www.example.com");');
1123
- break;
1124
- }
1125
- case "object":
1126
- break;
1127
- default:
1128
- throw new TypeError("Invalid argument type.");
1129
- }
1130
- }
1131
- #url = {
1132
- hash: "",
1133
- host: "",
1134
- hostname: "",
1135
- href: "",
1136
- password: "",
1137
- pathname: "",
1138
- port: Number.NaN,
1139
- protocol: "",
1140
- search: "",
1141
- searchParams: new URLSearchParams(""),
1142
- username: "",
1143
- };
1144
- // refer: http://www.ietf.org/rfc/rfc3986.txt
1145
- static #URLRegExp = /^(?<scheme>([^:\/?#]+):)?(?:\/\/(?<authority>[^\/?#]*))?(?<path>[^?#]*)(?<query>\?([^#]*))?(?<hash>#(.*))?$/;
1146
- static #AuthorityRegExp = /^(?<authentication>(?<username>[^:]*)(:(?<password>[^@]*))?@)?(?<hostname>[^:]+)(:(?<port>\d+))?$/;
1147
- get hash() {
1148
- return this.#url.hash;
1149
- }
1150
- set hash(value) {
1151
- if (value.length !== 0) {
1152
- if (value.startsWith("#"))
1153
- value = value.slice(1);
1154
- this.#url.hash = `#${encodeURIComponent(value)}`;
1155
- }
1156
- }
1157
- get host() {
1158
- return this.port.length > 0 ? `${this.hostname}:${this.port}` : this.hostname;
1159
- }
1160
- set host(value) {
1161
- [this.hostname, this.port] = value.split(":", 2);
1162
- }
1163
- get hostname() {
1164
- return encodeURIComponent(this.#url.hostname);
1165
- }
1166
- set hostname(value) {
1167
- this.#url.hostname = value ?? "";
1168
- }
1169
- get href() {
1170
- let authority = "";
1171
- if (this.username.length > 0) {
1172
- authority += this.username;
1173
- if (this.password.length > 0)
1174
- authority += `:${this.password}`;
1175
- authority += "@";
1176
- }
1177
- return `${this.protocol}//${authority}${this.host}${this.pathname}${this.search}${this.hash}`;
1178
- }
1179
- set href(value) {
1180
- if (value.startsWith("blob:") || value.startsWith("file:"))
1181
- value = value.slice(5);
1182
- const urlMatch = value.match(URL.#URLRegExp);
1183
- if (!urlMatch)
1184
- throw new TypeError("Invalid URL format.");
1185
- this.protocol = urlMatch.groups.scheme ?? "";
1186
- const authorityMatch = urlMatch.groups.authority.match(URL.#AuthorityRegExp);
1187
- this.username = authorityMatch.groups.username ?? "";
1188
- this.password = authorityMatch.groups.password ?? "";
1189
- this.hostname = authorityMatch.groups.hostname ?? "";
1190
- this.port = authorityMatch.groups.port ?? "";
1191
- this.pathname = urlMatch.groups.path ?? "";
1192
- this.search = urlMatch.groups.query ?? "";
1193
- this.hash = urlMatch.groups.hash ?? "";
1194
- }
1195
- get origin() {
1196
- return `${this.protocol}//${this.host}`;
1197
- }
1198
- get password() {
1199
- return encodeURIComponent(this.#url.password);
1200
- }
1201
- set password(value) {
1202
- if (this.username.length > 0)
1203
- this.#url.password = value ?? "";
1204
- }
1205
- get pathname() {
1206
- return `/${this.#url.pathname}`;
1207
- }
1208
- set pathname(value) {
1209
- value = `${value}`;
1210
- if (value.startsWith("/"))
1211
- value = value.slice(1);
1212
- this.#url.pathname = value;
1213
- }
1214
- get port() {
1215
- if (Number.isNaN(this.#url.port))
1216
- return "";
1217
- const port = this.#url.port.toString();
1218
- if (this.protocol === "ftp:" && port === "21")
1219
- return "";
1220
- if (this.protocol === "http:" && port === "80")
1221
- return "";
1222
- if (this.protocol === "https:" && port === "443")
1223
- return "";
1224
- return port;
1225
- }
1226
- set port(value) {
1227
- switch (value) {
1228
- case "":
1229
- this.#url.port = Number.NaN;
1230
- break;
1231
- default: {
1232
- const port = Number.parseInt(value, 10);
1233
- if (port >= 0 && port < 65535)
1234
- this.#url.port = port;
1235
- }
1236
- }
1237
- }
1238
- get protocol() {
1239
- return `${this.#url.protocol}:`;
1240
- }
1241
- set protocol(value) {
1242
- if (value.endsWith(":"))
1243
- value = value.slice(0, -1);
1244
- this.#url.protocol = value;
1245
- }
1246
- get search() {
1247
- if (this.#url.search.length > 0)
1248
- return `?${this.#url.search}`;
1249
- else
1250
- return "";
1251
- }
1252
- set search(value) {
1253
- value = `${value}`;
1254
- if (value.startsWith("?"))
1255
- value = value.slice(1);
1256
- this.#url.search = value;
1257
- this.#url.searchParams = new URLSearchParams(this.#url.search, search => {
1258
- this.#url.search = search;
1259
- });
1260
- }
1261
- get searchParams() {
1262
- return this.#url.searchParams;
1263
- }
1264
- get username() {
1265
- return encodeURIComponent(this.#url.username);
1266
- }
1267
- set username(value) {
1268
- this.#url.username = value ?? "";
1269
- }
1270
- static parse = (url, base) => new URL(url, base);
1271
- /**
1272
- * Returns the string representation of the URL.
1273
- *
1274
- * @returns {string} The href of the URL.
1275
- */
1276
- toString = () => this.href;
1277
- /**
1278
- * Converts the URL object properties to a JSON string.
1279
- *
1280
- * @returns {string} A JSON string representation of the URL object.
1281
- */
1282
- toJSON = () => JSON.stringify({
1283
- hash: this.hash,
1284
- host: this.host,
1285
- hostname: this.hostname,
1286
- href: this.href,
1287
- origin: this.origin,
1288
- password: this.password,
1289
- pathname: this.pathname,
1290
- port: this.port,
1291
- protocol: this.protocol,
1292
- search: this.search,
1293
- searchParams: this.searchParams,
1294
- username: this.username,
1295
- });
1296
- }
1297
-
1298
- /**
1299
- * 统一请求参数。
1300
- * Unified request payload.
1301
- *
1302
- * @typedef {object} FetchRequest
1303
- * @property {string} url 请求地址 / Request URL.
1304
- * @property {string} [method] 请求方法 / HTTP method.
1305
- * @property {Record<string, any>} [headers] 请求头 / Request headers.
1306
- * @property {string|ArrayBuffer|ArrayBufferView|object} [body] 请求体 / Request body.
1307
- * @property {ArrayBuffer} [bodyBytes] 二进制请求体 / Binary request body.
1308
- * @property {number|string} [timeout] 超时(秒或毫秒)/ Timeout (seconds or milliseconds).
1309
- * @property {string} [policy] 指定策略 / Preferred policy.
1310
- * @property {boolean} [redirection] 是否跟随重定向 / Whether to follow redirects.
1311
- * @property {boolean} ["auto-redirect"] 平台重定向字段 / Platform redirect flag.
1312
- * @property {boolean|number|string} ["auto-cookie"] Worker / Node.js Cookie 开关 / Worker / Node.js Cookie toggle.
1313
- * @property {Record<string, any>} [opts] 平台扩展字段 / Platform extension fields.
1314
- */
1315
-
1316
- /**
1317
- * 统一响应结构。
1318
- * Unified response payload.
1319
- *
1320
- * @typedef {object} FetchResponse
1321
- * @property {boolean} ok 请求是否成功 / Whether request is successful.
1322
- * @property {number} status 状态码 / HTTP status code.
1323
- * @property {number} [statusCode] 状态码别名 / Status code alias.
1324
- * @property {string} [statusText] 状态文本 / HTTP status text.
1325
- * @property {Record<string, any>} [headers] 响应头 / Response headers.
1326
- * @property {string|ArrayBuffer} [body] 响应体 / Response body.
1327
- * @property {ArrayBuffer} [bodyBytes] 二进制响应体 / Binary response body.
1328
- */
1329
-
1330
- /**
1331
- * 跨平台 `fetch` 适配层。
1332
- * Cross-platform `fetch` adapter.
1333
- *
1334
- * 设计目标:
1335
- * Design goal:
1336
- * - 仿照 Web API `fetch`(`Window.fetch`)接口设计
1337
- * - Modeled after Web API `fetch` (`Window.fetch`)
1338
- * - 统一 VPN App、Worker 与 Node.js 环境中的请求调用
1339
- * - Unify request calls across VPN apps, Worker, and Node.js
1340
- *
1341
- * 功能:
1342
- * Features:
1343
- * - 统一 Quantumult X / Loon / Surge / Stash / Egern / Shadowrocket / Worker / Node.js 请求接口
1344
- * - Normalize request APIs across Quantumult X / Loon / Surge / Stash / Egern / Shadowrocket / Worker / Node.js
1345
- * - 统一返回体字段(`ok/status/statusText/body/bodyBytes`)
1346
- * - Normalize response fields (`ok/status/statusText/body/bodyBytes`)
1347
- *
1348
- * 与 Web `fetch` 的已知差异:
1349
- * Known differences from Web `fetch`:
1350
- * - 支持 `policy`、`auto-redirect` 等平台扩展字段
1351
- * - Supports platform extension fields like `policy` and `auto-redirect`
1352
- * - Worker / Node.js 共享基于 `fetch` 的请求分支
1353
- * - Worker / Node.js share the `fetch`-based request branch
1354
- * - Node.js ESM 的 `auto-cookie` 由 `fetch.node.mjs` 处理,本文件只使用宿主 `fetch`
1355
- * - Node.js ESM `auto-cookie` is handled by `fetch.node.mjs`; this module only uses the host `fetch`
1356
- * - 非浏览器平台通过 `$httpClient/$task` 实现,不是原生 Fetch 实现
1357
- * - Non-browser platforms use `$httpClient/$task` instead of native Fetch engine
1358
- * - 返回结构包含 `statusCode/bodyBytes` 等兼容字段
1359
- * - Response includes compatibility fields like `statusCode/bodyBytes`
1360
- *
1361
- * @link https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch
1362
- * @link https://developer.mozilla.org/zh-CN/docs/Web/API/Window/fetch
1363
- * @async
1364
- * @param {FetchRequest|string} resource 请求对象或 URL / Request object or URL string.
1365
- * @param {Partial<FetchRequest>} [options={}] 追加参数 / Extra options.
1366
- * @returns {Promise<FetchResponse>}
1367
- */
1368
- async function fetch(resource, options = {}) {
1369
- // 初始化参数。
1370
- // Initialize request input.
1371
- switch (typeof resource) {
1372
- case "object":
1373
- resource = { ...options, ...resource };
1374
- break;
1375
- case "string":
1376
- resource = { ...options, url: resource };
1377
- break;
1378
- case "undefined":
1379
- default:
1380
- throw new TypeError(`${Function.name}: 参数类型错误, resource 必须为对象或字符串`);
1381
- }
1382
- // 自动判断请求方法。
1383
- // Infer the HTTP method automatically.
1384
- if (!resource.method) {
1385
- resource.method = "GET";
1386
- if (resource.body ?? resource.bodyBytes) resource.method = "POST";
1387
- }
1388
- // 移除需要由底层实现自动生成的请求头。
1389
- // Remove headers that should be generated by the underlying runtime.
1390
- delete resource.headers?.Host;
1391
- delete resource.headers?.[":authority"];
1392
- delete resource.headers?.["Content-Length"];
1393
- delete resource.headers?.["content-length"];
1394
- // 统一请求方法为小写,方便后续索引平台 API。
1395
- // Normalize the method to lowercase for platform API lookups.
1396
- const method = resource.method.toLocaleLowerCase();
1397
- // 默认请求超时时间为 5 秒。
1398
- // Default request timeout to 5 seconds.
1399
- if (!resource.timeout) resource.timeout = 5;
1400
- if (resource.timeout) {
1401
- resource.timeout = Number.parseInt(resource.timeout, 10);
1402
- // 统一先转换为秒,大于 500 视为毫秒输入。
1403
- // Convert to seconds first and treat values above 500 as milliseconds.
1404
- if (resource.timeout > 500) resource.timeout = Math.round(resource.timeout / 1000);
1405
- }
1406
- if (resource.timeout) {
1407
- switch ($app) {
1408
- case "Loon":
1409
- case "Quantumult X":
1410
- case "Worker":
1411
- case "Node.js":
1412
- // 这些平台要求毫秒,因此把秒重新换算为毫秒。
1413
- // These platforms expect milliseconds, so convert seconds back to milliseconds.
1414
- resource.timeout = resource.timeout * 1000;
1415
- break;
1416
- }
1417
- }
1418
- // 根据当前平台选择请求实现。
1419
- // Select the request engine for the current platform.
1420
- switch ($app) {
1421
- case "Loon":
1422
- case "Surge":
1423
- case "Stash":
1424
- case "Egern":
1425
- case "Shadowrocket":
1426
- // 转换通用请求参数到 `$httpClient` 语义。
1427
- // Map shared request fields to `$httpClient` semantics.
1428
- if (resource.policy) {
1429
- switch ($app) {
1430
- case "Loon":
1431
- resource.node = resource.policy;
1432
- break;
1433
- case "Stash":
1434
- Lodash.set(resource, "headers.X-Stash-Selected-Proxy", encodeURI(resource.policy));
1435
- break;
1436
- case "Shadowrocket":
1437
- Lodash.set(resource, "headers.X-Surge-Proxy", resource.policy);
1438
- break;
1439
- }
1440
- }
1441
- if (typeof resource.redirection === "boolean") resource["auto-redirect"] = resource.redirection;
1442
- // 优先把 `bodyBytes` 映射回 `$httpClient` 能接受的 `body`。
1443
- // Prefer mapping `bodyBytes` back to the `body` field expected by `$httpClient`.
1444
- if (resource.bodyBytes && !resource.body) {
1445
- resource.body = resource.bodyBytes;
1446
- resource.bodyBytes = undefined;
1447
- }
1448
- // 根据 `Accept` 推断是否需要二进制响应体。
1449
- // Infer whether the response should be treated as binary from `Accept`.
1450
- switch ((resource.headers?.Accept || resource.headers?.accept)?.split(";")?.[0]) {
1451
- case "application/protobuf":
1452
- case "application/x-protobuf":
1453
- case "application/vnd.google.protobuf":
1454
- case "application/vnd.apple.flatbuffer":
1455
- case "application/grpc":
1456
- case "application/grpc-web":
1457
- case "application/grpc+proto":
1458
- case "application/octet-stream":
1459
- resource["binary-mode"] = true;
1460
- break;
1461
- }
1462
- // 发送 `$httpClient` 请求并归一化返回结构。
1463
- // Send the `$httpClient` request and normalize the response payload.
1464
- return new Promise((resolve, reject) => {
1465
- globalThis.$httpClient[method](resource, (error, response, body) => {
1466
- if (error) reject(error);
1467
- else {
1468
- response.ok = /^2\d\d$/.test(response.status);
1469
- response.statusCode = response.status;
1470
- response.statusText = StatusTexts[response.status];
1471
- if (body) {
1472
- response.body = body;
1473
- if (resource["binary-mode"] == true) response.bodyBytes = body;
1474
- }
1475
- resolve(response);
1476
- }
1477
- });
1478
- });
1479
- case "Quantumult X":
1480
- // 转换 Quantumult X 专有请求参数。
1481
- // Map request fields to Quantumult X specific options.
1482
- if (resource.policy) Lodash.set(resource, "opts.policy", resource.policy);
1483
- if (typeof resource["auto-redirect"] === "boolean") Lodash.set(resource, "opts.redirection", resource["auto-redirect"]);
1484
- // Quantumult X 使用 `bodyBytes` 传输二进制请求体。
1485
- // Quantumult X uses `bodyBytes` for binary request payloads.
1486
- if (resource.body instanceof ArrayBuffer) {
1487
- resource.bodyBytes = resource.body;
1488
- resource.body = undefined;
1489
- } else if (ArrayBuffer.isView(resource.body)) {
1490
- resource.bodyBytes = resource.body.buffer.slice(resource.body.byteOffset, resource.body.byteLength + resource.body.byteOffset);
1491
- resource.body = undefined;
1492
- } else if (resource.body) resource.bodyBytes = undefined;
1493
- // 发送请求,并用 `Promise.race` 提供统一超时保护。
1494
- // Send the request and enforce timeout with `Promise.race`.
1495
- return Promise.race([
1496
- globalThis.$task.fetch(resource).then(
1497
- response => {
1498
- response.ok = /^2\d\d$/.test(response.statusCode);
1499
- response.status = response.statusCode;
1500
- response.statusText = StatusTexts[response.status];
1501
- switch ((response.headers?.["Content-Type"] ?? response.headers?.["content-type"])?.split(";")?.[0]) {
1502
- case "application/protobuf":
1503
- case "application/x-protobuf":
1504
- case "application/vnd.google.protobuf":
1505
- case "application/vnd.apple.flatbuffer":
1506
- case "application/grpc":
1507
- case "application/grpc-web":
1508
- case "application/grpc+proto":
1509
- case "application/octet-stream":
1510
- response.body = response.bodyBytes;
1511
- break;
1512
- }
1513
- response.bodyBytes = undefined;
1514
- return response;
1515
- },
1516
- reason => Promise.reject(reason.error),
1517
- ),
1518
- new Promise((resolve, reject) => {
1519
- setTimeout(() => {
1520
- reject(new Error(`${Function.name}: 请求超时, 请检查网络后重试`));
1521
- }, resource.timeout);
1522
- }),
1523
- ]);
1524
- case "Worker":
1525
- case "Node.js":
1526
- default: {
1527
- let request;
1528
- let timeout;
1529
- let shouldWrapError = false;
1530
- switch ($app) {
1531
- case "Worker":
1532
- case "Node.js":
1533
- switch (typeof globalThis.fetch) {
1534
- case "function":
1535
- break;
1536
- default:
1537
- throw new Error(`${Function.name}: 当前运行环境不支持 Fetch API`);
1538
- }
1539
- // 将通用字段映射到 Worker / Node.js Fetch 语义。
1540
- // Map shared fields to Worker / Node.js Fetch semantics.
1541
- resource.redirect = resource.redirection ? "follow" : "manual";
1542
- request = resource;
1543
- timeout = resource.timeout;
1544
- shouldWrapError = true;
1545
- break;
1546
- default: {
1547
- // 未识别宿主也可使用完整标准 Fetch API;不将能力推断为宿主类型。
1548
- // An unrecognized host may still use the complete standard Fetch API; capability does not imply a host type.
1549
- if (typeof globalThis.fetch !== "function" || typeof globalThis.Headers !== "function" || typeof globalThis.Request !== "function" || typeof globalThis.Response !== "function") {
1550
- throw new Error(`${Function.name}: 当前运行环境不支持 Fetch API`);
1551
- }
1552
- const { url, bodyBytes, redirection, timeout: _timeout, policy: _policy, "auto-redirect": _autoRedirect, "auto-cookie": _autoCookie, opts: _opts, ...fetchOptions } = resource;
1553
- if (bodyBytes !== undefined && fetchOptions.body === undefined) fetchOptions.body = bodyBytes;
1554
- fetchOptions.redirect = redirection ? "follow" : "manual";
1555
- request = { url, ...fetchOptions };
1556
- timeout = resource.timeout * 1000;
1557
- break;
1558
- }
1559
- }
1560
- const { url, ...options } = request;
1561
- // 发起请求并归一化响应头、文本与二进制响应体。
1562
- // Send the request and normalize headers, text, and binary response data.
1563
- const responsePromise = globalThis.fetch(url, options).then(async response => {
1564
- const bodyBytes = await response.arrayBuffer();
1565
- let headers;
1566
- try {
1567
- headers = response.headers.raw();
1568
- } catch {
1569
- headers = Array.from(response.headers.entries()).reduce((acc, [key, value]) => {
1570
- acc[key] = acc[key] ? [...acc[key], value] : [value];
1571
- return acc;
1572
- }, {});
1573
- }
1574
- return {
1575
- ok: response.ok ?? /^2\d\d$/.test(response.status),
1576
- status: response.status,
1577
- statusCode: response.status,
1578
- statusText: response.statusText,
1579
- body: new TextDecoder("utf-8").decode(bodyBytes),
1580
- bodyBytes: bodyBytes,
1581
- headers: Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, key.toLowerCase() !== "set-cookie" ? value.toString() : value])),
1582
- };
1583
- });
1584
- return Promise.race([
1585
- shouldWrapError ? responsePromise.catch(error => Promise.reject(error.message)) : responsePromise,
1586
- new Promise((_resolve, reject) => {
1587
- setTimeout(() => {
1588
- reject(new Error(`${Function.name}: 请求超时, 请检查网络后重试`));
1589
- }, timeout);
1590
- }),
1591
- ]);
1592
- }
1593
- }
1594
- }
1595
-
1596
- /**
1597
- * 跨平台持久化存储适配器。
1598
- * Cross-platform persistent storage adapter.
1599
- *
1600
- * 设计目标:
1601
- * Design goal:
1602
- * - 仿照 Web Storage (`Storage`) 接口设计
1603
- * - Modeled after Web Storage (`Storage`) interface
1604
- * - 统一 VPN App 脚本环境中的持久化读写接口
1605
- * - Unify persistence APIs across VPN app script environments
1606
- *
1607
- * 支持后端:
1608
- * Supported backends:
1609
- * - Surge/Loon/Stash/Egern/Shadowrocket: `$persistentStore`
1610
- * - Quantumult X: `$prefs`
1611
- * - Worker: 内存缓存(非持久化)
1612
- * - Worker: in-memory cache (non-persistent)
1613
- * - Node.js: 由 Node.js ESM 入口注入持久化后端
1614
- * - Node.js: persistent backend injected by the Node.js ESM entry
1615
- *
1616
- * 支持路径键:
1617
- * Supports path key:
1618
- * - `@root.path.to.value`
1619
- *
1620
- * Web Storage 的已知差异:
1621
- * Known differences from Web Storage:
1622
- * - 支持 `@key.path` 深路径读写(Web Storage 原生不支持)
1623
- * - Supports `@key.path` deep-path access (not native in Web Storage)
1624
- * - `removeItem/clear` 并非所有平台都可用
1625
- * - `removeItem/clear` are not available on every platform
1626
- * - 读取时会尝试 `JSON.parse`,写入对象会 `JSON.stringify`
1627
- * - Reads try `JSON.parse`, writes stringify objects
1628
- *
1629
- * @link https://developer.mozilla.org/en-US/docs/Web/API/Storage
1630
- * @link https://developer.mozilla.org/zh-CN/docs/Web/API/Storage
1631
- */
1632
- class Storage {
1633
- /**
1634
- * Worker / Node.js 环境下的内存数据缓存。
1635
- * In-memory data cache for Worker / Node.js runtime.
1636
- *
1637
- * @type {Record<string, any>|null}
1638
- */
1639
- static data = null;
1640
-
1641
- /**
1642
- * Node.js 持久化文件名。
1643
- * Data file name used in Node.js.
1644
- *
1645
- * @type {string}
1646
- */
1647
- static dataFile = "box.dat";
1648
-
1649
- /**
1650
- * Node.js ESM 入口注入的存储后端。
1651
- * Storage backend injected by the Node.js ESM entry.
1652
- *
1653
- * @type {{load: (dataFile: string) => Record<string, any>, write: (dataFile: string, data: Record<string, any>) => void}|null}
1654
- */
1655
- static nodeBackend = null;
1656
-
1657
- /**
1658
- * `@key.path` 解析正则。
1659
- * Regex for `@key.path` parsing.
1660
- *
1661
- * @type {RegExp}
1662
- */
1663
- static #nameRegex = /^@(?<key>[^.]+)(?:\.(?<path>.*))?$/;
1664
-
1665
- /**
1666
- * 读取存储值。
1667
- * Read value from persistent storage.
1668
- *
1669
- * @param {string} keyName 键名或路径键 / Key or path key.
1670
- * @param {*} [defaultValue=null] 默认值 / Default value when key is missing.
1671
- * @returns {*}
1672
- */
1673
- static getItem(keyName, defaultValue = null) {
1674
- let keyValue = defaultValue;
1675
- // 如果以 @
1676
- switch (keyName.startsWith("@")) {
1677
- case true: {
1678
- const { key, path } = keyName.match(Storage.#nameRegex)?.groups;
1679
- keyName = key;
1680
- let value = Storage.getItem(keyName, {});
1681
- if (typeof value !== "object") value = {};
1682
- keyValue = Lodash.get(value, path);
1683
- try {
1684
- keyValue = JSON.parse(keyValue);
1685
- } catch {}
1686
- break;
1687
- }
1688
- default:
1689
- switch ($app) {
1690
- case "Surge":
1691
- case "Loon":
1692
- case "Stash":
1693
- case "Egern":
1694
- case "Shadowrocket":
1695
- keyValue = $persistentStore.read(keyName);
1696
- break;
1697
- case "Quantumult X":
1698
- keyValue = $prefs.valueForKey(keyName);
1699
- break;
1700
- case "Worker":
1701
- Storage.data = Storage.data ?? {};
1702
- keyValue = Storage.data[keyName];
1703
- break;
1704
- case "Node.js":
1705
- Storage.data = Storage.nodeBackend.load(Storage.dataFile);
1706
- keyValue = Storage.data?.[keyName];
1707
- break;
1708
- default:
1709
- keyValue = Storage.data?.[keyName] || null;
1710
- break;
1711
- }
1712
- try {
1713
- keyValue = JSON.parse(keyValue);
1714
- } catch {
1715
- // do nothing
1716
- }
1717
- break;
1718
- }
1719
- return keyValue ?? defaultValue;
1720
- }
1721
-
1722
- /**
1723
- * 写入存储值。
1724
- * Write value into persistent storage.
1725
- *
1726
- * @param {string} keyName 键名或路径键 / Key or path key.
1727
- * @param {*} keyValue 写入值 / Value to store.
1728
- * @returns {boolean}
1729
- */
1730
- static setItem(keyName = new String(), keyValue = new String()) {
1731
- let result = false;
1732
- switch (typeof keyValue) {
1733
- case "object":
1734
- keyValue = JSON.stringify(keyValue);
1735
- break;
1736
- default:
1737
- keyValue = String(keyValue);
1738
- break;
1739
- }
1740
- switch (keyName.startsWith("@")) {
1741
- case true: {
1742
- const { key, path } = keyName.match(Storage.#nameRegex)?.groups;
1743
- keyName = key;
1744
- let value = Storage.getItem(keyName, {});
1745
- if (typeof value !== "object") value = {};
1746
- Lodash.set(value, path, keyValue);
1747
- result = Storage.setItem(keyName, value);
1748
- break;
1749
- }
1750
- default:
1751
- switch ($app) {
1752
- case "Surge":
1753
- case "Loon":
1754
- case "Stash":
1755
- case "Egern":
1756
- case "Shadowrocket":
1757
- result = $persistentStore.write(keyValue, keyName);
1758
- break;
1759
- case "Quantumult X":
1760
- result = $prefs.setValueForKey(keyValue, keyName);
1761
- break;
1762
- case "Worker":
1763
- Storage.data = Storage.data ?? {};
1764
- Storage.data[keyName] = keyValue;
1765
- result = true;
1766
- break;
1767
- case "Node.js":
1768
- Storage.data = Storage.nodeBackend.load(Storage.dataFile);
1769
- Storage.data[keyName] = keyValue;
1770
- Storage.nodeBackend.write(Storage.dataFile, Storage.data);
1771
- result = true;
1772
- break;
1773
- default:
1774
- result = Storage.data?.[keyName] || null;
1775
- break;
1776
- }
1777
- break;
1778
- }
1779
- return result;
1780
- }
1781
-
1782
- /**
1783
- * 删除存储值。
1784
- * Remove value from persistent storage.
1785
- *
1786
- * 平台说明:
1787
- * Platform notes:
1788
- * - Quantumult X: `$prefs.removeValueForKey`
1789
- * - Surge: 通过 `$persistentStore.write(null, keyName)` 删除
1790
- * - 其余平台当前返回 `false`
1791
- *
1792
- * @param {string} keyName 键名或路径键 / Key or path key.
1793
- * @returns {boolean}
1794
- */
1795
- static removeItem(keyName) {
1796
- let result = false;
1797
- switch (keyName.startsWith("@")) {
1798
- case true: {
1799
- const { key, path } = keyName.match(Storage.#nameRegex)?.groups;
1800
- keyName = key;
1801
- let value = Storage.getItem(keyName);
1802
- if (typeof value !== "object") value = {};
1803
- Lodash.unset(value, path);
1804
- result = Storage.setItem(keyName, value);
1805
- break;
1806
- }
1807
- default:
1808
- switch ($app) {
1809
- case "Surge":
1810
- result = $persistentStore.write(null, keyName);
1811
- break;
1812
- case "Loon":
1813
- case "Stash":
1814
- case "Egern":
1815
- case "Shadowrocket":
1816
- result = false;
1817
- break;
1818
- case "Quantumult X":
1819
- result = $prefs.removeValueForKey(keyName);
1820
- break;
1821
- case "Worker":
1822
- Storage.data = Storage.data ?? {};
1823
- delete Storage.data[keyName];
1824
- result = true;
1825
- break;
1826
- case "Node.js":
1827
- // result = false;
1828
- Storage.data = Storage.nodeBackend.load(Storage.dataFile);
1829
- delete Storage.data[keyName];
1830
- Storage.nodeBackend.write(Storage.dataFile, Storage.data);
1831
- result = true;
1832
- break;
1833
- default:
1834
- result = false;
1835
- break;
1836
- }
1837
- break;
1838
- }
1839
- return result;
1840
- }
1841
-
1842
- /**
1843
- * 清空存储。
1844
- * Clear storage.
1845
- *
1846
- * @returns {boolean}
1847
- */
1848
- static clear() {
1849
- let result = false;
1850
- switch ($app) {
1851
- case "Surge":
1852
- case "Loon":
1853
- case "Stash":
1854
- case "Egern":
1855
- case "Shadowrocket":
1856
- result = false;
1857
- break;
1858
- case "Quantumult X":
1859
- result = $prefs.removeAllValues();
1860
- break;
1861
- case "Worker":
1862
- Storage.data = {};
1863
- result = true;
1864
- break;
1865
- case "Node.js":
1866
- // result = false;
1867
- Storage.data = Storage.nodeBackend.load(Storage.dataFile);
1868
- Storage.data = {};
1869
- Storage.nodeBackend.write(Storage.dataFile, Storage.data);
1870
- result = true;
1871
- break;
1872
- default:
1873
- result = false;
1874
- break;
1875
- }
1876
- return result;
1877
- }
1878
- }
1879
-
1880
- /**
1881
- * 解析已经取得的 pathname,避免重复构造 URL。
1882
- * Parse an existing pathname without constructing another URL.
1883
- * @param {string} pathname 以 / 开头的 URL pathname / URL pathname beginning with /.
1884
- * @returns {string[] | undefined} 解码后的路径,非 API 路径不处理 / Decoded path, or undefined outside /api/.
1885
- * @throws {TypeError} 转义编码或路径片段非法 / Invalid percent encoding or path segments.
1886
- */
1887
- function parseSettingsPathname(pathname) {
1888
- if (!pathname.startsWith("/api/")) return;
1889
- let parts;
1890
- try {
1891
- parts = pathname.slice(5).replace(/\/$/, "").split("/").map(decodeURIComponent);
1892
- } catch {
1893
- throw new TypeError("Invalid encoded key path");
1894
- }
1895
- return validatePathParts(parts);
1896
- }
1897
-
1898
- /**
1899
- * 校验原始路径片段,不进行 URL 编码转换。
1900
- * Validate raw path segments without URL encoding conversion.
1901
- * @param {string[]} parts 原始路径片段 / Raw path segments.
1902
- * @returns {string[]} 同一数组,不复制或修改 / The same array without copying or mutation.
1903
- * @throws {TypeError} 空片段、非法字符或原型属性名 / Empty segments, invalid characters or prototype property names.
1904
- */
1905
- function validatePathParts(parts) {
1906
- if (!parts.every(part => typeof part === "string" && /^[a-zA-Z0-9_-]+$/.test(part) && !["__proto__", "prototype", "constructor"].includes(part))) throw new TypeError("Invalid key path");
1907
- return parts;
1908
- }
1909
-
1910
- /**
1911
- * 按插件声明的根和模块桥接持久化存储,不下载或解析 BoxJS。
1912
- * Bridge persistence within the installed root and module without downloading or parsing BoxJS.
1913
- */
1914
- class SettingsHandler {
1915
- /** @type {string} 接管来源 / Handled origin. */
1916
- #origin;
1917
- /** @type {string} 安装配置中的存储根 / Storage root from installation config. */
1918
- #storageKey;
1919
- /** @type {string} 安装配置中的模块 / Module from installation config. */
1920
- #module;
1921
- /** @type {string} 页面标记头 / Page marker header. */
1922
- #requestHeader;
1923
-
1924
- /**
1925
- * 固定来源、存储根和模块,构造时不访问网络或存储。
1926
- * Fix the origin, storage root and module without network or storage access at construction.
1927
- * @param {import("./index.js").SettingsHandlerOptions} options 插件安装配置 / Plugin installation config.
1928
- * @throws {TypeError} 安装配置无效 / Invalid installation config.
1929
- */
1930
- constructor({ origin, storageKey, module, requestHeader = "X-Settings-Client" }) {
1931
- const target = new URL(origin);
1932
- if (target.protocol !== "https:" || target.pathname !== "/" || target.search || target.hash || target.username || target.password) throw new TypeError("origin must be an HTTPS origin");
1933
- if (typeof storageKey !== "string" || !storageKey || storageKey.startsWith("@")) throw new TypeError("storageKey must be a literal root key");
1934
- validatePathParts([module]);
1935
- if (!/^[a-z][a-z0-9-]*$/i.test(requestHeader)) throw new TypeError("Invalid requestHeader");
1936
- this.#origin = target.origin;
1937
- this.#storageKey = storageKey;
1938
- this.#module = module;
1939
- this.#requestHeader = requestHeader;
1940
- }
1941
-
1942
- /**
1943
- * GET 返回指定值,POST 替换指定值,DELETE 删除指定键或整个模块。
1944
- * GET returns a value, POST replaces it, and DELETE removes a key or the entire module.
1945
- * @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
1946
- * @returns {Promise<import("./index.js").SettingsResponse | undefined>} 响应或非接管请求 / Response, or undefined for an unhandled request.
1947
- */
1948
- async handle(request) {
1949
- const url = new URL(request.url);
1950
- if (url.origin !== this.#origin || !url.pathname.startsWith("/api/")) return;
1951
- const headers = { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" };
1952
- const reply = (status, data) => ({ status, headers, body: request.method === "HEAD" ? "" : JSON.stringify(data) });
1953
- let parts;
1954
- try {
1955
- parts = parseSettingsPathname(url.pathname);
1956
- } catch (error) {
1957
- return reply(400, { error: error.message });
1958
- }
1959
- if (parts[0] !== this.#module) return reply(404, { error: "Module is not handled" });
1960
- const requestHeaders = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
1961
- if (requestHeaders[this.#requestHeader.toLowerCase()] !== "1" || (requestHeaders.origin && requestHeaders.origin !== this.#origin)) return reply(403, { error: "Forbidden settings client" });
1962
- let value;
1963
- switch (request.method) {
1964
- case "HEAD":
1965
- return reply(200, undefined);
1966
- case "GET":
1967
- case "DELETE":
1968
- break;
1969
- case "POST":
1970
- if (requestHeaders["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/json") return reply(415, { error: "Expected application/json" });
1971
- if (typeof request.body !== "string") return reply(400, { error: "Expected a JSON string body" });
1972
- if (request.body.length > 65536) return reply(413, { error: "Body exceeds 65536 UTF-16 code units" });
1973
- try {
1974
- value = JSON.parse(request.body);
1975
- } catch {
1976
- return reply(400, { error: "Invalid JSON" });
1977
- }
1978
- break;
1979
- default:
1980
- return { ...reply(405, { error: "Method not allowed" }), headers: { ...headers, Allow: "HEAD, GET, POST, DELETE" } };
1981
- }
1982
- try {
1983
- const root = Storage.getItem(this.#storageKey, {});
1984
- if (!isRecord(root)) throw new TypeError("stored root must be an object");
1985
- const parent = storageParent(root, parts, request.method === "POST");
1986
- const key = parts.at(-1);
1987
- switch (request.method) {
1988
- case "GET": {
1989
- const result = parent ? Lodash.get(parent, [key]) : undefined;
1990
- return result === undefined ? reply(404, { error: "Stored path does not exist" }) : reply(200, result);
1991
- }
1992
- case "POST":
1993
- Lodash.set(parent, [key], value);
1994
- break;
1995
- case "DELETE":
1996
- if (parent) Lodash.unset(parent, [key]);
1997
- break;
1998
- }
1999
- if (!Storage.setItem(this.#storageKey, root)) throw new Error("Storage write failed");
2000
- return reply(200, request.method === "POST" ? { saved: true } : { deleted: true });
2001
- } catch (error) {
2002
- return reply(500, { error: error.message });
2003
- }
2004
- }
2005
- }
2006
-
2007
- /**
2008
- * 判断根节点是否为普通对象。
2009
- * Determine whether a root node is a plain object.
2010
- * @param {unknown} value 待检查值 / Value to inspect.
2011
- * @returns {boolean} 是否为普通对象 / Whether this is a plain object.
2012
- */
2013
- function isRecord(value) {
2014
- return value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype;
2015
- }
2016
-
2017
- /**
2018
- * 遍历父路径,兼容旧存储中 JSON 字符串形式的中间节点。
2019
- * Traverse parents, supporting legacy intermediate nodes serialized as JSON strings.
2020
- * @param {Record<string, unknown>} root 存储根 / Storage root.
2021
- * @param {string[]} parts 完整路径 / Complete path.
2022
- * @param {boolean} create 是否创建缺失节点 / Whether to create missing parents.
2023
- * @returns {object | undefined} 父节点,缺失且不创建时为 undefined / Parent, or undefined when absent and not creating.
2024
- * @throws {TypeError} 无法继续遍历标量节点 / A scalar node cannot be traversed.
2025
- */
2026
- function storageParent(root, parts, create) {
2027
- let parent = root;
2028
- for (const part of parts.slice(0, -1)) {
2029
- let next = Lodash.get(parent, [part]);
2030
- switch (typeof next) {
2031
- case "undefined":
2032
- if (!create) return;
2033
- next = {};
2034
- break;
2035
- case "string":
2036
- next = JSON.parse(next);
2037
- break;
2038
- }
2039
- if (!isRecord(next) && !Array.isArray(next)) throw new TypeError("Stored parent is not an object or array");
2040
- Lodash.set(parent, [part], next);
2041
- parent = next;
2042
- }
2043
- return parent;
2044
- }
2045
-
2046
- /**
2047
- * 统一处理存储 API 和无原生 Mock 平台的静态资源请求。
2048
- * Handle storage APIs and static resources on hosts without native Mock support.
2049
- */
2050
- class PreferencesHandler extends SettingsHandler {
2051
- #origin;
2052
- #resources;
2053
- /**
2054
- * 根据安装 JSON 配置资源映射,不执行项目自定义代码。
2055
- * Configure resource mappings from installation JSON without project-specific code.
2056
- * @param {import("./index.js").PreferencesHandlerOptions} options 安装映射 / Installation mapping.
2057
- */
2058
- constructor(options) {
2059
- super(options);
2060
- this.#origin = new URL(options.origin).origin;
2061
- this.#resources = options.resources.map(({ pattern, source, contentType }) => {
2062
- const url = new URL(source);
2063
- if (url.protocol !== "https:") throw new TypeError("Resource sources must use HTTPS");
2064
- if (typeof contentType !== "string" || /[\r\n]/.test(contentType)) throw new TypeError("Invalid resource content type");
2065
- return { pattern: new RegExp(pattern), source: url.href, contentType };
2066
- });
2067
- }
2068
- /**
2069
- * API 先交给存储桥接,只有资源路由才发出下载请求。
2070
- * Dispatch APIs to storage first; only resource routes perform downloads.
2071
- * @param {import("./index.js").SettingsRequest} request 宿主请求 / Host request.
2072
- * @returns {Promise<import("./index.js").SettingsResponse | undefined>} 响应或非接管请求 / Response or unhandled request.
2073
- */
2074
- async handle(request) {
2075
- const api = await super.handle(request);
2076
- if (api) return api;
2077
- const url = new URL(request.url);
2078
- if (url.origin !== this.#origin) return;
2079
- const resource = this.#resources.find(resource => resource.pattern.test(url.pathname));
2080
- if (!resource) return;
2081
- switch (request.method) {
2082
- case "GET":
2083
- case "HEAD": {
2084
- const response = await fetch({ url: resource.source, method: "GET", headers: { "Cache-Control": "no-cache" }, timeout: 5000 });
2085
- return { status: response.status, headers: { "Content-Type": `${resource.contentType}; charset=utf-8`, "Cache-Control": "no-store" }, body: request.method === "HEAD" ? "" : response.body };
2086
- }
2087
- default:
2088
- return { status: 405, headers: { Allow: "HEAD, GET" }, body: "" };
2089
- }
2090
- }
2091
- }
2092
-
2093
- /**
2094
- * 执行独立代理脚本;安装映射由托管站点生成,或由宿主参数提供。
2095
- * Run a standalone proxy script with a site-generated installation mapping or host arguments.
2096
- * @param {import("../index.js").PreferencesHandlerOptions} [options] 安装映射 / Installation mapping.
2097
- * @returns {Promise<void>} 已交给宿主的响应 / Response delivered to the proxy host.
2098
- */
2099
- async function runPreferences(options) {
2100
- let response;
2101
- try {
2102
- const config = options ?? qs.parse(globalThis.$argument);
2103
- const handler = new PreferencesHandler({ ...config, resources: config.resources ?? [] });
2104
- response = await handler.handle(globalThis.$request);
2105
- } catch (error) {
2106
- console.error(`PreferencePanes: ${error.message}`);
2107
- response = {
2108
- status: 500,
2109
- headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" },
2110
- body: globalThis.$request.method === "HEAD" ? "" : JSON.stringify({ error: "Settings execution failed" }),
2111
- };
2112
- }
2113
- if (!response) {
2114
- done({});
2115
- return;
2116
- }
2117
- done($app === "Quantumult X" ? response : { response });
2118
- }
2119
-
2120
- exports.runPreferences = runPreferences;
2121
-
2122
- return exports;
2
+ 'use strict';
3
+
4
+ class URLSearchParams {
5
+ constructor(params, onUpdate) {
6
+ switch (typeof params) {
7
+ case "string": {
8
+ if (params.length === 0)
9
+ break;
10
+ if (params.startsWith("?"))
11
+ params = params.slice(1);
12
+ const pairs = params.split("&").map(pair => {
13
+ const separator = pair.indexOf("=");
14
+ return separator < 0 ? [pair, ""] : [pair.slice(0, separator), pair.slice(separator + 1)];
15
+ });
16
+ pairs.forEach(([key, value]) => {
17
+ this.#params.push(key ? this.#decodeQueryComponent(key) : key);
18
+ this.#values.push(this.#decodeQueryComponent(value));
19
+ });
20
+ break;
21
+ }
22
+ case "object":
23
+ if (Array.isArray(params)) {
24
+ Object.entries(params).forEach(([key, value]) => {
25
+ this.#params.push(key);
26
+ this.#values.push(value);
27
+ });
28
+ }
29
+ else if (Symbol.iterator in Object(params)) {
30
+ for (const [key, value] of params) {
31
+ this.#params.push(key);
32
+ this.#values.push(value);
33
+ }
34
+ }
35
+ break;
36
+ }
37
+ this.#updateSearchString(this.#params, this.#values);
38
+ this.#onUpdate = onUpdate;
39
+ }
40
+ // Create 2 seperate arrays for the params and values to make management and lookup easier.
41
+ #param = "";
42
+ #params = [];
43
+ #values = [];
44
+ #onUpdate;
45
+ #decodeQueryComponent(str) {
46
+ return decodeURIComponent(str.replace(/\+/g, " "));
47
+ }
48
+ #encodeQueryComponent(str) {
49
+ return encodeURIComponent(str)
50
+ .replace(/%20/g, "+")
51
+ .replace(/[!'()~]/g, character => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
52
+ }
53
+ // Update the search property of the URL instance with the new params and values.
54
+ #updateSearchString(params, values) {
55
+ if (params.length === 0)
56
+ this.#param = "";
57
+ else
58
+ this.#param = params
59
+ .map((param, index) => {
60
+ switch (typeof values[index]) {
61
+ case "object":
62
+ return `${this.#encodeQueryComponent(param)}=${this.#encodeQueryComponent(JSON.stringify(values[index]))}`;
63
+ case "boolean":
64
+ case "number":
65
+ case "string":
66
+ return `${this.#encodeQueryComponent(param)}=${this.#encodeQueryComponent(values[index])}`;
67
+ case "undefined":
68
+ default:
69
+ return this.#encodeQueryComponent(param);
70
+ }
71
+ })
72
+ .join("&");
73
+ this.#onUpdate?.(this.#param);
74
+ }
75
+ // Add a given param with a given value to the end.
76
+ append(name, value) {
77
+ this.#params.push(name);
78
+ this.#values.push(value);
79
+ this.#updateSearchString(this.#params, this.#values);
80
+ }
81
+ // Remove all occurances of a given param
82
+ delete(name, value) {
83
+ while (this.#params.indexOf(name) > -1) {
84
+ this.#values.splice(this.#params.indexOf(name), 1);
85
+ this.#params.splice(this.#params.indexOf(name), 1);
86
+ }
87
+ this.#updateSearchString(this.#params, this.#values);
88
+ }
89
+ // Return an array to be structured in this way: [[param1, value1], [param2, value2]] to mimic the native method's ES6 iterator.
90
+ entries() {
91
+ return this.#params.map((param, index) => [param, this.#values[index]]);
92
+ }
93
+ // Return the value matched to the first occurance of a given param.
94
+ get(name) {
95
+ return this.#values[this.#params.indexOf(name)];
96
+ }
97
+ // Return all values matched to all occurances of a given param.
98
+ getAll(name) {
99
+ return this.#values.filter((value, index) => this.#params[index] === name);
100
+ }
101
+ // Return a boolean to indicate whether a given param exists.
102
+ has(name, value) {
103
+ return this.#params.indexOf(name) > -1;
104
+ }
105
+ // Return an array of the param names to mimic the native method's ES6 iterator.
106
+ keys() {
107
+ return this.#params;
108
+ }
109
+ // Set a given param to a given value.
110
+ set(name, value) {
111
+ if (this.#params.indexOf(name) === -1) {
112
+ this.append(name, value); // If the given param doesn't already exist, append it.
113
+ }
114
+ else {
115
+ let first = true;
116
+ const newValues = [];
117
+ // If the param already exists, change the value of the first occurance and remove any remaining occurances.
118
+ this.#params = this.#params.filter((currentParam, index) => {
119
+ if (currentParam !== name) {
120
+ newValues.push(this.#values[index]);
121
+ return true;
122
+ // If the currentParam matches the one being changed and it's the first one, keep the param and change its value to the given one.
123
+ }
124
+ else if (first) {
125
+ first = false;
126
+ newValues.push(value);
127
+ return true;
128
+ }
129
+ // If the currentParam matches the one being changed, but it's not the first, remove it.
130
+ return false;
131
+ });
132
+ this.#values = newValues;
133
+ this.#updateSearchString(this.#params, this.#values);
134
+ }
135
+ }
136
+ // Sort all key/value pairs, if any, by their keys then by their values.
137
+ sort() {
138
+ // Call entries to make sorting easier, then rewrite the params and values in the new order.
139
+ const sortedPairs = this.entries().sort();
140
+ this.#params = [];
141
+ this.#values = [];
142
+ sortedPairs.forEach(pair => {
143
+ this.#params.push(pair[0]);
144
+ this.#values.push(pair[1]);
145
+ });
146
+ this.#updateSearchString(this.#params, this.#values);
147
+ }
148
+ // Return the search string without the '?'.
149
+ toString = () => this.#param;
150
+ // Return and array of the param values to mimic the native method's ES6 iterator..
151
+ values = () => this.#values.values();
152
+ }
153
+
154
+ class URL {
155
+ constructor(url, base) {
156
+ switch (typeof url) {
157
+ case "string": {
158
+ const urlIsValid = /^(blob:|file:)?[a-zA-z]+:\/\/.*/.test(url);
159
+ const baseIsValid = base ? /^(blob:|file:)?[a-zA-z]+:\/\/.*/.test(base) : false;
160
+ // If a string is passed for url instead of location or link, then set the properties of the URL instance.
161
+ if (urlIsValid)
162
+ this.href = url;
163
+ // If the url isn't valid, but the base is, then prepend the base to the url.
164
+ else if (baseIsValid)
165
+ this.href = base + url;
166
+ // If no valid url or base is given, then throw a type error.
167
+ else
168
+ throw new TypeError('URL string is not valid. If using a relative url, a second argument needs to be passed representing the base URL. Example: new URL("relative/path", "http://www.example.com");');
169
+ break;
170
+ }
171
+ case "object":
172
+ break;
173
+ default:
174
+ throw new TypeError("Invalid argument type.");
175
+ }
176
+ }
177
+ #url = {
178
+ hash: "",
179
+ host: "",
180
+ hostname: "",
181
+ href: "",
182
+ password: "",
183
+ pathname: "",
184
+ port: Number.NaN,
185
+ protocol: "",
186
+ search: "",
187
+ searchParams: new URLSearchParams(""),
188
+ username: "",
189
+ };
190
+ // refer: http://www.ietf.org/rfc/rfc3986.txt
191
+ static #URLRegExp = /^(?<scheme>([^:\/?#]+):)?(?:\/\/(?<authority>[^\/?#]*))?(?<path>[^?#]*)(?<query>\?([^#]*))?(?<hash>#(.*))?$/;
192
+ static #AuthorityRegExp = /^(?<authentication>(?<username>[^:]*)(:(?<password>[^@]*))?@)?(?<hostname>[^:]+)(:(?<port>\d+))?$/;
193
+ get hash() {
194
+ return this.#url.hash;
195
+ }
196
+ set hash(value) {
197
+ if (value.length !== 0) {
198
+ if (value.startsWith("#"))
199
+ value = value.slice(1);
200
+ this.#url.hash = `#${encodeURIComponent(value)}`;
201
+ }
202
+ }
203
+ get host() {
204
+ return this.port.length > 0 ? `${this.hostname}:${this.port}` : this.hostname;
205
+ }
206
+ set host(value) {
207
+ [this.hostname, this.port] = value.split(":", 2);
208
+ }
209
+ get hostname() {
210
+ return encodeURIComponent(this.#url.hostname);
211
+ }
212
+ set hostname(value) {
213
+ this.#url.hostname = value ?? "";
214
+ }
215
+ get href() {
216
+ let authority = "";
217
+ if (this.username.length > 0) {
218
+ authority += this.username;
219
+ if (this.password.length > 0)
220
+ authority += `:${this.password}`;
221
+ authority += "@";
222
+ }
223
+ return `${this.protocol}//${authority}${this.host}${this.pathname}${this.search}${this.hash}`;
224
+ }
225
+ set href(value) {
226
+ if (value.startsWith("blob:") || value.startsWith("file:"))
227
+ value = value.slice(5);
228
+ const urlMatch = value.match(URL.#URLRegExp);
229
+ if (!urlMatch)
230
+ throw new TypeError("Invalid URL format.");
231
+ this.protocol = urlMatch.groups.scheme ?? "";
232
+ const authorityMatch = urlMatch.groups.authority.match(URL.#AuthorityRegExp);
233
+ this.username = authorityMatch.groups.username ?? "";
234
+ this.password = authorityMatch.groups.password ?? "";
235
+ this.hostname = authorityMatch.groups.hostname ?? "";
236
+ this.port = authorityMatch.groups.port ?? "";
237
+ this.pathname = urlMatch.groups.path ?? "";
238
+ this.search = urlMatch.groups.query ?? "";
239
+ this.hash = urlMatch.groups.hash ?? "";
240
+ }
241
+ get origin() {
242
+ return `${this.protocol}//${this.host}`;
243
+ }
244
+ get password() {
245
+ return encodeURIComponent(this.#url.password);
246
+ }
247
+ set password(value) {
248
+ if (this.username.length > 0)
249
+ this.#url.password = value ?? "";
250
+ }
251
+ get pathname() {
252
+ return `/${this.#url.pathname}`;
253
+ }
254
+ set pathname(value) {
255
+ value = `${value}`;
256
+ if (value.startsWith("/"))
257
+ value = value.slice(1);
258
+ this.#url.pathname = value;
259
+ }
260
+ get port() {
261
+ if (Number.isNaN(this.#url.port))
262
+ return "";
263
+ const port = this.#url.port.toString();
264
+ if (this.protocol === "ftp:" && port === "21")
265
+ return "";
266
+ if (this.protocol === "http:" && port === "80")
267
+ return "";
268
+ if (this.protocol === "https:" && port === "443")
269
+ return "";
270
+ return port;
271
+ }
272
+ set port(value) {
273
+ switch (value) {
274
+ case "":
275
+ this.#url.port = Number.NaN;
276
+ break;
277
+ default: {
278
+ const port = Number.parseInt(value, 10);
279
+ if (port >= 0 && port < 65535)
280
+ this.#url.port = port;
281
+ }
282
+ }
283
+ }
284
+ get protocol() {
285
+ return `${this.#url.protocol}:`;
286
+ }
287
+ set protocol(value) {
288
+ if (value.endsWith(":"))
289
+ value = value.slice(0, -1);
290
+ this.#url.protocol = value;
291
+ }
292
+ get search() {
293
+ if (this.#url.search.length > 0)
294
+ return `?${this.#url.search}`;
295
+ else
296
+ return "";
297
+ }
298
+ set search(value) {
299
+ value = `${value}`;
300
+ if (value.startsWith("?"))
301
+ value = value.slice(1);
302
+ this.#url.search = value;
303
+ this.#url.searchParams = new URLSearchParams(this.#url.search, search => {
304
+ this.#url.search = search;
305
+ });
306
+ }
307
+ get searchParams() {
308
+ return this.#url.searchParams;
309
+ }
310
+ get username() {
311
+ return encodeURIComponent(this.#url.username);
312
+ }
313
+ set username(value) {
314
+ this.#url.username = value ?? "";
315
+ }
316
+ static parse = (url, base) => new URL(url, base);
317
+ /**
318
+ * Returns the string representation of the URL.
319
+ *
320
+ * @returns {string} The href of the URL.
321
+ */
322
+ toString = () => this.href;
323
+ /**
324
+ * Converts the URL object properties to a JSON string.
325
+ *
326
+ * @returns {string} A JSON string representation of the URL object.
327
+ */
328
+ toJSON = () => JSON.stringify({
329
+ hash: this.hash,
330
+ host: this.host,
331
+ hostname: this.hostname,
332
+ href: this.href,
333
+ origin: this.origin,
334
+ password: this.password,
335
+ pathname: this.pathname,
336
+ port: this.port,
337
+ protocol: this.protocol,
338
+ search: this.search,
339
+ searchParams: this.searchParams,
340
+ username: this.username,
341
+ });
342
+ }
343
+
344
+ var assets = {"page":{"type":"text/html","body":"<!doctype html>\n<html lang=\"zh-CN\">\n <head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1,viewport-fit=cover\">\n <meta name=\"color-scheme\" content=\"light dark\">\n <title>Module Preferences</title>\n <style>body { margin: 0; }</style>\n </head>\n <body>\n <main id=\"preferences\"></main>\n <script type=\"module\" src=\"/settings/assets/app.mjs?v=0.7.0\"></script>\n </body>\n</html>\n"},"/settings/assets/app.mjs":{"type":"text/javascript","body":"/**\n * 解析已经取得的 pathname,避免重复构造 URL。\n * Parse an existing pathname without constructing another URL.\n * @param {string} pathname 以 / 开头的 URL pathname / URL pathname beginning with /.\n * @returns {string[] | undefined} 解码后的路径,非 API 路径不处理 / Decoded path, or undefined outside /api/.\n * @throws {TypeError} 转义编码或路径片段非法 / Invalid percent encoding or path segments.\n */\n\n/**\n * 校验原始路径片段,不进行 URL 编码转换。\n * Validate raw path segments without URL encoding conversion.\n * @param {string[]} parts 原始路径片段 / Raw path segments.\n * @returns {string[]} 同一数组,不复制或修改 / The same array without copying or mutation.\n * @throws {TypeError} 空片段、非法字符或原型属性名 / Empty segments, invalid characters or prototype property names.\n */\nfunction validatePathParts(parts) {\n if (!parts.every(part => typeof part === \"string\" && /^[a-zA-Z0-9_-]+$/.test(part) && ![\"__proto__\", \"prototype\", \"constructor\"].includes(part))) throw new TypeError(\"Invalid key path\");\n return parts;\n}\n\n/**\n * BoxJS 的共同目录:模块、存储根和展示元数据都来自同一份 JSON。\n * Shared BoxJS catalog deriving modules, storage roots and metadata from one JSON document.\n */\nclass BoxJS {\n /**\n * 建立路径索引,不解析控件类型,也不读写持久化存储。\n * Index field paths without interpreting controls or accessing persistence.\n * @param {unknown} input 字段数组、单个 app 或 apps 订阅 / Field array, app or apps subscription.\n */\n constructor(input) {\n if (!input || typeof input !== \"object\") throw new TypeError(\"Expected BoxJS JSON\");\n this.document = JSON.parse(JSON.stringify(input));\n const apps = Array.isArray(this.document) ? [{ settings: this.document }] : (this.document.apps ?? [this.document]);\n if (!Array.isArray(apps)) throw new TypeError(\"Expected BoxJS apps array\");\n this.modules = new Map();\n for (const app of apps) {\n if (!app || !Array.isArray(app.settings)) throw new TypeError(\"Expected BoxJS settings array\");\n for (const entry of app.settings) {\n if (typeof entry.id !== \"string\") throw new TypeError(\"BoxJS settings require string IDs\");\n if (!entry.id.startsWith(\"@\")) {\n if (Array.isArray(this.document)) throw new TypeError(\"BoxJS settings require @root.path IDs\");\n continue;\n }\n const [storageKey, ...parts] = entry.id.slice(1).split(\".\");\n if (!storageKey || storageKey.startsWith(\"@\") || parts.length < 2) throw new TypeError(\"A BoxJS setting must be below a literal storage root and module\");\n validatePathParts(parts);\n const module = parts[0];\n let target = this.modules.get(module);\n if (!target) {\n target = { module, storageKey, entries: [], owners: new Set() };\n this.modules.set(module, target);\n }\n if (target.storageKey !== storageKey) throw new TypeError(`A module must use one storage root: ${module}`);\n target.entries.push(entry);\n target.owners.add(app);\n }\n }\n this.metadata = metadata(Array.isArray(this.document) ? {} : this.document);\n for (const target of this.modules.values()) target.metadata = target.owners.size === 1 ? metadata([...target.owners][0]) : {};\n }\n\n /**\n * 提取一个模块的原生 BoxJS,保留所属 app 的元数据。\n * Select a module's native BoxJS while retaining owning-app metadata.\n * @param {string} module 模块标识 / Module identifier.\n * @returns {unknown} 可直接用作配置 Mock 的 JSON / JSON suitable for a configuration Mock.\n */\n select(module) {\n const target = this.modules.get(module);\n if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);\n if (Array.isArray(this.document)) return target.entries;\n const apps = [...target.owners].map(app => ({ ...app, settings: app.settings.filter(entry => target.entries.includes(entry)) }));\n return this.document.apps ? { ...this.document, apps } : apps[0];\n }\n\n /**\n * 取得本次导入的唯一模块,避免把模块数据变成项目目录。\n * Get the single imported module without turning module data into a project directory.\n * @returns {object} 唯一模块的目录项 / The single module entry.\n */\n get module() {\n if (this.modules.size !== 1) throw new TypeError(\"Import BoxJS JSON for exactly one module\");\n return this.modules.values().next().value;\n }\n}\n\n/**\n * 保留标准 BoxJS 展示信息;script 仅为元数据,不执行。\n * Retain standard BoxJS presentation data; script is metadata only and never executed.\n * @param {object} source BoxJS app 或订阅 / BoxJS app or subscription.\n * @returns {object} 经过类型检查的展示信息 / Type-checked presentation metadata.\n */\nfunction metadata(source) {\n const result = {};\n for (const key of [\"id\", \"name\", \"author\", \"repo\", \"script\", \"icon\", \"description\", \"desc\", \"icons\", \"descs\"]) {\n if (source[key] === undefined) continue;\n const multiple = key === \"icons\" || key === \"descs\";\n const values = multiple ? source[key] : [source[key]];\n if (!Array.isArray(values) || values.some(item => typeof item !== \"string\")) throw new TypeError(`Invalid BoxJS app ${key}`);\n result[key] = multiple ? [...values] : source[key];\n }\n return result;\n}\n\n/**\n * 创建元素,所有展示文本通过 textContent 写入。\n * Create elements and assign display text through textContent only.\n * @template {keyof HTMLElementTagNameMap} T\n * @param {T} tag 元素标签 / Element tag.\n * @param {string} className 样式类名 / CSS class.\n * @param {string} [text] 纯文本 / Plain text.\n * @returns {HTMLElementTagNameMap[T]} 创建的元素 / Created element.\n */\nfunction element(tag, className, text) {\n const node = document.createElement(tag);\n node.className = className;\n if (text !== undefined) node.textContent = text;\n return node;\n}\n\n/**\n * 元数据地址只允许 HTTP(S) 和相对地址。\n * Allow only HTTP(S) and relative metadata addresses.\n * @param {string} value 元数据地址 / Metadata address.\n * @returns {string} 完整地址 / Absolute address.\n */\nfunction resourceURL(value) {\n const url = new URL(value, location.href);\n if (![\"http:\", \"https:\"].includes(url.protocol)) throw new TypeError(\"Metadata URLs must use HTTP(S)\");\n return url.href;\n}\n\n/**\n * 展示标准 BoxJS 图标;icons 保持透明/彩色语义,不解释为亮暗版本。\n * Display standard BoxJS icons, preserving transparent/color rather than light/dark semantics.\n * @param {import(\"../index.js\").BoxJSMetadata} metadata 展示信息 / Presentation metadata.\n * @param {string} className 样式 / CSS class.\n * @returns {HTMLImageElement | null} 图标或无图标 / Icon or no icon.\n */\nfunction icon(metadata, className) {\n const source = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];\n if (!source) return null;\n const image = element(\"img\", className);\n image.src = resourceURL(source);\n image.alt = \"\";\n return image;\n}\n\n/**\n * 共享加载失败视图,不创建配置表单或数据读取。\n * Share a load-error view without creating controls or reading settings.\n * @param {Error} error 失败原因 / Failure reason.\n * @param {() => unknown} retry 重试动作 / Retry action.\n * @returns {HTMLElement} 错误视图 / Error view.\n */\nfunction errorView(error, retry) {\n const view = element(\"section\", \"pp-error\");\n const button = element(\"button\", \"\", \"重新读取\");\n button.type = \"button\";\n button.onclick = retry;\n view.append(element(\"p\", \"\", `加载失败:${error.message}`), button);\n return view;\n}\n\nvar defaults = \"/* 分组列表沿用 Bilibili 设置页的行结构,样式限定在面板内。\\n * Grouped rows follow the Bilibili settings layout, scoped to the panel. */\\n.pp-panel {\\n --pp-text: #18191c;\\n --pp-background: #f6f7f8;\\n --pp-surface: #fff;\\n --pp-border: #e3e5e7;\\n --pp-muted: #9499a0;\\n --pp-accent: #fb7299;\\n font:\\n 15px / 1.5 -apple-system,\\n BlinkMacSystemFont,\\n \\\"Segoe UI\\\",\\n sans-serif;\\n color: var(--pp-text);\\n background: var(--pp-background);\\n position: relative;\\n min-height: 100vh;\\n}\\n.pp-panel * {\\n box-sizing: border-box;\\n letter-spacing: 0;\\n}\\n.pp-header {\\n height: calc(52px + env(safe-area-inset-top));\\n padding: env(safe-area-inset-top) 12px 0;\\n display: flex;\\n align-items: center;\\n background: var(--pp-surface);\\n border-bottom: 1px solid var(--pp-border);\\n position: relative;\\n}\\n.pp-title {\\n font-size: 17px;\\n font-weight: 500;\\n margin: 0;\\n min-width: 0;\\n overflow-wrap: anywhere;\\n}\\n.pp-header .pp-title {\\n flex: 1;\\n text-align: center;\\n}\\n.pp-nav-spacer {\\n width: 44px;\\n flex: none;\\n}\\n.pp-panel button {\\n font: inherit;\\n cursor: pointer;\\n border: 0;\\n background: none;\\n color: inherit;\\n}\\n.pp-panel .pp-back {\\n width: 44px;\\n height: 44px;\\n flex: none;\\n font-size: 34px;\\n line-height: 32px;\\n padding: 0;\\n}\\n.pp-panel button:disabled {\\n opacity: 0.5;\\n cursor: wait;\\n}\\n.pp-viewport {\\n position: relative;\\n height: calc(100vh - 52px - env(safe-area-inset-top));\\n overflow: hidden;\\n}\\n@supports (height: 100dvh) {\\n .pp-viewport {\\n height: calc(100dvh - 52px - env(safe-area-inset-top));\\n }\\n}\\n.pp-fields,\\n.pp-choice-page {\\n position: absolute;\\n inset: 0;\\n overflow: auto;\\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom));\\n background: var(--pp-background);\\n}\\n.pp-panel .form-group {\\n margin: 0 0 12px;\\n}\\n.pp-panel .form-group__title {\\n font-size: 12px;\\n line-height: 17px;\\n font-weight: 400;\\n color: var(--pp-muted);\\n padding-left: 12px;\\n margin: 12px 0 6px;\\n}\\n.pp-panel .form-group__row {\\n border-radius: 8px;\\n overflow: hidden;\\n background: var(--pp-surface);\\n}\\n.pp-panel .form-row {\\n position: relative;\\n display: flex;\\n align-items: center;\\n width: 100%;\\n min-height: 46px;\\n padding: 12px;\\n border: 0;\\n border-bottom: 1px solid var(--pp-border);\\n background: var(--pp-surface);\\n gap: 12px;\\n}\\n.pp-panel .form-row:last-child {\\n border-bottom: 0;\\n}\\n.pp-panel .form-row__text {\\n flex: 1;\\n min-width: 0;\\n margin: 0;\\n display: flex;\\n flex-direction: column;\\n}\\n.pp-panel .form-row__title {\\n font-size: 15px;\\n line-height: 22px;\\n color: var(--pp-text);\\n text-align: left;\\n}\\n.pp-panel .form-row__subtitle {\\n font-size: 12px;\\n line-height: 18px;\\n color: var(--pp-muted);\\n overflow-wrap: anywhere;\\n margin-top: 2px;\\n}\\n.pp-choice-link {\\n display: flex;\\n align-items: center;\\n justify-content: flex-end;\\n gap: 8px;\\n max-width: 45%;\\n min-width: 44px;\\n min-height: 44px;\\n padding: 0;\\n text-align: right;\\n flex: 1;\\n}\\n.pp-summary {\\n color: var(--pp-muted);\\n font-size: 13px;\\n line-height: 18px;\\n display: -webkit-box;\\n -webkit-line-clamp: 2;\\n -webkit-box-orient: vertical;\\n overflow: hidden;\\n overflow-wrap: anywhere;\\n}\\n.pp-chevron {\\n color: var(--pp-muted);\\n font-size: 22px;\\n flex: none;\\n}\\n.pp-input {\\n font: inherit;\\n color: var(--pp-text);\\n background: var(--pp-surface);\\n border: 1px solid var(--pp-border);\\n border-radius: 6px;\\n padding: 8px;\\n min-width: 0;\\n max-width: 45%;\\n width: 45%;\\n}\\nselect.pp-input {\\n text-overflow: ellipsis;\\n font-size: 13px;\\n}\\n.pp-panel .pp-multiline {\\n display: block;\\n}\\n.pp-multiline .pp-input {\\n max-width: 100%;\\n width: 100%;\\n margin-top: 10px;\\n}\\n.pp-switch {\\n appearance: none;\\n -webkit-appearance: none;\\n position: relative;\\n flex: none;\\n width: 32px;\\n height: 20px;\\n max-width: none;\\n border: 0;\\n border-radius: 15px;\\n padding: 0;\\n background: #c9ccd0;\\n cursor: pointer;\\n transition: background 0.2s;\\n}\\n.pp-switch::before {\\n content: \\\"\\\";\\n position: absolute;\\n top: 3px;\\n left: 3px;\\n width: 14px;\\n height: 14px;\\n border-radius: 50%;\\n background: white;\\n transition: transform 0.2s;\\n}\\n.pp-switch:checked {\\n background: var(--pp-accent);\\n}\\n.pp-switch:checked::before {\\n transform: translateX(12px);\\n}\\n.pp-choice {\\n justify-content: space-between;\\n cursor: pointer;\\n}\\n.pp-choice input {\\n width: 20px;\\n height: 20px;\\n flex: none;\\n accent-color: var(--pp-accent);\\n margin: 0;\\n}\\n.pp-description {\\n font-size: 12px;\\n line-height: 1.6;\\n color: var(--pp-muted);\\n white-space: pre-wrap;\\n overflow-wrap: anywhere;\\n}\\n.pp-module-info {\\n display: flex;\\n gap: 12px;\\n margin: 12px 0;\\n}\\n.pp-module-icon {\\n width: 48px;\\n height: 48px;\\n object-fit: contain;\\n flex: none;\\n}\\n.pp-module-details {\\n min-width: 0;\\n overflow-wrap: anywhere;\\n}\\n.pp-module-source {\\n color: inherit;\\n text-decoration: underline;\\n}\\n.pp-maintenance {\\n margin-top: 24px;\\n}\\n.pp-actions {\\n display: flex;\\n flex-wrap: wrap;\\n gap: 8px;\\n}\\n.pp-actions button,\\n.pp-error button {\\n min-height: 44px;\\n padding: 8px 12px;\\n border-radius: 6px;\\n background: var(--pp-surface);\\n}\\n.pp-panel .pp-danger {\\n color: #e45656;\\n}\\n.pp-cache {\\n max-height: 320px;\\n overflow: auto;\\n white-space: pre-wrap;\\n overflow-wrap: anywhere;\\n}\\n.pp-toast {\\n pointer-events: none;\\n position: fixed;\\n bottom: calc(30px + env(safe-area-inset-bottom));\\n left: 50%;\\n transform: translateX(-50%);\\n max-width: 90vw;\\n padding: 10px 16px;\\n border-radius: 8px;\\n background: #333e;\\n color: white;\\n font-size: 13px;\\n z-index: 20;\\n}\\n.pp-toast[data-kind=\\\"error\\\"] {\\n background: #8d2424;\\n}\\n.pp-panel :focus-visible {\\n outline: 2px solid var(--pp-accent);\\n outline-offset: -2px;\\n}\\n@media (prefers-color-scheme: dark) {\\n .pp-panel {\\n --pp-text: #e3e5e7;\\n --pp-background: #17181a;\\n --pp-surface: #232427;\\n --pp-border: #343538;\\n }\\n}\\n:root[data-theme=\\\"dark\\\"] .pp-panel {\\n --pp-text: #e3e5e7;\\n --pp-background: #17181a;\\n --pp-surface: #232427;\\n --pp-border: #343538;\\n}\\n:root[data-theme=\\\"light\\\"] .pp-panel {\\n --pp-text: #18191c;\\n --pp-background: #f6f7f8;\\n --pp-surface: #fff;\\n --pp-border: #e3e5e7;\\n}\\n@media (prefers-reduced-motion: reduce) {\\n .pp-panel .pp-switch,\\n .pp-panel .pp-switch::before {\\n transition: none;\\n }\\n}\\n\";\n\n/**\n * 将 BoxJS 数组、app 或订阅转换为模块字段,保留原文件为唯一字段来源。\n * Normalize a BoxJS array, app or subscription using the source JSON as the field authority.\n * @param {unknown} config BoxJS JSON / BoxJS document.\n * @param {string} module API 第一段模块名 / First API path segment.\n * @returns {import(\"../index.js\").ModuleDefinition} 存储根和字段 / Storage root and fields.\n * @throws {TypeError} 配置结构、字段路径、默认值或展示属性无效 / Invalid configuration, field path, default or presentation attribute.\n */\nfunction normalizeBoxJs(config, module) {\n validatePathParts([module]);\n const target = new BoxJS(config).modules.get(module);\n if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);\n const { entries, storageKey, metadata } = target;\n const fields = [];\n for (const entry of entries) {\n const parts = entry.id.slice(1).split(\".\").slice(1);\n const type = { boolean: \"boolean\", checkboxes: \"array\", selects: \"select\", text: \"string\", textarea: \"string\", number: \"number\" }[entry.type];\n if (!type) throw new TypeError(`Unsupported BoxJS control: ${entry.type}`);\n const field = {\n key: parts.join(\".\"),\n type: type === \"select\" ? typeof entry.val : type,\n\n name: entry.name,\n description: entry.desc ?? \"\",\n control: entry.type,\n ...(entry.placeholder === undefined ? {} : { placeholder: entry.placeholder }),\n ...(entry.rows === undefined ? {} : { rows: entry.rows }),\n ...(entry.autoGrow === undefined ? {} : { autoGrow: entry.autoGrow }),\n };\n if (type === \"select\" && ![\"string\", \"number\", \"boolean\"].includes(field.type)) throw new TypeError(`Select requires a scalar val: ${entry.id}`);\n if (entry.items) field.options = entry.items.map(item => ({ key: item.key, label: item.label }));\n if (Object.hasOwn(entry, \"val\")) field.defaultValue = normalizeStoredValue(field, entry.val);\n if (\n typeof field.name !== \"string\" ||\n (field.placeholder !== undefined && typeof field.placeholder !== \"string\") ||\n (field.rows !== undefined && (!Number.isInteger(field.rows) || field.rows < 1)) ||\n (field.autoGrow !== undefined && typeof field.autoGrow !== \"boolean\") ||\n fields.some(other => other.key === field.key || other.key.startsWith(`${field.key}.`) || field.key.startsWith(`${other.key}.`))\n )\n throw new TypeError(`Invalid or overlapping BoxJS field: ${entry.id}`);\n if (field.options && (new Set(field.options.map(item => item.key)).size !== field.options.length || field.options.some(item => !scalar(item.key) || typeof item.label !== \"string\"))) throw new TypeError(`Invalid options: ${entry.id}`);\n if (Object.hasOwn(field, \"defaultValue\") && !validValue(field, field.defaultValue)) throw new TypeError(`Invalid BoxJS val: ${entry.id}`);\n fields.push(field);\n }\n if (!fields.length) throw new TypeError(`No BoxJS settings for module: ${module}`);\n const common = fields[0].key.split(\".\").slice(0, -1);\n for (const field of fields) while (!field.key.startsWith(`${common.join(\".\")}.`)) common.pop();\n return {\n module,\n storageKey,\n fields,\n settingsPath: common,\n ...(Object.keys(metadata).length ? { metadata } : {}),\n };\n}\n\n/**\n * 归一化 BoxJS 的字符串存储值,不改变普通文本内容。\n * Normalize BoxJS string persistence without changing free-text values.\n * @param {import(\"../index.js\").SettingsField} field 前端字段约束 / Frontend field constraints.\n * @param {unknown} value 存储值 / Stored value.\n * @returns {unknown} 转换后的控件值;是否允许写入由 validValue 单独校验 / Converted control value; write eligibility is checked separately by validValue.\n */\nfunction normalizeStoredValue(field, value) {\n switch (field.type) {\n case \"boolean\":\n if (value === \"true\" || value === \"false\") return value === \"true\";\n break;\n case \"number\":\n if (typeof value === \"string\" && value.trim() !== \"\") return Number(value);\n break;\n case \"array\":\n if (typeof value === \"string\") value = value === \"\" || value === \"[]\" ? [] : value.split(\",\");\n break;\n }\n if (field.options) {\n const match = item => field.options.find(option => String(option.key) === String(item))?.key ?? item;\n return field.type === \"array\" && Array.isArray(value) ? value.map(match) : match(value);\n }\n return value;\n}\n\n/**\n * 校验支持的标量范围,包括文本长度与数值有限性。\n * Validate supported scalar bounds, including text length and numeric finiteness.\n * @param {unknown} value 待检查值 / Value to inspect.\n * @returns {boolean} 是否为有效标量 / Whether the scalar is valid.\n */\nfunction scalar(value) {\n switch (typeof value) {\n case \"boolean\":\n return true;\n case \"string\":\n return value.length <= 2048;\n case \"number\":\n return Number.isFinite(value);\n default:\n return false;\n }\n}\n\n/**\n * 检查值类型、数组唯一性及声明的选项,不进行转换。\n * Check value type, array uniqueness and declared choices without coercion.\n * @param {import(\"../index.js\").SettingsField} field 前端归一化字段 / Normalized frontend field.\n * @param {unknown} value 待写入的 JSON 值 / JSON value to write.\n * @returns {boolean} 是否符合字段约束 / Whether the value satisfies field constraints.\n */\nfunction validValue(field, value) {\n if (field.type === \"array\") {\n if (!Array.isArray(value) || value.some(item => !scalar(item)) || new Set(value).size !== value.length) return false;\n } else if (typeof value !== field.type || !scalar(value)) return false;\n return !field.options || (field.type === \"array\" ? value : [value]).every(item => field.options.some(option => option.key === item));\n}\n\n/**\n * 单个模块的临时会话;离开页面后丢弃。\n * Transient module session discarded when leaving the page.\n * @typedef {object} ModuleSession\n * @property {AbortController} controller 读取请求的取消控制器 / Abort controller for reads.\n * @property {import(\"../index.js\").ModuleDefinition | null} definition 加载完成的配置,加载中为 null / Loaded configuration, or null while loading.\n * @property {import(\"./client.mjs\").ModuleSnapshot[\"values\"]} values 当前显示值 / Current display values.\n * @property {boolean} saving 是否正在写入 / Whether a mutation is in progress.\n */\n\n/**\n * 创建页面会话缓存;打开时重读,选项操作仅在 HTTP 200 后更新缓存。\n * Create a page-session cache; reload on open and mutate cache only after HTTP 200.\n * @param {import(\"./client.mjs\").PreferencesClientOptions} options 包内目录、请求与通知 / Internal catalog, requests and notifications.\n * @returns {import(\"./client.mjs\").PreferencesClient} 通用客户端 / Generic client.\n */\nfunction createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {\n /**\n * 模块会话表\n * Module session map.\n * @type {Map<string, ModuleSession>}\n */\n const sessions = new Map();\n /**\n * 发送同源请求,处理超时与取消;数据 GET 的 404 交给调用方处理。\n * Send a same-origin request with timeout and cancellation; callers handle missing-data GET responses.\n * @param {string} path 相对请求路径 / Relative request path.\n * @param {\"HEAD\" | \"GET\" | \"POST\" | \"DELETE\"} method HTTP 方法 / HTTP method.\n * @param {unknown} body POST 值,其它方法忽略 / POST value, ignored by other methods.\n * @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.\n * @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.\n * @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.\n */\n async function send(path, method, body, signal) {\n const controller = new AbortController();\n const abort = () => controller.abort();\n if (signal?.aborted) abort();\n signal?.addEventListener(\"abort\", abort, { once: true });\n const timer = setTimeout(abort, timeout);\n try {\n const response = await request(path, {\n method,\n credentials: \"omit\",\n cache: \"no-store\",\n signal: controller.signal,\n headers: { \"X-Settings-Client\": \"1\", ...(method === \"POST\" ? { \"Content-Type\": \"application/json\" } : {}) },\n ...(method === \"POST\" ? { body: JSON.stringify(body) } : {}),\n });\n if (response.status !== 200 && !(method === \"GET\" && response.status === 404)) throw new Error(`HTTP ${response.status}`);\n return response;\n } finally {\n clearTimeout(timer);\n signal?.removeEventListener(\"abort\", abort);\n }\n }\n /**\n * 获取独立快照,避免调用方修改内部缓存。\n * Return an independent snapshot so callers cannot mutate the cache.\n * @param {string} module 已打开模块 / Open module.\n * @returns {import(\"./client.mjs\").ModuleSnapshot} 会话快照 / Session snapshot.\n * @throws {Error} 模块未完成加载 / Module has not finished loading.\n */\n const snapshot = module => {\n const state = sessions.get(module);\n if (!state?.definition) throw new Error(\"Open the module first\");\n return structuredClone({ definition: state.definition, values: state.values });\n };\n /**\n * 串行修改单键,仅成功后更新仍存活的会话。\n * Serialize single-key mutations and update a still-active session only after success.\n * @param {string} module 已打开模块 / Open module.\n * @param {string} key 完整点分字段路径 / Complete dotted field path.\n * @param {\"POST\" | \"DELETE\"} method 写入或删除 / Write or delete.\n * @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.\n * @param {\"write\" | \"delete\" | \"clearCaches\" | \"reset\"} [operation] 操作类型 / Operation kind.\n * @returns {Promise<void>} 操作完成 / Operation completion.\n * @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.\n */\n async function change(module, key, method, value, operation = method === \"POST\" ? \"write\" : \"delete\") {\n const state = sessions.get(module);\n if (!state?.definition) throw new Error(\"Open the module first\");\n if (state.saving) throw new Error(\"A settings write is already in progress\");\n const field = state.definition.fields.find(field => field.key === key);\n state.saving = true;\n try {\n if ((operation === \"write\" || operation === \"delete\") && (!field || (method === \"POST\" && !validValue(field, value)))) throw new TypeError(\"Invalid setting value\");\n await send(`/api/${key.split(\".\").map(encodeURIComponent).join(\"/\")}`, method, value);\n if (sessions.get(module) === state) {\n switch (operation) {\n case \"write\":\n state.values[key] = structuredClone(value);\n break;\n case \"delete\":\n case \"clearCaches\":\n case \"reset\":\n for (const candidate of state.definition.fields) {\n if (candidate.key !== key && !candidate.key.startsWith(`${key}.`)) continue;\n delete state.values[candidate.key];\n if (Object.hasOwn(candidate, \"defaultValue\")) state.values[candidate.key] = structuredClone(candidate.defaultValue);\n }\n break;\n }\n }\n notify({ kind: \"success\", operation, module, key });\n } catch (error) {\n notify({ kind: \"error\", operation, module, key, message: error.message });\n throw error;\n } finally {\n state.saving = false;\n }\n }\n return {\n /**\n * 从已导入的 JSON 创建新会话,只读取一次设置值。\n * Create a session from imported JSON and read stored settings once.\n * @param {string} module 模块标识 / Module identifier.\n * @returns {Promise<import(\"./client.mjs\").ModuleSnapshot>} 新快照 / New snapshot.\n * @throws {Error} 读取失败、会话被替换或写入尚未完成 / Read failure, replaced session or unfinished write.\n */\n async open(module) {\n const binding = catalog.modules.get(module);\n if (!binding) throw new TypeError(`No BoxJS settings for module: ${module}`);\n const previous = sessions.get(module);\n if (previous?.saving) throw new Error(\"Cannot refresh while saving\");\n previous?.controller.abort();\n const state = { controller: new AbortController(), definition: null, values: {}, saving: false };\n sessions.set(module, state);\n try {\n const definition = normalizeBoxJs(catalog.select(module), module);\n const response = await send(`/api/${definition.settingsPath.map(encodeURIComponent).join(\"/\")}/`, \"GET\", undefined, state.controller.signal);\n let subtree = response.status === 404 ? {} : await response.json();\n if (typeof subtree === \"string\") subtree = JSON.parse(subtree);\n if (!subtree || typeof subtree !== \"object\" || Array.isArray(subtree)) throw new TypeError(\"Expected a settings subtree object\");\n if (sessions.get(module) !== state) throw new Error(\"Module session was replaced\");\n state.definition = definition;\n for (const field of definition.fields) {\n const stored = field.key\n .split(\".\")\n .slice(definition.settingsPath.length)\n .reduce((parent, part) => Object(parent)[part], subtree);\n const value = stored === undefined ? field.defaultValue : stored;\n if (value !== undefined) state.values[field.key] = normalizeStoredValue(field, value);\n }\n return snapshot(module);\n } catch (error) {\n if (sessions.get(module) === state) sessions.delete(module);\n throw error;\n }\n },\n snapshot,\n /**\n * 按需读取模块 Caches,不自动读取其它设置。\n * Read module Caches on demand without refreshing other settings.\n * @param {string} module 已打开的模块 / Open module.\n * @returns {Promise<unknown>} 缓存值,缺失为 undefined / Cache value, or undefined when absent.\n */\n async readCaches(module) {\n const state = sessions.get(module);\n if (!state?.definition) throw new Error(\"Open the module first\");\n const response = await send(`/api/${encodeURIComponent(module)}/Caches`, \"GET\", undefined, state.controller.signal);\n return response.status === 404 ? undefined : response.json();\n },\n /**\n * 删除整个 Caches 节点,成功后不追加 GET。\n * Delete the entire Caches node without a follow-up GET.\n * @param {string} module 已打开模块 / Open module.\n * @returns {Promise<void>} 清理完成 / Cleanup completion.\n */\n clearCaches: module => change(module, `${module}.Caches`, \"DELETE\", undefined, \"clearCaches\"),\n /**\n * 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。\n * Delete module persistence and reset the page cache using current BoxJS defaults.\n * @param {string} module 已打开模块 / Open module.\n * @returns {Promise<void>} 重置完成 / Reset completion.\n */\n reset: module => change(module, module, \"DELETE\", undefined, \"reset\"),\n /**\n * 取消读取并清除会话,不撤销已发送的写入。\n * Abort reads and clear the session without undoing dispatched writes.\n * @param {string} module 模块标识 / Module identifier.\n * @returns {void} 无返回值 / No return value.\n */\n leave(module) {\n sessions.get(module)?.controller.abort();\n sessions.delete(module);\n },\n /**\n * 写入单键并更新当前会话。\n * Write one key and update the current session.\n * @param {string} module 已打开模块 / Open module.\n * @param {string} key 点分字段路径 / Dotted field path.\n * @param {import(\"../index.js\").SettingsScalar | import(\"../index.js\").SettingsScalar[]} value 字段值 / Field value.\n * @returns {Promise<void>} 写入完成 / Write completion.\n */\n set: (module, key, value) => change(module, key, \"POST\", value),\n /**\n * 删除单键覆盖值并显示默认值。\n * Delete one override and display its default value.\n * @param {string} module 已打开模块 / Open module.\n * @param {string} key 点分字段路径 / Dotted field path.\n * @returns {Promise<void>} 删除完成 / Delete completion.\n */\n remove: (module, key) => change(module, key, \"DELETE\"),\n };\n}\n\n/**\n * 挂载已导入 BoxJS 对应的模块表单和短暂通知。\n * Mount the imported BoxJS module form and transient notifications.\n * @param {HTMLElement} root 包内挂载元素 / Internal mount element.\n * @param {import(\"../BoxJS.mjs\").BoxJS} catalog 包内 BoxJS 目录 / Internal BoxJS catalog.\n * @returns {import(\"./index.js\").MountedPreferences} 面板生命周期句柄 / Panel lifecycle handle.\n */\nfunction mountPanel(root, catalog) {\n const title = catalog.module.metadata.name ?? catalog.module.module;\n const document = root.ownerDocument;\n const window = document.defaultView;\n const shell = element(\"div\", \"pp-panel\");\n const header = element(\"header\", \"pp-header\");\n const back = element(\"button\", \"pp-back\", \"‹\");\n back.setAttribute(\"aria-label\", \"返回\");\n back.type = \"button\";\n const heading = element(\"h1\", \"pp-title\", title);\n const viewport = element(\"div\", \"pp-viewport\");\n const toast = element(\"div\", \"pp-toast\");\n toast.setAttribute(\"role\", \"status\");\n toast.hidden = true;\n header.append(back, heading, element(\"span\", \"pp-nav-spacer\"));\n shell.append(header, viewport, toast);\n root.append(shell);\n let timer,\n secondaryRoute,\n generation = 0,\n active = null,\n saving = false,\n destroyed = false;\n /**\n * 展示短暂通知,不刷新设置数据。\n * Display a transient notification without refreshing settings.\n * @param {{kind: \"success\" | \"error\", operation?: \"write\" | \"delete\" | \"clearCaches\" | \"reset\", message?: string}} event 操作结果 / Operation result.\n * @returns {void} 无返回值 / No return value.\n */\n const notify = event => {\n if (destroyed) return;\n switch (true) {\n case event.kind === \"error\":\n toast.textContent = `操作失败:${event.message}`;\n break;\n case event.operation === \"delete\":\n toast.textContent = \"删除成功\";\n break;\n case event.operation === \"clearCaches\":\n toast.textContent = \"Caches 已清空\";\n break;\n case event.operation === \"reset\":\n toast.textContent = \"模块已重置\";\n break;\n default:\n toast.textContent = \"修改成功\";\n break;\n }\n toast.dataset.kind = event.kind;\n toast.hidden = false;\n clearTimeout(timer);\n timer = setTimeout(() => {\n toast.hidden = true;\n }, 2400);\n };\n const client = createPreferencesClient({ catalog, notify });\n /**\n * 切换加载或错误视图,按用户的动态效果偏好播放过渡。\n * Replace a loading or error view, respecting reduced-motion preferences.\n * @param {HTMLElement} view 新视图 / New view.\n * @param {number} direction 过渡方向,正数从右侧进入 / Transition direction; positive enters from the right.\n * @returns {void} 无返回值 / No return value.\n */\n function replace(view, direction) {\n const old = viewport.firstElementChild;\n viewport.replaceChildren(view);\n if (old && !window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches)\n view.animate(\n [\n { opacity: 0.4, transform: `translateX(${direction * 24}px)` },\n { opacity: 1, transform: \"translateX(0)\" },\n ],\n { duration: 180, easing: \"ease-out\" },\n );\n }\n /**\n * 打开模块并忽略已过期的异步结果。\n * Open a module and ignore stale asynchronous results.\n * @param {string} module 模块标识 / Module identifier.\n * @returns {Promise<void>} 视图加载完成,失败显示错误视图 / View load completion; failures display an error view.\n */\n async function open(module) {\n const version = ++generation;\n active = module;\n back.disabled = window.history.length <= 1;\n heading.textContent = module;\n replace(element(\"p\", \"pp-loading\", \"读取设置…\"), 1);\n try {\n await client.open(module);\n if (version === generation) controls();\n } catch (error) {\n if (version !== generation) return;\n replace(\n errorView(error, () => open(module)),\n 1,\n );\n }\n }\n /**\n * 从会话快照创建控件与操作按钮,不重新读取网络配置。\n * Build controls and actions from the session snapshot without fetching config again.\n * @returns {void} 无返回值 / No return value.\n */\n function controls() {\n const { definition, values } = client.snapshot(active);\n heading.textContent = definition.metadata?.name || active;\n const view = element(\"section\", \"pp-fields\");\n /**\n * 挂载后执行的多行高度更新\n * Textarea sizing callbacks run after mounting.\n * @type {Array<() => void>}\n */\n const growingInputs = [];\n const editors = new Map();\n const summaries = [];\n const groups = new Map();\n const scrollPositions = new WeakMap();\n let activeEditor;\n let queue = Promise.resolve(),\n pendingWrites = 0;\n /**\n * 根据 hash 切换多选页,保留上级 DOM 和滚动位置。\n * Switch multi-select views by hash while retaining parent DOM and scroll position.\n * @returns {void} 无返回值 / No return value.\n */\n const showEditor = () => {\n let key;\n try {\n key = decodeURIComponent(window.location.hash.slice(1));\n } catch {\n key = \"\";\n }\n const editor = editors.get(key);\n const previous = activeEditor?.node ?? view;\n const next = editor?.node ?? view;\n if (previous !== next) {\n scrollPositions.set(previous, previous.scrollTop);\n previous.remove();\n viewport.append(next);\n next.scrollTop = scrollPositions.get(next) ?? 0;\n if (!window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) next.animate([{ transform: `translateX(${editor ? 100 : -100}%)` }, { transform: \"translateX(0)\" }], { duration: 260, easing: \"cubic-bezier(.22,.61,.36,1)\" });\n }\n activeEditor = editor;\n heading.textContent = editor?.title ?? definition.metadata?.name ?? active;\n back.disabled = saving || (!editor && window.history.length <= 1);\n };\n secondaryRoute = showEditor;\n /**\n * 串行执行模块操作,保持输入可编辑。\n * Serialize module actions while keeping inputs editable.\n * @param {() => Promise<void>} action 请求或写入 / Request or mutation.\n * @param {() => void} success 成功后的局部更新 / Local update after success.\n * @param {() => void} [failure] 失败后恢复当前输入 / Restore the current input on failure.\n * @returns {Promise<void>} 操作完成 / Operation completion.\n */\n function perform(action, success, failure = () => {}) {\n pendingWrites++;\n saving = true;\n back.disabled = true;\n return (queue = queue\n .then(action)\n .then(() => {\n if (!destroyed) success();\n })\n .catch(() => {\n /* 请求层已通知错误。\n * The request layer has already reported the error. */\n if (!destroyed) failure();\n })\n .finally(() => {\n pendingWrites--;\n saving = pendingWrites > 0;\n if (destroyed && !saving) client.leave(active);\n back.disabled = saving || (!activeEditor && window.history.length <= 1);\n }));\n }\n const metadata = definition.metadata;\n if (metadata) {\n const info = element(\"div\", \"pp-module-info\");\n const image = icon(metadata, \"pp-module-icon\");\n if (image) info.append(image);\n const details = element(\"div\", \"pp-module-details\");\n for (const description of [metadata.author, metadata.desc ?? metadata.description, ...(metadata.descs ?? [])]) if (description) details.append(element(\"p\", \"pp-description\", description));\n if (metadata.repo) {\n const link = element(\"a\", \"pp-module-source\", \"项目主页\");\n link.href = resourceURL(metadata.repo);\n link.target = \"_blank\";\n link.rel = \"noopener noreferrer\";\n details.append(link);\n }\n info.append(details);\n view.append(info);\n }\n for (const field of definition.fields) {\n const match = /^\\[([^\\]]+)\\]\\s*(.*)$/.exec(field.name);\n const group = match?.[1] ?? \"通用\";\n if (!groups.has(group)) {\n const section = element(\"section\", \"form-group\");\n const rows = element(\"div\", \"form-group__row\");\n section.append(element(\"h2\", \"form-group__title\", group), rows);\n groups.set(group, rows);\n view.append(section);\n }\n const row = element(\"div\", \"form-row pp-field\");\n const label = element(\"div\", \"form-row__text\");\n label.append(element(\"span\", \"form-row__title\", match?.[2] ?? field.name));\n if (field.description) label.append(element(\"span\", \"form-row__subtitle\", field.description));\n row.append(label);\n const value = values[field.key];\n /**\n * 读取尚未保存的输入\n * Read the unsaved input.\n * @type {() => unknown}\n */\n let read;\n /**\n * 更新当前控件\n * Update the current control.\n * @type {(value: unknown) => void}\n */\n let write;\n let inputContainer = row;\n let eventName = \"change\";\n switch (true) {\n case Boolean(field.options) && field.type !== \"array\": {\n const select = element(\"select\", \"pp-input\");\n select.setAttribute(\"aria-label\", field.name);\n field.options.forEach((option, index) => {\n const item = element(\"option\", \"\", option.label);\n item.value = String(index);\n select.append(item);\n });\n write = value => {\n select.selectedIndex = field.options.findIndex(option => option.key === value);\n };\n row.append(select);\n read = () => field.options[select.selectedIndex]?.key;\n break;\n }\n case field.type === \"array\" && Boolean(field.options): {\n const page = element(\"section\", \"pp-choice-page\");\n if (field.description) page.append(element(\"p\", \"pp-description\", field.description));\n const choices = element(\"div\", \"form-group__row\");\n page.append(choices);\n inputContainer = choices;\n editors.set(field.key, { node: page, title: match?.[2] ?? field.name });\n const summary = element(\"span\", \"form-row__value pp-summary\");\n const link = element(\"button\", \"pp-choice-link\");\n link.type = \"button\";\n link.setAttribute(\"aria-label\", field.name);\n link.append(summary, element(\"span\", \"pp-chevron\", \"›\"));\n row.append(link);\n const refresh = () => {\n const value = client.snapshot(active).values[field.key];\n summary.textContent =\n field.options\n .filter(option => Array.isArray(value) && value.includes(option.key))\n .map(option => option.label)\n .join(\"、\") || \"未选择\";\n };\n summaries.push(refresh);\n refresh();\n link.onclick = () => {\n window.history.pushState({ ...window.history.state, preferencePane: active }, \"\", `#${encodeURIComponent(field.key)}`);\n showEditor();\n };\n row.addEventListener(\"click\", event => {\n if (!link.contains(event.target)) link.click();\n });\n const inputs = field.options.map(option => {\n const label = element(\"label\", \"form-row pp-choice\", option.label);\n const input = element(\"input\", \"\");\n input.type = \"checkbox\";\n input.setAttribute(\"aria-label\", option.label);\n label.append(input);\n choices.append(label);\n return { input, key: option.key };\n });\n read = () => inputs.filter(option => option.input.checked).map(option => option.key);\n write = value => {\n for (const option of inputs) option.input.checked = Array.isArray(value) && value.includes(option.key);\n };\n break;\n }\n default: {\n const multiline = field.control === \"textarea\" || field.type === \"array\";\n const input = element(multiline ? \"textarea\" : \"input\", \"pp-input\");\n if (multiline) row.classList.add(\"pp-multiline\");\n input.setAttribute(\"aria-label\", field.name);\n if (field.placeholder) input.placeholder = field.placeholder;\n if (multiline && field.rows) input.rows = field.rows;\n /**\n * 在挂载后根据内容调整高度,同时保留基础行数。\n * Size mounted textareas to their contents while retaining baseline rows.\n * @returns {void} 无返回值 / No return value.\n */\n const grow = () => {\n if (!multiline || !field.autoGrow || !input.isConnected) return;\n input.style.height = \"auto\";\n const baseline = input.getBoundingClientRect().height;\n const style = window.getComputedStyle(input);\n const borders = Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth);\n input.style.height = `${Math.max(baseline, input.scrollHeight + borders)}px`;\n };\n if (multiline && field.autoGrow) {\n input.addEventListener(\"input\", grow);\n growingInputs.push(grow);\n }\n if (field.type === \"boolean\") {\n input.type = \"checkbox\";\n input.classList.add(\"pp-switch\");\n input.setAttribute(\"role\", \"switch\");\n write = value => {\n input.checked = value === true;\n };\n read = () => input.checked;\n } else {\n eventName = \"input\";\n if (!multiline) input.type = field.type === \"number\" ? \"number\" : \"text\";\n write = value => {\n input.value = field.type === \"array\" ? JSON.stringify(value ?? []) : (value ?? \"\");\n grow();\n };\n read = () => {\n switch (field.type) {\n case \"array\":\n return JSON.parse(input.value);\n case \"number\":\n return input.value === \"\" ? Number.NaN : Number(input.value);\n default:\n return input.value;\n }\n };\n }\n row.append(input);\n break;\n }\n }\n write(value);\n let inputVersion = 0;\n inputContainer.addEventListener(eventName, event => {\n if (event.isComposing) return;\n const version = ++inputVersion,\n module = active;\n let value;\n try {\n value = read();\n } catch (error) {\n notify({ kind: \"error\", message: error.message });\n return;\n }\n const restore = () => {\n if (version === inputVersion) write(client.snapshot(module).values[field.key]);\n };\n perform(\n () => client.set(module, field.key, value),\n () => {\n for (const refresh of summaries) refresh();\n },\n restore,\n );\n });\n if (eventName === \"input\") inputContainer.addEventListener(\"compositionend\", event => event.target.dispatchEvent(new window.Event(\"input\", { bubbles: true })));\n groups.get(group).append(row);\n }\n const maintenance = element(\"section\", \"pp-maintenance\");\n maintenance.append(element(\"h2\", \"pp-title\", \"模块数据\"));\n const actions = element(\"div\", \"pp-actions\");\n const cacheView = element(\"button\", \"\", \"查看 Caches\");\n const cacheClear = element(\"button\", \"\", \"清空 Caches\");\n const reset = element(\"button\", \"pp-danger\", \"重置模块\");\n const output = element(\"pre\", \"pp-cache\");\n output.hidden = true;\n output.setAttribute(\"aria-label\", \"Caches 内容\");\n for (const button of [cacheView, cacheClear, reset]) button.type = \"button\";\n cacheView.onclick = () => {\n if (saving) return;\n let value;\n return perform(\n async () => {\n try {\n value = await client.readCaches(active);\n } catch (error) {\n notify({ kind: \"error\", message: error.message });\n throw error;\n }\n },\n () => {\n output.textContent = value === undefined ? \"暂无缓存\" : JSON.stringify(value, null, 2);\n output.hidden = false;\n cacheView.textContent = \"刷新 Caches\";\n },\n );\n };\n cacheClear.onclick = () => {\n if (saving) return;\n if (!window.confirm(`清空 ${active} 的全部 Caches?`)) return;\n return perform(\n () => client.clearCaches(active),\n () => {\n output.textContent = \"暂无缓存\";\n },\n );\n };\n reset.onclick = () => {\n if (saving) return;\n if (!window.confirm(`重置 ${active}?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) return;\n return perform(() => client.reset(active), controls);\n };\n actions.append(cacheView, cacheClear, reset);\n maintenance.append(actions, output);\n view.append(maintenance);\n viewport.replaceChildren(view);\n for (const grow of growingInputs) grow();\n showEditor();\n }\n /**\n * 历史导航只切换当前模块的二级页,不接管项目主页或跨模块路由。\n * History navigation switches only this module's subpages, never project or cross-module routes.\n * @returns {void} 无返回值 / No return value.\n */\n const onPopState = () => secondaryRoute?.();\n const onHashChange = () => secondaryRoute?.();\n back.onclick = () => {\n if (saving) return;\n if (window.location.hash && window.history.state?.preferencePane !== active) {\n window.history.replaceState(window.history.state, \"\", window.location.pathname);\n secondaryRoute?.();\n } else window.history.back();\n };\n window.addEventListener(\"popstate\", onPopState);\n window.addEventListener(\"hashchange\", onHashChange);\n open(catalog.module.module);\n return {\n /**\n * 移除监听器、定时器、会话和挂载内容。\n * Remove listeners, timers, session and mounted content.\n * @returns {void} 无返回值 / No return value.\n */\n destroy() {\n destroyed = true;\n window.removeEventListener(\"popstate\", onPopState);\n window.removeEventListener(\"hashchange\", onHashChange);\n generation++;\n if (active && !saving) client.leave(active);\n clearTimeout(timer);\n shell.remove();\n },\n };\n}\n\n/**\n * 只挂载导入 JSON 对应的模块设置页,默认样式内置,CSS 仅用于该页。\n * Mount only the imported module's settings page with built-in defaults and optional page CSS.\n * @param {import(\"../index.js\").BoxJSInput} boxjs 单个模块的 BoxJS JSON / BoxJS JSON for one module.\n * @param {string} [css] 可选 CSS 正文 / Optional CSS text.\n * @returns {import(\"./index.js\").MountedPreferences} 模块生命周期句柄 / Module lifecycle handle.\n */\nfunction mount(boxjs, css = \"\") {\n if (typeof css !== \"string\") throw new TypeError(\"CSS must be a string\");\n const catalog = new BoxJS(boxjs);\n const metadata = catalog.module.metadata;\n const image = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];\n if (image) resourceURL(image);\n if (metadata.repo) resourceURL(metadata.repo);\n const existing = document.querySelector(\"#preferences\");\n const root = existing ?? element(\"main\", \"\");\n if (!existing) {\n root.id = \"preferences\";\n document.body.append(root);\n }\n const base = element(\"style\", \"\"),\n custom = element(\"style\", \"\");\n base.textContent = defaults;\n custom.textContent = css;\n document.head.append(base, custom);\n const previousTitle = document.title;\n const previousTheme = document.documentElement.dataset.theme;\n const theme = navigator.userAgent.match(/themeId\\/(\\d+)/)?.[1];\n if (theme) document.documentElement.dataset.theme = theme === \"2\" ? \"dark\" : \"light\";\n document.title = metadata.name ?? catalog.module.module;\n let panel;\n const view = {\n /**\n * 释放模块视图、样式与会话,不操作项目入口页。\n * Release the module view, styles and session without operating a project landing page.\n * @returns {void} 无返回值 / No return value.\n */\n destroy() {\n panel?.destroy();\n base.remove();\n custom.remove();\n if (existing) root.replaceChildren();\n else root.remove();\n document.title = previousTitle;\n if (previousTheme === undefined) delete document.documentElement.dataset.theme;\n else document.documentElement.dataset.theme = previousTheme;\n },\n };\n try {\n root.replaceChildren();\n panel = mountPanel(root, catalog);\n return view;\n } catch (error) {\n view.destroy();\n throw error;\n }\n}\n\nlet view;\n/**\n * 在具体模块地址导入 JSON 与 CSS,再交给模块渲染器。\n * Import JSON and CSS at a concrete module URL and pass them to the module renderer.\n * @returns {Promise<void>} 启动完成 / Startup completion.\n */\nasync function start() {\n try {\n view?.destroy();\n view = undefined;\n const match = /^\\/settings\\/([a-zA-Z0-9_-]+)\\/?$/.exec(location.pathname);\n if (!match) throw new Error(\"Open a concrete module URL\");\n const module = match[1];\n const [data, style] = await Promise.all([fetch(`/configs/${module}`, { cache: \"no-store\" }), fetch(`/settings/assets/${module}.css`, { cache: \"no-store\" })]);\n if (data.status !== 200 || style.status !== 200) throw new Error(`HTTP ${data.status !== 200 ? data.status : style.status}`);\n const boxjs = await data.json();\n if (new BoxJS(boxjs).module.module !== module) throw new Error(\"Imported JSON does not match the module URL\");\n view = mount(boxjs, await style.text());\n } catch (error) {\n document.querySelector(\"#preferences\").replaceChildren(errorView(error, start));\n }\n}\nstart();\nwindow.addEventListener(\"pageshow\", event => {\n if (event.persisted) start();\n});\n"}};
345
+
346
+ /**
347
+ * 解析已经取得的 pathname,避免重复构造 URL。
348
+ * Parse an existing pathname without constructing another URL.
349
+ * @param {string} pathname 以 / 开头的 URL pathname / URL pathname beginning with /.
350
+ * @returns {string[] | undefined} 解码后的路径,非 API 路径不处理 / Decoded path, or undefined outside /api/.
351
+ * @throws {TypeError} 转义编码或路径片段非法 / Invalid percent encoding or path segments.
352
+ */
353
+ function parseSettingsPathname(pathname) {
354
+ if (!pathname.startsWith("/api/")) return;
355
+ let parts;
356
+ try {
357
+ parts = pathname.slice(5).replace(/\/$/, "").split("/").map(decodeURIComponent);
358
+ } catch {
359
+ throw new TypeError("Invalid encoded key path");
360
+ }
361
+ return validatePathParts(parts);
362
+ }
363
+
364
+ /**
365
+ * 校验原始路径片段,不进行 URL 编码转换。
366
+ * Validate raw path segments without URL encoding conversion.
367
+ * @param {string[]} parts 原始路径片段 / Raw path segments.
368
+ * @returns {string[]} 同一数组,不复制或修改 / The same array without copying or mutation.
369
+ * @throws {TypeError} 空片段、非法字符或原型属性名 / Empty segments, invalid characters or prototype property names.
370
+ */
371
+ function validatePathParts(parts) {
372
+ if (!parts.every(part => typeof part === "string" && /^[a-zA-Z0-9_-]+$/.test(part) && !["__proto__", "prototype", "constructor"].includes(part))) throw new TypeError("Invalid key path");
373
+ return parts;
374
+ }
375
+
376
+ /**
377
+ * BoxJS 的共同目录:模块、存储根和展示元数据都来自同一份 JSON。
378
+ * Shared BoxJS catalog deriving modules, storage roots and metadata from one JSON document.
379
+ */
380
+ class BoxJS {
381
+ /**
382
+ * 建立路径索引,不解析控件类型,也不读写持久化存储。
383
+ * Index field paths without interpreting controls or accessing persistence.
384
+ * @param {unknown} input 字段数组、单个 app 或 apps 订阅 / Field array, app or apps subscription.
385
+ */
386
+ constructor(input) {
387
+ if (!input || typeof input !== "object") throw new TypeError("Expected BoxJS JSON");
388
+ this.document = JSON.parse(JSON.stringify(input));
389
+ const apps = Array.isArray(this.document) ? [{ settings: this.document }] : (this.document.apps ?? [this.document]);
390
+ if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
391
+ this.modules = new Map();
392
+ for (const app of apps) {
393
+ if (!app || !Array.isArray(app.settings)) throw new TypeError("Expected BoxJS settings array");
394
+ for (const entry of app.settings) {
395
+ if (typeof entry.id !== "string") throw new TypeError("BoxJS settings require string IDs");
396
+ if (!entry.id.startsWith("@")) {
397
+ if (Array.isArray(this.document)) throw new TypeError("BoxJS settings require @root.path IDs");
398
+ continue;
399
+ }
400
+ const [storageKey, ...parts] = entry.id.slice(1).split(".");
401
+ if (!storageKey || storageKey.startsWith("@") || parts.length < 2) throw new TypeError("A BoxJS setting must be below a literal storage root and module");
402
+ validatePathParts(parts);
403
+ const module = parts[0];
404
+ let target = this.modules.get(module);
405
+ if (!target) {
406
+ target = { module, storageKey, entries: [], owners: new Set() };
407
+ this.modules.set(module, target);
408
+ }
409
+ if (target.storageKey !== storageKey) throw new TypeError(`A module must use one storage root: ${module}`);
410
+ target.entries.push(entry);
411
+ target.owners.add(app);
412
+ }
413
+ }
414
+ this.metadata = metadata(Array.isArray(this.document) ? {} : this.document);
415
+ for (const target of this.modules.values()) target.metadata = target.owners.size === 1 ? metadata([...target.owners][0]) : {};
416
+ }
417
+
418
+ /**
419
+ * 提取一个模块的原生 BoxJS,保留所属 app 的元数据。
420
+ * Select a module's native BoxJS while retaining owning-app metadata.
421
+ * @param {string} module 模块标识 / Module identifier.
422
+ * @returns {unknown} 可直接用作配置 Mock 的 JSON / JSON suitable for a configuration Mock.
423
+ */
424
+ select(module) {
425
+ const target = this.modules.get(module);
426
+ if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
427
+ if (Array.isArray(this.document)) return target.entries;
428
+ const apps = [...target.owners].map(app => ({ ...app, settings: app.settings.filter(entry => target.entries.includes(entry)) }));
429
+ return this.document.apps ? { ...this.document, apps } : apps[0];
430
+ }
431
+
432
+ /**
433
+ * 取得本次导入的唯一模块,避免把模块数据变成项目目录。
434
+ * Get the single imported module without turning module data into a project directory.
435
+ * @returns {object} 唯一模块的目录项 / The single module entry.
436
+ */
437
+ get module() {
438
+ if (this.modules.size !== 1) throw new TypeError("Import BoxJS JSON for exactly one module");
439
+ return this.modules.values().next().value;
440
+ }
441
+ }
442
+
443
+ /**
444
+ * 保留标准 BoxJS 展示信息;script 仅为元数据,不执行。
445
+ * Retain standard BoxJS presentation data; script is metadata only and never executed.
446
+ * @param {object} source BoxJS app 或订阅 / BoxJS app or subscription.
447
+ * @returns {object} 经过类型检查的展示信息 / Type-checked presentation metadata.
448
+ */
449
+ function metadata(source) {
450
+ const result = {};
451
+ for (const key of ["id", "name", "author", "repo", "script", "icon", "description", "desc", "icons", "descs"]) {
452
+ if (source[key] === undefined) continue;
453
+ const multiple = key === "icons" || key === "descs";
454
+ const values = multiple ? source[key] : [source[key]];
455
+ if (!Array.isArray(values) || values.some(item => typeof item !== "string")) throw new TypeError(`Invalid BoxJS app ${key}`);
456
+ result[key] = multiple ? [...values] : source[key];
457
+ }
458
+ return result;
459
+ }
460
+
461
+ /**
462
+ * 统一生成不可缓存的响应,HEAD 始终省略正文。
463
+ * Create an uncached response, always omitting the body for HEAD.
464
+ * @param {import("../index.js").SettingsRequest} request 宿主请求 / Host request.
465
+ * @param {number} status HTTP 状态 / HTTP status.
466
+ * @param {unknown} body JSON 数据或资源正文 / JSON data or resource body.
467
+ * @param {string} [type] 媒体类型 / Media type.
468
+ * @returns {import("../index.js").SettingsResponse} 通用响应 / Common response.
469
+ */
470
+ function response(request, status, body, type = "application/json") {
471
+ return {
472
+ status,
473
+ headers: { "Content-Type": `${type}; charset=utf-8`, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" },
474
+ body: request.method === "HEAD" ? "" : type === "application/json" ? JSON.stringify(body) : body,
475
+ };
476
+ }
477
+
478
+ /* https://www.lodashjs.com */
479
+ /**
480
+ * 轻量 Lodash 工具集。
481
+ * Lightweight Lodash-like utilities.
482
+ *
483
+ * 说明:
484
+ * Notes:
485
+ * - 这是 Lodash 的“部分方法”简化实现,不等价于完整 Lodash
486
+ * - This is a simplified subset, not a full Lodash implementation
487
+ * - 各方法语义可参考 Lodash 官方文档
488
+ * - Method semantics can be referenced from official Lodash docs
489
+ * - 导入时建议使用 `Lodash as _`,遵循 lodash 官方示例惯例
490
+ * - Use `Lodash as _` when importing, following official lodash example convention
491
+ *
492
+ * 参考:
493
+ * Reference:
494
+ * - https://www.lodashjs.com
495
+ * - https://lodash.com
496
+ */
497
+ class Lodash {
498
+ /**
499
+ * HTML 特殊字符转义。
500
+ * Escape HTML special characters.
501
+ *
502
+ * @param {string} string 输入文本 / Input text.
503
+ * @returns {string}
504
+ * @see {@link https://lodash.com/docs/#escape lodash.escape}
505
+ * @see {@link https://www.lodashjs.com/docs/lodash.escape lodash.escape (中文)}
506
+ */
507
+ static escape(string) {
508
+ const map = {
509
+ "&": "&amp;",
510
+ "<": "&lt;",
511
+ ">": "&gt;",
512
+ '"': "&quot;",
513
+ "'": "&#39;",
514
+ };
515
+ return string.replace(/[&<>"']/g, m => map[m]);
516
+ }
517
+
518
+ /**
519
+ * 按路径读取对象值。
520
+ * Get object value by path.
521
+ *
522
+ * @param {object} [object={}] 目标对象 / Target object.
523
+ * @param {string|string[]} [path=""] 路径 / Path.
524
+ * @param {*} [defaultValue=undefined] 默认值 / Default value.
525
+ * @returns {*}
526
+ * @see {@link https://lodash.com/docs/#get lodash.get}
527
+ * @see {@link https://www.lodashjs.com/docs/lodash.get lodash.get (中文)}
528
+ */
529
+ static get(object = {}, path = "", defaultValue = undefined) {
530
+ // translate array case to dot case, then split with .
531
+ // a[0].b -> a.0.b -> ['a', '0', 'b']
532
+ if (!Array.isArray(path)) path = Lodash.toPath(path);
533
+
534
+ const result = path.reduce((previousValue, currentValue) => {
535
+ return Object(previousValue)[currentValue]; // null undefined get attribute will throwError, Object() can return a object
536
+ }, object);
537
+ return result === undefined ? defaultValue : result;
538
+ }
539
+
540
+ /**
541
+ * 递归合并源对象的自身可枚举属性到目标对象
542
+ * Recursively merge source enumerable properties into target object.
543
+ * @description 简化版 lodash.merge,用于合并配置对象
544
+ * @description A simplified lodash.merge for config merging.
545
+ *
546
+ * 适用情况:
547
+ * - 合并嵌套的配置/设置对象
548
+ * - 需要深度合并而非浅层覆盖的场景
549
+ * - 多个源对象依次合并到目标对象
550
+ *
551
+ * 限制:
552
+ * - 仅处理普通对象 (Plain Object),不处理 Date/RegExp 等特殊对象
553
+ * - Map/Set 仅支持同类型合并,不递归内部值
554
+ * - 数组会被直接覆盖,不会合并数组元素
555
+ * - 不处理循环引用,可能导致栈溢出
556
+ * - 不复制 Symbol 属性和不可枚举属性
557
+ * - 不保留原型链,仅处理自身属性
558
+ * - 会修改原始目标对象 (mutates target)
559
+ *
560
+ * @param {object} object - 目标对象
561
+ * @param {object} object - Target object.
562
+ * @param {...object} sources - 源对象(可多个)
563
+ * @param {...object} sources - Source objects.
564
+ * @returns {object} 返回合并后的目标对象
565
+ * @returns {object} Merged target object.
566
+ * @see {@link https://lodash.com/docs/#merge lodash.merge}
567
+ * @see {@link https://www.lodashjs.com/docs/lodash.merge lodash.merge (中文)}
568
+ * @example
569
+ * const target = { a: { b: 1 }, c: 2 };
570
+ * const source = { a: { d: 3 }, e: 4 };
571
+ * Lodash.merge(target, source);
572
+ * // => { a: { b: 1, d: 3 }, c: 2, e: 4 }
573
+ */
574
+ static merge(object, ...sources) {
575
+ if (object === null || object === undefined) return object;
576
+
577
+ for (const source of sources) {
578
+ if (source === null || source === undefined) continue;
579
+
580
+ for (const key of Object.keys(source)) {
581
+ const sourceValue = source[key];
582
+ const targetValue = object[key];
583
+
584
+ switch (true) {
585
+ case Lodash.#isPlainObject(sourceValue) && Lodash.#isPlainObject(targetValue):
586
+ // 递归合并对象
587
+ object[key] = Lodash.merge(targetValue, sourceValue);
588
+ break;
589
+ case sourceValue instanceof Map && targetValue instanceof Map:
590
+ // 合并 Map(空 Map 跳过)
591
+ if (sourceValue.size > 0) {
592
+ for (const [k, v] of sourceValue) {
593
+ targetValue.set(k, v);
594
+ }
595
+ }
596
+ break;
597
+ case sourceValue instanceof Set && targetValue instanceof Set:
598
+ // 合并 Set(空 Set 跳过)
599
+ if (sourceValue.size > 0) {
600
+ for (const v of sourceValue) {
601
+ targetValue.add(v);
602
+ }
603
+ }
604
+ break;
605
+ case Array.isArray(sourceValue) && sourceValue.length === 0 && targetValue !== undefined:
606
+ // 空数组不覆盖已有值
607
+ break;
608
+ case (sourceValue instanceof Map && sourceValue.size === 0 && targetValue !== undefined):
609
+ case (sourceValue instanceof Set && sourceValue.size === 0 && targetValue !== undefined):
610
+ // Map/Set 不覆盖已有值
611
+ break;
612
+ case sourceValue !== undefined:
613
+ object[key] = sourceValue;
614
+ break;
615
+ }
616
+ }
617
+ }
618
+
619
+ return object;
620
+ }
621
+
622
+ /**
623
+ * 判断值是否为普通对象 (Plain Object)
624
+ * Check whether a value is a plain object.
625
+ * @param {*} value - 要检查的值
626
+ * @param {*} value - Value to check.
627
+ * @returns {boolean} 如果是普通对象返回 true
628
+ * @returns {boolean} Returns true when value is a plain object.
629
+ * @see {@link https://lodash.com/docs/#isPlainObject lodash.isPlainObject}
630
+ * @see {@link https://www.lodashjs.com/docs/lodash.isPlainObject lodash.isPlainObject (中文)}
631
+ */
632
+ static #isPlainObject(value) {
633
+ if (value === null || typeof value !== "object") return false;
634
+ const proto = Object.getPrototypeOf(value);
635
+ return proto === null || proto === Object.prototype;
636
+ }
637
+
638
+ /**
639
+ * 删除对象指定路径并返回对象。
640
+ * Omit paths from object and return the same object.
641
+ *
642
+ * @param {object} [object={}] 目标对象 / Target object.
643
+ * @param {string|string[]} [paths=[]] 要删除的路径 / Paths to remove.
644
+ * @returns {object}
645
+ * @see {@link https://lodash.com/docs/#omit lodash.omit}
646
+ * @see {@link https://www.lodashjs.com/docs/lodash.omit lodash.omit (中文)}
647
+ */
648
+ static omit(object = {}, paths = []) {
649
+ if (!Array.isArray(paths)) paths = [paths.toString()];
650
+ paths.forEach(path => Lodash.unset(object, path));
651
+ return object;
652
+ }
653
+
654
+ /**
655
+ * 仅保留对象指定键(第一层)。
656
+ * Pick selected keys from object (top level only).
657
+ *
658
+ * @param {object} [object={}] 目标对象 / Target object.
659
+ * @param {string|string[]} [paths=[]] 需要保留的键 / Keys to keep.
660
+ * @returns {object}
661
+ * @see {@link https://lodash.com/docs/#pick lodash.pick}
662
+ * @see {@link https://www.lodashjs.com/docs/lodash.pick lodash.pick (中文)}
663
+ */
664
+ static pick(object = {}, paths = []) {
665
+ if (!Array.isArray(paths)) paths = [paths.toString()];
666
+ const filteredEntries = Object.entries(object).filter(([key, value]) => paths.includes(key));
667
+ return Object.fromEntries(filteredEntries);
668
+ }
669
+
670
+ /**
671
+ * 按路径写入对象值。
672
+ * Set object value by path.
673
+ *
674
+ * @param {object} object 目标对象 / Target object.
675
+ * @param {string|string[]} path 路径 / Path.
676
+ * @param {*} value 写入值 / Value.
677
+ * @returns {object}
678
+ * @see {@link https://lodash.com/docs/#set lodash.set}
679
+ * @see {@link https://www.lodashjs.com/docs/lodash.set lodash.set (中文)}
680
+ */
681
+ static set(object, path, value) {
682
+ if (!Array.isArray(path)) path = Lodash.toPath(path);
683
+ path.slice(0, -1).reduce((previousValue, currentValue, currentIndex) => (Object(previousValue[currentValue]) === previousValue[currentValue] ? previousValue[currentValue] : (previousValue[currentValue] = /^\d+$/.test(path[currentIndex + 1]) ? [] : {})), object)[path[path.length - 1]] = value;
684
+ return object;
685
+ }
686
+
687
+ /**
688
+ * 将点路径或数组下标路径转换为数组。
689
+ * Convert dot/array-index path string into path segments.
690
+ *
691
+ * @param {string} value 路径字符串 / Path string.
692
+ * @returns {string[]}
693
+ * @see {@link https://lodash.com/docs/#toPath lodash.toPath}
694
+ * @see {@link https://www.lodashjs.com/docs/lodash.toPath lodash.toPath (中文)}
695
+ */
696
+ static toPath(value) {
697
+ return value
698
+ .replace(/\[(\d+)\]/g, ".$1")
699
+ .split(".")
700
+ .filter(Boolean);
701
+ }
702
+
703
+ /**
704
+ * HTML 实体反转义。
705
+ * Unescape HTML entities.
706
+ *
707
+ * @param {string} string 输入文本 / Input text.
708
+ * @returns {string}
709
+ * @see {@link https://lodash.com/docs/#unescape lodash.unescape}
710
+ * @see {@link https://www.lodashjs.com/docs/lodash.unescape lodash.unescape (中文)}
711
+ */
712
+ static unescape(string) {
713
+ const map = {
714
+ "&amp;": "&",
715
+ "&lt;": "<",
716
+ "&gt;": ">",
717
+ "&quot;": '"',
718
+ "&#39;": "'",
719
+ };
720
+ return string.replace(/&amp;|&lt;|&gt;|&quot;|&#39;/g, m => map[m]);
721
+ }
722
+
723
+ /**
724
+ * 删除对象路径对应的值。
725
+ * Remove value by object path.
726
+ *
727
+ * @param {object} [object={}] 目标对象 / Target object.
728
+ * @param {string|string[]} [path=""] 路径 / Path.
729
+ * @returns {boolean}
730
+ * @see {@link https://lodash.com/docs/#unset lodash.unset}
731
+ * @see {@link https://www.lodashjs.com/docs/lodash.unset lodash.unset (中文)}
732
+ */
733
+ static unset(object = {}, path = "") {
734
+ if (!Array.isArray(path)) path = Lodash.toPath(path);
735
+ const result = path.reduce((previousValue, currentValue, currentIndex) => {
736
+ if (currentIndex === path.length - 1) {
737
+ delete previousValue[currentValue];
738
+ return true;
739
+ }
740
+ return Object(previousValue)[currentValue];
741
+ }, object);
742
+ return result;
743
+ }
744
+ }
745
+
746
+ /**
747
+ * 当前运行平台名称(脚本平台优先,模块系统次之)。
748
+ * Current runtime platform name (script platform first, module system second).
749
+ *
750
+ * 识别顺序:
751
+ * Detection order:
752
+ * 1) `$task` -> Quantumult X
753
+ * 2) `$loon` -> Loon
754
+ * 3) `$rocket` -> Shadowrocket
755
+ * 4) `Egern` -> Egern
756
+ * 5) `$environment["surge-version"]` -> Surge
757
+ * 6) `$environment["stash-version"]` -> Stash
758
+ * 7) `Cloudflare` -> Worker
759
+ * 8) `process.versions.node` -> Node.js
760
+ * 9) 默认回落 -> undefined
761
+ * default fallback -> undefined
762
+ *
763
+ * 说明:
764
+ * Notes:
765
+ * - 使用 `'key' in globalThis`,避免 `Object.keys` 对不可枚举全局变量漏检。
766
+ * - Use `'key' in globalThis` to avoid missing non-enumerable globals with `Object.keys`.
767
+ *
768
+ * @type {("Quantumult X" | "Loon" | "Shadowrocket" | "Egern" | "Surge" | "Stash" | "Worker" | "Node.js" | undefined)}
769
+ */
770
+ const $app = (() => {
771
+ const has = key => key in globalThis;
772
+ switch (true) {
773
+ case has("$task"):
774
+ return "Quantumult X";
775
+ case has("$loon"):
776
+ return "Loon";
777
+ case has("$rocket"):
778
+ return "Shadowrocket";
779
+ case has("Egern"):
780
+ return "Egern";
781
+ case Boolean(globalThis.$environment?.["surge-version"]):
782
+ return "Surge";
783
+ case Boolean(globalThis.$environment?.["stash-version"]):
784
+ return "Stash";
785
+ case has("Cloudflare"):
786
+ //case has("ServiceWorkerGlobalScope") && has("self") && has("caches") && has("scheduler"):
787
+ return "Worker";
788
+ case Boolean(globalThis.process?.versions?.node):
789
+ return "Node.js";
790
+ default:
791
+ return undefined;
792
+ }
793
+ })();
794
+
795
+ /**
796
+ * 跨平台持久化存储适配器。
797
+ * Cross-platform persistent storage adapter.
798
+ *
799
+ * 设计目标:
800
+ * Design goal:
801
+ * - 仿照 Web Storage (`Storage`) 接口设计
802
+ * - Modeled after Web Storage (`Storage`) interface
803
+ * - 统一 VPN App 脚本环境中的持久化读写接口
804
+ * - Unify persistence APIs across VPN app script environments
805
+ *
806
+ * 支持后端:
807
+ * Supported backends:
808
+ * - Surge/Loon/Stash/Egern/Shadowrocket: `$persistentStore`
809
+ * - Quantumult X: `$prefs`
810
+ * - Worker: 内存缓存(非持久化)
811
+ * - Worker: in-memory cache (non-persistent)
812
+ * - Node.js: 由 Node.js ESM 入口注入持久化后端
813
+ * - Node.js: persistent backend injected by the Node.js ESM entry
814
+ *
815
+ * 支持路径键:
816
+ * Supports path key:
817
+ * - `@root.path.to.value`
818
+ *
819
+ * Web Storage 的已知差异:
820
+ * Known differences from Web Storage:
821
+ * - 支持 `@key.path` 深路径读写(Web Storage 原生不支持)
822
+ * - Supports `@key.path` deep-path access (not native in Web Storage)
823
+ * - `removeItem/clear` 并非所有平台都可用
824
+ * - `removeItem/clear` are not available on every platform
825
+ * - 读取时会尝试 `JSON.parse`,写入对象会 `JSON.stringify`
826
+ * - Reads try `JSON.parse`, writes stringify objects
827
+ *
828
+ * @link https://developer.mozilla.org/en-US/docs/Web/API/Storage
829
+ * @link https://developer.mozilla.org/zh-CN/docs/Web/API/Storage
830
+ */
831
+ class Storage {
832
+ /**
833
+ * Worker / Node.js 环境下的内存数据缓存。
834
+ * In-memory data cache for Worker / Node.js runtime.
835
+ *
836
+ * @type {Record<string, any>|null}
837
+ */
838
+ static data = null;
839
+
840
+ /**
841
+ * Node.js 持久化文件名。
842
+ * Data file name used in Node.js.
843
+ *
844
+ * @type {string}
845
+ */
846
+ static dataFile = "box.dat";
847
+
848
+ /**
849
+ * Node.js ESM 入口注入的存储后端。
850
+ * Storage backend injected by the Node.js ESM entry.
851
+ *
852
+ * @type {{load: (dataFile: string) => Record<string, any>, write: (dataFile: string, data: Record<string, any>) => void}|null}
853
+ */
854
+ static nodeBackend = null;
855
+
856
+ /**
857
+ * `@key.path` 解析正则。
858
+ * Regex for `@key.path` parsing.
859
+ *
860
+ * @type {RegExp}
861
+ */
862
+ static #nameRegex = /^@(?<key>[^.]+)(?:\.(?<path>.*))?$/;
863
+
864
+ /**
865
+ * 读取存储值。
866
+ * Read value from persistent storage.
867
+ *
868
+ * @param {string} keyName 键名或路径键 / Key or path key.
869
+ * @param {*} [defaultValue=null] 默认值 / Default value when key is missing.
870
+ * @returns {*}
871
+ */
872
+ static getItem(keyName, defaultValue = null) {
873
+ let keyValue = defaultValue;
874
+ // 如果以 @
875
+ switch (keyName.startsWith("@")) {
876
+ case true: {
877
+ const { key, path } = keyName.match(Storage.#nameRegex)?.groups;
878
+ keyName = key;
879
+ let value = Storage.getItem(keyName, {});
880
+ if (typeof value !== "object") value = {};
881
+ keyValue = Lodash.get(value, path);
882
+ try {
883
+ keyValue = JSON.parse(keyValue);
884
+ } catch {}
885
+ break;
886
+ }
887
+ default:
888
+ switch ($app) {
889
+ case "Surge":
890
+ case "Loon":
891
+ case "Stash":
892
+ case "Egern":
893
+ case "Shadowrocket":
894
+ keyValue = $persistentStore.read(keyName);
895
+ break;
896
+ case "Quantumult X":
897
+ keyValue = $prefs.valueForKey(keyName);
898
+ break;
899
+ case "Worker":
900
+ Storage.data = Storage.data ?? {};
901
+ keyValue = Storage.data[keyName];
902
+ break;
903
+ case "Node.js":
904
+ Storage.data = Storage.nodeBackend.load(Storage.dataFile);
905
+ keyValue = Storage.data?.[keyName];
906
+ break;
907
+ default:
908
+ keyValue = Storage.data?.[keyName] || null;
909
+ break;
910
+ }
911
+ try {
912
+ keyValue = JSON.parse(keyValue);
913
+ } catch {
914
+ // do nothing
915
+ }
916
+ break;
917
+ }
918
+ return keyValue ?? defaultValue;
919
+ }
920
+
921
+ /**
922
+ * 写入存储值。
923
+ * Write value into persistent storage.
924
+ *
925
+ * @param {string} keyName 键名或路径键 / Key or path key.
926
+ * @param {*} keyValue 写入值 / Value to store.
927
+ * @returns {boolean}
928
+ */
929
+ static setItem(keyName = new String(), keyValue = new String()) {
930
+ let result = false;
931
+ switch (typeof keyValue) {
932
+ case "object":
933
+ keyValue = JSON.stringify(keyValue);
934
+ break;
935
+ default:
936
+ keyValue = String(keyValue);
937
+ break;
938
+ }
939
+ switch (keyName.startsWith("@")) {
940
+ case true: {
941
+ const { key, path } = keyName.match(Storage.#nameRegex)?.groups;
942
+ keyName = key;
943
+ let value = Storage.getItem(keyName, {});
944
+ if (typeof value !== "object") value = {};
945
+ Lodash.set(value, path, keyValue);
946
+ result = Storage.setItem(keyName, value);
947
+ break;
948
+ }
949
+ default:
950
+ switch ($app) {
951
+ case "Surge":
952
+ case "Loon":
953
+ case "Stash":
954
+ case "Egern":
955
+ case "Shadowrocket":
956
+ result = $persistentStore.write(keyValue, keyName);
957
+ break;
958
+ case "Quantumult X":
959
+ result = $prefs.setValueForKey(keyValue, keyName);
960
+ break;
961
+ case "Worker":
962
+ Storage.data = Storage.data ?? {};
963
+ Storage.data[keyName] = keyValue;
964
+ result = true;
965
+ break;
966
+ case "Node.js":
967
+ Storage.data = Storage.nodeBackend.load(Storage.dataFile);
968
+ Storage.data[keyName] = keyValue;
969
+ Storage.nodeBackend.write(Storage.dataFile, Storage.data);
970
+ result = true;
971
+ break;
972
+ default:
973
+ result = Storage.data?.[keyName] || null;
974
+ break;
975
+ }
976
+ break;
977
+ }
978
+ return result;
979
+ }
980
+
981
+ /**
982
+ * 删除存储值。
983
+ * Remove value from persistent storage.
984
+ *
985
+ * 平台说明:
986
+ * Platform notes:
987
+ * - Quantumult X: `$prefs.removeValueForKey`
988
+ * - Surge: 通过 `$persistentStore.write(null, keyName)` 删除
989
+ * - 其余平台当前返回 `false`
990
+ *
991
+ * @param {string} keyName 键名或路径键 / Key or path key.
992
+ * @returns {boolean}
993
+ */
994
+ static removeItem(keyName) {
995
+ let result = false;
996
+ switch (keyName.startsWith("@")) {
997
+ case true: {
998
+ const { key, path } = keyName.match(Storage.#nameRegex)?.groups;
999
+ keyName = key;
1000
+ let value = Storage.getItem(keyName);
1001
+ if (typeof value !== "object") value = {};
1002
+ Lodash.unset(value, path);
1003
+ result = Storage.setItem(keyName, value);
1004
+ break;
1005
+ }
1006
+ default:
1007
+ switch ($app) {
1008
+ case "Surge":
1009
+ result = $persistentStore.write(null, keyName);
1010
+ break;
1011
+ case "Loon":
1012
+ case "Stash":
1013
+ case "Egern":
1014
+ case "Shadowrocket":
1015
+ result = false;
1016
+ break;
1017
+ case "Quantumult X":
1018
+ result = $prefs.removeValueForKey(keyName);
1019
+ break;
1020
+ case "Worker":
1021
+ Storage.data = Storage.data ?? {};
1022
+ delete Storage.data[keyName];
1023
+ result = true;
1024
+ break;
1025
+ case "Node.js":
1026
+ // result = false;
1027
+ Storage.data = Storage.nodeBackend.load(Storage.dataFile);
1028
+ delete Storage.data[keyName];
1029
+ Storage.nodeBackend.write(Storage.dataFile, Storage.data);
1030
+ result = true;
1031
+ break;
1032
+ default:
1033
+ result = false;
1034
+ break;
1035
+ }
1036
+ break;
1037
+ }
1038
+ return result;
1039
+ }
1040
+
1041
+ /**
1042
+ * 清空存储。
1043
+ * Clear storage.
1044
+ *
1045
+ * @returns {boolean}
1046
+ */
1047
+ static clear() {
1048
+ let result = false;
1049
+ switch ($app) {
1050
+ case "Surge":
1051
+ case "Loon":
1052
+ case "Stash":
1053
+ case "Egern":
1054
+ case "Shadowrocket":
1055
+ result = false;
1056
+ break;
1057
+ case "Quantumult X":
1058
+ result = $prefs.removeAllValues();
1059
+ break;
1060
+ case "Worker":
1061
+ Storage.data = {};
1062
+ result = true;
1063
+ break;
1064
+ case "Node.js":
1065
+ // result = false;
1066
+ Storage.data = Storage.nodeBackend.load(Storage.dataFile);
1067
+ Storage.data = {};
1068
+ Storage.nodeBackend.write(Storage.dataFile, Storage.data);
1069
+ result = true;
1070
+ break;
1071
+ default:
1072
+ result = false;
1073
+ break;
1074
+ }
1075
+ return result;
1076
+ }
1077
+ }
1078
+
1079
+ /**
1080
+ * 根据 BoxJS 目录桥接持久化存储,不下载配置或解析控件。
1081
+ * Bridge persistence using the BoxJS catalog without downloading configuration or interpreting controls.
1082
+ */
1083
+ class Store {
1084
+ #catalog;
1085
+
1086
+ /**
1087
+ * 复用包内已解析的目录,构造时不访问网络或存储。
1088
+ * Reuse the parsed internal catalog without network or persistence access during construction.
1089
+ * @param {import("./BoxJS.mjs").BoxJS} catalog BoxJS 路径目录 / BoxJS path catalog.
1090
+ */
1091
+ constructor(catalog) {
1092
+ this.#catalog = catalog;
1093
+ }
1094
+
1095
+ /**
1096
+ * GET 返回指定值,POST 替换指定值,DELETE 删除指定键或整个模块。
1097
+ * GET returns a value, POST replaces it, and DELETE removes a key or the entire module.
1098
+ * @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
1099
+ * @param {URL} [url] 包内复用的已解析地址 / Parsed URL reused within the package.
1100
+ * @returns {Promise<import("./index.js").SettingsResponse | undefined>} 响应或非接管请求 / Response, or undefined for an unhandled request.
1101
+ */
1102
+ async handle(request, url = new URL(request.url)) {
1103
+ if (!url.pathname.startsWith("/api/")) return;
1104
+ const reply = (status, data) => response(request, status, data);
1105
+ let parts;
1106
+ try {
1107
+ parts = parseSettingsPathname(url.pathname);
1108
+ } catch (error) {
1109
+ return reply(400, { error: error.message });
1110
+ }
1111
+ const binding = this.#catalog.modules.get(parts[0]);
1112
+ if (!binding) return reply(404, { error: "Module is not declared in BoxJS" });
1113
+ const requestHeaders = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
1114
+ if (requestHeaders["x-settings-client"] !== "1" || (requestHeaders.origin && requestHeaders.origin !== url.origin)) return reply(403, { error: "Forbidden settings client" });
1115
+ let value;
1116
+ switch (request.method) {
1117
+ case "HEAD":
1118
+ return reply(200, undefined);
1119
+ case "GET":
1120
+ case "DELETE":
1121
+ break;
1122
+ case "POST":
1123
+ if (requestHeaders["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/json") return reply(415, { error: "Expected application/json" });
1124
+ if (typeof request.body !== "string") return reply(400, { error: "Expected a JSON string body" });
1125
+ if (request.body.length > 65536) return reply(413, { error: "Body exceeds 65536 UTF-16 code units" });
1126
+ try {
1127
+ value = JSON.parse(request.body);
1128
+ } catch {
1129
+ return reply(400, { error: "Invalid JSON" });
1130
+ }
1131
+ break;
1132
+ default:
1133
+ return { ...reply(405, { error: "Method not allowed" }), headers: { ...reply(405).headers, Allow: "HEAD, GET, POST, DELETE" } };
1134
+ }
1135
+ try {
1136
+ const root = Storage.getItem(binding.storageKey, {});
1137
+ if (!isRecord(root)) throw new TypeError("stored root must be an object");
1138
+ const parent = storageParent(root, parts, request.method === "POST");
1139
+ const key = parts.at(-1);
1140
+ switch (request.method) {
1141
+ case "GET": {
1142
+ const result = parent ? Lodash.get(parent, [key]) : undefined;
1143
+ return result === undefined ? reply(404, { error: "Stored path does not exist" }) : reply(200, result);
1144
+ }
1145
+ case "POST":
1146
+ Lodash.set(parent, [key], value);
1147
+ break;
1148
+ case "DELETE":
1149
+ if (parent) Lodash.unset(parent, [key]);
1150
+ break;
1151
+ }
1152
+ if (!Storage.setItem(binding.storageKey, root)) throw new Error("Storage write failed");
1153
+ return reply(200, request.method === "POST" ? { saved: true } : { deleted: true });
1154
+ } catch (error) {
1155
+ return reply(500, { error: error.message });
1156
+ }
1157
+ }
1158
+ }
1159
+
1160
+ /**
1161
+ * 判断根节点是否为普通对象。
1162
+ * Determine whether a root node is a plain object.
1163
+ * @param {unknown} value 待检查值 / Value to inspect.
1164
+ * @returns {boolean} 是否为普通对象 / Whether this is a plain object.
1165
+ */
1166
+ function isRecord(value) {
1167
+ return value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype;
1168
+ }
1169
+
1170
+ /**
1171
+ * 遍历父路径,兼容旧存储中 JSON 字符串形式的中间节点。
1172
+ * Traverse parents, supporting legacy intermediate nodes serialized as JSON strings.
1173
+ * @param {Record<string, unknown>} root 存储根 / Storage root.
1174
+ * @param {string[]} parts 完整路径 / Complete path.
1175
+ * @param {boolean} create 是否创建缺失节点 / Whether to create missing parents.
1176
+ * @returns {object | undefined} 父节点,缺失且不创建时为 undefined / Parent, or undefined when absent and not creating.
1177
+ * @throws {TypeError} 无法继续遍历标量节点 / A scalar node cannot be traversed.
1178
+ */
1179
+ function storageParent(root, parts, create) {
1180
+ let parent = root;
1181
+ for (const part of parts.slice(0, -1)) {
1182
+ let next = Lodash.get(parent, [part]);
1183
+ switch (typeof next) {
1184
+ case "undefined":
1185
+ if (!create) return;
1186
+ next = {};
1187
+ break;
1188
+ case "string":
1189
+ next = JSON.parse(next);
1190
+ break;
1191
+ }
1192
+ if (!isRecord(next) && !Array.isArray(next)) throw new TypeError("Stored parent is not an object or array");
1193
+ Lodash.set(parent, [part], next);
1194
+ parent = next;
1195
+ }
1196
+ return parent;
1197
+ }
1198
+
1199
+ /**
1200
+ * 统一日志工具,兼容各脚本平台、Worker 与 Node.js。
1201
+ * Unified logger compatible with script platforms, Worker, and Node.js.
1202
+ *
1203
+ * logLevel 用法:
1204
+ * logLevel usage:
1205
+ * - 可读: `Console.logLevel` 返回 `OFF|ERROR|WARN|INFO|DEBUG|ALL`
1206
+ * - Read: `Console.logLevel` returns `OFF|ERROR|WARN|INFO|DEBUG|ALL`
1207
+ * - 可写: 数字 `0~5` 或字符串 `off/error/warn/info/debug/all`
1208
+ * - Write: number `0~5` or string `off/error/warn/info/debug/all`
1209
+ *
1210
+ * @example
1211
+ * Console.logLevel = "debug";
1212
+ * Console.debug("only shown when level >= DEBUG");
1213
+ * Console.logLevel = 2; // WARN
1214
+ */
1215
+ class Console {
1216
+ static #counts = new Map([]);
1217
+ static #groups = [];
1218
+ static #times = new Map([]);
1219
+
1220
+ /**
1221
+ * 清空控制台(当前为空实现)。
1222
+ * Clear console (currently a no-op).
1223
+ *
1224
+ * @returns {void}
1225
+ */
1226
+ static clear = () => {};
1227
+
1228
+ /**
1229
+ * 增加计数器并打印当前值。
1230
+ * Increment counter and print the current value.
1231
+ *
1232
+ * @param {string} [label="default"] 计数器名称 / Counter label.
1233
+ * @returns {void}
1234
+ */
1235
+ static count = (label = "default") => {
1236
+ switch (Console.#counts.has(label)) {
1237
+ case true:
1238
+ Console.#counts.set(label, Console.#counts.get(label) + 1);
1239
+ break;
1240
+ case false:
1241
+ Console.#counts.set(label, 0);
1242
+ break;
1243
+ }
1244
+ Console.log(`${label}: ${Console.#counts.get(label)}`);
1245
+ };
1246
+
1247
+ /**
1248
+ * 重置计数器。
1249
+ * Reset a counter.
1250
+ *
1251
+ * @param {string} [label="default"] 计数器名称 / Counter label.
1252
+ * @returns {void}
1253
+ */
1254
+ static countReset = (label = "default") => {
1255
+ switch (Console.#counts.has(label)) {
1256
+ case true:
1257
+ Console.#counts.set(label, 0);
1258
+ Console.log(`${label}: ${Console.#counts.get(label)}`);
1259
+ break;
1260
+ case false:
1261
+ Console.warn(`Counter "${label}" doesn’t exist`);
1262
+ break;
1263
+ }
1264
+ };
1265
+
1266
+ /**
1267
+ * 输出调试日志。
1268
+ * Print debug logs.
1269
+ *
1270
+ * @param {...any} msg 日志内容 / Log messages.
1271
+ * @returns {void}
1272
+ */
1273
+ static debug = (...msg) => {
1274
+ if (Console.#level < 4) return;
1275
+ msg = msg.map(m => `🅱️ ${m}`);
1276
+ Console.log(...msg);
1277
+ };
1278
+
1279
+ /**
1280
+ * 输出错误日志。
1281
+ * Print error logs.
1282
+ *
1283
+ * @param {...any} msg 日志内容 / Log messages.
1284
+ * @returns {void}
1285
+ */
1286
+ static error(...msg) {
1287
+ if (Console.#level < 1) return;
1288
+ switch ($app) {
1289
+ case "Surge":
1290
+ case "Loon":
1291
+ case "Stash":
1292
+ case "Egern":
1293
+ case "Shadowrocket":
1294
+ case "Quantumult X":
1295
+ default:
1296
+ msg = msg.map(m => `❌ ${m}`);
1297
+ break;
1298
+ case "Worker":
1299
+ case "Node.js":
1300
+ msg = msg.map(m => `❌ ${m?.stack ?? m}`);
1301
+ break;
1302
+ }
1303
+ Console.log(...msg);
1304
+ }
1305
+
1306
+ /**
1307
+ * `error` 的别名。
1308
+ * Alias of `error`.
1309
+ *
1310
+ * @param {...any} msg 日志内容 / Log messages.
1311
+ * @returns {void}
1312
+ */
1313
+ static exception = (...msg) => Console.error(...msg);
1314
+
1315
+ /**
1316
+ * 进入日志分组。
1317
+ * Enter a log group.
1318
+ *
1319
+ * @param {string} label 分组名 / Group label.
1320
+ * @returns {number}
1321
+ */
1322
+ static group = label => Console.#groups.unshift(label);
1323
+
1324
+ /**
1325
+ * 退出日志分组。
1326
+ * Exit the latest log group.
1327
+ *
1328
+ * @returns {*}
1329
+ */
1330
+ static groupEnd = () => Console.#groups.shift();
1331
+
1332
+ /**
1333
+ * 输出信息日志。
1334
+ * Print info logs.
1335
+ *
1336
+ * @param {...any} msg 日志内容 / Log messages.
1337
+ * @returns {void}
1338
+ */
1339
+ static info(...msg) {
1340
+ if (Console.#level < 3) return;
1341
+ msg = msg.map(m => `ℹ️ ${m}`);
1342
+ Console.log(...msg);
1343
+ }
1344
+
1345
+ static #level = 3;
1346
+
1347
+ /**
1348
+ * 获取日志级别文本。
1349
+ * Get current log level text.
1350
+ *
1351
+ * @returns {"OFF"|"ERROR"|"WARN"|"INFO"|"DEBUG"|"ALL"}
1352
+ */
1353
+ static get logLevel() {
1354
+ switch (Console.#level) {
1355
+ case 0:
1356
+ return "OFF";
1357
+ case 1:
1358
+ return "ERROR";
1359
+ case 2:
1360
+ return "WARN";
1361
+ case 3:
1362
+ default:
1363
+ return "INFO";
1364
+ case 4:
1365
+ return "DEBUG";
1366
+ case 5:
1367
+ return "ALL";
1368
+ }
1369
+ }
1370
+
1371
+ /**
1372
+ * 设置日志级别。
1373
+ * Set current log level.
1374
+ *
1375
+ * @param {number|string} level 级别值 / Level value.
1376
+ */
1377
+ static set logLevel(level) {
1378
+ switch (typeof level) {
1379
+ case "string":
1380
+ level = level.toLowerCase();
1381
+ break;
1382
+ case "number":
1383
+ break;
1384
+ case "undefined":
1385
+ default:
1386
+ level = "warn";
1387
+ break;
1388
+ }
1389
+ switch (level) {
1390
+ case 0:
1391
+ case "off":
1392
+ Console.#level = 0;
1393
+ break;
1394
+ case 1:
1395
+ case "error":
1396
+ Console.#level = 1;
1397
+ break;
1398
+ case 2:
1399
+ case "warn":
1400
+ case "warning":
1401
+ default:
1402
+ Console.#level = 2;
1403
+ break;
1404
+ case 3:
1405
+ case "info":
1406
+ Console.#level = 3;
1407
+ break;
1408
+ case 4:
1409
+ case "debug":
1410
+ Console.#level = 4;
1411
+ break;
1412
+ case 5:
1413
+ case "all":
1414
+ Console.#level = 5;
1415
+ break;
1416
+ }
1417
+ }
1418
+
1419
+ /**
1420
+ * 输出通用日志。
1421
+ * Print generic logs.
1422
+ *
1423
+ * 说明:
1424
+ * Notes:
1425
+ * - 多行字符串参数会按换行拆分为多个独立日志项。
1426
+ * - Multi-line string arguments are split into multiple log entries by line breaks.
1427
+ *
1428
+ * @param {...any} msg 日志内容 / Log messages.
1429
+ * @returns {void}
1430
+ */
1431
+ static log = (...msg) => {
1432
+ if (Console.#level === 0) return;
1433
+ msg = msg.flatMap(log => {
1434
+ switch (typeof log) {
1435
+ case "object":
1436
+ return [JSON.stringify(log)];
1437
+ case "bigint":
1438
+ case "number":
1439
+ case "boolean":
1440
+ return [log.toString()];
1441
+ case "string":
1442
+ return log.split(/\r?\n/u);
1443
+ case "undefined":
1444
+ default:
1445
+ return [log];
1446
+ }
1447
+ });
1448
+ Console.#groups.forEach(group => {
1449
+ msg = msg.map(log => ` ${log}`);
1450
+ msg.unshift(`▼ ${group}:`);
1451
+ });
1452
+ msg = ["", ...msg];
1453
+ console.log(msg.join("\n"));
1454
+ };
1455
+
1456
+ /**
1457
+ * 开始计时。
1458
+ * Start timer.
1459
+ *
1460
+ * @param {string} [label="default"] 计时器名称 / Timer label.
1461
+ * @returns {Map<string, number>}
1462
+ */
1463
+ static time = (label = "default") => Console.#times.set(label, Date.now());
1464
+
1465
+ /**
1466
+ * 结束计时并移除计时器。
1467
+ * End timer and remove it.
1468
+ *
1469
+ * @param {string} [label="default"] 计时器名称 / Timer label.
1470
+ * @returns {boolean}
1471
+ */
1472
+ static timeEnd = (label = "default") => Console.#times.delete(label);
1473
+
1474
+ /**
1475
+ * 输出当前计时器耗时。
1476
+ * Print elapsed time for a timer.
1477
+ *
1478
+ * @param {string} [label="default"] 计时器名称 / Timer label.
1479
+ * @returns {void}
1480
+ */
1481
+ static timeLog = (label = "default") => {
1482
+ const time = Console.#times.get(label);
1483
+ if (time) Console.log(`${label}: ${Date.now() - time}ms`);
1484
+ else Console.warn(`Timer "${label}" doesn’t exist`);
1485
+ };
1486
+
1487
+ /**
1488
+ * 输出警告日志。
1489
+ * Print warning logs.
1490
+ *
1491
+ * @param {...any} msg 日志内容 / Log messages.
1492
+ * @returns {void}
1493
+ */
1494
+ static warn(...msg) {
1495
+ if (Console.#level < 2) return;
1496
+ msg = msg.map(m => `⚠️ ${m}`);
1497
+ Console.log(...msg);
1498
+ }
1499
+ }
1500
+
1501
+ /**
1502
+ * HTTP 状态码文本映射表。
1503
+ * HTTP status code to status text map.
1504
+ *
1505
+ * 主要用途:
1506
+ * Primary usage:
1507
+ * - 为 Quantumult X 的 `$done` 状态行拼接提供状态文本
1508
+ * - Provide status text for Quantumult X `$done` status-line composition
1509
+ * - QX 在部分场景要求 `status` 为完整状态行(如 `HTTP/1.1 200 OK`)
1510
+ * - QX may require full status line (e.g. `HTTP/1.1 200 OK`) in some cases
1511
+ *
1512
+ * 参考:
1513
+ * Reference:
1514
+ * - https://github.com/crossutility/Quantumult-X/raw/refs/heads/master/sample-rewrite-response-header.js
1515
+ *
1516
+ * @type {Record<number, string>}
1517
+ */
1518
+ const StatusTexts = {
1519
+ 100: "Continue",
1520
+ 101: "Switching Protocols",
1521
+ 102: "Processing",
1522
+ 103: "Early Hints",
1523
+ 200: "OK",
1524
+ 201: "Created",
1525
+ 202: "Accepted",
1526
+ 203: "Non-Authoritative Information",
1527
+ 204: "No Content",
1528
+ 205: "Reset Content",
1529
+ 206: "Partial Content",
1530
+ 207: "Multi-Status",
1531
+ 208: "Already Reported",
1532
+ 226: "IM Used",
1533
+ 300: "Multiple Choices",
1534
+ 301: "Moved Permanently",
1535
+ 302: "Found",
1536
+ 304: "Not Modified",
1537
+ 307: "Temporary Redirect",
1538
+ 308: "Permanent Redirect",
1539
+ 400: "Bad Request",
1540
+ 401: "Unauthorized",
1541
+ 402: "Payment Required",
1542
+ 403: "Forbidden",
1543
+ 404: "Not Found",
1544
+ 405: "Method Not Allowed",
1545
+ 406: "Not Acceptable",
1546
+ 407: "Proxy Authentication Required",
1547
+ 408: "Request Timeout",
1548
+ 409: "Conflict",
1549
+ 410: "Gone",
1550
+ 411: "Length Required",
1551
+ 412: "Precondition Failed",
1552
+ 413: "Content Too Large",
1553
+ 414: "URI Too Long",
1554
+ 415: "Unsupported Media Type",
1555
+ 416: "Range Not Satisfiable",
1556
+ 417: "Expectation Failed",
1557
+ 418: "I'm a teapot",
1558
+ 421: "Misdirected Request",
1559
+ 422: "Unprocessable Entity",
1560
+ 423: "Locked",
1561
+ 424: "Failed Dependency",
1562
+ 425: "Too Early",
1563
+ 426: "Upgrade Required",
1564
+ 428: "Precondition Required",
1565
+ 429: "Too Many Requests",
1566
+ 431: "Request Header Fields Too Large",
1567
+ 451: "Unavailable For Legal Reasons",
1568
+ 500: "Internal Server Error",
1569
+ 501: "Not Implemented",
1570
+ 502: "Bad Gateway",
1571
+ 503: "Service Unavailable",
1572
+ 504: "Gateway Timeout",
1573
+ 505: "HTTP Version Not Supported",
1574
+ 506: "Variant Also Negotiates",
1575
+ 507: "Insufficient Storage",
1576
+ 508: "Loop Detected",
1577
+ 510: "Not Extended",
1578
+ 511: "Network Authentication Required",
1579
+ };
1580
+
1581
+ /**
1582
+ * `done` 的统一入参结构。
1583
+ * Unified `done` input payload.
1584
+ *
1585
+ * @typedef {object} DonePayload
1586
+ * @property {number|string} [status] 响应状态码或状态行 / Response status code or status line.
1587
+ * @property {string} [url] 响应 URL / Response URL.
1588
+ * @property {Record<string, any>} [headers] 响应头 / Response headers.
1589
+ * @property {string|ArrayBuffer|ArrayBufferView} [body] 响应体 / Response body.
1590
+ * @property {ArrayBuffer} [bodyBytes] 二进制响应体 / Binary response body.
1591
+ * @property {string} [policy] 指定策略名 / Preferred policy name.
1592
+ */
1593
+
1594
+ /**
1595
+ * 结束脚本执行并按平台转换参数。
1596
+ * Complete script execution with platform-specific parameter mapping.
1597
+ *
1598
+ * 说明:
1599
+ * Notes:
1600
+ * - 这是调用入口,平台原生 `$done` 差异在内部处理
1601
+ * - This is the call entry and native `$done` differences are handled internally
1602
+ * - Worker 不调用 `$done` 或退出进程,仅记录日志
1603
+ * - Worker neither calls `$done` nor exits the process; it only logs
1604
+ * - Node.js 不调用 `$done`,而是直接退出进程
1605
+ * - Node.js does not call `$done`; it exits the process directly
1606
+ * - 未识别平台仅记录结束日志,不会强制退出
1607
+ * - Unknown runtimes only log completion and do not force an exit
1608
+ *
1609
+ * @param {DonePayload} [object={}] 统一响应对象 / Unified response object.
1610
+ * @returns {void}
1611
+ */
1612
+ function done(object = {}) {
1613
+ switch ($app) {
1614
+ case "Surge":
1615
+ if (object.policy) Lodash.set(object, "headers.X-Surge-Policy", object.policy);
1616
+ Console.log("🚩 执行结束!", `🕛 ${new Date().getTime() / 1000 - $script.startTime} 秒`);
1617
+ $done(object);
1618
+ break;
1619
+ case "Loon":
1620
+ if (object.policy) object.node = object.policy;
1621
+ Console.log("🚩 执行结束!", `🕛 ${(new Date() - $script.startTime) / 1000} 秒`);
1622
+ $done(object);
1623
+ break;
1624
+ case "Stash":
1625
+ if (object.policy) Lodash.set(object, "headers.X-Stash-Selected-Proxy", encodeURI(object.policy));
1626
+ Console.log("🚩 执行结束!", `🕛 ${(new Date() - $script.startTime) / 1000} 秒`);
1627
+ $done(object);
1628
+ break;
1629
+ case "Egern":
1630
+ Console.log("🚩 执行结束!");
1631
+ $done(object);
1632
+ break;
1633
+ case "Shadowrocket":
1634
+ Console.log("🚩 执行结束!");
1635
+ $done(object);
1636
+ break;
1637
+ case "Quantumult X":
1638
+ if (object.policy) Lodash.set(object, "opts.policy", object.policy);
1639
+ object = Lodash.pick(object, ["status", "url", "headers", "body", "bodyBytes"]);
1640
+ switch (typeof object.status) {
1641
+ case "number":
1642
+ object.status = `HTTP/1.1 ${object.status} ${StatusTexts[object.status]}`;
1643
+ break;
1644
+ case "string":
1645
+ case "undefined":
1646
+ break;
1647
+ default:
1648
+ throw new TypeError(`${Function.name}: 参数类型错误, status 必须为数字或字符串`);
1649
+ }
1650
+ if (object.body instanceof ArrayBuffer) {
1651
+ object.bodyBytes = object.body;
1652
+ object.body = undefined;
1653
+ } else if (ArrayBuffer.isView(object.body)) {
1654
+ object.bodyBytes = object.body.buffer.slice(object.body.byteOffset, object.body.byteLength + object.body.byteOffset);
1655
+ object.body = undefined;
1656
+ } else if (object.body) object.bodyBytes = undefined;
1657
+ Console.log("🚩 执行结束!");
1658
+ $done(object);
1659
+ break;
1660
+ case "Worker":
1661
+ Console.log("🚩 执行结束!");
1662
+ break;
1663
+ case "Node.js":
1664
+ Console.log("🚩 执行结束!");
1665
+ process.exit(1);
1666
+ break;
1667
+ default:
1668
+ Console.log("🚩 执行结束!");
1669
+ break;
1670
+ }
1671
+ }
1672
+
1673
+ /**
1674
+ * 统一适配代理的完成格式;未接管的请求原样继续。
1675
+ * Adapt the host completion format and pass through unhandled requests.
1676
+ * @param {import("../index.js").SettingsResponse | undefined} result 通用响应 / Common response.
1677
+ * @returns {void} 响应已交给宿主 / Response delivered to the host.
1678
+ */
1679
+ function complete(result) {
1680
+ if (!result) {
1681
+ done({});
1682
+ return;
1683
+ }
1684
+ done($app === "Quantumult X" ? result : { response: result });
1685
+ }
1686
+
1687
+ /**
1688
+ * 仅为导入的模块提供页面与存储服务,项目主页由调用方自行托管。
1689
+ * Serve only the imported module's page and persistence; callers host their own project landing pages.
1690
+ * @param {unknown} boxjs BoxJS JSON / BoxJS JSON.
1691
+ * @param {string} [css] 自定义 CSS 正文 / Custom CSS text.
1692
+ * @returns {Promise<void>} 已提交宿主响应 / Delivered host response.
1693
+ */
1694
+ async function run(boxjs, css = "") {
1695
+ const request = globalThis.$request;
1696
+ let result;
1697
+ try {
1698
+ if (typeof css !== "string") throw new TypeError("CSS must be a string");
1699
+ const catalog = new BoxJS(boxjs);
1700
+ const module = catalog.module.module;
1701
+ const url = new URL(request.url);
1702
+ switch (true) {
1703
+ case url.pathname.startsWith("/api/"):
1704
+ result = await new Store(catalog).handle(request, url);
1705
+ break;
1706
+ case url.pathname.startsWith("/configs/"):
1707
+ break;
1708
+ case url.pathname === `/settings/assets/${module}.css`:
1709
+ result = response(request, 200, css, "text/css");
1710
+ break;
1711
+ default: {
1712
+ const path = url.pathname === `/settings/${module}` || url.pathname === `/settings/${module}/` ? "page" : url.pathname;
1713
+ const asset = assets[path];
1714
+ if (asset) result = response(request, 200, asset.body, asset.type);
1715
+ }
1716
+ }
1717
+ if (result && !url.pathname.startsWith("/api/") && !["GET", "HEAD"].includes(request.method)) result = response(request, 405, { error: "Method not allowed" });
1718
+ } catch (error) {
1719
+ console.error(`PreferencePanes: ${error.message}`);
1720
+ result = response(request, 500, { error: "Settings execution failed" });
1721
+ }
1722
+ complete(result);
1723
+ }
1724
+
1725
+ exports.run = run;
1726
+
1727
+ return exports;
2123
1728
 
2124
1729
  })({});