feat: 添加 Terminal 模块,启动 XCCMD.exe 服务
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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'),
|
||||
})
|
||||
|
||||
175
electron/services/terminalService.ts
Normal file
175
electron/services/terminalService.ts
Normal 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();
|
||||
12
shared/modules/terminal/index.ts
Normal file
12
shared/modules/terminal/index.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
86
src/modules/terminal/TerminalPage.tsx
Normal file
86
src/modules/terminal/TerminalPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
src/modules/terminal/index.tsx
Normal file
11
src/modules/terminal/index.tsx
Normal 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 }
|
||||
4
src/types/electron.d.ts
vendored
4
src/types/electron.d.ts
vendored
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user