import { app, BrowserWindow, shell, ipcMain } from 'electron' import path from 'node:path' import fs from 'node:fs' import net from 'node:net' import { spawn } from 'node:child_process' import started from 'electron-squirrel-startup' if (started) app.quit() // ========== OpenCode 服务管理 ========== const DEFAULT_PORT = 4096 let opencodeProcess = null let opencodePort = null let opencodeStarting = null function isPortAvailable(port) { return new Promise((resolve) => { const server = net.createServer() server.once('error', () => resolve(false)) server.once('listening', () => server.close(() => resolve(true))) server.listen(port, '127.0.0.1') }) } async function resolvePort() { let port = DEFAULT_PORT while (!(await isPortAvailable(port))) { port++ if (port > 65535) throw new Error('没有可用的端口') } return port } function waitForReady(port, timeout = 15000) { return new Promise((resolve, reject) => { const start = Date.now() const check = () => { const socket = net.createConnection({ port, host: '127.0.0.1' }) socket.once('connect', () => { socket.end() resolve() }) socket.once('error', () => { socket.destroy() if (Date.now() - start >= timeout) return reject(new Error('OpenCode 服务启动超时')) setTimeout(check, 300) }) } check() }) } function buildInfo() { return { running: !!opencodeProcess, port: opencodePort, url: opencodePort ? `http://127.0.0.1:${opencodePort}` : null, } } function buildEnv(exeDir) { const env = { ...process.env } for (const key of Object.keys(env)) { if (key.startsWith('npm_')) delete env[key] } env.INIT_CWD = exeDir env.PWD = exeDir return env } function getExePath() { // 开发模式:__dirname = .vite/build,往上两级到项目根 // 打包模式:用 process.resourcesPath if (app.isPackaged) { return path.join(process.resourcesPath, 'opencode.exe') } return path.join(__dirname, '..', '..', 'resources', 'windows', 'x64', 'opencode.exe') } async function startOpencode() { if (opencodeProcess) return buildInfo() if (opencodeStarting) return opencodeStarting opencodeStarting = (async () => { const exePath = getExePath() console.log('[opencode] exe path:', exePath) const exeDir = path.dirname(exePath) await fs.promises.access(exePath, fs.constants.F_OK) opencodePort = await resolvePort() opencodeProcess = spawn(exePath, ['serve', '--port', String(opencodePort)], { cwd: exeDir, windowsHide: true, env: buildEnv(exeDir), }) opencodeProcess.stdout?.on('data', (d) => console.log(`[opencode] ${d.toString().trim()}`)) opencodeProcess.stderr?.on('data', (d) => console.error(`[opencode error] ${d.toString().trim()}`)) opencodeProcess.once('error', (e) => console.error('[opencode spawn error]', e)) opencodeProcess.once('close', (code) => { console.log(`[opencode exited] code=${code}`) opencodeProcess = null opencodePort = null opencodeStarting = null }) await waitForReady(opencodePort) return buildInfo() })() try { return await opencodeStarting } catch (err) { opencodeProcess?.kill() opencodeProcess = null opencodePort = null opencodeStarting = null throw err } } function stopOpencode() { opencodeProcess?.kill() opencodeProcess = null opencodePort = null opencodeStarting = null } // ========== IPC Handlers ========== function registerIpcHandlers() { ipcMain.handle('opencode:start', () => startOpencode()) ipcMain.handle('opencode:stop', () => { stopOpencode() return buildInfo() }) ipcMain.handle('opencode:info', () => buildInfo()) ipcMain.handle('opencode:port', () => opencodePort) ipcMain.handle('opencode:health', async () => { if (!opencodePort) throw new Error('OpenCode 服务未启动') const res = await fetch(`http://127.0.0.1:${opencodePort}/global/health`) if (!res.ok) throw new Error(`健康检查失败: ${res.status}`) return res.json() }) ipcMain.handle('opencode:session:create', async (_e, data) => { if (!opencodePort) throw new Error('OpenCode 服务未启动') const res = await fetch(`http://127.0.0.1:${opencodePort}/session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data ?? {}), }) if (!res.ok) throw new Error(`创建会话失败: ${res.status}`) return res.json() }) ipcMain.handle('opencode:session:send', async (_e, sessionId, text) => { if (!opencodePort) throw new Error('OpenCode 服务未启动') const res = await fetch(`http://127.0.0.1:${opencodePort}/session/${sessionId}/message`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ parts: [{ type: 'text', text }] }), }) if (!res.ok) throw new Error(`发送消息失败: ${res.status}`) return res.json() }) } // ========== 窗口 ========== const createWindow = () => { const mainWindow = new BrowserWindow({ width: 1280, height: 800, minWidth: 800, minHeight: 600, webPreferences: { preload: path.join(__dirname, 'index.js'), contextIsolation: true, nodeIntegration: false, }, titleBarStyle: 'hiddenInset', show: false, }) mainWindow.once('ready-to-show', () => mainWindow.show()) mainWindow.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url) return { action: 'deny' } }) // 注入 baseUrl,让渲染进程的 getBaseUrl() 能拿到正确端口 mainWindow.webContents.on('did-finish-load', () => { if (opencodePort) { mainWindow.webContents.executeJavaScript(`window.__opencodeBaseUrl = 'http://127.0.0.1:${opencodePort}'`) } }) if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL) } else { mainWindow.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`)) } } app.whenReady().then(() => { registerIpcHandlers() createWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) }) app.on('window-all-closed', () => { stopOpencode() if (process.platform !== 'darwin') app.quit() }) app.on('before-quit', () => stopOpencode())