Add OpenCode binary selection with version detection

- Add binary selector component with dropdown and validation
- Support custom OpenCode binary paths and system PATH binary
- Async version checking for all binaries when selector opens
- Store recent binaries with timestamps and version info
- Enhanced instance creation to use selected binary
- Improved storage system for binary preferences
- Fixed validation to handle system PATH binaries properly
This commit is contained in:
Shantur Rathore
2025-10-26 10:26:32 +00:00
parent f4a664bfe7
commit f63a4b3754
11 changed files with 684 additions and 55 deletions

View File

@@ -1,8 +1,9 @@
import { ipcMain, BrowserWindow } from "electron"
import { ipcMain, BrowserWindow, dialog } from "electron"
import { processManager } from "./process-manager"
import { randomBytes } from "crypto"
import * as fs from "fs"
import * as path from "path"
import { execSync } from "child_process"
import ignore from "ignore"
interface Instance {
@@ -23,7 +24,20 @@ function generateId(): string {
export function setupInstanceIPC(mainWindow: BrowserWindow) {
processManager.setMainWindow(mainWindow)
ipcMain.handle("instance:create", async (event, id: string, folder: string) => {
ipcMain.handle("dialog:selectFolder", async () => {
const result = await dialog.showOpenDialog(mainWindow!, {
title: "Select Project Folder",
properties: ["openDirectory"],
})
if (result.canceled || !result.filePaths.length) {
return null
}
return result.filePaths[0]
})
ipcMain.handle("instance:create", async (event, id: string, folder: string, binaryPath?: string) => {
const instance: Instance = {
id,
folder,
@@ -35,13 +49,13 @@ export function setupInstanceIPC(mainWindow: BrowserWindow) {
instances.set(id, instance)
try {
const { pid, port, binaryPath } = await processManager.spawn(folder, id)
const { pid, port, binaryPath: actualBinaryPath } = await processManager.spawn(folder, id, binaryPath)
instance.port = port
instance.pid = pid
instance.status = "ready"
mainWindow.webContents.send("instance:started", { id, port, pid, binaryPath })
mainWindow.webContents.send("instance:started", { id, port, pid, binaryPath: actualBinaryPath })
const meta = processManager.getAllProcesses().get(pid)
if (meta) {
@@ -51,7 +65,7 @@ export function setupInstanceIPC(mainWindow: BrowserWindow) {
})
}
return { id, port, pid, binaryPath }
return { id, port, pid, binaryPath: actualBinaryPath }
} catch (error) {
instance.status = "error"
instance.error = error instanceof Error ? error.message : String(error)
@@ -128,4 +142,88 @@ export function setupInstanceIPC(mainWindow: BrowserWindow) {
return scanDir(workspaceFolder, workspaceFolder)
})
// OpenCode binary operations
ipcMain.handle("dialog:selectOpenCodeBinary", async () => {
const result = await dialog.showOpenDialog(mainWindow!, {
title: "Select OpenCode Binary",
filters: [
{ name: "Executable Files", extensions: ["exe", "cmd", "bat", "sh", "command", "app", ""] },
{ name: "All Files", extensions: ["*"] },
],
properties: ["openFile"],
})
if (result.canceled || !result.filePaths.length) {
return null
}
return result.filePaths[0]
})
ipcMain.handle("opencode:validateBinary", async (event, binaryPath: string) => {
try {
// Special handling for system PATH binary
const isSystemPath = binaryPath === "opencode"
if (!isSystemPath) {
// Check if file exists and is executable for custom paths
if (!fs.existsSync(binaryPath)) {
return { valid: false, error: "File does not exist" }
}
const stats = fs.statSync(binaryPath)
if (!stats.isFile()) {
return { valid: false, error: "Path is not a file" }
}
}
// Try to get version
let version: string | undefined
try {
// Try -v flag first (opencode uses this)
let versionOutput = execSync(`${binaryPath} -v`, {
stdio: "pipe",
encoding: "utf-8",
timeout: 5000,
})
version = versionOutput.trim()
} catch (error) {
// Version check failed, but binary might still be valid
try {
let versionOutput = execSync(`${binaryPath} --version`, {
stdio: "pipe",
encoding: "utf-8",
timeout: 5000,
})
version = versionOutput.trim()
} catch (fallbackError) {}
}
// Try to run help command to verify it's actually opencode
try {
const helpOutput = execSync(`${binaryPath} --help`, {
stdio: "pipe",
encoding: "utf-8",
timeout: 5000,
})
if (!helpOutput.toLowerCase().includes("opencode")) {
return { valid: false, error: "Not an OpenCode binary" }
}
} catch (error) {
return { valid: false, error: "Binary failed to execute" }
}
return { valid: true, version }
} catch (error) {
return {
valid: false,
error: error instanceof Error ? error.message : String(error),
}
}
})
}

View File

@@ -4,6 +4,9 @@ import { createApplicationMenu } from "./menu"
import { setupInstanceIPC } from "./ipc"
import { setupStorageIPC } from "./storage"
// Setup IPC handlers before creating windows
setupStorageIPC()
let mainWindow: BrowserWindow | null = null
function createWindow() {
@@ -28,33 +31,13 @@ function createWindow() {
createApplicationMenu(mainWindow)
setupInstanceIPC(mainWindow)
setupStorageIPC()
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", () => {

View File

@@ -50,14 +50,14 @@ class ProcessManager {
}
}
async spawn(folder: string, instanceId: string): Promise<ProcessInfo> {
async spawn(folder: string, instanceId: string, binaryPath?: string): Promise<ProcessInfo> {
this.validateFolder(folder)
const binaryPath = this.validateOpenCodeBinary()
const actualBinaryPath = binaryPath ? this.validateCustomBinary(binaryPath) : this.validateOpenCodeBinary()
this.sendLog(instanceId, "info", `Starting OpenCode server for ${folder}...`)
this.sendLog(instanceId, "info", `Starting OpenCode server for ${folder} using ${actualBinaryPath}...`)
return new Promise((resolve, reject) => {
const child = spawn("opencode", ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"], {
const child = spawn(actualBinaryPath, ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"], {
cwd: folder,
stdio: ["ignore", "pipe", "pipe"],
env: process.env,
@@ -103,7 +103,7 @@ class ProcessManager {
}
this.processes.set(child.pid!, meta)
resolve({ pid: child.pid!, port, binaryPath })
resolve({ pid: child.pid!, port, binaryPath: actualBinaryPath })
}
const meta = this.processes.get(child.pid!)
@@ -221,6 +221,28 @@ class ProcessManager {
)
}
}
private validateCustomBinary(binaryPath: string): string {
if (!existsSync(binaryPath)) {
throw new Error(`OpenCode binary not found: ${binaryPath}`)
}
const stats = statSync(binaryPath)
if (!stats.isFile()) {
throw new Error(`Path is not a file: ${binaryPath}`)
}
// Check if executable (on Unix systems)
if (process.platform !== "win32") {
try {
execSync(`test -x "${binaryPath}"`, { stdio: "pipe" })
} catch {
throw new Error(`Binary is not executable: ${binaryPath}`)
}
}
return binaryPath
}
}
export const processManager = new ProcessManager()

View File

@@ -51,8 +51,8 @@ function invalidateConfigCache() {
export function setupStorageIPC() {
ensureDirectories()
ipcMain.handle("storage:getConfigPath", () => CONFIG_FILE)
ipcMain.handle("storage:getInstancesDir", () => INSTANCES_DIR)
ipcMain.handle("storage:getConfigPath", async () => CONFIG_FILE)
ipcMain.handle("storage:getInstancesDir", async () => INSTANCES_DIR)
ipcMain.handle("storage:readConfigFile", async () => {
try {

View File

@@ -2,7 +2,11 @@ import { contextBridge, ipcRenderer } from "electron"
export interface ElectronAPI {
selectFolder: () => Promise<string | null>
createInstance: (id: string, folder: string) => Promise<{ id: string; port: number; pid: number; binaryPath: string }>
createInstance: (
id: string,
folder: string,
binaryPath?: string,
) => Promise<{ id: string; port: number; pid: number; binaryPath: string }>
stopInstance: (pid: number) => Promise<void>
onInstanceStarted: (callback: (data: { id: string; port: number; pid: number; binaryPath: string }) => void) => void
onInstanceError: (callback: (data: { id: string; error: string }) => void) => void
@@ -15,19 +19,24 @@ export interface ElectronAPI {
) => void
onNewInstance: (callback: () => void) => void
scanDirectory: (workspaceFolder: string) => Promise<string[]>
// OpenCode binary operations
selectOpenCodeBinary: () => Promise<string | null>
validateOpenCodeBinary: (path: string) => Promise<{ valid: boolean; version?: string; error?: string }>
// Storage operations
getConfigPath: () => string
getInstancesDir: () => string
getConfigPath: () => Promise<string>
getInstancesDir: () => Promise<string>
readConfigFile: () => Promise<string>
writeConfigFile: (content: string) => Promise<void>
readInstanceFile: (instanceId: string) => Promise<string>
writeInstanceFile: (instanceId: string, content: string) => Promise<void>
deleteInstanceFile: (instanceId: string) => Promise<void>
onConfigChanged: (callback: () => void) => () => void
}
const electronAPI: ElectronAPI = {
selectFolder: () => ipcRenderer.invoke("dialog:selectFolder"),
createInstance: (id: string, folder: string) => ipcRenderer.invoke("instance:create", id, folder),
createInstance: (id: string, folder: string, binaryPath?: string) =>
ipcRenderer.invoke("instance:create", id, folder, binaryPath),
stopInstance: (pid: number) => ipcRenderer.invoke("instance:stop", pid),
onInstanceStarted: (callback) => {
ipcRenderer.on("instance:started", (_, data) => callback(data))
@@ -45,6 +54,9 @@ const electronAPI: ElectronAPI = {
ipcRenderer.on("menu:newInstance", () => callback())
},
scanDirectory: (workspaceFolder: string) => ipcRenderer.invoke("fs:scanDirectory", workspaceFolder),
// OpenCode binary operations
selectOpenCodeBinary: () => ipcRenderer.invoke("dialog:selectOpenCodeBinary"),
validateOpenCodeBinary: (path: string) => ipcRenderer.invoke("opencode:validateBinary", path),
// Storage operations
getConfigPath: () => ipcRenderer.invoke("storage:getConfigPath"),
getInstancesDir: () => ipcRenderer.invoke("storage:getInstancesDir"),
@@ -56,6 +68,7 @@ const electronAPI: ElectronAPI = {
deleteInstanceFile: (filename: string) => ipcRenderer.invoke("storage:deleteInstanceFile", filename),
onConfigChanged: (callback: () => void) => {
ipcRenderer.on("storage:configChanged", () => callback())
return () => ipcRenderer.removeAllListeners("storage:configChanged")
},
}