Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions VCPDistributedServer/frontend-plugin-loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@

if (window.VCPFrontendPlugins?.loaderStarted) return;

const registry = new Map();
const registry = new Map();
const loadStates = new Map();
const api = {
loaderStarted: true,
registry,
registry,
getLoadState(id) { return loadStates.get(id); },
register(id, instance) {
if (!id || registry.has(id)) return false;
registry.set(id, instance || {});
Expand Down Expand Up @@ -53,7 +55,9 @@
const plugins = response?.success && Array.isArray(response.plugins) ? response.plugins : [];
for (const plugin of plugins) {
loadStyle(plugin);
results.push(await loadScript(plugin));
const result = await loadScript(plugin);
loadStates.set(plugin.id, Object.freeze(result));
results.push(result);
}
} catch (error) {
console.error('[FrontendPlugins] 无法读取已启用插件清单。', error);
Expand Down
37 changes: 37 additions & 0 deletions docs/settings-plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# 设置 → 插件与工具

主聊天的插件入口打开此设置分区,沿用现有设置壳和 DSH 控件样式。

## 功能

- 界面插件:搜索、启停及 Manifest 配置表单;显示加载器的真实加载结果。
- 本机工具:搜索、启停及配置;运行状态取自本机插件与服务模块,节点连接在工具栏集中显示。
- 后端插件:使用当前 VCPToolBox 管理端账号连接,读取注册清单并通过已有管理 API 启停和保存配置。分布式条目只读。

正常卡片省略重复状态标记,由开关表示启停。异常、未加载、状态未知、待重启、只读仍有短标记;完整运行事实可在详情和无障碍描述中读取。后端“已注册”只表示注册,不证明一次工具调用健康或浏览器已连接。

## 配置行为

本机启停与配置在重启 VCPChat 后生效,不打断当前聊天。后端由现有管理 API 重新加载插件。布尔/枚举使用共享选择器;数字遵守类型和范围;密钥遮挡,多行内容保留换行。

已有 `plugin-config.json` 时编辑该 JSON 文件,否则使用 `config.env`。未知键和注释保留。Manifest 与原始配置位于“高级配置”;后端 Manifest 只读。本机 Manifest 不能改变插件标识或分类。

插件配置独立显式保存,不进入全局设置自动保存。切换分类、搜索、刷新及本次应用会话内关闭重开设置保留草稿。保存与启停校验 revision,阻止覆盖其他窗口的修改;后端 revision 同时绑定服务器 origin。干净表单刷新读取最新配置,“放弃更改”读取成功后才替换草稿。插件移除时保留草稿供复制,并阻止保存。

## 接口与边界

插件 IPC 只接受应用真实主窗口顶层 frame。插件目录和配置拒绝越界路径、目录链接与符号链接,支持正常 Unicode 文件夹。

后端凭据只保留在主进程应用会话,导航、关闭、断开及服务器 origin 改变时清除;请求禁止跳转,超时 12 秒。管理地址采用服务器 origin 的根路径,尚不支持额外反向代理路径前缀。聊天 API key 不能代替管理端账号密码。

复用现有 UIUX 按钮、开关、门户选择器和生命周期所有者;样式通过 `settings.css` 导入,不修改生成的 UIUX 文件。DSH 样式参考固定源码 `639ed015`:正常启用项不显示状态标记、空描述不占位、线形展开箭头、36px/12px 按钮、36×20px 开关及明暗主题语义色。未做完整 DSH 应用像素 A/B。

## 验证

```powershell
npm run test:settings-plugins
node --test tests/settings-schema-render.test.mjs tests/global-settings-save.test.mjs tests/settings-autosave-coordinator.test.mjs tests/preload-registry.test.js tests/frontend-plugins.test.js tests/uiux-settings-bridge-modules.test.mjs tests/uiux-settings-css-parts.test.mjs tests/uiux-primitives.test.mjs
npm run check:ui-system
```

真实 Electron 验证使用隔离插件实际改清单、写配置、制造外部修改冲突并恢复草稿,结束后清理。真实 VCPToolBox 验证管理端登录与清单读取;后端写操作通过匹配现有 API 的模拟服务验证,未停用真实后端插件。检查明暗主题、窄窗口、键盘展开、选择器、搜索和主聊天插件入口。
3 changes: 2 additions & 1 deletion main.html
Original file line number Diff line number Diff line change
Expand Up @@ -723,7 +723,7 @@ <h2 class="vcp-settings-source-title">全局设置</h2>

<div class="vcp-settings-source-layout">
<!-- 左侧导航菜单 -->
<nav class="vcp-settings-source-nav" data-settings-sections='[{"value":"user-identity","label":"用户身份"},{"value":"server-connection","label":"服务器连接"},{"value":"jev-service","label":"Jev 服务"},{"value":"appearance-settings","label":"界面与外观"},{"value":"render-settings","label":"消息渲染"},{"value":"selection-assistant","label":"划词助手"},{"value":"voice-settings","label":"语音设置"},{"value":"advanced-features","label":"高级功能"},{"value":"workspace-management","label":"工作区管理"},{"value":"quick-actions","label":"快捷操作"}]'>
<nav class="vcp-settings-source-nav" data-settings-sections='[{"value":"user-identity","label":"用户身份"},{"value":"server-connection","label":"服务器连接"},{"value":"jev-service","label":"Jev 服务"},{"value":"appearance-settings","label":"界面与外观"},{"value":"plugins-tools","label":"插件与工具"},{"value":"render-settings","label":"消息渲染"},{"value":"selection-assistant","label":"划词助手"},{"value":"voice-settings","label":"语音设置"},{"value":"advanced-features","label":"高级功能"},{"value":"workspace-management","label":"工作区管理"},{"value":"quick-actions","label":"快捷操作"}]'>
<template class="vcp-settings-source-list">
<li class="vcp-settings-source-item active" data-section="user-identity">
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
Expand Down Expand Up @@ -829,6 +829,7 @@ <h2 class="vcp-settings-source-title">全局设置</h2>
<div class="settings-section" id="section-server-connection" data-settings-section-key="server-connection"></div>
<div class="settings-section" id="section-jev-service" data-settings-section-key="jev-service"></div>
<div class="settings-section" id="section-appearance-settings" data-settings-section-key="appearance-settings"></div>
<div class="settings-section" id="section-plugins-tools" data-settings-section-key="plugins-tools"></div>
<div class="settings-section" id="section-render-settings" data-settings-section-key="render-settings"></div>
<div class="settings-section" id="section-selection-assistant" data-settings-section-key="selection-assistant"></div>
<div class="settings-section" id="section-voice-settings" data-settings-section-key="voice-settings"></div>
Expand Down
16 changes: 14 additions & 2 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -1619,8 +1619,20 @@ if (!gotTheLock) {
mainWindow,
openChildWindows,
settingsManager: appSettingsManager,
APP_DATA_ROOT_IN_PROJECT,
});
APP_DATA_ROOT_IN_PROJECT,
});
require('./modules/ipc/pluginSettingsHandlers').initialize({
mainWindow,
settingsManager: appSettingsManager,
getRuntime: () => {
const manager = require('./VCPDistributedServer/Plugin');
return {
plugins: distributedServer ? manager.plugins : new Map(),
services: distributedServer ? manager.serviceModules : new Map(),
connected: distributedServer?.ws?.readyState === 1,
};
},
});
await embeddedAppSessions?.closeAll();
embeddedAppTasks?.dispose('main-window-reinitialized');
embeddedAppTasks = new SenderTaskRegistry({ label: 'embedded-app-tasks' });
Expand Down
35 changes: 35 additions & 0 deletions modules/ipc/applicationSender.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'use strict';

