moar effect

This commit is contained in:
Brendan Allan
2026-08-20 16:20:50 +08:00
parent 12d3824212
commit 41b4e1e8e2
52 changed files with 1868 additions and 1340 deletions
+1
View File
@@ -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:",
+1
View File
@@ -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",
+63 -62
View File
@@ -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<string | null> {
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<string | null> {
.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)),
)
}
@@ -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 = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem>) =>
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")
})
})
@@ -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<ArrayBuffer> = readAttachment,
read: (path: string, maxBytes: number) => Effect.Effect<ArrayBuffer, unknown>,
budget = MAX_ATTACHMENT_BYTES,
) {
const selections = new Map<string, { sender: number; paths: Set<string>; 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)
}),
)
}
+85 -57
View File
@@ -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<typeof make>
export class Service extends Context.Service<Service, Interface>()("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<void>((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<void>((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
+61 -83
View File
@@ -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<ServerReadyData, unknown>()
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<typeof registerIpcHandlers>[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<void>((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,
)
+10 -12
View File
@@ -4,22 +4,20 @@ import type { DesktopEvent } from "../shared/ipc-rpc/events"
const queues = new Map<number, Queue.Queue<DesktopEvent>>()
export function bindIpcEvents(senderId: number) {
const queue = Effect.runSync(Queue.unbounded<DesktopEvent>())
export const bindIpcEvents = Effect.fn("IpcEvents.bind")(function* (senderId: number) {
const queue = yield* Queue.unbounded<DesktopEvent>()
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<DesktopEvent>())
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) {
+62 -54
View File
@@ -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<ServerReadyData>
consumeInitialDeepLinks: () => Promise<string[]> | string[]
getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void
isFirstLaunchOnboardingPending: () => Promise<boolean> | boolean
finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null> | string | null
checkAppExists: (appName: string) => Promise<boolean> | boolean
resolveAppPath: (appName: string) => Promise<string | null>
setBackgroundColor: (color: string) => void
exportDebugLogs: () => Promise<string>
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | 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<A>(evaluate: () => A | Promise<A>) {
return Effect.promise(async () => evaluate())
return Effect.tryPromise(async () => evaluate()).pipe(Effect.orDie)
}
+37 -35
View File
@@ -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<typeof createFileCapabilities>) {
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
}),
})
}),
)
+21 -19
View File
@@ -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> | 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,
}),
),
})
}),
)
@@ -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<typeof createDesktopStorage>) {
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
}),
})
}),
)
@@ -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,
})
}),
)
+22 -23
View File
@@ -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),
})
}),
)
+15 -12
View File
@@ -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<void>
}
type Handoff = {
@@ -33,9 +33,11 @@ export const IpcServerProtocolLive = Layer.unwrap(
const serialization = yield* RpcSerialization.RpcSerialization
const disconnects = yield* Queue.unbounded<number>()
const inbound = yield* Queue.unbounded<readonly [number, RpcMessage.FromClientEncoded]>()
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,
+51 -26
View File
@@ -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> | void
}
export async function registerIpcHandlers(deps: Deps, updater: UpdaterIpc, wsl: WslIpc) {
export function layer(initialization: Effect.Effect<ServerReadyData>, 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),
}
})
@@ -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<string, string> = {
dev: "OpenCode Dev",
@@ -27,7 +24,8 @@ const appIDs: Record<string, string> = {
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"]
+141 -74
View File
@@ -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<void>) {
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<void>
readonly consumeInitialDeepLinks: () => string[]
readonly createWindow: () => BrowserWindow
readonly restoreWindows: () => BrowserWindow[]
}
export class Service extends Context.Service<Service, Interface>()("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()
},
})
}),
)
@@ -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
}
})
@@ -0,0 +1,28 @@
export * as Shutdown from "./shutdown"
import { Context, Effect, Layer } from "effect"
export interface Interface {
readonly add: (effect: Effect.Effect<void>) => Effect.Effect<() => void>
readonly run: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/Shutdown") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const effects = new Set<Effect.Effect<void>>()
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,
})
}),
)
+204 -126
View File
@@ -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<typeof initLogging>
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<void>
readonly exportDebug: Effect.Effect<string>
}
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<Service, Interface>()("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<string, unknown>,
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<void>) {
return effect.pipe(Effect.annotateLogs("scope", name))
}
@@ -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()
+4 -2
View File
@@ -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
+18 -6
View File
@@ -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)
@@ -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<string, unknown>): void
error(message: string, meta?: Record<string, unknown>): 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 {}
@@ -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<ServerReadyData>
}
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/Initialization") {}
export const layer = (initialization: Effect.Effect<ServerReadyData>) =>
Layer.succeed(Service, Service.of({ await: initialization }))
export function forwardInitializationFailure<A>(initialization: Deferred.Deferred<A, unknown>) {
return <B, E, R>(effect: Effect.Effect<B, E, R>) =>
@@ -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)
})
})
+25 -28
View File
@@ -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<string, string> } | { 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<string, string> | null, env: Record<string, string>) {
return {
+118 -71
View File
@@ -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 = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) =>
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"])
}),
),
)
})
+49 -39
View File
@@ -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<StoreCandidate>()
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
}
}
})
+44 -15
View File
@@ -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<typeof make>
export class Service extends Context.Service<Service, Interface>()("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,
},
}
}
@@ -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", () => {
@@ -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"
})
}
+5 -7
View File
@@ -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)
}
+109 -78
View File
@@ -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<void>) {
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<void>
readonly unsubscribe: (id: number) => Effect.Effect<void>
readonly check: Effect.Effect<UpdaterState>
readonly install: Effect.Effect<void>
readonly show: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("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<number, () => 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<typeof createUpdaterIpc>
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<number, () => 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<typeof createUpdaterController>) {
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<A>(evaluate: () => Promise<A>) {
return Effect.tryPromise(evaluate).pipe(Effect.orDie)
}
+26 -15
View File
@@ -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<typeof getLogger>): UpdaterPlatform {
configureUpdater(logger)
autoUpdater.on("before-quit-for-update", () => setAppQuitting())
export function createUpdaterPlatform(
runFork: (effect: Effect.Effect<void>) => 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<typeof getLogger>): Upd
return result.updateInfo.version
},
stageUpdate,
installAndRestart: () => installAndRestart(logger),
installAndRestart: () => installAndRestart(runFork),
dispose: () => autoUpdater.off("before-quit-for-update", beforeQuit),
}
}
function configureUpdater(logger: ReturnType<typeof getLogger>) {
updateClient.logger = logger
function configureUpdater(runFork: (effect: Effect.Effect<void>) => 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<typeof getLogger>) {
function installAndRestart(runFork: (effect: Effect.Effect<void>) => unknown) {
return new Promise<never>((_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 = () => {
+13 -11
View File
@@ -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() {
+40 -17
View File
@@ -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<BrowserWindow, string>()
const registry = createWindowRegistry<BrowserWindow>({
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<void>) => unknown
readonly exportDebug: () => Promise<string>
}
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) {
+24 -15
View File
@@ -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<void>) => 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 })
}
})
+41 -14
View File
@@ -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<string>,
runFork: (effect: Effect.Effect<void>) => 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 })))
})
}
@@ -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"])
})
})
@@ -3,7 +3,6 @@
export function createWindowRegistry<W>(persistence: {
read: () => unknown
write: (ids: string[]) => void
cleanup: (id: string) => void
}) {
const windows = new Map<string, W>()
let quitting = false
@@ -39,9 +38,9 @@ export function createWindowRegistry<W>(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
},
}
}
@@ -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" }
@@ -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<void>) => unknown,
) {
let sampleTimer: ReturnType<typeof setTimeout> | undefined
let stopTimer: ReturnType<typeof setTimeout> | 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
}
+70 -86
View File
@@ -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<void>
refreshDistros(): Promise<void>
installWsl(): Promise<void>
installDistro(value: string): Promise<void>
probeAddable(value: string[]): Promise<void>
installOpencode(value: string): Promise<void>
openTerminal(value: string): Promise<void>
addServer(value: string): Promise<WslServerConfig>
removeServer(value: string): Promise<void>
startServer(value: string): Promise<void>
export interface Interface {
readonly subscribe: (sender: WebContents) => Effect.Effect<void>
readonly unsubscribe: (id: number) => Effect.Effect<void>
readonly getState: () => Effect.Effect<WslServersState>
readonly probeRuntime: () => Effect.Effect<void>
readonly refreshDistros: () => Effect.Effect<void>
readonly installWsl: () => Effect.Effect<void>
readonly installDistro: (value: string) => Effect.Effect<void>
readonly probeAddable: (value: string[]) => Effect.Effect<void>
readonly installOpencode: (value: string) => Effect.Effect<void>
readonly openTerminal: (value: string) => Effect.Effect<void>
readonly addServer: (value: string) => Effect.Effect<WslServerConfig>
readonly removeServer: (value: string) => Effect.Effect<void>
readonly startServer: (value: string) => Effect.Effect<void>
}
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<number, () => 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<A>(evaluate: () => Promise<A>) {
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),
}
}
+41 -31
View File
@@ -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)))
})
+31 -20
View File
@@ -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 {
@@ -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<ReturnType<ControllerOptions["spawnSidecar"]>>) => 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> = {}): ControllerOptions {
return {
cli: { version: "0.0.0-dev-16365" },
installCli: async () => undefined,
installDistro: async () => undefined,
spawnSidecar: async () => ({
stop: async () => undefined,
onExit: () => undefined,
+20 -5
View File
@@ -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<void>
installDistro: (distro: string) => Promise<void>
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<string, RunningSidecar>()
const starts = new Map<string, symbol>()
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()
},
+48 -25
View File
@@ -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<void>
}
export async function startWsl(cli: Cli, logger: Logger) {
if (process.platform !== "win32") return { ipc: createWslIpc(), start: () => {}, stop: async () => {} }
export class Service extends Context.Service<Service, Interface>()("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<FileSystem.FileSystem | Path.Path>()
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
})
@@ -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<EventTag, Set<(value: unknown) => void>>()
window.addEventListener("pagehide", () => void runtime.dispose(), { once: true })
runtime.runFork(
Effect.gen(function* () {
@@ -2752,7 +2752,6 @@ ToolRegistry.register({
const trigger = () => (
<div data-slot="skill-tool-trigger" class="flex min-w-0 items-center gap-1.5">
<Icon name="post-skill" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span
data-slot="skill-tool-label"
class="shrink-0 text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-muted"