Working messages display
This commit is contained in:
83
electron/main/ipc.ts
Normal file
83
electron/main/ipc.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { ipcMain, BrowserWindow } from "electron"
|
||||
import { processManager } from "./process-manager"
|
||||
import { randomBytes } from "crypto"
|
||||
|
||||
interface Instance {
|
||||
id: string
|
||||
folder: string
|
||||
port: number
|
||||
pid: number
|
||||
status: "starting" | "ready" | "error" | "stopped"
|
||||
error?: string
|
||||
}
|
||||
|
||||
const instances = new Map<string, Instance>()
|
||||
|
||||
function generateId(): string {
|
||||
return randomBytes(16).toString("hex")
|
||||
}
|
||||
|
||||
export function setupInstanceIPC(mainWindow: BrowserWindow) {
|
||||
ipcMain.handle("instance:create", async (event, folder: string) => {
|
||||
const id = generateId()
|
||||
|
||||
const instance: Instance = {
|
||||
id,
|
||||
folder,
|
||||
port: 0,
|
||||
pid: 0,
|
||||
status: "starting",
|
||||
}
|
||||
|
||||
instances.set(id, instance)
|
||||
|
||||
try {
|
||||
const { pid, port } = await processManager.spawn(folder)
|
||||
|
||||
instance.port = port
|
||||
instance.pid = pid
|
||||
instance.status = "ready"
|
||||
|
||||
mainWindow.webContents.send("instance:started", { id, port, pid })
|
||||
|
||||
const meta = processManager.getAllProcesses().get(pid)
|
||||
if (meta) {
|
||||
meta.childProcess.on("exit", (code, signal) => {
|
||||
instance.status = "stopped"
|
||||
mainWindow.webContents.send("instance:stopped", { id })
|
||||
})
|
||||
}
|
||||
|
||||
return { port, pid }
|
||||
} catch (error) {
|
||||
instance.status = "error"
|
||||
instance.error = error instanceof Error ? error.message : String(error)
|
||||
|
||||
mainWindow.webContents.send("instance:error", {
|
||||
id,
|
||||
error: instance.error,
|
||||
})
|
||||
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle("instance:stop", async (event, pid: number) => {
|
||||
await processManager.kill(pid)
|
||||
|
||||
for (const [id, instance] of instances.entries()) {
|
||||
if (instance.pid === pid) {
|
||||
instance.status = "stopped"
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle("instance:status", async (event, pid: number) => {
|
||||
return processManager.getStatus(pid)
|
||||
})
|
||||
|
||||
ipcMain.handle("instance:list", async () => {
|
||||
return Array.from(instances.values())
|
||||
})
|
||||
}
|
||||
69
electron/main/main.ts
Normal file
69
electron/main/main.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain } from "electron"
|
||||
import { join } from "path"
|
||||
import { createApplicationMenu } from "./menu"
|
||||
import { setupInstanceIPC } from "./ipc"
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, "../preload/index.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
mainWindow.loadURL("http://localhost:3000")
|
||||
mainWindow.webContents.openDevTools()
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, "../renderer/index.html"))
|
||||
}
|
||||
|
||||
createApplicationMenu(mainWindow)
|
||||
setupInstanceIPC(mainWindow)
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null
|
||||
})
|
||||
}
|
||||
|
||||
function setupIPC() {
|
||||
ipcMain.handle("dialog:selectFolder", async () => {
|
||||
if (!mainWindow) return null
|
||||
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
title: "Select Project Folder",
|
||||
buttonLabel: "Select",
|
||||
properties: ["openDirectory"],
|
||||
})
|
||||
|
||||
if (result.canceled) {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.filePaths[0] || null
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
setupIPC()
|
||||
createWindow()
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
84
electron/main/menu.ts
Normal file
84
electron/main/menu.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Menu, BrowserWindow, MenuItemConstructorOptions } from "electron"
|
||||
|
||||
export function createApplicationMenu(mainWindow: BrowserWindow) {
|
||||
const isMac = process.platform === "darwin"
|
||||
|
||||
const template: MenuItemConstructorOptions[] = [
|
||||
...(isMac
|
||||
? [
|
||||
{
|
||||
label: "OpenCode Client",
|
||||
submenu: [
|
||||
{ role: "about" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "hide" as const },
|
||||
{ role: "hideOthers" as const },
|
||||
{ role: "unhide" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "quit" as const },
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "File",
|
||||
submenu: [
|
||||
{
|
||||
label: "New Instance",
|
||||
accelerator: "CmdOrCtrl+N",
|
||||
click: () => {
|
||||
mainWindow.webContents.send("menu:newInstance")
|
||||
},
|
||||
},
|
||||
{ type: "separator" as const },
|
||||
isMac ? { role: "close" as const } : { role: "quit" as const },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
submenu: [
|
||||
{ role: "undo" as const },
|
||||
{ role: "redo" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "cut" as const },
|
||||
{ role: "copy" as const },
|
||||
{ role: "paste" as const },
|
||||
...(isMac
|
||||
? [{ role: "pasteAndMatchStyle" as const }, { role: "delete" as const }, { role: "selectAll" as const }]
|
||||
: [{ role: "delete" as const }, { type: "separator" as const }, { role: "selectAll" as const }]),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "View",
|
||||
submenu: [
|
||||
{ role: "reload" as const },
|
||||
{ role: "forceReload" as const },
|
||||
{ role: "toggleDevTools" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "resetZoom" as const },
|
||||
{ role: "zoomIn" as const },
|
||||
{ role: "zoomOut" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "togglefullscreen" as const },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Window",
|
||||
submenu: [
|
||||
{ role: "minimize" as const },
|
||||
{ role: "zoom" as const },
|
||||
...(isMac
|
||||
? [
|
||||
{ type: "separator" as const },
|
||||
{ role: "front" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "window" as const },
|
||||
]
|
||||
: [{ role: "close" as const }]),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const menu = Menu.buildFromTemplate(template)
|
||||
Menu.setApplicationMenu(menu)
|
||||
}
|
||||
190
electron/main/process-manager.ts
Normal file
190
electron/main/process-manager.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { spawn, ChildProcess } from "child_process"
|
||||
import { app } from "electron"
|
||||
import { existsSync, statSync } from "fs"
|
||||
import { execSync } from "child_process"
|
||||
|
||||
export interface ProcessInfo {
|
||||
pid: number
|
||||
port: number
|
||||
}
|
||||
|
||||
interface ProcessMeta {
|
||||
pid: number
|
||||
port: number
|
||||
folder: string
|
||||
startTime: number
|
||||
childProcess: ChildProcess
|
||||
logs: string[]
|
||||
}
|
||||
|
||||
class ProcessManager {
|
||||
private processes = new Map<number, ProcessMeta>()
|
||||
|
||||
async spawn(folder: string): Promise<ProcessInfo> {
|
||||
this.validateFolder(folder)
|
||||
this.validateOpenCodeBinary()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("opencode", ["serve", "--port", "0"], {
|
||||
cwd: folder,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: process.env,
|
||||
shell: false,
|
||||
})
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGKILL")
|
||||
reject(new Error("Server startup timeout (10s exceeded)"))
|
||||
}, 10000)
|
||||
|
||||
let stdoutBuffer = ""
|
||||
let stderrBuffer = ""
|
||||
let portFound = false
|
||||
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
const text = data.toString()
|
||||
stdoutBuffer += text
|
||||
|
||||
const lines = stdoutBuffer.split("\n")
|
||||
stdoutBuffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
const portMatch = line.match(/opencode server listening on http:\/\/[^:]+:(\d+)/)
|
||||
if (portMatch && !portFound) {
|
||||
portFound = true
|
||||
const port = parseInt(portMatch[1], 10)
|
||||
clearTimeout(timeout)
|
||||
|
||||
const meta: ProcessMeta = {
|
||||
pid: child.pid!,
|
||||
port,
|
||||
folder,
|
||||
startTime: Date.now(),
|
||||
childProcess: child,
|
||||
logs: [line],
|
||||
}
|
||||
|
||||
this.processes.set(child.pid!, meta)
|
||||
resolve({ pid: child.pid!, port })
|
||||
}
|
||||
|
||||
const logEntry = { timestamp: Date.now(), level: "info", message: line }
|
||||
const meta = this.processes.get(child.pid!)
|
||||
if (meta) {
|
||||
meta.logs.push(line)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
const text = data.toString()
|
||||
stderrBuffer += text
|
||||
|
||||
const lines = stderrBuffer.split("\n")
|
||||
stderrBuffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
const logEntry = { timestamp: Date.now(), level: "error", message: line }
|
||||
const meta = this.processes.get(child.pid!)
|
||||
if (meta) {
|
||||
meta.logs.push(line)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout)
|
||||
if (error.message.includes("ENOENT")) {
|
||||
reject(new Error("opencode binary not found in PATH"))
|
||||
} else {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
clearTimeout(timeout)
|
||||
this.processes.delete(child.pid!)
|
||||
|
||||
if (!portFound) {
|
||||
const errorMsg = stderrBuffer || `Process exited with code ${code}`
|
||||
reject(new Error(errorMsg))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async kill(pid: number): Promise<void> {
|
||||
const meta = this.processes.get(pid)
|
||||
if (!meta) {
|
||||
throw new Error(`Process ${pid} not found`)
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = meta.childProcess
|
||||
|
||||
const killTimeout = setTimeout(() => {
|
||||
child.kill("SIGKILL")
|
||||
}, 2000)
|
||||
|
||||
child.on("exit", () => {
|
||||
clearTimeout(killTimeout)
|
||||
this.processes.delete(pid)
|
||||
resolve()
|
||||
})
|
||||
|
||||
child.kill("SIGTERM")
|
||||
})
|
||||
}
|
||||
|
||||
getStatus(pid: number): "running" | "stopped" | "unknown" {
|
||||
if (!this.processes.has(pid)) {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return "running"
|
||||
} catch {
|
||||
return "stopped"
|
||||
}
|
||||
}
|
||||
|
||||
getAllProcesses(): Map<number, ProcessMeta> {
|
||||
return new Map(this.processes)
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
const killPromises = Array.from(this.processes.keys()).map((pid) => this.kill(pid).catch(() => {}))
|
||||
await Promise.all(killPromises)
|
||||
}
|
||||
|
||||
private validateFolder(folder: string): void {
|
||||
if (!existsSync(folder)) {
|
||||
throw new Error(`Folder does not exist: ${folder}`)
|
||||
}
|
||||
|
||||
const stats = statSync(folder)
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${folder}`)
|
||||
}
|
||||
}
|
||||
|
||||
private validateOpenCodeBinary(): void {
|
||||
const command = process.platform === "win32" ? "where opencode" : "which opencode"
|
||||
try {
|
||||
execSync(command, { stdio: "pipe" })
|
||||
} catch {
|
||||
throw new Error(
|
||||
"opencode binary not found in PATH. Please install OpenCode CLI first: npm install -g @opencode/cli",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const processManager = new ProcessManager()
|
||||
|
||||
app.on("before-quit", async (event) => {
|
||||
event.preventDefault()
|
||||
await processManager.cleanup()
|
||||
app.exit(0)
|
||||
})
|
||||
Reference in New Issue
Block a user