feat: 实现 OpenCode 页面生命周期管理 XCOpenCodeWeb.exe

- 新增 electron/services/xcOpenCodeWebService.ts 服务管理模块
- 标签页打开时启动 XCOpenCodeWeb.exe,关闭时停止
- 使用 iframe 在 OpenCode 页面显示 Web 服务 (端口 3002)
- 添加 bin 目录打包配置
- 添加 TypeScript 类型定义
This commit is contained in:
2026-03-13 20:55:34 +08:00
parent 53c1045406
commit 72d79ae214
7 changed files with 236 additions and 6 deletions

1
.gitignore vendored
View File

@@ -32,6 +32,7 @@ dist-electron/
# XCOpenCodeWeb (来自独立仓库 https://github.com/anomalyco/XCOpenCodeWeb)
remote/xcopencodeweb/XCOpenCodeWeb.exe
service/xcopencodeweb/XCOpenCodeWeb.exe
bin/XCOpenCodeWeb.exe
# Tools output
tools/tongyi/ppt_output/

View File

@@ -6,6 +6,7 @@ import log from 'electron-log';
import { generatePdf } from './services/pdfGenerator';
import { selectHtmlFile } from './services/htmlImport';
import { opencodeService } from './services/opencodeService';
import { xcOpenCodeWebService } from './services/xcOpenCodeWebService';
import { electronState } from './state';
log.initialize();
@@ -361,6 +362,18 @@ ipcMain.handle('opencode-stop-server', async () => {
return await opencodeService.stop();
});
ipcMain.handle('xc-opencode-web-get-status', () => {
return xcOpenCodeWebService.getStatus();
});
ipcMain.handle('xc-opencode-web-start', async () => {
return await xcOpenCodeWebService.start();
});
ipcMain.handle('xc-opencode-web-stop', async () => {
return await xcOpenCodeWebService.stop();
});
async function startServer() {
if (electronState.isDevelopment()) {
log.info('In dev mode, assuming external servers are running.');

View File

@@ -41,4 +41,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('remote-download-file', id, serverHost, port, fileName, remotePath, localPath, password),
opencodeStartServer: () => ipcRenderer.invoke('opencode-start-server'),
opencodeStopServer: () => ipcRenderer.invoke('opencode-stop-server'),
xcOpenCodeWebStart: () => ipcRenderer.invoke('xc-opencode-web-start'),
xcOpenCodeWebStop: () => ipcRenderer.invoke('xc-opencode-web-stop'),
xcOpenCodeWebGetStatus: () => ipcRenderer.invoke('xc-opencode-web-get-status'),
})

View File

