diff --git a/bun.lock b/bun.lock index 04c79c171b7..883b4290d91 100644 --- a/bun.lock +++ b/bun.lock @@ -414,6 +414,7 @@ "name": "@opencode-ai/desktop", "version": "1.18.15", "dependencies": { + "@effect/platform-node": "catalog:", "@zip.js/zip.js": "2.7.62", "drizzle-orm": "catalog:", "effect": "catalog:", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 4cae0fe87f4..3f2192c4e15 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -23,6 +23,7 @@ }, "main": "./out/main/index.js", "dependencies": { + "@effect/platform-node": "catalog:", "@zip.js/zip.js": "2.7.62", "effect": "catalog:", "electron-context-menu": "4.1.2", diff --git a/packages/desktop/src/main/files/apps.ts b/packages/desktop/src/main/files/apps.ts index 1aec1923606..8981f2838dd 100644 --- a/packages/desktop/src/main/files/apps.ts +++ b/packages/desktop/src/main/files/apps.ts @@ -1,103 +1,100 @@ import { execFile } from "node:child_process" -import { access, readFile, readdir } from "node:fs/promises" -import { dirname, extname, join } from "node:path" import util from "node:util" +import { Effect, FileSystem, Path } from "effect" const execFilePromise = util.promisify(execFile) -const exists = (path: string) => - access(path) - .then(() => true) - .catch(() => false) - -export function checkAppExists(appName: string) { +export const checkAppExists = Effect.fn("DesktopFiles.checkAppExists")(function* (appName: string) { if (process.platform === "win32") return true if (process.platform === "linux") return true - return checkMacosApp(appName) -} + return yield* checkMacosApp(appName) +}) -export function resolveAppPath(appName: string) { +export const resolveAppPath = Effect.fn("DesktopFiles.resolveAppPath")(function* (appName: string) { if (process.platform !== "win32") return appName - return resolveWindowsAppPath(appName) -} + return yield* resolveWindowsAppPath(appName) +}) -async function checkMacosApp(appName: string) { +const checkMacosApp = Effect.fn("DesktopFiles.checkMacosApp")(function* (appName: string) { + const fs = yield* FileSystem.FileSystem const locations = [`/Applications/${appName}.app`, `/System/Applications/${appName}.app`] const home = process.env.HOME if (home) locations.push(`${home}/Applications/${appName}.app`) for (const location of locations) { - if (await exists(location)) return true + if (yield* exists(fs, location)) return true } - return execFilePromise("which", [appName]) - .then(() => true) - .catch(() => false) -} + return yield* Effect.tryPromise(() => execFilePromise("which", [appName])).pipe( + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), + ) +}) -async function resolveWindowsAppPath(appName: string): Promise { - let output: string - try { - output = await execFilePromise("where", [appName]).then((r) => r.stdout.toString()) - } catch { - return null - } +const resolveWindowsAppPath = Effect.fn("DesktopFiles.resolveWindowsAppPath")(function* (appName: string) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const result = yield* Effect.tryPromise(() => execFilePromise("where", [appName])).pipe( + Effect.catch(() => Effect.succeed(undefined)), + ) + if (!result) return null - const paths = output + const paths = result.stdout + .toString() .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line.length > 0) - const hasExt = (path: string, ext: string) => extname(path).toLowerCase() === `.${ext}` + const hasExt = (value: string, ext: string) => path.extname(value).toLowerCase() === `.${ext}` const exe = paths.find((path) => hasExt(path, "exe")) if (exe) return exe - const resolveCmd = async (path: string) => { - const content = await readFile(path, "utf8") + const resolveCmd = Effect.fnUntraced(function* (file: string) { + const content = yield* fs.readFileString(file) for (const token of content.split('"').map((value: string) => value.trim())) { const lower = token.toLowerCase() if (!lower.includes(".exe")) continue const index = lower.indexOf("%~dp0") if (index >= 0) { - const base = dirname(path) + const base = path.dirname(file) const suffix = token.slice(index + 5) const resolved = suffix .replace(/\//g, "\\") .split("\\") .filter((part: string) => part && part !== ".") .reduce((current: string, part: string) => { - if (part === "..") return dirname(current) - return join(current, part) + if (part === "..") return path.dirname(current) + return path.join(current, part) }, base) - if (await exists(resolved)) return resolved + if (yield* exists(fs, resolved)) return resolved } - if (await exists(token)) return token + if (yield* exists(fs, token)) return token } return null - } + }) - for (const path of paths) { - if (hasExt(path, "cmd") || hasExt(path, "bat")) { - const resolved = await resolveCmd(path) + for (const file of paths) { + if (hasExt(file, "cmd") || hasExt(file, "bat")) { + const resolved = yield* resolveCmd(file) if (resolved) return resolved } - if (!extname(path)) { - const cmd = `${path}.cmd` - if (await exists(cmd)) { - const resolved = await resolveCmd(cmd) + if (!path.extname(file)) { + const cmd = `${file}.cmd` + if (yield* exists(fs, cmd)) { + const resolved = yield* resolveCmd(cmd) if (resolved) return resolved } - const bat = `${path}.bat` - if (await exists(bat)) { - const resolved = await resolveCmd(bat) + const bat = `${file}.bat` + if (yield* exists(fs, bat)) { + const resolved = yield* resolveCmd(bat) if (resolved) return resolved } } @@ -110,27 +107,31 @@ async function resolveWindowsAppPath(appName: string): Promise { .join("") if (key) { - for (const path of paths) { - const dirs = [dirname(path), dirname(dirname(path)), dirname(dirname(dirname(path)))] + for (const file of paths) { + const dirs = [path.dirname(file), path.dirname(path.dirname(file)), path.dirname(path.dirname(path.dirname(file)))] for (const dir of dirs) { - try { - for (const entry of await readdir(dir)) { - const candidate = join(dir, entry) - if (!hasExt(candidate, "exe")) continue - const stem = entry.replace(/\.exe$/i, "") - const name = stem - .split("") - .filter((value: string) => /[a-z0-9]/i.test(value)) - .map((value: string) => value.toLowerCase()) - .join("") - if (name.includes(key) || key.includes(name)) return candidate - } - } catch { - continue + const entries = yield* fs.readDirectory(dir).pipe(Effect.catch(() => Effect.succeed([]))) + for (const entry of entries) { + const candidate = path.join(dir, entry) + if (!hasExt(candidate, "exe")) continue + const stem = entry.replace(/\.exe$/i, "") + const name = stem + .split("") + .filter((value: string) => /[a-z0-9]/i.test(value)) + .map((value: string) => value.toLowerCase()) + .join("") + if (name.includes(key) || key.includes(name)) return candidate } } } } return paths[0] ?? null +}) + +function exists(fs: FileSystem.FileSystem, path: string) { + return fs.access(path).pipe( + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), + ) } diff --git a/packages/desktop/src/main/files/attachment-picker.test.ts b/packages/desktop/src/main/files/attachment-picker.test.ts index f2d5d6b56cb..c9fefcb6e5a 100644 --- a/packages/desktop/src/main/files/attachment-picker.test.ts +++ b/packages/desktop/src/main/files/attachment-picker.test.ts @@ -1,7 +1,9 @@ import { describe, expect, test } from "bun:test" +import { NodeFileSystem } from "@effect/platform-node" import { mkdtemp, rm, truncate, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" +import { Effect, FileSystem } from "effect" import { assertAttachmentBudget, createPickedFileAuthorizations, @@ -9,6 +11,9 @@ import { readAttachment, } from "./attachment-picker" +const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(NodeFileSystem.layer))) + describe("assertAttachmentBudget", () => { test("accepts selections within the media ingest limit", () => { expect(() => @@ -25,7 +30,7 @@ describe("assertAttachmentBudget", () => { const file = join(directory, "example.txt") try { await writeFile(file, "lorem ipsum") - expect(new TextDecoder().decode(await readAttachment(file))).toBe("lorem ipsum") + expect(new TextDecoder().decode(await run(readAttachment(file)))).toBe("lorem ipsum") } finally { await rm(directory, { recursive: true, force: true }) } @@ -37,7 +42,7 @@ describe("assertAttachmentBudget", () => { try { await writeFile(file, "") await truncate(file, MAX_ATTACHMENT_BYTES + 1) - await expect(readAttachment(file)).rejects.toThrow("20 MB limit") + await expect(run(readAttachment(file))).rejects.toThrow("20 MB limit") } finally { await rm(directory, { recursive: true, force: true }) } @@ -45,16 +50,16 @@ describe("assertAttachmentBudget", () => { }) describe("picked file authorizations", () => { - const read = async (path: string) => new TextEncoder().encode(path).buffer + const read = (path: string) => Effect.sync(() => new TextEncoder().encode(path).buffer) test("keeps concurrent picker selections isolated", async () => { const authorizations = createPickedFileAuthorizations(read) const first = authorizations.add(1, ["a.txt", "b.txt"]) const second = authorizations.add(1, ["c.txt"]) - expect(new TextDecoder().decode(await authorizations.read(1, first, "a.txt"))).toBe("a.txt") - expect(new TextDecoder().decode(await authorizations.read(1, second, "c.txt"))).toBe("c.txt") - expect(new TextDecoder().decode(await authorizations.read(1, first, "b.txt"))).toBe("b.txt") + expect(new TextDecoder().decode(await run(authorizations.read(1, first, "a.txt")))).toBe("a.txt") + expect(new TextDecoder().decode(await run(authorizations.read(1, second, "c.txt")))).toBe("c.txt") + expect(new TextDecoder().decode(await run(authorizations.read(1, first, "b.txt")))).toBe("b.txt") }) test("releases unread files for one picker without affecting another", async () => { @@ -63,25 +68,29 @@ describe("picked file authorizations", () => { const second = authorizations.add(1, ["b.txt"]) authorizations.release(1, first) - await expect(authorizations.read(1, first, "a.txt")).rejects.toThrow("not selected") - expect(new TextDecoder().decode(await authorizations.read(1, second, "b.txt"))).toBe("b.txt") + await expect(run(authorizations.read(1, first, "a.txt"))).rejects.toThrow("not selected") + expect(new TextDecoder().decode(await run(authorizations.read(1, second, "b.txt")))).toBe("b.txt") }) test("keeps picker tokens scoped to their renderer", async () => { const authorizations = createPickedFileAuthorizations(read) const token = authorizations.add(1, ["a.txt"]) - await expect(authorizations.read(2, token, "a.txt")).rejects.toThrow("not selected") + await expect(run(authorizations.read(2, token, "a.txt"))).rejects.toThrow("not selected") }) test("charges actual reads against the selection budget", async () => { - const authorizations = createPickedFileAuthorizations(async (_path, maxBytes) => { - if (6 > maxBytes) throw new Error("budget exceeded") - return new ArrayBuffer(6) - }, 10) + const authorizations = createPickedFileAuthorizations( + (_path, maxBytes) => + Effect.sync(() => { + if (6 > maxBytes) throw new Error("budget exceeded") + return new ArrayBuffer(6) + }), + 10, + ) const token = authorizations.add(1, ["a.txt", "b.txt"]) - await authorizations.read(1, token, "a.txt") - await expect(authorizations.read(1, token, "b.txt")).rejects.toThrow("budget exceeded") + await run(authorizations.read(1, token, "a.txt")) + await expect(run(authorizations.read(1, token, "b.txt"))).rejects.toThrow("budget exceeded") }) }) diff --git a/packages/desktop/src/main/files/attachment-picker.ts b/packages/desktop/src/main/files/attachment-picker.ts index 60caf3cf11b..99cedb6be56 100644 --- a/packages/desktop/src/main/files/attachment-picker.ts +++ b/packages/desktop/src/main/files/attachment-picker.ts @@ -1,11 +1,11 @@ import { randomUUID } from "node:crypto" -import { open } from "node:fs/promises" +import { Effect, FileSystem } from "effect" import { nativeT } from "../native/translations" export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 export function createPickedFileAuthorizations( - read: (path: string, maxBytes: number) => Promise = readAttachment, + read: (path: string, maxBytes: number) => Effect.Effect, budget = MAX_ATTACHMENT_BYTES, ) { const selections = new Map; remaining: number }>() @@ -16,15 +16,15 @@ export function createPickedFileAuthorizations( selections.set(token, { sender, paths: new Set(paths), remaining: budget }) return token }, - async read(sender: number, token: string, path: string) { + read: Effect.fn("DesktopFiles.readPickedFile")(function* (sender: number, token: string, path: string) { const selection = selections.get(token) if (selection?.sender !== sender || !selection.paths.delete(path)) throw new Error(nativeT("desktop.picker.error.notSelected")) - const bytes = await read(path, selection.remaining) + const bytes = yield* read(path, selection.remaining) selection.remaining -= bytes.byteLength if (selection.paths.size === 0) selections.delete(token) return bytes - }, + }), release(sender: number, token: string) { if (selections.get(token)?.sender === sender) selections.delete(token) }, @@ -37,21 +37,23 @@ export function assertAttachmentBudget(files: { size: number }[]) { throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 })) } -export async function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT_BYTES) { - const file = await open(filePath, "r") - try { - const info = await file.stat() - if (info.size > maxBytes) - throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 })) - const bytes = Buffer.allocUnsafe(info.size) - let offset = 0 - while (offset < info.size) { - const result = await file.read(bytes, offset, info.size - offset, offset) - if (result.bytesRead === 0) break - offset += result.bytesRead - } - return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + offset) as ArrayBuffer - } finally { - await file.close() - } +export function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT_BYTES) { + return Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const file = yield* fs.open(filePath, { flag: "r" }) + const info = yield* file.stat + if (info.size > FileSystem.Size(maxBytes)) + throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 })) + + const bytes = new Uint8Array(Number(info.size)) + let offset = 0 + while (offset < bytes.byteLength) { + const read = Number(yield* file.read(bytes.subarray(offset))) + if (read === 0) break + offset += read + } + return bytes.buffer.slice(0, offset) + }), + ) } diff --git a/packages/desktop/src/main/files/index.ts b/packages/desktop/src/main/files/index.ts index a01d6050324..b73a4f2ee04 100644 --- a/packages/desktop/src/main/files/index.ts +++ b/packages/desktop/src/main/files/index.ts @@ -1,69 +1,98 @@ +export * as DesktopFiles from "./index" + import { execFile } from "node:child_process" -import { stat } from "node:fs/promises" -import { basename } from "node:path" import { clipboard, dialog, shell } from "electron" +import { Context, Effect, FileSystem, Layer, Path } from "effect" import type { DirectoryPickerOptions, FilePickerOptions, SaveFilePickerOptions } from "../../shared/ipc-contract" -import { writeLog } from "../native/logging" +import { scoped } from "../native/logging" import { nativeT } from "../native/translations" -import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker" +import { assertAttachmentBudget, createPickedFileAuthorizations, readAttachment } from "./attachment-picker" import { resolveExternalURL, resolveLocalFilePath } from "./external-url" -export function createFileCapabilities() { - const pickedFiles = createPickedFileAuthorizations() +export type Interface = ReturnType + +export class Service extends Context.Service()("opencode/desktop/DesktopFiles") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + return Service.of(make(fs, path)) + }), +) + +function make(fs: FileSystem.FileSystem, path: Path.Path) { + const pickedFiles = createPickedFileAuthorizations((file, maxBytes) => + readAttachment(file, maxBytes).pipe(Effect.provideService(FileSystem.FileSystem, fs)), + ) return { - async openDirectoryPicker(options?: DirectoryPickerOptions) { - const result = await dialog.showOpenDialog({ - properties: ["openDirectory", ...(options?.multiple ? ["multiSelections" as const] : []), "createDirectory"], - title: options?.title ?? nativeT("desktop.dialog.chooseFolder"), - defaultPath: options?.defaultPath, - }) + openDirectoryPicker: Effect.fn("DesktopFiles.openDirectoryPicker")(function* (options?: DirectoryPickerOptions) { + const result = yield* Effect.promise(() => + dialog.showOpenDialog({ + properties: ["openDirectory", ...(options?.multiple ? ["multiSelections" as const] : []), "createDirectory"], + title: options?.title ?? nativeT("desktop.dialog.chooseFolder"), + defaultPath: options?.defaultPath, + }), + ) if (result.canceled) return null return options?.multiple ? result.filePaths : result.filePaths[0] - }, - async openFilePicker(sender: number, options?: FilePickerOptions) { - const result = await dialog.showOpenDialog({ - properties: ["openFile", ...(options?.multiple ? ["multiSelections" as const] : [])], - title: options?.title ?? nativeT("desktop.dialog.chooseFile"), - defaultPath: options?.defaultPath, - filters: pickerFilters(options?.extensions), - }) + }), + openFilePicker: Effect.fn("DesktopFiles.openFilePicker")(function* (sender: number, options?: FilePickerOptions) { + const result = yield* Effect.promise(() => + dialog.showOpenDialog({ + properties: ["openFile", ...(options?.multiple ? ["multiSelections" as const] : [])], + title: options?.title ?? nativeT("desktop.dialog.chooseFile"), + defaultPath: options?.defaultPath, + filters: pickerFilters(options?.extensions), + }), + ) if (result.canceled) return null - const files = await Promise.all( - result.filePaths.map(async (path) => ({ path, name: basename(path), size: (await stat(path)).size })), + const files = yield* Effect.forEach( + result.filePaths, + Effect.fnUntraced(function* (file) { + const info = yield* fs.stat(file) + return { path: file, name: path.basename(file), size: Number(info.size) } + }), + { concurrency: "unbounded" }, ) assertAttachmentBudget(files) return { token: pickedFiles.add(sender, result.filePaths), files } - }, - readPickedFile: (sender: number, token: string, path: string) => pickedFiles.read(sender, token, path), - releasePickedFiles: (sender: number, token: string) => pickedFiles.release(sender, token), - async saveFilePicker(options?: SaveFilePickerOptions) { - const result = await dialog.showSaveDialog({ - title: options?.title ?? nativeT("desktop.dialog.saveFile"), - defaultPath: options?.defaultPath, - }) + }), + readPickedFile: pickedFiles.read, + releasePickedFiles: pickedFiles.release, + saveFilePicker: Effect.fn("DesktopFiles.saveFilePicker")(function* (options?: SaveFilePickerOptions) { + const result = yield* Effect.promise(() => + dialog.showSaveDialog({ + title: options?.title ?? nativeT("desktop.dialog.saveFile"), + defaultPath: options?.defaultPath, + }), + ) if (result.canceled) return null return result.filePath ?? null - }, - async openPath(path: string, application?: string) { - if (!application) return shell.openPath(path) - await new Promise((resolve, reject) => { - const command = - process.platform === "darwin" - ? { file: "open", arguments: ["-a", application, path] } - : { file: application, arguments: [path] } - execFile(command.file, command.arguments, (error) => (error ? reject(error) : resolve())) - }) - }, - async revealPath(path: string) { - const exists = await stat(path).then( - () => true, - () => false, + }), + openPath: Effect.fn("DesktopFiles.openPath")(function* (target: string, application?: string) { + if (!application) return yield* Effect.promise(() => shell.openPath(target)) + yield* Effect.tryPromise(() => + new Promise((resolve, reject) => { + const command = + process.platform === "darwin" + ? { file: "open", arguments: ["-a", application, target] } + : { file: application, arguments: [target] } + execFile(command.file, command.arguments, (error) => (error ? reject(error) : resolve())) + }), + ) + }), + revealPath: Effect.fn("DesktopFiles.revealPath")(function* (target: string) { + const exists = yield* fs.stat(target).pipe( + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), ) if (!exists) return false - shell.showItemInFolder(path) + shell.showItemInFolder(target) return true - }, + }), readClipboardImage() { const image = clipboard.readImage() if (image.isEmpty()) return null @@ -73,25 +102,24 @@ export function createFileCapabilities() { } } -export function openExternalURL(value: string) { +export const openExternalURL = Effect.fn("DesktopFiles.openExternalURL")(function* (value: string) { const url = resolveExternalURL(value) if (!url) { - writeLog("window", "blocked external target", { url: value }, "warn") + yield* scoped("window", Effect.logWarning("blocked external target", { url: value })) return } - void shell.openExternal(url) -} + yield* Effect.promise(() => shell.openExternal(url)) +}) -export function openLocalFileURL(value: string) { +export const openLocalFileURL = Effect.fn("DesktopFiles.openLocalFileURL")(function* (value: string) { const path = resolveLocalFilePath(value) if (!path) { - writeLog("window", "blocked local file target", { url: value }, "warn") + yield* scoped("window", Effect.logWarning("blocked local file target", { url: value })) return } - void shell.openPath(path).then((error) => { - if (error) writeLog("window", "failed to open local file", { path, error }, "error") - }) -} + const error = yield* Effect.promise(() => shell.openPath(path)) + if (error) yield* scoped("window", Effect.logError("failed to open local file", { path, error })) +}) function pickerFilters(extensions?: string[]) { if (!extensions?.length) return undefined diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index ccbf6352abb..b2ed803a73c 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -1,97 +1,75 @@ +import { NodeFileSystem, NodePath, NodeRuntime } from "@effect/platform-node" import { app } from "electron" -import { Deferred, Effect, Fiber } from "effect" +import { Effect, Exit, Layer } from "effect" import type { ServerReadyData } from "../shared/ipc-contract" -import { checkAppExists, resolveAppPath } from "./files/apps" -import { registerIpcHandlers } from "./ipc" +import { Ipc } from "./ipc" import { acquireApplicationLock, configureApplication, loadProxyEnvironment, preferApplicationEnvironment, + prepareApplicationEnvironment, prepareDesktop, } from "./lifecycle/environment" -import { createApplicationLifecycle } from "./lifecycle" -import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "./lifecycle/onboarding" -import { exportDebugLogs, startNetworkLogging, writeLog } from "./native/logging" -import { createMenu, sendMenuCommand } from "./native/menu" -import { setNativeTranslations } from "./native/translations" +import { ApplicationLifecycle } from "./lifecycle" +import { initializeFirstLaunchOnboarding } from "./lifecycle/onboarding" +import { Shutdown } from "./lifecycle/shutdown" +import { DesktopLogging } from "./native/logging" import { startBackgroundCli } from "./service/background-service" -import { forwardInitializationFailure } from "./service/initialization" -import { getDefaultServerUrl, setDefaultServerUrl } from "./service/server-settings" -import { createUpdaterIpc, setupAutoUpdater, showUpdaterDialog, startAutoUpdater } from "./updater" -import { getLastFocusedWindow, setBackgroundColor } from "./windows" -import { startWsl } from "./wsl/start" -import { createDeferredWslIpc } from "./wsl/ipc" - -const main = Effect.gen(function* () { - const logger = configureApplication() - if (!acquireApplicationLock()) return - preferApplicationEnvironment(logger) - const lifecycle = createApplicationLifecycle(logger) - const serverReady = Deferred.makeUnsafe() +import { Updater } from "./updater" +const runApplication = Effect.gen(function* () { + yield* initializeFirstLaunchOnboarding(app.getPath("userData")) + yield* prepareApplicationEnvironment + yield* preferApplicationEnvironment yield* Effect.promise(() => app.whenReady()) - yield* prepareDesktop(logger) - - const updater = setupAutoUpdater(lifecycle.prepareToRestart) - const updaterIpc = createUpdaterIpc(updater) - const wslIpc = createDeferredWslIpc() - const menu = { - trigger: (id: string) => { - const win = getLastFocusedWindow() - if (win) sendMenuCommand(win, id) - }, - checkForUpdates: () => void showUpdaterDialog(updater), - relaunch: lifecycle.relaunch, - } - const ipcDeps: Parameters[0] = { - relaunch: lifecycle.relaunch, - awaitInitialization: Effect.fnUntraced( - function* () { - logger.log("awaiting server ready") - const result = yield* Deferred.await(serverReady) - logger.log("server ready", { url: result.url }) - return result - }, - (effect) => Effect.runPromise(effect), - ), - consumeInitialDeepLinks: lifecycle.consumeInitialDeepLinks, - getDefaultServerUrl, - setDefaultServerUrl, - isFirstLaunchOnboardingPending, - finishFirstLaunchOnboarding, - checkAppExists, - resolveAppPath: async (appName) => resolveAppPath(appName), - showUpdater: () => showUpdaterDialog(updater), - setBackgroundColor, - exportDebugLogs, - recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"), - setNativeTranslations: (bundle) => { - if (setNativeTranslations(bundle)) createMenu(menu) - }, - } - yield* Effect.promise(() => registerIpcHandlers(ipcDeps, updaterIpc, wslIpc.ipc)) - startAutoUpdater(updater) - yield* Effect.promise(() => startNetworkLogging()) - - const loadingTask = yield* Effect.gen(function* () { - loadProxyEnvironment(logger) - logger.log("starting v2 background service") - const background = yield* Effect.promise(() => startBackgroundCli(logger)) - const wsl = yield* Effect.promise(() => startWsl(background, logger)) - wslIpc.set(wsl.ipc) - wsl.start() - lifecycle.setWslShutdown(wsl.stop) - yield* Deferred.succeed(serverReady, { - url: background.url, - username: background.username, - password: background.password, - }) - logger.log("loading task finished") - }).pipe(forwardInitializationFailure(serverReady), Effect.forkChild) - - yield* Fiber.await(loadingTask) - if (lifecycle.restoreWindows().length) createMenu(menu) + yield* prepareDesktop + yield* runDesktop.pipe(Effect.provide(Updater.layer)) }) -Effect.runFork(main) +const runDesktop = Effect.gen(function* () { + const logging = yield* DesktopLogging.Service + yield* logging.startNetwork + yield* loadProxyEnvironment + yield* Effect.logInfo("starting v2 background service") + const loading = yield* startBackgroundCli().pipe(Effect.exit) + const initialization = Exit.isSuccess(loading) + ? Effect.succeed({ + url: loading.value.url, + username: loading.value.username, + password: loading.value.password, + } satisfies ServerReadyData) + : Effect.failCause(loading.cause).pipe(Effect.orDie) + + yield* runIpc(Exit.isSuccess(loading)).pipe( + Effect.provide(Ipc.layer(initialization, Exit.isSuccess(loading) ? loading.value : undefined)), + ) +}) + +const runIpc = Effect.fn("Desktop.runIpc")(function* (loaded: boolean) { + const lifecycle = yield* ApplicationLifecycle.Service + const ipc = yield* Ipc.registerIpcHandlers + if (loaded) yield* Effect.logInfo("loading task finished") + if (lifecycle.restoreWindows().length) ipc.installMenu() + yield* Effect.callback((resume) => { + const quit = () => resume(Effect.void) + app.once("will-quit", quit) + return Effect.sync(() => app.off("will-quit", quit)) + }) +}) + +const platform = Layer.merge(DesktopLogging.layer, Shutdown.layer).pipe( + Layer.provideMerge(Layer.merge(NodeFileSystem.layer, NodePath.layer)), +) + +const main = Effect.gen(function* () { + if (!acquireApplicationLock()) return + yield* configureApplication() + yield* runApplication +}) + +main.pipe( + Effect.provide(ApplicationLifecycle.layer.pipe(Layer.provideMerge(platform))), + Effect.scoped, + NodeRuntime.runMain, +) diff --git a/packages/desktop/src/main/ipc-events.ts b/packages/desktop/src/main/ipc-events.ts index f9d9bbbf3d2..dec0bbc57b6 100644 --- a/packages/desktop/src/main/ipc-events.ts +++ b/packages/desktop/src/main/ipc-events.ts @@ -4,22 +4,20 @@ import type { DesktopEvent } from "../shared/ipc-rpc/events" const queues = new Map>() -export function bindIpcEvents(senderId: number) { - const queue = Effect.runSync(Queue.unbounded()) +export const bindIpcEvents = Effect.fn("IpcEvents.bind")(function* (senderId: number) { + const queue = yield* Queue.unbounded() + const previous = queues.get(senderId) queues.set(senderId, queue) - return () => { + if (previous) yield* Queue.shutdown(previous) + return Effect.fnUntraced(function* () { if (queues.get(senderId) === queue) queues.delete(senderId) - } -} + yield* Queue.shutdown(queue) + })() +}) export function ipcEventStream(senderId: number) { - return Stream.unwrap( - Effect.gen(function* () { - const queue = queues.get(senderId) ?? (yield* Queue.unbounded()) - if (!queues.has(senderId)) queues.set(senderId, queue) - return Stream.fromQueue(queue) - }), - ) + const queue = queues.get(senderId) + return queue ? Stream.fromQueue(queue) : Stream.empty } export function emitIpcEvent(sender: WebContents, event: DesktopEvent) { diff --git a/packages/desktop/src/main/ipc-handlers/app.ts b/packages/desktop/src/main/ipc-handlers/app.ts index 3e13901e9a9..c21bed5d74c 100644 --- a/packages/desktop/src/main/ipc-handlers/app.ts +++ b/packages/desktop/src/main/ipc-handlers/app.ts @@ -1,64 +1,72 @@ import { BrowserWindow } from "electron" -import { parseDesktopNativeBundle, type DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native" +import { parseDesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native" import { Effect } from "effect" -import type { FatalRendererError, ServerReadyData } from "../../shared/ipc-contract" import { AppRpcs } from "../../shared/ipc-rpc" +import { openExternalURL } from "../files" +import { checkAppExists, resolveAppPath } from "../files/apps" import { setForceFocus } from "../native/debug" +import { DesktopLogging, scoped } from "../native/logging" +import { createMenu, sendMenuCommand } from "../native/menu" +import { setNativeTranslations } from "../native/translations" import { IpcPortHandoff } from "../ipc-transport" +import { ApplicationLifecycle } from "../lifecycle" +import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "../lifecycle/onboarding" +import { Initialization } from "../service/initialization" +import { getDefaultServerUrl, setDefaultServerUrl } from "../service/server-settings" +import { Updater } from "../updater" +import { getLastFocusedWindow, setBackgroundColor } from "../windows" import { sender } from "./context" -export type AppHandlerDeps = { - relaunch: () => void - awaitInitialization: () => Promise - consumeInitialDeepLinks: () => Promise | string[] - getDefaultServerUrl: () => Promise | string | null - setDefaultServerUrl: (url: string | null) => Promise | void - isFirstLaunchOnboardingPending: () => Promise | boolean - finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise | string | null - checkAppExists: (appName: string) => Promise | boolean - resolveAppPath: (appName: string) => Promise - setBackgroundColor: (color: string) => void - exportDebugLogs: () => Promise - recordFatalRendererError: (error: FatalRendererError) => Promise | void - setNativeTranslations: (bundle: DesktopNativeBundle) => void -} - -export function appHandlers(deps: AppHandlerDeps) { - return AppRpcs.toLayer( - Effect.gen(function* () { - const handoff = yield* IpcPortHandoff - return AppRpcs.of({ - AppAwaitInitialization: () => Effect.promise(() => deps.awaitInitialization()), - AppConsumeInitialDeepLinks: () => promise(deps.consumeInitialDeepLinks), - AppGetDefaultServerUrl: () => promise(deps.getDefaultServerUrl), - AppSetDefaultServerUrl: ({ url }) => promise(() => deps.setDefaultServerUrl(url)), - AppIsFirstLaunchOnboardingPending: () => promise(deps.isFirstLaunchOnboardingPending), - AppFinishFirstLaunchOnboarding: ({ createDefaultProject }) => - promise(() => deps.finishFirstLaunchOnboarding(createDefaultProject)), - AppCheckAppExists: ({ appName }) => promise(() => deps.checkAppExists(appName)), - AppResolveAppPath: ({ appName }) => Effect.promise(() => deps.resolveAppPath(appName)), - AppSetBackgroundColor: ({ color }) => Effect.sync(() => deps.setBackgroundColor(color)), - AppExportDebugLogs: () => Effect.promise(() => deps.exportDebugLogs()), - AppSetForceFocus: ({ enabled }, context) => - Effect.promise(() => setForceFocus(sender(handoff, context), enabled)), - AppRecordFatalRendererError: ({ error }) => promise(() => deps.recordFatalRendererError(error)), - AppSetNativeTranslations: ({ value }, context) => - Effect.sync(() => { - const contents = sender(handoff, context) - const win = BrowserWindow.fromWebContents(contents) - if (!win || win.isDestroyed() || win.webContents !== contents) { - throw new Error("Invalid native translation sender") - } - const bundle = parseDesktopNativeBundle(value) - if (!bundle) throw new Error("Invalid native translation bundle") - deps.setNativeTranslations(bundle) - }), - AppRelaunch: () => Effect.sync(deps.relaunch), - }) - }), - ) -} +export const appHandlers = AppRpcs.toLayer( + Effect.gen(function* () { + const handoff = yield* IpcPortHandoff + const lifecycle = yield* ApplicationLifecycle.Service + const initialization = yield* Initialization.Service + const updater = yield* Updater.Service + const logging = yield* DesktopLogging.Service + const context = yield* Effect.context() + const runFork = Effect.runForkWith(context) + return AppRpcs.of({ + AppAwaitInitialization: () => initialization.await, + AppConsumeInitialDeepLinks: () => Effect.sync(lifecycle.consumeInitialDeepLinks), + AppGetDefaultServerUrl: () => Effect.sync(getDefaultServerUrl), + AppSetDefaultServerUrl: ({ url }) => Effect.sync(() => setDefaultServerUrl(url)), + AppIsFirstLaunchOnboardingPending: isFirstLaunchOnboardingPending, + AppFinishFirstLaunchOnboarding: ({ createDefaultProject }) => + finishFirstLaunchOnboarding(createDefaultProject).pipe(Effect.orDie), + AppCheckAppExists: ({ appName }) => checkAppExists(appName).pipe(Effect.orDie), + AppResolveAppPath: ({ appName }) => resolveAppPath(appName).pipe(Effect.orDie), + AppSetBackgroundColor: ({ color }) => Effect.sync(() => setBackgroundColor(color)), + AppExportDebugLogs: () => logging.exportDebug, + AppSetForceFocus: ({ enabled }, context) => promise(() => setForceFocus(sender(handoff, context), enabled)), + AppRecordFatalRendererError: ({ error }) => + scoped("renderer", Effect.logError("fatal renderer error", { ...error })), + AppSetNativeTranslations: ({ value }, context) => + Effect.sync(() => { + const contents = sender(handoff, context) + const win = BrowserWindow.fromWebContents(contents) + if (!win || win.isDestroyed() || win.webContents !== contents) { + throw new Error("Invalid native translation sender") + } + const bundle = parseDesktopNativeBundle(value) + if (!bundle) throw new Error("Invalid native translation bundle") + if (!setNativeTranslations(bundle)) return + createMenu({ + trigger: (id) => { + const win = getLastFocusedWindow() + if (win) sendMenuCommand(win, id) + }, + checkForUpdates: () => runFork(updater.show), + createWindow: lifecycle.createWindow, + openExternal: (url) => runFork(openExternalURL(url)), + relaunch: lifecycle.relaunch, + }) + }), + AppRelaunch: () => Effect.sync(lifecycle.relaunch), + }) + }), +) function promise(evaluate: () => A | Promise) { - return Effect.promise(async () => evaluate()) + return Effect.tryPromise(async () => evaluate()).pipe(Effect.orDie) } diff --git a/packages/desktop/src/main/ipc-handlers/files.ts b/packages/desktop/src/main/ipc-handlers/files.ts index 6dfccd7dabb..a99d26175a1 100644 --- a/packages/desktop/src/main/ipc-handlers/files.ts +++ b/packages/desktop/src/main/ipc-handlers/files.ts @@ -1,40 +1,42 @@ import { Effect } from "effect" import { FileRpcs } from "../../shared/ipc-rpc" -import { createFileCapabilities, openExternalURL, openLocalFileURL } from "../files" +import { DesktopFiles, openExternalURL, openLocalFileURL } from "../files" import { IpcPortHandoff } from "../ipc-transport" import { sender } from "./context" -export function fileHandlers(files: ReturnType) { - return FileRpcs.toLayer( - Effect.gen(function* () { - const handoff = yield* IpcPortHandoff - return FileRpcs.of({ - FilesOpenDirectoryPicker: ({ options }) => Effect.promise(() => files.openDirectoryPicker(options)), - FilesOpenFilePicker: ({ options }, context) => - Effect.promise(() => - files.openFilePicker( - sender(handoff, context).id, - options ? { ...options, extensions: options.extensions && [...options.extensions] } : undefined, - ), - ), - FilesReadPickedFile: ({ token, path }, context) => - Effect.promise( - async () => new Uint8Array(await files.readPickedFile(sender(handoff, context).id, token, path)), - ), - FilesReleasePickedFiles: ({ token }, context) => - Effect.sync(() => files.releasePickedFiles(sender(handoff, context).id, token)), - FilesSaveFilePicker: ({ options }) => Effect.promise(() => files.saveFilePicker(options)), - FilesOpenExternal: ({ url }) => Effect.sync(() => openExternalURL(url)), - FilesOpenLocalFile: ({ url }) => Effect.sync(() => openLocalFileURL(url)), - FilesOpenPath: ({ path, application }) => - Effect.promise(async () => (await files.openPath(path, application)) ?? null), - FilesRevealPath: ({ path }) => Effect.promise(() => files.revealPath(path)), - FilesReadClipboardImage: () => - Effect.sync(() => { - const image = files.readClipboardImage() - return image ? { ...image, buffer: new Uint8Array(image.buffer) } : null - }), - }) - }), - ) -} +export const fileHandlers = FileRpcs.toLayer( + Effect.gen(function* () { + const files = yield* DesktopFiles.Service + const handoff = yield* IpcPortHandoff + return FileRpcs.of({ + FilesOpenDirectoryPicker: ({ options }) => files.openDirectoryPicker(options), + FilesOpenFilePicker: ({ options }, context) => + files + .openFilePicker( + sender(handoff, context).id, + options ? { ...options, extensions: options.extensions && [...options.extensions] } : undefined, + ) + .pipe(Effect.orDie), + FilesReadPickedFile: ({ token, path }, context) => + files + .readPickedFile(sender(handoff, context).id, token, path) + .pipe(Effect.map((buffer) => new Uint8Array(buffer)), Effect.orDie), + FilesReleasePickedFiles: ({ token }, context) => + Effect.sync(() => files.releasePickedFiles(sender(handoff, context).id, token)), + FilesSaveFilePicker: ({ options }) => files.saveFilePicker(options), + FilesOpenExternal: ({ url }) => openExternalURL(url), + FilesOpenLocalFile: ({ url }) => openLocalFileURL(url), + FilesOpenPath: ({ path, application }) => + files.openPath(path, application).pipe( + Effect.map((result) => result ?? null), + Effect.orDie, + ), + FilesRevealPath: ({ path }) => files.revealPath(path), + FilesReadClipboardImage: () => + Effect.sync(() => { + const image = files.readClipboardImage() + return image ? { ...image, buffer: new Uint8Array(image.buffer) } : null + }), + }) + }), +) diff --git a/packages/desktop/src/main/ipc-handlers/menu.ts b/packages/desktop/src/main/ipc-handlers/menu.ts index 2a8d2ed14e8..df6d889a4e7 100644 --- a/packages/desktop/src/main/ipc-handlers/menu.ts +++ b/packages/desktop/src/main/ipc-handlers/menu.ts @@ -2,25 +2,27 @@ import { BrowserWindow } from "electron" import { Effect } from "effect" import { MenuRpcs } from "../../shared/ipc-rpc" import { IpcPortHandoff } from "../ipc-transport" +import { ApplicationLifecycle } from "../lifecycle" import { runDesktopMenuAction } from "../native/menu-actions" +import { Updater } from "../updater" import { sender } from "./context" -export function menuHandlers(deps: { - readonly showUpdater: () => Promise | void - readonly relaunch: () => void -}) { - return MenuRpcs.toLayer( - Effect.gen(function* () { - const handoff = yield* IpcPortHandoff - return MenuRpcs.of({ - MenuRunAction: ({ action }, context) => - Effect.sync(() => - runDesktopMenuAction(BrowserWindow.fromWebContents(sender(handoff, context)), action, { - checkForUpdates: () => void deps.showUpdater(), - relaunch: deps.relaunch, - }), - ), - }) - }), - ) -} +export const menuHandlers = MenuRpcs.toLayer( + Effect.gen(function* () { + const handoff = yield* IpcPortHandoff + const lifecycle = yield* ApplicationLifecycle.Service + const updater = yield* Updater.Service + const context = yield* Effect.context() + const runFork = Effect.runForkWith(context) + return MenuRpcs.of({ + MenuRunAction: ({ action }, context) => + Effect.sync(() => + runDesktopMenuAction(BrowserWindow.fromWebContents(sender(handoff, context)), action, { + checkForUpdates: () => runFork(updater.show), + createWindow: lifecycle.createWindow, + relaunch: lifecycle.relaunch, + }), + ), + }) + }), +) diff --git a/packages/desktop/src/main/ipc-handlers/storage.ts b/packages/desktop/src/main/ipc-handlers/storage.ts index 748d20229a5..c01395410ab 100644 --- a/packages/desktop/src/main/ipc-handlers/storage.ts +++ b/packages/desktop/src/main/ipc-handlers/storage.ts @@ -1,32 +1,29 @@ import { Effect } from "effect" import { StorageRpcs } from "../../shared/ipc-rpc" -import type { createDesktopStorage } from "../storage" +import { DesktopStorage } from "../storage" -export function storageHandlers(storage: ReturnType) { - return StorageRpcs.toLayer( - Effect.succeed( - StorageRpcs.of({ - StorageGet: ({ name, key }) => Effect.sync(() => storage.get(name, key)), - StorageSet: ({ name, key, value }) => Effect.sync(() => storage.set(name, key, value)), - StorageDelete: ({ name, key }) => Effect.sync(() => storage.deleteValue(name, key)), - StorageClear: ({ name }) => Effect.sync(() => storage.clear(name)), - StorageKeys: ({ name }) => Effect.sync(() => storage.keys(name)), - StorageLength: ({ name }) => Effect.sync(() => storage.length(name)), - DraftsGet: ({ key }) => Effect.sync(() => storage.drafts.get(key)), - DraftsSet: ({ key, value }) => Effect.sync(() => storage.drafts.set(key, value)), - DraftsDelete: ({ key }) => Effect.sync(() => storage.drafts.set(key, null)), - DraftsPutBlob: ({ data }) => - Effect.sync(() => - storage.drafts.putBlob( - data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer, - ), - ), - DraftsGetBlob: ({ id }) => - Effect.sync(() => { - const data = storage.drafts.getBlob(id) - return data ? new Uint8Array(data) : null - }), - }), - ), - ) -} +export const storageHandlers = StorageRpcs.toLayer( + Effect.gen(function* () { + const storage = yield* DesktopStorage.Service + return StorageRpcs.of({ + StorageGet: ({ name, key }) => Effect.sync(() => storage.get(name, key)), + StorageSet: ({ name, key, value }) => Effect.sync(() => storage.set(name, key, value)), + StorageDelete: ({ name, key }) => storage.deleteValue(name, key).pipe(Effect.orDie), + StorageClear: ({ name }) => storage.clear(name).pipe(Effect.orDie), + StorageKeys: ({ name }) => Effect.sync(() => storage.keys(name)), + StorageLength: ({ name }) => Effect.sync(() => storage.length(name)), + DraftsGet: ({ key }) => Effect.sync(() => storage.drafts.get(key)), + DraftsSet: ({ key, value }) => Effect.sync(() => storage.drafts.set(key, value)), + DraftsDelete: ({ key }) => Effect.sync(() => storage.drafts.set(key, null)), + DraftsPutBlob: ({ data }) => + Effect.sync(() => + storage.drafts.putBlob(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer), + ), + DraftsGetBlob: ({ id }) => + Effect.sync(() => { + const data = storage.drafts.getBlob(id) + return data ? new Uint8Array(data) : null + }), + }) + }), +) diff --git a/packages/desktop/src/main/ipc-handlers/updater.ts b/packages/desktop/src/main/ipc-handlers/updater.ts index f33ef0b2965..be1f8d98af8 100644 --- a/packages/desktop/src/main/ipc-handlers/updater.ts +++ b/packages/desktop/src/main/ipc-handlers/updater.ts @@ -1,19 +1,18 @@ import { Effect } from "effect" import { UpdaterRpcs } from "../../shared/ipc-rpc" -import type { UpdaterIpc } from "../updater" import { IpcPortHandoff } from "../ipc-transport" +import { Updater } from "../updater" import { sender } from "./context" -export function updaterHandlers(updater: UpdaterIpc) { - return UpdaterRpcs.toLayer( - Effect.gen(function* () { - const handoff = yield* IpcPortHandoff - return UpdaterRpcs.of({ - UpdaterSubscribe: (_args, context) => Effect.sync(() => updater.subscribe(sender(handoff, context))), - UpdaterUnsubscribe: (_args, context) => Effect.sync(() => updater.unsubscribe(sender(handoff, context).id)), - UpdaterCheck: () => Effect.promise(() => updater.check()), - UpdaterInstall: () => Effect.promise(() => updater.install()), - }) - }), - ) -} +export const updaterHandlers = UpdaterRpcs.toLayer( + Effect.gen(function* () { + const handoff = yield* IpcPortHandoff + const updater = yield* Updater.Service + return UpdaterRpcs.of({ + UpdaterSubscribe: (_args, context) => updater.subscribe(sender(handoff, context)), + UpdaterUnsubscribe: (_args, context) => updater.unsubscribe(sender(handoff, context).id), + UpdaterCheck: () => updater.check, + UpdaterInstall: () => updater.install, + }) + }), +) diff --git a/packages/desktop/src/main/ipc-handlers/wsl.ts b/packages/desktop/src/main/ipc-handlers/wsl.ts index 296d5cac85b..8757ee0f99d 100644 --- a/packages/desktop/src/main/ipc-handlers/wsl.ts +++ b/packages/desktop/src/main/ipc-handlers/wsl.ts @@ -1,28 +1,27 @@ import { Effect } from "effect" import { WslRpcs } from "../../shared/ipc-rpc" import { IpcPortHandoff } from "../ipc-transport" -import type { WslIpc } from "../wsl/ipc" +import { Wsl } from "../wsl/start" import { sender } from "./context" -export function wslHandlers(wsl: WslIpc) { - return WslRpcs.toLayer( - Effect.gen(function* () { - const handoff = yield* IpcPortHandoff - return WslRpcs.of({ - WslSubscribe: (_args, context) => Effect.sync(() => wsl.subscribe(sender(handoff, context))), - WslUnsubscribe: (_args, context) => Effect.sync(() => wsl.unsubscribe(sender(handoff, context).id)), - WslGetState: () => Effect.sync(() => wsl.getState()), - WslProbeRuntime: () => Effect.promise(() => wsl.probeRuntime()), - WslRefreshDistros: () => Effect.promise(() => wsl.refreshDistros()), - WslInstallWsl: () => Effect.promise(() => wsl.installWsl()), - WslInstallDistro: ({ name }) => Effect.promise(() => wsl.installDistro(name)), - WslProbeAddable: ({ distros }) => Effect.promise(() => wsl.probeAddable([...distros])), - WslInstallOpencode: ({ name }) => Effect.promise(() => wsl.installOpencode(name)), - WslOpenTerminal: ({ name }) => Effect.promise(() => wsl.openTerminal(name)), - WslAddServer: ({ distro }) => Effect.promise(() => wsl.addServer(distro)), - WslRemoveServer: ({ id }) => Effect.promise(() => wsl.removeServer(id)), - WslStartServer: ({ id }) => Effect.promise(() => wsl.startServer(id)), - }) - }), - ) -} +export const wslHandlers = WslRpcs.toLayer( + Effect.gen(function* () { + const handoff = yield* IpcPortHandoff + const wsl = yield* Wsl.Service + return WslRpcs.of({ + WslSubscribe: (_args, context) => wsl.subscribe(sender(handoff, context)), + WslUnsubscribe: (_args, context) => wsl.unsubscribe(sender(handoff, context).id), + WslGetState: () => wsl.getState(), + WslProbeRuntime: () => wsl.probeRuntime(), + WslRefreshDistros: () => wsl.refreshDistros(), + WslInstallWsl: () => wsl.installWsl(), + WslInstallDistro: ({ name }) => wsl.installDistro(name), + WslProbeAddable: ({ distros }) => wsl.probeAddable([...distros]), + WslInstallOpencode: ({ name }) => wsl.installOpencode(name), + WslOpenTerminal: ({ name }) => wsl.openTerminal(name), + WslAddServer: ({ distro }) => wsl.addServer(distro), + WslRemoveServer: ({ id }) => wsl.removeServer(id), + WslStartServer: ({ id }) => wsl.startServer(id), + }) + }), +) diff --git a/packages/desktop/src/main/ipc-transport.ts b/packages/desktop/src/main/ipc-transport.ts index 59dcc650c54..d7f72dbbb9f 100644 --- a/packages/desktop/src/main/ipc-transport.ts +++ b/packages/desktop/src/main/ipc-transport.ts @@ -10,7 +10,7 @@ type PortBinding = { readonly parser: RpcSerialization.Parser readonly onMessage: (event: Electron.MessageEvent) => void readonly onClose: () => void - readonly unbindEvents: () => void + readonly unbindEvents: Effect.Effect } type Handoff = { @@ -33,9 +33,11 @@ export const IpcServerProtocolLive = Layer.unwrap( const serialization = yield* RpcSerialization.RpcSerialization const disconnects = yield* Queue.unbounded() const inbound = yield* Queue.unbounded() + const context = yield* Effect.context() + const runFork = Effect.runForkWith(context) let nextClientId = 0 - const disconnect = (id: number) => { + const disconnect = Effect.fnUntraced(function* (id: number) { const binding = bindings.get(id) if (!binding) return bindings.delete(id) @@ -43,14 +45,14 @@ export const IpcServerProtocolLive = Layer.unwrap( binding.port.off("message", binding.onMessage) binding.port.off("close", binding.onClose) binding.sender.off("destroyed", binding.onClose) - binding.unbindEvents() + yield* binding.unbindEvents binding.port.close() Queue.offerUnsafe(disconnects, id) - } + }) - const bind = (sender: WebContents, port: MessagePortMain) => { + const bind = Effect.fnUntraced(function* (sender: WebContents, port: MessagePortMain) { const previous = senderBindings.get(sender.id) - if (previous !== undefined) disconnect(previous) + if (previous !== undefined) yield* disconnect(previous) if (sender.isDestroyed()) { port.close() return @@ -69,25 +71,26 @@ export const IpcServerProtocolLive = Layer.unwrap( return } } - const onClose = () => disconnect(id) - const binding = { id, sender, port, parser, onMessage, onClose, unbindEvents: bindIpcEvents(sender.id) } + const onClose = () => runFork(disconnect(id)) + const unbindEvents = yield* bindIpcEvents(sender.id) + const binding = { id, sender, port, parser, onMessage, onClose, unbindEvents } bindings.set(id, binding) senderBindings.set(sender.id, id) port.on("message", onMessage) port.on("close", onClose) sender.once("destroyed", onClose) port.start() - } + }) yield* Stream.fromQueue(handoffs).pipe( - Stream.runForEach(([sender, port]) => Effect.sync(() => bind(sender, port))), + Stream.runForEach(([sender, port]) => bind(sender, port)), Effect.forkScoped, ) yield* Stream.fromQueue(inbound).pipe( Stream.runForEach(([id, message]) => (bindings.has(id) ? writeRequest(id, message) : Effect.void)), Effect.forkScoped, ) - yield* Effect.addFinalizer(() => Effect.sync(() => [...bindings.keys()].forEach(disconnect))) + yield* Effect.addFinalizer(() => Effect.forEach([...bindings.keys()], disconnect, { discard: true })) return { disconnects, @@ -98,7 +101,7 @@ export const IpcServerProtocolLive = Layer.unwrap( const encoded = binding.parser.encode(response) if (encoded !== undefined) binding.port.postMessage(encoded) }), - end: (clientId) => Effect.sync(() => disconnect(clientId)), + end: disconnect, clientIds: Effect.sync(() => new Set(bindings.keys())), initialMessage: Effect.succeed(Option.none()), supportsAck: true, diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 50319e2639e..f3a60095d65 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -1,10 +1,13 @@ +export * as Ipc from "./ipc" + import { app, BrowserWindow, MessageChannelMain } from "electron" -import { Layer, ManagedRuntime } from "effect" +import { Effect, Layer } from "effect" import { RpcServer } from "effect/unstable/rpc" +import type { ServerReadyData } from "../shared/ipc-contract" import { DesktopRpcs } from "../shared/ipc-rpc" import { IpcTransportPort } from "../shared/ipc-transport" -import { createFileCapabilities } from "./files" -import { appHandlers, type AppHandlerDeps } from "./ipc-handlers/app" +import { DesktopFiles, openExternalURL } from "./files" +import { appHandlers } from "./ipc-handlers/app" import { eventHandlers } from "./ipc-handlers/events" import { fileHandlers } from "./ipc-handlers/files" import { menuHandlers } from "./ipc-handlers/menu" @@ -13,31 +16,51 @@ import { updaterHandlers } from "./ipc-handlers/updater" import { windowHandlers } from "./ipc-handlers/window" import { wslHandlers } from "./ipc-handlers/wsl" import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport" -import { createDesktopStorage } from "./storage" -import type { UpdaterIpc } from "./updater" -import type { WslIpc } from "./wsl/ipc" +import { ApplicationLifecycle } from "./lifecycle" +import { createMenu, sendMenuCommand } from "./native/menu" +import { Initialization } from "./service/initialization" +import { DesktopStorage } from "./storage" +import { Updater } from "./updater" +import { getLastFocusedWindow } from "./windows" +import { Wsl } from "./wsl/start" -type Deps = AppHandlerDeps & { - showUpdater: () => Promise | void -} - -export async function registerIpcHandlers(deps: Deps, updater: UpdaterIpc, wsl: WslIpc) { +export function layer(initialization: Effect.Effect, cli?: Wsl.Cli) { + const services = Layer.mergeAll(DesktopFiles.layer, DesktopStorage.layer, Wsl.layer(cli)).pipe( + Layer.provideMerge(Initialization.layer(initialization)), + ) const handlers = Layer.mergeAll( - appHandlers(deps), - storageHandlers(createDesktopStorage()), - fileHandlers(createFileCapabilities()), + appHandlers, + storageHandlers, + fileHandlers, windowHandlers, - menuHandlers(deps), - updaterHandlers(updater), - wslHandlers(wsl), + menuHandlers, + updaterHandlers, + wslHandlers, eventHandlers, ) - const live = RpcServer.layer(DesktopRpcs, { disableFatalDefects: true }).pipe( + return RpcServer.layer(DesktopRpcs, { disableFatalDefects: true }).pipe( Layer.provide(handlers), Layer.provideMerge(IpcServerProtocolLive), + Layer.provideMerge(services), ) - const runtime = ManagedRuntime.make(live) - const handoff = await runtime.runPromise(IpcPortHandoff) +} + +export const registerIpcHandlers = Effect.gen(function* () { + const handoff = yield* IpcPortHandoff + const lifecycle = yield* ApplicationLifecycle.Service + const updater = yield* Updater.Service + const context = yield* Effect.context() + const runFork = Effect.runForkWith(context) + const menu = { + trigger: (id: string) => { + const win = getLastFocusedWindow() + if (win) sendMenuCommand(win, id) + }, + checkForUpdates: () => runFork(updater.show), + createWindow: lifecycle.createWindow, + openExternal: (url: string) => runFork(openExternalURL(url)), + relaunch: lifecycle.relaunch, + } const wire = (_event: Electron.Event, win: BrowserWindow) => { win.webContents.on("did-finish-load", () => { if (win.isDestroyed() || win.webContents.isDestroyed()) return @@ -46,10 +69,12 @@ export async function registerIpcHandlers(deps: Deps, updater: UpdaterIpc, wsl: win.webContents.postMessage(IpcTransportPort, null, [channel.port2]) }) } - app.on("browser-window-created", wire) - BrowserWindow.getAllWindows().forEach((win) => wire({} as Electron.Event, win)) - app.once("will-quit", () => { - app.off("browser-window-created", wire) - void runtime.dispose() + yield* Effect.sync(() => { + app.on("browser-window-created", wire) + BrowserWindow.getAllWindows().forEach((win) => wire({} as Electron.Event, win)) }) -} + yield* Effect.addFinalizer(() => Effect.sync(() => app.off("browser-window-created", wire))) + return { + installMenu: () => createMenu(menu), + } +}) diff --git a/packages/desktop/src/main/lifecycle/environment.ts b/packages/desktop/src/main/lifecycle/environment.ts index 671b5c25f1a..e5493223044 100644 --- a/packages/desktop/src/main/lifecycle/environment.ts +++ b/packages/desktop/src/main/lifecycle/environment.ts @@ -1,18 +1,15 @@ import { randomUUID } from "node:crypto" -import { mkdirSync, rmSync } from "node:fs" import http from "node:http" import { homedir, tmpdir } from "node:os" -import { join } from "node:path" import { getCACertificates, setDefaultCACertificates } from "node:tls" import { app } from "electron" import contextMenu from "electron-context-menu" -import { Effect } from "effect" -import { CHANNEL, VERSION } from "../constants" -import { initCrashReporter, initLogging, type DesktopLogger } from "../native/logging" +import { Effect, FileSystem, Path } from "effect" +import { CHANNEL } from "../constants" +import { DesktopPaths } from "../paths" import { getUserShell, loadShellEnv } from "../service/shell-env" import { cleanupStoreFiles } from "../storage/cleanup" import { registerRendererProtocol, setDockIcon } from "../windows" -import { initializeFirstLaunchOnboarding } from "./onboarding" const appNames: Record = { dev: "OpenCode Dev", @@ -27,7 +24,8 @@ const appIDs: Record = { const testOnboarding = process.env.OPENCODE_TEST_ONBOARDING === "1" const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports" -export function configureApplication() { +export const configureApplication = Effect.fn("Application.configure")(function* () { + const path = yield* Path.Path contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false }) try { process.chdir(homedir()) @@ -35,29 +33,20 @@ export function configureApplication() { process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true" const appID = app.isPackaged ? appIDs[CHANNEL] : "ai.opencode.desktop.dev" - const onboardingRoot = createOnboardingTestRoot() app.setName(app.isPackaged ? appNames[CHANNEL] : "OpenCode Dev") app.setAppUserModelId(appID) - app.setPath("userData", onboardingRoot ? join(onboardingRoot, "desktop") : join(app.getPath("appData"), appID)) - if (onboardingRoot) app.setPath("sessionData", join(onboardingRoot, "session")) - - initializeFirstLaunchOnboarding(app.getPath("userData")) - const logger = initLogging() - initCrashReporter() - loadSystemCertificates(logger) - logger.log("app starting", { - version: VERSION, - packaged: app.isPackaged, - onboardingTest: Boolean(onboardingRoot), - }) - - loadProxyEnvironment(logger) app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") const features = app.commandLine.getSwitchValue("enable-features") app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature) if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222") - return logger -} + + const onboardingRoot = yield* createOnboardingTestRoot() + app.setPath( + "userData", + onboardingRoot ? path.join(onboardingRoot, "desktop") : path.join(app.getPath("appData"), appID), + ) + if (onboardingRoot) app.setPath("sessionData", path.join(onboardingRoot, "session")) +}) export function acquireApplicationLock() { if (app.requestSingleInstanceLock()) return true @@ -65,68 +54,76 @@ export function acquireApplicationLock() { return false } -export function preferApplicationEnvironment(logger: DesktopLogger) { +export const prepareApplicationEnvironment = Effect.gen(function* () { + yield* loadSystemCertificates + yield* loadProxyEnvironment +}) + +export const preferApplicationEnvironment = Effect.gen(function* () { const shell = process.platform === "win32" ? null : getUserShell() - const shellEnv = shell ? loadShellEnv(shell, logger) : null - if (!shellEnv?.XDG_STATE_HOME) delete process.env.XDG_STATE_HOME - Object.assign(process.env, { - ...shellEnv, - OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", - OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", - OPENCODE_CLIENT: "desktop", + const shellEnv = shell ? yield* loadShellEnv(shell) : null + yield* Effect.sync(() => { + if (!shellEnv?.XDG_STATE_HOME) delete process.env.XDG_STATE_HOME + Object.assign(process.env, { + ...shellEnv, + OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", + OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", + OPENCODE_CLIENT: "desktop", + }) }) -} +}) -export function prepareDesktop(logger: DesktopLogger) { - return Effect.gen(function* () { - yield* Effect.promise(() => cleanupStoreFiles(app.getPath("userData"))).pipe( - Effect.tap((result) => - Effect.sync(() => { - if (result.deleted.length === 0) return - logger.log("cleaned scoped store files", { count: result.deleted.length, scanned: result.scanned }) - }), - ), - Effect.catch((error) => Effect.sync(() => logger.warn("failed to clean scoped store files", error))), - ) - app.setAsDefaultProtocolClient("opencode") - registerRendererProtocol() - setDockIcon() - }) -} +export const prepareDesktop = Effect.gen(function* () { + const path = yield* Path.Path + const paths = yield* DesktopPaths.resolve + const context = yield* Effect.context() + yield* cleanupStoreFiles(app.getPath("userData")).pipe( + Effect.tap((result) => + result.deleted.length === 0 + ? Effect.void + : Effect.logInfo("cleaned scoped store files", { count: result.deleted.length, scanned: result.scanned }), + ), + Effect.catch((error) => Effect.logWarning("failed to clean scoped store files", { error })), + ) + app.setAsDefaultProtocolClient("opencode") + registerRendererProtocol(path, paths.rendererRoot, Effect.runForkWith(context)) + setDockIcon(path, paths) +}) -export function loadProxyEnvironment(logger: DesktopLogger) { - ensureLoopbackNoProxy() - try { +export const loadProxyEnvironment = Effect.gen(function* () { + yield* Effect.try(() => { + ensureLoopbackNoProxy() // Electron 41.2 has a newer Node API than the current @types/node package. const proxyAwareHttp = http as typeof http & { setGlobalProxyFromEnv(): void } proxyAwareHttp.setGlobalProxyFromEnv() - } catch (error) { - logger.warn("failed to load proxy environment", error) - } -} + }).pipe(Effect.catch((error) => Effect.logWarning("failed to load proxy environment", { error }))) +}) -function createOnboardingTestRoot() { +const createOnboardingTestRoot = Effect.fn("Application.createOnboardingTestRoot")(function* () { if (!testOnboarding) return undefined - const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`) - rmSync(root, { recursive: true, force: true }) - ;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) => - mkdirSync(join(root, dir), { recursive: true }), + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const root = path.join(tmpdir(), `opencode-onboarding-${randomUUID()}`) + yield* fs.remove(root, { recursive: true, force: true }) + yield* Effect.forEach( + ["data", "config", "cache", "state", "desktop", "session"], + (dir) => fs.makeDirectory(path.join(root, dir), { recursive: true }), + { discard: true }, ) process.env.OPENCODE_DB = ":memory:" - process.env.XDG_DATA_HOME = join(root, "data") - process.env.XDG_CONFIG_HOME = join(root, "config") - process.env.XDG_CACHE_HOME = join(root, "cache") - process.env.XDG_STATE_HOME = join(root, "state") + process.env.XDG_DATA_HOME = path.join(root, "data") + process.env.XDG_CONFIG_HOME = path.join(root, "config") + process.env.XDG_CACHE_HOME = path.join(root, "cache") + process.env.XDG_STATE_HOME = path.join(root, "state") return root -} +}) -function loadSystemCertificates(logger: DesktopLogger) { - try { +const loadSystemCertificates = Effect.try({ + try: () => { setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])]) - } catch (error) { - logger.warn("failed to load system certificates", error) - } -} + }, + catch: (error) => error, +}).pipe(Effect.catch((error) => Effect.logWarning("failed to load system certificates", { error }))) function ensureLoopbackNoProxy() { const loopback = ["127.0.0.1", "localhost", "::1"] diff --git a/packages/desktop/src/main/lifecycle/index.ts b/packages/desktop/src/main/lifecycle/index.ts index 36c20656730..18b02ce06e0 100644 --- a/packages/desktop/src/main/lifecycle/index.ts +++ b/packages/desktop/src/main/lifecycle/index.ts @@ -1,81 +1,148 @@ +export * as ApplicationLifecycle from "./index" + import { app, BrowserWindow } from "electron" import type { Event } from "electron" +import { Context, Effect, FileSystem, Layer, Path } from "effect" import { DeepLinksOpened } from "../../shared/ipc-rpc/events" import { emitIpcEvent } from "../ipc-events" -import { writeLog, type DesktopLogger } from "../native/logging" +import { DesktopLogging, scoped } from "../native/logging" +import { DesktopPaths } from "../paths" import { safeWebContentsURL } from "../windows/state" -import { getLastFocusedWindow, restoreMainWindows, setAppQuitting, setRelaunchHandler } from "../windows" +import { createMainWindow, getLastFocusedWindow, restoreMainWindows, setAppQuitting, setRelaunchHandler } from "../windows" +import { Shutdown } from "./shutdown" -export function createApplicationLifecycle(logger: DesktopLogger) { - const pendingDeepLinks: string[] = [] - const wsl = { stop: async () => {} } - const emitDeepLinks = (urls: string[]) => { - if (!urls.length) return - pendingDeepLinks.push(...urls) - const win = getLastFocusedWindow() - if (win) emitIpcEvent(win.webContents, new DeepLinksOpened({ urls })) - } - const relaunch = () => { - setAppQuitting() - void wsl.stop().finally(() => { - app.relaunch() - app.quit() - }) - } - - app.on("second-instance", (_event: Event, argv: string[]) => { - const urls = argv.filter((arg) => arg.startsWith("opencode://")) - if (urls.length) { - logger.log("deep link received via second-instance", { urls }) - emitDeepLinks(urls) - } - const win = getLastFocusedWindow() - if (!win) return - win.show() - win.focus() - }) - app.on("open-url", (event: Event, url: string) => { - event.preventDefault() - logger.log("deep link received via open-url", { url }) - emitDeepLinks([url]) - }) - app.on("before-quit", () => { - setAppQuitting() - void wsl.stop() - }) - app.on("will-quit", () => { - setAppQuitting() - void wsl.stop() - }) - app.on("child-process-gone", (_event, details) => { - writeLog("utility", "child process gone", { details }, "error") - }) - app.on("render-process-gone", (_event, webContents, details) => { - writeLog("window", "app render process gone", { url: safeWebContentsURL(webContents), details }, "error") - }) - setRelaunchHandler(relaunch) - ;(["SIGINT", "SIGTERM"] as const).forEach((signal) => { - process.on(signal, () => { - setAppQuitting() - void wsl.stop().finally(() => app.quit()) - }) - }) - - return { - relaunch, - prepareToRestart: () => wsl.stop(), - setWslShutdown(stop: () => Promise) { - wsl.stop = stop - }, - consumeInitialDeepLinks: () => pendingDeepLinks.splice(0), - restoreWindows() { - app.on("window-all-closed", () => { - if (process.platform !== "darwin") app.quit() - }) - app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) restoreMainWindows() - }) - return restoreMainWindows() - }, - } +export interface Interface { + readonly relaunch: () => void + readonly prepareToRestart: Effect.Effect + readonly consumeInitialDeepLinks: () => string[] + readonly createWindow: () => BrowserWindow + readonly restoreWindows: () => BrowserWindow[] } + +export class Service extends Context.Service()("opencode/desktop/ApplicationLifecycle") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const logging = yield* DesktopLogging.Service + const shutdown = yield* Shutdown.Service + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const paths = yield* DesktopPaths.resolve + const context = yield* Effect.context() + const runFork = Effect.runForkWith(context) + const runPromise = Effect.runPromiseWith(context) + const windows = { fs, path, paths, runFork, exportDebug: () => runPromise(logging.exportDebug) } + const createWindow = () => createMainWindow(windows) + const restoreWindows = () => restoreMainWindows(windows) + const pendingDeepLinks: string[] = [] + let shutdownReady = false + const prepareToRestart = shutdown.run.pipe(Effect.ensuring(Effect.sync(() => (shutdownReady = true)))) + const emitDeepLinks = (urls: string[]) => { + if (!urls.length) return + pendingDeepLinks.push(...urls) + const win = getLastFocusedWindow() + if (win) emitIpcEvent(win.webContents, new DeepLinksOpened({ urls })) + } + const relaunch = () => { + setAppQuitting() + runFork( + prepareToRestart.pipe( + Effect.ensuring( + Effect.sync(() => { + app.relaunch() + app.quit() + }), + ), + ), + ) + } + const secondInstance = (_event: Event, argv: string[]) => { + const urls = argv.filter((arg) => arg.startsWith("opencode://")) + if (urls.length) { + runFork(Effect.logInfo("deep link received via second-instance", { urls })) + emitDeepLinks(urls) + } + const win = getLastFocusedWindow() + if (!win) return + win.show() + win.focus() + } + const openUrl = (event: Event, url: string) => { + event.preventDefault() + runFork(Effect.logInfo("deep link received via open-url", { url })) + emitDeepLinks([url]) + } + const beforeQuit = (event: Event) => { + setAppQuitting() + if (shutdownReady) return + event.preventDefault() + runFork(prepareToRestart.pipe(Effect.ensuring(Effect.sync(() => app.quit())))) + } + const willQuit = () => { + setAppQuitting() + runFork(shutdown.run) + } + const childProcessGone = (_event: Event, details: Electron.Details) => { + runFork(scoped("utility", Effect.logError("child process gone", { details }))) + } + const renderProcessGone = ( + _event: Event, + webContents: Electron.WebContents, + details: Electron.RenderProcessGoneDetails, + ) => { + runFork(scoped("window", Effect.logError("app render process gone", { url: safeWebContentsURL(webContents), details }))) + } + const signal = () => { + setAppQuitting() + runFork(prepareToRestart.pipe(Effect.ensuring(Effect.sync(() => app.quit())))) + } + const windowAllClosed = () => { + if (process.platform !== "darwin") app.quit() + } + const activate = () => { + if (BrowserWindow.getAllWindows().length === 0) restoreWindows() + } + const resetRelaunchHandler = setRelaunchHandler(relaunch) + let windowsWired = false + + app.on("second-instance", secondInstance) + app.on("open-url", openUrl) + app.on("before-quit", beforeQuit) + app.on("will-quit", willQuit) + app.on("child-process-gone", childProcessGone) + app.on("render-process-gone", renderProcessGone) + process.on("SIGINT", signal) + process.on("SIGTERM", signal) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + app.off("second-instance", secondInstance) + app.off("open-url", openUrl) + app.off("before-quit", beforeQuit) + app.off("will-quit", willQuit) + app.off("child-process-gone", childProcessGone) + app.off("render-process-gone", renderProcessGone) + app.off("window-all-closed", windowAllClosed) + app.off("activate", activate) + process.off("SIGINT", signal) + process.off("SIGTERM", signal) + resetRelaunchHandler() + }), + ) + + return Service.of({ + relaunch, + prepareToRestart, + consumeInitialDeepLinks: () => pendingDeepLinks.splice(0), + createWindow, + restoreWindows: () => { + if (!windowsWired) { + windowsWired = true + app.on("window-all-closed", windowAllClosed) + app.on("activate", activate) + } + return restoreWindows() + }, + }) + }), +) diff --git a/packages/desktop/src/main/lifecycle/onboarding.ts b/packages/desktop/src/main/lifecycle/onboarding.ts index 4ba8358391c..39e5437b30c 100644 --- a/packages/desktop/src/main/lifecycle/onboarding.ts +++ b/packages/desktop/src/main/lifecycle/onboarding.ts @@ -1,16 +1,23 @@ -import { existsSync, readdirSync } from "node:fs" -import { mkdir } from "node:fs/promises" -import { join } from "node:path" import { app } from "electron" -import { writeLog } from "../native/logging" +import { Effect, FileSystem, Option, Path } from "effect" +import { scoped } from "../native/logging" import { hasExistingAppState } from "../storage/install-state" import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY } from "../storage/keys" import { getStore } from "../storage/store" const DEFAULT_PROJECT_DIR = "Default Project" -export function initializeFirstLaunchOnboarding(userDataPath: string) { - const entries = existsSync(userDataPath) ? readdirSync(userDataPath, { withFileTypes: true }) : [] +export const initializeFirstLaunchOnboarding = Effect.fn("Onboarding.initialize")(function* (userDataPath: string) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const names = (yield* fs.exists(userDataPath)) ? yield* fs.readDirectory(userDataPath) : [] + const entries = yield* Effect.forEach( + names, + Effect.fnUntraced(function* (name) { + const info = yield* fs.stat(path.join(userDataPath, name)).pipe(Effect.option) + return { name, directory: Option.isSome(info) && info.value.type === "Directory" } + }), + ) const store = getStore() const current = store.get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY) if (typeof current === "boolean") return current @@ -18,24 +25,29 @@ export function initializeFirstLaunchOnboarding(userDataPath: string) { const complete = hasExistingAppState(entries) store.set(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, complete) return complete -} +}) -export function isFirstLaunchOnboardingPending() { +export const isFirstLaunchOnboardingPending = Effect.fn("Onboarding.isPending")(function* () { const pending = getStore().get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY) !== true - writeLog("onboarding", "first launch onboarding pending checked", { pending }) + yield* scoped("onboarding", Effect.logInfo("first launch onboarding pending checked", { pending })) return pending -} +}) -export async function finishFirstLaunchOnboarding(createDefaultProject: boolean) { - if (!isFirstLaunchOnboardingPending()) { - writeLog("onboarding", "first launch onboarding already completed") +export const finishFirstLaunchOnboarding = Effect.fn("Onboarding.finish")(function* (createDefaultProject: boolean) { + if (!(yield* isFirstLaunchOnboardingPending())) { + yield* scoped("onboarding", Effect.logInfo("first launch onboarding already completed")) return null } - const defaultProject = createDefaultProject ? join(app.getPath("documents"), DEFAULT_PROJECT_DIR) : null - if (defaultProject) await mkdir(defaultProject, { recursive: true }) + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const defaultProject = createDefaultProject ? path.join(app.getPath("documents"), DEFAULT_PROJECT_DIR) : null + if (defaultProject) yield* fs.makeDirectory(defaultProject, { recursive: true }) getStore().set(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, true) - writeLog("onboarding", "first launch onboarding completed", { createDefaultProject, defaultProject }) + yield* scoped( + "onboarding", + Effect.logInfo("first launch onboarding completed", { createDefaultProject, defaultProject }), + ) return defaultProject -} +}) diff --git a/packages/desktop/src/main/lifecycle/shutdown.ts b/packages/desktop/src/main/lifecycle/shutdown.ts new file mode 100644 index 00000000000..a70d590a7e0 --- /dev/null +++ b/packages/desktop/src/main/lifecycle/shutdown.ts @@ -0,0 +1,28 @@ +export * as Shutdown from "./shutdown" + +import { Context, Effect, Layer } from "effect" + +export interface Interface { + readonly add: (effect: Effect.Effect) => Effect.Effect<() => void> + readonly run: Effect.Effect +} + +export class Service extends Context.Service()("opencode/desktop/Shutdown") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const effects = new Set>() + const run = yield* Effect.cached( + Effect.suspend(() => Effect.forEach(effects, (effect) => effect, { concurrency: "unbounded", discard: true })), + ) + return Service.of({ + add: (effect) => + Effect.sync(() => { + effects.add(effect) + return () => effects.delete(effect) + }), + run, + }) + }), +) diff --git a/packages/desktop/src/main/native/logging.ts b/packages/desktop/src/main/native/logging.ts index 3aa5030dd8a..1283fd5b67d 100644 --- a/packages/desktop/src/main/native/logging.ts +++ b/packages/desktop/src/main/native/logging.ts @@ -1,9 +1,9 @@ -import { MainLogger } from "electron-log" +export * as DesktopLogging from "./logging" + import log from "electron-log/main.js" import { app, crashReporter, netLog, shell } from "electron" -import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs" +import { Context, Effect, FileSystem, Layer, Logger, Option, Path, References } from "effect" import { ZipWriter, BlobWriter, BlobReader } from "@zip.js/zip.js" -import { dirname, join } from "node:path" import { homedir } from "node:os" import { VERSION } from "../constants" @@ -17,97 +17,160 @@ let root = "" let run = "" let netLogPath: string | undefined -let logger: MainLogger -export const getLogger = () => logger -export type DesktopLogger = ReturnType - -export function initLogging() { - initRunDirectory() - log.transports.file.maxSize = 5 * 1024 * 1024 - log.transports.file.resolvePathFn = (_vars, message) => - join( - run, - `${safeLogName(message?.scope ?? (message?.variables?.processType === "renderer" ? "renderer" : "main"))}.log`, - ) - log.initialize({ preload: false, spyRendererConsole: true }) - initConsoleTransport() - cleanup() - return (logger = log) +export interface Interface { + readonly startNetwork: Effect.Effect + readonly exportDebug: Effect.Effect } -export function initCrashReporter() { - const dir = join(app.getPath("userData"), "Crashpad") - mkdirSync(dir, { recursive: true }) - app.setPath("crashDumps", dir) - crashReporter.start({ uploadToServer: false, compress: true }) - writeLog("crash", "crash reporter started", { path: dir }) -} +export class Service extends Context.Service()("opencode/desktop/DesktopLogging") {} -async function startNetLog() { - if (netLog.currentlyLogging) return - netLogPath = join(run, "network.netlog") - await netLog.startLogging(netLogPath, { captureMode: "default", maxFileSize: NET_LOG_SIZE }) - writeLog("network", "net log started", { path: netLogPath }) -} +const serviceLayer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + yield* initLogging(fs, path).pipe(Effect.orDie) + yield* initCrashReporter(fs, path).pipe(Effect.orDie) + yield* Effect.logInfo("app starting", { + version: VERSION, + packaged: app.isPackaged, + onboardingTest: process.env.OPENCODE_TEST_ONBOARDING === "1", + }) + const exportDebug = exportDebugLogsEffect(fs, path).pipe(Effect.orDie) + return Service.of({ + startNetwork: startNetLog(path).pipe( + Effect.catch((error) => Effect.logWarning("failed to start net log", { error })), + ), + exportDebug, + }) + }), +) -export function startNetworkLogging() { - return startNetLog().catch((error) => logger.warn("failed to start net log", error)) -} - -export async function exportDebugLogs() { - const restartNetLog = netLog.currentlyLogging - if (restartNetLog) { - await netLog.stopLogging().catch((error) => writeLog("network", "failed to stop net log", { error })) - } - - const output = join(app.getPath("downloads"), `opencode-debug-${stamp()}.zip`) +const nativeLogger = Logger.make((options) => { try { - writeLog("main", "exporting debug logs", { output }) - await writeZip(output, [ - { name: "manifest.json", data: Buffer.from(JSON.stringify(manifest(), null, 2)) }, - ...collect(root, "desktop"), - ...serverLogRoots().flatMap((dir, i) => collect(dir, `server-${i + 1}`)), - ...collect(app.getPath("crashDumps"), "crashpad"), - ]) - shell.showItemInFolder(output) - return output - } finally { - if (restartNetLog) { - await startNetLog().catch((error) => writeLog("network", "failed to restart net log", { error })) + if (!run) return + const entry = Logger.formatStructured.log(options) + const scope = typeof entry.annotations.scope === "string" ? entry.annotations.scope : "main" + const annotations = Object.fromEntries(Object.entries(entry.annotations).filter(([key]) => key !== "scope")) + const context = { + ...(Object.keys(annotations).length === 0 ? {} : { annotations }), + ...(Object.keys(entry.spans).length === 0 ? {} : { spans: entry.spans }), + ...(entry.cause === undefined ? {} : { cause: entry.cause }), } + const messages = Array.isArray(options.message) ? options.message : [options.message] + log.scope(safeLogName(scope))[methods[options.logLevel]]( + ...messages, + ...(Object.keys(context).length === 0 ? [] : [context]), + ) + } catch { + // Logging must not interrupt application work. } +}) + +const methods = { + All: "silly", + Trace: "silly", + Debug: "debug", + Info: "info", + Warn: "warn", + Error: "error", + Fatal: "error", + None: "silly", +} as const + +const nativeLoggerLayer = Layer.merge( + Logger.layer([nativeLogger], { mergeWithExisting: false }), + Layer.succeed(References.MinimumLogLevel, "All"), +) + +export const layer = serviceLayer.pipe(Layer.provideMerge(nativeLoggerLayer)) + +function initLogging(fs: FileSystem.FileSystem, path: Path.Path) { + return Effect.gen(function* () { + yield* initRunDirectory(fs, path) + yield* Effect.sync(() => { + log.transports.file.maxSize = 5 * 1024 * 1024 + log.transports.file.resolvePathFn = (_vars, message) => + path.join( + run, + `${safeLogName(message?.scope ?? (message?.variables?.processType === "renderer" ? "renderer" : "main"))}.log`, + ) + log.initialize({ preload: false, spyRendererConsole: true }) + initConsoleTransport() + }) + yield* cleanup(fs, path) + }) } -export function writeLog( - name: string, - message: string, - extra?: Record, - level: "info" | "warn" | "error" = "info", -) { - if (!run) return - const scoped = log.scope(safeLogName(name)) - if (extra !== undefined) { - scoped[level](message, extra) - return - } - scoped[level](message) +function initCrashReporter(fs: FileSystem.FileSystem, path: Path.Path) { + return Effect.gen(function* () { + const dir = path.join(app.getPath("userData"), "Crashpad") + yield* fs.makeDirectory(dir, { recursive: true }) + yield* Effect.sync(() => { + app.setPath("crashDumps", dir) + crashReporter.start({ uploadToServer: false, compress: true }) + }) + yield* scoped("crash", Effect.logInfo("crash reporter started", { path: dir })) + }) } -export function tail(): string { - try { +function startNetLog(path: Path.Path) { + if (netLog.currentlyLogging) return Effect.void + const target = path.join(run, "network.netlog") + netLogPath = target + return Effect.tryPromise(() => netLog.startLogging(target, { captureMode: "default", maxFileSize: NET_LOG_SIZE })).pipe( + Effect.tap(() => scoped("network", Effect.logInfo("net log started", { path: target }))), + ) +} + +function exportDebugLogsEffect(fs: FileSystem.FileSystem, path: Path.Path) { + return Effect.gen(function* () { + const restartNetLog = netLog.currentlyLogging + if (restartNetLog) { + yield* Effect.tryPromise(() => netLog.stopLogging()).pipe( + Effect.catch((error) => scoped("network", Effect.logWarning("failed to stop net log", { error }))), + ) + } + + const output = path.join(app.getPath("downloads"), `opencode-debug-${stamp()}.zip`) + return yield* Effect.gen(function* () { + yield* Effect.logInfo("exporting debug logs", { output }) + yield* writeZip(fs, output, [ + { name: "manifest.json", data: Buffer.from(JSON.stringify(manifest(path), null, 2)) }, + ...(yield* collect(fs, path, root, "desktop")), + ...(yield* Effect.forEach(serverLogRoots(path), (dir, i) => collect(fs, path, dir, `server-${i + 1}`))).flat(), + ...(yield* collect(fs, path, app.getPath("crashDumps"), "crashpad")), + ]) + yield* Effect.sync(() => shell.showItemInFolder(output)) + return output + }).pipe( + Effect.ensuring( + restartNetLog + ? startNetLog(path).pipe( + Effect.catch((error) => + scoped("network", Effect.logWarning("failed to restart net log", { error })), + ), + ) + : Effect.void, + ), + ) + }) +} + +export const tail = Effect.fn("DesktopLogging.tail")(function* () { + const fs = yield* FileSystem.FileSystem + return yield* Effect.gen(function* () { const path = log.transports.file.getFile().path - const contents = readFileSync(path, "utf8") + const contents = yield* fs.readFileString(path) const lines = contents.split("\n") return lines.slice(Math.max(0, lines.length - TAIL_LINES)).join("\n") - } catch { - return "" - } -} + }).pipe(Effect.catch(() => Effect.succeed(""))) +}) -function initRunDirectory() { - root = join(app.getPath("userData"), "logs") - run = join(root, stamp()) - mkdirSync(run, { recursive: true }) +function initRunDirectory(fs: FileSystem.FileSystem, path: Path.Path) { + root = path.join(app.getPath("userData"), "logs") + run = path.join(root, stamp()) + return fs.makeDirectory(run, { recursive: true }) } function stamp() { @@ -121,22 +184,27 @@ function safeLogName(name: string) { return name.replace(/[^a-z0-9_.-]/gi, "_") || "main" } -function cleanup() { - const dir = root || dirname(log.transports.file.getFile().path) - const cutoff = Date.now() - MAX_LOG_AGE_DAYS * 24 * 60 * 60 * 1000 - - for (const entry of readdirSync(dir)) { - const file = join(dir, entry) - try { - const info = statSync(file) - if (info.mtimeMs < cutoff) rmSync(file, { recursive: true, force: true }) - } catch { - continue - } - } +function cleanup(fs: FileSystem.FileSystem, path: Path.Path) { + return Effect.gen(function* () { + const dir = root || path.dirname(log.transports.file.getFile().path) + const cutoff = Date.now() - MAX_LOG_AGE_DAYS * 24 * 60 * 60 * 1000 + const entries = yield* fs.readDirectory(dir) + yield* Effect.forEach( + entries, + (entry) => + Effect.gen(function* () { + const file = path.join(dir, entry) + const info = yield* fs.stat(file) + if (Option.getOrElse(info.mtime, () => new Date(0)).getTime() < cutoff) { + yield* fs.remove(file, { recursive: true, force: true }) + } + }).pipe(Effect.catch(() => Effect.void)), + { discard: true }, + ) + }) } -function manifest() { +function manifest(path: Path.Path) { return { generated: new Date().toISOString(), version: VERSION, @@ -150,48 +218,54 @@ function manifest() { logs: root, currentRun: run, crashDumps: app.getPath("crashDumps"), - serverLogs: serverLogRoots(), + serverLogs: serverLogRoots(path), netLog: netLogPath, } } -function serverLogRoots() { - const xdgData = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share") - return [...new Set([join(xdgData, "opencode", "log"), join(app.getPath("userData"), "opencode", "log")])] +function serverLogRoots(path: Path.Path) { + const xdgData = process.env.XDG_DATA_HOME || path.join(homedir(), ".local", "share") + return [ + ...new Set([path.join(xdgData, "opencode", "log"), path.join(app.getPath("userData"), "opencode", "log")]), + ] } -type Entry = { name: string; path?: string; data?: Buffer } +type Entry = { name: string; path: string } | { name: string; data: Uint8Array } -function collect(dir: string, prefix: string): Entry[] { - if (!existsSync(dir)) return [] - const cutoff = Date.now() - EXPORT_WINDOW - const result: Entry[] = [] - const walk = (current: string) => { - for (const entry of readdirSync(current)) { - const file = join(current, entry) - const info = statSync(file) - if (info.isDirectory()) { - walk(file) - continue - } - if (info.mtimeMs < cutoff) continue - if (info.size > MAX_EXPORT_FILE_SIZE) continue - if (file.endsWith(".heapsnapshot")) continue - result.push({ name: join(prefix, file.slice(dir.length + 1)).replace(/\\/g, "/"), path: file }) - } - } - walk(dir) - return result +function collect(fs: FileSystem.FileSystem, path: Path.Path, dir: string, prefix: string) { + return Effect.gen(function* () { + if (!(yield* fs.exists(dir).pipe(Effect.orElseSucceed(() => false)))) return [] + const cutoff = Date.now() - EXPORT_WINDOW + const entries = yield* fs.readDirectory(dir, { recursive: true }) + return (yield* Effect.forEach(entries, (entry) => + Effect.gen(function* () { + const file = path.join(dir, entry) + const info = yield* fs.stat(file) + if (info.type === "Directory") return null + if (Option.getOrElse(info.mtime, () => new Date(0)).getTime() < cutoff) return null + if (info.size > FileSystem.Size(MAX_EXPORT_FILE_SIZE)) return null + if (file.endsWith(".heapsnapshot")) return null + return { name: path.join(prefix, entry).replace(/\\/g, "/"), path: file } + }), + )).filter((entry) => entry !== null) + }) } -async function writeZip(output: string, entries: Entry[]) { - const writer = new ZipWriter(new BlobWriter("application/zip")) - for (const entry of entries) { - const data = entry.data ?? readFileSync(entry.path!) - await writer.add(entry.name, new BlobReader(new Blob([new Uint8Array(data)]))) - } - const zip = await writer.close() - writeFileSync(output, Buffer.from(await zip.arrayBuffer())) +function writeZip(fs: FileSystem.FileSystem, output: string, entries: Entry[]) { + return Effect.gen(function* () { + const writer = new ZipWriter(new BlobWriter("application/zip")) + yield* Effect.forEach( + entries, + (entry) => + Effect.gen(function* () { + const data = "data" in entry ? entry.data : yield* fs.readFile(entry.path) + yield* Effect.tryPromise(() => writer.add(entry.name, new BlobReader(new Blob([new Uint8Array(data)])))) + }), + { concurrency: 1, discard: true }, + ) + const zip = yield* Effect.tryPromise(() => writer.close()) + yield* fs.writeFile(output, new Uint8Array(yield* Effect.tryPromise(() => zip.arrayBuffer()))) + }) } function initConsoleTransport() { @@ -214,3 +288,7 @@ function initConsoleTransport() { function isBrokenPipe(err: unknown) { return typeof err === "object" && err !== null && "code" in err && err.code === "EPIPE" } + +export function scoped(name: string, effect: Effect.Effect) { + return effect.pipe(Effect.annotateLogs("scope", name)) +} diff --git a/packages/desktop/src/main/native/menu-actions.ts b/packages/desktop/src/main/native/menu-actions.ts index d4eebbd5559..831b9487823 100644 --- a/packages/desktop/src/main/native/menu-actions.ts +++ b/packages/desktop/src/main/native/menu-actions.ts @@ -1,9 +1,10 @@ import { BrowserWindow } from "electron" import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" -import { createMainWindow, updateTitlebar } from "../windows" +import { updateTitlebar } from "../windows" export type DesktopMenuActionHandlers = Partial<{ checkForUpdates: () => void + createWindow: () => void relaunch: () => void }> @@ -20,7 +21,7 @@ export function runDesktopMenuAction( handlers.relaunch?.() return case "window.new": - createMainWindow() + handlers.createWindow?.() return case "window.close": win?.close() diff --git a/packages/desktop/src/main/native/menu.ts b/packages/desktop/src/main/native/menu.ts index b81158e424b..535eb8c7b09 100644 --- a/packages/desktop/src/main/native/menu.ts +++ b/packages/desktop/src/main/native/menu.ts @@ -10,13 +10,14 @@ import { MenuCommandTriggered } from "../../shared/ipc-rpc/events" import { emitIpcEvent } from "../ipc-events" import { UPDATER_ENABLED } from "../constants" -import { openExternalURL } from "../files" import { runDesktopMenuAction } from "./menu-actions" import { nativeT } from "./translations" type Deps = { trigger: (id: string) => void checkForUpdates: () => void + createWindow: () => void + openExternal: (url: string) => void relaunch: () => void } @@ -59,12 +60,13 @@ function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOpt item.click = () => runDesktopMenuAction(BrowserWindow.getFocusedWindow(), action, { checkForUpdates: deps.checkForUpdates, + createWindow: deps.createWindow, relaunch: deps.relaunch, }) } if (entry.href) { const href = entry.href - item.click = () => openExternalURL(href) + item.click = () => deps.openExternal(href) } return item diff --git a/packages/desktop/src/main/paths.ts b/packages/desktop/src/main/paths.ts index fb82348b883..e0b74193293 100644 --- a/packages/desktop/src/main/paths.ts +++ b/packages/desktop/src/main/paths.ts @@ -1,7 +1,19 @@ -import { dirname, join } from "node:path" -import { fileURLToPath } from "node:url" +export * as DesktopPaths from "./paths" -export const mainBundleRoot = dirname(fileURLToPath(import.meta.url)) -export const developmentResourcesRoot = join(mainBundleRoot, "../../resources") -export const preloadPath = join(mainBundleRoot, "../preload/index.js") -export const rendererRoot = join(mainBundleRoot, "../renderer") +import { Effect, Path } from "effect" + +export interface Resolved { + readonly developmentResourcesRoot: string + readonly preloadPath: string + readonly rendererRoot: string +} + +export const resolve = Effect.gen(function* () { + const path = yield* Path.Path + const root = path.dirname(yield* path.fromFileUrl(new URL(import.meta.url))) + return { + developmentResourcesRoot: path.join(root, "../../resources"), + preloadPath: path.join(root, "../preload/index.js"), + rendererRoot: path.join(root, "../renderer"), + } satisfies Resolved +}).pipe(Effect.orDie) diff --git a/packages/desktop/src/main/service/background-service.ts b/packages/desktop/src/main/service/background-service.ts index 0d6fb523b5f..acae80d9a40 100644 --- a/packages/desktop/src/main/service/background-service.ts +++ b/packages/desktop/src/main/service/background-service.ts @@ -1,20 +1,21 @@ import { Service } from "@opencode-ai/client/service" import { execFile } from "node:child_process" -import { existsSync } from "node:fs" -import { chmod, copyFile, mkdir, readdir, rename, rm } from "node:fs/promises" -import { dirname, join } from "node:path" import { promisify } from "node:util" import { app } from "electron" +import { Effect, FileSystem, Path } from "effect" import { parseCliVersion } from "./cli-version" -import { developmentResourcesRoot } from "../paths" +import { DesktopPaths } from "../paths" const execFileAsync = promisify(execFile) -type Logger = { - log(message: string, meta?: Record): void - error(message: string, meta?: Record): void -} -export async function startBackgroundCli(logger: Logger) { +export const startBackgroundCli = Effect.fn("BackgroundService.start")(function* () { + return yield* start().pipe(Effect.orDie) +}) + +const start = Effect.fn("BackgroundService.startInternal")(function* () { + const path = yield* Path.Path + const context = yield* Effect.context() + const runFork = Effect.runForkWith(context) const isolated = !app.isPackaged && process.env.OPENCODE_DESKTOP_ISOLATED_SERVER === "1" const development = !app.isPackaged && process.env.OPENCODE_DESKTOP_CLI_DEV const developmentVersion = process.env.OPENCODE_VERSION ?? "local" @@ -31,24 +32,27 @@ export async function startBackgroundCli(logger: Logger) { ], binary: undefined, } - : await resolveBundledCli(isolated, logger) + : yield* resolveBundledCli(isolated) if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData") - const service = await Service.ensure({ - file: - isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local" - ? join(app.getPath("userData"), "opencode", "service-local.json") - : undefined, - version: cli.version, - command: [...cli.command, "serve", "--service", ...(isolated ? ["--port", "0"] : [])], - onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }), - }) + const service = yield* Effect.tryPromise(() => + Service.ensure({ + file: + isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local" + ? path.join(app.getPath("userData"), "opencode", "service-local.json") + : undefined, + version: cli.version, + command: [...cli.command, "serve", "--service", ...(isolated ? ["--port", "0"] : [])], + onStart: (reason, previousVersion) => + runFork(Effect.logInfo("v2 CLI background service starting", { reason, previousVersion })), + }), + ) if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication") - logger.log("v2 CLI background service ready", { + yield* Effect.logInfo("v2 CLI background service ready", { username: service.auth.username, version: cli.version, ...endpoint(service.url), }) - if (isolated && cli.binary) await cleanCliStages(cli.binary, logger) + if (isolated && cli.binary) yield* cleanCliStages(cli.binary) return { url: service.url, username: service.auth.username, @@ -62,73 +66,80 @@ export async function startBackgroundCli(logger: Logger) { output: process.env.OPENCODE_DESKTOP_WSL_CLI_OUTPUT, }, } -} +}) -async function resolveBundledCli(isolated: boolean, logger: Logger) { +const resolveBundledCli = Effect.fn("BackgroundService.resolveBundledCli")(function* (isolated: boolean) { + const path = yield* Path.Path + const paths = yield* DesktopPaths.resolve const bundled = app.isPackaged - ? join(process.resourcesPath, executableName()) - : join(developmentResourcesRoot, isolated ? developmentExecutableName() : executableName()) - logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged }) - const version = parseCliVersion(await run(bundled, ["--version"], logger)) - const binary = app.isPackaged || isolated ? await installCli(bundled, version, logger) : bundled + ? path.join(process.resourcesPath, executableName()) + : path.join(paths.developmentResourcesRoot, isolated ? developmentExecutableName() : executableName()) + yield* Effect.logInfo("v2 CLI executable resolved", { bundled, packaged: app.isPackaged }) + const version = parseCliVersion(yield* run(bundled, ["--version"])) + const binary = app.isPackaged || isolated ? yield* installCli(bundled, version) : bundled return { version, binary, command: [binary] } -} +}) -async function cleanCliStages(binary: string, logger: Logger) { - const current = dirname(binary) - const root = dirname(current) - await Promise.all( - (await readdir(root, { withFileTypes: true })) - .filter((entry) => entry.isDirectory() && join(root, entry.name) !== current) - .map((entry) => - rm(join(root, entry.name), { recursive: true, force: true }).catch((error) => - logger.error("failed to clean staged v2 CLI", { path: join(root, entry.name), error }), - ), - ), +const cleanCliStages = Effect.fn("BackgroundService.cleanCliStages")(function* (binary: string) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const current = path.dirname(binary) + const root = path.dirname(current) + const entries = yield* fs.readDirectory(root) + yield* Effect.forEach( + entries, + Effect.fnUntraced(function* (entry) { + const target = path.join(root, entry) + if (target === current) return + const stat = yield* fs.stat(target).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (stat?.type !== "Directory") return + yield* fs.remove(target, { recursive: true, force: true }).pipe( + Effect.catch((error) => Effect.logError("failed to clean staged v2 CLI", { path: target, error })), + ) + }), + { concurrency: "unbounded" }, ) -} +}) -async function installCli(source: string, version: string, logger: Logger) { - const directory = join(app.getPath("userData"), "cli", version.replace(/[^a-zA-Z0-9._-]/g, "-")) - const destination = join(directory, executableName()) - if (existsSync(destination)) { - logger.log("v2 CLI staged executable reused", { path: destination, version }) +const installCli = Effect.fn("BackgroundService.installCli")(function* (source: string, version: string) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const directory = path.join(app.getPath("userData"), "cli", version.replace(/[^a-zA-Z0-9._-]/g, "-")) + const destination = path.join(directory, executableName()) + if (yield* fs.exists(destination)) { + yield* Effect.logInfo("v2 CLI staged executable reused", { path: destination, version }) return destination } const temp = destination + `.${process.pid}.tmp` - await mkdir(directory, { recursive: true }) - await copyFile(source, temp) - if (process.platform !== "win32") await chmod(temp, 0o755) - await rename(temp, destination).catch(async (error) => { - await rm(temp, { force: true }) - throw error - }) - logger.log("v2 CLI executable staged", { source, path: destination, version }) + yield* fs.makeDirectory(directory, { recursive: true }) + yield* fs.copyFile(source, temp) + if (process.platform !== "win32") yield* fs.chmod(temp, 0o755) + yield* fs + .rename(temp, destination) + .pipe(Effect.catch((error) => fs.remove(temp, { force: true }).pipe(Effect.andThen(Effect.fail(error))))) + yield* Effect.logInfo("v2 CLI executable staged", { source, path: destination, version }) return destination -} +}) -async function run(binary: string, args: string[], logger: Logger) { - logger.log("v2 CLI command started", { binary, args }) - return execFileAsync(binary, args, { windowsHide: true }).then( - (result) => { - const stdout = result.stdout.trim() - const stderr = result.stderr.trim() - logger.log("v2 CLI command completed", { args, stdout, stderr }) - return stdout - }, - (error: unknown) => { +const run = Effect.fn("BackgroundService.run")(function* (binary: string, args: string[]) { + yield* Effect.logInfo("v2 CLI command started", { binary, args }) + const result = yield* Effect.tryPromise(() => execFileAsync(binary, args, { windowsHide: true })).pipe( + Effect.tapError((error) => { const output = error as { stdout?: string; stderr?: string } - logger.error("v2 CLI command failed", { + return Effect.logError("v2 CLI command failed", { args, error: error instanceof Error ? error.message : String(error), stdout: output.stdout?.trim() ?? "", stderr: output.stderr?.trim() ?? "", }) - throw error - }, + }), ) -} + const stdout = result.stdout.trim() + const stderr = result.stderr.trim() + yield* Effect.logInfo("v2 CLI command completed", { args, stdout, stderr }) + return stdout +}) function endpoint(url: string | undefined) { if (!url || !URL.canParse(url)) return {} diff --git a/packages/desktop/src/main/service/initialization.ts b/packages/desktop/src/main/service/initialization.ts index 476abac7f59..4249b9df685 100644 --- a/packages/desktop/src/main/service/initialization.ts +++ b/packages/desktop/src/main/service/initialization.ts @@ -1,4 +1,16 @@ -import { Deferred, Effect } from "effect" +export * as Initialization from "./initialization" + +import type { ServerReadyData } from "../../shared/ipc-contract" +import { Context, Deferred, Effect, Layer } from "effect" + +export interface Interface { + readonly await: Effect.Effect +} + +export class Service extends Context.Service()("opencode/desktop/Initialization") {} + +export const layer = (initialization: Effect.Effect) => + Layer.succeed(Service, Service.of({ await: initialization })) export function forwardInitializationFailure(initialization: Deferred.Deferred) { return (effect: Effect.Effect) => diff --git a/packages/desktop/src/main/service/shell-env.test.ts b/packages/desktop/src/main/service/shell-env.test.ts index e71708ad049..c3a265c22e8 100644 --- a/packages/desktop/src/main/service/shell-env.test.ts +++ b/packages/desktop/src/main/service/shell-env.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test" +import { NodePath } from "@effect/platform-node" +import { Effect } from "effect" import { isNushell, mergeShellEnv, parseShellEnv, resolveUserShell } from "./shell-env" @@ -42,9 +44,10 @@ describe("shell env", () => { }) test("isNushell handles path and binary name", () => { - expect(isNushell("nu")).toBe(true) - expect(isNushell("/opt/homebrew/bin/nu")).toBe(true) - expect(isNushell("C:\\Program Files\\nu.exe")).toBe(true) - expect(isNushell("/bin/zsh")).toBe(false) + const check = (shell: string) => Effect.runSync(isNushell(shell).pipe(Effect.provide(NodePath.layer))) + expect(check("nu")).toBe(true) + expect(check("/opt/homebrew/bin/nu")).toBe(true) + expect(check("C:\\Program Files\\nu.exe")).toBe(true) + expect(check("/bin/zsh")).toBe(false) }) }) diff --git a/packages/desktop/src/main/service/shell-env.ts b/packages/desktop/src/main/service/shell-env.ts index 082ed5e930d..ef54cb53889 100644 --- a/packages/desktop/src/main/service/shell-env.ts +++ b/packages/desktop/src/main/service/shell-env.ts @@ -1,14 +1,10 @@ import { spawnSync } from "node:child_process" import { userInfo } from "node:os" -import { basename } from "node:path" +import { Effect, Path } from "effect" const TIMEOUT = 5_000 type Probe = { type: "Loaded"; value: Record } | { type: "Timeout" } | { type: "Unavailable" } -type ShellEnvLogger = { - log: (message: string) => void -} - export function resolveUserShell(envShell: string | undefined, loginShell: string | null | undefined) { const resolvedLoginShell = loginShell && loginShell !== "unknown" ? loginShell : undefined return envShell || resolvedLoginShell || "/bin/sh" @@ -33,7 +29,7 @@ export function parseShellEnv(out: Buffer) { return env } -function probe(shell: string, mode: "-il" | "-l"): Probe { +const probe = Effect.fn("ShellEnv.probe")(function* (shell: string, mode: "-il" | "-l") { const out = spawnSync(shell, [mode, "-c", "env -0"], { stdio: ["ignore", "pipe", "ignore"], timeout: TIMEOUT, @@ -42,56 +38,57 @@ function probe(shell: string, mode: "-il" | "-l"): Probe { const err = out.error as NodeJS.ErrnoException | undefined if (err) { - if (err.code === "ETIMEDOUT") return { type: "Timeout" } - console.log(`[server] Shell env probe failed for ${shell} ${mode}: ${err.message}`) - return { type: "Unavailable" } + if (err.code === "ETIMEDOUT") return { type: "Timeout" } satisfies Probe + yield* Effect.logWarning(`[server] Shell env probe failed for ${shell} ${mode}: ${err.message}`) + return { type: "Unavailable" } satisfies Probe } if (out.status !== 0) { - console.log(`[server] Shell env probe exited with non-zero status for ${shell} ${mode}`) - return { type: "Unavailable" } + yield* Effect.logWarning(`[server] Shell env probe exited with non-zero status for ${shell} ${mode}`) + return { type: "Unavailable" } satisfies Probe } const env = parseShellEnv(out.stdout) if (Object.keys(env).length === 0) { - console.log(`[server] Shell env probe returned empty env for ${shell} ${mode}`) - return { type: "Unavailable" } + yield* Effect.logWarning(`[server] Shell env probe returned empty env for ${shell} ${mode}`) + return { type: "Unavailable" } satisfies Probe } - return { type: "Loaded", value: env } -} + return { type: "Loaded", value: env } satisfies Probe +}) -export function isNushell(shell: string) { - const name = basename(shell).toLowerCase() +export const isNushell = Effect.fn("ShellEnv.isNushell")(function* (shell: string) { + const path = yield* Path.Path + const name = path.basename(shell).toLowerCase() const raw = shell.toLowerCase() return name === "nu" || name === "nu.exe" || raw.endsWith("\\nu.exe") -} +}) -export function loadShellEnv(shell: string, logger: ShellEnvLogger) { - if (isNushell(shell)) { - logger.log(`[server] Skipping shell env probe for nushell: ${shell}`) +export const loadShellEnv = Effect.fn("ShellEnv.load")(function* (shell: string) { + if (yield* isNushell(shell)) { + yield* Effect.logInfo(`[server] Skipping shell env probe for nushell: ${shell}`) return null } - const interactive = probe(shell, "-il") + const interactive = yield* probe(shell, "-il") if (interactive.type === "Loaded") { - logger.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`) + yield* Effect.logInfo(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`) return interactive.value } if (interactive.type === "Timeout") { - logger.log(`[server] Interactive shell env probe timed out: ${shell}`) + yield* Effect.logInfo(`[server] Interactive shell env probe timed out: ${shell}`) return null } - const login = probe(shell, "-l") + const login = yield* probe(shell, "-l") if (login.type === "Loaded") { - logger.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`) + yield* Effect.logInfo(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`) return login.value } - logger.log(`[server] Falling back to app environment: ${shell}`) + yield* Effect.logInfo(`[server] Falling back to app environment: ${shell}`) return null -} +}) export function mergeShellEnv(shell: Record | null, env: Record) { return { diff --git a/packages/desktop/src/main/storage/cleanup.test.ts b/packages/desktop/src/main/storage/cleanup.test.ts index d818057498b..ef19612fb68 100644 --- a/packages/desktop/src/main/storage/cleanup.test.ts +++ b/packages/desktop/src/main/storage/cleanup.test.ts @@ -1,93 +1,140 @@ +import { NodeFileSystem, NodePath } from "@effect/platform-node" import { afterEach, describe, expect, test } from "bun:test" -import { mkdtemp, readdir, rm, utimes, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" -import { join } from "node:path" +import { Effect, FileSystem, Layer, Path } from "effect" import { cleanupStoreFiles, deleteStoreFileIfEmpty } from "./cleanup" const roots: string[] = [] +const platform = Layer.merge(NodeFileSystem.layer, NodePath.layer) +const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(platform))) -async function tempRoot() { - const root = await mkdtemp(join(tmpdir(), "opencode-store-cleanup-")) +const tempRoot = Effect.fn("StorageTest.tempRoot")(function* () { + const fs = yield* FileSystem.FileSystem + const root = yield* fs.makeTempDirectory({ directory: tmpdir(), prefix: "opencode-store-cleanup-" }) roots.push(root) return root -} - -async function writeStore(root: string, name: string, value: string, modified: Date) { - await writeFile(join(root, name), value) - await utimes(join(root, name), modified, modified) -} - -afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) +const writeStore = Effect.fn("StorageTest.writeStore")(function* ( + root: string, + name: string, + value: string, + modified: Date, +) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + yield* fs.writeFileString(path.join(root, name), value) + yield* fs.utimes(path.join(root, name), modified, modified) +}) + +afterEach(() => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + yield* Effect.forEach(roots.splice(0), (root) => fs.remove(root, { recursive: true, force: true }), { + concurrency: "unbounded", + discard: true, + }) + }), + ), +) + describe("store cleanup", () => { - test("removes empty scoped stores and leaves global stores alone", async () => { - const root = await tempRoot() - const now = new Date("2026-07-01T00:00:00.000Z") - await writeStore(root, "opencode.draft.empty.dat", "{}", now) - await writeStore(root, "opencode.workspace.empty.dat", "{\n}", now) - await writeStore(root, "opencode.global.dat", "{}", now) - await writeStore(root, "opencode.workspace.empty.dat.json", "{}", now) + test("removes empty scoped stores and leaves global stores alone", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const root = yield* tempRoot() + const now = new Date("2026-07-01T00:00:00.000Z") + yield* writeStore(root, "opencode.draft.empty.dat", "{}", now) + yield* writeStore(root, "opencode.workspace.empty.dat", "{\n}", now) + yield* writeStore(root, "opencode.global.dat", "{}", now) + yield* writeStore(root, "opencode.workspace.empty.dat.json", "{}", now) - const result = await cleanupStoreFiles(root, now.getTime()) + const result = yield* cleanupStoreFiles(root, now.getTime()) - expect(result.deleted.sort()).toEqual(["opencode.draft.empty.dat", "opencode.workspace.empty.dat"]) - expect((await readdir(root)).sort()).toEqual(["opencode.global.dat", "opencode.workspace.empty.dat.json"]) - }) + expect(result.deleted.sort()).toEqual(["opencode.draft.empty.dat", "opencode.workspace.empty.dat"]) + expect((yield* fs.readDirectory(root)).sort()).toEqual([ + "opencode.global.dat", + "opencode.workspace.empty.dat.json", + ]) + }), + ), + ) - test("removes stale drafts by age without removing non-empty workspace stores", async () => { - const root = await tempRoot() - const now = new Date("2026-07-01T00:00:00.000Z") - await writeStore(root, "opencode.draft.old.dat", '{"draft:prompt":"hello"}', new Date("2026-05-01T00:00:00.000Z")) - await writeStore(root, "opencode.draft.recent.dat", '{"draft:prompt":"hello"}', now) - await writeStore( - root, - "opencode.workspace.old.dat", - '{"workspace:layout":"wide"}', - new Date("2025-01-01T00:00:00.000Z"), - ) - await writeStore(root, "opencode.workspace.recent.dat", '{"workspace:layout":"wide"}', now) - - const result = await cleanupStoreFiles(root, now.getTime()) - - expect(result.deleted).toEqual(["opencode.draft.old.dat"]) - expect((await readdir(root)).sort()).toEqual([ - "opencode.draft.recent.dat", - "opencode.workspace.old.dat", - "opencode.workspace.recent.dat", - ]) - }) - - test("caps scoped stores by recency", async () => { - const root = await tempRoot() - const now = new Date("2026-07-01T00:00:00.000Z") - await Promise.all( - Array.from({ length: 102 }, (_, index) => - writeStore( + test("removes stale drafts by age without removing non-empty workspace stores", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const root = yield* tempRoot() + const now = new Date("2026-07-01T00:00:00.000Z") + yield* writeStore( root, - `opencode.draft.${index}.dat`, + "opencode.draft.old.dat", '{"draft:prompt":"hello"}', - new Date(now.getTime() - index * 1000), - ), - ), - ) + new Date("2026-05-01T00:00:00.000Z"), + ) + yield* writeStore(root, "opencode.draft.recent.dat", '{"draft:prompt":"hello"}', now) + yield* writeStore( + root, + "opencode.workspace.old.dat", + '{"workspace:layout":"wide"}', + new Date("2025-01-01T00:00:00.000Z"), + ) + yield* writeStore(root, "opencode.workspace.recent.dat", '{"workspace:layout":"wide"}', now) - const result = await cleanupStoreFiles(root, now.getTime()) + const result = yield* cleanupStoreFiles(root, now.getTime()) - const remaining = await readdir(root) + expect(result.deleted).toEqual(["opencode.draft.old.dat"]) + expect((yield* fs.readDirectory(root)).sort()).toEqual([ + "opencode.draft.recent.dat", + "opencode.workspace.old.dat", + "opencode.workspace.recent.dat", + ]) + }), + ), + ) - expect(result.deleted.sort()).toEqual(["opencode.draft.100.dat", "opencode.draft.101.dat"]) - expect(remaining).toHaveLength(100) - }) + test("caps scoped stores by recency", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const root = yield* tempRoot() + const now = new Date("2026-07-01T00:00:00.000Z") + yield* Effect.forEach( + Array.from({ length: 102 }, (_, index) => index), + (index) => + writeStore( + root, + `opencode.draft.${index}.dat`, + '{"draft:prompt":"hello"}', + new Date(now.getTime() - index * 1000), + ), + { concurrency: "unbounded", discard: true }, + ) - test("removes a scoped store immediately when it becomes empty", async () => { - const root = await tempRoot() - await writeStore(root, "opencode.draft.empty.dat", "{}", new Date("2026-07-01T00:00:00.000Z")) - await writeStore(root, "opencode.global.dat", "{}", new Date("2026-07-01T00:00:00.000Z")) + const result = yield* cleanupStoreFiles(root, now.getTime()) + const remaining = yield* fs.readDirectory(root) - expect(await deleteStoreFileIfEmpty(root, "opencode.draft.empty.dat")).toBe(true) - expect(await deleteStoreFileIfEmpty(root, "opencode.global.dat")).toBe(false) - expect(await readdir(root)).toEqual(["opencode.global.dat"]) - }) + expect(result.deleted.sort()).toEqual(["opencode.draft.100.dat", "opencode.draft.101.dat"]) + expect(remaining).toHaveLength(100) + }), + ), + ) + + test("removes a scoped store immediately when it becomes empty", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const root = yield* tempRoot() + yield* writeStore(root, "opencode.draft.empty.dat", "{}", new Date("2026-07-01T00:00:00.000Z")) + yield* writeStore(root, "opencode.global.dat", "{}", new Date("2026-07-01T00:00:00.000Z")) + + expect(yield* deleteStoreFileIfEmpty(root, "opencode.draft.empty.dat")).toBe(true) + expect(yield* deleteStoreFileIfEmpty(root, "opencode.global.dat")).toBe(false) + expect(yield* fs.readDirectory(root)).toEqual(["opencode.global.dat"]) + }), + ), + ) }) diff --git a/packages/desktop/src/main/storage/cleanup.ts b/packages/desktop/src/main/storage/cleanup.ts index 4d592ebc4b6..64fa091cfa4 100644 --- a/packages/desktop/src/main/storage/cleanup.ts +++ b/packages/desktop/src/main/storage/cleanup.ts @@ -1,5 +1,4 @@ -import { readdir, readFile, rm, stat } from "node:fs/promises" -import { join } from "node:path" +import { Effect, FileSystem, Option, Path } from "effect" const EMPTY_STORE_MAX_BYTES = 128 const DRAFT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000 @@ -14,30 +13,33 @@ type StoreCandidate = { empty: boolean } -export async function cleanupStoreFiles(userDataPath: string, now = Date.now()) { - const entries = await readdir(userDataPath, { withFileTypes: true }).catch(() => []) - const candidates = ( - await Promise.all( - entries - .filter((entry) => entry.isFile()) - .map(async (entry) => { - const kind = storeKind(entry.name) - if (!kind) return +export const cleanupStoreFiles = Effect.fn("Storage.cleanupStoreFiles")(function* ( + userDataPath: string, + now = Date.now(), +) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const entries = yield* fs.readDirectory(userDataPath).pipe(Effect.catch(() => Effect.succeed([]))) + const candidates = (yield* Effect.forEach( + entries, + Effect.fnUntraced(function* (entry) { + const kind = storeKind(entry) + if (!kind) return - const file = join(userDataPath, entry.name) - const stats = await stat(file).catch(() => undefined) - if (!stats?.isFile()) return + const file = path.join(userDataPath, entry) + const stats = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (stats?.type !== "File") return - return { - name: entry.name, - path: file, - kind, - modified: stats.mtimeMs, - empty: await isEmptyStore(file, stats.size), - } - }), - ) - ).filter((candidate) => !!candidate) + return { + name: entry, + path: file, + kind, + modified: Option.getOrElse(stats.mtime, () => new Date(0)).getTime(), + empty: yield* isEmptyStore(file, stats.size), + } + }), + { concurrency: 5 }, + )).filter((candidate) => !!candidate) const stale = new Set() for (const candidate of candidates) { @@ -51,37 +53,45 @@ export async function cleanupStoreFiles(userDataPath: string, now = Date.now()) .slice(DRAFT_KEEP_RECENT) .forEach((candidate) => stale.add(candidate)) - const deleted = await Promise.all( - [...stale].map(async (candidate) => { - await rm(candidate.path, { force: true }) + const deleted = yield* Effect.forEach( + stale, + Effect.fnUntraced(function* (candidate) { + yield* fs.remove(candidate.path, { force: true }) return candidate.name }), + { concurrency: "unbounded" }, ) return { scanned: candidates.length, deleted } -} +}) -export async function deleteStoreFileIfEmpty(userDataPath: string, name: string) { +export const deleteStoreFileIfEmpty = Effect.fn("Storage.deleteStoreFileIfEmpty")(function* ( + userDataPath: string, + name: string, +) { if (!storeKind(name)) return false - const file = join(userDataPath, name) - const stats = await stat(file).catch(() => undefined) - if (!stats?.isFile()) return false - if (!(await isEmptyStore(file, stats.size))) return false + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const file = path.join(userDataPath, name) + const stats = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (stats?.type !== "File") return false + if (!(yield* isEmptyStore(file, stats.size))) return false - await rm(file, { force: true }) + yield* fs.remove(file, { force: true }) return true -} +}) function storeKind(name: string): StoreKind | undefined { if (/^opencode\.draft\..+\.dat$/.test(name)) return "draft" if (/^opencode\.workspace\..+\.dat$/.test(name)) return "workspace" } -async function isEmptyStore(file: string, size: number) { - if (size > EMPTY_STORE_MAX_BYTES) return false +const isEmptyStore = Effect.fn("Storage.isEmptyStore")(function* (file: string, size: FileSystem.Size) { + if (size > FileSystem.Size(EMPTY_STORE_MAX_BYTES)) return false - const raw = await readFile(file, "utf8").catch(() => undefined) + const fs = yield* FileSystem.FileSystem + const raw = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined))) if (raw === undefined) return false if (raw.trim() === "") return true @@ -91,4 +101,4 @@ async function isEmptyStore(file: string, size: number) { } catch { return false } -} +}) diff --git a/packages/desktop/src/main/storage/index.ts b/packages/desktop/src/main/storage/index.ts index 458611feef5..8f86b5ff03b 100644 --- a/packages/desktop/src/main/storage/index.ts +++ b/packages/desktop/src/main/storage/index.ts @@ -1,13 +1,46 @@ -import { join } from "node:path" -import { app } from "electron" +export * as DesktopStorage from "./index" + +import { app, BrowserWindow } from "electron" +import { Context, Effect, Layer, Path } from "effect" import { createDesktopDraftStore } from "./drafts" import { getStore, removeStoreFileIfEmpty } from "./store" -export function createDesktopStorage() { - const drafts = createDesktopDraftStore(join(app.getPath("userData"), "drafts.sqlite")) - app.on("before-quit", () => drafts.flush()) - app.once("will-quit", () => drafts.close()) - app.on("browser-window-created", (_event, win) => win.on("session-end", () => drafts.flush())) +export type Interface = ReturnType + +export class Service extends Context.Service()("opencode/desktop/DesktopStorage") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const path = yield* Path.Path + const storage = make(path.join(app.getPath("userData"), "drafts.sqlite")) + const flush = () => storage.drafts.flush() + const wire = (_event: Electron.Event, win: BrowserWindow) => win.on("session-end", flush) + app.on("before-quit", flush) + app.on("browser-window-created", wire) + BrowserWindow.getAllWindows().forEach((win) => wire({} as Electron.Event, win)) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + app.off("before-quit", flush) + app.off("browser-window-created", wire) + BrowserWindow.getAllWindows().forEach((win) => win.off("session-end", flush)) + storage.drafts.close() + }), + ) + return Service.of(storage) + }), +) + +function make(draftFile: string) { + const drafts = createDesktopDraftStore(draftFile) + const deleteValue = Effect.fn("DesktopStorage.delete")(function* (name: string, key: string) { + getStore(name).delete(key) + yield* removeStoreFileIfEmpty(name).pipe(Effect.ignore) + }) + const clear = Effect.fn("DesktopStorage.clear")(function* (name: string) { + getStore(name).clear() + yield* removeStoreFileIfEmpty(name).pipe(Effect.ignore) + }) return { get(name: string, key: string) { @@ -20,14 +53,8 @@ export function createDesktopStorage() { } }, set: (name: string, key: string, value: string) => getStore(name).set(key, value), - deleteValue(name: string, key: string) { - getStore(name).delete(key) - void removeStoreFileIfEmpty(name) - }, - clear(name: string) { - getStore(name).clear() - void removeStoreFileIfEmpty(name) - }, + deleteValue, + clear, keys: (name: string) => Object.keys(getStore(name).store), length: (name: string) => Object.keys(getStore(name).store).length, drafts: { @@ -38,6 +65,8 @@ export function createDesktopStorage() { const data = drafts.getBlob(id) return data ? new Uint8Array(data).buffer : null }, + flush: drafts.flush, + close: drafts.close, }, } } diff --git a/packages/desktop/src/main/storage/install-state.test.ts b/packages/desktop/src/main/storage/install-state.test.ts index e85d11815e5..3400446d2bb 100644 --- a/packages/desktop/src/main/storage/install-state.test.ts +++ b/packages/desktop/src/main/storage/install-state.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test" import { hasExistingAppState } from "./install-state" -const file = (name: string) => ({ name, isDirectory: () => false }) -const directory = (name: string) => ({ name, isDirectory: () => true }) +const file = (name: string) => ({ name, directory: false }) +const directory = (name: string) => ({ name, directory: true }) describe("hasExistingAppState", () => { test("ignores files Electron may create on a fresh install", () => { diff --git a/packages/desktop/src/main/storage/install-state.ts b/packages/desktop/src/main/storage/install-state.ts index 32f1df3640c..4e46582db56 100644 --- a/packages/desktop/src/main/storage/install-state.ts +++ b/packages/desktop/src/main/storage/install-state.ts @@ -1,8 +1,8 @@ -export function hasExistingAppState(entries: Array<{ name: string; isDirectory: () => boolean }>) { +export function hasExistingAppState(entries: Array<{ name: string; directory: boolean }>) { return entries.some((entry) => { if (entry.name === "opencode.settings") return true if (entry.name.endsWith(".dat")) return true if (/^window-state-.+\.json$/.test(entry.name)) return true - return entry.isDirectory() && entry.name === "opencode" + return entry.directory && entry.name === "opencode" }) } diff --git a/packages/desktop/src/main/storage/store.ts b/packages/desktop/src/main/storage/store.ts index 4216dec341c..e853b34235e 100644 --- a/packages/desktop/src/main/storage/store.ts +++ b/packages/desktop/src/main/storage/store.ts @@ -1,7 +1,6 @@ import Store from "electron-store" import electron from "electron" -import { rmSync } from "node:fs" -import { join } from "node:path" +import { Effect } from "effect" import { deleteStoreFileIfEmpty } from "./cleanup" import { SETTINGS_STORE } from "./keys" @@ -25,11 +24,10 @@ export function getStore(name = SETTINGS_STORE) { return next } -export async function removeStoreFileIfEmpty(name: string) { - if (await deleteStoreFileIfEmpty(electron.app.getPath("userData"), name)) cache.delete(name) -} +export const removeStoreFileIfEmpty = Effect.fn("DesktopStorage.removeStoreFileIfEmpty")(function* (name: string) { + if (yield* deleteStoreFileIfEmpty(electron.app.getPath("userData"), name)) cache.delete(name) +}) -export function removeStoreFile(name: string) { - rmSync(join(electron.app.getPath("userData"), name), { force: true }) +export function forgetStore(name: string) { cache.delete(name) } diff --git a/packages/desktop/src/main/updater/index.ts b/packages/desktop/src/main/updater/index.ts index aad46d0c70e..3c93cf8154a 100644 --- a/packages/desktop/src/main/updater/index.ts +++ b/packages/desktop/src/main/updater/index.ts @@ -1,99 +1,130 @@ +export * as Updater from "./index" + import { app, dialog } from "electron" import type { WebContents } from "electron" +import { Context, Effect, Layer } from "effect" +import type { UpdaterState } from "@opencode-ai/app/updater" import { UpdaterStateChanged } from "../../shared/ipc-rpc/events" import { emitIpcEvent } from "../ipc-events" import { UPDATER_ENABLED } from "../constants" -import { getLogger } from "../native/logging" +import { ApplicationLifecycle } from "../lifecycle" import { nativeT } from "../native/translations" import { getStore } from "../storage/store" -import { createUpdaterController, type UpdaterController, type UpdaterReadyRecord } from "./controller" +import { createUpdaterController, type UpdaterReadyRecord } from "./controller" import { createUpdaterPlatform } from "./platform" const key = "ready" -export function setupAutoUpdater(prepareToRestart: () => Promise) { - const logger = getLogger() - const store = getStore("opencode.updater") - return createUpdaterController({ - currentVersion: app.getVersion(), - platform: UPDATER_ENABLED ? createUpdaterPlatform(logger) : undefined, - lifecycle: { prepareToRestart }, - persistence: { - get() { - const value = store.get(key) - if (!value || typeof value !== "object" || !("version" in value) || typeof value.version !== "string") return - return { version: value.version } satisfies UpdaterReadyRecord +export interface Interface { + readonly subscribe: (sender: WebContents) => Effect.Effect + readonly unsubscribe: (id: number) => Effect.Effect + readonly check: Effect.Effect + readonly install: Effect.Effect + readonly show: Effect.Effect +} + +export class Service extends Context.Service()("opencode/desktop/Updater") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const lifecycle = yield* ApplicationLifecycle.Service + const context = yield* Effect.context() + const runFork = Effect.runForkWith(context) + const store = getStore("opencode.updater") + const platform = UPDATER_ENABLED ? createUpdaterPlatform(runFork) : undefined + const controller = createUpdaterController({ + currentVersion: app.getVersion(), + platform, + lifecycle: { prepareToRestart: () => Effect.runPromiseWith(context)(lifecycle.prepareToRestart) }, + persistence: { + get() { + const value = store.get(key) + if (!value || typeof value !== "object" || !("version" in value) || typeof value.version !== "string") return + return { version: value.version } satisfies UpdaterReadyRecord + }, + set: (value) => store.set(key, value), + clear: () => store.delete(key), }, - set: (value) => store.set(key, value), - clear: () => store.delete(key), - }, - log: (message, data) => logger.log(message, data), - }) -} - -export function startAutoUpdater(controller: UpdaterController) { - void controller.start() - const timer = setInterval(() => void controller.check(), 10 * 60 * 1000) - timer.unref() - app.once("will-quit", () => clearInterval(timer)) -} - -export function createUpdaterIpc(controller: UpdaterController) { - const subscriptions = new Map void>() - const unsubscribe = (id: number) => { - subscriptions.get(id)?.() - subscriptions.delete(id) - } - app.once("will-quit", () => subscriptions.forEach((dispose) => dispose())) - - return { - subscribe(sender: WebContents) { - const id = sender.id - subscriptions.get(id)?.() // a reloaded renderer replaces its previous subscription - subscriptions.set( - id, - controller.subscribe((state) => { - if (sender.isDestroyed()) return unsubscribe(id) - emitIpcEvent(sender, new UpdaterStateChanged({ state })) - }), - ) - sender.once("destroyed", () => unsubscribe(id)) - }, - unsubscribe, - check: () => controller.check(), - install: () => controller.install(), - } -} - -export type UpdaterIpc = ReturnType - -export async function showUpdaterDialog(controller: UpdaterController) { - const state = await controller.check() - if (state.status === "error") { - await dialog.showMessageBox({ - type: "error", - message: nativeT("desktop.updater.dialog.checkFailed.message"), - title: nativeT("desktop.updater.dialog.checkFailed.title"), + log: (message, data) => runFork(Effect.logInfo(message, data)), }) + const subscriptions = new Map void>() + const unsubscribe = (id: number) => { + subscriptions.get(id)?.() + subscriptions.delete(id) + } + yield* promise(() => controller.start()).pipe(Effect.forkScoped) + yield* Effect.gen(function* () { + yield* Effect.sleep("10 minutes") + yield* promise(() => controller.check()) + }).pipe(Effect.forever, Effect.forkScoped) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + platform?.dispose() + subscriptions.forEach((dispose) => dispose()) + subscriptions.clear() + }), + ) + + return Service.of({ + subscribe: (sender) => + Effect.sync(() => { + const id = sender.id + subscriptions.get(id)?.() + subscriptions.set( + id, + controller.subscribe((state) => { + if (sender.isDestroyed()) return unsubscribe(id) + emitIpcEvent(sender, new UpdaterStateChanged({ state })) + }), + ) + sender.once("destroyed", () => unsubscribe(id)) + }), + unsubscribe: (id) => Effect.sync(() => unsubscribe(id)), + check: promise(() => controller.check()), + install: promise(() => controller.install()), + show: show(controller), + }) + }), +) + +const show = Effect.fn("Updater.show")(function* (controller: ReturnType) { + const state = yield* promise(() => controller.check()) + if (state.status === "error") { + yield* promise(() => + dialog.showMessageBox({ + type: "error", + message: nativeT("desktop.updater.dialog.checkFailed.message"), + title: nativeT("desktop.updater.dialog.checkFailed.title"), + }), + ) return } if (state.status === "up-to-date") { - await dialog.showMessageBox({ - type: "info", - message: nativeT("desktop.updater.dialog.upToDate.message"), - title: nativeT("desktop.updater.dialog.upToDate.title"), - }) + yield* promise(() => + dialog.showMessageBox({ + type: "info", + message: nativeT("desktop.updater.dialog.upToDate.message"), + title: nativeT("desktop.updater.dialog.upToDate.title"), + }), + ) return } if (state.status !== "ready") return - const response = await dialog.showMessageBox({ - type: "info", - message: nativeT("desktop.updater.dialog.ready.message", { version: state.version }), - title: nativeT("desktop.updater.dialog.ready.title"), - buttons: [nativeT("desktop.updater.dialog.restart"), nativeT("desktop.updater.dialog.later")], - defaultId: 0, - cancelId: 1, - }) - if (response.response === 0) await controller.install() + const response = yield* promise(() => + dialog.showMessageBox({ + type: "info", + message: nativeT("desktop.updater.dialog.ready.message", { version: state.version }), + title: nativeT("desktop.updater.dialog.ready.title"), + buttons: [nativeT("desktop.updater.dialog.restart"), nativeT("desktop.updater.dialog.later")], + defaultId: 0, + cancelId: 1, + }), + ) + if (response.response === 0) yield* promise(() => controller.install()) +}) + +function promise(evaluate: () => Promise) { + return Effect.tryPromise(evaluate).pipe(Effect.orDie) } diff --git a/packages/desktop/src/main/updater/platform.ts b/packages/desktop/src/main/updater/platform.ts index 3a34b05a3d7..5ba10463f35 100644 --- a/packages/desktop/src/main/updater/platform.ts +++ b/packages/desktop/src/main/updater/platform.ts @@ -1,15 +1,18 @@ import { app, autoUpdater } from "electron" import pkg from "electron-updater" -import { getLogger } from "../native/logging" +import { Effect } from "effect" import { setAppQuitting } from "../windows" import type { UpdaterPlatform } from "./controller" const updateClient = pkg.autoUpdater const restartTimeout = 10_000 -export function createUpdaterPlatform(logger: ReturnType): UpdaterPlatform { - configureUpdater(logger) - autoUpdater.on("before-quit-for-update", () => setAppQuitting()) +export function createUpdaterPlatform( + runFork: (effect: Effect.Effect) => unknown, +): UpdaterPlatform & { readonly dispose: () => void } { + configureUpdater(runFork) + const beforeQuit = () => setAppQuitting() + autoUpdater.on("before-quit-for-update", beforeQuit) return { async checkForUpdate() { @@ -18,23 +21,31 @@ export function createUpdaterPlatform(logger: ReturnType): Upd return result.updateInfo.version }, stageUpdate, - installAndRestart: () => installAndRestart(logger), + installAndRestart: () => installAndRestart(runFork), + dispose: () => autoUpdater.off("before-quit-for-update", beforeQuit), } } -function configureUpdater(logger: ReturnType) { - updateClient.logger = logger +function configureUpdater(runFork: (effect: Effect.Effect) => unknown) { + updateClient.logger = { + info: (...args) => runFork(Effect.logInfo(...args)), + warn: (...args) => runFork(Effect.logWarning(...args)), + error: (...args) => runFork(Effect.logError(...args)), + debug: (...args) => runFork(Effect.logDebug(...args)), + } updateClient.channel = "latest" updateClient.allowPrerelease = false updateClient.allowDowngrade = true updateClient.autoDownload = false updateClient.autoInstallOnAppQuit = process.platform === "darwin" - logger.log("auto updater configured", { - channel: updateClient.channel, - allowPrerelease: updateClient.allowPrerelease, - allowDowngrade: updateClient.allowDowngrade, - currentVersion: app.getVersion(), - }) + runFork( + Effect.logInfo("auto updater configured", { + channel: updateClient.channel, + allowPrerelease: updateClient.allowPrerelease, + allowDowngrade: updateClient.allowDowngrade, + currentVersion: app.getVersion(), + }), + ) } function stageUpdate() { @@ -60,10 +71,10 @@ function stageUpdate() { }) } -function installAndRestart(logger: ReturnType) { +function installAndRestart(runFork: (effect: Effect.Effect) => unknown) { return new Promise((_resolve, reject) => { const timeout = setTimeout(() => { - logger.error("update restart did not start") + runFork(Effect.logError("update restart did not start")) fail(new Error()) }, restartTimeout) const started = () => { diff --git a/packages/desktop/src/main/windows/appearance.ts b/packages/desktop/src/main/windows/appearance.ts index 7d63cfd79ce..1b9e6abfb9c 100644 --- a/packages/desktop/src/main/windows/appearance.ts +++ b/packages/desktop/src/main/windows/appearance.ts @@ -2,11 +2,11 @@ import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve" import type { DesktopTheme } from "@opencode-ai/ui/theme/types" import oc2ThemeJson from "../../../../ui/src/theme/themes/oc-2.json" import { app, BrowserWindow, nativeImage, nativeTheme } from "electron" -import { join } from "node:path" +import type { Path } from "effect" import { type TitlebarTheme } from "../../shared/ipc-contract" import { WindowFullscreenChanged, WindowPinchZoomChanged, WindowZoomChanged } from "../../shared/ipc-rpc/events" import { emitIpcEvent } from "../ipc-events" -import { developmentResourcesRoot, preloadPath } from "../paths" +import type { DesktopPaths } from "../paths" import { PINCH_ZOOM_ENABLED_KEY } from "../storage/keys" import { getStore } from "../storage/store" @@ -22,11 +22,11 @@ const maxZoomLevel = 10 const minZoomLevel = 0.2 let backgroundColor: string | undefined -export function windowAppearance() { +export function windowAppearance(path: Path.Path, paths: DesktopPaths.Resolved) { const mode = tone() return { title: "OpenCode", - icon: iconPath(), + icon: iconPath(path, paths), backgroundColor: backgroundColor ?? oc2Background[mode], ...(process.platform === "darwin" ? { @@ -42,7 +42,7 @@ export function windowAppearance() { } : {}), webPreferences: { - preload: preloadPath, + preload: paths.preloadPath, contextIsolation: true, nodeIntegration: false, sandbox: true, @@ -50,9 +50,9 @@ export function windowAppearance() { } } -export function setDockIcon() { +export function setDockIcon(path: Path.Path, paths: DesktopPaths.Resolved) { if (process.platform !== "darwin") return - const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png")) + const icon = nativeImage.createFromPath(path.join(iconsDir(path, paths), "dock.png")) if (!icon.isEmpty()) app.dock?.setIcon(icon) } @@ -119,12 +119,14 @@ export function wireFullscreen(win: BrowserWindow) { win.on("leave-full-screen", () => send(false)) } -function iconsDir() { - return app.isPackaged ? join(process.resourcesPath, "icons") : join(developmentResourcesRoot, "icons") +function iconsDir(path: Path.Path, paths: DesktopPaths.Resolved) { + return app.isPackaged + ? path.join(process.resourcesPath, "icons") + : path.join(paths.developmentResourcesRoot, "icons") } -function iconPath() { - return join(iconsDir(), `icon.${process.platform === "win32" ? "ico" : "png"}`) +function iconPath(path: Path.Path, paths: DesktopPaths.Resolved) { + return path.join(iconsDir(path, paths), `icon.${process.platform === "win32" ? "ico" : "png"}`) } function tone() { diff --git a/packages/desktop/src/main/windows/index.ts b/packages/desktop/src/main/windows/index.ts index 5d7ea2ae6e9..7c7f6a0840e 100644 --- a/packages/desktop/src/main/windows/index.ts +++ b/packages/desktop/src/main/windows/index.ts @@ -1,9 +1,12 @@ import windowState from "electron-window-state" import { randomUUID } from "node:crypto" -import { rmSync } from "node:fs" -import { join } from "node:path" import { app, BrowserWindow } from "electron" -import { removeStoreFile, getStore } from "../storage/store" +import { Effect } from "effect" +import type { FileSystem, Path } from "effect" +import { openExternalURL } from "../files" +import { scoped } from "../native/logging" +import type { DesktopPaths } from "../paths" +import { forgetStore, getStore } from "../storage/store" import { WINDOW_IDS_KEY } from "../storage/keys" import { getBackgroundColor, @@ -26,10 +29,6 @@ const windowIDs = new WeakMap() const registry = createWindowRegistry({ read: () => getStore().get(WINDOW_IDS_KEY), write: (ids) => getStore().set(WINDOW_IDS_KEY, ids), - cleanup: (id) => { - rmSync(join(app.getPath("userData"), windowStateFile(id)), { force: true }) - removeStoreFile(windowDataFile(id)) - }, }) let relaunchHandler = () => { setAppQuitting() @@ -49,7 +48,11 @@ export { } export function setRelaunchHandler(handler: () => void) { + const previous = relaunchHandler relaunchHandler = handler + return () => { + if (relaunchHandler === handler) relaunchHandler = previous + } } export function setAppQuitting(quitting = true) { @@ -68,12 +71,20 @@ export function getLastFocusedWindow() { return win } -export function restoreMainWindows() { - const ids = registry.persisted() - return (ids.length ? ids : [randomUUID()]).map((id) => createMainWindow(id)) +export interface Dependencies { + readonly fs: FileSystem.FileSystem + readonly path: Path.Path + readonly paths: DesktopPaths.Resolved + readonly runFork: (effect: Effect.Effect) => unknown + readonly exportDebug: () => Promise } -export function createMainWindow(id: string = randomUUID()) { +export function restoreMainWindows(deps: Dependencies) { + const ids = registry.persisted() + return (ids.length ? ids : [randomUUID()]).map((id) => createMainWindow(deps, id)) +} + +export function createMainWindow(deps: Dependencies, id: string = randomUUID()) { const state = windowState({ file: windowStateFile(id), defaultWidth: 1280, defaultHeight: 800 }) const win = new BrowserWindow({ x: state.x, @@ -82,15 +93,15 @@ export function createMainWindow(id: string = randomUUID()) { height: state.height, show: false, autoHideMenuBar: true, - ...windowAppearance(), + ...windowAppearance(deps.path, deps.paths), }) allowRendererPermissions(win) - wireWindowRecovery(win, id, () => relaunchHandler()) - wireNavigationPolicy(win) + wireWindowRecovery(win, id, () => relaunchHandler(), deps.exportDebug, deps.runFork) + wireNavigationPolicy(win, (url) => deps.runFork(openExternalURL(url))) wireRendererHeaders(win) state.manage(win) - registerWindow(win, id) + registerWindow(deps, win, id) wireFullscreen(win) loadWindow(win, "index.html") wireZoom(win) @@ -98,13 +109,25 @@ export function createMainWindow(id: string = randomUUID()) { return win } -function registerWindow(win: BrowserWindow, id: string) { +function registerWindow(deps: Dependencies, win: BrowserWindow, id: string) { windowIDs.set(win, id) registry.register(id, win) win.on("focus", () => registry.focused(id)) // Windows emits session-end, but not before-quit, during shutdown and logoff. win.on("session-end", () => registry.setQuitting()) - win.on("closed", () => registry.closed(id)) + win.on("closed", () => { + if (!registry.closed(id)) return + const data = windowDataFile(id) + deps.runFork( + Effect.gen(function* () { + yield* deps.fs.remove(deps.path.join(app.getPath("userData"), windowStateFile(id)), { force: true }) + yield* deps.fs.remove(deps.path.join(app.getPath("userData"), data), { force: true }) + }).pipe( + Effect.tap(() => Effect.sync(() => forgetStore(data))), + Effect.catch((error) => scoped("window", Effect.logError("failed to clean window files", { id, error }))), + ), + ) + }) } function windowStateFile(id: string) { diff --git a/packages/desktop/src/main/windows/protocol.ts b/packages/desktop/src/main/windows/protocol.ts index 346afa86619..abf7996a3e1 100644 --- a/packages/desktop/src/main/windows/protocol.ts +++ b/packages/desktop/src/main/windows/protocol.ts @@ -1,9 +1,9 @@ import { net, protocol } from "electron" import type { BrowserWindow } from "electron" -import { isAbsolute, relative, resolve } from "node:path" import { pathToFileURL } from "node:url" -import { writeLog } from "../native/logging" -import { rendererRoot } from "../paths" +import { Effect } from "effect" +import type { Path } from "effect" +import { scoped } from "../native/logging" const rendererProtocol = "oc" const rendererHost = "renderer" @@ -22,20 +22,24 @@ protocol.registerSchemesAsPrivileged([ }, ]) -export function registerRendererProtocol() { +export function registerRendererProtocol( + path: Path.Path, + rendererRoot: string, + runFork: (effect: Effect.Effect) => unknown, +) { if (protocol.isProtocolHandled(rendererProtocol)) return protocol.handle(rendererProtocol, async (request) => { const url = new URL(request.url) if (url.host !== rendererHost) { - writeLog("protocol", "rejected host", { url: request.url }, "warn") + runFork(scoped("protocol", Effect.logWarning("rejected host", { url: request.url }))) return new Response("Not found", { status: 404 }) } - const file = resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`) - const rel = relative(rendererRoot, file) - if (rel.startsWith("..") || isAbsolute(rel)) { - writeLog("protocol", "rejected path", { url: request.url, file }, "warn") + const file = path.resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`) + const rel = path.relative(rendererRoot, file) + if (rel.startsWith("..") || path.isAbsolute(rel)) { + runFork(scoped("protocol", Effect.logWarning("rejected path", { url: request.url, file }))) return new Response("Not found", { status: 404 }) } @@ -43,16 +47,21 @@ export function registerRendererProtocol() { const range = request.headers.get("range") const response = await net.fetch(pathToFileURL(file).toString(), { headers: range ? { range } : undefined }) if (response.status >= 400) { - writeLog( - "protocol", - "fetch failed", - { url: request.url, file, status: response.status, statusText: response.statusText }, - "error", + runFork( + scoped( + "protocol", + Effect.logError("fetch failed", { + url: request.url, + file, + status: response.status, + statusText: response.statusText, + }), + ), ) } return addDocumentPolicy(response, file) } catch (error) { - writeLog("protocol", "fetch error", { url: request.url, file, error }, "error") + runFork(scoped("protocol", Effect.logError("fetch error", { url: request.url, file, error }))) return new Response("Not found", { status: 404 }) } }) diff --git a/packages/desktop/src/main/windows/recovery.ts b/packages/desktop/src/main/windows/recovery.ts index 0c1d08475ef..1cd17edee21 100644 --- a/packages/desktop/src/main/windows/recovery.ts +++ b/packages/desktop/src/main/windows/recovery.ts @@ -1,19 +1,26 @@ import { app, dialog } from "electron" import type { BrowserWindow } from "electron" -import { exportDebugLogs, writeLog } from "../native/logging" +import { Effect } from "effect" +import { scoped } from "../native/logging" import { nativeT } from "../native/translations" import { safeWindowURL } from "./state" import { createUnresponsiveSampler } from "./unresponsive" -export function wireWindowRecovery(win: BrowserWindow, name: string, relaunch: () => void) { +export function wireWindowRecovery( + win: BrowserWindow, + name: string, + relaunch: () => void, + exportDebugLogs: () => Promise, + runFork: (effect: Effect.Effect) => unknown, +) { let showing = false - const sampler = createUnresponsiveSampler(win, name) + const sampler = createUnresponsiveSampler(win, name, runFork) type RecoveryAction = "relaunch" | "export-logs" | "keep-waiting" | "quit" const handle = async (action: RecoveryAction | undefined, wait: boolean) => { if (action === "export-logs") { const sampling = sampler.stopAndFlush() - await exportDebugLogs().catch((error) => writeLog("main", "failed to export debug logs", { error }, "error")) + await exportDebugLogs().catch((error) => runFork(Effect.logError("failed to export debug logs", { error }))) if (wait && sampling) sampler.start() return true } @@ -68,11 +75,19 @@ export function wireWindowRecovery(win: BrowserWindow, name: string, relaunch: ( validatedURL: string, isMainFrame: boolean, ) => { - writeLog( - "window", - "renderer load failed", - { window: name, event, errorCode, errorDescription, validatedURL, currentURL: safeWindowURL(win), isMainFrame }, - "error", + runFork( + scoped( + "window", + Effect.logError("renderer load failed", { + window: name, + event, + errorCode, + errorDescription, + validatedURL, + currentURL: safeWindowURL(win), + isMainFrame, + }), + ), ) if (!isMainFrame || errorCode === -3) return void show( @@ -95,7 +110,12 @@ export function wireWindowRecovery(win: BrowserWindow, name: string, relaunch: ( }) win.webContents.on("render-process-gone", (_event, details) => { sampler.stopAndFlush() - writeLog("window", "renderer process gone", { window: name, currentURL: safeWindowURL(win), details }, "error") + runFork( + scoped( + "window", + Effect.logError("renderer process gone", { window: name, currentURL: safeWindowURL(win), details }), + ), + ) void show( nativeT("desktop.recovery.terminated"), nativeT("desktop.recovery.terminated.detail", { @@ -107,20 +127,27 @@ export function wireWindowRecovery(win: BrowserWindow, name: string, relaunch: ( ) }) win.on("unresponsive", () => { - writeLog("window", "renderer unresponsive", { window: name, currentURL: safeWindowURL(win) }, "error") + runFork( + scoped( + "window", + Effect.logError("renderer unresponsive", { window: name, currentURL: safeWindowURL(win) }), + ), + ) sampler.start() void show(nativeT("desktop.recovery.unresponsive"), nativeT("desktop.recovery.unresponsive.detail"), true) }) win.on("responsive", () => { - writeLog("window", "renderer responsive", { window: name, currentURL: safeWindowURL(win) }, "error") + runFork( + scoped("window", Effect.logError("renderer responsive", { window: name, currentURL: safeWindowURL(win) })), + ) sampler.stopAndFlush() }) win.webContents.on("console-message", (_event, level, message, line, sourceId) => { if (message.toLowerCase().includes("terminal") || sourceId.toLowerCase().includes("terminal")) { - writeLog("pty", "console", { window: name, level, message, line, sourceId }) + runFork(scoped("pty", Effect.logInfo("console", { window: name, level, message, line, sourceId }))) } }) win.webContents.on("preload-error", (_event, path, error) => { - writeLog("preload", "preload error", { window: name, preloadPath: path, error }, "error") + runFork(scoped("preload", Effect.logError("preload error", { window: name, preloadPath: path, error }))) }) } diff --git a/packages/desktop/src/main/windows/registry.test.ts b/packages/desktop/src/main/windows/registry.test.ts index f404bf60ee8..cad9f63eca9 100644 --- a/packages/desktop/src/main/windows/registry.test.ts +++ b/packages/desktop/src/main/windows/registry.test.ts @@ -3,15 +3,13 @@ import { createWindowRegistry } from "./registry" function setup(initial: unknown = []) { const state = { stored: initial } - const cleaned: string[] = [] const registry = createWindowRegistry<{ name: string }>({ read: () => state.stored, write: (ids) => { state.stored = ids }, - cleanup: (id) => cleaned.push(id), }) - return { registry, state, cleaned } + return { registry, state } } describe("window registry", () => { @@ -33,24 +31,21 @@ describe("window registry", () => { const app = setup() app.registry.register("a", { name: "a" }) app.registry.register("b", { name: "b" }) - app.registry.closed("a") + expect(app.registry.closed("a")).toBe(true) expect(app.state.stored).toEqual(["b"]) - expect(app.cleaned).toEqual(["a"]) }) test("keeps the id when the last window closes so relaunch restores it", () => { const app = setup() app.registry.register("a", { name: "a" }) - app.registry.closed("a") + expect(app.registry.closed("a")).toBe(false) expect(app.state.stored).toEqual(["a"]) - expect(app.cleaned).toEqual([]) const restarted = createWindowRegistry<{ name: string }>({ read: () => app.state.stored, write: (ids) => { app.state.stored = ids }, - cleanup: () => {}, }) expect(restarted.persisted()).toEqual(["a"]) }) @@ -60,10 +55,9 @@ describe("window registry", () => { app.registry.register("a", { name: "a" }) app.registry.register("b", { name: "b" }) app.registry.setQuitting() - app.registry.closed("a") - app.registry.closed("b") + expect(app.registry.closed("a")).toBe(false) + expect(app.registry.closed("b")).toBe(false) expect(app.state.stored).toEqual(["a", "b"]) - expect(app.cleaned).toEqual([]) }) test("tracks the last focused window and falls back on close", () => { @@ -84,8 +78,7 @@ describe("window registry", () => { app.registry.register("b", { name: "b" }) app.registry.setQuitting() app.registry.setQuitting(false) - app.registry.closed("a") + expect(app.registry.closed("a")).toBe(true) expect(app.state.stored).toEqual(["b"]) - expect(app.cleaned).toEqual(["a"]) }) }) diff --git a/packages/desktop/src/main/windows/registry.ts b/packages/desktop/src/main/windows/registry.ts index f40902ef78d..d5862daa3c1 100644 --- a/packages/desktop/src/main/windows/registry.ts +++ b/packages/desktop/src/main/windows/registry.ts @@ -3,7 +3,6 @@ export function createWindowRegistry(persistence: { read: () => unknown write: (ids: string[]) => void - cleanup: (id: string) => void }) { const windows = new Map() let quitting = false @@ -39,9 +38,9 @@ export function createWindowRegistry(persistence: { // forgets a window. Closing the last window quits the app and fires // `closed` before `before-quit`, so treat it as a quit and keep the id // for restore on next launch. - if (quitting || windows.size === 0) return + if (quitting || windows.size === 0) return false persistence.write(persisted().filter((item) => item !== id)) - persistence.cleanup(id) + return true }, } } diff --git a/packages/desktop/src/main/windows/security.ts b/packages/desktop/src/main/windows/security.ts index 3afff900712..52ba46a7e96 100644 --- a/packages/desktop/src/main/windows/security.ts +++ b/packages/desktop/src/main/windows/security.ts @@ -1,5 +1,4 @@ import type { BrowserWindow } from "electron" -import { openExternalURL } from "../files" import { addRendererHeaders, isRendererUrl, upsertHeader } from "./protocol" const rendererPermissions = new Set(["clipboard-sanitized-write", "notifications"]) @@ -18,7 +17,7 @@ export function allowRendererPermissions(win: BrowserWindow) { }) } -export function wireNavigationPolicy(win: BrowserWindow) { +export function wireNavigationPolicy(win: BrowserWindow, openExternalURL: (url: string) => unknown) { win.webContents.setWindowOpenHandler(({ url }) => { if (!isRendererUrl(url)) openExternalURL(url) return { action: "deny" } diff --git a/packages/desktop/src/main/windows/unresponsive.ts b/packages/desktop/src/main/windows/unresponsive.ts index 77848e03a9f..76490f55f8e 100644 --- a/packages/desktop/src/main/windows/unresponsive.ts +++ b/packages/desktop/src/main/windows/unresponsive.ts @@ -1,11 +1,16 @@ import type { BrowserWindow } from "electron" -import { writeLog } from "../native/logging" +import { Effect } from "effect" +import { scoped } from "../native/logging" import { safeWindowURL } from "./state" const sampleInterval = 1000 const samplePeriod = 15000 -export function createUnresponsiveSampler(win: BrowserWindow, name: string) { +export function createUnresponsiveSampler( + win: BrowserWindow, + name: string, + runFork: (effect: Effect.Effect) => unknown, +) { let sampleTimer: ReturnType | undefined let stopTimer: ReturnType | undefined let sampling = false @@ -28,7 +33,7 @@ export function createUnresponsiveSampler(win: BrowserWindow, name: string) { const collect = async () => { if (!active()) return const stack = await win.webContents.mainFrame.collectJavaScriptCallStack().catch((error) => { - writeLog("window", "failed to collect unresponsive sample", { window: name, error }, "error") + runFork(scoped("window", Effect.logError("failed to collect unresponsive sample", { window: name, error }))) return undefined }) if (!active()) return @@ -51,7 +56,7 @@ export function createUnresponsiveSampler(win: BrowserWindow, name: string) { ...entries.map((entry) => `<${entry[1]}> ${entry[0]}`), `Total Samples: ${total}`, ].join("\n") - writeLog("window", message, undefined, "error") + runFork(scoped("window", Effect.logError(message))) samples.clear() return wasSampling } diff --git a/packages/desktop/src/main/wsl/ipc.ts b/packages/desktop/src/main/wsl/ipc.ts index bcd194119bf..c0765027863 100644 --- a/packages/desktop/src/main/wsl/ipc.ts +++ b/packages/desktop/src/main/wsl/ipc.ts @@ -1,56 +1,31 @@ +export * as WslIpc from "./ipc" + import { app } from "electron" import type { WebContents } from "electron" import type { WslServerConfig, WslServersState } from "@opencode-ai/app/wsl/types" +import { Effect } from "effect" import { WslServersChanged } from "../../shared/ipc-rpc/events" import { emitIpcEvent } from "../ipc-events" import type { WslServersController } from "./servers" import { nativeT } from "../native/translations" -export type WslIpc = { - subscribe(sender: WebContents): void - unsubscribe(id: number): void - getState(): WslServersState - probeRuntime(): Promise - refreshDistros(): Promise - installWsl(): Promise - installDistro(value: string): Promise - probeAddable(value: string[]): Promise - installOpencode(value: string): Promise - openTerminal(value: string): Promise - addServer(value: string): Promise - removeServer(value: string): Promise - startServer(value: string): Promise +export interface Interface { + readonly subscribe: (sender: WebContents) => Effect.Effect + readonly unsubscribe: (id: number) => Effect.Effect + readonly getState: () => Effect.Effect + readonly probeRuntime: () => Effect.Effect + readonly refreshDistros: () => Effect.Effect + readonly installWsl: () => Effect.Effect + readonly installDistro: (value: string) => Effect.Effect + readonly probeAddable: (value: string[]) => Effect.Effect + readonly installOpencode: (value: string) => Effect.Effect + readonly openTerminal: (value: string) => Effect.Effect + readonly addServer: (value: string) => Effect.Effect + readonly removeServer: (value: string) => Effect.Effect + readonly startServer: (value: string) => Effect.Effect } -export function createDeferredWslIpc() { - let current: WslIpc | undefined - const get = () => { - if (!current) throw new Error("WSL service is not initialized") - return current - } - return { - ipc: { - subscribe: (sender) => get().subscribe(sender), - unsubscribe: (id) => get().unsubscribe(id), - getState: () => get().getState(), - probeRuntime: () => get().probeRuntime(), - refreshDistros: () => get().refreshDistros(), - installWsl: () => get().installWsl(), - installDistro: (value) => get().installDistro(value), - probeAddable: (value) => get().probeAddable(value), - installOpencode: (value) => get().installOpencode(value), - openTerminal: (value) => get().openTerminal(value), - addServer: (value) => get().addServer(value), - removeServer: (value) => get().removeServer(value), - startServer: (value) => get().startServer(value), - } satisfies WslIpc, - set: (ipc: WslIpc) => { - current = ipc - }, - } -} - -export function createWslIpc(controller?: WslServersController): WslIpc { +export function create(controller?: WslServersController): Interface { if (!controller) return createUnavailableWslIpc() const subscriptions = new Map void>() @@ -67,45 +42,53 @@ export function createWslIpc(controller?: WslServersController): WslIpc { }) return { - subscribe(sender) { - const id = sender.id - if (subscriptions.has(id)) return - subscriptions.set( - id, - controller.subscribe((payload) => { - if (sender.isDestroyed()) { - unsubscribe(id) - return - } - emitIpcEvent(sender, new WslServersChanged({ event: payload })) - }), - ) - sender.once("destroyed", () => unsubscribe(id)) - }, - unsubscribe, - getState: () => controller.getState(), - probeRuntime: () => controller.probeRuntime(), - refreshDistros: () => controller.refreshDistros(), - installWsl: () => controller.installWsl(), - installDistro: (value) => controller.installDistro(requireWslIpcString("distro", value)), - probeAddable: (value) => controller.probeAddable(requireWslIpcStrings("distro", value)), - installOpencode: (value) => controller.installOpencode(requireWslIpcString("distro", value)), - openTerminal: (value) => controller.openTerminal(requireWslIpcString("distro", value)), - addServer: (value) => controller.addServer(requireWslIpcString("distro", value)), - removeServer: (value) => controller.removeServer(requireWslIpcString("server id", value)), - startServer: (value) => controller.startServer(requireWslIpcString("server id", value)), + subscribe: (sender) => + Effect.sync(() => { + const id = sender.id + if (subscriptions.has(id)) return + subscriptions.set( + id, + controller.subscribe((payload) => { + if (sender.isDestroyed()) { + unsubscribe(id) + return + } + emitIpcEvent(sender, new WslServersChanged({ event: payload })) + }), + ) + sender.once("destroyed", () => unsubscribe(id)) + }), + unsubscribe: (id) => Effect.sync(() => unsubscribe(id)), + getState: () => Effect.sync(() => controller.getState()), + probeRuntime: () => promise(() => controller.probeRuntime()), + refreshDistros: () => promise(() => controller.refreshDistros()), + installWsl: () => promise(() => controller.installWsl()), + installDistro: (value) => promise(() => controller.installDistro(requireWslIpcString("distro", value))), + probeAddable: (value) => promise(() => controller.probeAddable(requireWslIpcStrings("distro", value))), + installOpencode: (value) => promise(() => controller.installOpencode(requireWslIpcString("distro", value))), + openTerminal: (value) => promise(() => controller.openTerminal(requireWslIpcString("distro", value))), + addServer: (value) => promise(() => controller.addServer(requireWslIpcString("distro", value))), + removeServer: (value) => promise(() => controller.removeServer(requireWslIpcString("server id", value))), + startServer: (value) => promise(() => controller.startServer(requireWslIpcString("server id", value))), } } -function createUnavailableWslIpc(): WslIpc { +function promise(evaluate: () => Promise) { + return Effect.tryPromise(evaluate).pipe(Effect.orDie) +} + +function createUnavailableWslIpc(): Interface { + const message = nativeT( + process.platform === "win32" ? "desktop.wsl.error.unavailable" : "desktop.wsl.error.windowsOnly", + ) const unavailable = () => { - throw new Error(nativeT("desktop.wsl.error.windowsOnly")) + throw new Error(message) } const state = (): WslServersState => ({ runtime: { available: false, version: null, - error: nativeT("desktop.wsl.error.windowsOnly"), + error: message, }, installed: [], online: [], @@ -117,19 +100,20 @@ function createUnavailableWslIpc(): WslIpc { }) return { - subscribe: (sender) => emitIpcEvent(sender, new WslServersChanged({ event: { type: "state", state: state() } })), - unsubscribe: () => undefined, - getState: state, - probeRuntime: unavailable, - refreshDistros: unavailable, - installWsl: unavailable, - installDistro: unavailable, - probeAddable: unavailable, - installOpencode: unavailable, - openTerminal: unavailable, - addServer: unavailable, - removeServer: unavailable, - startServer: unavailable, + subscribe: (sender) => + Effect.sync(() => emitIpcEvent(sender, new WslServersChanged({ event: { type: "state", state: state() } }))), + unsubscribe: () => Effect.void, + getState: () => Effect.sync(state), + probeRuntime: () => Effect.sync(unavailable), + refreshDistros: () => Effect.sync(unavailable), + installWsl: () => Effect.sync(unavailable), + installDistro: () => Effect.sync(unavailable), + probeAddable: () => Effect.sync(unavailable), + installOpencode: () => Effect.sync(unavailable), + openTerminal: () => Effect.sync(unavailable), + addServer: () => Effect.sync(unavailable), + removeServer: () => Effect.sync(unavailable), + startServer: () => Effect.sync(unavailable), } } diff --git a/packages/desktop/src/main/wsl/local.ts b/packages/desktop/src/main/wsl/local.ts index e1ec23eb0d7..1456cb91621 100644 --- a/packages/desktop/src/main/wsl/local.ts +++ b/packages/desktop/src/main/wsl/local.ts @@ -1,38 +1,48 @@ import { execFile } from "node:child_process" -import { copyFile, mkdtemp, readFile, rm } from "node:fs/promises" -import { tmpdir } from "node:os" -import { dirname, join } from "node:path" import { promisify } from "node:util" +import { Effect, FileSystem, Path } from "effect" const execFileAsync = promisify(execFile) -export async function buildLocalWslCli(input: { version: string; script: string; output: string }) { - const directory = await mkdtemp(join(tmpdir(), "opencode-wsl-cli-")) - const root = join(dirname(input.script), "../../..") - const packageManager = (JSON.parse(await readFile(join(root, "package.json"), "utf8")) as { packageManager: string }) - .packageManager - const target = `linux-${process.arch}` - try { - await execFileAsync("bunx", [packageManager, "install", "--os=*", "--cpu=*", "--frozen-lockfile"], { - cwd: root, - env: process.env, - windowsHide: true, - }) - await execFileAsync( - "bunx", - [ - packageManager, - input.script, - `--target=opencode2-${target}`, - "--skip-install", - "--skip-web-ui", - `--outdir=${directory}`, - ], - { cwd: root, env: { ...process.env, OPENCODE_VERSION: input.version }, windowsHide: true }, +export const buildLocalWslCli = Effect.fn("Wsl.buildLocalCli")(function* (input: { + version: string + script: string + output: string +}) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const directory = yield* fs.makeTempDirectory({ prefix: "opencode-wsl-cli-" }) + const build = Effect.gen(function* () { + const root = path.join(path.dirname(input.script), "../../..") + const packageManager = ( + JSON.parse(yield* fs.readFileString(path.join(root, "package.json"))) as { + packageManager: string + } + ).packageManager + const target = `linux-${process.arch}` + yield* Effect.tryPromise(() => + execFileAsync("bunx", [packageManager, "install", "--os=*", "--cpu=*", "--frozen-lockfile"], { + cwd: root, + env: process.env, + windowsHide: true, + }), ) - await copyFile(join(directory, `cli-${target}`, "bin", "opencode2"), input.output) + yield* Effect.tryPromise(() => + execFileAsync( + "bunx", + [ + packageManager, + input.script, + `--target=opencode2-${target}`, + "--skip-install", + "--skip-web-ui", + `--outdir=${directory}`, + ], + { cwd: root, env: { ...process.env, OPENCODE_VERSION: input.version }, windowsHide: true }, + ), + ) + yield* fs.copyFile(path.join(directory, `cli-${target}`, "bin", "opencode2"), input.output) return input.output - } finally { - await rm(directory, { recursive: true, force: true }) - } -} + }) + return yield* build.pipe(Effect.ensuring(fs.remove(directory, { recursive: true, force: true }).pipe(Effect.orDie))) +}) diff --git a/packages/desktop/src/main/wsl/runtime.ts b/packages/desktop/src/main/wsl/runtime.ts index f0d00bbe905..662ad260ec9 100644 --- a/packages/desktop/src/main/wsl/runtime.ts +++ b/packages/desktop/src/main/wsl/runtime.ts @@ -1,8 +1,7 @@ import { spawn } from "node:child_process" -import { existsSync } from "node:fs" -import { join } from "node:path" import * as pty from "@lydell/node-pty" import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "@opencode-ai/app/wsl/types" +import { Effect, FileSystem, Path } from "effect" import { nativeT } from "../native/translations" import { parseCliVersion } from "../service/cli-version" @@ -261,25 +260,35 @@ export async function installWslRuntimeElevated(opts?: RunWslOptions) { requireSuccess(result, nativeT("desktop.wsl.error.installWsl")) } -export async function installWslDistro(distro: string, opts?: RunWslOptions) { - const result = await runInteractiveCommand( - resolveSystem32Command("wsl.exe"), - ["--install", "-d", distro, "--web-download", "--no-launch"], - withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS), - DEFAULT_WSL_INSTALL_TIMEOUT_MS, +export const installWslDistro = Effect.fn("Wsl.installDistro")(function* (distro: string, opts?: RunWslOptions) { + const command = yield* resolveSystem32Command("wsl.exe") + const result = yield* Effect.tryPromise(() => + runInteractiveCommand( + command, + ["--install", "-d", distro, "--web-download", "--no-launch"], + withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS), + DEFAULT_WSL_INSTALL_TIMEOUT_MS, + ), ) requireSuccess(result, nativeT("desktop.wsl.error.installDistro", { distro })) -} +}) -export async function installWslCli(distro: string, cli: WslCliBuild, opts?: RunWslOptions) { - const result = await runInteractiveCommand( - resolveSystem32Command("wsl.exe"), - wslArgs(["bash", "-lc", wslCliInstallCommand(cli)], distro), - withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS), - DEFAULT_WSL_INSTALL_TIMEOUT_MS, +export const installWslCli = Effect.fn("Wsl.installCli")(function* ( + distro: string, + cli: WslCliBuild, + opts?: RunWslOptions, +) { + const command = yield* resolveSystem32Command("wsl.exe") + const result = yield* Effect.tryPromise(() => + runInteractiveCommand( + command, + wslArgs(["bash", "-lc", wslCliInstallCommand(cli)], distro), + withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS), + DEFAULT_WSL_INSTALL_TIMEOUT_MS, + ), ) requireSuccess(result, nativeT("desktop.wsl.error.installOpencode")) -} +}) export function wslCliInstallCommand(cli: WslCliBuild) { const installer = "curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash -s --" @@ -407,12 +416,14 @@ export function shellEscape(value: string) { return `'${value.replace(/'/g, `'"'"'`)}'` } -function resolveSystem32Command(command: string) { +const resolveSystem32Command = Effect.fn("Wsl.resolveSystem32Command")(function* (command: string) { const root = process.env.SystemRoot ?? process.env.windir if (!root) return command - const resolved = join(root, "System32", command) - return existsSync(resolved) ? resolved : command -} + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const resolved = path.join(root, "System32", command) + return (yield* fs.exists(resolved).pipe(Effect.orElseSucceed(() => false))) ? resolved : command +}) function withTimeout(opts: RunWslOptions | undefined, timeoutMs: number): RunWslOptions { return { diff --git a/packages/desktop/src/main/wsl/servers.test.ts b/packages/desktop/src/main/wsl/servers.test.ts index 93593d1598c..4ae158ffaad 100644 --- a/packages/desktop/src/main/wsl/servers.test.ts +++ b/packages/desktop/src/main/wsl/servers.test.ts @@ -77,6 +77,33 @@ test("stops a running WSL server before replacing its CLI", async () => { await controller.stopServers() }) +test("stops a sidecar that finishes starting after shutdown", async () => { + persistedServers = [{ id: "wsl:Debian", distro: "Debian" }] + const stopped: string[] = [] + let resolveSidecar: ((sidecar: Awaited>) => void) | undefined + const controller = createWslServersController( + testControllerOptions({ + spawnSidecar: () => new Promise((resolve) => (resolveSidecar = resolve)), + }), + ) + controller.startConfiguredServers() + await waitFor(() => controller.getState().servers[0]?.runtime.kind === "starting") + + await controller.stopServers() + resolveSidecar?.({ + stop: async () => { + stopped.push("stop") + }, + onExit: () => undefined, + url: "http://127.0.0.1:4096", + username: "opencode", + password: "secret", + }) + await waitFor(() => stopped.length === 1) + + expect(stopped).toEqual(["stop"]) +}) + test("probes addable distros in parallel before checking OpenCode", async () => { persistedServers = [] const started: string[] = [] @@ -148,6 +175,8 @@ async function waitFor(check: () => boolean) { function testControllerOptions(overrides: Partial = {}): ControllerOptions { return { cli: { version: "0.0.0-dev-16365" }, + installCli: async () => undefined, + installDistro: async () => undefined, spawnSidecar: async () => ({ stop: async () => undefined, onExit: () => undefined, diff --git a/packages/desktop/src/main/wsl/servers.ts b/packages/desktop/src/main/wsl/servers.ts index 957237b89a8..34da877e551 100644 --- a/packages/desktop/src/main/wsl/servers.ts +++ b/packages/desktop/src/main/wsl/servers.ts @@ -12,8 +12,6 @@ import { nativeT } from "../native/translations" import { WSL_SERVERS_KEY } from "../storage/keys" import { getStore } from "../storage/store" import { - installWslCli, - installWslDistro, installWslRuntimeElevated, listInstalledWslDistros, listOnlineWslDistros, @@ -43,10 +41,11 @@ type ControllerLogger = { type WslServersControllerOptions = { cli: WslCliBuild spawnSidecar: SpawnSidecar + installCli: (distro: string, cli: WslCliBuild) => Promise + installDistro: (distro: string) => Promise logger?: ControllerLogger readServers?: () => WslServerConfig[] writeServers?: (servers: WslServerConfig[]) => void - installCli?: typeof installWslCli probeDistro?: typeof probeWslDistro resolveCli?: typeof resolveWslCli readCliVersion?: typeof readWslCliVersion @@ -62,6 +61,8 @@ export function createWslServersController(options: WslServersControllerOptions) let state: WslServersState = initialState() const listeners = new Set<(event: WslServersEvent) => void>() const sidecars = new Map() + const starts = new Map() + let closed = false const readServers = options.readServers ?? readPersistedServers const writeServers = options.writeServers ?? writePersistedServers const probeDistro = options.probeDistro ?? probeWslDistro @@ -159,10 +160,18 @@ export function createWslServersController(options: WslServersControllerOptions) const item = state.servers.find((x) => x.config.id === id) if (!item) return await stopServer(id) + if (closed) return + const token = Symbol() + starts.set(id, token) setRuntime(id, { kind: "starting" }) options.logger?.log("wsl sidecar starting", { id, distro: item.config.distro }) try { const sidecar = await options.spawnSidecar(item.config.distro) + if (starts.get(id) !== token) { + await sidecar.stop() + return + } + starts.delete(id) sidecars.set(id, sidecar) setRuntime(id, { kind: "ready", @@ -180,6 +189,8 @@ export function createWslServersController(options: WslServersControllerOptions) void refreshCliCheckSafely(id, item.config.distro) options.logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url }) } catch (error) { + if (starts.get(id) !== token) return + starts.delete(id) const message = error instanceof Error ? error.message : String(error) setRuntime(id, { kind: "failed", message }) options.logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message }) @@ -187,6 +198,7 @@ export function createWslServersController(options: WslServersControllerOptions) } const stopServer = async (id: string) => { + starts.delete(id) const existing = sidecars.get(id) if (!existing) return sidecars.delete(id) @@ -213,6 +225,7 @@ export function createWslServersController(options: WslServersControllerOptions) }, startConfiguredServers() { + closed = false refreshFromStore() void refreshCliChecks() state.servers.forEach((item) => void startServer(item.config.id)) @@ -244,7 +257,7 @@ export function createWslServersController(options: WslServersControllerOptions) async installDistro(distro: string) { await runJob({ kind: "install-distro", distro, startedAt: Date.now() }, async () => { - await installWslDistro(distro) + await options.installDistro(distro) const distros = await refreshDistroLists() const probe = await probeDistro(distro) setState({ @@ -263,7 +276,7 @@ export function createWslServersController(options: WslServersControllerOptions) await runJob({ kind: "install-opencode", distro, startedAt: Date.now() }, async () => { const id = state.servers.find((item) => item.config.distro === distro)?.config.id if (id) await stopServer(id) - await (options.installCli ?? installWslCli)(distro, options.cli) + await options.installCli(distro, options.cli) requireMatchingCli(await refreshCliCheck(distro), options.cli.version) if (id) await startServer(id) }) @@ -304,6 +317,8 @@ export function createWslServersController(options: WslServersControllerOptions) startServer, async stopServers() { + closed = true + starts.clear() await Promise.all([...sidecars.values()].map((sidecar) => sidecar.stop())) sidecars.clear() }, diff --git a/packages/desktop/src/main/wsl/start.ts b/packages/desktop/src/main/wsl/start.ts index c9eebe88273..0813c77fc5a 100644 --- a/packages/desktop/src/main/wsl/start.ts +++ b/packages/desktop/src/main/wsl/start.ts @@ -1,47 +1,70 @@ -import { createWslIpc } from "./ipc" +export * as Wsl from "./start" -type Cli = { +import { Context, Effect, FileSystem, Layer, Path } from "effect" +import { Shutdown } from "../lifecycle/shutdown" +import { WslIpc } from "./ipc" + +export type Cli = { version: string wslBuild?: { script: string; output: string } } -type Logger = { - log(message: string, meta?: unknown): void - error(message: string, meta?: unknown): void +export interface Interface extends WslIpc.Interface { + readonly stop: Effect.Effect } -export async function startWsl(cli: Cli, logger: Logger) { - if (process.platform !== "win32") return { ipc: createWslIpc(), start: () => {}, stop: async () => {} } +export class Service extends Context.Service()("opencode/desktop/Wsl") {} - const { createWslServersController } = await import("./servers") - const { spawnWslSidecar } = await import("./sidecar") +export const layer = (cli?: Cli) => + Layer.effect( + Service, + Effect.gen(function* () { + const wsl = cli ? yield* makeWsl(cli) : { ...WslIpc.create(), stop: Effect.void } + const shutdown = yield* Shutdown.Service + const removeShutdown = yield* shutdown.add(wsl.stop) + yield* Effect.addFinalizer(() => Effect.sync(removeShutdown).pipe(Effect.andThen(wsl.stop))) + return Service.of(wsl) + }), + ) + +const makeWsl = Effect.fn("Wsl.make")(function* (cli: Cli) { + if (process.platform !== "win32") return { ...WslIpc.create(), stop: Effect.void } + + const { createWslServersController } = yield* Effect.promise(() => import("./servers")) + const { spawnWslSidecar } = yield* Effect.promise(() => import("./sidecar")) + const { installWslCli, installWslDistro } = yield* Effect.promise(() => import("./runtime")) + const context = yield* Effect.context() + const run = Effect.runPromiseWith(context) + const runFork = Effect.runForkWith(context) const local = cli.wslBuild const controller = createWslServersController({ cli: { version: cli.version }, + installDistro: (distro) => run(installWslDistro(distro)), installCli: local ? async (distro) => { const { buildLocalWslCli } = await import("./local") - const { installWslCli } = await import("./runtime") - await installWslCli(distro, { - version: cli.version, - binary: await buildLocalWslCli({ ...local, version: cli.version }), - }) + await run( + Effect.gen(function* () { + const binary = yield* buildLocalWslCli({ ...local, version: cli.version }) + yield* installWslCli(distro, { version: cli.version, binary }) + }), + ) } - : undefined, - spawnSidecar: async (distro) => { - logger.log("spawning wsl sidecar", { distro }) + : (distro, build) => run(installWslCli(distro, build)), + spawnSidecar: (distro) => { + runFork(Effect.logInfo("spawning wsl sidecar", { distro })) return spawnWslSidecar(distro, { - onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }), + onLine: (line) => runFork(Effect.logInfo("wsl sidecar", { distro, stream: line.stream, text: line.text })), }) }, logger: { - log: (message, meta) => logger.log(message, meta), - error: (message, meta) => logger.error(message, meta), + log: (message, meta) => runFork(Effect.logInfo(message, meta)), + error: (message, meta) => runFork(Effect.logError(message, meta)), }, }) + controller.startConfiguredServers() return { - ipc: createWslIpc(controller), - start: () => controller.startConfiguredServers(), - stop: () => controller.stopServers(), - } -} + ...WslIpc.create(controller), + stop: Effect.tryPromise(() => controller.stopServers()).pipe(Effect.orDie), + } satisfies Interface +}) diff --git a/packages/desktop/src/renderer/ipc-client.ts b/packages/desktop/src/renderer/ipc-client.ts index 4b7fa8da87a..e1e98996723 100644 --- a/packages/desktop/src/renderer/ipc-client.ts +++ b/packages/desktop/src/renderer/ipc-client.ts @@ -28,6 +28,7 @@ const ClientProtocolLive = Layer.unwrap(Effect.promise(() => port).pipe(Effect.m const ClientLive = Layer.effect(DesktopClient, RpcClient.make(DesktopRpcs)).pipe(Layer.provide(ClientProtocolLive)) const runtime = ManagedRuntime.make(ClientLive) const listeners = new Map void>>() +window.addEventListener("pagehide", () => void runtime.dispose(), { once: true }) runtime.runFork( Effect.gen(function* () { diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index df468376241..f7c99364e74 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -2752,7 +2752,6 @@ ToolRegistry.register({ const trigger = () => (
-