chore: initialize qiming workspace repository
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 开机自启动管理器
|
||||
*
|
||||
* 跨平台支持:
|
||||
* - Windows: 使用 app.setLoginItemSettings() 巻加注册表项
|
||||
* - macOS: 使用 app.setLoginItemSettings() 添加 LaunchAgent
|
||||
* - Linux: 使用 auto-launch 库创建 .desktop 文件
|
||||
*/
|
||||
|
||||
import { app } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import log from 'electron-log';
|
||||
import { APP_DISPLAY_NAME } from '@shared/constants';
|
||||
|
||||
// auto-launch 库用于 Linux 支持
|
||||
let AutoLaunch: any = null;
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
AutoLaunch = require('auto-launch');
|
||||
} catch (e) {
|
||||
log.warn('[AutoLaunch] auto-launch module not available:', e);
|
||||
}
|
||||
|
||||
export interface AutoLaunchStatus {
|
||||
enabled: boolean;
|
||||
supported: boolean;
|
||||
}
|
||||
|
||||
export class AutoLaunchManager {
|
||||
private enabled: boolean = false;
|
||||
private supported: boolean = true;
|
||||
|
||||
/** Windows 需要 args 一致才能正确匹配注册表项 */
|
||||
private readonly loginItemArgs = ['--hidden'];
|
||||
|
||||
/**
|
||||
* 检查是否支持自启动
|
||||
*/
|
||||
isSupported(): boolean {
|
||||
return this.supported;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查自启动状态
|
||||
*/
|
||||
async isEnabled(): Promise<boolean> {
|
||||
try {
|
||||
if (process.platform === 'linux') {
|
||||
// Linux: 使用 auto-launch 库
|
||||
if (!AutoLaunch) {
|
||||
this.supported = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
const autoLauncher = new AutoLaunch({
|
||||
name: APP_DISPLAY_NAME,
|
||||
path: process.execPath,
|
||||
isHiddenOnLaunch: true,
|
||||
});
|
||||
|
||||
this.enabled = await autoLauncher.isEnabled();
|
||||
return this.enabled;
|
||||
} else {
|
||||
// Windows/macOS: 使用 Electron 原生 API
|
||||
const settings = app.getLoginItemSettings({
|
||||
args: this.loginItemArgs,
|
||||
});
|
||||
this.enabled = settings.openAtLogin;
|
||||
return this.enabled;
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('[AutoLaunchManager] Failed to check status:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置自启动
|
||||
*/
|
||||
async setEnabled(enabled: boolean): Promise<boolean> {
|
||||
try {
|
||||
if (process.platform === 'linux') {
|
||||
// Linux: 使用 auto-launch 库
|
||||
if (!AutoLaunch) {
|
||||
this.supported = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
const autoLauncher = new AutoLaunch({
|
||||
name: APP_DISPLAY_NAME,
|
||||
path: process.execPath,
|
||||
isHiddenOnLaunch: true,
|
||||
});
|
||||
|
||||
if (enabled) {
|
||||
await autoLauncher.enable();
|
||||
log.info('[AutoLaunchManager] Auto-launch enabled');
|
||||
} else {
|
||||
await autoLauncher.disable();
|
||||
log.info('[AutoLaunchManager] Auto-launch disabled');
|
||||
}
|
||||
|
||||
this.enabled = enabled;
|
||||
return true;
|
||||
} else {
|
||||
// Windows/macOS: 使用 Electron 原生 API
|
||||
app.setLoginItemSettings({
|
||||
openAtLogin: enabled,
|
||||
openAsHidden: true, // macOS: 启动时隐藏
|
||||
args: this.loginItemArgs,
|
||||
});
|
||||
|
||||
this.enabled = enabled;
|
||||
log.info(`[AutoLaunchManager] Auto-launch ${enabled ? 'enabled' : 'disabled'}`);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('[AutoLaunchManager] Failed to set auto-launch:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换自启动状态
|
||||
*/
|
||||
async toggle(): Promise<boolean> {
|
||||
const currentState = await this.isEnabled();
|
||||
return this.setEnabled(!currentState);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Singleton ====================
|
||||
|
||||
let autoLaunchManager: AutoLaunchManager | null = null;
|
||||
|
||||
export function createAutoLaunchManager(): AutoLaunchManager {
|
||||
// 直接创建新实例,保留用户设置
|
||||
autoLaunchManager = new AutoLaunchManager();
|
||||
return autoLaunchManager;
|
||||
}
|
||||
|
||||
export function getAutoLaunchManager(): AutoLaunchManager | null {
|
||||
return autoLaunchManager;
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
/**
|
||||
* 服务管理器 - 统一的服务启停逻辑
|
||||
*
|
||||
* 供 IPC handlers 和 Tray 菜单共同使用
|
||||
*/
|
||||
|
||||
import * as path from "path";
|
||||
import * as fs from "fs";
|
||||
import { app } from "electron";
|
||||
import log from "electron-log";
|
||||
import { createFileServerPerfHandler } from "../ipc/perfHandlers";
|
||||
import type { ManagedProcess } from "../processManager";
|
||||
import { readSetting } from "../db";
|
||||
import { t } from "../services/i18n";
|
||||
import { checkLanproxyHealth } from "../services/packages/lanproxyHealth";
|
||||
import { checkFileServerHealth } from "../services/packages/fileServerHealth";
|
||||
import {
|
||||
APP_DATA_DIR_NAME,
|
||||
DEFAULT_STARTUP_DELAY,
|
||||
normalizeAgentEngine,
|
||||
normalizeOptionalPort,
|
||||
} from "../services/constants";
|
||||
import { getConfiguredPorts } from "../services/startupPorts";
|
||||
import {
|
||||
getAppEnv,
|
||||
getLanproxyBinPath,
|
||||
getQimingFileServerBundledDir,
|
||||
} from "../services/system/dependencies";
|
||||
import { agentService } from "../services/engines/unifiedAgent";
|
||||
import type { AgentConfig } from "../services/engines/unifiedAgent";
|
||||
import { mcpProxyManager } from "../services/packages/mcp";
|
||||
import { FEATURES } from "@shared/featureFlags";
|
||||
import {
|
||||
startGuiAgentServer,
|
||||
stopGuiAgentServer,
|
||||
} from "../services/packages/guiAgentServer";
|
||||
import {
|
||||
startWindowsMcp,
|
||||
stopWindowsMcp,
|
||||
} from "../services/packages/windowsMcp";
|
||||
import { stopAllEngines } from "../services/engines/engineManager";
|
||||
import { clearAllSseEventBuffers } from "../services/computerServer";
|
||||
|
||||
export interface ServiceManagerContext {
|
||||
lanproxy: ManagedProcess;
|
||||
fileServer: ManagedProcess;
|
||||
agentRunner: ManagedProcess;
|
||||
}
|
||||
|
||||
export interface ServiceResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
message?: string;
|
||||
healthCheck?: {
|
||||
healthy: boolean;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建服务管理器
|
||||
*/
|
||||
export function createServiceManager(ctx: ServiceManagerContext) {
|
||||
/**
|
||||
* 启动文件服务器(备用路径:restartAllServices 调用此处)
|
||||
* 注:正常启动流程经由 processHandlers.ts:startFileServerProcess,
|
||||
* 两处均挂载 createFileServerPerfHandler() 以保证任一路径均有 PERF 覆盖。
|
||||
*/
|
||||
const startFileServer = async (port: number): Promise<ServiceResult> => {
|
||||
if (ctx.fileServer.running) {
|
||||
return { success: true, message: "Already running" };
|
||||
}
|
||||
|
||||
const appDataDir = path.join(app.getPath("home"), APP_DATA_DIR_NAME);
|
||||
// 优先使用应用内集成的 bundled 路径,回退到 node_modules
|
||||
const bundledDir = getQimingFileServerBundledDir();
|
||||
const serverJsPath = bundledDir
|
||||
? path.join(bundledDir, "dist", "server.js")
|
||||
: path.join(
|
||||
appDataDir,
|
||||
"node_modules",
|
||||
"qiming-file-server",
|
||||
"dist",
|
||||
"server.js",
|
||||
);
|
||||
const step1Parsed = readSetting("step1_config") as {
|
||||
workspaceDir?: string;
|
||||
} | null;
|
||||
const baseWorkspace =
|
||||
step1Parsed?.workspaceDir || path.join(appDataDir, "workspace");
|
||||
const logsDir = path.join(appDataDir, "logs");
|
||||
|
||||
const dirConfig: Record<string, string> = {
|
||||
INIT_PROJECT_NAME: "qiming-template",
|
||||
INIT_PROJECT_DIR: path.join(baseWorkspace, "project_init"),
|
||||
UPLOAD_PROJECT_DIR: path.join(baseWorkspace, "project_zips"),
|
||||
PROJECT_SOURCE_DIR: path.join(baseWorkspace, "project_workspace"),
|
||||
DIST_TARGET_DIR: path.join(baseWorkspace, "project_nginx"),
|
||||
COMPUTER_WORKSPACE_DIR: path.join(
|
||||
baseWorkspace,
|
||||
"computer-project-workspace",
|
||||
),
|
||||
LOG_BASE_DIR: path.join(logsDir, "project_logs"),
|
||||
COMPUTER_LOG_DIR: path.join(logsDir, "computer_logs"),
|
||||
};
|
||||
|
||||
for (const dir of Object.values(dirConfig)) {
|
||||
if (dir && dir.includes(path.sep)) {
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("[ServiceManager] Starting file server on port", port);
|
||||
const startResult = await ctx.fileServer.start({
|
||||
command: process.execPath,
|
||||
args: [serverJsPath],
|
||||
env: {
|
||||
...getAppEnv(),
|
||||
...dirConfig,
|
||||
PORT: String(port),
|
||||
NODE_ENV: "production",
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
},
|
||||
startupDelayMs: DEFAULT_STARTUP_DELAY,
|
||||
onStdoutLine: createFileServerPerfHandler(),
|
||||
});
|
||||
|
||||
// 启动后进行健康检查验证
|
||||
if (startResult.success) {
|
||||
const health = await checkFileServerHealth(port);
|
||||
if (!health.healthy) {
|
||||
log.error(
|
||||
"[ServiceManager] FileServer health check failed:",
|
||||
health.error,
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: `FileServer started but health check failed: ${health.error}`,
|
||||
};
|
||||
}
|
||||
log.info("[ServiceManager] FileServer health check passed");
|
||||
}
|
||||
|
||||
return startResult;
|
||||
};
|
||||
|
||||
/**
|
||||
* 启动 Lanproxy
|
||||
*/
|
||||
const startLanproxy = async (config: {
|
||||
serverIp: string;
|
||||
serverPort: number;
|
||||
clientKey: string;
|
||||
ssl?: boolean;
|
||||
}): Promise<ServiceResult> => {
|
||||
if (ctx.lanproxy.running) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const binPath = getLanproxyBinPath();
|
||||
if (!fs.existsSync(binPath)) {
|
||||
return { success: false, error: t("Claw.Lanproxy.platformNotSupported") };
|
||||
}
|
||||
|
||||
const useSsl = config.ssl !== false;
|
||||
const args = [
|
||||
"-s",
|
||||
config.serverIp,
|
||||
"-p",
|
||||
String(config.serverPort),
|
||||
"-k",
|
||||
config.clientKey,
|
||||
`--ssl=${useSsl}`,
|
||||
];
|
||||
|
||||
return ctx.lanproxy.start({
|
||||
command: binPath,
|
||||
args,
|
||||
env: getAppEnv(),
|
||||
startupDelayMs: 1000,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 重启所有服务
|
||||
*/
|
||||
const restartAllServices = async (): Promise<{
|
||||
success: boolean;
|
||||
results: Record<string, ServiceResult>;
|
||||
}> => {
|
||||
log.info("[ServiceManager] Restarting all services...");
|
||||
const results: Record<string, ServiceResult> = {};
|
||||
|
||||
// 读取配置
|
||||
const agentConfig =
|
||||
(readSetting("agent_config") as Record<string, unknown>) || {};
|
||||
const step1Config =
|
||||
(readSetting("step1_config") as Record<string, unknown>) || {};
|
||||
|
||||
// 1. 停止现有服务(先清 SSE 缓冲,再 destroy Agent,避免重启后回放旧事件)
|
||||
clearAllSseEventBuffers();
|
||||
try {
|
||||
await agentService.destroy();
|
||||
} catch (e) {
|
||||
log.warn("[ServiceManager] Agent destroy error (ignored):", e);
|
||||
}
|
||||
ctx.fileServer.stop();
|
||||
ctx.lanproxy.stop();
|
||||
await mcpProxyManager.stop();
|
||||
|
||||
// 2. 启动 MCP Proxy(必须先于 Agent:Agent 初始化时会连 MCP Proxy 注入 mcpServers)
|
||||
try {
|
||||
await mcpProxyManager.start();
|
||||
results.mcpProxy = { success: true };
|
||||
log.info("[ServiceManager] MCP Proxy started");
|
||||
|
||||
// 非阻塞预热:提前启动 PersistentMcpBridge,避免首次会话启动延迟
|
||||
mcpProxyManager
|
||||
.ensureBridgeStarted()
|
||||
.catch((e) =>
|
||||
log.warn(
|
||||
"[ServiceManager] PersistentMcpBridge prewarm failed (will retry on first session):",
|
||||
e,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
results.mcpProxy = { success: false, error: String(e) };
|
||||
log.error("[ServiceManager] MCP Proxy start failed:", e);
|
||||
}
|
||||
|
||||
// 2.5. 启动 GUI Agent Server(非 Windows 平台,提供 GUI 自动化 MCP tools)
|
||||
if (FEATURES.ENABLE_GUI_AGENT_SERVER) {
|
||||
try {
|
||||
const guiResult = await startGuiAgentServer();
|
||||
results.guiAgentServer = guiResult;
|
||||
if (!guiResult.success) {
|
||||
log.warn(
|
||||
`[ServiceManager] GUI Agent Server start failed: ${guiResult.error}`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
results.guiAgentServer = { success: false, error: String(e) };
|
||||
log.warn("[ServiceManager] GUI Agent Server start exception:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 2.6. 启动 Windows MCP(Windows 平台,提供 GUI 自动化 MCP tools)
|
||||
if (FEATURES.ENABLE_GUI_AGENT_SERVER) {
|
||||
try {
|
||||
const winResult = await startWindowsMcp();
|
||||
results.windowsMcp = winResult;
|
||||
if (!winResult.success) {
|
||||
log.warn(
|
||||
`[ServiceManager] Windows MCP start failed: ${winResult.error}`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
results.windowsMcp = { success: false, error: String(e) };
|
||||
log.warn("[ServiceManager] Windows MCP start exception:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 启动 Agent(依赖 MCP Proxy 已就绪以便 getAgentMcpConfig 对应进程可连)
|
||||
try {
|
||||
const finalConfig: AgentConfig = {
|
||||
engine: normalizeAgentEngine(agentConfig.type),
|
||||
apiKey: agentConfig.apiKey as string | undefined,
|
||||
baseUrl: agentConfig.apiBaseUrl as string | undefined,
|
||||
model: agentConfig.model as string | undefined,
|
||||
workspaceDir: (step1Config.workspaceDir as string) || "",
|
||||
port: normalizeOptionalPort(agentConfig.backendPort),
|
||||
engineBinaryPath: agentConfig.binPath as string | undefined,
|
||||
};
|
||||
const mcpConfig = mcpProxyManager.getAgentMcpConfig();
|
||||
if (mcpConfig) Object.assign(finalConfig, { mcpServers: mcpConfig });
|
||||
const ok = await agentService.init(finalConfig);
|
||||
results.agent = { success: ok };
|
||||
log.info("[ServiceManager] Agent started");
|
||||
} catch (e) {
|
||||
results.agent = { success: false, error: String(e) };
|
||||
log.error("[ServiceManager] Agent start failed:", e);
|
||||
}
|
||||
|
||||
// 4. 启动文件服务器(端口来自聚合配置)
|
||||
try {
|
||||
const { fileServer: fileServerPort } = getConfiguredPorts();
|
||||
results.fileServer = await startFileServer(fileServerPort);
|
||||
log.info("[ServiceManager] FileServer started");
|
||||
} catch (e) {
|
||||
results.fileServer = { success: false, error: String(e) };
|
||||
log.error("[ServiceManager] FileServer start failed:", e);
|
||||
}
|
||||
|
||||
// 5. 启动 Lanproxy
|
||||
try {
|
||||
const clientKey = readSetting("auth.saved_key") as string | null;
|
||||
const lpConfig =
|
||||
(readSetting("lanproxy_config") as Record<string, unknown>) || {};
|
||||
const serverHost = readSetting("lanproxy.server_host") as string | null;
|
||||
const serverPortStored = readSetting("lanproxy.server_port") as
|
||||
| number
|
||||
| null;
|
||||
const serverIp =
|
||||
(lpConfig.serverIp as string) ||
|
||||
serverHost?.replace(/^https?:\/\//, "");
|
||||
const serverPort = (lpConfig.serverPort as number) || serverPortStored;
|
||||
|
||||
if (serverIp && clientKey && serverPort) {
|
||||
results.lanproxy = await startLanproxy({
|
||||
serverIp,
|
||||
serverPort,
|
||||
clientKey,
|
||||
ssl: lpConfig.ssl as boolean,
|
||||
});
|
||||
if (results.lanproxy.success) {
|
||||
// 远端 health 接口可选;异步探测仅打日志,不阻塞批量重启
|
||||
const lanproxyResult = results.lanproxy;
|
||||
void checkLanproxyHealth(clientKey)
|
||||
.then((health) => {
|
||||
lanproxyResult.healthCheck = health;
|
||||
if (!health.healthy) {
|
||||
log.warn(
|
||||
"[Lanproxy] Post-start health probe failed (non-fatal; private backends may omit /api/sandbox/config/health):",
|
||||
health.error,
|
||||
);
|
||||
} else {
|
||||
log.info("[Lanproxy] Post-start health probe OK");
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
log.warn(
|
||||
"[Lanproxy] Post-start health probe error (non-fatal):",
|
||||
e,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
log.error("[Lanproxy] Batch start failed", {
|
||||
error: results.lanproxy.error,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
results.lanproxy = { success: false, error: "Lanproxy config missing" };
|
||||
log.warn("[Lanproxy] Skipped: missing config", {
|
||||
hasServerIp: !!serverIp,
|
||||
hasClientKey: !!clientKey,
|
||||
hasServerPort: !!serverPort,
|
||||
hint: "Set server_host, server_port, and saved_key (or lanproxy_config)",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
results.lanproxy = { success: false, error: String(e) };
|
||||
log.error("[Lanproxy] Start error", {
|
||||
error: String(e),
|
||||
stack: e instanceof Error ? e.stack : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
log.info("[ServiceManager] All services restart complete");
|
||||
return { success: true, results };
|
||||
};
|
||||
|
||||
/**
|
||||
* 重启除 Lanproxy 外的所有服务
|
||||
*
|
||||
* 用于 HTTP 重启接口,不停止/启动 lanproxy
|
||||
*/
|
||||
const restartAllServicesExceptLanproxy = async (): Promise<{
|
||||
success: boolean;
|
||||
results: Record<string, ServiceResult>;
|
||||
}> => {
|
||||
log.info("[ServiceManager] Restarting all services except lanproxy...");
|
||||
const results: Record<string, ServiceResult> = {};
|
||||
|
||||
// 读取配置
|
||||
const agentConfig =
|
||||
(readSetting("agent_config") as Record<string, unknown>) || {};
|
||||
const step1Config =
|
||||
(readSetting("step1_config") as Record<string, unknown>) || {};
|
||||
|
||||
// 1. 停止现有服务(先清 SSE 缓冲,再 destroy Agent,避免重启后回放旧事件)
|
||||
// 注意:不停止 lanproxy
|
||||
clearAllSseEventBuffers();
|
||||
try {
|
||||
await agentService.destroy();
|
||||
} catch (e) {
|
||||
log.warn("[ServiceManager] Agent destroy error (ignored):", e);
|
||||
}
|
||||
ctx.fileServer.stop();
|
||||
// 不停止 lanproxy: ctx.lanproxy.stop();
|
||||
// 先停止 GUI agents(它们依赖 MCP Proxy,先停 MCP 再停 GUI)
|
||||
await mcpProxyManager.stop();
|
||||
if (FEATURES.ENABLE_GUI_AGENT_SERVER) {
|
||||
await stopGuiAgentServer();
|
||||
}
|
||||
await stopWindowsMcp();
|
||||
|
||||
// 2. 启动 MCP Proxy(必须先于 Agent:Agent 初始化时会连 MCP Proxy 注入 mcpServers)
|
||||
try {
|
||||
await mcpProxyManager.start();
|
||||
results.mcpProxy = { success: true };
|
||||
log.info("[ServiceManager] MCP Proxy started");
|
||||
|
||||
mcpProxyManager
|
||||
.ensureBridgeStarted()
|
||||
.catch((e) =>
|
||||
log.warn(
|
||||
"[ServiceManager] PersistentMcpBridge prewarm failed (will retry on first session):",
|
||||
e,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
results.mcpProxy = { success: false, error: String(e) };
|
||||
log.error("[ServiceManager] MCP Proxy start failed:", e);
|
||||
}
|
||||
|
||||
// 2.5. 启动 GUI Agent Server(非 Windows 平台)
|
||||
if (FEATURES.ENABLE_GUI_AGENT_SERVER) {
|
||||
try {
|
||||
const guiResult = await startGuiAgentServer();
|
||||
results.guiAgentServer = guiResult;
|
||||
if (!guiResult.success) {
|
||||
log.warn(
|
||||
`[ServiceManager] GUI Agent Server start failed: ${guiResult.error}`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
results.guiAgentServer = { success: false, error: String(e) };
|
||||
log.warn("[ServiceManager] GUI Agent Server start exception:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 2.6. 启动 Windows MCP(Windows 平台)
|
||||
try {
|
||||
const winResult = await startWindowsMcp();
|
||||
results.windowsMcp = winResult;
|
||||
if (!winResult.success) {
|
||||
log.warn(
|
||||
`[ServiceManager] Windows MCP start failed: ${winResult.error}`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
results.windowsMcp = { success: false, error: String(e) };
|
||||
log.warn("[ServiceManager] Windows MCP start exception:", e);
|
||||
}
|
||||
|
||||
// 3. 启动 Agent(依赖 MCP Proxy 已就绪)
|
||||
try {
|
||||
const finalConfig: AgentConfig = {
|
||||
engine: normalizeAgentEngine(agentConfig.type),
|
||||
apiKey: agentConfig.apiKey as string | undefined,
|
||||
baseUrl: agentConfig.apiBaseUrl as string | undefined,
|
||||
model: agentConfig.model as string | undefined,
|
||||
workspaceDir: (step1Config.workspaceDir as string) || "",
|
||||
port: normalizeOptionalPort(agentConfig.backendPort),
|
||||
engineBinaryPath: agentConfig.binPath as string | undefined,
|
||||
};
|
||||
const mcpConfig = mcpProxyManager.getAgentMcpConfig();
|
||||
if (mcpConfig) Object.assign(finalConfig, { mcpServers: mcpConfig });
|
||||
const ok = await agentService.init(finalConfig);
|
||||
results.agent = { success: ok };
|
||||
log.info("[ServiceManager] Agent started");
|
||||
} catch (e) {
|
||||
results.agent = { success: false, error: String(e) };
|
||||
log.error("[ServiceManager] Agent start failed:", e);
|
||||
}
|
||||
|
||||
// 4. 启动文件服务器
|
||||
try {
|
||||
const { fileServer: fileServerPort } = getConfiguredPorts();
|
||||
results.fileServer = await startFileServer(fileServerPort);
|
||||
log.info("[ServiceManager] FileServer started");
|
||||
} catch (e) {
|
||||
results.fileServer = { success: false, error: String(e) };
|
||||
log.error("[ServiceManager] FileServer start failed:", e);
|
||||
}
|
||||
|
||||
// 注意:不启动 lanproxy
|
||||
// 注意:computerServer 的重启由调用方(processHandlers)处理
|
||||
log.info(
|
||||
"[ServiceManager] All services (except lanproxy) restart complete",
|
||||
);
|
||||
return { success: true, results };
|
||||
};
|
||||
|
||||
/**
|
||||
* 停止所有服务
|
||||
*/
|
||||
const stopAllServices = async (): Promise<{
|
||||
success: boolean;
|
||||
results: Record<string, ServiceResult>;
|
||||
}> => {
|
||||
log.info("[ServiceManager] Stopping all services...");
|
||||
const results: Record<string, ServiceResult> = {};
|
||||
|
||||
// 停止 Agent 前清除所有 SSE 事件缓冲,避免重启/重连后仍回放旧会话事件
|
||||
clearAllSseEventBuffers();
|
||||
|
||||
// 停止 Agent
|
||||
try {
|
||||
await agentService.destroy();
|
||||
results.agent = { success: true };
|
||||
log.info("[ServiceManager] Agent stopped");
|
||||
} catch (e) {
|
||||
results.agent = { success: false, error: String(e) };
|
||||
log.error("[ServiceManager] Agent stop failed:", e);
|
||||
}
|
||||
|
||||
// 停止文件服务器
|
||||
try {
|
||||
ctx.fileServer.stop();
|
||||
results.fileServer = { success: true };
|
||||
log.info("[ServiceManager] FileServer stopped");
|
||||
} catch (e) {
|
||||
results.fileServer = { success: false, error: String(e) };
|
||||
}
|
||||
|
||||
// 停止 Lanproxy
|
||||
try {
|
||||
ctx.lanproxy.stop();
|
||||
results.lanproxy = { success: true };
|
||||
log.info("[Lanproxy] Stopped");
|
||||
} catch (e) {
|
||||
results.lanproxy = { success: false, error: String(e) };
|
||||
log.error("[Lanproxy] Stop error", {
|
||||
error: String(e),
|
||||
stack: e instanceof Error ? e.stack : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// 停止 MCP Proxy
|
||||
try {
|
||||
await mcpProxyManager.stop();
|
||||
results.mcpProxy = { success: true };
|
||||
log.info("[ServiceManager] MCP Proxy stopped");
|
||||
} catch (e) {
|
||||
results.mcpProxy = { success: false, error: String(e) };
|
||||
}
|
||||
|
||||
// 停止 GUI MCP:先 Windows(uv/python),再非 Windows 的 agent-gui-server,与 main cleanupAllProcesses 顺序一致
|
||||
try {
|
||||
await stopWindowsMcp();
|
||||
results.windowsMcp = { success: true };
|
||||
log.info("[ServiceManager] Windows MCP stopped");
|
||||
} catch (e) {
|
||||
results.windowsMcp = { success: false, error: String(e) };
|
||||
}
|
||||
|
||||
if (FEATURES.ENABLE_GUI_AGENT_SERVER) {
|
||||
try {
|
||||
await stopGuiAgentServer();
|
||||
results.guiAgentServer = { success: true };
|
||||
log.info("[ServiceManager] GUI Agent Server stopped");
|
||||
} catch (e) {
|
||||
results.guiAgentServer = { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
// 停止所有引擎
|
||||
try {
|
||||
stopAllEngines();
|
||||
results.engines = { success: true };
|
||||
log.info("[ServiceManager] Engines stopped");
|
||||
} catch (e) {
|
||||
results.engines = { success: false, error: String(e) };
|
||||
}
|
||||
|
||||
log.info("[ServiceManager] All services stopped");
|
||||
return { success: true, results };
|
||||
};
|
||||
|
||||
return {
|
||||
startFileServer,
|
||||
startLanproxy,
|
||||
restartAllServices,
|
||||
restartAllServicesExceptLanproxy,
|
||||
stopAllServices,
|
||||
};
|
||||
}
|
||||
|
||||
export type ServiceManager = ReturnType<typeof createServiceManager>;
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 单元测试: TrayManager
|
||||
*
|
||||
* 测试托盘管理器逻辑(Electron API 被 mock)
|
||||
*
|
||||
* 注意:由于 TrayManager 依赖 Electron 的 Tray/nativeImage 等 API,
|
||||
* 这些测试主要验证状态更新和菜单构建逻辑。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// 在导入模块前设置 mock
|
||||
vi.mock('electron', () => ({
|
||||
Tray: vi.fn().mockImplementation(() => ({
|
||||
setToolTip: vi.fn(),
|
||||
setContextMenu: vi.fn(),
|
||||
on: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
setImage: vi.fn(),
|
||||
})),
|
||||
nativeImage: {
|
||||
createFromPath: vi.fn().mockReturnValue({
|
||||
isEmpty: vi.fn(() => false),
|
||||
getSize: vi.fn(() => ({ width: 22, height: 22 })),
|
||||
resize: vi.fn(function(this: any) { return this; }),
|
||||
setTemplateImage: vi.fn(),
|
||||
}),
|
||||
createFromDataURL: vi.fn().mockReturnValue({
|
||||
isEmpty: vi.fn(() => false),
|
||||
getSize: vi.fn(() => ({ width: 16, height: 16 })),
|
||||
resize: vi.fn(function(this: any) { return this; }),
|
||||
}),
|
||||
},
|
||||
app: {
|
||||
getVersion: vi.fn(() => '0.7.4'),
|
||||
isPackaged: false,
|
||||
dock: {
|
||||
show: vi.fn(),
|
||||
},
|
||||
},
|
||||
Menu: {
|
||||
buildFromTemplate: vi.fn(() => ({})),
|
||||
},
|
||||
dialog: {
|
||||
showErrorBox: vi.fn(),
|
||||
},
|
||||
BrowserWindow: {
|
||||
getAllWindows: vi.fn(() => []),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('electron-log', () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('path', () => ({
|
||||
join: vi.fn((...args: string[]) => args.join('/')),
|
||||
dirname: vi.fn((p: string) => p.split('/').slice(0, -1).join('/')),
|
||||
}));
|
||||
|
||||
vi.mock('./autoLaunchManager', () => ({
|
||||
createAutoLaunchManager: vi.fn(() => ({
|
||||
isEnabled: vi.fn().mockResolvedValue(false),
|
||||
setEnabled: vi.fn().mockResolvedValue(true),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { TrayManager, TrayStatus } from './trayManager';
|
||||
|
||||
describe('TrayManager', () => {
|
||||
let trayManager: TrayManager;
|
||||
const mockOptions = {
|
||||
onShowWindow: vi.fn(),
|
||||
onRestartServices: vi.fn().mockResolvedValue(undefined),
|
||||
onStopServices: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
trayManager = new TrayManager(mockOptions);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
trayManager.destroy();
|
||||
});
|
||||
|
||||
describe('初始化', () => {
|
||||
it('should create TrayManager instance', () => {
|
||||
expect(trayManager).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create()', () => {
|
||||
it('should create tray and return void', async () => {
|
||||
const result = await trayManager.create();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateServicesStatus()', () => {
|
||||
it('should update status to running', async () => {
|
||||
await trayManager.create();
|
||||
// 不应抛出错误
|
||||
expect(() => trayManager.updateServicesStatus(true)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should update status to stopped', async () => {
|
||||
await trayManager.create();
|
||||
expect(() => trayManager.updateServicesStatus(false)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setStatus()', () => {
|
||||
it('should set error status', async () => {
|
||||
await trayManager.create();
|
||||
expect(() => trayManager.setStatus('error')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should set starting status', async () => {
|
||||
await trayManager.create();
|
||||
expect(() => trayManager.setStatus('starting')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should set running status', async () => {
|
||||
await trayManager.create();
|
||||
expect(() => trayManager.setStatus('running')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should set stopped status', async () => {
|
||||
await trayManager.create();
|
||||
expect(() => trayManager.setStatus('stopped')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy()', () => {
|
||||
it('should destroy tray without error', () => {
|
||||
expect(() => trayManager.destroy()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTray()', () => {
|
||||
it('should return null before create', () => {
|
||||
expect(trayManager.getTray()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('TrayStatus 类型', () => {
|
||||
it('should have correct status values', () => {
|
||||
const statuses: TrayStatus[] = ['running', 'stopped', 'error', 'starting'];
|
||||
expect(statuses).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* 托盘管理器 - Electron 客户端
|
||||
*
|
||||
* 功能:
|
||||
* - 统一托盘图标(32x32.png,所有状态共用;状态通过 tooltip 区分)
|
||||
* - 服务管理菜单(重启/停止服务)
|
||||
* - 开机自启动
|
||||
* - IPC 状态同步
|
||||
*/
|
||||
|
||||
import { Tray, Menu, nativeImage, app, dialog, BrowserWindow } from "electron";
|
||||
import * as path from "path";
|
||||
import log from "electron-log";
|
||||
import { APP_DISPLAY_NAME } from "@shared/constants";
|
||||
import {
|
||||
createAutoLaunchManager,
|
||||
AutoLaunchManager,
|
||||
} from "./autoLaunchManager";
|
||||
import { t } from "../services/i18n";
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export type TrayStatus = "running" | "stopped" | "error" | "starting";
|
||||
|
||||
export interface TrayManagerOptions {
|
||||
onShowWindow: () => void;
|
||||
onRestartServices: () => Promise<void>;
|
||||
onStopServices: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ==================== Constants ====================
|
||||
|
||||
/**
|
||||
* 托盘图标文件名(所有状态共用同一图标,状态由 tooltip 区分)
|
||||
*
|
||||
* macOS: trayTemplate.png / trayTemplate@2x.png — 22x22 / 44x44 黑色剪影,
|
||||
* Electron 根据 "Template" 后缀自动设为 template image(随系统明暗主题变色)。
|
||||
* Windows / Linux: tray.png — 32x32 彩色图标。
|
||||
*/
|
||||
const TRAY_ICON_MAC = "trayTemplate.png";
|
||||
const TRAY_ICON_MAC_RETINA = "trayTemplate@2x.png";
|
||||
const TRAY_ICON_DEFAULT = "tray.png";
|
||||
|
||||
// ==================== Tray Manager ====================
|
||||
|
||||
export class TrayManager {
|
||||
private tray: Tray | null = null;
|
||||
private status: TrayStatus = "stopped";
|
||||
private servicesRunning: boolean = false;
|
||||
private autoLaunchEnabled: boolean = false;
|
||||
private autoLaunchManager: AutoLaunchManager;
|
||||
private options: TrayManagerOptions;
|
||||
|
||||
constructor(options: TrayManagerOptions) {
|
||||
this.options = options;
|
||||
this.autoLaunchManager = createAutoLaunchManager();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建托盘
|
||||
*/
|
||||
async create(): Promise<void> {
|
||||
const icon = this.createTrayIcon("stopped");
|
||||
|
||||
this.tray = new Tray(icon);
|
||||
this.tray.setToolTip(APP_DISPLAY_NAME);
|
||||
|
||||
// 左键点击显示窗口
|
||||
this.tray.on("click", () => {
|
||||
this.options.onShowWindow();
|
||||
});
|
||||
|
||||
// 双击也显示窗口
|
||||
this.tray.on("double-click", () => {
|
||||
this.options.onShowWindow();
|
||||
});
|
||||
|
||||
// 检查自启动状态
|
||||
this.autoLaunchEnabled = await this.autoLaunchManager.isEnabled();
|
||||
|
||||
// 构建初始菜单
|
||||
this.updateMenu();
|
||||
log.info("[Tray] Tray created");
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新服务状态
|
||||
*/
|
||||
updateServicesStatus(running: boolean): void {
|
||||
this.servicesRunning = running;
|
||||
this.status = running ? "running" : "stopped";
|
||||
this.updateIcon();
|
||||
this.updateMenu();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置状态(用于错误状态)
|
||||
*/
|
||||
setStatus(status: TrayStatus): void {
|
||||
this.status = status;
|
||||
this.updateIcon();
|
||||
this.updateMenu();
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新自启动缓存状态(当外部修改了自启动设置时调用)
|
||||
*/
|
||||
async refreshAutoLaunchState(): Promise<void> {
|
||||
this.autoLaunchEnabled = await this.autoLaunchManager.isEnabled();
|
||||
this.updateMenu();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新托盘图标
|
||||
*/
|
||||
private updateIcon(): void {
|
||||
if (!this.tray) return;
|
||||
|
||||
const icon = this.createTrayIcon(this.status);
|
||||
this.tray.setImage(icon);
|
||||
|
||||
const statusKey: Record<TrayStatus, string> = {
|
||||
running: "Claw.Tray.Status.running",
|
||||
stopped: "Claw.Tray.Status.stopped",
|
||||
error: "Claw.Tray.Status.error",
|
||||
starting: "Claw.Tray.Status.starting",
|
||||
};
|
||||
this.tray.setToolTip(`${APP_DISPLAY_NAME} - ${t(statusKey[this.status])}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新托盘菜单
|
||||
*/
|
||||
private updateMenu(): void {
|
||||
if (!this.tray) return;
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: t("Claw.Tray.showWindow"),
|
||||
click: () => this.options.onShowWindow(),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: t("Claw.Tray.restartServices"),
|
||||
click: async () => {
|
||||
log.info("[Tray] Restarting services...");
|
||||
try {
|
||||
await this.options.onRestartServices();
|
||||
} catch (e) {
|
||||
log.error("[Tray] Restart services failed:", e);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("Claw.Tray.stopServices"),
|
||||
enabled: this.servicesRunning,
|
||||
click: async () => {
|
||||
log.info("[Tray] Stopping services...");
|
||||
try {
|
||||
await this.options.onStopServices();
|
||||
} catch (e) {
|
||||
log.error("[Tray] Stop services failed:", e);
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: t("Claw.Tray.autoLaunch"),
|
||||
type: "checkbox",
|
||||
checked: this.autoLaunchEnabled,
|
||||
click: async () => {
|
||||
const newEnabled = !this.autoLaunchEnabled;
|
||||
const success = await this.autoLaunchManager.setEnabled(newEnabled);
|
||||
if (success) {
|
||||
this.autoLaunchEnabled = newEnabled;
|
||||
this.updateMenu();
|
||||
// 通知渲染进程同步状态
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send("autolaunch:changed", newEnabled);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dialog.showErrorBox(
|
||||
t("Claw.Dialog.error"),
|
||||
t("Claw.Dialog.autoLaunchFailed"),
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("Claw.Tray.checkUpdate"),
|
||||
click: async () => {
|
||||
const { showUpdateDialogFlow } = require("../services/autoUpdater");
|
||||
await showUpdateDialogFlow();
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: t("Claw.Tray.about", APP_DISPLAY_NAME, app.getVersion()),
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
label: t("Claw.Tray.quit"),
|
||||
click: () => app.quit(),
|
||||
},
|
||||
]);
|
||||
|
||||
this.tray.setContextMenu(contextMenu);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取托盘图标路径
|
||||
* 开发模式:使用 __dirname 相对路径,避免 process.cwd() 在 monorepo 或从其他目录启动时指向错误目录导致图标加载失败、托盘不显示。
|
||||
* 编译后 main 在 dist/main/,window 在 dist/main/window/,故 ../../../ 为包根目录。
|
||||
*/
|
||||
private getIconPath(fileName: string): string {
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, "tray", fileName);
|
||||
}
|
||||
const devPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"public",
|
||||
"tray",
|
||||
fileName,
|
||||
);
|
||||
return devPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建托盘图标
|
||||
*
|
||||
* macOS 开发模式:从终端运行时 template 图标常不显示,故一律使用彩色图标 tray.png,
|
||||
* 并缩放到 22x22 以符合菜单栏尺寸,保证托盘可见。
|
||||
* macOS 打包后:使用 trayTemplate / trayTemplate@2x + setTemplateImage(true)。
|
||||
* Windows / Linux: 使用彩色图标,优先高清版,并缩放到合适尺寸。
|
||||
*/
|
||||
private createTrayIcon(_status: TrayStatus): Electron.NativeImage {
|
||||
if (process.platform === "darwin") {
|
||||
const isDev = !app.isPackaged;
|
||||
|
||||
if (isDev) {
|
||||
// 开发模式:始终用彩色图标,避免 template 在菜单栏不显示
|
||||
const path22 = this.getIconPath(TRAY_ICON_DEFAULT);
|
||||
const path44 = this.getIconPath("tray@2x.png");
|
||||
let icon = nativeImage.createFromPath(path44);
|
||||
if (icon.isEmpty()) icon = nativeImage.createFromPath(path22);
|
||||
if (!icon.isEmpty()) {
|
||||
const size = icon.getSize();
|
||||
if (size.width > 22 || size.height > 22) {
|
||||
icon = icon.resize({ width: 22, height: 22 });
|
||||
}
|
||||
log.info(
|
||||
"[Tray] macOS dev: using non-template icon (menu bar visibility):",
|
||||
icon.getSize().width > 22 ? path44 : path22,
|
||||
);
|
||||
return icon;
|
||||
}
|
||||
// 最后兜底:生成 22x22 占位图,确保 Tray 收到非空图
|
||||
icon = this.createPlaceholderTrayImage(22);
|
||||
log.warn(
|
||||
"[Tray] macOS dev: tray icon files not found, using placeholder",
|
||||
);
|
||||
return icon;
|
||||
}
|
||||
|
||||
// 打包后:template 图标
|
||||
const retinaPath = this.getIconPath(TRAY_ICON_MAC_RETINA);
|
||||
const normalPath = this.getIconPath(TRAY_ICON_MAC);
|
||||
let icon = nativeImage.createFromPath(retinaPath);
|
||||
if (icon.isEmpty()) {
|
||||
log.warn(
|
||||
"[Tray] Retina template icon not found, trying @1x:",
|
||||
normalPath,
|
||||
);
|
||||
icon = nativeImage.createFromPath(normalPath);
|
||||
}
|
||||
if (icon.isEmpty()) {
|
||||
log.error("[Tray] macOS tray icon not found. Paths tried:", {
|
||||
retinaPath,
|
||||
normalPath,
|
||||
});
|
||||
return this.createPlaceholderTrayImage(22);
|
||||
}
|
||||
log.info(
|
||||
"[Tray] macOS tray icon loaded from:",
|
||||
icon.getSize().width ? retinaPath : normalPath,
|
||||
);
|
||||
icon.setTemplateImage(true);
|
||||
return icon;
|
||||
}
|
||||
|
||||
// Windows / Linux: 彩色图标,参考 macOS 的处理方式
|
||||
const targetSize = process.platform === "win32" ? 16 : 22; // Windows 托盘图标标准尺寸 16x16
|
||||
const pathNormal = this.getIconPath(TRAY_ICON_DEFAULT);
|
||||
const pathRetina = this.getIconPath("tray@2x.png");
|
||||
|
||||
// 优先使用高清图标
|
||||
let icon = nativeImage.createFromPath(pathRetina);
|
||||
if (icon.isEmpty()) {
|
||||
icon = nativeImage.createFromPath(pathNormal);
|
||||
}
|
||||
|
||||
if (!icon.isEmpty()) {
|
||||
const size = icon.getSize();
|
||||
// 如果图标尺寸过大,缩放到目标尺寸
|
||||
if (size.width > targetSize || size.height > targetSize) {
|
||||
icon = icon.resize({ width: targetSize, height: targetSize });
|
||||
}
|
||||
log.info(
|
||||
`[Tray] ${process.platform} tray icon loaded, size:`,
|
||||
icon.getSize(),
|
||||
);
|
||||
return icon;
|
||||
}
|
||||
|
||||
// 兜底:生成占位图
|
||||
log.error("[Tray] Tray icon not found. Paths tried:", {
|
||||
pathNormal,
|
||||
pathRetina,
|
||||
});
|
||||
return this.createPlaceholderTrayImage(targetSize);
|
||||
}
|
||||
|
||||
/** 生成灰色占位图(1x1 PNG 放大),用于图标缺失时保证 Tray 收到非空图 */
|
||||
private createPlaceholderTrayImage(size: number): Electron.NativeImage {
|
||||
const s = Math.max(16, Math.min(22, size));
|
||||
const dataUrl =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
|
||||
const img = nativeImage.createFromDataURL(dataUrl);
|
||||
return img.isEmpty() ? img : img.resize({ width: s, height: s });
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁托盘
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.tray) {
|
||||
this.tray.destroy();
|
||||
this.tray = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取托盘实例
|
||||
*/
|
||||
getTray(): Tray | null {
|
||||
return this.tray;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新托盘菜单和图标(用于语言切换等场景)
|
||||
*/
|
||||
refresh(): void {
|
||||
this.updateIcon();
|
||||
this.updateMenu();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Singleton ====================
|
||||
|
||||
let trayManager: TrayManager | null = null;
|
||||
|
||||
export function createTrayManager(options: TrayManagerOptions): TrayManager {
|
||||
if (trayManager) {
|
||||
trayManager.destroy();
|
||||
}
|
||||
trayManager = new TrayManager(options);
|
||||
return trayManager;
|
||||
}
|
||||
|
||||
export function getTrayManager(): TrayManager | null {
|
||||
return trayManager;
|
||||
}
|
||||
Reference in New Issue
Block a user