@nsnanocat/preference-panes 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,76 +1,92 @@
1
1
  # @nsnanocat/preference-panes
2
2
 
3
- PreferencePanes 分别提供具体模块的设置前端和本地持久化 API。`web.js` 只返回模块 HTML 与公共浏览器资源,`api.js` 只处理模块数据和持久化;业务模块自己发布版本对应的 BoxJS JSON。项目主页及其客户端 SDK 调用属于调用方,不进入本包。
3
+ PreferencePanes 提供一个由 BoxJS JSON 驱动的通用设置前端,以及一个独立的代理配置与持久化 API。业务模块只发布自己的 `/configs/{module}`;通用 `/settings/**` 前端和 `/api/**` 后端分别只需要安装一次。
4
4
 
5
- ## 模块 API
5
+ Biliverse 中,Enhanced 是唯一安装 `web.js` 的模块。Global、Redirect、ADBlock 不携带 `web.js`,它们的设置页仍由同一份通用前端读取各自 BoxJS 后渲染。
6
6
 
7
- 嵌入的模块页跟随宿主根元素的 `data-theme`(light/dark)和 `--pp-keyboard-height`(CSS 长度),退出时释放观察器。独立页面使用网页自身或系统主题;通用包不识别任何客户端 User-Agent。
7
+ ## 浏览器入口
8
8
 
