Compare commits

...
Sign in to create a new pull request.

4 commits

Author SHA1 Message Date
Brendan Allan
bf353e8c77
Merge branch 'dev' into brendan/desktop-electron-refactor 2026-05-05 13:49:38 +08:00
Brendan Allan
36d8973925
platform-node 2026-05-05 07:36:43 +08:00
Brendan Allan
41cf0f36bd
cleanup 2026-05-05 07:36:43 +08:00
Brendan Allan
1821714479
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
2026-05-05 07:36:42 +08:00
9 changed files with 372 additions and 296 deletions

View file

@ -230,6 +230,7 @@
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.14.35", "version": "1.14.35",
"dependencies": { "dependencies": {
"@effect/platform-node": "catalog:",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"electron-context-menu": "4.1.2", "electron-context-menu": "4.1.2",

View file

@ -25,6 +25,7 @@
"main": "./out/main/index.js", "main": "./out/main/index.js",
"dependencies": { "dependencies": {
"effect": "catalog:", "effect": "catalog:",
"@effect/platform-node": "catalog:",
"electron-context-menu": "4.1.2", "electron-context-menu": "4.1.2",
"electron-log": "^5", "electron-log": "^5",
"electron-store": "^10", "electron-store": "^10",

View file

@ -1,12 +1,13 @@
import { randomUUID } from "node:crypto" import { randomUUID } from "node:crypto"
import { EventEmitter } from "node:events"
import { existsSync } from "node:fs" import { existsSync } from "node:fs"
import { createServer } from "node:net" import { createServer } from "node:net"
import { homedir } from "node:os" import { homedir } from "node:os"
import { join } from "node:path" import { join } from "node:path"
import type { Event } from "electron"
import { app, BrowserWindow, dialog } from "electron" 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 pkg from "electron-updater"
import { Data, Deferred, Effect, Fiber, Option, PubSub, Queue, Ref, Stream, SubscriptionRef } from "effect"
import contextMenu from "electron-context-menu" import contextMenu from "electron-context-menu"
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false }) contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
@ -34,14 +35,15 @@ app.setAppUserModelId(appId)
app.setPath("userData", join(app.getPath("appData"), appId)) app.setPath("userData", join(app.getPath("appData"), appId))
const { autoUpdater } = pkg 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 { checkAppExists, resolveAppPath, wslPath } from "./apps"
import { CHANNEL, UPDATER_ENABLED } from "./constants" import { CHANNEL, UPDATER_ENABLED } from "./constants"
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc" import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
import { initLogging } from "./logging" import { initLogging } from "./logging"
import { parseMarkdown } from "./markdown" import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu" import { createMenu } from "./menu"
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServerEffect } from "./server"
import { import {
createLoadingWindow, createLoadingWindow,
createMainWindow, createMainWindow,
@ -49,233 +51,188 @@ import {
setBackgroundColor, setBackgroundColor,
setDockIcon, setDockIcon,
} from "./windows" } from "./windows"
import { drizzle } from "drizzle-orm/node-sqlite/driver"
import type { Server } from "virtual:opencode-server"
import { migrate } from "./migrate"
const initEmitter = new EventEmitter() // ---------------------------------------------------------------------------
let initStep: InitStep = { phase: "server_waiting" } // State — individual pieces, synchronously allocated at module load.
// ---------------------------------------------------------------------------
let mainWindow: BrowserWindow | null = null const initStep = Effect.runSync(SubscriptionRef.make<InitStep>(InitStep.ServerWaiting()))
let server: Server.Listener | null = null const serverReady = Deferred.makeUnsafe<ServerReadyData>()
const loadingComplete = defer<void>() const loadingComplete = Deferred.makeUnsafe<void>()
const deepLinkQueue = Effect.runSync(Queue.unbounded<string[]>())
const deepLinksConsumed = Deferred.makeUnsafe<void>()
const server = Ref.makeUnsafe<Option.Option<Server.Listener>>(Option.none())
const menuCommands = Effect.runSync(PubSub.unbounded<string>())
const sqliteProgress = Effect.runSync(PubSub.unbounded<SqliteMigrationProgress>())
const pendingDeepLinks: string[] = [] // ---------------------------------------------------------------------------
// App events (Data.TaggedEnum)
// ---------------------------------------------------------------------------
const serverReady = defer<ServerReadyData>() type AppEvent = Data.TaggedEnum<{
const logger = initLogging() SecondInstance: { readonly argv: readonly string[] }
OpenUrl: { readonly url: string }
BeforeQuit: {}
WillQuit: {}
}>
logger.log("app starting", { const appEvent = Data.taggedEnum<AppEvent>()
version: app.getVersion(),
packaged: app.isPackaged,
})
setupApp() const handleAppEvent = (
event: AppEvent,
deepLinkQueue: Queue.Queue<string[]>,
mainWindow: BrowserWindow,
server: Ref.Ref<Option.Option<Server.Listener>>,
) =>
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() { const focusMainWindow = (win: BrowserWindow) => {
ensureLoopbackNoProxy() win.show()
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") win.focus()
if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222") }
if (!app.requestSingleInstanceLock()) { const stopServer = (ref: Ref.Ref<Option.Option<Server.Listener>>) =>
app.quit() Effect.gen(function* () {
return const srv = yield* Ref.get(ref)
} if (Option.isSome(srv)) {
yield* Effect.promise(() => srv.value.stop())
app.on("second-instance", (_event: Event, argv: string[]) => { yield* Ref.set(ref, Option.none())
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
if (urls.length) {
logger.log("deep link received via second-instance", { urls })
emitDeepLinks(urls)
} }
focusMainWindow()
}) })
app.on("open-url", (event: Event, url: string) => { const initialize = Effect.fn("Main.initialize")(function* () {
event.preventDefault()
logger.log("deep link received via open-url", { url })
emitDeepLinks([url])
})
app.on("before-quit", () => {
killSidecar()
})
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 () => {
migrate()
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 needsMigration = !sqliteFileExists()
const sqliteDone = needsMigration ? defer<void>() : undefined
let overlay: BrowserWindow | null = null
const port = await getSidecarPort() const port = yield* getSidecarPort
const hostname = "127.0.0.1" const hostname = "127.0.0.1"
const url = `http://${hostname}:${port}` const url = `http://${hostname}:${port}`
const password = randomUUID() const password = randomUUID()
const loadingTask = (async () => { const loadingFiber = yield* Effect.gen(function* () {
logger.log("sidecar connection started", { url }) 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) { if (needsMigration) {
const { Database, JsonMigration } = await import("virtual:opencode-server") const { Database, JsonMigration } = yield* Effect.promise(
await JsonMigration.run(drizzle({ client: Database.Client().$client }), { () => import("virtual:opencode-server") as Promise<typeof import("virtual:opencode-server")>,
progress: (event: { current: number; total: number }) => { )
const percent = Math.round(event.current / event.total) * 100 const client = Database.Client().$client
initEmitter.emit("sqlite", { type: "InProgress", value: percent }) const db = yield* Effect.promise(() =>
}, import("drizzle-orm/node-sqlite/driver").then((m) => m.drizzle({ client })),
}) )
initEmitter.emit("sqlite", { type: "Done" })
sqliteDone?.resolve() yield* SubscriptionRef.set(initStep, InitStep.SqliteWaiting())
}
if (needsMigration) { yield* Effect.promise(() =>
await sqliteDone?.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" })
} }
logger.log("spawning sidecar", { url }) logger.log("spawning sidecar", { url })
const { listener, health } = await spawnLocalServer(hostname, port, password) const { listener, health } = yield* spawnLocalServerEffect(hostname, port, password)
server = listener yield* Ref.set(server, Option.some(listener))
serverReady.resolve({
yield* Deferred.succeed(serverReady, {
url, url,
username: "opencode", username: "opencode",
password, password,
}) })
await Promise.race([ yield* Effect.raceAll([
health.wait, health,
delay(30_000).then(() => { Effect.sleep("30 seconds").pipe(Effect.flatMap(() => Effect.fail(new Error("Sidecar health check timed out")))),
throw new Error("Sidecar health check timed out") ]).pipe(Effect.catch((error) => Effect.sync(() => logger.error("sidecar health check failed", error))))
}),
]).catch((error) => {
logger.error("sidecar health check failed", error)
})
logger.log("loading task finished") logger.log("loading task finished")
})()
if (needsMigration) { return listener
const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)]) }).pipe(Effect.forkChild)
if (show) {
overlay = createLoadingWindow() const overlay = yield* Effect.gen(function* () {
await delay(1_000) if (!needsMigration) return
}
const show = yield* Effect.raceAll([
Fiber.join(loadingFiber).pipe(Effect.as(false)),
Effect.sleep("1 second").pipe(Effect.as(true)),
])
if (!show) return
const overlay = createLoadingWindow()
yield* Effect.sleep("1 second")
return overlay
}).pipe(Effect.map(Option.fromNullishOr))
yield* Fiber.join(loadingFiber)
yield* SubscriptionRef.set(initStep, InitStep.Done())
if (Option.isSome(overlay)) {
yield* Deferred.await(loadingComplete)
overlay.value.close()
} }
await loadingTask
setInitStep({ phase: "done" })
if (overlay) {
await loadingComplete.promise
}
mainWindow = createMainWindow()
wireMenu()
overlay?.close()
}
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)
},
})
}
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 // App lifecycle (imperative Electron shell, thin wrappers around Effects)
server.stop() // ---------------------------------------------------------------------------
server = null
const logger = initLogging()
const shutdown = Effect.gen(function* () {
yield* stopServer(server)
app.exit(0)
})
const registerAppEventListeners = (appEvents: PubSub.PubSub<AppEvent>) => () => {
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(shutdown)
})
}
} }
function ensureLoopbackNoProxy() { const ensureLoopbackNoProxy = () => {
const loopback = ["127.0.0.1", "localhost", "::1"] const loopback = ["127.0.0.1", "localhost", "::1"]
const upsert = (key: string) => { const upsert = (key: string) => {
const items = (process.env[key] ?? "") const items = (process.env[key] ?? "")
@ -295,30 +252,159 @@ function ensureLoopbackNoProxy() {
upsert("no_proxy") 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<AppEvent>()
registerAppEventListeners(appEvents)
yield* Effect.promise(() => app.whenReady())
app.setAsDefaultProtocolClient("opencode")
registerRendererProtocol()
setDockIcon()
setupAutoUpdater()
registerIpcHandlersImpl()
yield* initialize()
const mainWindow = createMainWindow()
wireMenu(mainWindow)
yield* Effect.all(
[
Stream.fromPubSub(appEvents).pipe(
Stream.runForEach((event) => handleAppEvent(event, deepLinkQueue, mainWindow, server)),
),
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) =>
Effect.sync(() => {
logger.error("initialization failed", error)
app.exit(1)
}),
),
)
main.pipe(Effect.provide(NodeHttpClient.layerFetch), NodeRuntime.runMain())
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)
}),
)
},
})
}
const registerIpcHandlersImpl = () =>
registerIpcHandlers({
killSidecar: () => Effect.runPromise(stopServer(server)),
awaitInitialization: (sendStep) =>
Effect.runPromise(
Effect.gen(function* () {
const currentStep = yield* SubscriptionRef.get(initStep)
sendStep(currentStep)
yield* SubscriptionRef.changes(initStep).pipe(
Stream.runForEach((step) => Effect.sync(() => sendStep(step))),
Effect.forkScoped,
)
logger.log("awaiting server ready")
const res = yield* Deferred.await(serverReady)
logger.log("server ready", { url: res.url })
return res
}).pipe(Effect.scoped),
),
getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }),
consumeInitialDeepLinks: () =>
Effect.runPromise(
Queue.clear(deepLinkQueue).pipe(
Effect.map((links) => links.flat()),
Effect.tap(() => Deferred.succeed(deepLinksConsumed, undefined)),
),
),
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)),
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,
})
const getSidecarPort = Effect.gen(function* () {
const fromEnv = process.env.OPENCODE_PORT const fromEnv = process.env.OPENCODE_PORT
if (fromEnv) { if (fromEnv) {
const parsed = Number.parseInt(fromEnv, 10) const parsed = Number.parseInt(fromEnv, 10)
if (!Number.isNaN(parsed)) return parsed if (!Number.isNaN(parsed)) return parsed
} }
return await new Promise<number>((resolve, reject) => { const deferred = yield* Deferred.make<number, string>()
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))
})
})
}
function sqliteFileExists() { 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 = () => {
const xdg = process.env.XDG_DATA_HOME const xdg = process.env.XDG_DATA_HOME
const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share") const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share")
return existsSync(join(base, "opencode", "opencode.db")) return existsSync(join(base, "opencode", "opencode.db"))
@ -393,7 +479,7 @@ async function installUpdate() {
logger.log("installing downloaded update", { logger.log("installing downloaded update", {
version: downloadedUpdateVersion, version: downloadedUpdateVersion,
}) })
killSidecar() void Effect.runPromise(stopServer(server))
autoUpdater.quitAndInstall() autoUpdater.quitAndInstall()
} }
@ -439,17 +525,3 @@ async function checkForUpdates(alertOnFail: boolean) {
await installUpdate() await installUpdate()
} }
} }
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function defer<T>() {
let resolve!: (value: T) => void
let reject!: (error: Error) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}

