From d54510a86448895165362bb661cda21942ce44ac Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Fri, 20 Mar 2026 13:08:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Terminal=20?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=EF=BC=8C=E5=90=AF=E5=8A=A8=20XCCMD.exe=20?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 22 +++- electron/preload.ts | 4 + electron/services/terminalService.ts | 175 ++++++++++++++++++++++++++ shared/modules/terminal/index.ts | 12 ++ src/modules/terminal/TerminalPage.tsx | 86 +++++++++++++ src/modules/terminal/index.tsx | 11 ++ src/types/electron.d.ts | 4 + 7 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 electron/services/terminalService.ts create mode 100644 shared/modules/terminal/index.ts create mode 100644 src/modules/terminal/TerminalPage.tsx create mode 100644 src/modules/terminal/index.tsx diff --git a/electron/main.ts b/electron/main.ts index 3b4324b..9d221eb 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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'); diff --git a/electron/preload.ts b/electron/preload.ts index 0ef222d..87c69bc 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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'), }) diff --git a/electron/services/terminalService.ts b/electron/services/terminalService.ts new file mode 100644 index 0000000..dbdd84f --- /dev/null +++ b/electron/services/terminalService.ts @@ -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 { + 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((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(); \ No newline at end of file diff --git a/shared/modules/terminal/index.ts b/shared/modules/terminal/index.ts new file mode 100644 index 0000000..c79fa5d --- /dev/null +++ b/shared/modules/terminal/index.ts @@ -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, + }, +}) \ No newline at end of file diff --git a/src/modules/terminal/TerminalPage.tsx b/src/modules/terminal/TerminalPage.tsx new file mode 100644 index 0000000..132d386 --- /dev/null +++ b/src/modules/terminal/TerminalPage.tsx @@ -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(9997) + const startedRef = useRef(false) + const restartingRef = useRef(false) + const webviewRef = useRef(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 ( +
+ + {!isHealthy && ( +
+
+
+ )} + {isHealthy && ( + + )} +
+ ) +} \ No newline at end of file diff --git a/src/modules/terminal/index.tsx b/src/modules/terminal/index.tsx new file mode 100644 index 0000000..5c97403 --- /dev/null +++ b/src/modules/terminal/index.tsx @@ -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 } \ No newline at end of file diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 4611852..8065ca5 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -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 {