From 18217144792daf42343a6598cef2ad2802896d57 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 1 May 2026 19:18:45 +0800 Subject: [PATCH] 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..." })