chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -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;
}

View File

@@ -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必须先于 AgentAgent 初始化时会连 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 MCPWindows 平台,提供 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必须先于 AgentAgent 初始化时会连 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 MCPWindows 平台)
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先 Windowsuv/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>;

View File

@@ -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);
});
});

View File

@@ -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;
}