const path = require('node:path');
const { fileURLToPath } = require('node:url');
const PROJECT_ROOT = path.resolve(__dirname, '../..');

function isApplicationPageUrl(raw, pages = ['main.html'], projectRoot = PROJECT_ROOT) {
try {
const url = new URL(raw);
if (url.protocol !== 'file:') return false;
const normalize = p => process.platform === 'win32' ? path.resolve(p).toLowerCase() : path.resolve(p);
const actual = normalize(fileURLToPath(url));
return pages.some(page => actual === normalize(path.join(projectRoot, page)));
} catch { return false; }
}

function createApplicationSenderGuard({ pages = ['main.html'], projectRoot = PROJECT_ROOT, getWebContents = null, getMainWebContents = null } = {}) {
return event => {
try {
const sender = event?.sender;
const frame = event?.senderFrame;
// 文件名后缀和 frame 自报 URL 都不能证明权限:必须是应用页面的真实顶层窗口。
if (!sender || sender.isDestroyed() || !frame || frame !== sender.mainFrame || frame.detached) return false;
if (sender.getType() !== 'window') return false;
if (getWebContents && sender !== getWebContents()) return false;
if (getMainWebContents && isApplicationPageUrl(frame.url, ['main.html'], projectRoot)) {
const main = getMainWebContents();
if (main && sender !== main) return false;
}
return isApplicationPageUrl(frame.url, pages, projectRoot) && isApplicationPageUrl(sender.getURL(), pages, projectRoot);
} catch { return false; }
};
}

module.exports = { isApplicationPageUrl, createApplicationSenderGuard };
24 changes: 24 additions & 0 deletions modules/ipc/pluginSettingsHandlers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use strict';
const path = require('node:path');
const { ipcMain } = require('electron');
const { createApplicationSenderGuard } = require('./applicationSender');
const { createPluginSettingsService } = require('../services/pluginSettingsService');

function initialize({ mainWindow, settingsManager, getRuntime }) {
const allowed = createApplicationSenderGuard({ getMainWebContents: () => mainWindow?.webContents });
const service = createPluginSettingsService({ root: path.resolve(__dirname, '../..'), readSettings: () => settingsManager.readSettings(), getRuntime });
const handler = fn => async (event, data) => {
if (!allowed(event)) return { success: false, error: '此页面无权管理插件' };
try { return { success: true, data: await fn(data || {}) }; }
catch (error) { return { success: false, error: error.message.includes('fetch') ? '无法连接 VCPToolBox 管理端,请检查服务器连接' : error.message }; }
};
ipcMain.handle('plugin-settings:list', handler(data => service.list(data.category)));
ipcMain.handle('plugin-settings:detail', handler(data => service.detail(data.category, data.id)));
ipcMain.handle('plugin-settings:toggle', handler(data => service.toggle(data)));
ipcMain.handle('plugin-settings:save', handler(data => service.save(data)));
ipcMain.handle('plugin-settings:connect', handler(data => service.connect(data)));
ipcMain.handle('plugin-settings:disconnect', handler(() => service.disconnect()));
mainWindow?.webContents?.on('did-start-navigation', (_event, _url, _inPlace, isMainFrame) => { if (isMainFrame) service.disconnect(); });
mainWindow?.once('closed', () => service.disconnect());
}
module.exports = { initialize };
Loading
Loading