From 18217144792daf42343a6598cef2ad2802896d57 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 1 May 2026 19:18:45 +0800 Subject: [PATCH 1/3] refactor(desktop-electron): improve main process architecture - Refactor index.ts for better organization and readability - Update ipc.ts with improved type safety - Enhance menu.ts with better structure - Optimize server.ts with cleaner implementation - Improve windows.ts window management - Update preload types for better type definitions - Clean up loading.tsx renderer component --- packages/desktop-electron/src/main/index.ts | 511 +++++++++++------- packages/desktop-electron/src/main/ipc.ts | 13 +- packages/desktop-electron/src/main/menu.ts | 5 +- packages/desktop-electron/src/main/server.ts | 135 ++--- packages/desktop-electron/src/main/windows.ts | 16 +- .../desktop-electron/src/preload/types.ts | 9 +- .../desktop-electron/src/renderer/loading.tsx | 14 +- 7 files changed, 405 insertions(+), 298 deletions(-) diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index c9f16606ae..1e3c121b8f 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -1,12 +1,11 @@ import { randomUUID } from "node:crypto" -import { EventEmitter } from "node:events" import { existsSync } from "node:fs" import { createServer } from "node:net" import { homedir } from "node:os" import { join } from "node:path" -import type { Event } from "electron" import { app, BrowserWindow, dialog } from "electron" import pkg from "electron-updater" +import { Data, Deferred, Effect, Fiber, Option, PubSub, Queue, Ref, Stream, SubscriptionRef } from "effect" import contextMenu from "electron-context-menu" contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false }) @@ -34,14 +33,15 @@ app.setAppUserModelId(appId) app.setPath("userData", join(app.getPath("appData"), appId)) const { autoUpdater } = pkg -import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" +import { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" +import type { Server } from "virtual:opencode-server" import { checkAppExists, resolveAppPath, wslPath } from "./apps" import { CHANNEL, UPDATER_ENABLED } from "./constants" import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc" import { initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" -import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" +import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServerEffect } from "./server" import { createLoadingWindow, createMainWindow, @@ -49,230 +49,207 @@ import { setBackgroundColor, setDockIcon, } from "./windows" -import { drizzle } from "drizzle-orm/node-sqlite/driver" -import type { Server } from "virtual:opencode-server" -const initEmitter = new EventEmitter() -let initStep: InitStep = { phase: "server_waiting" } +// --------------------------------------------------------------------------- +// State — individual pieces, synchronously allocated at module load. +// --------------------------------------------------------------------------- -let mainWindow: BrowserWindow | null = null -let server: Server.Listener | null = null -const loadingComplete = defer() +const initStep = Effect.runSync(SubscriptionRef.make({ _tag: "ServerWaiting" })) +const serverReady = Effect.runSync(Deferred.make()) +const loadingComplete = Effect.runSync(Deferred.make()) +const deepLinkQueue = Effect.runSync(Queue.unbounded()) +const deepLinksConsumed = Effect.runSync(Deferred.make()) +const server = Effect.runSync(Ref.make>(Option.none())) +const menuCommands = Effect.runSync(PubSub.unbounded()) +const sqliteProgress = Effect.runSync(PubSub.unbounded()) -const pendingDeepLinks: string[] = [] +// --------------------------------------------------------------------------- +// App events (Data.TaggedEnum) +// --------------------------------------------------------------------------- -const serverReady = defer() -const logger = initLogging() +type AppEvent = Data.TaggedEnum<{ + SecondInstance: { readonly argv: readonly string[] } + OpenUrl: { readonly url: string } + BeforeQuit: {} + WillQuit: {} +}> -logger.log("app starting", { - version: app.getVersion(), - packaged: app.isPackaged, -}) +const appEvent = Data.taggedEnum() -setupApp() +const handleAppEvent = ( + event: AppEvent, + deepLinkQueue: Queue.Queue, + mainWindow: BrowserWindow, + server: Ref.Ref>, +) => + appEvent.$match(event, { + SecondInstance: ({ argv }) => + Effect.gen(function* () { + const urls = argv.filter((arg) => arg.startsWith("opencode://")) + if (urls.length) { + logger.log("deep link received via second-instance", { urls }) + yield* Queue.offer(deepLinkQueue, urls) + } + focusMainWindow(mainWindow) + }), + OpenUrl: ({ url }) => + Effect.gen(function* () { + logger.log("deep link received via open-url", { url }) + yield* Queue.offer(deepLinkQueue, [url]) + }), + BeforeQuit: () => stopServer(server), + WillQuit: () => stopServer(server), + }) -function setupApp() { - ensureLoopbackNoProxy() - app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") +// --------------------------------------------------------------------------- +// Pure state helpers (explicit parameters — no hidden closures) +// --------------------------------------------------------------------------- - if (!app.requestSingleInstanceLock()) { - app.quit() - return - } +const focusMainWindow = (win: BrowserWindow) => { + win.show() + win.focus() +} - app.on("second-instance", (_event: Event, argv: string[]) => { - const urls = argv.filter((arg: string) => arg.startsWith("opencode://")) - if (urls.length) { - logger.log("deep link received via second-instance", { urls }) - emitDeepLinks(urls) +const stopServer = (ref: Ref.Ref>) => + Effect.gen(function* () { + const srv = yield* Ref.get(ref) + if (Option.isSome(srv)) { + yield* Effect.promise(() => srv.value.stop()) + yield* Ref.set(ref, Option.none()) } - focusMainWindow() }) - app.on("open-url", (event: Event, url: string) => { - event.preventDefault() - logger.log("deep link received via open-url", { url }) - emitDeepLinks([url]) - }) +// --------------------------------------------------------------------------- +// Initialization flow (pure Effect — all state wired explicitly) +// --------------------------------------------------------------------------- - app.on("before-quit", () => { - killSidecar() - }) +const initialize = Effect.fn("Main.initialize")(function* () { + const needsMigration = !(yield* sqliteFileExists) + const sqliteDone = needsMigration ? yield* Deferred.make() : undefined - app.on("will-quit", () => { - killSidecar() - }) - - for (const signal of ["SIGINT", "SIGTERM"] as const) { - process.on(signal, () => { - killSidecar() - app.exit(0) - }) - } - - void app.whenReady().then(async () => { - app.setAsDefaultProtocolClient("opencode") - registerRendererProtocol() - setDockIcon() - setupAutoUpdater() - await initialize() - }) -} - -function emitDeepLinks(urls: string[]) { - if (urls.length === 0) return - pendingDeepLinks.push(...urls) - if (mainWindow) sendDeepLinks(mainWindow, urls) -} - -function focusMainWindow() { - if (!mainWindow) return - mainWindow.show() - mainWindow.focus() -} - -function setInitStep(step: InitStep) { - initStep = step - logger.log("init step", { step }) - initEmitter.emit("step", step) -} - -async function initialize() { - const needsMigration = !sqliteFileExists() - const sqliteDone = needsMigration ? defer() : undefined - let overlay: BrowserWindow | null = null - - const port = await getSidecarPort() + const port = yield* getSidecarPort const hostname = "127.0.0.1" const url = `http://${hostname}:${port}` const password = randomUUID() - const loadingTask = (async () => { + const loadingFiber = yield* Effect.gen(function* () { logger.log("sidecar connection started", { url }) - initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => { - setInitStep({ phase: "sqlite_waiting" }) - if (overlay) sendSqliteMigrationProgress(overlay, progress) - if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress) - if (progress.type === "Done") sqliteDone?.resolve() - }) + if (needsMigration && sqliteDone) { + yield* Effect.gen(function* () { + const { Database, JsonMigration } = yield* Effect.promise( + () => import("virtual:opencode-server") as Promise, + ) + const client = yield* Effect.sync(() => Database.Client().$client) + const db = yield* Effect.promise(() => + import("drizzle-orm/node-sqlite/driver").then((m) => m.drizzle({ client })), + ) - if (needsMigration) { - const { Database, JsonMigration } = await import("virtual:opencode-server") - await JsonMigration.run(drizzle({ client: Database.Client().$client }), { - progress: (event: { current: number; total: number }) => { - const percent = Math.round(event.current / event.total) * 100 - initEmitter.emit("sqlite", { type: "InProgress", value: percent }) - }, + yield* SubscriptionRef.set(initStep, InitStep.SqliteWaiting()) + + yield* Effect.promise(() => + JsonMigration.run(db, { + progress: (event: { current: number; total: number }) => { + const percent = Math.round((event.current / event.total) * 100) + const progress: SqliteMigrationProgress = { type: "InProgress", value: percent } + if (Option.isSome(overlay)) sendSqliteMigrationProgress(overlay.value, progress) + void Effect.runPromise(PubSub.publish(sqliteProgress, progress)) + }, + }), + ) + + yield* PubSub.publish(sqliteProgress, { type: "Done" }) + yield* Deferred.succeed(sqliteDone, undefined) }) - initEmitter.emit("sqlite", { type: "Done" }) - - sqliteDone?.resolve() } - if (needsMigration) { - await sqliteDone?.promise + if (needsMigration && sqliteDone) { + yield* Deferred.await(sqliteDone) } logger.log("spawning sidecar", { url }) - const { listener, health } = await spawnLocalServer(hostname, port, password) - server = listener - serverReady.resolve({ + const { listener, health } = yield* spawnLocalServerEffect(hostname, port, password) + yield* Ref.set(server, Option.some(listener)) + + yield* Deferred.succeed(serverReady, { url, username: "opencode", password, }) - await Promise.race([ - health.wait, - delay(30_000).then(() => { - throw new Error("Sidecar health check timed out") - }), - ]).catch((error) => { - logger.error("sidecar health check failed", error) - }) + yield* Effect.raceAll([ + health, + Effect.sleep("30 seconds").pipe(Effect.flatMap(() => Effect.fail(new Error("Sidecar health check timed out")))), + ]).pipe(Effect.catch((error) => Effect.sync(() => logger.error("sidecar health check failed", error)))) logger.log("loading task finished") - })() - if (needsMigration) { - const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)]) - if (show) { - overlay = createLoadingWindow() - await delay(1_000) - } - } + return listener + }).pipe(Effect.forkChild) - await loadingTask - setInitStep({ phase: "done" }) + const overlay = yield* Effect.gen(function* () { + if (!needsMigration) return - if (overlay) { - await loadingComplete.promise - } + const show = yield* Effect.raceAll([ + Fiber.join(loadingFiber).pipe(Effect.as(false)), + Effect.sleep("1 second").pipe(Effect.as(true)), + ]) + if (!show) return - mainWindow = createMainWindow() - wireMenu() + const overlay = createLoadingWindow() + yield* Effect.sleep("1 second") + return overlay + }).pipe(Effect.map(Option.fromNullishOr)) - overlay?.close() -} + const listener = yield* Fiber.join(loadingFiber) + yield* SubscriptionRef.set(initStep, InitStep.Done()) -function wireMenu() { - if (!mainWindow) return - createMenu({ - trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id), - checkForUpdates: () => { - void checkForUpdates(true) - }, - reload: () => mainWindow?.reload(), - relaunch: () => { - killSidecar() - app.relaunch() - app.exit(0) - }, + yield* Option.match(overlay, { + onSome: Effect.fnUntraced(function* (overlay) { + yield* Deferred.await(loadingComplete) + overlay.close() + }), + onNone: () => Effect.void, }) -} - -registerIpcHandlers({ - killSidecar: () => killSidecar(), - awaitInitialization: async (sendStep) => { - sendStep(initStep) - const listener = (step: InitStep) => sendStep(step) - initEmitter.on("step", listener) - try { - logger.log("awaiting server ready") - const res = await serverReady.promise - logger.log("server ready", { url: res.url }) - return res - } finally { - initEmitter.off("step", listener) - } - }, - getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }), - consumeInitialDeepLinks: () => pendingDeepLinks.splice(0), - getDefaultServerUrl: () => getDefaultServerUrl(), - setDefaultServerUrl: (url) => setDefaultServerUrl(url), - getWslConfig: () => Promise.resolve(getWslConfig()), - setWslConfig: (config: WslConfig) => setWslConfig(config), - getDisplayBackend: async () => null, - setDisplayBackend: async () => undefined, - parseMarkdown: async (markdown) => parseMarkdown(markdown), - checkAppExists: async (appName) => checkAppExists(appName), - wslPath: async (path, mode) => wslPath(path, mode), - resolveAppPath: async (appName) => resolveAppPath(appName), - loadingWindowComplete: () => loadingComplete.resolve(), - runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail), - checkUpdate: async () => checkUpdate(), - installUpdate: async () => installUpdate(), - setBackgroundColor: (color) => setBackgroundColor(color), }) -function killSidecar() { - if (!server) return - server.stop() - server = null +// --------------------------------------------------------------------------- +// App lifecycle (imperative Electron shell, thin wrappers around Effects) +// --------------------------------------------------------------------------- + +const logger = initLogging() + +const shutdownEffect = Effect.gen(function* () { + yield* stopServer(server) + app.exit(0) +}) + +const registerAppEventListeners = (appEvents: PubSub.PubSub) => () => { + app.on("second-instance", (_event, argv) => { + PubSub.publishUnsafe(appEvents, appEvent.SecondInstance({ argv })) + }) + + app.on("open-url", (event, url) => { + event.preventDefault() + PubSub.publishUnsafe(appEvents, appEvent.OpenUrl({ url })) + }) + + app.on("before-quit", () => { + PubSub.publishUnsafe(appEvents, appEvent.BeforeQuit()) + }) + + app.on("will-quit", () => { + PubSub.publishUnsafe(appEvents, appEvent.WillQuit()) + }) + + for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => { + void Effect.runPromise(shutdownEffect) + }) + } } -function ensureLoopbackNoProxy() { +const ensureLoopbackNoProxy = () => { const loopback = ["127.0.0.1", "localhost", "::1"] const upsert = (key: string) => { const items = (process.env[key] ?? "") @@ -292,14 +269,154 @@ function ensureLoopbackNoProxy() { upsert("no_proxy") } -async function getSidecarPort() { +const main = Effect.gen(function* () { + logger.log("app starting", { + version: app.getVersion(), + packaged: app.isPackaged, + }) + + if (!app.requestSingleInstanceLock()) { + app.quit() + return + } + + ensureLoopbackNoProxy() + app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") + + const appEvents = yield* PubSub.unbounded() + registerAppEventListeners(appEvents) + + yield* Effect.promise(() => app.whenReady()) + + app.setAsDefaultProtocolClient("opencode") + registerRendererProtocol() + setDockIcon() + setupAutoUpdater() + + registerIpcHandlersEffect() + + yield* initialize() + + const mainWindow = createMainWindow() + wireMenu(mainWindow) + + yield* Stream.fromPubSub(appEvents).pipe( + Stream.runForEach((event) => handleAppEvent(event, deepLinkQueue, mainWindow, server)), + Effect.forkChild, + ) + + yield* Deferred.await(deepLinksConsumed).pipe( + Effect.andThen( + Stream.fromQueue(deepLinkQueue).pipe( + Stream.runForEach((urls) => Effect.sync(() => sendDeepLinks(mainWindow, urls))), + ), + ), + Effect.forkChild, + ) + + yield* Stream.fromPubSub(menuCommands).pipe( + Stream.runForEach((id) => Effect.sync(() => sendMenuCommand(mainWindow, id))), + Effect.forkChild, + ) +}).pipe( + Effect.catch((error) => + Effect.sync(() => { + logger.error("initialization failed", error) + app.exit(1) + }), + ), +) + +void Effect.runPromise(main) + +// --------------------------------------------------------------------------- +// Menu wiring +// --------------------------------------------------------------------------- + +const wireMenu = (win: BrowserWindow) => { + createMenu({ + trigger: (id) => { + sendMenuCommand(win, id) + }, + checkForUpdates: () => { + void checkForUpdates(true) + }, + reload: () => win.reload(), + relaunch: () => { + void Effect.runPromise( + Effect.gen(function* () { + yield* stopServer(server) + app.relaunch() + app.exit(0) + }), + ) + }, + }) +} + +// --------------------------------------------------------------------------- +// IPC handlers +// --------------------------------------------------------------------------- + +const registerIpcHandlersEffect = () => + registerIpcHandlers({ + killSidecar: () => Effect.runPromise(stopServer(server)), + awaitInitialization: (sendStep) => + Effect.runPromise( + Effect.gen(function* () { + const currentStep = SubscriptionRef.getUnsafe(initStep) + sendStep(currentStep) + + const fiber = yield* SubscriptionRef.changes(initStep).pipe( + Stream.runForEach((step) => Effect.sync(() => sendStep(step))), + Effect.forkChild, + ) + + logger.log("awaiting server ready") + const res = yield* Deferred.await(serverReady) + logger.log("server ready", { url: res.url }) + + yield* Fiber.interrupt(fiber) + return res + }), + ), + getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }), + consumeInitialDeepLinks: () => + Effect.runPromise( + Queue.clear(deepLinkQueue).pipe( + Effect.map((links) => links.flat()), + Effect.tap(() => Deferred.succeed(deepLinksConsumed, undefined)), + ), + ), + getDefaultServerUrl: () => Effect.runPromise(getDefaultServerUrl), + setDefaultServerUrl: (url) => Effect.runPromise(setDefaultServerUrl(url)), + getWslConfig: () => Effect.runPromise(getWslConfig), + setWslConfig: (config: WslConfig) => Effect.runPromise(setWslConfig(config)), + getDisplayBackend: () => Promise.resolve(null), + setDisplayBackend: () => Promise.resolve(undefined), + parseMarkdown: (markdown) => Promise.resolve(parseMarkdown(markdown)), + checkAppExists: (appName) => checkAppExists(appName), + wslPath: (path, mode) => Promise.resolve(wslPath(path, mode)), + resolveAppPath: (appName) => Promise.resolve(resolveAppPath(appName)), + loadingWindowComplete: () => Effect.runPromise(Deferred.succeed(loadingComplete, undefined)), + runUpdater: (alertOnFail) => checkForUpdates(alertOnFail), + checkUpdate: () => checkUpdate(), + installUpdate: () => installUpdate(), + setBackgroundColor, + }) + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +const getSidecarPort = Effect.promise(() => { const fromEnv = process.env.OPENCODE_PORT if (fromEnv) { const parsed = Number.parseInt(fromEnv, 10) - if (!Number.isNaN(parsed)) return parsed + if (!Number.isNaN(parsed)) return Promise.resolve(parsed) } - return await new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const server = createServer() server.on("error", reject) server.listen(0, "127.0.0.1", () => { @@ -313,13 +430,13 @@ async function getSidecarPort() { server.close(() => resolve(port)) }) }) -} +}) -function sqliteFileExists() { +const sqliteFileExists = Effect.sync(() => { const xdg = process.env.XDG_DATA_HOME const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share") return existsSync(join(base, "opencode", "opencode.db")) -} +}) function setupAutoUpdater() { if (!UPDATER_ENABLED) return @@ -390,7 +507,7 @@ async function installUpdate() { logger.log("installing downloaded update", { version: downloadedUpdateVersion, }) - killSidecar() + void Effect.runPromise(stopServer(server)) autoUpdater.quitAndInstall() } @@ -436,17 +553,3 @@ async function checkForUpdates(alertOnFail: boolean) { await installUpdate() } } - -function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -function defer() { - let resolve!: (value: T) => void - let reject!: (error: Error) => void - const promise = new Promise((res, rej) => { - resolve = res - reject = rej - }) - return { promise, resolve, reject } -} diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 8dbca8eea1..6cf05d36b6 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -191,14 +191,7 @@ export function registerIpcHandlers(deps: Deps) { }) } -export function sendSqliteMigrationProgress(win: BrowserWindow, progress: SqliteMigrationProgress) { +export const sendSqliteMigrationProgress = (win: BrowserWindow, progress: SqliteMigrationProgress) => win.webContents.send("sqlite-migration-progress", progress) -} - -export function sendMenuCommand(win: BrowserWindow, id: string) { - win.webContents.send("menu-command", id) -} - -export function sendDeepLinks(win: BrowserWindow, urls: string[]) { - win.webContents.send("deep-link", urls) -} +export const sendMenuCommand = (win: BrowserWindow, id: string) => win.webContents.send("menu-command", id) +export const sendDeepLinks = (win: BrowserWindow, urls: string[]) => win.webContents.send("deep-link", urls) diff --git a/packages/desktop-electron/src/main/menu.ts b/packages/desktop-electron/src/main/menu.ts index 0d9a697fa9..ba85c603b3 100644 --- a/packages/desktop-electron/src/main/menu.ts +++ b/packages/desktop-electron/src/main/menu.ts @@ -1,5 +1,4 @@ import { Menu, shell } from "electron" - import { UPDATER_ENABLED } from "./constants" import { createMainWindow } from "./windows" @@ -47,7 +46,9 @@ export function createMenu(deps: Deps) { { label: "New Window", accelerator: "Cmd+Shift+N", - click: () => createMainWindow(), + click: () => { + void createMainWindow() + }, }, { type: "separator" }, { role: "close" }, diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index 83d50f7cb6..75c62e7b58 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -1,80 +1,81 @@ import { app } from "electron" +import { Effect } from "effect" import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants" import { getUserShell, loadShellEnv } from "./shell-env" import { getStore } from "./store" export type WslConfig = { enabled: boolean } -export type HealthCheck = { wait: Promise } - -export function getDefaultServerUrl(): string | null { +export const getDefaultServerUrl = Effect.sync((): string | null => { const value = getStore().get(DEFAULT_SERVER_URL_KEY) return typeof value === "string" ? value : null -} +}) -export function setDefaultServerUrl(url: string | null) { - if (url) { - getStore().set(DEFAULT_SERVER_URL_KEY, url) - return - } - - getStore().delete(DEFAULT_SERVER_URL_KEY) -} - -export function getWslConfig(): WslConfig { - const value = getStore().get(WSL_ENABLED_KEY) - return { enabled: typeof value === "boolean" ? value : false } -} - -export function setWslConfig(config: WslConfig) { - getStore().set(WSL_ENABLED_KEY, config.enabled) -} - -export async function spawnLocalServer(hostname: string, port: number, password: string) { - prepareServerEnv(password) - const { Log, Server } = await import("virtual:opencode-server") - await Log.init({ level: "WARN" }) - const listener = await Server.listen({ - port, - hostname, - username: "opencode", - password, - cors: ["oc://renderer"], +export const setDefaultServerUrl = (url: string | null) => + Effect.sync(() => { + if (url) { + getStore().set(DEFAULT_SERVER_URL_KEY, url) + return + } + getStore().delete(DEFAULT_SERVER_URL_KEY) }) - const wait = (async () => { - const url = `http://${hostname}:${port}` +export const getWslConfig = Effect.sync((): WslConfig => { + const value = getStore().get(WSL_ENABLED_KEY) + return { enabled: typeof value === "boolean" ? value : false } +}) - const ready = async () => { +export const setWslConfig = (config: WslConfig) => + Effect.sync(() => getStore().set(WSL_ENABLED_KEY, config.enabled)) + +export const spawnLocalServerEffect = Effect.fn("Server.spawnLocalServer")( + function* (hostname: string, port: number, password: string) { + yield* prepareServerEnv(password) + const { Log, Server } = yield* Effect.promise(() => + import("virtual:opencode-server") as Promise, + ) + yield* Effect.promise(() => Log.init({ level: "WARN" })) + const listener = yield* Effect.promise(() => + Server.listen({ + port, + hostname, + username: "opencode", + password, + cors: ["oc://renderer"], + }), + ) + + const healthCheck = Effect.gen(function* () { + const url = `http://${hostname}:${port}` while (true) { - await new Promise((resolve) => setTimeout(resolve, 100)) - if (await checkHealth(url, password)) return + const healthy = yield* checkHealthEffect(url, password) + if (healthy) return + yield* Effect.sleep("100 millis") } + }) + + return { listener, health: healthCheck } + }, +) + +const prepareServerEnv = (password: string) => + Effect.sync(() => { + const shell = process.platform === "win32" ? null : getUserShell() + const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {} + const env = { + ...process.env, + ...shellEnv, + OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", + OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", + OPENCODE_CLIENT: "desktop", + OPENCODE_SERVER_USERNAME: "opencode", + OPENCODE_SERVER_PASSWORD: password, + XDG_STATE_HOME: app.getPath("userData"), } + Object.assign(process.env, env) + }) - await ready() - })() - - return { listener, health: { wait } } -} - -function prepareServerEnv(password: string) { - const shell = process.platform === "win32" ? null : getUserShell() - const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {} - const env = { - ...process.env, - ...shellEnv, - OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", - OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", - OPENCODE_CLIENT: "desktop", - OPENCODE_SERVER_USERNAME: "opencode", - OPENCODE_SERVER_PASSWORD: password, - XDG_STATE_HOME: app.getPath("userData"), - } - Object.assign(process.env, env) -} - -export async function checkHealth(url: string, password?: string | null): Promise { +export const checkHealthEffect = Effect.fn("Server.checkHealth")(function* (url: string, password?: string | null) { let healthUrl: URL try { healthUrl = new URL("/global/health", url) @@ -89,13 +90,15 @@ export async function checkHealth(url: string, password?: string | null): Promis } try { - const res = await fetch(healthUrl, { - method: "GET", - headers, - signal: AbortSignal.timeout(3000), - }) + const res = yield* Effect.promise(() => + fetch(healthUrl, { + method: "GET", + headers, + signal: AbortSignal.timeout(3000), + }), + ) return res.ok } catch { return false } -} +}) diff --git a/packages/desktop-electron/src/main/windows.ts b/packages/desktop-electron/src/main/windows.ts index 337e1ca0bc..18f9bef8d1 100644 --- a/packages/desktop-electron/src/main/windows.ts +++ b/packages/desktop-electron/src/main/windows.ts @@ -20,15 +20,13 @@ protocol.registerSchemesAsPrivileged([ }, ]) -let backgroundColor: string | undefined +let backgroundColor: string | undefined = undefined -export function setBackgroundColor(color: string) { +export const setBackgroundColor = (color: string) => { backgroundColor = color } -export function getBackgroundColor(): string | undefined { - return backgroundColor -} +export const getBackgroundColor = () => backgroundColor function iconsDir() { return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons") @@ -52,7 +50,7 @@ function overlay(theme: Partial = {}) { } } -export function setTitlebar(win: BrowserWindow, theme: Partial = {}) { +export const setTitlebar = (win: BrowserWindow, theme: Partial = {}) => { if (process.platform !== "win32") return win.setTitleBarOverlay(overlay(theme)) } @@ -70,6 +68,7 @@ export function createMainWindow() { }) const mode = tone() + const bg = getBackgroundColor() const win = new BrowserWindow({ x: state.x, y: state.y, @@ -78,7 +77,7 @@ export function createMainWindow() { show: false, title: "OpenCode", icon: iconPath(), - backgroundColor, + backgroundColor: bg, ...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const, @@ -126,6 +125,7 @@ export function createMainWindow() { export function createLoadingWindow() { const mode = tone() + const bg = getBackgroundColor() const win = new BrowserWindow({ width: 640, height: 480, @@ -133,7 +133,7 @@ export function createLoadingWindow() { center: true, show: true, icon: iconPath(), - backgroundColor, + backgroundColor: bg, ...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}), ...(process.platform === "win32" ? { diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index 6e22954d18..5a0fc478dc 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -1,4 +1,11 @@ -export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" } | { phase: "done" } +import { Data } from "effect" + +export type InitStep = Data.TaggedEnum<{ + ServerWaiting: {} + SqliteWaiting: {} + Done: {} +}> +export const InitStep = Data.taggedEnum() export type ServerReadyData = { url: string diff --git a/packages/desktop-electron/src/renderer/loading.tsx b/packages/desktop-electron/src/renderer/loading.tsx index 000057e0a8..7baa10c094 100644 --- a/packages/desktop-electron/src/renderer/loading.tsx +++ b/packages/desktop-electron/src/renderer/loading.tsx @@ -17,14 +17,14 @@ render(() => { const [line, setLine] = createSignal(0) const [percent, setPercent] = createSignal(0) - const phase = createMemo(() => step()?.phase) + const phase = createMemo(() => step()?._tag) const value = createMemo(() => { - if (phase() === "done") return 100 + if (phase() === "Done") return 100 return Math.max(25, Math.min(100, percent())) }) - window.api.awaitInitialization((next) => setStep(next as InitStep)).catch(() => undefined) + window.api.awaitInitialization((next) => setStep(next)).catch(() => undefined) onMount(() => { setLine(0) @@ -36,7 +36,7 @@ render(() => { if (progress.type === "InProgress") setPercent(Math.max(0, Math.min(100, progress.value))) if (progress.type === "Done") { setPercent(100) - setStep({ phase: "done" }) + setStep({ _tag: "Done" }) } }) @@ -47,15 +47,15 @@ render(() => { }) createEffect(() => { - if (phase() !== "done") return + if (phase() !== "Done") return const timer = setTimeout(() => window.api.loadingWindowComplete(), 1000) onCleanup(() => clearTimeout(timer)) }) const status = createMemo(() => { - if (phase() === "done") return "All done" - if (phase() === "sqlite_waiting") return lines[line()] + if (phase() === "Done") return "All done" + if (phase() === "SqliteWaiting") return lines[line()] return "Just a moment..." }) From 41cf0f36bd70a3649633e9a2c5f10d6d0768c05e Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 4 May 2026 16:41:06 +0800 Subject: [PATCH 2/3] cleanup --- packages/desktop-electron/src/main/index.ts | 174 +++++++++----------- 1 file changed, 74 insertions(+), 100 deletions(-) diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 1e3c121b8f..463989e0d4 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -54,12 +54,12 @@ import { // State — individual pieces, synchronously allocated at module load. // --------------------------------------------------------------------------- -const initStep = Effect.runSync(SubscriptionRef.make({ _tag: "ServerWaiting" })) -const serverReady = Effect.runSync(Deferred.make()) -const loadingComplete = Effect.runSync(Deferred.make()) +const initStep = Effect.runSync(SubscriptionRef.make(InitStep.ServerWaiting())) +const serverReady = Deferred.makeUnsafe() +const loadingComplete = Deferred.makeUnsafe() const deepLinkQueue = Effect.runSync(Queue.unbounded()) -const deepLinksConsumed = Effect.runSync(Deferred.make()) -const server = Effect.runSync(Ref.make>(Option.none())) +const deepLinksConsumed = Deferred.makeUnsafe() +const server = Ref.makeUnsafe>(Option.none()) const menuCommands = Effect.runSync(PubSub.unbounded()) const sqliteProgress = Effect.runSync(PubSub.unbounded()) @@ -119,13 +119,8 @@ const stopServer = (ref: Ref.Ref>) => } }) -// --------------------------------------------------------------------------- -// Initialization flow (pure Effect — all state wired explicitly) -// --------------------------------------------------------------------------- - const initialize = Effect.fn("Main.initialize")(function* () { - const needsMigration = !(yield* sqliteFileExists) - const sqliteDone = needsMigration ? yield* Deferred.make() : undefined + const needsMigration = !sqliteFileExists() const port = yield* getSidecarPort const hostname = "127.0.0.1" @@ -135,36 +130,29 @@ const initialize = Effect.fn("Main.initialize")(function* () { const loadingFiber = yield* Effect.gen(function* () { logger.log("sidecar connection started", { url }) - if (needsMigration && sqliteDone) { - yield* Effect.gen(function* () { - const { Database, JsonMigration } = yield* Effect.promise( - () => import("virtual:opencode-server") as Promise, - ) - const client = yield* Effect.sync(() => Database.Client().$client) - const db = yield* Effect.promise(() => - import("drizzle-orm/node-sqlite/driver").then((m) => m.drizzle({ client })), - ) + if (needsMigration) { + const { Database, JsonMigration } = yield* Effect.promise( + () => import("virtual:opencode-server") as Promise, + ) + const client = Database.Client().$client + const db = yield* Effect.promise(() => + import("drizzle-orm/node-sqlite/driver").then((m) => m.drizzle({ client })), + ) - yield* SubscriptionRef.set(initStep, InitStep.SqliteWaiting()) + yield* SubscriptionRef.set(initStep, InitStep.SqliteWaiting()) - yield* Effect.promise(() => - JsonMigration.run(db, { - progress: (event: { current: number; total: number }) => { - const percent = Math.round((event.current / event.total) * 100) - const progress: SqliteMigrationProgress = { type: "InProgress", value: percent } - if (Option.isSome(overlay)) sendSqliteMigrationProgress(overlay.value, progress) - void Effect.runPromise(PubSub.publish(sqliteProgress, progress)) - }, - }), - ) + yield* Effect.promise(() => + JsonMigration.run(db, { + progress: (event: { current: number; total: number }) => { + const percent = Math.round((event.current / event.total) * 100) + const progress: SqliteMigrationProgress = { type: "InProgress", value: percent } + if (Option.isSome(overlay)) sendSqliteMigrationProgress(overlay.value, progress) + void Effect.runPromise(PubSub.publish(sqliteProgress, progress)) + }, + }), + ) - yield* PubSub.publish(sqliteProgress, { type: "Done" }) - yield* Deferred.succeed(sqliteDone, undefined) - }) - } - - if (needsMigration && sqliteDone) { - yield* Deferred.await(sqliteDone) + yield* PubSub.publish(sqliteProgress, { type: "Done" }) } logger.log("spawning sidecar", { url }) @@ -201,16 +189,13 @@ const initialize = Effect.fn("Main.initialize")(function* () { return overlay }).pipe(Effect.map(Option.fromNullishOr)) - const listener = yield* Fiber.join(loadingFiber) + yield* Fiber.join(loadingFiber) yield* SubscriptionRef.set(initStep, InitStep.Done()) - yield* Option.match(overlay, { - onSome: Effect.fnUntraced(function* (overlay) { - yield* Deferred.await(loadingComplete) - overlay.close() - }), - onNone: () => Effect.void, - }) + if (Option.isSome(overlay)) { + yield* Deferred.await(loadingComplete) + overlay.value.close() + } }) // --------------------------------------------------------------------------- @@ -219,7 +204,7 @@ const initialize = Effect.fn("Main.initialize")(function* () { const logger = initLogging() -const shutdownEffect = Effect.gen(function* () { +const shutdown = Effect.gen(function* () { yield* stopServer(server) app.exit(0) }) @@ -244,7 +229,7 @@ const registerAppEventListeners = (appEvents: PubSub.PubSub) => () => for (const signal of ["SIGINT", "SIGTERM"] as const) { process.on(signal, () => { - void Effect.runPromise(shutdownEffect) + void Effect.runPromise(shutdown) }) } } @@ -293,30 +278,30 @@ const main = Effect.gen(function* () { setDockIcon() setupAutoUpdater() - registerIpcHandlersEffect() + registerIpcHandlersImpl() yield* initialize() const mainWindow = createMainWindow() wireMenu(mainWindow) - yield* Stream.fromPubSub(appEvents).pipe( - Stream.runForEach((event) => handleAppEvent(event, deepLinkQueue, mainWindow, server)), - Effect.forkChild, - ) - - yield* Deferred.await(deepLinksConsumed).pipe( - Effect.andThen( - Stream.fromQueue(deepLinkQueue).pipe( - Stream.runForEach((urls) => Effect.sync(() => sendDeepLinks(mainWindow, urls))), + yield* Effect.all( + [ + Stream.fromPubSub(appEvents).pipe( + Stream.runForEach((event) => handleAppEvent(event, deepLinkQueue, mainWindow, server)), ), - ), - Effect.forkChild, - ) - - yield* Stream.fromPubSub(menuCommands).pipe( - Stream.runForEach((id) => Effect.sync(() => sendMenuCommand(mainWindow, id))), - Effect.forkChild, + Deferred.await(deepLinksConsumed).pipe( + Effect.andThen( + Stream.fromQueue(deepLinkQueue).pipe( + Stream.runForEach((urls) => Effect.sync(() => sendDeepLinks(mainWindow, urls))), + ), + ), + ), + Stream.fromPubSub(menuCommands).pipe( + Stream.runForEach((id) => Effect.sync(() => sendMenuCommand(mainWindow, id))), + ), + ], + { concurrency: "unbounded" }, ) }).pipe( Effect.catch((error) => @@ -329,10 +314,6 @@ const main = Effect.gen(function* () { void Effect.runPromise(main) -// --------------------------------------------------------------------------- -// Menu wiring -// --------------------------------------------------------------------------- - const wireMenu = (win: BrowserWindow) => { createMenu({ trigger: (id) => { @@ -354,31 +335,26 @@ const wireMenu = (win: BrowserWindow) => { }) } -// --------------------------------------------------------------------------- -// IPC handlers -// --------------------------------------------------------------------------- - -const registerIpcHandlersEffect = () => +const registerIpcHandlersImpl = () => registerIpcHandlers({ killSidecar: () => Effect.runPromise(stopServer(server)), awaitInitialization: (sendStep) => Effect.runPromise( Effect.gen(function* () { - const currentStep = SubscriptionRef.getUnsafe(initStep) + const currentStep = yield* SubscriptionRef.get(initStep) sendStep(currentStep) - const fiber = yield* SubscriptionRef.changes(initStep).pipe( + yield* SubscriptionRef.changes(initStep).pipe( Stream.runForEach((step) => Effect.sync(() => sendStep(step))), - Effect.forkChild, + Effect.forkScoped, ) logger.log("awaiting server ready") const res = yield* Deferred.await(serverReady) logger.log("server ready", { url: res.url }) - yield* Fiber.interrupt(fiber) return res - }), + }).pipe(Effect.scoped), ), getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }), consumeInitialDeepLinks: () => @@ -405,38 +381,36 @@ const registerIpcHandlersEffect = () => setBackgroundColor, }) -// --------------------------------------------------------------------------- -// Utilities -// --------------------------------------------------------------------------- - -const getSidecarPort = Effect.promise(() => { +const getSidecarPort = Effect.gen(function* () { const fromEnv = process.env.OPENCODE_PORT if (fromEnv) { const parsed = Number.parseInt(fromEnv, 10) - if (!Number.isNaN(parsed)) return Promise.resolve(parsed) + if (!Number.isNaN(parsed)) return parsed } - return new Promise((resolve, reject) => { - const server = createServer() - server.on("error", reject) - server.listen(0, "127.0.0.1", () => { - const address = server.address() - if (typeof address !== "object" || !address) { - server.close() - reject(new Error("Failed to get port")) - return - } - const port = address.port - server.close(() => resolve(port)) - }) + const deferred = yield* Deferred.make() + + const server = createServer() + server.on("error", (e) => Deferred.failSync(deferred, () => e.toString())) + server.listen(0, "127.0.0.1", () => { + const address = server.address() + if (typeof address !== "object" || !address) { + server.close() + Deferred.failSync(deferred, () => "Failed to get port") + return + } + const port = address.port + server.close(() => Effect.runSync(Deferred.succeed(deferred, port))) }) + + return yield* Deferred.await(deferred) }) -const sqliteFileExists = Effect.sync(() => { +const sqliteFileExists = () => { const xdg = process.env.XDG_DATA_HOME const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share") return existsSync(join(base, "opencode", "opencode.db")) -}) +} function setupAutoUpdater() { if (!UPDATER_ENABLED) return From 36d897392582cf659f01ce03429c541741408f8a Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 4 May 2026 17:02:43 +0800 Subject: [PATCH 3/3] platform-node --- bun.lock | 1 + packages/desktop-electron/package.json | 1 + packages/desktop-electron/src/main/index.ts | 12 +- packages/desktop-electron/src/main/server.ts | 136 +++++++++---------- 4 files changed, 76 insertions(+), 74 deletions(-) diff --git a/bun.lock b/bun.lock index 3cf2d9ce99..4f40c58c78 100644 --- a/bun.lock +++ b/bun.lock @@ -265,6 +265,7 @@ "name": "@opencode-ai/desktop-electron", "version": "1.14.34", "dependencies": { + "@effect/platform-node": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", "electron-context-menu": "4.1.2", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 8a6fcf5786..c3c95ec46b 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -25,6 +25,7 @@ "main": "./out/main/index.js", "dependencies": { "effect": "catalog:", + "@effect/platform-node": "catalog:", "electron-context-menu": "4.1.2", "electron-log": "^5", "electron-store": "^10", diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 463989e0d4..4f9857833a 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -4,6 +4,8 @@ import { createServer } from "node:net" import { homedir } from "node:os" import { join } from "node:path" import { app, BrowserWindow, dialog } from "electron" +import * as NodeRuntime from "@effect/platform-node/NodeRuntime" +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient" import pkg from "electron-updater" import { Data, Deferred, Effect, Fiber, Option, PubSub, Queue, Ref, Stream, SubscriptionRef } from "effect" @@ -312,7 +314,7 @@ const main = Effect.gen(function* () { ), ) -void Effect.runPromise(main) +main.pipe(Effect.provide(NodeHttpClient.layerFetch), NodeRuntime.runMain()) const wireMenu = (win: BrowserWindow) => { createMenu({ @@ -364,10 +366,10 @@ const registerIpcHandlersImpl = () => Effect.tap(() => Deferred.succeed(deepLinksConsumed, undefined)), ), ), - getDefaultServerUrl: () => Effect.runPromise(getDefaultServerUrl), - setDefaultServerUrl: (url) => Effect.runPromise(setDefaultServerUrl(url)), - getWslConfig: () => Effect.runPromise(getWslConfig), - setWslConfig: (config: WslConfig) => Effect.runPromise(setWslConfig(config)), + getDefaultServerUrl: () => getDefaultServerUrl(), + setDefaultServerUrl: (url) => setDefaultServerUrl(url), + getWslConfig: () => Promise.resolve(getWslConfig()), + setWslConfig: (config: WslConfig) => setWslConfig(config), getDisplayBackend: () => Promise.resolve(null), setDisplayBackend: () => Promise.resolve(undefined), parseMarkdown: (markdown) => Promise.resolve(parseMarkdown(markdown)), diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index 75c62e7b58..2e8ac536e7 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -1,81 +1,83 @@ import { app } from "electron" -import { Effect } from "effect" +import { Effect, Option } from "effect" import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants" import { getUserShell, loadShellEnv } from "./shell-env" import { getStore } from "./store" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" export type WslConfig = { enabled: boolean } -export const getDefaultServerUrl = Effect.sync((): string | null => { +export const getDefaultServerUrl = (): string | null => { const value = getStore().get(DEFAULT_SERVER_URL_KEY) return typeof value === "string" ? value : null -}) +} -export const setDefaultServerUrl = (url: string | null) => - Effect.sync(() => { - if (url) { - getStore().set(DEFAULT_SERVER_URL_KEY, url) - return - } - getStore().delete(DEFAULT_SERVER_URL_KEY) - }) +export const setDefaultServerUrl = (url: string | null) => { + if (url) { + getStore().set(DEFAULT_SERVER_URL_KEY, url) + return + } + getStore().delete(DEFAULT_SERVER_URL_KEY) +} -export const getWslConfig = Effect.sync((): WslConfig => { +export const getWslConfig = (): WslConfig => { const value = getStore().get(WSL_ENABLED_KEY) return { enabled: typeof value === "boolean" ? value : false } -}) +} -export const setWslConfig = (config: WslConfig) => - Effect.sync(() => getStore().set(WSL_ENABLED_KEY, config.enabled)) +export const setWslConfig = (config: WslConfig) => getStore().set(WSL_ENABLED_KEY, config.enabled) -export const spawnLocalServerEffect = Effect.fn("Server.spawnLocalServer")( - function* (hostname: string, port: number, password: string) { - yield* prepareServerEnv(password) - const { Log, Server } = yield* Effect.promise(() => - import("virtual:opencode-server") as Promise, - ) - yield* Effect.promise(() => Log.init({ level: "WARN" })) - const listener = yield* Effect.promise(() => - Server.listen({ - port, - hostname, - username: "opencode", - password, - cors: ["oc://renderer"], - }), - ) +export const spawnLocalServerEffect = Effect.fn("Server.spawnLocalServer")(function* ( + hostname: string, + port: number, + password: string, +) { + prepareServerEnv(password) + const { Log, Server } = yield* Effect.promise( + () => import("virtual:opencode-server") as Promise, + ) + yield* Effect.promise(() => Log.init({ level: "WARN" })) + const listener = yield* Effect.promise(() => + Server.listen({ + port, + hostname, + username: "opencode", + password, + cors: ["oc://renderer"], + }), + ) - const healthCheck = Effect.gen(function* () { - const url = `http://${hostname}:${port}` - while (true) { - const healthy = yield* checkHealthEffect(url, password) - if (healthy) return - yield* Effect.sleep("100 millis") - } - }) - - return { listener, health: healthCheck } - }, -) - -const prepareServerEnv = (password: string) => - Effect.sync(() => { - const shell = process.platform === "win32" ? null : getUserShell() - const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {} - const env = { - ...process.env, - ...shellEnv, - OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", - OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", - OPENCODE_CLIENT: "desktop", - OPENCODE_SERVER_USERNAME: "opencode", - OPENCODE_SERVER_PASSWORD: password, - XDG_STATE_HOME: app.getPath("userData"), + const healthCheck = Effect.gen(function* () { + const url = `http://${hostname}:${port}` + while (true) { + const healthy = yield* checkHealthEffect(url, password) + if (healthy) return + yield* Effect.sleep("100 millis") } - Object.assign(process.env, env) }) + return { listener, health: healthCheck } +}) + +const prepareServerEnv = (password: string) => () => { + const shell = process.platform === "win32" ? null : getUserShell() + const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {} + const env = { + ...process.env, + ...shellEnv, + OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", + OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", + OPENCODE_CLIENT: "desktop", + OPENCODE_SERVER_USERNAME: "opencode", + OPENCODE_SERVER_PASSWORD: password, + XDG_STATE_HOME: app.getPath("userData"), + } + Object.assign(process.env, env) +} + export const checkHealthEffect = Effect.fn("Server.checkHealth")(function* (url: string, password?: string | null) { + const httpClient = yield* HttpClient.HttpClient + let healthUrl: URL try { healthUrl = new URL("/global/health", url) @@ -89,16 +91,12 @@ export const checkHealthEffect = Effect.fn("Server.checkHealth")(function* (url: headers.set("authorization", `Basic ${auth}`) } - try { - const res = yield* Effect.promise(() => - fetch(healthUrl, { - method: "GET", - headers, - signal: AbortSignal.timeout(3000), - }), + return yield* httpClient + .get(healthUrl, { headers }) + .pipe( + Effect.timeout("3 seconds"), + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.option, + Effect.map(Option.isSome), ) - return res.ok - } catch { - return false - } })