feat(gui): WebUI 与 Electron 壳的插件安装/删除/禁用/编辑

## WebUI:新增「插件」页
- 列表来自 on_disk(不是 loaded 集合)——**加载失败的插件也必须显示并带错误**,
  否则一个语法错误看起来和"插件没装"完全一样
- 启用/禁用(PUT {"enabled":bool})、删除、编辑源码、安装/覆盖
- 显示 hook_errors:插件抛异常在别处毫无痕迹,没有这一栏的症状就是
  "功能就是不work"
- 插到 dropzone 与代码编辑器都做了泛型化(bindDropzone / openCodeModal),
  适配器与插件共用一份,而不是复制第二份只改 4 个 id 的函数

## TABS 收敛为单一常量
tab 清单原本是字面量散在三处:goTab、refresh()、admin-only 隐藏列表。
加一个 tab 意味着三处都要记得改,漏一处就是"路由认得但界面不显示"——
和今天早些时候 chain_step 漏报同一类静默缺口。现在只有 const TABS。

## Electron 壳:设置面板里的插件管理
渲染进程不能直连内嵌核心(没有 key、不知道端口),所以走 IPC:
  renderer → plugins:proxy → main → HTTP /api/plugins
代理是 (method, path, body) 透传而不是固定命令表:固定表每加一个端点就要扩,
而"按钮存在但什么都不做"比"没有这个按钮"更糟。透传让渲染层能调用核心将来
新增的任何 /api/plugins 路由,路径在主进程校验。

## ★ GUI 此前零测试,而本次改动就引入了三类"看起来没事"的问题
1. 引用了不存在的 CSS 类(.tag / .sm)——渲染成无样式文本
2. 引用了不存在的 helper(esc / escAttr)——那是 WebUI 的,renderer/app.js
   是独立文档,点击时 ReferenceError
3. .ghost/.primary 只在 .form .actions 作用域内生效,插件按钮在 .pl-acts 里
   于是是无样式裸按钮

补 4 个静态判据(不启动 Electron,守卫的正是"打开应用才看得见"那一类):
  TestGUICSSClassesExist          用到的类必须在样式表里定义
  TestGUIHelperFunctionsAreDefined 被调用的函数必须有定义
  TestGUIPluginPanelIsReachable  面板在 overlay 内、按钮已绑定、打开设置会加载
  TestGUIIPCPathIsConstrained    代理必须限定 /api/plugins 前缀并拒绝路径穿越

写第一个判据时我错了三次:CSS 解析器先丢最后一个 selector、再把变量块当
selector、最后漏掉复合选择器(.tb-btn.tb-close)。两次"判据自己坏了"的
教训和本项目一贯一致——**判据出错的信号是它报了一个假问题**。现在改用宽松的
token 提取 + 显式的 guiKnownUnstyled 豁免表(blob/tgl/rail 是既有无样式类,
不是本次引入,失败它们只会让判据对新工作失去意义)。

## 变异验证
  改坏唯一的 CSS 定义(.pl-empty)→ TestGUICSSClassesExist 红
  改坏 helper 名 → TestGUIHelperFunctionsAreDefined 红
★ 第一次变异我改了 .pl-broken,判据**正确地没报**——因为它还被另一条规则定义。
  这是变异选错目标,不是判据有洞;换 .pl-empty 后如期变红。

363 个测试全绿。
This commit is contained in:
JianFeeeee
2026-10-02 08:47:08 +08:00
parent a78f7cb6c5
commit 1c690611f8
8 changed files with 837 additions and 10 deletions

View File

@ -671,6 +671,78 @@ ipcMain.handle(
(e) => !!BrowserWindow.fromWebContents(e.sender)?.isMaximized(),
);
// ---- plugin management over IPC -------------------------------------------
//
// The renderer cannot call the embedded core directly: it has no key and no
// network identity, and the core binds a loopback port that only the main
// process knows about. So every plugin action is proxied through the main
// process, which already knows how to obtain the admin key (unsealViaCore).
//
// The proxy is deliberately a raw (method, path, body) pass-through rather than
// a fixed set of commands. A fixed set would have to be extended for every new
// plugin endpoint, and the one thing worse than "no button for this" is "a
// button that silently does nothing" — with a pass-through the renderer can talk
// to any /api/plugins route the core grows, and the path is validated here so
// this channel cannot be used to reach arbitrary endpoints.
function pluginProxy(req) {
const { method, path, body } = req || {};
const M = ["GET", "POST", "PUT", "DELETE"];
if (!M.includes(method)) throw new Error("bad method: " + method);
// The path must stay inside the plugin namespace. A prefix check alone would
// still allow /api/plugins/../keys, so reject any traversal outright.
if (typeof path !== "string" || !path.startsWith("/api/plugins")) {
throw new Error("path must start with /api/plugins");
}
if (path.includes("..") || path.includes("\\")) {
throw new Error("path traversal rejected");
}
const key = unsealViaCore();
if (!key) throw new Error("no admin key available yet");
return new Promise((resolve, reject) => {
const u = new URL(embeddedBaseUrl() + path);
const data = body == null ? null : JSON.stringify(body);
const headers = { Authorization: "Bearer " + key };
if (data) {
headers["Content-Type"] = "application/json";
headers["Content-Length"] = Buffer.byteLength(data);
}
const r = http.request(
{
hostname: u.hostname,
port: u.port,
path: u.pathname + u.search,
method,
headers,
},
(res) => {
let raw = "";
res.setEncoding("utf8");
res.on("data", (c) => (raw += c));
res.on("end", () => {
let parsed = null;
try {
parsed = raw ? JSON.parse(raw) : null;
} catch (e) {
parsed = { raw };
}
if (res.statusCode >= 400) {
const msg =
(parsed && parsed.error && parsed.error.message) ||
"HTTP " + res.statusCode;
reject(new Error(msg));
return;
}
resolve(parsed);
});
},
);
r.on("error", reject);
if (data) r.write(data);
r.end();
});
}
ipcMain.handle("plugins:proxy", (_e, req) => pluginProxy(req));
ipcMain.handle("core:state", () => ({
running: coreStarted() && coreReady,
ready: coreReady,