diff --git a/docs/http-api.md b/docs/http-api.md index 4b593124..7fa47ca4 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -53,7 +53,8 @@ API 密钥可在 **设置 → HTTP 服务** 中查看和复制。 | ---- | ----------- | --------------------------------- | | 0 | 200 | 操作成功 | | 401 | 401 | API 密钥无效或未提供 | -| 404 | 200 | 未知接口路径 | +| 404 | 200 | 未知接口路径、插件或功能不存在 | +| 400 | 200 | 请求参数错误 | | 405 | 405 | 请求方法不允许(仅支持 GET/POST) | | 500 | 500 | 服务器内部错误 | @@ -211,6 +212,77 @@ curl -X POST http://127.0.0.1:36578/api/window/toggle \ --- +### POST /api/plugin/launch — 启动插件 + +按 `plugin.json` 中的 `name` 和功能 code 启动已安装插件,并按手动启动插件时的参数格式传入 `type` 与 `payload`。 + +**请求** + +``` +POST /api/plugin/launch +Authorization: Bearer +Content-Type: application/json +``` + +**入参** + +| 字段 | 类型 | 必填 | 说明 | +| ------------ | -------- | ---- | -------------------------------------------------------------------------------------- | +| `pluginName` | `string` | 是 | 插件 `plugin.json` 中的 `name` | +| `code` | `string` | 是 | 要启动的插件功能 code | +| `type` | `string` | 否 | 启动类型,支持 `text`、`over`、`regex`、`img`、`files`、`window`,默认 `text` | +| `payload` | `any` | 否 | 传给插件的内容,与手动启动插件时的 `payload` 含义一致;`type=files` 时可传文件路径数组 | + +**请求体示例** + +```json +{ + "pluginName": "demo-plugin", + "code": "open", + "type": "text", + "payload": "来自 HTTP API 的内容" +} +``` + +文件类型插件也可以传文件路径数组,ZTools 会转换为手动启动插件时一致的文件对象数组。 + +```json +{ + "pluginName": "demo-plugin", + "code": "open-files", + "type": "files", + "payload": ["/Users/me/Desktop/a.txt", "/Users/me/Desktop/images"] +} +``` + +**返回** + +```json +{ + "code": 0, + "message": "操作成功", + "data": { + "name": "demo-plugin", + "title": "Demo 插件", + "path": "/path/to/demo-plugin", + "result": { + "success": true + } + } +} +``` + +**curl 示例** + +```bash +curl -X POST http://127.0.0.1:36578/api/plugin/launch \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"pluginName": "demo-plugin", "code": "open", "type": "text", "payload": "hello"}' +``` + +--- + ## 在各语言/工具中调用 ### JavaScript / Node.js diff --git a/internal-plugins/setting/src/views/HttpServiceSetting/HttpServiceSetting.vue b/internal-plugins/setting/src/views/HttpServiceSetting/HttpServiceSetting.vue index b913b514..555aaee5 100644 --- a/internal-plugins/setting/src/views/HttpServiceSetting/HttpServiceSetting.vue +++ b/internal-plugins/setting/src/views/HttpServiceSetting/HttpServiceSetting.vue @@ -19,7 +19,7 @@ interface ApiEndpoint { path: string desc: string auth: boolean - body?: Record + body?: unknown } const apiEndpoints: ApiEndpoint[] = [ @@ -37,6 +37,18 @@ const apiEndpoints: ApiEndpoint[] = [ path: '/api/window/toggle', desc: '切换 ZTools 主窗口显示/隐藏状态', auth: true + }, + { + method: 'POST', + path: '/api/plugin/launch', + desc: '启动插件,支持传参。pluginName 对应 plugin.json 中的 name,code 和 type(text、over 等)也保持和 plugin.json 一致。type=files 时 payload 可传文件路径数组', + auth: true, + body: { + pluginName: '插件名称', + code: '功能 code', + type: 'text', + payload: '传给插件的内容' + } } ] @@ -247,6 +259,7 @@ onMounted(() => {

{{ item.desc }}

