feat: 添加 Terminal 模块,启动 XCCMD.exe 服务

This commit is contained in:
2026-03-20 13:08:43 +08:00
parent 28df633b00
commit d54510a864
7 changed files with 313 additions and 1 deletions

View File

@@ -8,6 +8,7 @@ import { selectHtmlFile } from './services/htmlImport';
import { opencodeService } from './services/opencodeService';
import { xcOpenCodeWebService } from './services/xcOpenCodeWebService';
import { sddService } from './services/sddService';
import { terminalService } from './services/terminalService';
import { electronState } from './state';
log.initialize();
@@ -404,6 +405,22 @@ ipcMain.handle('sdd-stop', async () => {
return await sddService.stop();
});
ipcMain.handle('terminal-get-status', () => {
return terminalService.getStatus();
});
ipcMain.handle('terminal-get-port', () => {
return { port: terminalService.port };
});
ipcMain.handle('terminal-start', async () => {
return await terminalService.start();
});
ipcMain.handle('terminal-stop', async () => {
return await terminalService.stop();
});
async function startServer() {
if (electronState.isDevelopment()) {
log.info('In dev mode, assuming external servers are running.');
@@ -442,6 +459,7 @@ app.whenReady().then(async () => {
await startServer();
await opencodeService.start();
await terminalService.start();
await createWindow();
@@ -475,6 +493,7 @@ app.on('window-all-closed', () => {
opencodeService.stop();
xcOpenCodeWebService.stop();
sddService.stop();
terminalService.stop();
stopClipboardWatcher();
if (process.platform !== 'darwin') {
app.quit();
@@ -493,7 +512,8 @@ app.on('before-quit', async (event) => {
await Promise.all([
opencodeService.stop(),
xcOpenCodeWebService.stop(),
sddService.stop()
sddService.stop(),
terminalService.stop()
]);
log.info('[App] All services stopped');

View File

@@ -49,4 +49,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
sddStop: () => ipcRenderer.invoke('sdd-stop'),
sddGetStatus: () => ipcRenderer.invoke('sdd-get-status'),
sddGetPort: () => ipcRenderer.invoke('sdd-get-port'),
terminalStart: () => ipcRenderer.invoke('terminal-start'),
terminalStop: () => ipcRenderer.invoke('terminal-stop'),
terminalGetStatus: () => ipcRenderer.invoke('terminal-get-status'),
terminalGetPort: () => ipcRenderer.invoke('terminal-get-port'),
})

View File

@@ -0,0 +1,175 @@
import { spawn, exec, ChildProcess } from 'child_process';
import { app } from 'electron';
import path from 'path';
import log from 'electron-log';
const TERMINAL_PORT = 9997;
const HEALTH_CHECK_INTERVAL = 10000;
const HEALTH_CHECK_TIMEOUT = 2000;
class TerminalService {
private process: ChildProcess | null = null;
private processPid: number | null = null;
private healthCheckTimer: NodeJS.Timeout | null = null;
private _isRunning = false;
private _isStarting = false;
get port(): number {
return TERMINAL_PORT;
}
isRunning(): boolean {
return this._isRunning;
}
getStatus(): { running: boolean; port: number } {
return {
running: this._isRunning,
port: this.port,
};
}
private getExePath(): string {
const exeName = 'XCCMD.exe';
const isPackaged = app.isPackaged;
let basePath: string;
if (isPackaged) {
basePath = path.join(process.resourcesPath, 'app.asar.unpacked');
} else {
basePath = process.cwd();
}
return path.join(basePath, 'services', 'xccmd', exeName);
}
private getExeArgs(): string[] {
return ['--port', this.port.toString(), '--headless'];
}
private async checkHealth(): Promise<boolean> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT);
const response = await fetch(`http://127.0.0.1:${this.port}`, {
signal: controller.signal,
});
clearTimeout(timeoutId);
return response.ok || response.status === 401;
} catch (error) {
log.warn('[TerminalService] Health check failed:', error);
return false;
}
}
private startHealthCheck(): void {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
}
this.healthCheckTimer = setInterval(async () => {
const isHealthy = await this.checkHealth();
if (!isHealthy && this._isRunning) {
log.warn('[TerminalService] Service not responding');
}
}, HEALTH_CHECK_INTERVAL);
}
async start(): Promise<{ success: boolean; error?: string }> {
if (this._isRunning && this.process) {
log.info('[TerminalService] Already running');
return { success: true };
}
if (this._isStarting) {
log.info('[TerminalService] Already starting');
return { success: true };
}
this._isStarting = true;
try {
const exePath = this.getExePath();
const exeArgs = this.getExeArgs();
log.info(`[TerminalService] Starting from: ${exePath} with args: ${exeArgs.join(' ')}`);
this.process = spawn(exePath, exeArgs, {
stdio: 'pipe',
});
this.processPid = this.process.pid ?? null;
this.process.stdout?.on('data', (data) => {
log.info(`[Terminal] ${data}`);
});
this.process.stderr?.on('data', (data) => {
log.error(`[Terminal error] ${data}`);
});
this.process.on('error', (err) => {
log.error('[TerminalService] Process error:', err);
this._isRunning = false;
this._isStarting = false;
this.process = null;
this.processPid = null;
});
this.process.on('exit', (code) => {
log.info(`[TerminalService] Process exited with code ${code}`);
this._isRunning = false;
this._isStarting = false;
this.process = null;
this.processPid = null;
});
await new Promise<void>((resolve) => setTimeout(resolve, 2000));
this._isRunning = true;
this._isStarting = false;
this.startHealthCheck();
log.info(`[TerminalService] Started successfully on port ${this.port}`);
return { success: true };
} catch (error: any) {
log.error('[TerminalService] Failed to start:', error);
this._isRunning = false;
this._isStarting = false;
return { success: false, error: error.message };
}
}
async stop(): Promise<{ success: boolean; error?: string }> {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = null;
}
if (!this.processPid) {
log.info('[TerminalService] Not running');
return { success: true };
}
return new Promise((resolve) => {
log.info(`[TerminalService] Stopping process ${this.processPid}...`);
exec(`taskkill /F /PID ${this.processPid}`, (error) => {
this.process = null;
this.processPid = null;
this._isRunning = false;
if (error) {
log.error('[TerminalService] Failed to stop:', error);
resolve({ success: false, error: error.message });
} else {
log.info('[TerminalService] Stopped');
resolve({ success: true });
}
});
});
}
}
export const terminalService = new TerminalService();

View File

@@ -0,0 +1,12 @@
import { defineModule } from '../types.js'
export const TERMINAL_MODULE = defineModule({
id: 'terminal',
name: 'Terminal',
basePath: '/terminal',
order: 17,
version: '1.0.0',
backend: {
enabled: false,
},
})

View File

@@ -0,0 +1,86 @@
import React, { useEffect, useState, useRef } from 'react'
export const TerminalPage: React.FC = () => {
const [isHealthy, setIsHealthy] = useState(false)
const [port, setPort] = useState<number>(9997)
const startedRef = useRef(false)
const restartingRef = useRef(false)
const webviewRef = useRef<HTMLWebViewElement>(null)
useEffect(() => {
let mounted = true
const start = async () => {
try {
const portResult = await window.electronAPI.terminalGetPort()
if (mounted) {
setPort(portResult.port)
}
const result = await window.electronAPI.terminalStart()
if (!result.success && mounted) {
console.error('Failed to start Terminal:', result.error)
}
restartingRef.current = false
} catch (err) {
console.error('Failed to start Terminal:', err)
restartingRef.current = false
}
}
const checkStatus = async () => {
try {
const status = await window.electronAPI.terminalGetStatus()
if (mounted) {
setIsHealthy(status.running)
if (!status.running && !restartingRef.current) {
restartingRef.current = true
start()
}
}
} catch (err) {
if (mounted) {
setIsHealthy(false)
if (!restartingRef.current) {
restartingRef.current = true
start()
}
}
}
}
if (!startedRef.current) {
startedRef.current = true
start()
}
const interval = setInterval(checkStatus, 2000)
return () => {
mounted = false
clearInterval(interval)
window.electronAPI.terminalStop()
startedRef.current = false
}
}, [])
return (
<div className="h-full w-full relative">
<span className={`absolute bottom-2 right-2 z-10 w-1.5 h-1.5 rounded-full ${isHealthy ? 'bg-green-500' : 'bg-red-500'}`} />
{!isHealthy && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
</div>
)}
{isHealthy && (
<webview
ref={webviewRef}
src={`http://localhost:${port}`}
style={{ width: '100%', height: '100%', border: 'none' }}
allowpopups={true}
webpreferences="contextIsolation=no"
/>
)}
</div>
)
}

View File

@@ -0,0 +1,11 @@
import { Terminal } from 'lucide-react'
import { TerminalPage } from './TerminalPage'
import { TERMINAL_MODULE } from '@shared/modules/terminal'
import { createFrontendModule } from '@/lib/module-registry'
export default createFrontendModule(TERMINAL_MODULE, {
icon: Terminal,
component: TerminalPage,
})
export { TerminalPage }

View File

@@ -49,6 +49,10 @@ export interface ElectronAPI {
sddStop: () => Promise<{ success: boolean; error?: string }>
sddGetStatus: () => Promise<{ running: boolean; port: number }>
sddGetPort: () => Promise<{ port: number }>
terminalStart: () => Promise<{ success: boolean; error?: string }>
terminalStop: () => Promise<{ success: boolean; error?: string }>
terminalGetStatus: () => Promise<{ running: boolean; port: number }>
terminalGetPort: () => Promise<{ port: number }>
}
declare global {