chore: initialize qiming workspace repository
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import http from "http";
|
||||
import { LOCALHOST_HOSTNAME } from "../constants";
|
||||
|
||||
export interface FileServerHealthResult {
|
||||
healthy: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 GET /health 端点检查 file-server 健康状态
|
||||
* 非阻塞,5 秒超时
|
||||
*/
|
||||
export async function checkFileServerHealth(
|
||||
port: number,
|
||||
): Promise<FileServerHealthResult> {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: LOCALHOST_HOSTNAME,
|
||||
port,
|
||||
path: "/health",
|
||||
method: "GET",
|
||||
timeout: 5000,
|
||||
},
|
||||
(res) => {
|
||||
let body = "";
|
||||
res.on("data", (chunk) => (body += chunk));
|
||||
res.on("end", () => {
|
||||
if (res.statusCode === 200) {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
if (data.status === "ok") {
|
||||
resolve({ healthy: true });
|
||||
} else {
|
||||
resolve({
|
||||
healthy: false,
|
||||
error: `Unexpected status: ${data.status}`,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
resolve({
|
||||
healthy: false,
|
||||
error: `Invalid JSON response: ${e}`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
resolve({
|
||||
healthy: false,
|
||||
error: `HTTP ${res.statusCode}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
req.on("error", (err) => {
|
||||
resolve({ healthy: false, error: err.message });
|
||||
});
|
||||
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
resolve({ healthy: false, error: "Health check timeout (5s)" });
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* GUI Agent Server 管理器(Electron 集成)
|
||||
*
|
||||
* 提供非 Windows 平台下 agent-gui-server (Node.js MCP server) 的启动、停止和状态管理。
|
||||
* agent-gui-server 提供 GUI 桌面自动化能力(截图、鼠标、键盘等 MCP tools)。
|
||||
*
|
||||
* 端口通过 settings 中的 guiMcpPort 配置(默认 60008),与 agent 会话注入的 MCP URL 对齐。
|
||||
* API Key 通过 GUI_AGENT_API_KEY 环境变量传入(复用 anthropic_api_key)。
|
||||
*/
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import log from "electron-log";
|
||||
import { t } from "../i18n";
|
||||
import { ManagedProcess } from "../../processManager";
|
||||
import {
|
||||
getResourcesPath,
|
||||
getNodeBinPathWithFallback,
|
||||
getAppEnv,
|
||||
} from "../system/dependencies";
|
||||
import { isWindows } from "../system/shellEnv";
|
||||
import { getDb, readSetting } from "../../db";
|
||||
import { DEFAULT_GUI_MCP_PORT } from "@shared/constants";
|
||||
import { killProcessTreesListeningOnTcpPort } from "../utils/processTree";
|
||||
|
||||
/** GUI Agent Server 进程名称 */
|
||||
const PROCESS_NAME = "gui-agent-server";
|
||||
|
||||
/**
|
||||
* 从 DB 读取 GUI MCP 端口配置
|
||||
*/
|
||||
export function getGuiMcpPort(): number {
|
||||
try {
|
||||
const config = readSetting("step1_config") as {
|
||||
guiMcpPort?: number;
|
||||
} | null;
|
||||
return config?.guiMcpPort ?? DEFAULT_GUI_MCP_PORT;
|
||||
} catch {
|
||||
return DEFAULT_GUI_MCP_PORT;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 agent-gui-server 的入口 JS 文件路径
|
||||
*
|
||||
* 打包后: process.resourcesPath/agent-gui-server/dist/index.js
|
||||
* 开发时: {cwd}/resources/agent-gui-server/dist/index.js
|
||||
*/
|
||||
function getGuiAgentServerEntryPath(): string | null {
|
||||
const resourcesPath = getResourcesPath();
|
||||
const entryPath = path.join(
|
||||
resourcesPath,
|
||||
"agent-gui-server",
|
||||
"dist",
|
||||
"index.js",
|
||||
);
|
||||
if (fs.existsSync(entryPath)) {
|
||||
return entryPath;
|
||||
}
|
||||
log.warn(`[GuiAgentServer] Entry file not found: ${entryPath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 SQLite 获取 API Key(优先 gui_agent_vision_model 中的 apiKey)
|
||||
*/
|
||||
function getApiKeyFromDb(): string | null {
|
||||
try {
|
||||
const db = getDb();
|
||||
if (!db) return null;
|
||||
|
||||
// 优先读取 gui_agent_vision_model 配置中的 apiKey
|
||||
const visionConfigRow = db
|
||||
.prepare("SELECT value FROM settings WHERE key = ?")
|
||||
.get("gui_agent_vision_model") as { value: string } | undefined;
|
||||
|
||||
if (visionConfigRow) {
|
||||
const config = JSON.parse(visionConfigRow.value);
|
||||
if (config?.apiKey) return config.apiKey;
|
||||
}
|
||||
|
||||
// 回退到全局 anthropic_api_key
|
||||
const apiKeyRow = db
|
||||
.prepare("SELECT value FROM settings WHERE key = ?")
|
||||
.get("anthropic_api_key") as { value: string } | undefined;
|
||||
|
||||
return apiKeyRow?.value ?? null;
|
||||
} catch (e) {
|
||||
log.warn("[GuiAgentServer] Failed to read API Key:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查视觉模型是否已配置
|
||||
*/
|
||||
function checkVisionModelConfigured(): boolean {
|
||||
try {
|
||||
const db = getDb();
|
||||
if (!db) return false;
|
||||
|
||||
const visionConfigRow = db
|
||||
.prepare("SELECT value FROM settings WHERE key = ?")
|
||||
.get("gui_agent_vision_model") as { value: string } | undefined;
|
||||
|
||||
if (visionConfigRow) {
|
||||
const config = JSON.parse(visionConfigRow.value);
|
||||
if (config?.apiKey && config?.model) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视觉模型名称
|
||||
*/
|
||||
function getVisionModelName(): string | null {
|
||||
try {
|
||||
const db = getDb();
|
||||
if (!db) return null;
|
||||
|
||||
const visionConfigRow = db
|
||||
.prepare("SELECT value FROM settings WHERE key = ?")
|
||||
.get("gui_agent_vision_model") as { value: string } | undefined;
|
||||
|
||||
if (visionConfigRow) {
|
||||
const config = JSON.parse(visionConfigRow.value);
|
||||
if (config?.model) return config.model;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 状态管理 ==========
|
||||
|
||||
let processInstance: ManagedProcess | null = null;
|
||||
let running = false;
|
||||
let runningPort: number | null = null;
|
||||
let lastError: string | null = null;
|
||||
|
||||
/**
|
||||
* 启动 GUI Agent Server
|
||||
*
|
||||
* 仅在非 Windows 平台有效。Windows 平台使用 windows-mcp(见 windowsMcp.ts)。
|
||||
*/
|
||||
export async function startGuiAgentServer(): Promise<{
|
||||
success: boolean;
|
||||
port?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
// Windows 平台跳过(使用 windows-mcp)
|
||||
if (isWindows()) {
|
||||
log.info("[GuiAgentServer] Skipped: Windows platform uses windows-mcp");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// 已运行则直接返回
|
||||
if (running && processInstance && runningPort !== null) {
|
||||
return { success: true, port: runningPort };
|
||||
}
|
||||
|
||||
// 读取配置的端口
|
||||
const port = getGuiMcpPort();
|
||||
|
||||
// 启动前清理占用该端口的残留进程(跨平台实现)
|
||||
try {
|
||||
log.info(`[GuiAgentServer] Pre-start port sweep for ${port}...`);
|
||||
await killProcessTreesListeningOnTcpPort(port);
|
||||
await new Promise((r) => setTimeout(r, 450));
|
||||
} catch (e) {
|
||||
log.warn("[GuiAgentServer] Pre-start port sweep:", e);
|
||||
}
|
||||
|
||||
// 解析入口文件
|
||||
const entryPath = getGuiAgentServerEntryPath();
|
||||
if (!entryPath) {
|
||||
const error = t("Claw.GUIAgent.entryNotFound");
|
||||
log.error("[GuiAgentServer] GUI Agent entry file not found");
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
// 检查 Node.js 可用性
|
||||
const nodeBinPath = getNodeBinPathWithFallback();
|
||||
if (!nodeBinPath) {
|
||||
const error = t("Claw.GUIAgent.nodeNotFound");
|
||||
log.error("[GuiAgentServer] Node.js binary not found");
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
// 获取 API Key
|
||||
const apiKey = getApiKeyFromDb();
|
||||
if (!apiKey) {
|
||||
log.warn("[GuiAgentServer] API key missing (GUI Agent may be limited)");
|
||||
}
|
||||
|
||||
// 检查视觉模型是否配置
|
||||
const hasVisionModel = checkVisionModelConfigured();
|
||||
const visionModelName = getVisionModelName();
|
||||
|
||||
// 构建环境变量
|
||||
const env: Record<string, string> = {
|
||||
...getAppEnv(),
|
||||
GUI_AGENT_API_KEY: apiKey ?? "",
|
||||
// 明确指定传输协议和端口
|
||||
GUI_AGENT_TRANSPORT: "http",
|
||||
GUI_AGENT_PORT: port.toString(),
|
||||
// 传递视觉模型名称,无配置则为空(用于在 MCP 工具列表中禁用 gui_analyze_screen)
|
||||
GUI_AGENT_VISION_MODEL: hasVisionModel ? (visionModelName ?? "") : "",
|
||||
// 禁用遥测
|
||||
ANONYMIZED_TELEMETRY: "false",
|
||||
};
|
||||
|
||||
// 构建启动参数
|
||||
const args = [entryPath, "--port", port.toString(), "--transport", "http"];
|
||||
|
||||
// 创建并启动进程
|
||||
processInstance = new ManagedProcess(PROCESS_NAME);
|
||||
|
||||
log.info(`[GuiAgentServer] Starting on port ${port}...`);
|
||||
log.info(`[GuiAgentServer] Entry: ${entryPath}`);
|
||||
|
||||
try {
|
||||
const result = await processInstance.start({
|
||||
command: nodeBinPath,
|
||||
args,
|
||||
env,
|
||||
startupDelayMs: 5000, // GUI Agent Server 需要较长的启动时间
|
||||
onStdoutLine: (line) => {
|
||||
// 转发到 electron-log
|
||||
if (line.trim()) {
|
||||
log.info(`[GuiAgentServer] ${line}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
lastError = result.error ?? "Failed to start";
|
||||
running = false;
|
||||
runningPort = null;
|
||||
processInstance = null;
|
||||
log.error(`[GuiAgentServer] Failed to start: ${lastError}`);
|
||||
return { success: false, error: lastError };
|
||||
}
|
||||
|
||||
running = true;
|
||||
runningPort = port;
|
||||
lastError = null;
|
||||
log.info(`[GuiAgentServer] Started successfully on port ${port}`);
|
||||
return { success: true, port };
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||
lastError = errorMsg;
|
||||
running = false;
|
||||
runningPort = null;
|
||||
processInstance = null;
|
||||
log.error(`[GuiAgentServer] Start exception: ${errorMsg}`);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止 GUI Agent Server
|
||||
*/
|
||||
export async function stopGuiAgentServer(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
if (!running || !processInstance) {
|
||||
running = false;
|
||||
runningPort = null;
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
log.info("[GuiAgentServer] Stopping...");
|
||||
|
||||
// 兜底:ManagedProcess 进程可能已退出但端口仍被占用(如子进程残留)
|
||||
try {
|
||||
await killProcessTreesListeningOnTcpPort(getGuiMcpPort());
|
||||
} catch (e) {
|
||||
log.warn("[GuiAgentServer] TCP port sweep after stop:", e);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await processInstance.stopAsync();
|
||||
running = false;
|
||||
runningPort = null;
|
||||
processInstance = null;
|
||||
|
||||
if (result.success) {
|
||||
log.info("[GuiAgentServer] Stopped successfully");
|
||||
return { success: true };
|
||||
} else {
|
||||
log.error(`[GuiAgentServer] Stop failed: ${result.message}`);
|
||||
return { success: false, error: result.message };
|
||||
}
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||
running = false;
|
||||
runningPort = null;
|
||||
processInstance = null;
|
||||
log.error(`[GuiAgentServer] Stop exception: ${errorMsg}`);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 GUI Agent Server 运行状态
|
||||
*/
|
||||
export function getGuiAgentServerStatus(): {
|
||||
running: boolean;
|
||||
port?: number;
|
||||
error?: string | null;
|
||||
} {
|
||||
return {
|
||||
running,
|
||||
port: running ? (runningPort ?? undefined) : undefined,
|
||||
error: lastError,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 GUI Agent Server MCP URL(供 Agent 引擎注入使用)
|
||||
*
|
||||
* 返回 http://127.0.0.1:{runningPort}/mcp,使用实际运行的端口。
|
||||
* 仅在非 Windows 平台且内嵌 gui 服务已启动时返回非 null。
|
||||
* Windows 桌面自动化由 windows-mcp 提供,请使用 getWindowsMcpUrl()(见 acpEngine)。
|
||||
*/
|
||||
export function getGuiAgentServerUrl(): string | null {
|
||||
if (isWindows()) {
|
||||
return null;
|
||||
}
|
||||
if (!running || runningPort === null) {
|
||||
return null;
|
||||
}
|
||||
return `http://127.0.0.1:${runningPort}/mcp`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 GUI Agent Server 是否可用(入口文件存在且非 Windows)
|
||||
*/
|
||||
export function isGuiAgentServerAvailable(): boolean {
|
||||
if (isWindows()) {
|
||||
return false;
|
||||
}
|
||||
const entryPath = getGuiAgentServerEntryPath();
|
||||
return entryPath !== null;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Main process package management services
|
||||
export {
|
||||
mcpProxyManager,
|
||||
DEFAULT_MCP_PROXY_CONFIG,
|
||||
type McpServersConfig,
|
||||
} from './mcp';
|
||||
export { getAppPaths, isInstalledLocally } from './packageLocator';
|
||||
// Re-export ports from constants
|
||||
export { DEFAULT_MCP_PROXY_PORT, DEFAULT_MCP_PROXY_HOST } from '../constants';
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { readSetting } from "../../db";
|
||||
import { t } from "../i18n";
|
||||
import type { HttpResult } from "@shared/types/computerTypes";
|
||||
import log from "electron-log";
|
||||
|
||||
function isHttpResult(value: unknown): value is HttpResult<unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const body = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof body.code === "string" &&
|
||||
typeof body.message === "string" &&
|
||||
"data" in body
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Lanproxy 通道健康状态
|
||||
*/
|
||||
export async function checkLanproxyHealth(savedKey: string): Promise<{
|
||||
healthy: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const serverHost = readSetting("lanproxy.server_host") as string | null;
|
||||
|
||||
if (!serverHost) {
|
||||
return { healthy: false, error: t("Claw.Lanproxy.missingServerConfig") };
|
||||
}
|
||||
|
||||
try {
|
||||
// serverHost 可能是纯域名(如 testagent.xspaceagi.com)或带协议(如 https://testagent.xspaceagi.com)
|
||||
const protocol = serverHost.startsWith("https") ? "https" : "http";
|
||||
const normalizedHost = serverHost.replace(/^https?:\/\//, "");
|
||||
const url = `${protocol}://${normalizedHost}/api/sandbox/config/health/${savedKey}`;
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return {
|
||||
healthy: false,
|
||||
error: `HTTP ${response.status}`,
|
||||
};
|
||||
}
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await response.json();
|
||||
log.info("[LanproxyHealth] Health API response", {
|
||||
status: response.status,
|
||||
data,
|
||||
});
|
||||
} catch {
|
||||
return {
|
||||
healthy: false,
|
||||
error: "Invalid JSON in health response",
|
||||
};
|
||||
}
|
||||
if (!isHttpResult(data)) {
|
||||
return {
|
||||
healthy: false,
|
||||
error: "Unexpected health response",
|
||||
};
|
||||
}
|
||||
const body = data;
|
||||
if (body.code === "0000") {
|
||||
return { healthy: true };
|
||||
}
|
||||
const apiMessage =
|
||||
typeof body.message === "string" && body.message.trim()
|
||||
? body.message
|
||||
: undefined;
|
||||
const codeWithBracket = `[${body.code}]`;
|
||||
return {
|
||||
healthy: false,
|
||||
error: apiMessage
|
||||
? `${codeWithBracket} ${apiMessage}`
|
||||
: `${codeWithBracket} Health check failed`,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
healthy: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* MCP Helper Utilities
|
||||
*
|
||||
* Pure functions for MCP server configuration comparison and filtering.
|
||||
* No Electron or side-effectful dependencies — safe to import anywhere,
|
||||
* including test files without additional mocks.
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import type { McpServerEntry } from './mcp';
|
||||
|
||||
/**
|
||||
* 从 MCP server 配置中过滤掉 bridge 聚合入口
|
||||
* (command 为 "mcp-proxy" 或其绝对路径形式)。
|
||||
*
|
||||
* 用于 detectConfigChange 和 engineRawMcpServers 的原始格式快照存储。
|
||||
*/
|
||||
export function filterBridgeEntries(
|
||||
servers: Record<string, McpServerEntry>,
|
||||
): Record<string, McpServerEntry> {
|
||||
const result: Record<string, McpServerEntry> = {};
|
||||
for (const [name, entry] of Object.entries(servers)) {
|
||||
if ('command' in entry && (entry.command === 'mcp-proxy' || path.basename(entry.command) === 'mcp-proxy')) continue;
|
||||
result[name] = entry;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两组原始 MCP server 配置是否语义等价。
|
||||
*
|
||||
* 只比较决定进程行为的字段:command / args / allowTools / denyTools / url(远程 server)。
|
||||
* 故意忽略 env:env 由 getAppEnv() 每次注入,内容固定,不触发引擎重建。
|
||||
*
|
||||
* @param a 本次请求传入的原始 MCP servers(已过滤掉 mcp-proxy 入口)
|
||||
* @param b 上次存储的原始 MCP servers;undefined 表示首次(无历史快照)
|
||||
*/
|
||||
export function rawMcpServersEqual(
|
||||
a: Record<string, McpServerEntry>,
|
||||
b: Record<string, McpServerEntry> | undefined,
|
||||
): boolean {
|
||||
if (!b) return false;
|
||||
const aKeys = Object.keys(a).sort();
|
||||
const bKeys = Object.keys(b).sort();
|
||||
if (aKeys.join('\0') !== bKeys.join('\0')) return false;
|
||||
for (const key of aKeys) {
|
||||
const ea = a[key];
|
||||
const eb = b[key];
|
||||
// 远程 server:只比较 URL
|
||||
if ('url' in ea || 'url' in eb) {
|
||||
if (('url' in ea ? ea.url : undefined) !== ('url' in eb ? eb.url : undefined)) return false;
|
||||
continue;
|
||||
}
|
||||
// stdio server:比较 command / args / allowTools / denyTools
|
||||
// 同名 key 类型不一致(一方有 command,另一方没有)→ 视为不等,触发重建
|
||||
if (!('command' in ea) || !('command' in eb)) return false;
|
||||
if (ea.command !== eb.command) return false;
|
||||
if (JSON.stringify(ea.args ?? []) !== JSON.stringify(eb.args ?? [])) return false;
|
||||
// 对数组型字段排序后比较,避免顺序差异误判
|
||||
const sortedAllowA = [...(ea.allowTools ?? [])].sort().join('\0');
|
||||
const sortedAllowB = [...(eb.allowTools ?? [])].sort().join('\0');
|
||||
if (sortedAllowA !== sortedAllowB) return false;
|
||||
const sortedDenyA = [...(ea.denyTools ?? [])].sort().join('\0');
|
||||
const sortedDenyB = [...(eb.denyTools ?? [])].sort().join('\0');
|
||||
if (sortedDenyA !== sortedDenyB) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import { spawn } from 'child_process';
|
||||
import { getAppEnv, getResourcesPath } from '../system/dependencies';
|
||||
import { APP_DATA_DIR_NAME } from '../constants';
|
||||
import { isWindows } from '../system/shellEnv';
|
||||
|
||||
// ==================== App Paths ====================
|
||||
|
||||
export interface AppPaths {
|
||||
appData: string;
|
||||
nodeModules: string;
|
||||
mcpModules: string;
|
||||
temp: string;
|
||||
logs: string;
|
||||
workspaces: string;
|
||||
}
|
||||
|
||||
let appPaths: AppPaths | null = null;
|
||||
|
||||
export function getAppPaths(basePath?: string): AppPaths {
|
||||
if (appPaths) return appPaths;
|
||||
|
||||
const base = basePath || os.homedir();
|
||||
|
||||
appPaths = {
|
||||
appData: path.join(base, APP_DATA_DIR_NAME),
|
||||
nodeModules: path.join(base, APP_DATA_DIR_NAME, 'node_modules'),
|
||||
mcpModules: path.join(base, APP_DATA_DIR_NAME, 'node_modules', 'mcp-servers'),
|
||||
temp: path.join(base, APP_DATA_DIR_NAME, 'temp'),
|
||||
logs: path.join(base, APP_DATA_DIR_NAME, 'logs'),
|
||||
workspaces: path.join(base, APP_DATA_DIR_NAME, 'workspaces'),
|
||||
};
|
||||
|
||||
// Ensure directories exist
|
||||
for (const [, dir] of Object.entries(appPaths)) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
return appPaths;
|
||||
}
|
||||
|
||||
// ==================== Package Detection ====================
|
||||
|
||||
export interface PackageInfo {
|
||||
name: string;
|
||||
version?: string;
|
||||
path: string;
|
||||
isLocal: boolean;
|
||||
isGlobal: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get executable path - check local first, then global
|
||||
* This ensures we use the app's own installation
|
||||
*/
|
||||
export function getExecutablePath(packageName: string): string | null {
|
||||
const dirs = getAppPaths();
|
||||
|
||||
// 1. Check local MCP modules
|
||||
const localBin = path.join(dirs.mcpModules, '.bin', packageName);
|
||||
if (isWindows()) {
|
||||
if (fs.existsSync(localBin + '.cmd')) return localBin + '.cmd';
|
||||
if (fs.existsSync(localBin + '.exe')) return localBin + '.exe';
|
||||
}
|
||||
if (fs.existsSync(localBin)) return localBin;
|
||||
|
||||
// 2. Check local node_modules
|
||||
const localNodeBin = path.join(dirs.nodeModules, '.bin', packageName);
|
||||
if (isWindows()) {
|
||||
if (fs.existsSync(localNodeBin + '.cmd')) return localNodeBin + '.cmd';
|
||||
if (fs.existsSync(localNodeBin + '.exe')) return localNodeBin + '.exe';
|
||||
}
|
||||
if (fs.existsSync(localNodeBin)) return localNodeBin;
|
||||
|
||||
// 3. Return null - package not found locally
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get package JS entry file path from package.json bin field.
|
||||
*
|
||||
* This resolves the actual JS entry file instead of using .cmd wrappers,
|
||||
* which avoids CMD window popup on Windows.
|
||||
*
|
||||
* @param packageName - The npm package name
|
||||
* @param binName - Optional bin name (defaults to packageName)
|
||||
* @returns The absolute path to the JS entry file, or null if not found
|
||||
*/
|
||||
export function getPackageJsEntryPath(packageName: string, binName?: string): string | null {
|
||||
const dirs = getAppPaths();
|
||||
|
||||
// Check in main node_modules
|
||||
const packageDir = path.join(dirs.nodeModules, packageName);
|
||||
if (fs.existsSync(packageDir)) {
|
||||
const entry = resolveBinEntry(packageDir, binName || packageName);
|
||||
if (entry) return entry;
|
||||
}
|
||||
|
||||
// Check in mcp-servers
|
||||
const mcpPackageDir = path.join(dirs.mcpModules, packageName);
|
||||
if (fs.existsSync(mcpPackageDir)) {
|
||||
const entry = resolveBinEntry(mcpPackageDir, binName || packageName);
|
||||
if (entry) return entry;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve bin entry from package.json
|
||||
*/
|
||||
function resolveBinEntry(packageDir: string, binName: string): string | null {
|
||||
const pkgJsonPath = path.join(packageDir, 'package.json');
|
||||
|
||||
if (!fs.existsSync(pkgJsonPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
|
||||
|
||||
// Get bin path from package.json
|
||||
let binPath: string | undefined;
|
||||
if (typeof pkg.bin === 'string') {
|
||||
binPath = pkg.bin;
|
||||
} else if (pkg.bin && typeof pkg.bin === 'object') {
|
||||
// bin: { "command-name": "./path/to/file.js" }
|
||||
const binValue = pkg.bin[binName] ?? Object.values(pkg.bin)[0];
|
||||
if (typeof binValue === 'string') {
|
||||
binPath = binValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (binPath) {
|
||||
const entryPath = path.join(packageDir, binPath);
|
||||
if (fs.existsSync(entryPath)) {
|
||||
return entryPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try common entry file locations
|
||||
const fallbacks = [
|
||||
path.join(packageDir, 'index.js'),
|
||||
path.join(packageDir, 'cli.js'),
|
||||
path.join(packageDir, 'dist', 'index.js'),
|
||||
path.join(packageDir, 'bin', binName),
|
||||
path.join(packageDir, 'bin', `${binName}.js`),
|
||||
];
|
||||
|
||||
for (const p of fallbacks) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
// Failed to parse package.json - this is expected for malformed packages
|
||||
// Silent fail as we have fallback mechanisms
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a package is installed locally (in app directory)
|
||||
*/
|
||||
export function isInstalledLocally(packageName: string): boolean {
|
||||
const dirs = getAppPaths();
|
||||
|
||||
// Check in mcp-servers
|
||||
const mcpPath = path.join(dirs.mcpModules, packageName);
|
||||
if (fs.existsSync(mcpPath)) return true;
|
||||
|
||||
// Check in main node_modules
|
||||
const nodePath = path.join(dirs.nodeModules, packageName);
|
||||
if (fs.existsSync(nodePath)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a package is installed globally (system-wide)
|
||||
*/
|
||||
export async function isInstalledGlobally(packageName: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const npmCmd = isWindows() ? 'npm.cmd' : 'npm';
|
||||
const args = ['list', '-g', '--depth=0', packageName];
|
||||
|
||||
const proc = spawn(npmCmd, args, {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
shell: isWindows(),
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
proc.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
resolve(code === 0 && stdout.includes(packageName));
|
||||
});
|
||||
|
||||
proc.on('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed package info - local vs global
|
||||
*/
|
||||
export async function getPackageInfo(packageName: string): Promise<PackageInfo> {
|
||||
const dirs = getAppPaths();
|
||||
|
||||
// Check local first
|
||||
const localPath = path.join(dirs.mcpModules, packageName);
|
||||
const localNodePath = path.join(dirs.nodeModules, packageName);
|
||||
|
||||
let localVersion: string | undefined;
|
||||
|
||||
if (fs.existsSync(localPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(localPath, 'package.json'), 'utf-8'));
|
||||
localVersion = pkg.version;
|
||||
} catch {}
|
||||
} else if (fs.existsSync(localNodePath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(localNodePath, 'package.json'), 'utf-8'));
|
||||
localVersion = pkg.version;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const isLocal = fs.existsSync(localPath) || fs.existsSync(localNodePath);
|
||||
const isGlobal = await isInstalledGlobally(packageName);
|
||||
|
||||
return {
|
||||
name: packageName,
|
||||
version: localVersion,
|
||||
path: isLocal ? (fs.existsSync(localPath) ? localPath : localNodePath) : '',
|
||||
isLocal,
|
||||
isGlobal,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version of locally installed package
|
||||
*/
|
||||
export function getLocalVersion(packageName: string): string | null {
|
||||
const dirs = getAppPaths();
|
||||
|
||||
// Check mcp-modules first
|
||||
const mcpPath = path.join(dirs.mcpModules, packageName, 'package.json');
|
||||
if (fs.existsSync(mcpPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(mcpPath, 'utf-8'));
|
||||
return pkg.version || null;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Check node_modules
|
||||
const nodePath = path.join(dirs.nodeModules, packageName, 'package.json');
|
||||
if (fs.existsSync(nodePath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(nodePath, 'utf-8'));
|
||||
return pkg.version || null;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version of globally installed package
|
||||
*/
|
||||
export async function getGlobalVersion(packageName: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const npmCmd = isWindows() ? 'npm.cmd' : 'npm';
|
||||
const args = ['view', packageName, 'version', '--json'];
|
||||
|
||||
const proc = spawn(npmCmd, args, {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
shell: isWindows(),
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', () => {
|
||||
try {
|
||||
const version = JSON.parse(stdout.trim());
|
||||
resolve(version || null);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', () => {
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare versions and return update info
|
||||
*/
|
||||
export async function checkForUpdate(packageName: string): Promise<{
|
||||
hasUpdate: boolean;
|
||||
localVersion: string | null;
|
||||
latestVersion: string | null;
|
||||
}> {
|
||||
const localVersion = getLocalVersion(packageName);
|
||||
const latestVersion = await getGlobalVersion(packageName);
|
||||
|
||||
if (!localVersion || !latestVersion) {
|
||||
return { hasUpdate: false, localVersion, latestVersion };
|
||||
}
|
||||
|
||||
// Simple version compare (would use semver in production)
|
||||
const hasUpdate = localVersion !== latestVersion;
|
||||
|
||||
return { hasUpdate, localVersion, latestVersion };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed version info for all MCP packages
|
||||
*/
|
||||
export async function getAllMCPVersions(): Promise<Record<string, {
|
||||
local: string | null;
|
||||
global: string | null;
|
||||
latest: string | null;
|
||||
hasUpdate: boolean;
|
||||
}>> {
|
||||
const mcpPackages = [
|
||||
'@modelcontextprotocol/server-filesystem',
|
||||
'@modelcontextprotocol/server-brave-search',
|
||||
'@modelcontextprotocol/server-github',
|
||||
'@modelcontextprotocol/server-sqlite',
|
||||
'@modelcontextprotocol/server-puppeteer',
|
||||
'@modelcontextprotocol/server-fetch',
|
||||
];
|
||||
|
||||
const results: Record<string, any> = {};
|
||||
|
||||
for (const pkg of mcpPackages) {
|
||||
const localVersion = getLocalVersion(pkg);
|
||||
const latestVersion = await getGlobalVersion(pkg);
|
||||
|
||||
results[pkg] = {
|
||||
local: localVersion,
|
||||
global: null, // Would need separate check
|
||||
latest: latestVersion,
|
||||
hasUpdate: localVersion !== latestVersion && latestVersion !== null,
|
||||
};
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of locally installed packages
|
||||
*/
|
||||
export function getLocalPackages(): string[] {
|
||||
const dirs = getAppPaths();
|
||||
const packages: string[] = [];
|
||||
|
||||
// Check mcp-modules
|
||||
if (fs.existsSync(dirs.mcpModules)) {
|
||||
const entries = fs.readdirSync(dirs.mcpModules);
|
||||
for (const entry of entries) {
|
||||
if (!entry.startsWith('.')) {
|
||||
const stat = fs.statSync(path.join(dirs.mcpModules, entry));
|
||||
if (stat.isDirectory()) {
|
||||
packages.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a command using LOCAL executable only
|
||||
* Throws error if package not found locally
|
||||
*/
|
||||
export function spawnLocal(
|
||||
packageName: string,
|
||||
args: string[],
|
||||
options?: {
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
registry?: string;
|
||||
}
|
||||
): ReturnType<typeof spawn> {
|
||||
const dirs = getAppPaths();
|
||||
|
||||
// Find local executable
|
||||
let exePath: string;
|
||||
let useArgs: string[];
|
||||
|
||||
if (packageName === 'npx' || packageName === 'npm' || packageName === 'yarn' || packageName === 'pnpm') {
|
||||
// For package managers, use local installation
|
||||
const localBin = path.join(dirs.nodeModules, '.bin', packageName);
|
||||
if (isWindows()) {
|
||||
exePath = localBin + '.cmd';
|
||||
} else {
|
||||
exePath = localBin;
|
||||
}
|
||||
|
||||
// Check if exists
|
||||
if (!fs.existsSync(exePath)) {
|
||||
// Fallback to system, but warn
|
||||
console.warn(`[Warning] Local ${packageName} not found, using system`);
|
||||
exePath = packageName;
|
||||
}
|
||||
useArgs = args;
|
||||
} else {
|
||||
// For other packages, use npx
|
||||
exePath = path.join(dirs.nodeModules, '.bin', 'npx');
|
||||
if (isWindows()) {
|
||||
exePath += '.cmd';
|
||||
}
|
||||
|
||||
if (!fs.existsSync(exePath)) {
|
||||
exePath = 'npx';
|
||||
}
|
||||
|
||||
useArgs = ['-y', packageName, ...args];
|
||||
}
|
||||
|
||||
// Build environment — getAppEnv() 提供完整的应用内隔离环境
|
||||
const env: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
...getAppEnv(),
|
||||
...options?.env,
|
||||
};
|
||||
|
||||
// Set npm registry if specified
|
||||
if (options?.registry) {
|
||||
env.NPM_CONFIG_REGISTRY = options.registry;
|
||||
}
|
||||
|
||||
return spawn(exePath, useArgs, {
|
||||
cwd: options?.cwd || dirs.mcpModules,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: isWindows(),
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Bundled MCP Proxy ====================
|
||||
|
||||
/**
|
||||
* 获取应用内集成的 qiming-mcp-stdio-proxy 目录
|
||||
*
|
||||
* 打包后: process.resourcesPath/qiming-mcp-stdio-proxy/
|
||||
* 开发时: resources/qiming-mcp-stdio-proxy/
|
||||
*
|
||||
* @returns 目录路径(存在且含 package.json),或 null
|
||||
*/
|
||||
export function getBundledMcpProxyDir(): string | null {
|
||||
const bundledDir = path.join(getResourcesPath(), 'qiming-mcp-stdio-proxy');
|
||||
if (fs.existsSync(path.join(bundledDir, 'package.json'))) {
|
||||
return bundledDir;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default {
|
||||
getAppPaths,
|
||||
getExecutablePath,
|
||||
isInstalledLocally,
|
||||
isInstalledGlobally,
|
||||
getPackageInfo,
|
||||
getLocalPackages,
|
||||
spawnLocal,
|
||||
};
|
||||
@@ -0,0 +1,213 @@
|
||||
import { app } from "electron";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs";
|
||||
import { spawn } from "child_process";
|
||||
import { getAppEnv } from "../system/dependencies";
|
||||
import { APP_DATA_DIR_NAME } from "../constants";
|
||||
import { isWindows } from "../system/shellEnv";
|
||||
|
||||
export interface AppPaths {
|
||||
appData: string; // 应用数据目录
|
||||
nodeModules: string; // 本地 node_modules
|
||||
temp: string; // 临时目录
|
||||
logs: string; // 日志目录
|
||||
workspaces: string; // 工作空间
|
||||
}
|
||||
|
||||
// Get application paths
|
||||
export function getAppPaths(): AppPaths {
|
||||
const appData =
|
||||
app?.getPath("userData") ||
|
||||
path.join(process.env.HOME || "", APP_DATA_DIR_NAME);
|
||||
|
||||
return {
|
||||
appData,
|
||||
nodeModules: path.join(appData, "node_modules"),
|
||||
temp: path.join(appData, "temp"),
|
||||
logs: path.join(appData, "logs"),
|
||||
workspaces: path.join(appData, "workspaces"),
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure directories exist
|
||||
export function ensureAppDirs(): AppPaths {
|
||||
const dirs = getAppPaths();
|
||||
|
||||
for (const [key, dir] of Object.entries(dirs)) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
// Get npm/pnpm executable path (use local if available)
|
||||
export function getPackageManager(): string {
|
||||
// Check for pnpm first, then yarn, then npm
|
||||
const appDirs = getAppPaths();
|
||||
|
||||
// Check local installation
|
||||
const localNpm = path.join(appDirs.nodeModules, ".bin", "npm");
|
||||
const localPnpm = path.join(appDirs.nodeModules, ".bin", "pnpm");
|
||||
const localYarn = path.join(appDirs.nodeModules, ".bin", "yarn");
|
||||
|
||||
if (fs.existsSync(localPnpm)) return localPnpm;
|
||||
if (fs.existsSync(localYarn)) return localYarn;
|
||||
if (fs.existsSync(localNpm)) return localNpm;
|
||||
|
||||
// Fallback to system
|
||||
return isWindows() ? "npm.cmd" : "npm";
|
||||
}
|
||||
|
||||
// Install package locally (not globally)
|
||||
export async function installPackage(
|
||||
packageName: string,
|
||||
options?: {
|
||||
registry?: string;
|
||||
cwd?: string;
|
||||
},
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const dirs = ensureAppDirs();
|
||||
const cwd = options?.cwd || dirs.nodeModules;
|
||||
const registry = options?.registry;
|
||||
|
||||
// Ensure node_modules exists
|
||||
if (!fs.existsSync(cwd)) {
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
}
|
||||
|
||||
// Build command
|
||||
const npmCmd = isWindows() ? "npm.cmd" : "npm";
|
||||
const args = ["install", "--save", "--no-save", packageName];
|
||||
|
||||
if (registry) {
|
||||
args.push(`--registry=${registry}`);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(npmCmd, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...getAppEnv(),
|
||||
},
|
||||
stdio: "pipe",
|
||||
shell: isWindows(),
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("error", (error) => {
|
||||
console.error(`[Install] Error:`, error);
|
||||
resolve({ success: false, error: error.message });
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ success: true });
|
||||
} else {
|
||||
console.error(`[Install] Failed: ${stderr}`);
|
||||
resolve({ success: false, error: stderr || "Install failed" });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Uninstall package
|
||||
export async function uninstallPackage(
|
||||
packageName: string,
|
||||
options?: {
|
||||
cwd?: string;
|
||||
},
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const dirs = getAppPaths();
|
||||
const cwd = options?.cwd || dirs.nodeModules;
|
||||
|
||||
const npmCmd = isWindows() ? "npm.cmd" : "npm";
|
||||
const args = ["uninstall", packageName];
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(npmCmd, args, {
|
||||
cwd,
|
||||
env: { ...process.env, ...getAppEnv() },
|
||||
stdio: "pipe",
|
||||
shell: isWindows(),
|
||||
});
|
||||
|
||||
let stderr = "";
|
||||
|
||||
proc.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("error", (error) => {
|
||||
resolve({ success: false, error: error.message });
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ success: true });
|
||||
} else {
|
||||
resolve({ success: false, error: stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Check if package is installed locally
|
||||
export function isPackageInstalled(packageName: string): boolean {
|
||||
const dirs = getAppPaths();
|
||||
const packagePath = path.join(dirs.nodeModules, packageName);
|
||||
return fs.existsSync(packagePath);
|
||||
}
|
||||
|
||||
// Get installed packages
|
||||
export function getInstalledPackages(): string[] {
|
||||
const dirs = getAppPaths();
|
||||
const packageJsonPath = path.join(dirs.nodeModules, "package.json");
|
||||
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
||||
return [
|
||||
...Object.keys(packageJson.dependencies || {}),
|
||||
...Object.keys(packageJson.devDependencies || {}),
|
||||
];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up temporary files
|
||||
export function cleanupTemp(): void {
|
||||
const dirs = getAppPaths();
|
||||
|
||||
if (fs.existsSync(dirs.temp)) {
|
||||
fs.rmSync(dirs.temp, { recursive: true, force: true });
|
||||
fs.mkdirSync(dirs.temp, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getAppPaths,
|
||||
ensureAppDirs,
|
||||
getPackageManager,
|
||||
installPackage,
|
||||
uninstallPackage,
|
||||
isPackageInstalled,
|
||||
getInstalledPackages,
|
||||
cleanupTemp,
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* PersistentMcpBridge — thin wrapper
|
||||
*
|
||||
* The implementation lives in qiming-mcp-stdio-proxy, installed to ~/.qimingclaw/node_modules.
|
||||
* This module creates a singleton with electron-log injected as the logger.
|
||||
* Uses dynamic require from ~/.qimingclaw so the app does not bundle the package.
|
||||
*/
|
||||
|
||||
import * as path from "path";
|
||||
import log from "electron-log";
|
||||
import { getAppPaths, getBundledMcpProxyDir } from "./packageLocator";
|
||||
|
||||
const PKG_NAME = "qiming-mcp-stdio-proxy";
|
||||
|
||||
/**
|
||||
* Wrap electron-log so high-frequency messages (e.g. "New HTTP session")
|
||||
* are downgraded from info → debug to reduce log noise.
|
||||
*/
|
||||
const NOISY_PATTERNS = [/New HTTP session/];
|
||||
|
||||
function createQuietLogger(): typeof log {
|
||||
const quiet = Object.create(log) as typeof log;
|
||||
const originalInfo = log.info.bind(log);
|
||||
quiet.info = (...args: Parameters<typeof log.info>) => {
|
||||
const first = args[0];
|
||||
if (
|
||||
typeof first === "string" &&
|
||||
NOISY_PATTERNS.some((p) => p.test(first))
|
||||
) {
|
||||
log.debug(...args);
|
||||
return;
|
||||
}
|
||||
originalInfo(...args);
|
||||
};
|
||||
return quiet;
|
||||
}
|
||||
|
||||
/** Lazy-loaded singleton instance (from qiming-mcp-stdio-proxy) */
|
||||
let instance: {
|
||||
start: (args: unknown) => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
isRunning: () => boolean;
|
||||
getBridgeUrl: (name: string) => string | null;
|
||||
} | null = null;
|
||||
|
||||
function getInstance(): NonNullable<typeof instance> {
|
||||
if (instance) return instance;
|
||||
|
||||
// 1. 应用内集成版本(bundled resources)
|
||||
const bundledDir = getBundledMcpProxyDir();
|
||||
if (bundledDir) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const pkg = require(bundledDir);
|
||||
if (pkg.PersistentMcpBridge) {
|
||||
log.info(
|
||||
`[PersistentMcpBridge] Using bundled integration: ${bundledDir}`,
|
||||
);
|
||||
instance = new pkg.PersistentMcpBridge(
|
||||
createQuietLogger(),
|
||||
) as NonNullable<typeof instance>;
|
||||
return instance;
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[PersistentMcpBridge] Bundled integration load failed, falling back to node_modules:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 回退兼容: ~/.qimingbot/node_modules
|
||||
const nodeModules = getAppPaths().nodeModules;
|
||||
const pkgPath = path.join(nodeModules, PKG_NAME);
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const pkg = require(pkgPath);
|
||||
if (!pkg.PersistentMcpBridge) {
|
||||
throw new Error(`${PKG_NAME}: PersistentMcpBridge export not found`);
|
||||
}
|
||||
log.info(
|
||||
`[PersistentMcpBridge] Using ~/.qimingbot path (legacy fallback): ${pkgPath}`,
|
||||
);
|
||||
instance = new pkg.PersistentMcpBridge(createQuietLogger()) as NonNullable<
|
||||
typeof instance
|
||||
>;
|
||||
return instance;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log.error(`[PersistentMcpBridge] Failed to load ${PKG_NAME}:`, msg);
|
||||
throw new Error(
|
||||
`${PKG_NAME} not installed or failed to load.${msg ? ` (${msg})` : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const persistentMcpBridge = {
|
||||
async start(
|
||||
args: Parameters<NonNullable<typeof instance>["start"]>[0],
|
||||
): Promise<void> {
|
||||
return getInstance().start(args);
|
||||
},
|
||||
async stop(): Promise<void> {
|
||||
return getInstance().stop();
|
||||
},
|
||||
isRunning(): boolean {
|
||||
try {
|
||||
return getInstance().isRunning();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
getBridgeUrl(name: string): string | null {
|
||||
try {
|
||||
return getInstance().getBridgeUrl(name);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Windows-MCP 进程运行器(Electron 实现)
|
||||
*
|
||||
* 实现 agent-gui-server 中定义的 ProcessRunner 接口,
|
||||
* 使用 ManagedProcess 管理子进程生命周期。
|
||||
*/
|
||||
|
||||
import type {
|
||||
ProcessRunner,
|
||||
ProcessConfig,
|
||||
StartResult,
|
||||
StopResult,
|
||||
} from "agent-gui-server";
|
||||
import log from "electron-log";
|
||||
import { ManagedProcess } from "../../processManager";
|
||||
import { killProcessTreeGraceful } from "../utils/processTree";
|
||||
|
||||
/**
|
||||
* Electron 进程运行器
|
||||
*
|
||||
* 使用 ManagedProcess 管理子进程,支持:
|
||||
* - 跨平台无窗口启动
|
||||
* - 自动日志收集
|
||||
* - 优雅停止和强制终止
|
||||
*/
|
||||
export class ElectronProcessRunner implements ProcessRunner {
|
||||
private process: ManagedProcess;
|
||||
|
||||
constructor() {
|
||||
this.process = new ManagedProcess("windows-mcp");
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动进程
|
||||
*/
|
||||
async start(config: ProcessConfig): Promise<StartResult> {
|
||||
const result = await this.process.start({
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
env: config.env,
|
||||
cwd: config.cwd,
|
||||
startupDelayMs: 5000, // windows-mcp 需要更长的启动时间
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止进程
|
||||
*
|
||||
* Windows 上 uv 会拉起 python/windows-mcp 子进程,仅 kill 父进程常导致子进程残留并占用 GUI MCP 端口(10048)。
|
||||
*/
|
||||
async stop(): Promise<StopResult> {
|
||||
const st = this.process.status();
|
||||
if (st.running && st.pid != null) {
|
||||
await killProcessTreeGraceful(st.pid, 5000).catch((e) => {
|
||||
log.warn("[windows-mcp] killProcessTreeGraceful:", e);
|
||||
});
|
||||
}
|
||||
const result = await this.process.stopAsync();
|
||||
return { success: result.success, error: result.message };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取进程 PID
|
||||
*/
|
||||
getPid(): number | null {
|
||||
return this.process.pid ?? null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user