import { app, BrowserWindow, dialog, ipcMain, shell } from "electron"; import { createHash, randomBytes } from "node:crypto"; import { createReadStream } from "node:fs"; import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import { createServer, IncomingMessage, Server, ServerResponse } from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { AppState, BrainInput, ModelProvider, WebAccessSettings, createBrainItem, createPairedDevice, createPairingChallenge, createProvider, isPairingChallengeValid, nowIso, probeOpenAiCompatibleProvider, runAgent, updateBrainItem } from "@coworker/core"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rendererDir = path.resolve(__dirname, "../dist"); const stateFile = () => path.join(app.getPath("userData"), "state.json"); let mainWindow: BrowserWindow | undefined; let webServer: Server | undefined; let activePairing = createPairingChallenge(0); const defaultState: AppState = { providers: [ createProvider("ollama", "Ollama", "http://localhost:11434/v1"), createProvider("lmstudio", "LM Studio", "http://localhost:1234/v1") ], brain: [], workspaces: [], webAccess: { enabled: false, bindHost: "127.0.0.1", port: 4789, requirePairing: true }, pairedDevices: [] }; async function readState(): Promise { try { return { ...defaultState, ...JSON.parse(await readFile(stateFile(), "utf8")) }; } catch { return defaultState; } } async function writeState(state: AppState): Promise { await mkdir(path.dirname(stateFile()), { recursive: true }); await writeFile(stateFile(), JSON.stringify(state, null, 2), "utf8"); return state; } async function mutateState(mutator: (state: AppState) => AppState | Promise): Promise { const current = await readState(); const next = await mutator(current); return writeState(next); } function createWindow() { mainWindow = new BrowserWindow({ width: 1320, height: 860, minWidth: 980, minHeight: 680, title: "Coworker", backgroundColor: "#111317", webPreferences: { preload: path.join(__dirname, "preload.js"), contextIsolation: true, nodeIntegration: false } }); const devUrl = process.env.VITE_DEV_SERVER_URL; if (devUrl) { void mainWindow.loadURL(devUrl); } else { void mainWindow.loadFile(path.join(rendererDir, "index.html")); } mainWindow.webContents.setWindowOpenHandler(({ url }) => { void shell.openExternal(url); return { action: "deny" }; }); } function registerIpc() { ipcMain.handle("state:get", () => readState()); ipcMain.handle("providers:save", async (_event, provider: ModelProvider) => mutateState((state) => { const next = { ...provider, baseUrl: provider.baseUrl.replace(/\/+$/, ""), updatedAt: nowIso() }; return { ...state, providers: state.providers.some((item) => item.id === next.id) ? state.providers.map((item) => item.id === next.id ? next : item) : [...state.providers, next] }; })); ipcMain.handle("providers:remove", async (_event, id: string) => mutateState((state) => ({ ...state, providers: state.providers.filter((provider) => provider.id !== id) }))); ipcMain.handle("providers:probe", async (_event, id: string) => { const state = await readState(); const provider = state.providers.find((item) => item.id === id); if (!provider) throw new Error("Provider not found."); return probeOpenAiCompatibleProvider(provider); }); ipcMain.handle("brain:add", async (_event, input: BrainInput) => mutateState((state) => ({ ...state, brain: [createBrainItem(input), ...state.brain] }))); ipcMain.handle("brain:update", async (_event, id: string, patch: Partial) => mutateState((state) => ({ ...state, brain: state.brain.map((item) => item.id === id ? updateBrainItem(item, patch) : item) }))); ipcMain.handle("brain:remove", async (_event, id: string) => mutateState((state) => ({ ...state, brain: state.brain.filter((item) => item.id !== id) }))); ipcMain.handle("workspace:open", async () => { const result = await dialog.showOpenDialog({ properties: ["openDirectory"] }); if (result.canceled || !result.filePaths[0]) return undefined; const rootPath = result.filePaths[0]; const workspace = { id: createHash("sha256").update(rootPath).digest("hex").slice(0, 16), name: path.basename(rootPath), rootPath, openedAt: nowIso() }; await mutateState((state) => ({ ...state, workspaces: [workspace, ...state.workspaces.filter((item) => item.id !== workspace.id)] })); return workspace; }); ipcMain.handle("workspace:list", async (_event, id: string) => { const workspace = (await readState()).workspaces.find((item) => item.id === id); if (!workspace) throw new Error("Workspace not found."); const entries = await readdir(workspace.rootPath, { withFileTypes: true }); return Promise.all(entries.slice(0, 200).map(async (entry) => { const absolute = path.join(workspace.rootPath, entry.name); const info = await stat(absolute); return { path: entry.name, type: entry.isDirectory() ? "directory" : "file", size: entry.isFile() ? info.size : undefined, modifiedAt: info.mtime.toISOString() }; })); }); ipcMain.handle("workspace:read", async (_event, id: string, relativePath: string) => { const workspace = (await readState()).workspaces.find((item) => item.id === id); if (!workspace) throw new Error("Workspace not found."); const absolute = confinedPath(workspace.rootPath, relativePath); const info = await stat(absolute); if (!info.isFile() || info.size > 512 * 1024) throw new Error("File is not readable in preview."); return readFile(absolute, "utf8"); }); ipcMain.handle("agent:run", async (_event, request) => { const state = await readState(); const provider = request.providerId ? state.providers.find((item) => item.id === request.providerId) : state.providers.find((item) => item.enabled); const model = request.model ?? provider?.defaults.coding ?? provider?.defaults.chat; return runAgent( { prompt: String(request.prompt ?? ""), workspace: request.workspace, provider, model, messages: request.messages ?? [], brain: state.brain }, (messages) => completeWithProvider(provider, model, messages) ); }); ipcMain.handle("web:settings", async (_event, settings: WebAccessSettings) => { const state = await mutateState((current) => ({ ...current, webAccess: settings })); await restartWebServer(state.webAccess); return state; }); ipcMain.handle("web:pairing:create", () => { activePairing = createPairingChallenge(); return activePairing; }); } async function completeWithProvider(provider: ModelProvider | undefined, model: string | undefined, messages: { role: string; content: string }[]) { if (!provider || !model) { return "No model is configured yet. Add Ollama, LM Studio, or another OpenAI-compatible provider in Settings."; } const response = await fetch(`${provider.baseUrl.replace(/\/+$/, "")}/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", ...(provider.apiKey ? { Authorization: `Bearer ${provider.apiKey}` } : {}) }, body: JSON.stringify({ model, messages: messages.map((message) => ({ role: message.role, content: message.content })), temperature: 0.2 }) }); if (!response.ok) return `Model request failed with HTTP ${response.status}.`; const body = await response.json() as { choices?: Array<{ message?: { content?: string } }> }; return body.choices?.[0]?.message?.content ?? "The model returned an empty response."; } function confinedPath(root: string, relativePath: string) { const absolute = path.resolve(root, relativePath); const normalizedRoot = path.resolve(root); if (absolute !== normalizedRoot && !absolute.startsWith(`${normalizedRoot}${path.sep}`)) { throw new Error("Path is outside the workspace."); } return absolute; } async function restartWebServer(settings: WebAccessSettings) { if (webServer) { await new Promise((resolve) => webServer?.close(() => resolve())); webServer = undefined; } if (!settings.enabled) return; webServer = createServer(handleWebRequest); await new Promise((resolve, reject) => { webServer?.listen(settings.port, settings.bindHost, resolve); webServer?.on("error", reject); }); } async function handleWebRequest(req: IncomingMessage, res: ServerResponse) { const url = new URL(req.url ?? "/", "http://coworker.local"); if (url.pathname === "/api/pair" && req.method === "POST") return handlePair(req, res); if (url.pathname === "/api/state") return sendJson(res, await publicState(req)); if (url.pathname.startsWith("/api/") && !(await isAuthorized(req))) return sendJson(res, { error: "Pairing required." }, 401); if (url.pathname === "/api/providers" && req.method === "POST") return handleWebProviderSave(req, res); if (url.pathname.match(/^\/api\/providers\/[^/]+\/probe$/) && req.method === "POST") return handleWebProviderProbe(url, res); if (url.pathname === "/api/brain" && req.method === "POST") return handleWebBrainAdd(req, res); if (url.pathname.match(/^\/api\/brain\/[^/]+$/) && req.method === "DELETE") return handleWebBrainRemove(url, res); if (url.pathname.match(/^\/api\/workspaces\/[^/]+\/files$/)) return handleWebWorkspaceList(url, res); if (url.pathname.match(/^\/api\/workspaces\/[^/]+\/files\/.+$/)) return handleWebWorkspaceRead(url, res); if (url.pathname === "/api/agent/run" && req.method === "POST") return handleWebAgent(req, res); return serveStatic(url.pathname, res); } async function handlePair(req: IncomingMessage, res: ServerResponse) { const body = await readJsonBody<{ code?: string; name?: string }>(req); if (!isPairingChallengeValid(activePairing, body.code ?? "")) return sendJson(res, { error: "Invalid pairing code." }, 403); const token = randomBytes(24).toString("hex"); const device = await createPairedDevice(body.name ?? "Mobile device", token, digest); await mutateState((state) => ({ ...state, pairedDevices: [device, ...state.pairedDevices] })); res.setHeader("Set-Cookie", `coworker_token=${token}; HttpOnly; SameSite=Lax; Path=/`); sendJson(res, { token }); } async function handleWebProviderSave(req: IncomingMessage, res: ServerResponse) { const provider = await readJsonBody(req); const state = await mutateState((current) => ({ ...current, providers: current.providers.some((item) => item.id === provider.id) ? current.providers.map((item) => item.id === provider.id ? { ...provider, updatedAt: nowIso() } : item) : [...current.providers, { ...createProvider(provider.kind, provider.name, provider.baseUrl), ...provider, updatedAt: nowIso() }] })); sendJson(res, sanitizeState(state)); } async function handleWebProviderProbe(url: URL, res: ServerResponse) { const id = decodeURIComponent(url.pathname.split("/")[3]); const provider = (await readState()).providers.find((item) => item.id === id); if (!provider) return sendJson(res, { error: "Provider not found." }, 404); sendJson(res, await probeOpenAiCompatibleProvider(provider)); } async function handleWebBrainAdd(req: IncomingMessage, res: ServerResponse) { const input = await readJsonBody(req); const state = await mutateState((current) => ({ ...current, brain: [createBrainItem(input), ...current.brain] })); sendJson(res, sanitizeState(state)); } async function handleWebBrainRemove(url: URL, res: ServerResponse) { const id = decodeURIComponent(url.pathname.split("/")[3]); const state = await mutateState((current) => ({ ...current, brain: current.brain.filter((item) => item.id !== id) })); sendJson(res, sanitizeState(state)); } async function handleWebWorkspaceList(url: URL, res: ServerResponse) { const id = decodeURIComponent(url.pathname.split("/")[3]); const workspace = (await readState()).workspaces.find((item) => item.id === id); if (!workspace) return sendJson(res, { error: "Workspace not found." }, 404); const entries = await readdir(workspace.rootPath, { withFileTypes: true }); const files = await Promise.all(entries.slice(0, 200).map(async (entry) => { const absolute = path.join(workspace.rootPath, entry.name); const info = await stat(absolute); return { path: entry.name, type: entry.isDirectory() ? "directory" : "file", size: entry.isFile() ? info.size : undefined, modifiedAt: info.mtime.toISOString() }; })); sendJson(res, files); } async function handleWebWorkspaceRead(url: URL, res: ServerResponse) { const parts = url.pathname.split("/"); const id = decodeURIComponent(parts[3]); const relativePath = decodeURIComponent(parts.slice(5).join("/")); const workspace = (await readState()).workspaces.find((item) => item.id === id); if (!workspace) return sendJson(res, { error: "Workspace not found." }, 404); const absolute = confinedPath(workspace.rootPath, relativePath); const info = await stat(absolute); if (!info.isFile() || info.size > 512 * 1024) return sendJson(res, { error: "File is not readable in preview." }, 400); sendJson(res, { content: await readFile(absolute, "utf8") }); } async function handleWebAgent(req: IncomingMessage, res: ServerResponse) { const body = await readJsonBody<{ prompt?: string; messages?: unknown[]; providerId?: string; model?: string }>(req); const state = await readState(); const provider = body.providerId ? state.providers.find((item) => item.id === body.providerId) : state.providers[0]; const result = await runAgent( { prompt: body.prompt ?? "", messages: [], brain: state.brain, provider, model: body.model }, (messages) => completeWithProvider(provider, body.model ?? provider?.defaults.chat, messages) ); sendJson(res, result); } async function publicState(req: IncomingMessage) { const state = await readState(); const paired = await isAuthorized(req); return { paired, webAccess: state.webAccess, providers: paired ? state.providers.map(({ apiKey, ...provider }) => provider) : [], brain: paired ? state.brain : [], workspaces: paired ? state.workspaces : [] }; } async function isAuthorized(req: IncomingMessage) { const token = (req.headers.cookie ?? "").split(";").map((item) => item.trim()).find((item) => item.startsWith("coworker_token="))?.split("=")[1]; if (!token) return false; const hash = await digest(token); const state = await readState(); return state.pairedDevices.some((device) => !device.revokedAt && device.tokenHash === hash); } async function digest(value: string) { return createHash("sha256").update(value).digest("hex"); } function sanitizeState(state: AppState): AppState { return { ...state, providers: state.providers.map(({ apiKey, ...provider }) => provider), pairedDevices: state.pairedDevices.map((device) => ({ ...device, tokenHash: "" })) }; } async function readJsonBody(req: IncomingMessage): Promise { const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(Buffer.from(chunk)); return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}") as T; } function sendJson(res: ServerResponse, data: unknown, status = 200) { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(data)); } function serveStatic(urlPath: string, res: ServerResponse) { try { const safePath = urlPath === "/" ? "index.html" : urlPath.replace(/^\/+/, ""); const absolute = confinedPath(rendererDir, safePath); const finalPath = absolute.endsWith(path.sep) ? path.join(absolute, "index.html") : absolute; createReadStream(finalPath) .on("error", () => createReadStream(path.join(rendererDir, "index.html")).pipe(res)) .pipe(res); } catch { res.writeHead(403); res.end("Forbidden"); } } app.whenReady().then(async () => { registerIpc(); createWindow(); await restartWebServer((await readState()).webAccess); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); }); app.on("window-all-closed", () => { if (process.platform !== "darwin") app.quit(); });