Split workspace into electron and ui packages
This commit is contained in:
4
packages/electron-app/.gitignore
vendored
Normal file
4
packages/electron-app/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
.vite/
|
||||
61
packages/electron-app/electron.vite.config.ts
Normal file
61
packages/electron-app/electron.vite.config.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { defineConfig, externalizeDepsPlugin } from "electron-vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { resolve } from "path"
|
||||
|
||||
const uiRoot = resolve(__dirname, "../ui")
|
||||
const uiSrc = resolve(uiRoot, "src")
|
||||
const uiRendererRoot = resolve(uiRoot, "src/renderer")
|
||||
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
outDir: "dist/main",
|
||||
lib: {
|
||||
entry: resolve(__dirname, "electron/main/main.ts"),
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ["electron"],
|
||||
},
|
||||
},
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
outDir: "dist/preload",
|
||||
lib: {
|
||||
entry: resolve(__dirname, "electron/preload/index.ts"),
|
||||
formats: ["cjs"],
|
||||
fileName: () => "index.js",
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ["electron"],
|
||||
output: {
|
||||
entryFileNames: "index.js",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
root: uiRendererRoot,
|
||||
plugins: [solid()],
|
||||
css: {
|
||||
postcss: resolve(uiRoot, "postcss.config.js"),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": uiSrc,
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
build: {
|
||||
outDir: resolve(__dirname, "dist/renderer"),
|
||||
rollupOptions: {
|
||||
input: uiRendererEntry,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
243
packages/electron-app/electron/main/ipc.ts
Normal file
243
packages/electron-app/electron/main/ipc.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
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 { spawn } from "child_process"
|
||||
import ignore from "ignore"
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
function runBinaryVersion(binaryPath: string, timeoutMs = 5000): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(binaryPath, ["-v"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGTERM")
|
||||
reject(new Error("Version check timed out"))
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
child.stderr?.on("data", (data) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
})
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timeout)
|
||||
if (code === 0) {
|
||||
resolve(stdout.trim())
|
||||
} else {
|
||||
reject(new Error(stderr.trim() || `Binary exited with code ${code}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function setupInstanceIPC(mainWindow: BrowserWindow) {
|
||||
processManager.setMainWindow(mainWindow)
|
||||
|
||||
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, environmentVariables?: Record<string, string>) => {
|
||||
const instance: Instance = {
|
||||
id,
|
||||
folder,
|
||||
port: 0,
|
||||
pid: 0,
|
||||
status: "starting",
|
||||
}
|
||||
|
||||
instances.set(id, instance)
|
||||
|
||||
try {
|
||||
const {
|
||||
pid,
|
||||
port,
|
||||
binaryPath: actualBinaryPath,
|
||||
} = await processManager.spawn(folder, id, binaryPath, environmentVariables)
|
||||
|
||||
instance.port = port
|
||||
instance.pid = pid
|
||||
instance.status = "ready"
|
||||
|
||||
mainWindow.webContents.send("instance:started", { id, port, pid, binaryPath: actualBinaryPath })
|
||||
|
||||
const meta = processManager.getAllProcesses().get(pid)
|
||||
if (meta) {
|
||||
meta.childProcess.on("exit", (code, signal) => {
|
||||
instance.status = "stopped"
|
||||
mainWindow.webContents.send("instance:stopped", { id })
|
||||
})
|
||||
}
|
||||
|
||||
return { id, port, pid, binaryPath: actualBinaryPath }
|
||||
} 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())
|
||||
})
|
||||
|
||||
ipcMain.handle("fs:scanDirectory", async (event, workspaceFolder: string) => {
|
||||
const ig = ignore()
|
||||
ig.add([".git", "node_modules"])
|
||||
|
||||
const gitignorePath = path.join(workspaceFolder, ".gitignore")
|
||||
if (fs.existsSync(gitignorePath)) {
|
||||
const content = fs.readFileSync(gitignorePath, "utf-8")
|
||||
ig.add(content)
|
||||
}
|
||||
|
||||
function scanDir(dirPath: string, baseDir: string): string[] {
|
||||
const results: string[] = []
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name)
|
||||
const relativePath = path.relative(baseDir, fullPath)
|
||||
|
||||
if (ig.ignores(relativePath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const dirWithSlash = relativePath + "/"
|
||||
if (!ig.ignores(dirWithSlash)) {
|
||||
results.push(dirWithSlash)
|
||||
const subFiles = scanDir(fullPath, baseDir)
|
||||
results.push(...subFiles)
|
||||
}
|
||||
} else {
|
||||
results.push(relativePath)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error scanning ${dirPath}:`, error)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
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 once via -v flag
|
||||
try {
|
||||
const version = await runBinaryVersion(binaryPath)
|
||||
return { valid: true, version }
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
102
packages/electron-app/electron/main/main.ts
Normal file
102
packages/electron-app/electron/main/main.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain, nativeImage, nativeTheme, session } from "electron"
|
||||
import { join } from "path"
|
||||
import { createApplicationMenu } from "./menu"
|
||||
import { setupInstanceIPC } from "./ipc"
|
||||
import { setupStorageIPC } from "./storage"
|
||||
|
||||
const isMac = process.platform === "darwin"
|
||||
|
||||
if (isMac) {
|
||||
app.commandLine.appendSwitch("disable-spell-checking")
|
||||
}
|
||||
|
||||
// Setup IPC handlers before creating windows
|
||||
setupStorageIPC()
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
|
||||
function getIconPath() {
|
||||
if (app.isPackaged) {
|
||||
return join(process.resourcesPath, "icon.png")
|
||||
}
|
||||
|
||||
return join(app.getAppPath(), "electron/resources/icon.png")
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const prefersDark = true //nativeTheme.shouldUseDarkColors
|
||||
const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff"
|
||||
const iconPath = getIconPath()
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
backgroundColor,
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, "../preload/index.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
spellcheck: !isMac,
|
||||
},
|
||||
})
|
||||
|
||||
if (isMac) {
|
||||
// Disable macOS spell server to avoid input lag
|
||||
mainWindow.webContents.session.setSpellCheckerEnabled(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
|
||||
})
|
||||
}
|
||||
|
||||
if (isMac) {
|
||||
app.on("web-contents-created", (_, contents) => {
|
||||
contents.session.setSpellCheckerEnabled(false)
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
if (isMac) {
|
||||
session.defaultSession.setSpellCheckerEnabled(false)
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
window.webContents.session.setSpellCheckerEnabled(false)
|
||||
})
|
||||
|
||||
if (app.dock) {
|
||||
const dockIcon = nativeImage.createFromPath(getIconPath())
|
||||
if (!dockIcon.isEmpty()) {
|
||||
app.dock.setIcon(dockIcon)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[spellcheck] default session enabled:", session.defaultSession.isSpellCheckerEnabled())
|
||||
|
||||
createWindow()
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
84
packages/electron-app/electron/main/menu.ts
Normal file
84
packages/electron-app/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: "CodeNomad",
|
||||
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)
|
||||
}
|
||||
353
packages/electron-app/electron/main/process-manager.ts
Normal file
353
packages/electron-app/electron/main/process-manager.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import { spawn, execSync, ChildProcess } from "child_process"
|
||||
import { app, BrowserWindow } from "electron"
|
||||
import { existsSync, statSync } from "fs"
|
||||
import { buildUserShellCommand, getUserShellEnv, runUserShellCommandSync, supportsUserShell } from "./user-shell"
|
||||
|
||||
export interface ProcessInfo {
|
||||
pid: number
|
||||
port: number
|
||||
binaryPath: string
|
||||
}
|
||||
|
||||
interface ProcessMeta {
|
||||
pid: number
|
||||
port: number
|
||||
folder: string
|
||||
startTime: number
|
||||
childProcess: ChildProcess
|
||||
logs: string[]
|
||||
instanceId: string
|
||||
}
|
||||
|
||||
class ProcessManager {
|
||||
private processes = new Map<number, ProcessMeta>()
|
||||
private mainWindow: BrowserWindow | null = null
|
||||
|
||||
setMainWindow(window: BrowserWindow) {
|
||||
this.mainWindow = window
|
||||
}
|
||||
|
||||
private parseLogLevel(message: string): "info" | "error" | "warn" | "debug" {
|
||||
const upperMessage = message.toUpperCase()
|
||||
if (upperMessage.includes("[ERROR]") || upperMessage.includes("ERROR:")) return "error"
|
||||
if (upperMessage.includes("[WARN]") || upperMessage.includes("WARN:")) return "warn"
|
||||
if (upperMessage.includes("[DEBUG]") || upperMessage.includes("DEBUG:")) return "debug"
|
||||
if (upperMessage.includes("[INFO]") || upperMessage.includes("INFO:")) return "info"
|
||||
return "info"
|
||||
}
|
||||
|
||||
private sendLog(instanceId: string, level: "info" | "error" | "warn" | "debug", message: string) {
|
||||
if (this.mainWindow && message.trim()) {
|
||||
const parsedLevel = this.parseLogLevel(message)
|
||||
this.mainWindow.webContents.send("instance:log", {
|
||||
id: instanceId,
|
||||
entry: {
|
||||
timestamp: Date.now(),
|
||||
level: parsedLevel,
|
||||
message: message.trim(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async spawn(
|
||||
folder: string,
|
||||
instanceId: string,
|
||||
binaryPath?: string,
|
||||
environmentVariables?: Record<string, string>,
|
||||
): Promise<ProcessInfo> {
|
||||
this.validateFolder(folder)
|
||||
const useUserShell = supportsUserShell()
|
||||
const logAttempt = (message: string) => {
|
||||
console.info(`[ProcessManager] ${message}`)
|
||||
this.sendLog(instanceId, "debug", message)
|
||||
}
|
||||
|
||||
const env = useUserShell ? getUserShellEnv() : { ...process.env }
|
||||
if (environmentVariables) {
|
||||
Object.assign(env, environmentVariables)
|
||||
this.sendLog(
|
||||
instanceId,
|
||||
"info",
|
||||
`Using ${Object.keys(environmentVariables).length} custom environment variables:`,
|
||||
)
|
||||
|
||||
// Log each environment variable
|
||||
for (const [key, value] of Object.entries(environmentVariables)) {
|
||||
this.sendLog(instanceId, "info", ` ${key}=${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
let targetBinary: string
|
||||
if (!binaryPath || binaryPath === "opencode") {
|
||||
targetBinary = useUserShell ? "opencode" : this.validateOpenCodeBinary(logAttempt)
|
||||
} else {
|
||||
targetBinary = this.validateCustomBinary(binaryPath, logAttempt)
|
||||
}
|
||||
|
||||
const spawnCommand = useUserShell
|
||||
? this.buildShellServeCommand(targetBinary)
|
||||
: { command: targetBinary, args: this.buildServeArgs() }
|
||||
|
||||
const launchDetail = `${spawnCommand.command} ${spawnCommand.args.join(" ")}`.trim()
|
||||
this.sendLog(instanceId, "debug", `Launching process with: ${launchDetail}`)
|
||||
|
||||
this.sendLog(
|
||||
instanceId,
|
||||
"info",
|
||||
`Starting OpenCode server for ${folder} using ${targetBinary}...`,
|
||||
)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(spawnCommand.command, spawnCommand.args, {
|
||||
cwd: folder,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: false,
|
||||
})
|
||||
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGKILL")
|
||||
this.sendLog(instanceId, "error", "Server startup timeout (10s exceeded)")
|
||||
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) {
|
||||
if (!line.trim()) continue
|
||||
|
||||
this.sendLog(instanceId, "info", line)
|
||||
|
||||
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],
|
||||
instanceId,
|
||||
}
|
||||
|
||||
this.processes.set(child.pid!, meta)
|
||||
resolve({ pid: child.pid!, port, binaryPath: targetBinary })
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!line.trim()) continue
|
||||
|
||||
this.sendLog(instanceId, "error", 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) {
|
||||
// Treat unknown processes as already stopped so tabs close cleanly
|
||||
return
|
||||
}
|
||||
|
||||
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(logAttempt?: (message: string) => void): string {
|
||||
const log = logAttempt ?? ((message: string) => console.info(`[ProcessManager] ${message}`))
|
||||
|
||||
if (process.platform === "win32") {
|
||||
log("Checking PATH via 'where opencode'")
|
||||
return this.resolveBinaryViaLocator("where opencode", log)
|
||||
}
|
||||
|
||||
const shellCheck = buildUserShellCommand("command -v opencode")
|
||||
const shellPreview = [shellCheck.command, ...shellCheck.args].join(" ")
|
||||
log(`Checking PATH via shell: ${shellPreview}`)
|
||||
|
||||
try {
|
||||
const resolved = runUserShellCommandSync("command -v opencode")
|
||||
const path = this.pickFirstPath(resolved)
|
||||
if (path) {
|
||||
log(`Shell located opencode at ${path}`)
|
||||
return path
|
||||
}
|
||||
throw new Error("Empty result from shell lookup")
|
||||
} catch (shellError) {
|
||||
const message = shellError instanceof Error ? shellError.message : String(shellError)
|
||||
log(`Shell lookup failed: ${message}`)
|
||||
try {
|
||||
log("Fallback to 'which opencode'")
|
||||
return this.resolveBinaryViaLocator("which opencode", log)
|
||||
} catch (locatorError) {
|
||||
const locatorMessage = locatorError instanceof Error ? locatorError.message : String(locatorError)
|
||||
log(`Locator fallback failed: ${locatorMessage}`)
|
||||
throw new Error(
|
||||
"opencode binary not found in PATH. Please install OpenCode CLI first: npm install -g @opencode/cli",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateCustomBinary(binaryPath: string, log?: (message: string) => void): string {
|
||||
log?.(`Validating custom binary at ${binaryPath}`)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private resolveBinaryViaLocator(command: string, log?: (message: string) => void): string {
|
||||
log?.(`Running locator command: ${command}`)
|
||||
const output = execSync(command, { stdio: "pipe", encoding: "utf-8" })
|
||||
log?.(`Locator output: ${output.trim() || "<empty>"}`)
|
||||
const path = this.pickFirstPath(output)
|
||||
if (!path) {
|
||||
throw new Error("opencode binary not found in PATH")
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
private pickFirstPath(output: string): string | null {
|
||||
const line = output
|
||||
.split("\n")
|
||||
.map((entry) => entry.trim())
|
||||
.find((entry) => entry.length > 0)
|
||||
return line ?? null
|
||||
}
|
||||
|
||||
private buildServeArgs(): string[] {
|
||||
return ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
|
||||
}
|
||||
|
||||
private buildShellServeCommand(binaryPath: string): { command: string; args: string[] } {
|
||||
const args = this.buildServeArgs()
|
||||
.map((arg) => JSON.stringify(arg))
|
||||
.join(" ")
|
||||
return buildUserShellCommand(`exec ${JSON.stringify(binaryPath)} ${args}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const processManager = new ProcessManager()
|
||||
|
||||
app.on("before-quit", async (event) => {
|
||||
event.preventDefault()
|
||||
await processManager.cleanup()
|
||||
app.exit(0)
|
||||
})
|
||||
121
packages/electron-app/electron/main/storage.ts
Normal file
121
packages/electron-app/electron/main/storage.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { app, ipcMain } from "electron"
|
||||
import { join } from "path"
|
||||
import { readFile, writeFile, mkdir, unlink, stat } from "fs/promises"
|
||||
import { existsSync } from "fs"
|
||||
|
||||
const CONFIG_DIR = join(app.getPath("home"), ".config", "codenomad")
|
||||
const CONFIG_FILE = join(CONFIG_DIR, "config.json")
|
||||
const INSTANCES_DIR = join(CONFIG_DIR, "instances")
|
||||
|
||||
// File watching for config changes
|
||||
let configWatchers = new Set<number>()
|
||||
let configLastModified = 0
|
||||
let configCache: string | null = null
|
||||
|
||||
async function ensureDirectories() {
|
||||
try {
|
||||
await mkdir(CONFIG_DIR, { recursive: true })
|
||||
await mkdir(INSTANCES_DIR, { recursive: true })
|
||||
} catch (error) {
|
||||
console.error("Failed to create directories:", error)
|
||||
}
|
||||
}
|
||||
|
||||
async function readConfigWithCache(): Promise<string> {
|
||||
try {
|
||||
const stats = await stat(CONFIG_FILE)
|
||||
const currentModified = stats.mtime.getTime()
|
||||
|
||||
// If file hasn't been modified since last read, return cache
|
||||
if (configCache && configLastModified >= currentModified) {
|
||||
return configCache
|
||||
}
|
||||
|
||||
const content = await readFile(CONFIG_FILE, "utf-8")
|
||||
configCache = content
|
||||
configLastModified = currentModified
|
||||
return content
|
||||
} catch (error) {
|
||||
// File doesn't exist or can't be read
|
||||
configCache = null
|
||||
configLastModified = 0
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateConfigCache() {
|
||||
configCache = null
|
||||
configLastModified = 0
|
||||
}
|
||||
|
||||
export function setupStorageIPC() {
|
||||
ensureDirectories()
|
||||
|
||||
ipcMain.handle("storage:getConfigPath", async () => CONFIG_FILE)
|
||||
ipcMain.handle("storage:getInstancesDir", async () => INSTANCES_DIR)
|
||||
|
||||
ipcMain.handle("storage:readConfigFile", async () => {
|
||||
try {
|
||||
return await readConfigWithCache()
|
||||
} catch (error) {
|
||||
// Return empty config if file doesn't exist
|
||||
return JSON.stringify({ preferences: { showThinkingBlocks: false }, recentFolders: [] }, null, 2)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle("storage:writeConfigFile", async (_, content: string) => {
|
||||
try {
|
||||
await writeFile(CONFIG_FILE, content, "utf-8")
|
||||
invalidateConfigCache()
|
||||
|
||||
// Notify other renderer processes about config change
|
||||
const windows = require("electron").BrowserWindow.getAllWindows()
|
||||
windows.forEach((win: any) => {
|
||||
if (win.webContents && !win.webContents.isDestroyed()) {
|
||||
win.webContents.send("storage:configChanged")
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to write config file:", error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle("storage:readInstanceFile", async (_, filename: string) => {
|
||||
const instanceFile = join(INSTANCES_DIR, `${filename}.json`)
|
||||
try {
|
||||
return await readFile(instanceFile, "utf-8")
|
||||
} catch (error) {
|
||||
// Return empty instance data if file doesn't exist
|
||||
return JSON.stringify({ messageHistory: [] }, null, 2)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle("storage:writeInstanceFile", async (_, filename: string, content: string) => {
|
||||
const instanceFile = join(INSTANCES_DIR, `${filename}.json`)
|
||||
try {
|
||||
await writeFile(instanceFile, content, "utf-8")
|
||||
} catch (error) {
|
||||
console.error(`Failed to write instance file for ${filename}:`, error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle("storage:deleteInstanceFile", async (_, filename: string) => {
|
||||
const instanceFile = join(INSTANCES_DIR, `${filename}.json`)
|
||||
try {
|
||||
if (existsSync(instanceFile)) {
|
||||
await unlink(instanceFile)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete instance file for ${filename}:`, error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Clean up on app quit
|
||||
app.on("before-quit", () => {
|
||||
configCache = null
|
||||
configLastModified = 0
|
||||
})
|
||||
139
packages/electron-app/electron/main/user-shell.ts
Normal file
139
packages/electron-app/electron/main/user-shell.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { spawn, spawnSync } from "child_process"
|
||||
import path from "path"
|
||||
|
||||
interface ShellCommand {
|
||||
command: string
|
||||
args: string[]
|
||||
}
|
||||
|
||||
const isWindows = process.platform === "win32"
|
||||
|
||||
function getDefaultShellPath(): string {
|
||||
if (process.env.SHELL && process.env.SHELL.trim().length > 0) {
|
||||
return process.env.SHELL
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
return "/bin/zsh"
|
||||
}
|
||||
|
||||
return "/bin/bash"
|
||||
}
|
||||
|
||||
function wrapCommandForShell(command: string, shellPath: string): string {
|
||||
const shellName = path.basename(shellPath)
|
||||
|
||||
if (shellName.includes("bash")) {
|
||||
return 'if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; ' + command
|
||||
}
|
||||
|
||||
if (shellName.includes("zsh")) {
|
||||
return 'if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; ' + command
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
function buildShellArgs(shellPath: string): string[] {
|
||||
const shellName = path.basename(shellPath)
|
||||
if (shellName.includes("zsh")) {
|
||||
return ["-l", "-i", "-c"]
|
||||
}
|
||||
return ["-l", "-c"]
|
||||
}
|
||||
|
||||
function sanitizeShellEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const cleaned = { ...env }
|
||||
delete cleaned.npm_config_prefix
|
||||
delete cleaned.NPM_CONFIG_PREFIX
|
||||
return cleaned
|
||||
}
|
||||
|
||||
export function supportsUserShell(): boolean {
|
||||
return !isWindows
|
||||
}
|
||||
|
||||
export function buildUserShellCommand(userCommand: string): ShellCommand {
|
||||
if (!supportsUserShell()) {
|
||||
throw new Error("User shell invocation is only supported on POSIX platforms")
|
||||
}
|
||||
|
||||
const shellPath = getDefaultShellPath()
|
||||
const script = wrapCommandForShell(userCommand, shellPath)
|
||||
const args = buildShellArgs(shellPath)
|
||||
|
||||
return {
|
||||
command: shellPath,
|
||||
args: [...args, script],
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserShellEnv(): NodeJS.ProcessEnv {
|
||||
if (!supportsUserShell()) {
|
||||
throw new Error("User shell invocation is only supported on POSIX platforms")
|
||||
}
|
||||
return sanitizeShellEnv(process.env)
|
||||
}
|
||||
|
||||
export function runUserShellCommand(userCommand: string, timeoutMs = 5000): Promise<string> {
|
||||
if (!supportsUserShell()) {
|
||||
return Promise.reject(new Error("User shell invocation is only supported on POSIX platforms"))
|
||||
}
|
||||
|
||||
const { command, args } = buildUserShellCommand(userCommand)
|
||||
const env = getUserShellEnv()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
})
|
||||
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGTERM")
|
||||
reject(new Error(`Shell command timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
child.stderr?.on("data", (data) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
})
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timeout)
|
||||
if (code === 0) {
|
||||
resolve(stdout.trim())
|
||||
} else {
|
||||
reject(new Error(stderr.trim() || `Shell command exited with code ${code}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function runUserShellCommandSync(userCommand: string): string {
|
||||
if (!supportsUserShell()) {
|
||||
throw new Error("User shell invocation is only supported on POSIX platforms")
|
||||
}
|
||||
|
||||
const { command, args } = buildUserShellCommand(userCommand)
|
||||
const env = getUserShellEnv()
|
||||
const result = spawnSync(command, args, { encoding: "utf-8", env })
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = (result.stderr || "").toString().trim()
|
||||
throw new Error(stderr || "Shell command failed")
|
||||
}
|
||||
|
||||
return (result.stdout || "").toString().trim()
|
||||
}
|
||||
49
packages/electron-app/electron/preload/index.ts
Normal file
49
packages/electron-app/electron/preload/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { contextBridge, ipcRenderer } from "electron"
|
||||
import type { ElectronAPI } from "../../../ui/src/types/electron-api"
|
||||
|
||||
const electronAPI: ElectronAPI = {
|
||||
selectFolder: () => ipcRenderer.invoke("dialog:selectFolder"),
|
||||
createInstance: (id: string, folder: string, binaryPath?: string, environmentVariables?: Record<string, string>) =>
|
||||
ipcRenderer.invoke("instance:create", id, folder, binaryPath, environmentVariables),
|
||||
stopInstance: (pid: number) => ipcRenderer.invoke("instance:stop", pid),
|
||||
onInstanceStarted: (callback) => {
|
||||
ipcRenderer.on("instance:started", (_, data) => callback(data))
|
||||
},
|
||||
onInstanceError: (callback) => {
|
||||
ipcRenderer.on("instance:error", (_, data) => callback(data))
|
||||
},
|
||||
onInstanceStopped: (callback) => {
|
||||
ipcRenderer.on("instance:stopped", (_, data) => callback(data))
|
||||
},
|
||||
onInstanceLog: (callback) => {
|
||||
ipcRenderer.on("instance:log", (_, data) => callback(data))
|
||||
},
|
||||
onNewInstance: (callback) => {
|
||||
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"),
|
||||
readConfigFile: () => ipcRenderer.invoke("storage:readConfigFile"),
|
||||
writeConfigFile: (content: string) => ipcRenderer.invoke("storage:writeConfigFile", content),
|
||||
readInstanceFile: (filename: string) => ipcRenderer.invoke("storage:readInstanceFile", filename),
|
||||
writeInstanceFile: (filename: string, content: string) =>
|
||||
ipcRenderer.invoke("storage:writeInstanceFile", filename, content),
|
||||
deleteInstanceFile: (filename: string) => ipcRenderer.invoke("storage:deleteInstanceFile", filename),
|
||||
onConfigChanged: (callback: () => void) => {
|
||||
ipcRenderer.on("storage:configChanged", () => callback())
|
||||
return () => ipcRenderer.removeAllListeners("storage:configChanged")
|
||||
},
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI
|
||||
}
|
||||
}
|
||||
BIN
packages/electron-app/electron/resources/icon.icns
Normal file
BIN
packages/electron-app/electron/resources/icon.icns
Normal file
Binary file not shown.
BIN
packages/electron-app/electron/resources/icon.ico
Normal file
BIN
packages/electron-app/electron/resources/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 422 KiB |
BIN
packages/electron-app/electron/resources/icon.png
Normal file
BIN
packages/electron-app/electron/resources/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
7
packages/electron-app/electron/tsconfig.json
Normal file
7
packages/electron-app/electron/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.tsx"]
|
||||
}
|
||||
124
packages/electron-app/package.json
Normal file
124
packages/electron-app/package.json
Normal file
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"name": "@codenomad/electron-app",
|
||||
"version": "0.1.2",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"author": {
|
||||
"name": "Shantur Rathore",
|
||||
"email": "codenomad@shantur.com"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/main/main.js",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"dev:electron": "NODE_ENV=development electron .",
|
||||
"build": "electron-vite build",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"preview": "electron-vite preview",
|
||||
"build:binaries": "node scripts/build.js",
|
||||
"build:mac": "node scripts/build.js mac",
|
||||
"build:mac-x64": "node scripts/build.js mac-x64",
|
||||
"build:mac-arm64": "node scripts/build.js mac-arm64",
|
||||
"build:win": "node scripts/build.js win",
|
||||
"build:win-arm64": "node scripts/build.js win-arm64",
|
||||
"build:linux": "node scripts/build.js linux",
|
||||
"build:linux-arm64": "node scripts/build.js linux-arm64",
|
||||
"build:linux-rpm": "node scripts/build.js linux-rpm",
|
||||
"build:all": "node scripts/build.js all",
|
||||
"package:mac": "electron-builder --mac",
|
||||
"package:win": "electron-builder --win",
|
||||
"package:linux": "electron-builder --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
"ignore": "7.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
"app-builder-bin": "^4.2.0",
|
||||
"electron": "39.0.0",
|
||||
"electron-builder": "^24.0.0",
|
||||
"electron-vite": "4.0.1",
|
||||
"png2icons": "^2.0.1",
|
||||
"pngjs": "^7.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-solid": "^2.10.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "ai.opencode.client",
|
||||
"productName": "CodeNomad",
|
||||
"directories": {
|
||||
"output": "release",
|
||||
"buildResources": "electron/resources"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": ["x64", "arm64", "universal"]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": ["x64", "arm64", "universal"]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"icon": "electron/resources/icon.icns"
|
||||
},
|
||||
"dmg": {
|
||||
"contents": [
|
||||
{ "x": 130, "y": 220 },
|
||||
{ "x": 410, "y": 220, "type": "link", "path": "/Applications" }
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": ["x64", "arm64"]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": ["x64", "arm64"]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"icon": "electron/resources/icon.ico"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": ["x64", "arm64"]
|
||||
},
|
||||
{
|
||||
"target": "deb",
|
||||
"arch": ["x64", "arm64"]
|
||||
},
|
||||
{
|
||||
"target": "rpm",
|
||||
"arch": ["x64", "arm64"]
|
||||
},
|
||||
{
|
||||
"target": "tar.gz",
|
||||
"arch": ["x64", "arm64"]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"category": "Development",
|
||||
"icon": "electron/resources/icon.png"
|
||||
}
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
123
packages/electron-app/scripts/build.js
Normal file
123
packages/electron-app/scripts/build.js
Normal file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "child_process"
|
||||
import { existsSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
||||
const appDir = join(__dirname, "..")
|
||||
|
||||
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
|
||||
const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"
|
||||
const nodeModulesPath = join(appDir, "node_modules")
|
||||
|
||||
const platforms = {
|
||||
mac: {
|
||||
args: ["--mac", "--x64", "--arm64", "--universal"],
|
||||
description: "macOS (Intel, Apple Silicon, Universal)",
|
||||
},
|
||||
"mac-x64": {
|
||||
args: ["--mac", "--x64"],
|
||||
description: "macOS (Intel only)",
|
||||
},
|
||||
"mac-arm64": {
|
||||
args: ["--mac", "--arm64"],
|
||||
description: "macOS (Apple Silicon only)",
|
||||
},
|
||||
win: {
|
||||
args: ["--win", "--x64"],
|
||||
description: "Windows (x64)",
|
||||
},
|
||||
"win-arm64": {
|
||||
args: ["--win", "--arm64"],
|
||||
description: "Windows (ARM64)",
|
||||
},
|
||||
linux: {
|
||||
args: ["--linux", "--x64"],
|
||||
description: "Linux (x64)",
|
||||
},
|
||||
"linux-arm64": {
|
||||
args: ["--linux", "--arm64"],
|
||||
description: "Linux (ARM64)",
|
||||
},
|
||||
"linux-rpm": {
|
||||
args: ["--linux", "rpm", "--x64", "--arm64"],
|
||||
description: "Linux RPM packages (x64 & ARM64)",
|
||||
},
|
||||
all: {
|
||||
args: ["--mac", "--win", "--linux", "--x64", "--arm64"],
|
||||
description: "All platforms (macOS, Windows, Linux)",
|
||||
},
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const spawnOptions = {
|
||||
cwd: appDir,
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
...options,
|
||||
env: { ...process.env, NODE_PATH: nodeModulesPath, ...(options.env || {}) },
|
||||
}
|
||||
|
||||
const child = spawn(command, args, spawnOptions)
|
||||
|
||||
child.on("error", reject)
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve(undefined)
|
||||
} else {
|
||||
reject(new Error(`${command} ${args.join(" ")} exited with code ${code}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function printAvailablePlatforms() {
|
||||
console.error(`\nAvailable platforms:`)
|
||||
for (const [name, cfg] of Object.entries(platforms)) {
|
||||
console.error(` - ${name.padEnd(12)} : ${cfg.description}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function build(platform) {
|
||||
const config = platforms[platform]
|
||||
|
||||
if (!config) {
|
||||
console.error(`❌ Unknown platform: ${platform}`)
|
||||
printAvailablePlatforms()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`\n🔨 Building for: ${config.description}\n`)
|
||||
|
||||
try {
|
||||
console.log("📦 Step 1/2: Building Electron app...\n")
|
||||
await run(npmCmd, ["run", "build"])
|
||||
|
||||
console.log("\n📦 Step 2/2: Packaging binaries...\n")
|
||||
const distPath = join(appDir, "dist")
|
||||
if (!existsSync(distPath)) {
|
||||
throw new Error("dist/ directory not found. Build failed.")
|
||||
}
|
||||
|
||||
await run(npxCmd, ["electron-builder", "--publish=never", ...config.args])
|
||||
|
||||
console.log("\n✅ Build complete!")
|
||||
console.log(`📁 Binaries available in: ${join(appDir, "release")}\n`)
|
||||
} catch (error) {
|
||||
console.error("\n❌ Build failed:", error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const platform = process.argv[2] || "mac"
|
||||
|
||||
console.log(`
|
||||
╔════════════════════════════════════════╗
|
||||
║ CodeNomad - Binary Builder ║
|
||||
╚════════════════════════════════════════╝
|
||||
`)
|
||||
|
||||
await build(platform)
|
||||
30
packages/electron-app/scripts/dev.sh
Executable file
30
packages/electron-app/scripts/dev.sh
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "Node.js is required to run the development environment." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve the Electron binary via Node to avoid Bun resolution hiccups
|
||||
ELECTRON_EXEC_PATH="$(node -p "require('electron')")"
|
||||
|
||||
if [[ -z "${ELECTRON_EXEC_PATH}" ]]; then
|
||||
echo "Failed to resolve the Electron binary path." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export NODE_ENV="${NODE_ENV:-development}"
|
||||
export ELECTRON_EXEC_PATH
|
||||
|
||||
# ELECTRON_VITE_BIN="$ROOT_DIR/node_modules/.bin/electron-vite"
|
||||
|
||||
if [[ ! -x "${ELECTRON_VITE_BIN}" ]]; then
|
||||
echo "electron-vite binary not found. Have you installed dependencies?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec "${ELECTRON_VITE_BIN}" dev "$@"
|
||||
155
packages/electron-app/scripts/generate-icons.js
Normal file
155
packages/electron-app/scripts/generate-icons.js
Normal file
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "fs"
|
||||
import { resolve, join, basename } from "path"
|
||||
import { PNG } from "pngjs"
|
||||
import png2icons from "png2icons"
|
||||
|
||||
function printUsage() {
|
||||
console.log(`\nUsage: node scripts/generate-icons.js <input.png> [outputDir] [--name icon] [--radius 0.22]\n\nOptions:\n --name Base filename for generated assets (default: icon)\n --radius Corner radius ratio between 0 and 0.5 (default: 0.22)\n --help Show this message\n`)
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = [...argv]
|
||||
const options = {
|
||||
name: "icon",
|
||||
radius: 0.22,
|
||||
}
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const token = args[i]
|
||||
if (token === "--help" || token === "-h") {
|
||||
options.help = true
|
||||
continue
|
||||
}
|
||||
if (token === "--name" && i + 1 < args.length) {
|
||||
options.name = args[i + 1]
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (token === "--radius" && i + 1 < args.length) {
|
||||
options.radius = Number(args[i + 1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (!options.input) {
|
||||
options.input = token
|
||||
continue
|
||||
}
|
||||
if (!options.output) {
|
||||
options.output = token
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
function applyRoundedCorners(png, ratio) {
|
||||
const { width, height, data } = png
|
||||
const clamped = Math.max(0, Math.min(ratio, 0.5))
|
||||
if (clamped === 0) return png
|
||||
|
||||
const radius = Math.max(1, Math.min(width, height) * clamped)
|
||||
const radiusSq = radius * radius
|
||||
const rightThreshold = width - radius
|
||||
const bottomThreshold = height - radius
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = (width * y + x) * 4
|
||||
if (data[idx + 3] === 0) continue
|
||||
|
||||
const px = x + 0.5
|
||||
const py = y + 0.5
|
||||
|
||||
const inLeft = px < radius
|
||||
const inRight = px > rightThreshold
|
||||
const inTop = py < radius
|
||||
const inBottom = py > bottomThreshold
|
||||
|
||||
let outside = false
|
||||
|
||||
if (inLeft && inTop) {
|
||||
outside = (px - radius) ** 2 + (py - radius) ** 2 > radiusSq
|
||||
} else if (inRight && inTop) {
|
||||
outside = (px - rightThreshold) ** 2 + (py - radius) ** 2 > radiusSq
|
||||
} else if (inLeft && inBottom) {
|
||||
outside = (px - radius) ** 2 + (py - bottomThreshold) ** 2 > radiusSq
|
||||
} else if (inRight && inBottom) {
|
||||
outside = (px - rightThreshold) ** 2 + (py - bottomThreshold) ** 2 > radiusSq
|
||||
}
|
||||
|
||||
if (outside) {
|
||||
data[idx + 3] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return png
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
|
||||
if (args.help || !args.input) {
|
||||
printUsage()
|
||||
process.exit(args.help ? 0 : 1)
|
||||
}
|
||||
|
||||
const inputPath = resolve(args.input)
|
||||
const outputDir = resolve(args.output || "electron/resources")
|
||||
const baseName = args.name || basename(inputPath, ".png")
|
||||
const radiusRatio = Number.isFinite(args.radius) ? args.radius : 0.22
|
||||
|
||||
let buffer
|
||||
try {
|
||||
buffer = readFileSync(inputPath)
|
||||
} catch (error) {
|
||||
console.error(`Failed to read ${inputPath}:`, error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let png
|
||||
try {
|
||||
png = PNG.sync.read(buffer)
|
||||
} catch (error) {
|
||||
console.error("Input must be a valid PNG:", error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
applyRoundedCorners(png, radiusRatio)
|
||||
|
||||
const roundedBuffer = PNG.sync.write(png)
|
||||
|
||||
try {
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
} catch (error) {
|
||||
console.error("Failed to create output directory:", error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const pngPath = join(outputDir, `${baseName}.png`)
|
||||
writeFileSync(pngPath, roundedBuffer)
|
||||
|
||||
const icns = png2icons.createICNS(roundedBuffer, png2icons.BICUBIC, false)
|
||||
if (!icns) {
|
||||
console.error("Failed to create ICNS file. Make sure the source PNG is at least 256x256.")
|
||||
process.exit(1)
|
||||
}
|
||||
writeFileSync(join(outputDir, `${baseName}.icns`), icns)
|
||||
|
||||
const ico = png2icons.createICO(roundedBuffer, png2icons.BICUBIC, false)
|
||||
if (!ico) {
|
||||
console.error("Failed to create ICO file. Make sure the source PNG is at least 256x256.")
|
||||
process.exit(1)
|
||||
}
|
||||
writeFileSync(join(outputDir, `${baseName}.ico`), ico)
|
||||
|
||||
console.log(`\nGenerated assets in ${outputDir}:`)
|
||||
console.log(`- ${baseName}.png`)
|
||||
console.log(`- ${baseName}.icns`)
|
||||
console.log(`- ${baseName}.ico`)
|
||||
}
|
||||
|
||||
main()
|
||||
18
packages/electron-app/tsconfig.json
Normal file
18
packages/electron-app/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020"],
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["electron/**/*.ts", "electron.vite.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user