View file

@ -196,14 +196,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) win.webContents.send("sqlite-migration-progress", progress)
} 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)
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)
}

View file

@ -1,5 +1,4 @@
import { Menu, shell } from "electron" import { Menu, shell } from "electron"
import { UPDATER_ENABLED } from "./constants" import { UPDATER_ENABLED } from "./constants"
import { createMainWindow } from "./windows" import { createMainWindow } from "./windows"
@ -47,7 +46,9 @@ export function createMenu(deps: Deps) {
{ {
label: "New Window", label: "New Window",
accelerator: "Cmd+Shift+N", accelerator: "Cmd+Shift+N",
click: () => createMainWindow(), click: () => {
void createMainWindow()
},
}, },
{ type: "separator" }, { type: "separator" },
{ role: "close" }, { role: "close" },

View file

@ -1,64 +1,65 @@
import { app } from "electron" import { app } from "electron"
import { Effect, Option } from "effect"
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants" import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
import { getUserShell, loadShellEnv } from "./shell-env" import { getUserShell, loadShellEnv } from "./shell-env"
import { getStore } from "./store" import { getStore } from "./store"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
export type WslConfig = { enabled: boolean } export type WslConfig = { enabled: boolean }
export type HealthCheck = { wait: Promise<void> } export const getDefaultServerUrl = (): string | null => {
export function getDefaultServerUrl(): string | null {
const value = getStore().get(DEFAULT_SERVER_URL_KEY) const value = getStore().get(DEFAULT_SERVER_URL_KEY)
return typeof value === "string" ? value : null return typeof value === "string" ? value : null
} }
export function setDefaultServerUrl(url: string | null) { export const setDefaultServerUrl = (url: string | null) => {
if (url) { if (url) {
getStore().set(DEFAULT_SERVER_URL_KEY, url) getStore().set(DEFAULT_SERVER_URL_KEY, url)
return return
} }
getStore().delete(DEFAULT_SERVER_URL_KEY) getStore().delete(DEFAULT_SERVER_URL_KEY)
} }
export function getWslConfig(): WslConfig { export const getWslConfig = (): WslConfig => {
const value = getStore().get(WSL_ENABLED_KEY) const value = getStore().get(WSL_ENABLED_KEY)
return { enabled: typeof value === "boolean" ? value : false } return { enabled: typeof value === "boolean" ? value : false }
} }
export function setWslConfig(config: WslConfig) { export const setWslConfig = (config: WslConfig) => getStore().set(WSL_ENABLED_KEY, config.enabled)
getStore().set(WSL_ENABLED_KEY, config.enabled)
}
export async function spawnLocalServer(hostname: string, port: number, password: string) { export const spawnLocalServerEffect = Effect.fn("Server.spawnLocalServer")(function* (
hostname: string,
port: number,
password: string,
) {
prepareServerEnv(password) prepareServerEnv(password)
const { Log, Server } = await import("virtual:opencode-server") const { Log, Server } = yield* Effect.promise(
await Log.init({ level: "WARN" }) () => import("virtual:opencode-server") as Promise<typeof import("virtual:opencode-server")>,
const listener = await Server.listen({ )
port, yield* Effect.promise(() => Log.init({ level: "WARN" }))
hostname, const listener = yield* Effect.promise(() =>
username: "opencode", Server.listen({
password, port,
cors: ["oc://renderer"], 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")
}
}) })
const wait = (async () => { return { listener, health: healthCheck }
const url = `http://${hostname}:${port}` })
const ready = async () => { const prepareServerEnv = (password: string) => () => {
while (true) {
await new Promise((resolve) => setTimeout(resolve, 100))
if (await checkHealth(url, password)) return
}
}
await ready()
})()
return { listener, health: { wait } }
}
function prepareServerEnv(password: string) {
const shell = process.platform === "win32" ? null : getUserShell() const shell = process.platform === "win32" ? null : getUserShell()
const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {} const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {}
const env = { const env = {
@ -74,7 +75,9 @@ function prepareServerEnv(password: string) {
Object.assign(process.env, env) Object.assign(process.env, env)
} }
export async function checkHealth(url: string, password?: string | null): Promise<boolean> { export const checkHealthEffect = Effect.fn("Server.checkHealth")(function* (url: string, password?: string | null) {
const httpClient = yield* HttpClient.HttpClient
let healthUrl: URL let healthUrl: URL
try { try {
healthUrl = new URL("/global/health", url) healthUrl = new URL("/global/health", url)
@ -88,14 +91,12 @@ export async function checkHealth(url: string, password?: string | null): Promis
headers.set("authorization", `Basic ${auth}`) headers.set("authorization", `Basic ${auth}`)
} }
try { return yield* httpClient
const res = await fetch(healthUrl, { .get(healthUrl, { headers })
method: "GET", .pipe(
headers, Effect.timeout("3 seconds"),
signal: AbortSignal.timeout(3000), Effect.flatMap(HttpClientResponse.filterStatusOk),
}) Effect.option,
return res.ok Effect.map(Option.isSome),
} catch { )
return false })
}
}

View file

@ -24,13 +24,11 @@ let backgroundColor: string | undefined
const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>() const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
const titlebarHeight = 40 const titlebarHeight = 40
export function setBackgroundColor(color: string) { export const setBackgroundColor = (color: string) => {
backgroundColor = color backgroundColor = color
} }
export function getBackgroundColor(): string | undefined { export const getBackgroundColor = () => backgroundColor
return backgroundColor
}
function iconsDir() { function iconsDir() {
return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons") return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons")
@ -77,6 +75,7 @@ export function createMainWindow() {
}) })
const mode = tone() const mode = tone()
const bg = getBackgroundColor()
const win = new BrowserWindow({ const win = new BrowserWindow({
x: state.x, x: state.x,
y: state.y, y: state.y,
@ -85,7 +84,7 @@ export function createMainWindow() {
show: false, show: false,
title: "OpenCode", title: "OpenCode",
icon: iconPath(), icon: iconPath(),
backgroundColor, backgroundColor: bg,
...(process.platform === "darwin" ...(process.platform === "darwin"
? { ? {
titleBarStyle: "hidden" as const, titleBarStyle: "hidden" as const,
@ -133,6 +132,7 @@ export function createMainWindow() {
export function createLoadingWindow() { export function createLoadingWindow() {
const mode = tone() const mode = tone()
const bg = getBackgroundColor()
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 640, width: 640,
height: 480, height: 480,
@ -140,7 +140,7 @@ export function createLoadingWindow() {
center: true, center: true,
show: true, show: true,
icon: iconPath(), icon: iconPath(),
backgroundColor, backgroundColor: bg,
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}), ...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
...(process.platform === "win32" ...(process.platform === "win32"
? { ? {

View file

@ -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<InitStep>()
export type ServerReadyData = { export type ServerReadyData = {
url: string url: string

View file

@ -17,14 +17,14 @@ render(() => {
const [line, setLine] = createSignal(0) const [line, setLine] = createSignal(0)
const [percent, setPercent] = createSignal(0) const [percent, setPercent] = createSignal(0)
const phase = createMemo(() => step()?.phase) const phase = createMemo(() => step()?._tag)
const value = createMemo(() => { const value = createMemo(() => {
if (phase() === "done") return 100 if (phase() === "Done") return 100
return Math.max(25, Math.min(100, percent())) 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(() => { onMount(() => {
setLine(0) setLine(0)
@ -36,7 +36,7 @@ render(() => {
if (progress.type === "InProgress") setPercent(Math.max(0, Math.min(100, progress.value))) if (progress.type === "InProgress") setPercent(Math.max(0, Math.min(100, progress.value)))
if (progress.type === "Done") { if (progress.type === "Done") {
setPercent(100) setPercent(100)
setStep({ phase: "done" }) setStep({ _tag: "Done" })
} }
}) })
@ -47,15 +47,15 @@ render(() => {
}) })
createEffect(() => { createEffect(() => {
if (phase() !== "done") return if (phase() !== "Done") return
const timer = setTimeout(() => window.api.loadingWindowComplete(), 1000) const timer = setTimeout(() => window.api.loadingWindowComplete(), 1000)
onCleanup(() => clearTimeout(timer)) onCleanup(() => clearTimeout(timer))
}) })
const status = createMemo(() => { const status = createMemo(() => {
if (phase() === "done") return "All done" if (phase() === "Done") return "All done"
if (phase() === "sqlite_waiting") return lines[line()] if (phase() === "SqliteWaiting") return lines[line()]
return "Just a moment..." return "Just a moment..."
}) })