+
{{ JSON.stringify(item.body, null, 2) }}
@@ -461,6 +474,19 @@ onMounted(() => { margin: 0; } +.api-body { + font-family: 'SF Mono', 'Menlo', 'Monaco', monospace; + font-size: 12px; + padding: 10px 12px; + background: var(--hover-bg); + border-radius: 6px; + color: var(--text-color); + margin: 10px 0 0 0; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + .docs-response { display: flex; flex-direction: column; diff --git a/src/main/api/index.ts b/src/main/api/index.ts index e634a26b..6431334c 100644 --- a/src/main/api/index.ts +++ b/src/main/api/index.ts @@ -161,7 +161,8 @@ class APIManager { // 初始化软件更新API updaterAPI.init(mainWindow) - // 初始化 HTTP 服务 + // 初始化 HTTP 服务,并复用应用内插件启动入口处理外部插件启动请求。 + httpServer.setPluginLauncher((options) => this.launchPlugin(options)) httpServer.init().catch((error) => { console.error('[API] HTTP 服务初始化失败:', error) }) diff --git a/src/main/core/httpServer.ts b/src/main/core/httpServer.ts index c7006b5e..be71e471 100644 --- a/src/main/core/httpServer.ts +++ b/src/main/core/httpServer.ts @@ -1,7 +1,11 @@ import { createServer, IncomingMessage, ServerResponse, Server } from 'http' import { randomBytes } from 'crypto' +import fsSync from 'fs' +import path from 'path' import windowManager from '../managers/windowManager' import databaseAPI from '../api/shared/database' +import lmdbInstance from './lmdb/lmdbInstance' +import { getPluginDataPrefix } from '../../shared/pluginRuntimeNamespace' interface HttpServerConfig { enabled: boolean @@ -15,17 +19,54 @@ interface ApiResponse { data?: unknown } +interface PluginLaunchOptions { + path: string + type: 'plugin' + featureCode?: string + param?: { + payload?: unknown + type: string + code: string + } + name?: string + cmdType?: string +} + +type PluginLauncher = (options: PluginLaunchOptions) => Promise<{ + success?: boolean + error?: string + [key: string]: unknown +}> + +interface PluginLaunchFile { + path: string + name: string + isDirectory: boolean + isFile: boolean +} + +const PLUGIN_COMMAND_TYPES = ['text', 'over', 'regex', 'img', 'files', 'window'] const DB_KEY = 'settings-http-server' const DEFAULT_PORT = 36578 class HttpServer { private server: Server | null = null + private pluginLauncher: PluginLauncher | null = null private config: HttpServerConfig = { enabled: false, port: DEFAULT_PORT, apiKey: '' } + /** + * 注入插件启动器,供 HTTP 接口复用应用内统一启动链路。 + * @param launcher 负责启动指定插件的异步函数。 + * @returns 无返回值。 + */ + public setPluginLauncher(launcher: PluginLauncher): void { + this.pluginLauncher = launcher + } + public async init(): Promise { await this.loadConfig() if (this.config.enabled) { @@ -199,6 +240,12 @@ class HttpServer { }) } + /** + * 根据请求路径分发到具体 HTTP API 处理器。 + * @param url 请求路径。 + * @param body 已解析的 JSON 请求体。 + * @returns 统一 API 响应。 + */ private async routeRequest(url: string, body: Record): Promise { switch (url) { case '/api/window/show': @@ -207,11 +254,246 @@ class HttpServer { return this.handleHideWindow() case '/api/window/toggle': return this.handleToggleWindow() + case '/api/plugin/launch': + return await this.handleLaunchPlugin(body) default: return { code: 404, message: `未知接口: ${url}` } } } + /** + * 按插件名称从已安装插件列表中查找目标插件。 + * @param pluginName 插件 name 或 title。 + * @returns 匹配到的插件记录;找不到时返回 null。 + */ + private findPluginByName(pluginName: string): any | null { + const plugins = databaseAPI.dbGet('plugins') + if (!Array.isArray(plugins)) return null + + // 优先匹配清单 name,title 作为面向用户展示名的兜底。 + return ( + plugins.find((plugin: any) => plugin?.name === pluginName) || + plugins.find((plugin: any) => plugin?.title === pluginName) || + null + ) + } + + /** + * 解析 HTTP 插件启动类型,保持与手动启动插件时的 type/cmdType 一致。 + * @param body 已解析的 JSON 请求体。 + * @returns 插件命令类型。 + */ + private resolvePluginCommandType(body: Record): string { + const type = typeof body.type === 'string' ? body.type.trim() : '' + if (!type) { + return 'text' + } + + if (PLUGIN_COMMAND_TYPES.includes(type)) { + return type + } + + throw new Error(`不支持的插件启动类型: ${type}`) + } + + /** + * 读取插件运行时注册的动态功能列表。 + * @param pluginName 插件运行时名称。 + * @returns 动态功能列表,读取失败时返回空数组。 + */ + private loadDynamicPluginFeatures(pluginName: string): any[] { + try { + const doc = lmdbInstance.get(`${getPluginDataPrefix(pluginName)}dynamic-features`) + if (!doc?.data) return [] + + const data = JSON.parse(doc.data) + return Array.isArray(data.features) ? data.features : [] + } catch (error) { + console.error('[HttpServer] 读取动态插件功能失败:', error) + return [] + } + } + + /** + * 校验插件是否声明了指定功能 code。 + * @param plugin 已安装插件记录。 + * @param featureCode 待启动的插件功能 code。 + * @returns 插件静态或动态功能中存在该 code 时返回 true。 + */ + private validatePluginFeatureCode(plugin: any, featureCode: string): boolean { + const features: any[] = [] + + // 先使用安装记录里的 features,覆盖常规已安装插件。 + if (Array.isArray(plugin?.features)) { + features.push(...plugin.features) + } + + try { + const pluginJsonPath = path.join(plugin.path, 'plugin.json') + const pluginConfig = JSON.parse(fsSync.readFileSync(pluginJsonPath, 'utf-8')) + if (Array.isArray(pluginConfig.features)) { + features.push(...pluginConfig.features) + } + } catch (error) { + console.error('[HttpServer] 读取插件功能配置失败:', error) + } + + // 动态 feature 与手动启动/搜索链路一致,按插件运行时名称读取。 + features.push(...this.loadDynamicPluginFeatures(plugin.name)) + + return features.some((feature) => feature?.code === featureCode) + } + + /** + * 将 HTTP 文件 payload 转成手动启动文件插件时一致的文件对象列表。 + * @param payload HTTP 请求中的 payload,支持路径字符串数组或文件对象数组。 + * @returns 标准文件 payload 列表。 + * @throws payload 结构不符合 files 类型要求时抛错。 + */ + private normalizeFilesPayload(payload: unknown): PluginLaunchFile[] { + if (!Array.isArray(payload)) { + throw new Error('files 类型的 payload 必须是文件数组') + } + + return payload.map((item) => { + const filePath = + typeof item === 'string' + ? item + : item && typeof item === 'object' && typeof (item as any).path === 'string' + ? (item as any).path + : '' + + if (!filePath) { + throw new Error('files 类型的 payload 每一项都必须包含文件路径') + } + + let isDirectory = false + try { + // 尽量补全目录信息,让插件收到的结构与手动文件启动保持一致。 + isDirectory = fsSync.statSync(filePath).isDirectory() + } catch { + isDirectory = + typeof item === 'object' && item !== null ? Boolean((item as any).isDirectory) : false + } + + const name = + typeof item === 'object' && item !== null && typeof (item as any).name === 'string' + ? (item as any).name + : path.basename(filePath) + + return { + path: filePath, + name, + isDirectory, + isFile: + typeof item === 'object' && item !== null && typeof (item as any).isFile === 'boolean' + ? (item as any).isFile + : !isDirectory + } + }) + } + + /** + * 解析并规范化 HTTP 插件启动 payload。 + * @param commandType 插件启动类型。 + * @param payload HTTP 请求中的原始 payload。 + * @returns 与手动启动插件一致的 payload。 + * @throws payload 与启动类型不匹配时抛错。 + */ + private normalizePluginLaunchPayload(commandType: string, payload: unknown): unknown { + if (commandType === 'files') { + return this.normalizeFilesPayload(payload) + } + + return payload + } + + /** + * 通过 HTTP 请求启动已安装插件,并将请求参数传给插件。 + * @param body 已解析的 JSON 请求体,必须包含 pluginName 和 code。 + * @returns 插件启动结果。 + */ + private async handleLaunchPlugin(body: Record): Promise { + try { + const pluginName = typeof body.pluginName === 'string' ? body.pluginName.trim() : '' + if (!pluginName) { + return { code: 400, message: '缺少插件名称 pluginName' } + } + + const featureCode = typeof body.code === 'string' ? body.code.trim() : '' + if (!featureCode) { + return { code: 400, message: '缺少插件功能 code' } + } + + if (!this.pluginLauncher) { + return { code: 500, message: '插件启动器未初始化' } + } + + const plugin = this.findPluginByName(pluginName) + if (!plugin?.path) { + return { code: 404, message: `未找到插件: ${pluginName}` } + } + + if (!this.validatePluginFeatureCode(plugin, featureCode)) { + return { code: 404, message: `插件 ${pluginName} 未找到功能 code: ${featureCode}` } + } + + // HTTP 启动参数与手动启动插件保持一致,避免插件侧适配额外协议。 + let commandType: string + try { + commandType = this.resolvePluginCommandType(body) + } catch (error) { + return { + code: 400, + message: error instanceof Error ? error.message : '插件启动类型无效' + } + } + + let payload: unknown + try { + payload = this.normalizePluginLaunchPayload(commandType, body.payload) + } catch (error) { + return { + code: 400, + message: error instanceof Error ? error.message : '插件启动 payload 无效' + } + } + + const result = await this.pluginLauncher({ + path: plugin.path, + type: 'plugin', + featureCode, + param: { + payload, + type: commandType, + code: featureCode + }, + name: plugin.title || plugin.name || pluginName, + cmdType: commandType + }) + + if (result?.success === false) { + return { code: 500, message: result.error || '启动插件失败' } + } + + return { + code: 0, + message: '操作成功', + data: { + name: plugin.name, + title: plugin.title, + path: plugin.path, + result + } + } + } catch (error) { + return { + code: 500, + message: error instanceof Error ? error.message : '启动插件失败' + } + } + } + private handleShowWindow(body: Record): ApiResponse { try { windowManager.showWindow()