@@ -0,0 +1,158 @@
import { spawn, ChildProcess } from 'child_process';
import { app } from 'electron';
import path from 'path';
import log from 'electron-log';
const XCOPENCODEWEB_PORT = 3002;
const HEALTH_CHECK_INTERVAL = 10000;
const HEALTH_CHECK_TIMEOUT = 2000;
class XCOpenCodeWebService {
private process: ChildProcess | null = null;
private healthCheckTimer: NodeJS.Timeout | null = null;
private _isRunning = false;
get port(): number {
return XCOPENCODEWEB_PORT;
}
isRunning(): boolean {
return this._isRunning;
}
getStatus(): { running: boolean; port: number } {
return {
running: this._isRunning,
port: this.port,
};
}
private getExePath(): string {
const exeName = 'XCOpenCodeWeb.exe';
const isPackaged = app.isPackaged;
let basePath: string;
if (isPackaged) {
basePath = path.dirname(app.getPath('exe'));
} else {
basePath = process.cwd();
}
return path.join(basePath, 'bin', exeName);
}
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('[XCOpenCodeWebService] 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('[XCOpenCodeWebService] Service not responding');
}
}, HEALTH_CHECK_INTERVAL);
}
async start(): Promise<{ success: boolean; error?: string }> {
if (this._isRunning && this.process) {
log.info('[XCOpenCodeWebService] Already running');
return { success: true };
}
try {
const exePath = this.getExePath();
log.info(`[XCOpenCodeWebService] Starting from: ${exePath}`);
this.process = spawn(exePath, [], {
stdio: 'pipe',
shell: true,
detached: false,
});
this.process.stdout?.on('data', (data) => {
log.info(`[XCOpenCodeWeb] ${data}`);
});
this.process.stderr?.on('data', (data) => {
log.error(`[XCOpenCodeWeb error] ${data}`);
});
this.process.on('error', (err) => {
log.error('[XCOpenCodeWebService] Process error:', err);
this._isRunning = false;
this.process = null;
});
this.process.on('exit', (code) => {
log.info(`[XCOpenCodeWebService] Process exited with code ${code}`);
this._isRunning = false;
this.process = null;
});
await new Promise<void>((resolve) => setTimeout(resolve, 2000));
this._isRunning = true;
this.startHealthCheck();
log.info(`[XCOpenCodeWebService] Started successfully on port ${this.port}`);
return { success: true };
} catch (error: any) {
log.error('[XCOpenCodeWebService] Failed to start:', error);
this._isRunning = 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.process) {
log.info('[XCOpenCodeWebService] Not running');
return { success: true };
}
try {
log.info('[XCOpenCodeWebService] Stopping...');
this.process.kill('SIGTERM');
await new Promise<void>((resolve) => setTimeout(resolve, 1000));
if (this.process && !this.process.killed) {
this.process.kill('SIGKILL');
}
this.process = null;
this._isRunning = false;
log.info('[XCOpenCodeWebService] Stopped');
return { success: true };
} catch (error: any) {
log.error('[XCOpenCodeWebService] Failed to stop:', error);
return { success: false, error: error.message };
}
}
}
export const xcOpenCodeWebService = new XCOpenCodeWebService();

View File

@@ -122,10 +122,12 @@
"dist-api/**/*",
"shared/**/*",
"tools/**/*",
"bin/**/*",
"package.json"
],
"asarUnpack": [
"tools/**/*"
"tools/**/*",
"bin/**/*"
],
"win": {
"target": "nsis"

View File

@@ -1,11 +1,61 @@
import React from 'react'
import React, { useEffect, useState } from 'react'
const XCOPENCODEWEB_PORT = 3002
export const OpenCodePage: React.FC = () => {
const [isRunning, setIsRunning] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let mounted = true
const startService = async () => {
try {
const result = await window.electronAPI.xcOpenCodeWebStart()
if (mounted) {
if (result.success) {
setIsRunning(true)
setError(null)
} else {
setError(result.error || '启动失败')
}
}
} catch (err) {
if (mounted) {
setError(err instanceof Error ? err.message : '启动失败')
}
}
}
startService()
return () => {
mounted = false
window.electronAPI.xcOpenCodeWebStop()
setIsRunning(false)
}
}, [])
return (
<div className="max-w-4xl mx-auto w-full px-14 py-12 pb-40">
<h1 className="text-2xl font-bold text-gray-800 dark:text-gray-200 mb-8 flex items-center gap-2">
<div className="h-full w-full flex flex-col">
<div className="max-w-4xl mx-auto w-full px-14 py-4 flex items-center gap-4">
<h1 className="text-2xl font-bold text-gray-800 dark:text-gray-200">
OpenCode
</h1>
<span className={`px-2 py-1 text-xs rounded ${isRunning ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'}`}>
{isRunning ? '运行中' : '启动中...'}
</span>
{error && (
<span className="text-red-500 text-sm">{error}</span>
)}
</div>
{isRunning && (
<iframe
src={`http://localhost:${XCOPENCODEWEB_PORT}`}
className="flex-1 w-full border-0"
title="XCOpenCodeWeb"
/>
)}
</div>
)
}

View File

@@ -40,6 +40,9 @@ export interface ElectronAPI {
}>
opencodeStartServer: () => Promise<{ success: boolean; port?: number; error?: string }>
opencodeStopServer: () => Promise<{ success: boolean; error?: string }>
xcOpenCodeWebStart: () => Promise<{ success: boolean; error?: string }>
xcOpenCodeWebStop: () => Promise<{ success: boolean; error?: string }>
xcOpenCodeWebGetStatus: () => Promise<{ running: boolean; port: number }>
}
declare global {