9
- 默认样式完全由包内 `pp-*` 类和 CSS 变量提供,不请求任何项目或客户端资源。调用方可通过 CSS 输入覆盖变量、组件,或在该 CSS 中自行引用所属客户端的官方样式。开关采用标准 checkbox 的 switch 属性及 switch 语义,由浏览器呈现原生控件([Safari/iOS 17.4 起显示开关](https://webkit.org/blog/15054/an-html-switch-control/),其他浏览器保留可操作的复选框);单选使用 select,多选保留二级页面。BoxJS 动态生成及修改即保存保持不变。
9
+ 浏览器包只公开一个入口:
10
10
 
11
- 宿主可监听 ModuleFrame 的 `confirm` 事件,调用 `preventDefault()` 接管确认框,再以 `event.detail.resolve(boolean)` 或 `reject(error)` 完成。未接管的独立网页使用浏览器对话框;模块离开后到达的确认结果不会继续写入。
12
-
13
- 宿主也可监听 `notice` 事件,通过 `preventDefault()` 接管 `{kind, message}` 提示;被接管时模块不创建网页 Toast、不启用提示计时器。独立使用的通用面板仍提供默认通知。
11
+ ```ts
12
+ function mount(boxjs: BoxJSInput): MountedPreferences;
13
+ interface MountedPreferences { destroy(): void }
14
+ ```
14
15
 
15
- 业务模块分别映射同一 Release 的两个产物:`api.js` 只匹配 `/api/{module}` 及其动作,`web.js` 只匹配 `/settings/{module}`、`/settings/assets/index.mjs` 和 `/settings/assets/navigation.mjs`。发布资源仍把 1.0.0 使用的 `/settings/assets/app.mjs` 映射到同一份 `index.mjs` 正文,兼容尚未更新的已安装模块,但不存在第二个页面入口实现。两者都不包含业务配置,也不需要额外安装独立设置插件。
16
+ ```js
17
+ import { mount } from "@nsnanocat/preference-panes/browser";
16
18
 
17
- 网页只调用模块 API:`HEAD /api/{module}` 探测模块,`GET /api/{module}` 取得原始 BoxJS 与当前已存值,`POST /api/{module}/get|set|delete` 执行持久化操作。`api.js` 负责取得 BoxJS、确认字段 ID 并把完整 `@root.path` 直接交给 util `Storage`;网页不直接请求配置 Mock,也不提交存储根。
19
+ const boxjs = await fetch("/api/Module", {
20
+ cache: "no-store",
21
+ credentials: "omit",
22
+ }).then(response => response.json());
18
23
 
19
- ```js
20
- await fetch("/api/Enhanced/set", {
21
- method: "POST",
22
- headers: {
23
- "Content-Type": "application/json",
24
- "X-PreferencePanes-JSON": "/configs/Enhanced",
25
- },
26
- body: JSON.stringify({ key: "Enhanced.Settings.Home.Top_left", value: "mine" }),
27
- });
24
+ const preferences = mount(boxjs);
25
+ preferences.destroy();
28
26
  ```
29
27
 
30
- 模块 API 返回的 `boxjs` 保持上游原样,`values` 只含实际已存值,不补默认值或解释控件类型。Web 侧 `mount()` 独占控件、选项、默认值、展示元数据和已存值校验,再生成页面。写入成功后只更新当前页面快照,不追加读取。旧 `/api/get|set|delete` form 接口和 `/api/module/{module}` 草稿路径均移除。
28
+ `mount()` 同步建立页面生命周期。BoxJS 规范化、默认值、已存值校验、加载状态和失败重试都由内部流程管理;不公开 API Model、视图 class、构建器或 CSS 第二参数。
31
29
 
32
- ## 页面与输入
30
+ 前端支持 BoxJS 字段数组、单个 app 和 apps 订阅,并要求输入恰好包含一个模块。模块名、存储根和 Settings 路径都从 `@Root.Module.Settings.key` 字段 ID 推导,不从 app 名称或调用参数补充。
33
31
 
34
- ```js
35
- import { PreferencesView } from "@nsnanocat/preference-panes/browser";
36
- const model = await fetch("/api/Module", {
37
- headers: { "X-PreferencePanes-JSON": "/configs/Module" },
38
- }).then(response => response.json());
39
- const page = new PreferencesView(model, ".pp-panel { --pp-accent: #16866a; }");
40
- page.destroy();
41
- ```
32
+ 默认样式随包内置。嵌入页面只跟随宿主根元素的 `data-theme` 和 `--pp-keyboard-height`,不识别客户端 User-Agent,也不加载项目 CSS 或客户端 SDK。
33
+
34
+ ## 页面与 API
35
+
36
+ `web.js` 只返回三类通用资源:
37
+
38
+ - `GET /settings/{module}`
39
+ - `GET /settings/assets/index.mjs`
40
+ - `GET /settings/assets/navigation.mjs`
42
41
 
43
- 浏览器生命周期依次由 `ModulePage`、`PreferencesView`、`PreferencesPanel` `PreferencesClient` 管理:页面入口读取输入并请求初始模型,视图负责 BoxJS 解析、校验、样式与主题,面板负责控件和导航,客户端只负责 API 请求与页面值快照。`mount(model, css?)` 仍作为创建 `PreferencesView` 的便捷入口。
42
+ 模块页面从 URL 或 `ModuleFrame` 的模块标记取得模块名,通过同源 `/api/{module}` 读取原始 BoxJS,然后调用 `mount(boxjs)`。页面不接受 JSON/CSS 查询参数、私有请求头或兼容资源别名,也不直接访问 `/configs/**`。
44
43
 
45
- API 支持字段数组、单 app 和 apps 订阅,只扫描 `@Root.Module.Settings.key` 形式的字段 ID。浏览器从 API 返回的同一份 BoxJS 解析控件、名称、图标和默认值;省略 CSS 使用内置样式。
44
+ `api.js` 只处理:
46
45
 
47
- `/settings/{module}` 由独立 `web.js` 返回模块文档。页面可通过 json/css 查询参数或 X-PreferencePanes-JSON/CSS Header 指定资源 URL;默认 JSON 是 /configs/{module},CSS 默认空。Header 分别优先。`web.js` 不访问网络或存储,浏览器执行其中的 `index.mjs` 后才调用 `api.js`。
46
+ - `HEAD /api/{module}`:探测同源 `/configs/{module}`,透传状态与 `X-PreferencePanes-Version`。
47
+ - `GET /api/{module}`:原样返回同源 `/configs/{module}` 的 BoxJS JSON 与版本头。
48
+ - `POST /api/get`:读取完整 `@root.path`。
49
+ - `POST /api/set`:写入完整 `@root.path`。
50
+ - `POST /api/delete`:删除完整 `@root.path` 或子树。
51
+
52
+ 模块 API 只转发业务配置;固定存储 API 不下载或解析 BoxJS。BoxJS 字段、控件、选项、默认值与写入值都由浏览器校验。
48
53
 
49
54
  ```js
50
- import { ModuleFrame, Navigation } from "@nsnanocat/preference-panes/navigation";
51
- const frame = new ModuleFrame("/settings/Module", {
52
- headers: { "X-PreferencePanes-JSON": "/configs/Module", "X-PreferencePanes-CSS": "/theme.css" },
53
- signal,
55
+ await fetch("/api/set", {
56
+ method: "POST",
57
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
58
+ body: new URLSearchParams([["@BiliBili.Enhanced.Settings.Home.Top_left", JSON.stringify("mine")]]),
54
59
  });
55
- container.append(frame.element);
56
- await frame.load();
57
- frame.addEventListener("change", () => { title.textContent = frame.state.title; });
58
- back.onclick = () => frame.back();
59
- frame.destroy();
60
60
  ```
61
61
 
62
- ModuleFrame iframe 元素上保存原请求上下文,HTML 原样加载,不从 about:srcdoc 猜模块、不注入临时 CSS。框架自身管理嵌入模式,通过事件发布标题、忙碌状态和返回能力。Navigation 统一管理 fragment 历史、滑动、滚动保留、加载取消及动画结束后释放;项目提供根页、子页工厂和布局。
62
+ 页面初始化时只执行一次 `POST /api/get` 读取 Settings 子树。写入成功后仅更新当前页面快照;查看 Settings/Caches 时按需读取,清空和重置通过 `/api/delete` 完成。
63
+
64
+ ## 宿主集成
65
+
66
+ ```js
67
+ import { ModuleFrame, ModuleStatus } from "@nsnanocat/preference-panes/navigation";
63
68
 
64
- 项目主页可按需使用导出的 `probeModule`、ModuleStatus、ModuleFrame Navigation,也可以自行实现入口。`probeModule("/api/Module", { json: "/configs/Module" })` 只对模块 API 发送一次 HEAD,并原样返回浏览器 `Response`;代理 API 负责探测 BoxJS 上游并透传状态和 `X-PreferencePanes-Version`。ModuleStatus 仅负责把同一响应渲染到状态行。PreferencePanes 不提供主页运行脚本,不识别具体 App,不加载或调用任何客户端 Bridge SDK。调用方若运行在原生 WebView,应在自己的 HTML/页面脚本中直接接入该客户端的官方 SDK,再把 ModuleFrame 事件映射到原生界面。
69
+ const status = new ModuleStatus(statusElement);
70
+ await status.check("/api/Module");
65
71
 
66
- 每次进入模块通过 API 取得 JSON 和设置一次,同时读取可选 CSS。二级多选返回复用内存缓存,修改经 Web 校验后提交 API;成功提示、失败回滚由公共组件处理。Caches 按需通过 API 查看/清空,重置只删除指定模块子树。
72
+ const frame = new ModuleFrame("/settings/Module", { signal });
73
+ container.append(frame.element);
74
+ await frame.load();
75
+ ```
67
76
 
68
- 标题栏只显示文字;嵌入模式隐藏模块自身标题栏,由宿主显示原生标题或自己的导航。模块数据操作位于标题栏右侧三点菜单,页面不再平铺维护按钮。`ActionMenu` 是共用的底部操作菜单:独立网页由其三点按钮打开;网页宿主或只有原生按钮、没有原生菜单的 WebView 宿主,将 `frame.state.actions` 传给 `menu.update(actions, busy)`,在宿主按钮点击时调用 `menu.open()`,选择后调用 `frame.perform(id)`。查看设置和缓存都会通过 `POST /api/{module}/get` 按需读取最新子树,再进入可返回的 JSON 详情子页;返回详情页不会触发额外读取,清空和重置设置通过 `/delete` 且仍要求确认。菜单组件提供遮罩、取消按钮、外部点击、Escape 与方向键操作,弹层挂载在文档根部,不依赖模块标题栏是否显示;宿主不访问 iframe 内部 DOM
77
+ `ModuleFrame` 只携带模块身份和取消信号。宿主通过 `change`、`confirm`、`notice` 事件同步标题、操作菜单、确认框和提示,不读取或改写 iframe 内部 DOM。项目主页、Bilibili JSBridge、原生导航和视觉样式仍由宿主负责。
69
78
 
70
79
  ## 构建与验证
71
80
 
72
- `npm run build` 生成无业务配置的 `dist/api.js`、`dist/web.js` 和公共前端模块;Release 工作流分别上传后端 API 与前端响应脚本。`build(boxjs, css?)` 仅输出 `settings/{module}/index.html`、公共 `settings/assets/index.mjs` 和可选模块 CSS,不复制配置、导航组件或重复 HTML。
81
+ ```sh
82
+ npm run build
83
+ npm run check
84
+ npm run apifox:check
85
+ npm pack --dry-run
86
+ ```
87
+
88
+ 构建生成 `dist/api.js`、`dist/web.js`、`dist/preference-panes.mjs` 和 `dist/module/` 通用页面资源。包不再提供按模块生成 HTML/CSS 的公开 `build()`。
73
89
 
74
- `npm run preview` 提供文件导入测试台,上传 JSON/CSS 后在隔离 iframe 预览;测试存储只在内存中。`npm run check` 检查代码、类型、行为;`npm run apifox:generate` 和 `npm run apifox:check` 维护原生接口文档。
90
+ `npm run preview` 启动 JSON 文件导入测试台,使用同一通用页面、同源配置和内存存储验证模块行为。
75
91
 
76
- [接口规范](apifox/Specification.md) · [Apifox JSON](apifox/preference-panes.apifox.json)
92
+ [接口规范](apifox/Specification.md) · [宿主集成](apifox/HostIntegration.md) · [Apifox JSON](apifox/preference-panes.apifox.json)
package/dist/api.js CHANGED
@@ -1921,11 +1921,9 @@
1921
1921
  return parts;
1922
1922
  }
1923
1923
 
1924
- const MISSING = Symbol("missing");
1925
-
1926
1924
  /**
1927
- * PreferencePanes 后端 API,只处理模块数据和持久化请求。
1928
- * PreferencePanes backend API handling only module data and persistence requests.
1925
+ * PreferencePanes 后端 API,只转发模块配置并提供通用持久化操作。
1926
+ * PreferencePanes backend API only relaying module configurations and providing generic persistence operations.
1929
1927
  */
1930
1928
  class API {
1931
1929
  /**
@@ -1947,153 +1945,107 @@
1947
1945
  }
1948
1946
 
1949
1947
  /**
1950
- * 处理 `/api/{module}` 及其动作,不接管页面或静态资源。
1951
- * Handle `/api/{module}` and its actions without intercepting pages or static assets.
1948
+ * 处理模块配置 API 与固定存储动作,不接管页面或静态资源。
1949
+ * Handle module configuration APIs and fixed storage actions without intercepting pages or static assets.
1952
1950
  * @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
1953
1951
  * @returns {Promise<import("./index.js").SettingsResponse | undefined>} API 响应或未接管 / API response or pass-through.
1954
1952
  */
1955
1953
  async handle(request) {
1956
1954
  const url = new URL(request.url);
1957
- const match = /^\/api\/([a-zA-Z0-9_-]+)(?:\/(get|set|delete))?\/?$/.exec(url.pathname);
1958
- if (!match) return;
1959
- const [, module, action] = match;
1960
- const configURL = this.#configURL(request, url, module);
1961
- switch (true) {
1962
- case !action && request.method === "HEAD":
1963
- return this.#probe(request, configURL);
1964
- case !action && request.method === "GET":
1965
- return this.#model(request, module, configURL);
1966
- case Boolean(action) && request.method === "POST":
1967
- return this.#action(request, module, action, configURL);
1968
- default:
1969
- return this.#response(request, 405, { error: "Use GET or HEAD for module reads, POST for module actions" });
1970
- }
1955
+ const action = /^\/api\/(get|set|delete)\/?$/.exec(url.pathname)?.[1];
1956
+ if (action) return this.#store(request, action);
1957
+ const module = /^\/api\/([a-zA-Z0-9_-]+)\/?$/.exec(url.pathname)?.[1];
1958
+ if (!module) return;
1959
+ if (!["HEAD", "GET"].includes(request.method)) return this.#response(request, 405, { error: "Use GET or HEAD for module configuration" });
1960
+ return this.#configuration(request, `${url.origin}/configs/${module}`);
1971
1961
  }
1972
1962
 
1973
- #configURL(request, url, module) {
1974
- const headers = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
1975
- const source = headers["x-preferencepanes-json"] ?? `/configs/${module}`;
1976
- if (/^https?:\/\//i.test(source)) return source;
1977
- if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(source)) throw Object.assign(new TypeError("BoxJS resources must use HTTP(S) URLs"), { status: 400 });
1978
- return `${url.origin}/${source.replace(/^\/+/, "")}`;
1979
- }
1980
-
1981
- async #probe(request, configURL) {
1963
+ /**
1964
+ * 从同源业务配置响应模块探测或原始 BoxJS JSON。
1965
+ * Respond to a module probe or raw BoxJS JSON from the same-origin business configuration.
1966
+ * @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
1967
+ * @param {string} configuration 同源配置地址 / Same-origin configuration URL.
1968
+ * @returns {Promise<import("./index.js").SettingsResponse>} 配置响应 / Configuration response.
1969
+ */
1970
+ async #configuration(request, configuration) {
1982
1971
  let result;
1983
1972
  try {
1984
- result = await fetch({ url: configURL, method: "HEAD", timeout: 5000, headers: { Accept: "application/json" } });
1973
+ result = await fetch({ url: configuration, method: request.method, timeout: 5000, headers: { Accept: "application/json" } });
1985
1974
  } catch (error) {
1986
1975
  return this.#response(request, 502, { error: error.message });
1987
1976
  }
1988
1977
  const version = this.#header(result.headers, "x-preferencepanes-version");
1989
- return this.#response(request, result.statusCode ?? result.status, undefined, version ? { "X-PreferencePanes-Version": version } : {});
1990
- }
1991
-
1992
- async #model(request, module, configURL) {
1993
- const loaded = await this.#load(module, configURL);
1994
- const values = {};
1995
- for (const entry of loaded.entries) {
1996
- const value = Storage.getItem(entry.id, MISSING);
1997
- if (value !== MISSING) values[entry.id.slice(loaded.storageKey.length + 2)] = value;
1998
- }
1999
- return this.#response(request, 200, { module, boxjs: loaded.boxjs, values, configURL }, loaded.version ? { "X-PreferencePanes-Version": loaded.version } : {});
2000
- }
2001
-
2002
- async #action(request, module, action, configURL) {
2003
- const payload = this.#jsonBody(request);
2004
- const target = await this.#load(module, configURL);
2005
- switch (action) {
2006
- case "get": {
2007
- const value = Storage.getItem(payload?.scope ? this.#scopePath(target, payload.scope) : this.#storagePath(target, payload?.key), MISSING);
2008
- return value === MISSING ? this.#response(request, 404, { error: "Stored path does not exist" }) : this.#response(request, 200, value);
2009
- }
2010
- case "set":
2011
- if (!Object.hasOwn(payload ?? {}, "value")) throw Object.assign(new TypeError("A value is required"), { status: 400 });
2012
- if (!Storage.setItem(this.#storagePath(target, payload.key), payload.value)) throw new Error("Storage write failed");
2013
- return this.#response(request, 200, { saved: true });
2014
- case "delete": {
2015
- const path = payload?.scope ? this.#scopePath(target, payload.scope) : this.#storagePath(target, payload?.key);
2016
- if (!Storage.removeItem(path)) throw new Error("Storage write failed");
2017
- return this.#response(request, 200, { deleted: true });
2018
- }
2019
- }
1978
+ const contentType = this.#header(result.headers, "content-type") ?? "application/json; charset=utf-8";
1979
+ return {
1980
+ status: result.statusCode ?? result.status,
1981
+ headers: { "Content-Type": contentType, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", ...(version ? { "X-PreferencePanes-Version": version } : {}) },
1982
+ body: request.method === "HEAD" ? "" : typeof result.body === "string" ? result.body : new TextDecoder().decode(result.body),
1983
+ };
2020
1984
  }
2021
1985
 
2022
- async #load(module, configURL) {
2023
- let result;
1986
+ /**
1987
+ * 使用唯一 form 字段中的完整 @root.path 执行存储操作。
1988
+ * Execute a storage operation using the complete @root.path from the sole form field.
1989
+ * @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
1990
+ * @param {"get" | "set" | "delete"} action 存储动作 / Storage action.
1991
+ * @returns {import("./index.js").SettingsResponse} 操作响应 / Operation response.
1992
+ */
1993
+ #store(request, action) {
1994
+ const reply = (status, data) => this.#response(request, status, data);
1995
+ if (request.method !== "POST") return reply(405, { error: "Use POST with a form body" });
1996
+ const headers = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
1997
+ if (headers["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/x-www-form-urlencoded") return reply(415, { error: "Expected application/x-www-form-urlencoded" });
1998
+ if (typeof request.body !== "string" || request.body.length > 65536) return reply(400, { error: "Expected a form body up to 65536 characters" });
1999
+ let parts, value;
2024
2000
  try {
2025
- result = await fetch({ url: configURL, method: "GET", timeout: 5000, headers: { Accept: "application/json" } });
2001
+ const fields = request.body.split("&");
2002
+ if (fields.length !== 1) throw new TypeError("Send exactly one storage key");
2003
+ const separator = fields[0].indexOf("=");
2004
+ if (separator < 0) throw new TypeError("Expected @root.path=value");
2005
+ const key = decodeURIComponent(fields[0].slice(0, separator).replace(/\+/g, " "));
2006
+ value = decodeURIComponent(fields[0].slice(separator + 1).replace(/\+/g, " "));
2007
+ if (!key.startsWith("@")) throw new TypeError("Storage keys must start with @");
2008
+ parts = validatePathParts(key.slice(1).split("."));
2009
+ if (parts.length < 2) throw new TypeError("Specify a storage root and child path");
2026
2010
  } catch (error) {
2027
- throw Object.assign(new Error(`Configuration request failed: ${error.message}`), { status: 502 });
2011
+ return reply(400, { error: error.message });
2028
2012
  }
2029
- const status = result.statusCode ?? result.status;
2030
- if (status !== 200) throw Object.assign(new Error(`Configuration HTTP ${status}`), { status });
2031
- try {
2032
- const body = typeof result.body === "string" ? result.body : new TextDecoder().decode(result.body);
2033
- const boxjs = JSON.parse(body);
2034
- const apps = Array.isArray(boxjs) ? [{ settings: boxjs }] : (boxjs.apps ?? [boxjs]);
2035
- if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
2036
- const entries = [];
2037
- let storageKey;
2038
- for (const app of apps) {
2039
- if (!app || !Array.isArray(app.settings)) throw new TypeError("Expected BoxJS settings array");
2040
- for (const entry of app.settings) {
2041
- if (typeof entry.id !== "string") throw new TypeError("BoxJS settings require string IDs");
2042
- if (!entry.id.startsWith("@")) {
2043
- if (Array.isArray(boxjs)) throw new TypeError("BoxJS settings require @root.path IDs");
2044
- continue;
2045
- }
2046
- const [root, ...parts] = entry.id.slice(1).split(".");
2047
- if (!root || root.startsWith("@") || parts.length < 2) throw new TypeError("A BoxJS setting must be below a literal storage root and module");
2048
- validatePathParts(parts);
2049
- if (parts[0] !== module) continue;
2050
- if (storageKey && storageKey !== root) throw new TypeError(`A module must use one storage root: ${module}`);
2051
- storageKey = root;
2052
- entries.push(entry);
2053
- }
2013
+ if (action === "set") {
2014
+ try {
2015
+ value = JSON.parse(value);
2016
+ } catch (error) {
2017
+ if (!(error instanceof SyntaxError)) throw error;
2054
2018
  }
2055
- if (!entries.length) throw new TypeError(`No BoxJS settings for module: ${module}`);
2056
- return { boxjs, entries, module, storageKey, version: this.#header(result.headers, "x-preferencepanes-version") };
2057
- } catch (error) {
2058
- throw Object.assign(new Error(`Invalid BoxJS: ${error.message}`), { status: 422 });
2059
2019
  }
2060
- }
2061
-
2062
- #jsonBody(request) {
2063
- const headers = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
2064
- if (headers["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/json") throw Object.assign(new TypeError("Expected application/json"), { status: 415 });
2065
- if (typeof request.body !== "string" || request.body.length > 65536) throw Object.assign(new TypeError("Expected a JSON body up to 65536 characters"), { status: 400 });
2020
+ const [storageKey, ...path] = parts;
2066
2021
  try {
2067
- return JSON.parse(request.body);
2022
+ const root = Storage.getItem(storageKey, {});
2023
+ if (!isRecord(root)) throw new TypeError("Stored root must be an object");
2024
+ const parent = storageParent(root, path, action === "set");
2025
+ const key = path.at(-1);
2026
+ switch (action) {
2027
+ case "get": {
2028
+ const result = parent ? Lodash.get(parent, [key]) : undefined;
2029
+ return result === undefined ? reply(404, { error: "Stored path does not exist" }) : reply(200, result);
2030
+ }
2031
+ case "set":
2032
+ Lodash.set(parent, [key], value);
2033
+ break;
2034
+ case "delete":
2035
+ if (parent) Lodash.unset(parent, [key]);
2036
+ break;
2037
+ }
2038
+ if (!Storage.setItem(storageKey, root)) throw new Error("Storage write failed");
2039
+ return reply(200, action === "set" ? { saved: true } : { deleted: true });
2068
2040
  } catch (error) {
2069
- throw Object.assign(error, { status: 400 });
2070
- }
2071
- }
2072
-
2073
- #storagePath(target, key) {
2074
- if (typeof key !== "string") throw Object.assign(new TypeError("A BoxJS field path is required"), { status: 400 });
2075
- const path = `@${target.storageKey}.${key}`;
2076
- if (!target.entries.some(entry => entry.id === path)) throw Object.assign(new TypeError(`Unknown BoxJS field: ${key}`), { status: 400 });
2077
- return path;
2078
- }
2079
-
2080
- #scopePath(target, scope) {
2081
- switch (scope) {
2082
- case "settings":
2083
- return `@${target.storageKey}.${target.module}.Settings`;
2084
- case "caches":
2085
- return `@${target.storageKey}.${target.module}.Caches`;
2086
- case "module":
2087
- return `@${target.storageKey}.${target.module}`;
2088
- default:
2089
- throw Object.assign(new TypeError("Scope must be settings, caches or module"), { status: 400 });
2041
+ return reply(500, { error: error.message });
2090
2042
  }
2091
2043
  }
2092
2044
 
2093
- #response(request, status, body, extraHeaders = {}) {
2045
+ #response(request, status, body) {
2094
2046
  return {
2095
2047
  status,
2096
- headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", ...extraHeaders },
2048
+ headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" },
2097
2049
  body: request.method === "HEAD" ? "" : JSON.stringify(body),
2098
2050
  };
2099
2051
  }
@@ -2104,6 +2056,44 @@
2104
2056
  }
2105
2057
  }
2106
2058
 
2059
+ /**
2060
+ * 判断存储根是否为普通对象。
2061
+ * Determine whether a storage root is a plain object.
2062
+ * @param {unknown} value 待检查值 / Value to inspect.
2063
+ * @returns {boolean} 是否为普通对象 / Whether this is a plain object.
2064
+ */
2065
+ function isRecord(value) {
2066
+ return value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype;
2067
+ }
2068
+
2069
+ /**
2070
+ * 遍历父路径,并解码旧存储中的 JSON 字符串中间节点。
2071
+ * Traverse parent paths and decode legacy intermediate nodes stored as JSON strings.
2072
+ * @param {Record<string, unknown>} root 存储根 / Storage root.
2073
+ * @param {string[]} parts 完整路径 / Complete path.
2074
+ * @param {boolean} create 是否创建缺失节点 / Whether to create missing parents.
2075
+ * @returns {object | undefined} 父节点或 undefined / Parent node or undefined.
2076
+ */
2077
+ function storageParent(root, parts, create) {
2078
+ let parent = root;
2079
+ for (const part of parts.slice(0, -1)) {
2080
+ let next = Lodash.get(parent, [part]);
2081
+ switch (typeof next) {
2082
+ case "undefined":
2083
+ if (!create) return;
2084
+ next = {};
2085
+ break;
2086
+ case "string":
2087
+ next = JSON.parse(next);
2088
+ break;
2089
+ }
2090
+ if (!isRecord(next) && !Array.isArray(next)) throw new TypeError("Stored parent is not an object or array");
2091
+ Lodash.set(parent, [part], next);
2092
+ parent = next;
2093
+ }
2094
+ return parent;
2095
+ }
2096
+
2107
2097
  new API().run();
2108
2098
 
2109
2099
  })();
@@ -9,6 +9,6 @@
9
9
  </head>
10
10
  <body>
11
11
  <main id="preferences"></main>
12
- <script type="module" src="/settings/assets/index.mjs?v=1.1.0"></script>
12
+ <script type="module" src="/settings/assets/index.mjs?v=1.1.2"></script>
13
13
  </body>
14
14
  </html>