Compare commits

..

3 Commits

Author SHA1 Message Date
Brendan Allan c4d283d23d chore(desktop): merge v2 into effect rpc 2026-08-20 16:51:48 +08:00
Brendan Allan 41b4e1e8e2 moar effect 2026-08-20 16:20:50 +08:00
Brendan Allan 12d3824212 refactor(desktop): move IPC to Effect RPC 2026-08-18 11:13:53 +00:00
92 changed files with 3396 additions and 1882 deletions
+1
View File
@@ -415,6 +415,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:",
@@ -8,13 +8,10 @@ const directory = "C:/OpenCode/SubagentNavigation"
const projectID = "proj_subagent_navigation"
const parentID = "ses_subagent_parent"
const childID = "ses_subagent_child"
const grandchildID = "ses_subagent_grandchild"
const parentTitle = "Parent session"
const childTitle = "Subagent child session"
const grandchildTitle = "Nested subagent session"
// Child session pages derive their heading from the task part that spawned them.
const taskDescription = "Inspect child navigation"
const nestedTaskDescription = "Inspect nested navigation"
test.use({ viewport: { width: 1440, height: 900 } })
@@ -29,23 +26,6 @@ test("navigates to a subagent child session missing from the session list", asyn
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
})
test("keeps the root tab active for a nested subagent", async ({ page }) => {
await setup(page)
await openChildFromParent(page)
await expectSessionTitle(page, taskDescription)
const card = page.locator(`a[href="${sessionHref(grandchildID)}"]`)
await expect(card).toBeVisible()
await card.click()
await expect(page).toHaveURL(new RegExp(`/server/.+/session/${grandchildID}$`), { timeout: 15_000 })
await expectSessionTitle(page, nestedTaskDescription)
const rootTab = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(parentID)}"])`)
await expect(rootTab).toHaveAttribute("data-active", "true")
await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(1)
})
test("keeps the parent visible while the child session resolves", async ({ page }) => {
await setup(page)
const requested = Promise.withResolvers<void>()
@@ -114,10 +94,8 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [session(parentID, parentTitle, 1700000000000), childSession(), grandchildSession()],
pageMessages: (sessionID) => ({
items: sessionID === parentID ? parentMessages() : sessionID === childID ? childMessages() : [],
}),
sessions: [session(parentID, parentTitle, 1700000000000), childSession()],
pageMessages: (sessionID) => ({ items: sessionID === parentID ? parentMessages() : [] }),
events,
eventRetry: events ? 16 : undefined,
})
@@ -167,10 +145,6 @@ function childSession() {
return session(childID, childTitle, 1700000001000, { parentID })
}
function grandchildSession() {
return session(grandchildID, grandchildTitle, 1700000002000, { parentID: childID })
}
function parentMessages(): SessionMessageInfo[] {
const userID = "msg_user_0001"
const assistantID = "msg_assistant_0001"
@@ -208,43 +182,6 @@ function parentMessages(): SessionMessageInfo[] {
]
}
function childMessages(): SessionMessageInfo[] {
const userID = "msg_user_0002"
const assistantID = "msg_assistant_0002"
return [
{
id: userID,
type: "user",
time: { created: 1700000002000 },
text: "Delegate nested work to a subagent",
},
{
id: assistantID,
type: "assistant",
time: { created: 1700000003000, completed: 1700000004000 },
model: { id: "claude-opus-4-6", providerID: "opencode" },
agent: "build",
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
finish: "stop",
content: [
{
type: "tool",
id: "call_subagent_0002",
name: "subagent",
time: { created: 1700000003000, ran: 1700000003000, completed: 1700000004000 },
state: {
status: "completed",
input: { description: nestedTaskDescription, agent: "explore", prompt: "Inspect the nested work." },
content: [{ type: "text", text: "Nested subagent finished" }],
metadata: { sessionID: grandchildID },
},
},
],
},
]
}
async function configurePage(page: Page) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript(
+23 -42
View File
@@ -34,7 +34,6 @@ import { tabKey, useTabs } from "@/context/tabs"
import type { PromptSession } from "@/context/prompt"
import "./titlebar.css"
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
import { rootSession } from "@/utils/session-route"
const v2TitlebarHeight = 36
const minTitlebarZoom = 0.25
@@ -171,43 +170,15 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const tabs = useTabs()
const tabsStore = tabs.store
const tabsStoreActions = tabs
const routeContext = createMemo(() => {
const route = layout.route()
if (route.type !== "session") return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
return conn ? { route, ctx: global.ensureServerCtx(conn) } : undefined
})
const [resolvedSession] = createResource(routeContext, ({ route, ctx }) =>
(async () => {
const session =
ctx.data.session.get(route.sessionId) ??
(await ctx.sdk.api.session.get({ sessionID: route.sessionId }))
const root = await rootSession(
session,
async (sessionID) =>
ctx.data.session.get(sessionID) ?? (await ctx.sdk.api.session.get({ sessionID })),
)
return { session, rootID: root.id }
})().catch(() => undefined),
const [session] = createResource(
() => {
const route = layout.route()
if (route.type !== "session") return undefined
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
},
({ route, sdk }) => sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}),
)
const session = () => {
const input = routeContext()
if (!input) return
const loaded = input.ctx.data.session.get(input.route.sessionId)
if (loaded) return loaded
const resolved = resolvedSession()
return resolved?.session.id === input.route.sessionId ? resolved.session : undefined
}
const rootID = () => {
const input = routeContext()
if (!input) return
const current = input.ctx.data.session.get(input.route.sessionId)
const resolved = resolvedSession()
if (!current) return resolved?.session.id === input.route.sessionId ? resolved.rootID : undefined
const root = input.ctx.data.session.root(current.id)
if (!current.parentID || input.ctx.data.session.get(root)) return root
return resolved?.session.id === input.route.sessionId ? resolved.rootID : undefined
}
const matchRoute = (route: LayoutRoute) => {
if (route.type === "home") return
@@ -215,10 +186,19 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
return tabsStore.find((item) => item.type === "draft" && item.draftID === route.draftID)
}
if (route.type === "session") {
const sessionId = rootID() ?? route.sessionId
return tabsStore.find(
(item) => item.type === "session" && item.server === route.server && item.sessionId === sessionId,
const main = tabsStore.find(
(item) =>
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
)
if (main) return main
const s = session()
if (s?.parentID) {
const parentID = s.parentID
const parent = tabsStore.find(
(item) => item.type === "session" && item.server === route.server && item.sessionId === parentID,
)
if (parent) return parent
}
}
}
@@ -234,8 +214,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
}
if (route.type === "session") {
const sessionId = rootID()
if (!sessionId) return
const s = session()
if (!s) return
const sessionId = s.parentID ?? s.id
const next = { server: route.server, sessionId }
tabsStoreActions.addSessionTab(next)
}
+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 -97
View File
@@ -1,111 +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,
registerUpdaterIpcHandlers,
registerWslInitialization,
registerWslIpcHandlers,
} 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"
const main = Effect.gen(function* () {
const logger = configureApplication()
if (!acquireApplicationLock()) return
preferApplicationEnvironment(logger)
loadProxyEnvironment(logger)
const lifecycle = createApplicationLifecycle(logger)
const serverReady = Deferred.makeUnsafe<ServerReadyData, unknown>()
const wslReady = Promise.withResolvers<void>()
logger.log("starting v2 background service")
const backgroundTask = yield* Effect.promise(() => startBackgroundCli(logger)).pipe(Effect.forkChild)
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 = yield* Effect.promise(() => setupAutoUpdater(lifecycle.prepareToRestart))
const menu = {
trigger: (id: string) => {
const win = getLastFocusedWindow()
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => void showUpdaterDialog(updater),
relaunch: lifecycle.relaunch,
}
registerIpcHandlers({
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)
},
})
registerUpdaterIpcHandlers(createUpdaterIpc(updater))
registerWslInitialization(wslReady.promise)
startAutoUpdater(updater)
yield* Effect.promise(() => startNetworkLogging())
const loadingTask = yield* Effect.gen(function* () {
const background = yield* Fiber.join(backgroundTask)
yield* Deferred.succeed(serverReady, {
url: background.url,
username: background.username,
password: background.password,
})
logger.log("loading task finished")
void startWsl(background, logger).then(
(wsl) => {
registerWslIpcHandlers(wsl.ipc)
lifecycle.setWslShutdown(wsl.stop)
wsl.start()
wslReady.resolve()
},
(error) => {
logger.error("failed to start WSL manager", { error })
wslReady.reject(error)
},
)
}).pipe(forwardInitializationFailure(serverReady), Effect.forkChild)
if (lifecycle.restoreWindows().length) createMenu(menu)
yield* Fiber.await(loadingTask)
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,
)
+26
View File
@@ -0,0 +1,26 @@
import type { WebContents } from "electron"
import { Effect, Queue, Stream } from "effect"
import type { DesktopEvent } from "../shared/ipc-rpc/events"
const queues = new Map<number, Queue.Queue<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)
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) {
const queue = queues.get(senderId)
return queue ? Stream.fromQueue(queue) : Stream.empty
}
export function emitIpcEvent(sender: WebContents, event: DesktopEvent) {
const queue = queues.get(sender.id)
if (queue) Queue.offerUnsafe(queue, event)
}
@@ -0,0 +1,72 @@
import { BrowserWindow } from "electron"
import { parseDesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
import { Effect } from "effect"
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 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.tryPromise(async () => evaluate()).pipe(Effect.orDie)
}
@@ -0,0 +1,9 @@
import type { IpcPortHandoff } from "../ipc-transport"
export type RpcContext = { readonly client: { readonly id: number } }
export function sender(handoff: IpcPortHandoff["Service"], context: RpcContext) {
const contents = handoff.sender(context.client.id)
if (!contents || contents.isDestroyed()) throw new Error("Renderer connection not found")
return contents
}
@@ -0,0 +1,14 @@
import { Effect } from "effect"
import { EventRpcs } from "../../shared/ipc-rpc"
import { ipcEventStream } from "../ipc-events"
import { IpcPortHandoff } from "../ipc-transport"
import { sender } from "./context"
export const eventHandlers = EventRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return EventRpcs.of({
DesktopEvents: (_request, context) => ipcEventStream(sender(handoff, context).id),
})
}),
)
@@ -0,0 +1,42 @@
import { Effect } from "effect"
import { FileRpcs } from "../../shared/ipc-rpc"
import { DesktopFiles, openExternalURL, openLocalFileURL } from "../files"
import { IpcPortHandoff } from "../ipc-transport"
import { sender } from "./context"
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
}),
})
}),
)
@@ -0,0 +1,28 @@
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 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,
}),
),
})
}),
)
@@ -0,0 +1,29 @@
import { Effect } from "effect"
import { StorageRpcs } from "../../shared/ipc-rpc"
import { DesktopStorage } from "../storage"
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
}),
})
}),
)
@@ -0,0 +1,18 @@
import { Effect } from "effect"
import { UpdaterRpcs } from "../../shared/ipc-rpc"
import { IpcPortHandoff } from "../ipc-transport"
import { Updater } from "../updater"
import { sender } from "./context"
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,
})
}),
)
@@ -0,0 +1,58 @@
import { BrowserWindow } from "electron"
import { Effect } from "effect"
import { WindowRpcs } from "../../shared/ipc-rpc"
import { IpcPortHandoff } from "../ipc-transport"
import {
getPinchZoomEnabled,
getWindowID,
setPinchZoomEnabled,
setTitlebar,
setWindowThemeReady,
updateTitlebar,
} from "../windows"
import { sender } from "./context"
export const windowHandlers = WindowRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return WindowRpcs.of({
WindowGetId: (_args, context) =>
Effect.sync(() => {
const win = BrowserWindow.fromWebContents(sender(handoff, context))
if (!win) throw new Error("Window not found")
const id = getWindowID(win)
if (!id) throw new Error("Window ID not found")
return id
}),
WindowThemeReady: (_args, context) =>
Effect.sync(() => {
const win = BrowserWindow.fromWebContents(sender(handoff, context))
if (!win) throw new Error("Window not found")
setWindowThemeReady(win)
}),
WindowGetFocused: (_args, context) =>
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.isFocused() ?? false),
WindowGetFullscreen: (_args, context) =>
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.isFullScreen() ?? false),
WindowSetFocus: (_args, context) =>
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.focus()),
WindowShow: (_args, context) =>
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.show()),
WindowGetZoomFactor: (_args, context) => Effect.sync(() => sender(handoff, context).getZoomFactor()),
WindowSetZoomFactor: ({ factor }, context) =>
Effect.sync(() => {
const contents = sender(handoff, context)
contents.setZoomFactor(factor)
const win = BrowserWindow.fromWebContents(contents)
if (win) updateTitlebar(win)
}),
WindowGetPinchZoomEnabled: () => Effect.sync(getPinchZoomEnabled),
WindowSetPinchZoomEnabled: ({ enabled }) => Effect.sync(() => setPinchZoomEnabled(enabled)),
WindowSetTitlebar: ({ theme }, context) =>
Effect.sync(() => {
const win = BrowserWindow.fromWebContents(sender(handoff, context))
if (win) setTitlebar(win, theme)
}),
})
}),
)
@@ -0,0 +1,27 @@
import { Effect } from "effect"
import { WslRpcs } from "../../shared/ipc-rpc"
import { IpcPortHandoff } from "../ipc-transport"
import { Wsl } from "../wsl/start"
import { sender } from "./context"
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),
})
}),
)
@@ -0,0 +1,181 @@
import { describe, expect, test } from "bun:test"
import { EventEmitter } from "node:events"
import { MessageChannel } from "node:worker_threads"
import type { MessagePortMain, WebContents } from "electron"
import { Context, Effect, Layer, ManagedRuntime, Option, Queue, Schema, Stream } from "effect"
import { Rpc, RpcClient, RpcClientError, RpcGroup, RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"
import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport"
describe("desktop RPC transport", () => {
test("keeps multiple renderer ports independent", async () => {
const handlers = TestRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return TestRpcs.of({
"test.focused": (_request, context) => Effect.succeed(handoff.sender(context.client.id)?.id === 1),
"test.blob.put": ({ data }) => Effect.succeed([...data].join(",")),
"test.blob.get": () => Effect.succeed(new Uint8Array([3, 1, 4])),
"test.events": () => Stream.make(new TestEvent({ value: "session.new" })),
})
}),
)
const live = RpcServer.layer(TestRpcs).pipe(Layer.provide(handlers), Layer.provideMerge(IpcServerProtocolLive))
const runtime = ManagedRuntime.make(live)
const handoff = await runtime.runPromise(IpcPortHandoff)
const first = new MessageChannel()
const second = new MessageChannel()
handoff.bind(sender(1), serverPort(first.port1))
handoff.bind(sender(2), serverPort(second.port1))
const firstClient = makeClient(first.port2)
const secondClient = makeClient(second.port2)
const [focused, unfocused] = await Promise.all([callFocused(firstClient), callFocused(secondClient)])
expect(focused).toBe(true)
expect(unfocused).toBe(false)
expect(await putBlob(firstClient, new Uint8Array([2, 7, 1]))).toBe("2,7,1")
expect(await getBlob(firstClient)).toEqual(new Uint8Array([3, 1, 4]))
expect(await firstEvent(firstClient)).toEqual(new TestEvent({ value: "session.new" }))
const reloaded = new MessageChannel()
handoff.bind(sender(1), serverPort(reloaded.port1))
const reloadedClient = makeClient(reloaded.port2)
const [reloadedFocused, stillUnfocused] = await Promise.all([
callFocused(reloadedClient),
callFocused(secondClient),
])
expect(reloadedFocused).toBe(true)
expect(stillUnfocused).toBe(false)
await Promise.all([firstClient.dispose(), secondClient.dispose(), reloadedClient.dispose()])
await runtime.dispose()
})
})
class TestEvent extends Schema.TaggedClass<TestEvent>()("TestEvent", { value: Schema.String }) {}
const TestRpcs = RpcGroup.make(
Rpc.make("test.focused", { success: Schema.Boolean }),
Rpc.make("test.blob.put", { payload: { data: Schema.Uint8Array }, success: Schema.String }),
Rpc.make("test.blob.get", { success: Schema.Uint8Array }),
Rpc.make("test.events", { success: TestEvent, stream: true }),
)
type TestRpcClient = RpcClient.FromGroup<typeof TestRpcs, RpcClientError.RpcClientError>
class TestClient extends Context.Service<TestClient, TestRpcClient>()("opencode/desktop/TestClient") {}
function makeClient(port: MessagePort) {
return ManagedRuntime.make(
Layer.effect(TestClient, RpcClient.make(TestRpcs)).pipe(Layer.provide(clientProtocol(port))),
)
}
function callFocused(runtime: ManagedRuntime.ManagedRuntime<TestClient, never>) {
return runtime.runPromise(
Effect.gen(function* () {
const client = yield* TestClient
return yield* client["test.focused"]()
}),
)
}
function putBlob(runtime: ManagedRuntime.ManagedRuntime<TestClient, never>, data: Uint8Array) {
return runtime.runPromise(
Effect.gen(function* () {
const client = yield* TestClient
return yield* client["test.blob.put"]({ data })
}),
)
}
function getBlob(runtime: ManagedRuntime.ManagedRuntime<TestClient, never>) {
return runtime.runPromise(
Effect.gen(function* () {
const client = yield* TestClient
return yield* client["test.blob.get"]()
}),
)
}
function firstEvent(runtime: ManagedRuntime.ManagedRuntime<TestClient, never>) {
return runtime.runPromise(
Effect.gen(function* () {
const client = yield* TestClient
return yield* client["test.events"]().pipe(Stream.runHead, Effect.map(Option.getOrThrow))
}),
)
}
function clientProtocol(port: MessagePort) {
return Layer.effect(
RpcClient.Protocol,
RpcClient.Protocol.make(
Effect.fnUntraced(function* (writeResponse, clientIds) {
const serialization = yield* RpcSerialization.RpcSerialization
const parser = serialization.makeUnsafe()
const inbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
const onMessage = (event: MessageEvent) =>
parser
.decode(event.data)
.forEach((message) => Queue.offerUnsafe(inbound, message as RpcMessage.FromServerEncoded))
port.addEventListener("message", onMessage)
port.start()
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
port.removeEventListener("message", onMessage)
port.close()
}),
)
yield* Stream.fromQueue(inbound).pipe(
Stream.runForEach((message) =>
Effect.forEach(clientIds, (clientId) => writeResponse(clientId, message), { discard: true }),
),
Effect.forkScoped,
)
return {
send: (_clientId: number, request: RpcMessage.FromClientEncoded) =>
Effect.sync(() => {
const encoded = parser.encode(request)
if (encoded !== undefined) port.postMessage(encoded)
}),
supportsAck: true,
supportsTransferables: false,
}
}),
),
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
}
function sender(id: number) {
const events = new EventEmitter()
return {
id,
isDestroyed: () => false,
once: events.once.bind(events),
off: events.off.bind(events),
} as unknown as WebContents
}
function serverPort(port: import("node:worker_threads").MessagePort) {
const listeners = new Map<(event: Electron.MessageEvent) => void, (data: unknown) => void>()
return {
on(event: string, listener: (event: Electron.MessageEvent) => void) {
if (event !== "message") {
port.on(event, listener)
return
}
const wrapped = (data: unknown) => listener({ data } as Electron.MessageEvent)
listeners.set(listener, wrapped)
port.on("message", wrapped)
},
off(event: string, listener: (event: Electron.MessageEvent) => void) {
if (event !== "message") {
port.off(event, listener)
return
}
const wrapped = listeners.get(listener)
if (wrapped) port.off("message", wrapped)
},
postMessage: port.postMessage.bind(port),
start: port.start.bind(port),
close: port.close.bind(port),
} as unknown as MessagePortMain
}
+125
View File
@@ -0,0 +1,125 @@
import type { MessagePortMain, WebContents } from "electron"
import { Context, Effect, Layer, Option, Queue, Stream } from "effect"
import { RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"
import { bindIpcEvents } from "./ipc-events"
type PortBinding = {
readonly id: number
readonly sender: WebContents
readonly port: MessagePortMain
readonly parser: RpcSerialization.Parser
readonly onMessage: (event: Electron.MessageEvent) => void
readonly onClose: () => void
readonly unbindEvents: Effect.Effect<void>
}
type Handoff = {
readonly bind: (sender: WebContents, port: MessagePortMain) => void
readonly sender: (clientId: number) => WebContents | undefined
}
export class IpcPortHandoff extends Context.Service<IpcPortHandoff, Handoff>()("opencode/desktop/IpcPortHandoff") {}
export const IpcServerProtocolLive = Layer.unwrap(
Effect.gen(function* () {
const handoffs = yield* Queue.unbounded<readonly [WebContents, MessagePortMain]>()
const bindings = new Map<number, PortBinding>()
const senderBindings = new Map<number, number>()
const protocol = Layer.effect(
RpcServer.Protocol,
RpcServer.Protocol.make(
Effect.fnUntraced(function* (writeRequest) {
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 = Effect.fnUntraced(function* (id: number) {
const binding = bindings.get(id)
if (!binding) return
bindings.delete(id)
if (senderBindings.get(binding.sender.id) === id) senderBindings.delete(binding.sender.id)
binding.port.off("message", binding.onMessage)
binding.port.off("close", binding.onClose)
binding.sender.off("destroyed", binding.onClose)
yield* binding.unbindEvents
binding.port.close()
Queue.offerUnsafe(disconnects, id)
})
const bind = Effect.fnUntraced(function* (sender: WebContents, port: MessagePortMain) {
const previous = senderBindings.get(sender.id)
if (previous !== undefined) yield* disconnect(previous)
if (sender.isDestroyed()) {
port.close()
return
}
const id = nextClientId++
const parser = serialization.makeUnsafe()
const onMessage = (event: Electron.MessageEvent) => {
try {
parser
.decode(event.data)
.forEach((message) =>
Queue.offerUnsafe(inbound, [id, message as RpcMessage.FromClientEncoded] as const),
)
} catch {
return
}
}
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]) => 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.forEach([...bindings.keys()], disconnect, { discard: true }))
return {
disconnects,
send: (clientId, response) =>
Effect.sync(() => {
const binding = bindings.get(clientId)
if (!binding) return
const encoded = binding.parser.encode(response)
if (encoded !== undefined) binding.port.postMessage(encoded)
}),
end: disconnect,
clientIds: Effect.sync(() => new Set(bindings.keys())),
initialMessage: Effect.succeed(Option.none()),
supportsAck: true,
supportsTransferables: false,
supportsSpanPropagation: false,
}
}),
),
)
return Layer.merge(
protocol,
Layer.succeed(IpcPortHandoff)({
bind: (sender, port) => {
Queue.offerUnsafe(handoffs, [sender, port])
},
sender: (clientId) => bindings.get(clientId)?.sender,
}),
)
}),
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
+74 -195
View File
@@ -1,201 +1,80 @@
import { BrowserWindow, ipcMain } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
import { parseDesktopNativeBundle, type DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
export * as Ipc from "./ipc"
import {
Ipc,
type FatalRendererError,
type IpcInvoke,
type IpcInvokeArgs,
type IpcInvokeResult,
type IpcSend,
type ServerReadyData,
} from "../shared/ipc-contract"
import { createFileCapabilities, openExternalURL, openLocalFileURL } from "./files"
import { setForceFocus } from "./native/debug"
import { runDesktopMenuAction } from "./native/menu-actions"
import { createDesktopStorage } from "./storage"
import {
getPinchZoomEnabled,
getWindowID,
setPinchZoomEnabled,
setTitlebar,
setWindowThemeReady,
updateTitlebar,
} from "./windows"
import type { UpdaterIpc } from "./updater"
import type { WslIpc } from "./wsl/ipc"
import { app, BrowserWindow, MessageChannelMain } from "electron"
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 { 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"
import { storageHandlers } from "./ipc-handlers/storage"
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 { 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 MaybePromise<Value> = Value | Promise<Value>
function handle<Channel extends keyof IpcInvoke>(
channel: Channel,
listener: (event: IpcMainInvokeEvent, ...args: IpcInvokeArgs<Channel>) => MaybePromise<IpcInvokeResult<Channel>>,
) {
ipcMain.handle(channel, listener)
}
function on<Channel extends keyof IpcSend>(
channel: Channel,
listener: (event: IpcMainEvent, ...args: IpcSend[Channel]) => void,
) {
ipcMain.on(channel, listener)
}
type Deps = {
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>
showUpdater: () => Promise<void> | void
setBackgroundColor: (color: string) => void
exportDebugLogs: () => Promise<string>
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
setNativeTranslations: (bundle: DesktopNativeBundle) => void
}
export function registerIpcHandlers(deps: Deps) {
const files = createFileCapabilities()
const storage = createDesktopStorage()
handle(Ipc.app.awaitInitialization, () => deps.awaitInitialization())
handle(Ipc.app.consumeInitialDeepLinks, () => deps.consumeInitialDeepLinks())
handle(Ipc.app.getDefaultServerUrl, () => deps.getDefaultServerUrl())
handle(Ipc.app.setDefaultServerUrl, (_event, url) => deps.setDefaultServerUrl(url))
handle(Ipc.app.isFirstLaunchOnboardingPending, () => deps.isFirstLaunchOnboardingPending())
handle(Ipc.app.finishFirstLaunchOnboarding, (_event, createDefaultProject) =>
deps.finishFirstLaunchOnboarding(createDefaultProject),
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)),
)
handle(Ipc.app.checkAppExists, (_event, appName) => deps.checkAppExists(appName))
handle(Ipc.app.resolveAppPath, (_event, appName) => deps.resolveAppPath(appName))
handle(Ipc.app.setBackgroundColor, (_event, color) => deps.setBackgroundColor(color))
handle(Ipc.app.exportDebugLogs, () => deps.exportDebugLogs())
handle(Ipc.app.setForceFocus, (event, enabled) => setForceFocus(event.sender, enabled))
handle(Ipc.app.recordFatalRendererError, (_event, error) => deps.recordFatalRendererError(error))
handle(Ipc.app.setNativeTranslations, (event, value) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win || win.isDestroyed() || win.webContents !== event.sender || event.senderFrame !== event.sender.mainFrame) {
throw new Error("Invalid native translation sender")
}
const bundle = parseDesktopNativeBundle(value)
if (!bundle) throw new Error("Invalid native translation bundle")
deps.setNativeTranslations(bundle)
})
handle(Ipc.storage.get, (_event, name, key) => {
return storage.get(name, key)
})
handle(Ipc.storage.set, (_event, name, key, value) => storage.set(name, key, value))
handle(Ipc.storage.delete, (_event, name, key) => storage.deleteValue(name, key))
handle(Ipc.storage.clear, (_event, name) => storage.clear(name))
handle(Ipc.storage.keys, (_event, name) => storage.keys(name))
handle(Ipc.storage.length, (_event, name) => storage.length(name))
handle(Ipc.drafts.get, (_event, key) => storage.drafts.get(key))
handle(Ipc.drafts.set, (_event, key, value) => storage.drafts.set(key, value))
handle(Ipc.drafts.delete, (_event, key) => storage.drafts.set(key, null))
handle(Ipc.drafts.putBlob, (_event, data) => storage.drafts.putBlob(data))
handle(Ipc.drafts.getBlob, (_event, id) => storage.drafts.getBlob(id))
const handlers = Layer.mergeAll(
appHandlers,
storageHandlers,
fileHandlers,
windowHandlers,
menuHandlers,
updaterHandlers,
wslHandlers,
eventHandlers,
)
return RpcServer.layer(DesktopRpcs, { disableFatalDefects: true }).pipe(
Layer.provide(handlers),
Layer.provideMerge(IpcServerProtocolLive),
Layer.provideMerge(services),
)
}
handle(Ipc.files.openDirectoryPicker, (_event, options) => files.openDirectoryPicker(options))
handle(Ipc.files.openFilePicker, (event, options) => files.openFilePicker(event.sender.id, options))
handle(Ipc.files.readPickedFile, (event, token, path) => files.readPickedFile(event.sender.id, token, path))
handle(Ipc.files.releasePickedFiles, (event, token) => files.releasePickedFiles(event.sender.id, token))
handle(Ipc.files.saveFilePicker, (_event, options) => files.saveFilePicker(options))
on(Ipc.files.openExternal, (_event, url) => openExternalURL(url))
on(Ipc.files.openLocalFile, (_event, url) => openLocalFileURL(url))
handle(Ipc.files.openPath, (_event, path, app) => files.openPath(path, app))
handle(Ipc.files.revealPath, (_event, path) => files.revealPath(path))
handle(Ipc.files.readClipboardImage, () => files.readClipboardImage())
handle(Ipc.window.getId, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) throw new Error("Window not found")
const id = getWindowID(win)
if (!id) throw new Error("Window ID not found")
return id
})
handle(Ipc.window.themeReady, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) throw new Error("Window not found")
setWindowThemeReady(win)
})
handle(Ipc.window.getFocused, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
return win?.isFocused() ?? false
})
handle(Ipc.window.getFullscreen, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
return win?.isFullScreen() ?? false
})
handle(Ipc.window.setFocus, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
win?.focus()
})
handle(Ipc.window.show, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
win?.show()
})
on(Ipc.app.relaunch, () => {
deps.relaunch()
})
handle(Ipc.window.getZoomFactor, (event) => event.sender.getZoomFactor())
handle(Ipc.window.setZoomFactor, (event, factor) => {
event.sender.setZoomFactor(factor)
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
updateTitlebar(win)
})
handle(Ipc.window.getPinchZoomEnabled, () => getPinchZoomEnabled())
handle(Ipc.window.setPinchZoomEnabled, (_event, enabled) => {
setPinchZoomEnabled(enabled)
})
handle(Ipc.window.setTitlebar, (event, theme) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
setTitlebar(win, theme)
})
handle(Ipc.menu.runAction, (event, action) => {
runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action, {
checkForUpdates: () => void deps.showUpdater(),
relaunch: deps.relaunch,
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
const channel = new MessageChannelMain()
handoff.bind(win.webContents, channel.port1)
win.webContents.postMessage(IpcTransportPort, null, [channel.port2])
})
}
yield* Effect.sync(() => {
app.on("browser-window-created", wire)
BrowserWindow.getAllWindows().forEach((win) => wire({} as Electron.Event, win))
})
}
export function registerUpdaterIpcHandlers(updater: UpdaterIpc) {
handle(Ipc.updater.subscribe, (event) => updater.subscribe(event.sender))
handle(Ipc.updater.unsubscribe, (event) => updater.unsubscribe(event.sender.id))
handle(Ipc.updater.check, () => updater.check())
handle(Ipc.updater.install, () => updater.install())
}
export function registerWslInitialization(ready: Promise<void>) {
handle(Ipc.wsl.awaitInitialization, () => ready)
}
export function registerWslIpcHandlers(wsl: WslIpc) {
handle(Ipc.wsl.subscribe, (event) => wsl.subscribe(event.sender))
handle(Ipc.wsl.unsubscribe, (event) => wsl.unsubscribe(event.sender.id))
handle(Ipc.wsl.getState, () => wsl.getState())
handle(Ipc.wsl.probeRuntime, () => wsl.probeRuntime())
handle(Ipc.wsl.refreshDistros, () => wsl.refreshDistros())
handle(Ipc.wsl.installWsl, () => wsl.installWsl())
handle(Ipc.wsl.installDistro, (_event, value) => wsl.installDistro(value))
handle(Ipc.wsl.probeAddable, (_event, value) => wsl.probeAddable(value))
handle(Ipc.wsl.installOpencode, (_event, value) => wsl.installOpencode(value))
handle(Ipc.wsl.openTerminal, (_event, value) => wsl.openTerminal(value))
handle(Ipc.wsl.addServer, (_event, value) => wsl.addServer(value))
handle(Ipc.wsl.removeServer, (_event, value) => wsl.removeServer(value))
handle(Ipc.wsl.startServer, (_event, value) => wsl.startServer(value))
}
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,30 +33,18 @@ export function configureApplication() {
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
const appID = app.isPackaged ? appIDs[CHANNEL] : "ai.opencode.desktop.dev"
const testRoot = createTestRoot()
app.setName(app.isPackaged ? appNames[CHANNEL] : "OpenCode Dev")
app.setAppUserModelId(appID)
app.setPath("userData", testRoot ? join(testRoot, "desktop") : join(app.getPath("appData"), appID))
if (testRoot) app.setPath("sessionData", join(testRoot, "session"))
initializeFirstLaunchOnboarding(app.getPath("userData"))
const logger = initLogging()
initCrashReporter()
loadSystemCertificates(logger)
logger.log("app starting", {
version: VERSION,
packaged: app.isPackaged,
onboardingTest: testOnboarding,
})
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", process.env.OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT ?? "9222")
return logger
}
const testRoot = yield* createTestRoot()
app.setPath("userData", testRoot ? path.join(testRoot, "desktop") : path.join(app.getPath("appData"), appID))
if (testRoot) app.setPath("sessionData", path.join(testRoot, "session"))
})
export function acquireApplicationLock() {
if (app.requestSingleInstanceLock()) return true
@@ -66,73 +52,81 @@ 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))),
)
if (app.isPackaged || process.env.OPENCODE_DESKTOP_DISABLE_PROTOCOL_REGISTRATION !== "1")
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 })),
)
if (app.isPackaged || process.env.OPENCODE_DESKTOP_DISABLE_PROTOCOL_REGISTRATION !== "1")
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 createTestRoot() {
const createTestRoot = Effect.fn("Application.createTestRoot")(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const root = testOnboarding
? join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
? path.join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
: app.isPackaged
? undefined
: process.env.OPENCODE_DESKTOP_TEST_ROOT
if (!root) return undefined
if (testOnboarding) rmSync(root, { recursive: true, force: true })
;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) =>
mkdirSync(join(root, dir), { recursive: true }),
if (testOnboarding) 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 },
)
if (testOnboarding) 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"]
+143 -75
View File
@@ -1,80 +1,148 @@
export * as ApplicationLifecycle from "./index"
import { app, BrowserWindow } from "electron"
import type { Event } from "electron"
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
import { writeLog, type DesktopLogger } from "../native/logging"
import { Context, Effect, FileSystem, Layer, Path } from "effect"
import { DeepLinksOpened } from "../../shared/ipc-rpc/events"
import { emitIpcEvent } from "../ipc-events"
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) sendIpcEvent(win.webContents, Ipc.app.deepLink, 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,
})
}),
)
+205 -127
View File
@@ -1,8 +1,8 @@
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 { dirname, join } from "node:path"
import { Context, Effect, FileSystem, Layer, Logger, Option, Path, References } from "effect"
import { homedir } from "node:os"
import { VERSION } from "../constants"
@@ -16,97 +16,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() {
@@ -120,22 +183,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,
@@ -149,49 +217,55 @@ 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 { BlobReader, BlobWriter, ZipWriter } = await import("@zip.js/zip.js")
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 { BlobReader, BlobWriter, ZipWriter } = yield* Effect.promise(() => import("@zip.js/zip.js"))
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()
+7 -4
View File
@@ -6,16 +6,18 @@ import {
type DesktopMenuEntry,
type DesktopMenuRole,
} from "@opencode-ai/app/desktop-menu"
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
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
}
@@ -36,7 +38,7 @@ export function createMenu(deps: Deps) {
}
export function sendMenuCommand(win: BrowserWindow, id: string) {
sendIpcEvent(win.webContents, Ipc.menu.command, id)
emitIpcEvent(win.webContents, new MenuCommandTriggered({ id }))
}
function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOptions {
@@ -58,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,26 +32,29 @@ 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")
const url = new URL(service.url)
if (url.hostname === "0.0.0.0") url.hostname = "127.0.0.1"
logger.log("v2 CLI background service ready", {
yield* Effect.logInfo("v2 CLI background service ready", {
username: service.auth.username,
version: cli.version,
...endpoint(url.origin),
})
if (isolated && cli.binary) await cleanCliStages(cli.binary, logger)
if (isolated && cli.binary) yield* cleanCliStages(cli.binary)
return {
url: url.origin,
username: service.auth.username,
@@ -64,73 +68,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)
}
+116 -81
View File
@@ -1,99 +1,134 @@
export * as Updater from "./index"
import { app, dialog } from "electron"
import type { WebContents } from "electron"
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
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"
const key = "ready"
export async function setupAutoUpdater(prepareToRestart: () => Promise<void>) {
const logger = getLogger()
const store = getStore("opencode.updater")
const platform = UPDATER_ENABLED ? (await import("./platform")).createUpdaterPlatform(logger) : undefined
return createUpdaterController({
currentVersion: app.getVersion(),
platform,
lifecycle: { prepareToRestart },
persistence: {
get() {
const value = store.get(key)
if (!value || typeof value !== "object" || !("version" in value) || typeof value.version !== "string")
return undefined
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
? yield* Effect.promise(async () => {
const { createUpdaterPlatform } = await import("./platform")
return 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)
sendIpcEvent(sender, Ipc.updater.state, 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 = () => {
+19 -15
View File
@@ -2,9 +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 { Ipc, sendIpcEvent, type TitlebarTheme } from "../../shared/ipc-contract"
import { developmentResourcesRoot, preloadPath } from "../paths"
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 type { DesktopPaths } from "../paths"
import { BACKGROUND_COLOR_KEY, PINCH_ZOOM_ENABLED_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
@@ -20,12 +22,12 @@ 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()
const storedBackground = getStore().get(BACKGROUND_COLOR_KEY)
return {
title: "OpenCode",
icon: iconPath(),
icon: iconPath(path, paths),
backgroundColor:
backgroundColor ?? (typeof storedBackground === "string" ? storedBackground : undefined) ?? oc2Background[mode],
...(process.platform === "darwin"
@@ -42,7 +44,7 @@ export function windowAppearance() {
}
: {}),
webPreferences: {
preload: preloadPath,
preload: paths.preloadPath,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
@@ -50,9 +52,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)
}
@@ -86,7 +88,7 @@ export function setPinchZoomEnabled(enabled: boolean) {
getStore().set(PINCH_ZOOM_ENABLED_KEY, enabled)
BrowserWindow.getAllWindows().forEach((win) => {
pinchZoomEnabled.set(win, enabled)
sendIpcEvent(win.webContents, Ipc.window.pinchZoomEnabledChanged, enabled)
emitIpcEvent(win.webContents, new WindowPinchZoomChanged({ enabled }))
if (!enabled && win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
updateZoom(win)
})
@@ -115,18 +117,20 @@ export function wireZoom(win: BrowserWindow) {
export function wireFullscreen(win: BrowserWindow) {
const send = (fullscreen: boolean) => {
if (win.isDestroyed() || win.webContents.isDestroyed()) return
sendIpcEvent(win.webContents, Ipc.window.fullscreenChanged, fullscreen)
emitIpcEvent(win.webContents, new WindowFullscreenChanged({ fullscreen }))
}
win.on("enter-full-screen", () => send(true))
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() {
@@ -148,5 +152,5 @@ function clampZoom(value: number) {
function updateZoom(win: BrowserWindow) {
updateTitlebar(win)
sendIpcEvent(win.webContents, Ipc.window.zoomFactorChanged, win.webContents.getZoomFactor())
emitIpcEvent(win.webContents, new WindowZoomChanged({ factor: win.webContents.getZoomFactor() }))
}
+42 -20
View File
@@ -1,10 +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 { writeLog } from "../native/logging"
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,
@@ -28,10 +30,6 @@ const themeReady = new WeakMap<BrowserWindow, () => void>()
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()
@@ -51,7 +49,11 @@ export {
}
export function setRelaunchHandler(handler: () => void) {
const previous = relaunchHandler
relaunchHandler = handler
return () => {
if (relaunchHandler === handler) relaunchHandler = previous
}
}
export function setAppQuitting(quitting = true) {
@@ -74,12 +76,20 @@ export function setWindowThemeReady(win: BrowserWindow) {
themeReady.get(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,
@@ -88,16 +98,17 @@ 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)
let contentReady = false
let appliedTheme = false
@@ -106,7 +117,7 @@ export function createMainWindow(id: string = randomUUID()) {
if (!contentReady || !appliedTheme || revealed || win.isDestroyed()) return
revealed = true
win.show()
writeLog("window", "main window visible", { window: id })
deps.runFork(Effect.logInfo("main window visible", { window: id }))
}
const ready = () => {
contentReady = true
@@ -119,17 +130,28 @@ export function createMainWindow(id: string = randomUUID()) {
win.once("ready-to-show", ready)
if (process.platform === "linux") win.webContents.once("did-finish-load", ready)
win.once("closed", () => themeReady.delete(win))
loadWindow(win, "index.html")
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
}
+72 -59
View File
@@ -1,27 +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 { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
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 createWslIpc(controller?: WslServersController): WslIpc {
export function create(controller?: WslServersController): Interface {
if (!controller) return createUnavailableWslIpc()
const subscriptions = new Map<number, () => void>()
@@ -38,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
}
sendIpcEvent(sender, Ipc.wsl.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: [],
@@ -88,19 +100,20 @@ function createUnavailableWslIpc(): WslIpc {
})
return {
subscribe: (sender) => sendIpcEvent(sender, Ipc.wsl.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
})
+8 -143
View File
@@ -1,146 +1,11 @@
import { contextBridge, ipcRenderer, webUtils } from "electron"
import type { IpcRendererEvent } from "electron"
import type { ElectronAPI } from "./types"
import type { UpdaterState } from "@opencode-ai/app/updater"
import {
Ipc,
type IpcEvent,
type IpcEventListener,
type IpcInvoke,
type IpcInvokeArgs,
type IpcInvokeResult,
type IpcSend,
} from "../shared/ipc-contract"
import { IpcTransportPort } from "../shared/ipc-transport"
function invoke<Channel extends keyof IpcInvoke>(channel: Channel, ...args: IpcInvokeArgs<Channel>) {
return ipcRenderer.invoke(channel, ...args) as Promise<IpcInvokeResult<Channel>>
}
ipcRenderer.on(IpcTransportPort, (event) => {
const port = event.ports[0]
if (port) window.postMessage(IpcTransportPort, "*", [port])
})
function send<Channel extends keyof IpcSend>(channel: Channel, ...args: IpcSend[Channel]) {
ipcRenderer.send(channel, ...args)
}
function listen<Channel extends keyof IpcEvent>(channel: Channel, listener: IpcEventListener<Channel>) {
const handler = (_event: IpcRendererEvent, ...args: IpcEvent[Channel]) => listener(...args)
ipcRenderer.on(channel, handler)
return () => ipcRenderer.removeListener(channel, handler)
}
const updaterCallbacks = new Set<(state: UpdaterState) => void>()
let updaterState: UpdaterState | undefined
let updaterSubscription: Promise<void> | undefined
let updaterListener: (() => void) | undefined
const updaterHandler = (state: UpdaterState) => {
updaterState = state
updaterCallbacks.forEach((callback) => callback(state))
}
type WslInvoke = Exclude<
(typeof Ipc.wsl)[keyof typeof Ipc.wsl],
typeof Ipc.wsl.awaitInitialization | typeof Ipc.wsl.event
>
function invokeWsl<Channel extends WslInvoke>(channel: Channel, ...args: IpcInvokeArgs<Channel>) {
return invoke(Ipc.wsl.awaitInitialization).then(() => invoke(channel, ...args))
}
const api: ElectronAPI = {
awaitInitialization: () => invoke(Ipc.app.awaitInitialization),
wslServers: {
getState: () => invokeWsl(Ipc.wsl.getState),
subscribe: (cb) => {
const dispose = listen(Ipc.wsl.event, cb)
const subscribed = invokeWsl(Ipc.wsl.subscribe)
return () => {
dispose()
void subscribed.then(() => invokeWsl(Ipc.wsl.unsubscribe))
}
},
probeRuntime: () => invokeWsl(Ipc.wsl.probeRuntime),
refreshDistros: () => invokeWsl(Ipc.wsl.refreshDistros),
installWsl: () => invokeWsl(Ipc.wsl.installWsl),
installDistro: (name) => invokeWsl(Ipc.wsl.installDistro, name),
probeAddable: (distros) => invokeWsl(Ipc.wsl.probeAddable, distros),
installOpencode: (name) => invokeWsl(Ipc.wsl.installOpencode, name),
openTerminal: (name) => invokeWsl(Ipc.wsl.openTerminal, name),
addServer: (distro) => invokeWsl(Ipc.wsl.addServer, distro),
removeServer: (id) => invokeWsl(Ipc.wsl.removeServer, id),
startServer: (id) => invokeWsl(Ipc.wsl.startServer, id),
},
updater: {
subscribe: async (cb) => {
updaterCallbacks.add(cb)
if (updaterState) cb(updaterState)
if (!updaterSubscription) {
updaterListener = listen(Ipc.updater.state, updaterHandler)
updaterSubscription = invoke(Ipc.updater.subscribe)
}
await updaterSubscription
return () => {
updaterCallbacks.delete(cb)
if (updaterCallbacks.size > 0) return
updaterListener?.()
updaterListener = undefined
updaterSubscription = undefined
void invoke(Ipc.updater.unsubscribe)
}
},
check: () => invoke(Ipc.updater.check),
install: () => invoke(Ipc.updater.install),
},
consumeInitialDeepLinks: () => invoke(Ipc.app.consumeInitialDeepLinks),
getDefaultServerUrl: () => invoke(Ipc.app.getDefaultServerUrl),
setDefaultServerUrl: (url) => invoke(Ipc.app.setDefaultServerUrl, url),
isFirstLaunchOnboardingPending: () => invoke(Ipc.app.isFirstLaunchOnboardingPending),
finishFirstLaunchOnboarding: (createDefaultProject) =>
invoke(Ipc.app.finishFirstLaunchOnboarding, createDefaultProject),
checkAppExists: (appName) => invoke(Ipc.app.checkAppExists, appName),
resolveAppPath: (appName) => invoke(Ipc.app.resolveAppPath, appName),
storeGet: (name, key) => invoke(Ipc.storage.get, name, key),
storeSet: (name, key, value) => invoke(Ipc.storage.set, name, key, value),
storeDelete: (name, key) => invoke(Ipc.storage.delete, name, key),
storeClear: (name) => invoke(Ipc.storage.clear, name),
storeKeys: (name) => invoke(Ipc.storage.keys, name),
storeLength: (name) => invoke(Ipc.storage.length, name),
draftGet: (key) => invoke(Ipc.drafts.get, key),
draftSet: (key, value) => invoke(Ipc.drafts.set, key, value),
draftDelete: (key) => invoke(Ipc.drafts.delete, key),
draftBlobPut: (data) => invoke(Ipc.drafts.putBlob, data),
draftBlobGet: (id) => invoke(Ipc.drafts.getBlob, id),
getWindowID: () => invoke(Ipc.window.getId),
themeReady: () => invoke(Ipc.window.themeReady),
onMenuCommand: (cb) => listen(Ipc.menu.command, cb),
onDeepLink: (cb) => listen(Ipc.app.deepLink, cb),
openDirectoryPicker: (opts) => invoke(Ipc.files.openDirectoryPicker, opts),
openFilePicker: (opts) => invoke(Ipc.files.openFilePicker, opts),
readPickedFile: (token, path) => invoke(Ipc.files.readPickedFile, token, path),
releasePickedFiles: (token) => invoke(Ipc.files.releasePickedFiles, token),
getPathForFile: (file) => webUtils.getPathForFile(file),
saveFilePicker: (opts) => invoke(Ipc.files.saveFilePicker, opts),
openExternal: (url) => send(Ipc.files.openExternal, url),
openLocalFile: (url) => send(Ipc.files.openLocalFile, url),
openPath: (path, app) => invoke(Ipc.files.openPath, path, app),
revealPath: (path) => invoke(Ipc.files.revealPath, path),
readClipboardImage: () => invoke(Ipc.files.readClipboardImage),
getWindowFocused: () => invoke(Ipc.window.getFocused),
getWindowFullscreen: () => invoke(Ipc.window.getFullscreen),
onWindowFullscreenChanged: (cb) => listen(Ipc.window.fullscreenChanged, cb),
setWindowFocus: () => invoke(Ipc.window.setFocus),
showWindow: () => invoke(Ipc.window.show),
relaunch: () => send(Ipc.app.relaunch),
getZoomFactor: () => invoke(Ipc.window.getZoomFactor),
setZoomFactor: (factor) => invoke(Ipc.window.setZoomFactor, factor),
getPinchZoomEnabled: () => invoke(Ipc.window.getPinchZoomEnabled),
setPinchZoomEnabled: (enabled) => invoke(Ipc.window.setPinchZoomEnabled, enabled),
onPinchZoomEnabledChanged: (cb) => listen(Ipc.window.pinchZoomEnabledChanged, cb),
onZoomFactorChanged: (cb) => listen(Ipc.window.zoomFactorChanged, cb),
setTitlebar: (theme) => invoke(Ipc.window.setTitlebar, theme),
runDesktopMenuAction: (action) => invoke(Ipc.menu.runAction, action),
setBackgroundColor: (color) => invoke(Ipc.app.setBackgroundColor, color),
exportDebugLogs: () => invoke(Ipc.app.exportDebugLogs),
setForceFocus: (enabled) => invoke(Ipc.app.setForceFocus, enabled),
recordFatalRendererError: (error) => invoke(Ipc.app.recordFatalRendererError, error),
setNativeTranslations: (bundle) => invoke(Ipc.app.setNativeTranslations, bundle),
}
contextBridge.exposeInMainWorld("api", api)
contextBridge.exposeInMainWorld("electron", {
getPathForFile: (file: File) => webUtils.getPathForFile(file),
})
+2 -74
View File
@@ -1,75 +1,3 @@
import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
import {
Ipc,
type IpcEventListener,
type IpcEventSubscription,
type IpcInvokeMethod,
type IpcSendMethod,
} from "../shared/ipc-contract"
export type WslServersAPI = WslServersPlatform
export type UpdaterAPI = {
subscribe: (cb: IpcEventListener<typeof Ipc.updater.state>) => Promise<() => void>
check: IpcInvokeMethod<typeof Ipc.updater.check>
install: IpcInvokeMethod<typeof Ipc.updater.install>
}
export type ElectronAPI = {
awaitInitialization: IpcInvokeMethod<typeof Ipc.app.awaitInitialization>
wslServers: WslServersAPI
updater: UpdaterAPI
consumeInitialDeepLinks: IpcInvokeMethod<typeof Ipc.app.consumeInitialDeepLinks>
getDefaultServerUrl: IpcInvokeMethod<typeof Ipc.app.getDefaultServerUrl>
setDefaultServerUrl: IpcInvokeMethod<typeof Ipc.app.setDefaultServerUrl>
isFirstLaunchOnboardingPending: IpcInvokeMethod<typeof Ipc.app.isFirstLaunchOnboardingPending>
finishFirstLaunchOnboarding: IpcInvokeMethod<typeof Ipc.app.finishFirstLaunchOnboarding>
checkAppExists: IpcInvokeMethod<typeof Ipc.app.checkAppExists>
resolveAppPath: IpcInvokeMethod<typeof Ipc.app.resolveAppPath>
storeGet: IpcInvokeMethod<typeof Ipc.storage.get>
storeSet: IpcInvokeMethod<typeof Ipc.storage.set>
storeDelete: IpcInvokeMethod<typeof Ipc.storage.delete>
storeClear: IpcInvokeMethod<typeof Ipc.storage.clear>
storeKeys: IpcInvokeMethod<typeof Ipc.storage.keys>
storeLength: IpcInvokeMethod<typeof Ipc.storage.length>
draftGet: IpcInvokeMethod<typeof Ipc.drafts.get>
draftSet: IpcInvokeMethod<typeof Ipc.drafts.set>
draftDelete: IpcInvokeMethod<typeof Ipc.drafts.delete>
draftBlobPut: IpcInvokeMethod<typeof Ipc.drafts.putBlob>
draftBlobGet: IpcInvokeMethod<typeof Ipc.drafts.getBlob>
getWindowID: IpcInvokeMethod<typeof Ipc.window.getId>
themeReady: IpcInvokeMethod<typeof Ipc.window.themeReady>
onMenuCommand: IpcEventSubscription<typeof Ipc.menu.command>
onDeepLink: IpcEventSubscription<typeof Ipc.app.deepLink>
openDirectoryPicker: IpcInvokeMethod<typeof Ipc.files.openDirectoryPicker>
openFilePicker: IpcInvokeMethod<typeof Ipc.files.openFilePicker>
readPickedFile: IpcInvokeMethod<typeof Ipc.files.readPickedFile>
releasePickedFiles: IpcInvokeMethod<typeof Ipc.files.releasePickedFiles>
getPathForFile: (file: File) => string
saveFilePicker: IpcInvokeMethod<typeof Ipc.files.saveFilePicker>
openExternal: IpcSendMethod<typeof Ipc.files.openExternal>
openLocalFile: IpcSendMethod<typeof Ipc.files.openLocalFile>
openPath: IpcInvokeMethod<typeof Ipc.files.openPath>
revealPath: IpcInvokeMethod<typeof Ipc.files.revealPath>
readClipboardImage: IpcInvokeMethod<typeof Ipc.files.readClipboardImage>
getWindowFocused: IpcInvokeMethod<typeof Ipc.window.getFocused>
getWindowFullscreen: IpcInvokeMethod<typeof Ipc.window.getFullscreen>
onWindowFullscreenChanged: IpcEventSubscription<typeof Ipc.window.fullscreenChanged>
setWindowFocus: IpcInvokeMethod<typeof Ipc.window.setFocus>
showWindow: IpcInvokeMethod<typeof Ipc.window.show>
relaunch: IpcSendMethod<typeof Ipc.app.relaunch>
getZoomFactor: IpcInvokeMethod<typeof Ipc.window.getZoomFactor>
setZoomFactor: IpcInvokeMethod<typeof Ipc.window.setZoomFactor>
getPinchZoomEnabled: IpcInvokeMethod<typeof Ipc.window.getPinchZoomEnabled>
setPinchZoomEnabled: IpcInvokeMethod<typeof Ipc.window.setPinchZoomEnabled>
onPinchZoomEnabledChanged: IpcEventSubscription<typeof Ipc.window.pinchZoomEnabledChanged>
onZoomFactorChanged: IpcEventSubscription<typeof Ipc.window.zoomFactorChanged>
setTitlebar: IpcInvokeMethod<typeof Ipc.window.setTitlebar>
runDesktopMenuAction: IpcInvokeMethod<typeof Ipc.menu.runAction>
setBackgroundColor: IpcInvokeMethod<typeof Ipc.app.setBackgroundColor>
exportDebugLogs: IpcInvokeMethod<typeof Ipc.app.exportDebugLogs>
setForceFocus: IpcInvokeMethod<typeof Ipc.app.setForceFocus>
recordFatalRendererError: IpcInvokeMethod<typeof Ipc.app.recordFatalRendererError>
setNativeTranslations: IpcInvokeMethod<typeof Ipc.app.setNativeTranslations>
export type ElectronNative = {
getPathForFile(file: File): string
}
@@ -0,0 +1,79 @@
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
import type { UpdaterState } from "@opencode-ai/app/updater"
import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
import type {
ClipboardImage,
DirectoryPickerOptions,
FatalRendererError,
FilePickerOptions,
PickedFiles,
SaveFilePickerOptions,
ServerReadyData,
TitlebarTheme,
} from "../shared/ipc-contract"
export type WslServersAPI = WslServersPlatform
export type UpdaterAPI = {
subscribe(cb: (state: UpdaterState) => void): Promise<() => void>
check(): Promise<UpdaterState>
install(): Promise<void>
}
export type ElectronAPI = {
awaitInitialization(): Promise<ServerReadyData>
wslServers: WslServersAPI
updater: UpdaterAPI
consumeInitialDeepLinks(): Promise<string[]>
getDefaultServerUrl(): Promise<string | null>
setDefaultServerUrl(url: string | null): Promise<void>
isFirstLaunchOnboardingPending(): Promise<boolean>
finishFirstLaunchOnboarding(createDefaultProject: boolean): Promise<string | null>
checkAppExists(appName: string): Promise<boolean>
resolveAppPath(appName: string): Promise<string | null>
storeGet(name: string, key: string): Promise<string | null>
storeSet(name: string, key: string, value: string): Promise<void>
storeDelete(name: string, key: string): Promise<void>
storeClear(name: string): Promise<void>
storeKeys(name: string): Promise<string[]>
storeLength(name: string): Promise<number>
draftGet(key: string): Promise<string | null>
draftSet(key: string, value: string): Promise<void>
draftDelete(key: string): Promise<void>
draftBlobPut(data: ArrayBuffer): Promise<string>
draftBlobGet(id: string): Promise<ArrayBuffer | null>
getWindowID(): Promise<string>
themeReady(): Promise<void>
onMenuCommand(cb: (id: string) => void): () => void
onDeepLink(cb: (urls: string[]) => void): () => void
openDirectoryPicker(opts?: DirectoryPickerOptions): Promise<string | string[] | null>
openFilePicker(opts?: FilePickerOptions): Promise<PickedFiles | null>
readPickedFile(token: string, path: string): Promise<ArrayBuffer>
releasePickedFiles(token: string): Promise<void>
getPathForFile(file: File): string
saveFilePicker(opts?: SaveFilePickerOptions): Promise<string | null>
openExternal(url: string): void
openLocalFile(url: string): void
openPath(path: string, app?: string): Promise<string | undefined>
revealPath(path: string): Promise<boolean>
readClipboardImage(): Promise<ClipboardImage | null>
getWindowFocused(): Promise<boolean>
getWindowFullscreen(): Promise<boolean>
onWindowFullscreenChanged(cb: (fullscreen: boolean) => void): () => void
setWindowFocus(): Promise<void>
showWindow(): Promise<void>
relaunch(): void
getZoomFactor(): Promise<number>
setZoomFactor(factor: number): Promise<void>
getPinchZoomEnabled(): Promise<boolean>
setPinchZoomEnabled(enabled: boolean): Promise<void>
onPinchZoomEnabledChanged(cb: (enabled: boolean) => void): () => void
onZoomFactorChanged(cb: (factor: number) => void): () => void
setTitlebar(theme: TitlebarTheme): Promise<void>
runDesktopMenuAction(action: DesktopMenuAction): Promise<void>
setBackgroundColor(color: string): Promise<void>
exportDebugLogs(): Promise<string>
setForceFocus(enabled: boolean): Promise<void>
recordFatalRendererError(error: FatalRendererError): Promise<void>
setNativeTranslations(bundle: DesktopNativeBundle): Promise<void>
}
+127
View File
@@ -0,0 +1,127 @@
import type { ElectronAPI } from "./api-types"
import type { UpdaterState } from "@opencode-ai/app/updater"
import { invoke, listen, send } from "./ipc-client"
type Mutable<Value> =
Value extends ReadonlyArray<unknown>
? { -readonly [Key in keyof Value]: Mutable<Value[Key]> }
: Value extends object
? { -readonly [Key in keyof Value]: Mutable<Value[Key]> }
: Value
const mutable = <Value>(value: Value) => value as Mutable<Value>
const toArrayBuffer = (value: Uint8Array) =>
value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) as ArrayBuffer
const updaterCallbacks = new Set<(state: UpdaterState) => void>()
let updaterState: UpdaterState | undefined
let updaterSubscription: Promise<void> | undefined
let updaterListener: (() => void) | undefined
const updaterHandler = (state: UpdaterState) => {
updaterState = state
updaterCallbacks.forEach((callback) => callback(state))
}
export const api: ElectronAPI = {
awaitInitialization: () => invoke("AppAwaitInitialization"),
wslServers: {
getState: () => invoke("WslGetState").then(mutable),
subscribe: (cb) => {
const dispose = listen("WslServersChanged", (event) => cb(mutable(event.event)))
void invoke("WslSubscribe")
return () => {
dispose()
void invoke("WslUnsubscribe")
}
},
probeRuntime: () => invoke("WslProbeRuntime"),
refreshDistros: () => invoke("WslRefreshDistros"),
installWsl: () => invoke("WslInstallWsl"),
installDistro: (name) => invoke("WslInstallDistro", { name }),
probeAddable: (distros) => invoke("WslProbeAddable", { distros }),
installOpencode: (name) => invoke("WslInstallOpencode", { name }),
openTerminal: (name) => invoke("WslOpenTerminal", { name }),
addServer: (distro) => invoke("WslAddServer", { distro }),
removeServer: (id) => invoke("WslRemoveServer", { id }),
startServer: (id) => invoke("WslStartServer", { id }),
},
updater: {
subscribe: async (cb) => {
updaterCallbacks.add(cb)
if (updaterState) cb(updaterState)
if (!updaterSubscription) {
updaterListener = listen("UpdaterStateChanged", (event) => updaterHandler(mutable(event.state)))
updaterSubscription = invoke("UpdaterSubscribe")
}
await updaterSubscription
return () => {
updaterCallbacks.delete(cb)
if (updaterCallbacks.size > 0) return
updaterListener?.()
updaterListener = undefined
updaterSubscription = undefined
void invoke("UpdaterUnsubscribe")
}
},
check: () => invoke("UpdaterCheck"),
install: () => invoke("UpdaterInstall"),
},
consumeInitialDeepLinks: () => invoke("AppConsumeInitialDeepLinks").then(mutable),
getDefaultServerUrl: () => invoke("AppGetDefaultServerUrl"),
setDefaultServerUrl: (url) => invoke("AppSetDefaultServerUrl", { url }),
isFirstLaunchOnboardingPending: () => invoke("AppIsFirstLaunchOnboardingPending"),
finishFirstLaunchOnboarding: (createDefaultProject) =>
invoke("AppFinishFirstLaunchOnboarding", { createDefaultProject }),
checkAppExists: (appName) => invoke("AppCheckAppExists", { appName }),
resolveAppPath: (appName) => invoke("AppResolveAppPath", { appName }),
storeGet: (name, key) => invoke("StorageGet", { name, key }),
storeSet: (name, key, value) => invoke("StorageSet", { name, key, value }),
storeDelete: (name, key) => invoke("StorageDelete", { name, key }),
storeClear: (name) => invoke("StorageClear", { name }),
storeKeys: (name) => invoke("StorageKeys", { name }).then(mutable),
storeLength: (name) => invoke("StorageLength", { name }),
draftGet: (key) => invoke("DraftsGet", { key }),
draftSet: (key, value) => invoke("DraftsSet", { key, value }),
draftDelete: (key) => invoke("DraftsDelete", { key }),
draftBlobPut: (data) => invoke("DraftsPutBlob", { data: new Uint8Array(data) }),
draftBlobGet: (id) => invoke("DraftsGetBlob", { id }).then((data) => (data ? toArrayBuffer(data) : null)),
getWindowID: () => invoke("WindowGetId"),
themeReady: () => invoke("WindowThemeReady"),
onMenuCommand: (cb) => listen("MenuCommandTriggered", (event) => cb(event.id)),
onDeepLink: (cb) => listen("DeepLinksOpened", (event) => cb(mutable(event.urls))),
openDirectoryPicker: (opts) => invoke("FilesOpenDirectoryPicker", { options: opts }).then(mutable),
openFilePicker: (opts) => invoke("FilesOpenFilePicker", { options: opts }).then(mutable),
readPickedFile: (token, path) => invoke("FilesReadPickedFile", { token, path }).then(toArrayBuffer),
releasePickedFiles: (token) => invoke("FilesReleasePickedFiles", { token }),
getPathForFile: (file) => window.electron.getPathForFile(file),
saveFilePicker: (opts) => invoke("FilesSaveFilePicker", { options: opts }),
openExternal: (url) => send("FilesOpenExternal", { url }),
openLocalFile: (url) => send("FilesOpenLocalFile", { url }),
openPath: (path, app) => invoke("FilesOpenPath", { path, application: app }).then((value) => value ?? undefined),
revealPath: (path) => invoke("FilesRevealPath", { path }),
readClipboardImage: () =>
invoke("FilesReadClipboardImage").then((image) =>
image ? { ...image, buffer: toArrayBuffer(image.buffer) } : null,
),
getWindowFocused: () => invoke("WindowGetFocused"),
getWindowFullscreen: () => invoke("WindowGetFullscreen"),
onWindowFullscreenChanged: (cb) => listen("WindowFullscreenChanged", (event) => cb(event.fullscreen)),
setWindowFocus: () => invoke("WindowSetFocus"),
showWindow: () => invoke("WindowShow"),
relaunch: () => send("AppRelaunch"),
getZoomFactor: () => invoke("WindowGetZoomFactor"),
setZoomFactor: (factor) => invoke("WindowSetZoomFactor", { factor }),
getPinchZoomEnabled: () => invoke("WindowGetPinchZoomEnabled"),
setPinchZoomEnabled: (enabled) => invoke("WindowSetPinchZoomEnabled", { enabled }),
onPinchZoomEnabledChanged: (cb) => listen("WindowPinchZoomChanged", (event) => cb(event.enabled)),
onZoomFactorChanged: (cb) => listen("WindowZoomChanged", (event) => cb(event.factor)),
setTitlebar: (theme) => invoke("WindowSetTitlebar", { theme }),
runDesktopMenuAction: (action) => invoke("MenuRunAction", { action }),
setBackgroundColor: (color) => invoke("AppSetBackgroundColor", { color }),
exportDebugLogs: () => invoke("AppExportDebugLogs"),
setForceFocus: (enabled) => invoke("AppSetForceFocus", { enabled }),
recordFatalRendererError: (error) => invoke("AppRecordFatalRendererError", { error }),
setNativeTranslations: (bundle) => invoke("AppSetNativeTranslations", { value: bundle }),
}
@@ -5,7 +5,6 @@ import {
AppBaseProviders,
AppInterface,
PlatformProvider,
preloadRoute,
ServerConnection,
useCommand,
useLanguage,
@@ -15,7 +14,7 @@ import {
import { useTheme } from "@opencode-ai/ui/theme/context"
import type { BaseRouterProps } from "@solidjs/router"
import { createEffect, createMemo, createResource, lazy, Show, Suspense } from "solid-js"
import type { ElectronAPI } from "../preload/types"
import type { ElectronAPI } from "./api-types"
import { DesktopFirstLaunchOnboarding } from "./onboarding"
import { createDesktopPlatform, type DesktopWindowState } from "./platform"
import { bindDesktopMenu } from "./platform/menu"
@@ -39,11 +38,9 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
function DesktopWindow(props: { api: ElectronAPI; updater: UpdaterPlatform; windowState: DesktopWindowState }) {
const platform = createDesktopPlatform(props.api, props.windowState, props.updater)
const initialUrl = getLastActiveUrl(props.windowState.id)
const [sidecar] = createResource(() => props.api.awaitInitialization())
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
const [locale] = createResource(() => preloadStoredLocale(platform))
const [route] = createResource(() => preloadRoute(initialUrl))
const router = (routerProps: BaseRouterProps) => (
<DesktopMemoryRouter {...routerProps} windowID={props.windowState.id} />
)
@@ -51,7 +48,9 @@ function DesktopWindow(props: { api: ElectronAPI; updater: UpdaterPlatform; wind
function ReadyApp() {
const wslServers = useWslServers()
const language = useLanguage()
const ready = createMemo(() => !defaultServer.loading && !sidecar.loading && !locale.loading && !route.loading)
const ready = createMemo(
() => !defaultServer.loading && !sidecar.loading && !locale.loading && !wslServers.isLoading,
)
const servers = createMemo(() => {
const data = initializationData(sidecar)
const list: ServerConnection.Any[] = []
@@ -81,7 +80,7 @@ function DesktopWindow(props: { api: ElectronAPI; updater: UpdaterPlatform; wind
<AppInterface defaultServer={key} servers={servers()} router={router}>
<DesktopFirstLaunchOnboarding
api={props.api}
initialUrl={initialUrl}
initialUrl={getLastActiveUrl(props.windowState.id)}
serverKey={key}
/>
<DesktopEffects api={props.api} />
+2 -2
View File
@@ -1,8 +1,8 @@
import type { ElectronAPI } from "../preload/types"
import type { ElectronNative } from "../preload/types"
declare global {
interface Window {
api: ElectronAPI
electron: ElectronNative
__OPENCODE__?: {
deepLinks?: string[]
}
+5 -4
View File
@@ -3,6 +3,7 @@
import "./diagnostics"
import "./styles.css"
import { render } from "solid-js/web"
import { api } from "./api"
import { DesktopApp } from "./desktop-app"
import { startDesktopMenu } from "./platform/menu"
import { startDesktopUpdater } from "./platform/updater"
@@ -14,8 +15,8 @@ const root = requireRendererRoot()
const version = desktopVersion()
await initializeSentry(version)
const updater = startDesktopUpdater(window.api)
startDesktopMenu(window.api)
startDeepLinks(window.api)
const updater = startDesktopUpdater(api)
startDesktopMenu(api)
startDeepLinks(api)
render(() => <DesktopApp api={window.api} updater={updater} version={version} />, root)
render(() => <DesktopApp api={api} updater={updater} version={version} />, root)
+114
View File
@@ -0,0 +1,114 @@
import { Context, Effect, Layer, ManagedRuntime, Queue, Stream } from "effect"
import { RpcClient, RpcMessage, RpcSerialization } from "effect/unstable/rpc"
import { DesktopRpcs, type DesktopRpcClient } from "../shared/ipc-rpc"
import type { DesktopEvent } from "../shared/ipc-rpc/events"
import { IpcTransportPort } from "../shared/ipc-transport"
class DesktopClient extends Context.Service<DesktopClient, DesktopRpcClient>()("opencode/desktop/DesktopClient") {}
type EventTag = DesktopEvent["_tag"]
type InvokeTag = Exclude<keyof DesktopRpcClient, "DesktopEvents">
type InvokeArgs<Tag extends InvokeTag> = Parameters<DesktopRpcClient[Tag]>
type InvokeResult<Tag extends InvokeTag> =
ReturnType<DesktopRpcClient[Tag]> extends Effect.Effect<infer Value, unknown> ? Value : never
type EventValue<Tag extends EventTag> = Extract<DesktopEvent, { readonly _tag: Tag }>
const port = new Promise<MessagePort>((resolve) => {
const onMessage = (event: MessageEvent) => {
if (event.source !== window || event.data !== IpcTransportPort) return
const value = event.ports[0]
if (!value) return
window.removeEventListener("message", onMessage)
resolve(value)
}
window.addEventListener("message", onMessage)
})
const ClientProtocolLive = Layer.unwrap(Effect.promise(() => port).pipe(Effect.map((value) => clientProtocol(value))))
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* () {
const client = yield* DesktopClient
yield* client
.DesktopEvents()
.pipe(
Stream.runForEach((event) =>
Effect.sync(() => listeners.get(event._tag)?.forEach((listener) => listener(event))),
),
)
}),
)
export function invoke<Tag extends InvokeTag>(tag: Tag, ...payload: InvokeArgs<Tag>): Promise<InvokeResult<Tag>> {
return runtime.runPromise(
Effect.gen(function* () {
const client = yield* DesktopClient
const method = client[tag] as unknown as (...args: ReadonlyArray<unknown>) => Effect.Effect<unknown, unknown>
return yield* method(...payload)
}),
) as Promise<InvokeResult<Tag>>
}
export function send<Tag extends InvokeTag>(tag: Tag, ...payload: InvokeArgs<Tag>) {
void invoke(tag, ...payload).catch(() => undefined)
}
export function listen<Tag extends EventTag>(tag: Tag, listener: (value: EventValue<Tag>) => void) {
const callback = listener as (value: unknown) => void
const callbacks = listeners.get(tag) ?? new Set()
callbacks.add(callback)
listeners.set(tag, callbacks)
return () => {
callbacks.delete(callback)
if (callbacks.size === 0) listeners.delete(tag)
}
}
function clientProtocol(value: MessagePort) {
return Layer.effect(
RpcClient.Protocol,
RpcClient.Protocol.make(
Effect.fnUntraced(function* (writeResponse, clientIds) {
const serialization = yield* RpcSerialization.RpcSerialization
const parser = serialization.makeUnsafe()
const inbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
const onMessage = (event: MessageEvent) => {
try {
parser
.decode(event.data)
.forEach((message) => Queue.offerUnsafe(inbound, message as RpcMessage.FromServerEncoded))
} catch {
return
}
}
value.addEventListener("message", onMessage)
value.start()
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
value.removeEventListener("message", onMessage)
value.close()
}),
)
yield* Stream.fromQueue(inbound).pipe(
Stream.runForEach((message) =>
Effect.forEach(clientIds, (clientId) => writeResponse(clientId, message), { discard: true }),
),
Effect.forkScoped,
)
return {
send: (_clientId, request) =>
Effect.sync(() => {
const encoded = parser.encode(request)
if (encoded !== undefined) value.postMessage(encoded)
}),
supportsAck: true,
supportsTransferables: false,
}
}),
),
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
}
@@ -1,6 +1,6 @@
import { ServerConnection, useServers, useTabs } from "@opencode-ai/app/desktop"
import { onMount } from "solid-js"
import type { ElectronAPI } from "../../preload/types"
import type { ElectronAPI } from "../api-types"
export function DesktopFirstLaunchOnboarding(props: {
api: ElectronAPI
@@ -1,5 +1,5 @@
import type { Platform } from "@opencode-ai/app"
import type { ElectronAPI } from "../../preload/types"
import type { ElectronAPI } from "../api-types"
type DesktopOS = Extract<Platform, { platform: "desktop" }>["os"]
type DesktopFileAPI = Pick<
@@ -1,5 +1,5 @@
import { ACCEPTED_FILE_EXTENSIONS, ServerConnection, type Platform, type UpdaterPlatform } from "@opencode-ai/app"
import type { ElectronAPI } from "../../preload/types"
import type { ElectronAPI } from "../api-types"
import { setPinchZoomEnabled, webviewZoom } from "../window/zoom"
import { windowFullscreen } from "../window/fullscreen"
import { createDesktopFiles } from "./files"
@@ -1,5 +1,5 @@
import type { Platform } from "@opencode-ai/app"
import type { ElectronAPI } from "../../preload/types"
import type { ElectronAPI } from "../api-types"
import { resetZoom, zoomIn, zoomOut } from "../window/zoom"
let trigger: ((id: string) => void) | null = null
@@ -1,5 +1,5 @@
import type { Platform } from "@opencode-ai/app"
import type { ElectronAPI } from "../../preload/types"
import type { ElectronAPI } from "../api-types"
export function createDesktopNotify(api: ElectronAPI): Platform["notify"] {
return async (title, description, onClick) => {
@@ -1,6 +1,6 @@
import { createDraftStore, type Platform } from "@opencode-ai/app"
import type { AsyncStorage } from "@solid-primitives/storage"
import type { ElectronAPI } from "../../preload/types"
import type { ElectronAPI } from "../api-types"
export function createDesktopStorage(api: ElectronAPI) {
const cache = new Map<string, AsyncStorage>()
@@ -1,6 +1,6 @@
import type { UpdaterPlatform, UpdaterState } from "@opencode-ai/app"
import { createSignal } from "solid-js"
import type { ElectronAPI } from "../../preload/types"
import type { ElectronAPI } from "../api-types"
export function startDesktopUpdater(api: ElectronAPI): UpdaterPlatform {
const [state, setState] = createSignal<UpdaterState>({ status: "disabled" })
@@ -1,4 +1,4 @@
import type { ElectronAPI } from "../../preload/types"
import type { ElectronAPI } from "../api-types"
const deepLinkEvent = "opencode:deep-link"
@@ -1,5 +1,4 @@
import { describe, expect, test } from "bun:test"
import { Ipc } from "../../shared/ipc-contract"
import { initializationData, initializationReady } from "./initialization"
describe("desktop renderer initialization", () => {
@@ -15,10 +14,8 @@ describe("desktop renderer initialization", () => {
}
})
test("removes Electron's remote invocation wrapper from startup errors", () => {
const error = new Error(
`Error invoking remote method '${Ipc.app.awaitInitialization}': Error: Cannot migrate session_message projections`,
)
test("preserves clean RPC startup errors", () => {
const error = new Error("Cannot migrate session_message projections")
try {
initializationData(Object.assign(() => undefined, { error }))
@@ -5,12 +5,6 @@ export function initializationData<A>(state: (() => A | undefined) & { error: un
function markLocalServerStartup(error: unknown) {
const failure = error instanceof Error ? error : new Error(String(error))
const prefix = `Error invoking remote method '${Ipc.app.awaitInitialization}': Error: `
if (failure.message.startsWith(prefix)) {
const previous = failure.message
failure.message = failure.message.slice(prefix.length)
if (failure.stack) failure.stack = failure.stack.replace(`Error: ${previous}`, `Error: ${failure.message}`)
}
Object.defineProperty(failure, "localServerStartup", { value: true })
return failure
}
@@ -20,4 +14,3 @@ export function initializationReady<A>(state: (() => A | undefined) & { error: u
initializationData(state)
return true
}
import { Ipc } from "../../shared/ipc-contract"
@@ -1,8 +1,9 @@
import { createSignal } from "solid-js"
import { api } from "../api"
const [windowFullscreen, setWindowFullscreen] = createSignal(false)
window.api.onWindowFullscreenChanged(setWindowFullscreen)
void window.api.getWindowFullscreen().then(setWindowFullscreen)
api.onWindowFullscreenChanged(setWindowFullscreen)
void api.getWindowFullscreen().then(setWindowFullscreen)
export { windowFullscreen }
+6 -5
View File
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: MIT
import { createSignal } from "solid-js"
import { api } from "../api"
const OS_NAME = (() => {
if (navigator.userAgent.includes("Mac")) return "macos"
@@ -33,7 +34,7 @@ const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_Z
const applyZoom = (next: number) => {
requestedZoom = next
void window.api
void api
.setZoomFactor(next)
.then(() => {
if (requestedZoom !== next) return
@@ -45,16 +46,16 @@ const applyZoom = (next: number) => {
})
}
window.api.onZoomFactorChanged((factor) => {
api.onZoomFactorChanged((factor) => {
requestedZoom = clamp(factor)
setWebviewZoom(requestedZoom)
})
void window.api.getPinchZoomEnabled().then((enabled) => {
void api.getPinchZoomEnabled().then((enabled) => {
pinchZoomEnabled = enabled
})
window.api.onPinchZoomEnabledChanged((enabled) => {
api.onPinchZoomEnabledChanged((enabled) => {
pinchZoomEnabled = enabled
resetWheelPinch()
})
@@ -62,7 +63,7 @@ window.api.onPinchZoomEnabledChanged((enabled) => {
const setPinchZoomEnabled = (enabled: boolean) => {
pinchZoomEnabled = enabled
resetWheelPinch()
return window.api.setPinchZoomEnabled(enabled)
return api.setPinchZoomEnabled(enabled)
}
const resetZoom = () => applyZoom(1)
-207
View File
@@ -1,99 +1,3 @@
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
import type { UpdaterState } from "@opencode-ai/app/updater"
import type { WslServerConfig, WslServersEvent, WslServersState } from "@opencode-ai/app/wsl/types"
export const Ipc = {
app: {
awaitInitialization: "await-initialization",
consumeInitialDeepLinks: "consume-initial-deep-links",
deepLink: "deep-link",
getDefaultServerUrl: "get-default-server-url",
setDefaultServerUrl: "set-default-server-url",
isFirstLaunchOnboardingPending: "is-first-launch-onboarding-pending",
finishFirstLaunchOnboarding: "finish-first-launch-onboarding",
checkAppExists: "check-app-exists",
resolveAppPath: "resolve-app-path",
relaunch: "relaunch",
setBackgroundColor: "set-background-color",
exportDebugLogs: "export-debug-logs",
setForceFocus: "set-force-focus",
recordFatalRendererError: "record-fatal-renderer-error",
setNativeTranslations: "set-native-translations",
},
storage: {
get: "store-get",
set: "store-set",
delete: "store-delete",
clear: "store-clear",
keys: "store-keys",
length: "store-length",
},
drafts: {
get: "draft-get",
set: "draft-set",
delete: "draft-delete",
putBlob: "draft-blob-put",
getBlob: "draft-blob-get",
},
files: {
openDirectoryPicker: "open-directory-picker",
openFilePicker: "open-file-picker",
readPickedFile: "read-picked-file",
releasePickedFiles: "release-picked-files",
saveFilePicker: "save-file-picker",
openExternal: "open-external",
openLocalFile: "open-local-file",
openPath: "open-path",
revealPath: "reveal-path",
readClipboardImage: "read-clipboard-image",
},
window: {
getId: "get-window-id",
themeReady: "window-theme-ready",
getFocused: "get-window-focused",
getFullscreen: "get-window-fullscreen",
fullscreenChanged: "window-fullscreen-changed",
setFocus: "set-window-focus",
show: "show-window",
getZoomFactor: "get-zoom-factor",
setZoomFactor: "set-zoom-factor",
zoomFactorChanged: "zoom-factor-changed",
getPinchZoomEnabled: "get-pinch-zoom-enabled",
setPinchZoomEnabled: "set-pinch-zoom-enabled",
pinchZoomEnabledChanged: "pinch-zoom-enabled-changed",
setTitlebar: "set-titlebar",
},
menu: {
command: "menu-command",
runAction: "run-desktop-menu-action",
},
updater: {
subscribe: "updater-subscribe",
unsubscribe: "updater-unsubscribe",
check: "updater-check",
install: "updater-install",
state: "updater-state",
},
wsl: {
awaitInitialization: "wsl-servers-await-initialization",
subscribe: "wsl-servers-subscribe",
unsubscribe: "wsl-servers-unsubscribe",
getState: "wsl-servers-get-state",
probeRuntime: "wsl-servers-probe-runtime",
refreshDistros: "wsl-servers-refresh-distros",
installWsl: "wsl-servers-install-wsl",
installDistro: "wsl-servers-install-distro",
probeAddable: "wsl-servers-probe-addable",
installOpencode: "wsl-servers-install-opencode",
openTerminal: "wsl-servers-open-terminal",
addServer: "wsl-servers-add",
removeServer: "wsl-servers-remove",
startServer: "wsl-servers-start",
event: "wsl-servers-event",
},
} as const
export type ServerReadyData = {
url: string
username: string | null
@@ -138,114 +42,3 @@ export type ClipboardImage = {
width: number
height: number
}
export type IpcInvoke = {
[Ipc.app.awaitInitialization]: { args: []; result: ServerReadyData }
[Ipc.app.consumeInitialDeepLinks]: { args: []; result: string[] }
[Ipc.app.getDefaultServerUrl]: { args: []; result: string | null }
[Ipc.app.setDefaultServerUrl]: { args: [url: string | null]; result: void }
[Ipc.app.isFirstLaunchOnboardingPending]: { args: []; result: boolean }
[Ipc.app.finishFirstLaunchOnboarding]: { args: [createDefaultProject: boolean]; result: string | null }
[Ipc.app.checkAppExists]: { args: [appName: string]; result: boolean }
[Ipc.app.resolveAppPath]: { args: [appName: string]; result: string | null }
[Ipc.app.setBackgroundColor]: { args: [color: string]; result: void }
[Ipc.app.exportDebugLogs]: { args: []; result: string }
[Ipc.app.setForceFocus]: { args: [enabled: boolean]; result: void }
[Ipc.app.recordFatalRendererError]: { args: [error: FatalRendererError]; result: void }
[Ipc.app.setNativeTranslations]: { args: [bundle: DesktopNativeBundle]; result: void }
[Ipc.storage.get]: { args: [name: string, key: string]; result: string | null }
[Ipc.storage.set]: { args: [name: string, key: string, value: string]; result: void }
[Ipc.storage.delete]: { args: [name: string, key: string]; result: void }
[Ipc.storage.clear]: { args: [name: string]; result: void }
[Ipc.storage.keys]: { args: [name: string]; result: string[] }
[Ipc.storage.length]: { args: [name: string]; result: number }
[Ipc.drafts.get]: { args: [key: string]; result: string | null }
[Ipc.drafts.set]: { args: [key: string, value: string]; result: void }
[Ipc.drafts.delete]: { args: [key: string]; result: void }
[Ipc.drafts.putBlob]: { args: [data: ArrayBuffer]; result: string }
[Ipc.drafts.getBlob]: { args: [id: string]; result: ArrayBuffer | null }
[Ipc.files.openDirectoryPicker]: {
args: [options?: DirectoryPickerOptions]
result: string | string[] | null
}
[Ipc.files.openFilePicker]: { args: [options?: FilePickerOptions]; result: PickedFiles | null }
[Ipc.files.readPickedFile]: { args: [token: string, path: string]; result: ArrayBuffer }
[Ipc.files.releasePickedFiles]: { args: [token: string]; result: void }
[Ipc.files.saveFilePicker]: { args: [options?: SaveFilePickerOptions]; result: string | null }
[Ipc.files.openPath]: { args: [path: string, app?: string]; result: string | undefined }
[Ipc.files.revealPath]: { args: [path: string]; result: boolean }
[Ipc.files.readClipboardImage]: { args: []; result: ClipboardImage | null }
[Ipc.window.getId]: { args: []; result: string }
[Ipc.window.themeReady]: { args: []; result: void }
[Ipc.window.getFocused]: { args: []; result: boolean }
[Ipc.window.getFullscreen]: { args: []; result: boolean }
[Ipc.window.setFocus]: { args: []; result: void }
[Ipc.window.show]: { args: []; result: void }
[Ipc.window.getZoomFactor]: { args: []; result: number }
[Ipc.window.setZoomFactor]: { args: [factor: number]; result: void }
[Ipc.window.getPinchZoomEnabled]: { args: []; result: boolean }
[Ipc.window.setPinchZoomEnabled]: { args: [enabled: boolean]; result: void }
[Ipc.window.setTitlebar]: { args: [theme: TitlebarTheme]; result: void }
[Ipc.menu.runAction]: { args: [action: DesktopMenuAction]; result: void }
[Ipc.updater.subscribe]: { args: []; result: void }
[Ipc.updater.unsubscribe]: { args: []; result: void }
[Ipc.updater.check]: { args: []; result: UpdaterState }
[Ipc.updater.install]: { args: []; result: void }
[Ipc.wsl.awaitInitialization]: { args: []; result: void }
[Ipc.wsl.subscribe]: { args: []; result: void }
[Ipc.wsl.unsubscribe]: { args: []; result: void }
[Ipc.wsl.getState]: { args: []; result: WslServersState }
[Ipc.wsl.probeRuntime]: { args: []; result: void }
[Ipc.wsl.refreshDistros]: { args: []; result: void }
[Ipc.wsl.installWsl]: { args: []; result: void }
[Ipc.wsl.installDistro]: { args: [name: string]; result: void }
[Ipc.wsl.probeAddable]: { args: [distros: string[]]; result: void }
[Ipc.wsl.installOpencode]: { args: [name: string]; result: void }
[Ipc.wsl.openTerminal]: { args: [name: string]; result: void }
[Ipc.wsl.addServer]: { args: [distro: string]; result: WslServerConfig }
[Ipc.wsl.removeServer]: { args: [id: string]; result: void }
[Ipc.wsl.startServer]: { args: [id: string]; result: void }
}
export type IpcSend = {
[Ipc.app.relaunch]: []
[Ipc.files.openExternal]: [url: string]
[Ipc.files.openLocalFile]: [url: string]
}
export type IpcEvent = {
[Ipc.app.deepLink]: [urls: string[]]
[Ipc.menu.command]: [id: string]
[Ipc.updater.state]: [state: UpdaterState]
[Ipc.wsl.event]: [event: WslServersEvent]
[Ipc.window.fullscreenChanged]: [fullscreen: boolean]
[Ipc.window.pinchZoomEnabledChanged]: [enabled: boolean]
[Ipc.window.zoomFactorChanged]: [factor: number]
}
export type IpcInvokeArgs<Channel extends keyof IpcInvoke> = IpcInvoke[Channel]["args"]
export type IpcInvokeResult<Channel extends keyof IpcInvoke> = IpcInvoke[Channel]["result"]
export type IpcInvokeMethod<Channel extends keyof IpcInvoke> = (
...args: IpcInvokeArgs<Channel>
) => Promise<IpcInvokeResult<Channel>>
export type IpcSendMethod<Channel extends keyof IpcSend> = (...args: IpcSend[Channel]) => void
export type IpcEventListener<Channel extends keyof IpcEvent> = (...args: IpcEvent[Channel]) => void
export type IpcEventSubscription<Channel extends keyof IpcEvent> = (listener: IpcEventListener<Channel>) => () => void
type IpcEventSender = {
send<Channel extends keyof IpcEvent>(channel: Channel, ...args: IpcEvent[Channel]): void
}
export function sendIpcEvent<Channel extends keyof IpcEvent>(
sender: IpcEventSender,
channel: Channel,
...args: IpcEvent[Channel]
) {
sender.send(channel, ...args)
}
+21
View File
@@ -0,0 +1,21 @@
import { RpcClient, RpcClientError } from "effect/unstable/rpc"
import { AppRpcs } from "./ipc-rpc/app"
import { EventRpcs } from "./ipc-rpc/events"
import { FileRpcs } from "./ipc-rpc/files"
import { MenuRpcs } from "./ipc-rpc/menu"
import { StorageRpcs } from "./ipc-rpc/storage"
import { UpdaterRpcs } from "./ipc-rpc/updater"
import { WindowRpcs } from "./ipc-rpc/window"
import { WslRpcs } from "./ipc-rpc/wsl"
export { AppRpcs } from "./ipc-rpc/app"
export { EventRpcs } from "./ipc-rpc/events"
export { FileRpcs } from "./ipc-rpc/files"
export { MenuRpcs } from "./ipc-rpc/menu"
export { StorageRpcs } from "./ipc-rpc/storage"
export { UpdaterRpcs } from "./ipc-rpc/updater"
export { WindowRpcs } from "./ipc-rpc/window"
export { WslRpcs } from "./ipc-rpc/wsl"
export const DesktopRpcs = AppRpcs.merge(StorageRpcs, FileRpcs, WindowRpcs, MenuRpcs, UpdaterRpcs, WslRpcs, EventRpcs)
export type DesktopRpcClient = RpcClient.FromGroup<typeof DesktopRpcs, RpcClientError.RpcClientError>
@@ -0,0 +1,72 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
const ServerReadyData = Schema.Struct({
url: Schema.String,
username: Schema.NullOr(Schema.String),
password: Schema.NullOr(Schema.String),
})
export const AppAwaitInitialization = Rpc.make("AppAwaitInitialization", { success: ServerReadyData })
export const AppConsumeInitialDeepLinks = Rpc.make("AppConsumeInitialDeepLinks", {
success: Schema.Array(Schema.String),
})
export const AppGetDefaultServerUrl = Rpc.make("AppGetDefaultServerUrl", {
success: Schema.NullOr(Schema.String),
})
export const AppSetDefaultServerUrl = Rpc.make("AppSetDefaultServerUrl", {
payload: { url: Schema.NullOr(Schema.String) },
})
export const AppIsFirstLaunchOnboardingPending = Rpc.make("AppIsFirstLaunchOnboardingPending", {
success: Schema.Boolean,
})
export const AppFinishFirstLaunchOnboarding = Rpc.make("AppFinishFirstLaunchOnboarding", {
payload: { createDefaultProject: Schema.Boolean },
success: Schema.NullOr(Schema.String),
})
export const AppCheckAppExists = Rpc.make("AppCheckAppExists", {
payload: { appName: Schema.String },
success: Schema.Boolean,
})
export const AppResolveAppPath = Rpc.make("AppResolveAppPath", {
payload: { appName: Schema.String },
success: Schema.NullOr(Schema.String),
})
export const AppSetBackgroundColor = Rpc.make("AppSetBackgroundColor", {
payload: { color: Schema.String },
})
export const AppExportDebugLogs = Rpc.make("AppExportDebugLogs", { success: Schema.String })
export const AppSetForceFocus = Rpc.make("AppSetForceFocus", {
payload: { enabled: Schema.Boolean },
})
export const AppRecordFatalRendererError = Rpc.make("AppRecordFatalRendererError", {
payload: {
error: Schema.Struct({
error: Schema.String,
url: Schema.String,
version: Schema.optionalKey(Schema.String),
platform: Schema.String,
os: Schema.optionalKey(Schema.String),
}),
},
})
export const AppSetNativeTranslations = Rpc.make("AppSetNativeTranslations", {
payload: { value: Schema.Unknown },
})
export const AppRelaunch = Rpc.make("AppRelaunch")
export const AppRpcs = RpcGroup.make(
AppAwaitInitialization,
AppConsumeInitialDeepLinks,
AppGetDefaultServerUrl,
AppSetDefaultServerUrl,
AppIsFirstLaunchOnboardingPending,
AppFinishFirstLaunchOnboarding,
AppCheckAppExists,
AppResolveAppPath,
AppSetBackgroundColor,
AppExportDebugLogs,
AppSetForceFocus,
AppRecordFatalRendererError,
AppSetNativeTranslations,
AppRelaunch,
)
@@ -0,0 +1,46 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
import { UpdaterStateSchema } from "./updater"
import { WslServersEventSchema } from "./wsl"
export class DeepLinksOpened extends Schema.TaggedClass<DeepLinksOpened>()("DeepLinksOpened", {
urls: Schema.Array(Schema.String),
}) {}
export class MenuCommandTriggered extends Schema.TaggedClass<MenuCommandTriggered>()("MenuCommandTriggered", {
id: Schema.String,
}) {}
export class UpdaterStateChanged extends Schema.TaggedClass<UpdaterStateChanged>()("UpdaterStateChanged", {
state: UpdaterStateSchema,
}) {}
export class WslServersChanged extends Schema.TaggedClass<WslServersChanged>()("WslServersChanged", {
event: WslServersEventSchema,
}) {}
export class WindowFullscreenChanged extends Schema.TaggedClass<WindowFullscreenChanged>()("WindowFullscreenChanged", {
fullscreen: Schema.Boolean,
}) {}
export class WindowPinchZoomChanged extends Schema.TaggedClass<WindowPinchZoomChanged>()("WindowPinchZoomChanged", {
enabled: Schema.Boolean,
}) {}
export class WindowZoomChanged extends Schema.TaggedClass<WindowZoomChanged>()("WindowZoomChanged", {
factor: Schema.Number,
}) {}
export const DesktopEvent = Schema.Union([
DeepLinksOpened,
MenuCommandTriggered,
UpdaterStateChanged,
WslServersChanged,
WindowFullscreenChanged,
WindowPinchZoomChanged,
WindowZoomChanged,
])
export type DesktopEvent = Schema.Schema.Type<typeof DesktopEvent>
export const DesktopEvents = Rpc.make("DesktopEvents", { success: DesktopEvent, stream: true })
export const EventRpcs = RpcGroup.make(DesktopEvents)
@@ -0,0 +1,71 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
const OptionalString = Schema.optionalKey(Schema.String)
const PickerOptions = Schema.Struct({
multiple: Schema.optionalKey(Schema.Boolean),
title: OptionalString,
defaultPath: OptionalString,
})
const FilePickerOptions = Schema.Struct({
multiple: Schema.optionalKey(Schema.Boolean),
title: OptionalString,
defaultPath: OptionalString,
extensions: Schema.optionalKey(Schema.Array(Schema.String)),
})
const SavePickerOptions = Schema.Struct({ title: OptionalString, defaultPath: OptionalString })
const PickedFiles = Schema.Struct({
token: Schema.String,
files: Schema.Array(Schema.Struct({ path: Schema.String, name: Schema.String, size: Schema.Number })),
})
const ClipboardImage = Schema.Struct({ buffer: Schema.Uint8Array, width: Schema.Number, height: Schema.Number })
export const FilesOpenDirectoryPicker = Rpc.make("FilesOpenDirectoryPicker", {
payload: { options: Schema.optionalKey(PickerOptions) },
success: Schema.NullOr(Schema.Union([Schema.String, Schema.Array(Schema.String)])),
})
export const FilesOpenFilePicker = Rpc.make("FilesOpenFilePicker", {
payload: { options: Schema.optionalKey(FilePickerOptions) },
success: Schema.NullOr(PickedFiles),
})
export const FilesReadPickedFile = Rpc.make("FilesReadPickedFile", {
payload: { token: Schema.String, path: Schema.String },
success: Schema.Uint8Array,
})
export const FilesReleasePickedFiles = Rpc.make("FilesReleasePickedFiles", {
payload: { token: Schema.String },
})
export const FilesSaveFilePicker = Rpc.make("FilesSaveFilePicker", {
payload: { options: Schema.optionalKey(SavePickerOptions) },
success: Schema.NullOr(Schema.String),
})
export const FilesOpenExternal = Rpc.make("FilesOpenExternal", {
payload: { url: Schema.String },
})
export const FilesOpenLocalFile = Rpc.make("FilesOpenLocalFile", {
payload: { url: Schema.String },
})
export const FilesOpenPath = Rpc.make("FilesOpenPath", {
payload: { path: Schema.String, application: Schema.optionalKey(Schema.String) },
success: Schema.NullOr(Schema.String),
})
export const FilesRevealPath = Rpc.make("FilesRevealPath", {
payload: { path: Schema.String },
success: Schema.Boolean,
})
export const FilesReadClipboardImage = Rpc.make("FilesReadClipboardImage", {
success: Schema.NullOr(ClipboardImage),
})
export const FileRpcs = RpcGroup.make(
FilesOpenDirectoryPicker,
FilesOpenFilePicker,
FilesReadPickedFile,
FilesReleasePickedFiles,
FilesSaveFilePicker,
FilesOpenExternal,
FilesOpenLocalFile,
FilesOpenPath,
FilesRevealPath,
FilesReadClipboardImage,
)
@@ -0,0 +1,29 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
const DesktopMenuAction = Schema.Literals([
"app.checkForUpdates",
"app.relaunch",
"edit.undo",
"edit.redo",
"edit.cut",
"edit.copy",
"edit.paste",
"edit.delete",
"edit.selectAll",
"view.reload",
"view.toggleDevTools",
"view.resetZoom",
"view.zoomIn",
"view.zoomOut",
"view.toggleFullscreen",
"window.new",
"window.close",
"window.minimize",
"window.toggleMaximize",
])
export const MenuRunAction = Rpc.make("MenuRunAction", {
payload: { action: DesktopMenuAction },
})
export const MenuRpcs = RpcGroup.make(MenuRunAction)
@@ -0,0 +1,52 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
export const StorageGet = Rpc.make("StorageGet", {
payload: { name: Schema.String, key: Schema.String },
success: Schema.NullOr(Schema.String),
})
export const StorageSet = Rpc.make("StorageSet", {
payload: { name: Schema.String, key: Schema.String, value: Schema.String },
})
export const StorageDelete = Rpc.make("StorageDelete", {
payload: { name: Schema.String, key: Schema.String },
})
export const StorageClear = Rpc.make("StorageClear", { payload: { name: Schema.String } })
export const StorageKeys = Rpc.make("StorageKeys", {
payload: { name: Schema.String },
success: Schema.Array(Schema.String),
})
export const StorageLength = Rpc.make("StorageLength", {
payload: { name: Schema.String },
success: Schema.Number,
})
export const DraftsGet = Rpc.make("DraftsGet", {
payload: { key: Schema.String },
success: Schema.NullOr(Schema.String),
})
export const DraftsSet = Rpc.make("DraftsSet", {
payload: { key: Schema.String, value: Schema.String },
})
export const DraftsDelete = Rpc.make("DraftsDelete", { payload: { key: Schema.String } })
export const DraftsPutBlob = Rpc.make("DraftsPutBlob", {
payload: { data: Schema.Uint8Array },
success: Schema.String,
})
export const DraftsGetBlob = Rpc.make("DraftsGetBlob", {
payload: { id: Schema.String },
success: Schema.NullOr(Schema.Uint8Array),
})
export const StorageRpcs = RpcGroup.make(
StorageGet,
StorageSet,
StorageDelete,
StorageClear,
StorageKeys,
StorageLength,
DraftsGet,
DraftsSet,
DraftsDelete,
DraftsPutBlob,
DraftsGetBlob,
)
@@ -0,0 +1,19 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
export const UpdaterStateSchema = Schema.Union([
Schema.Struct({ status: Schema.Literal("disabled") }),
Schema.Struct({ status: Schema.Literal("idle") }),
Schema.Struct({ status: Schema.Literal("checking") }),
Schema.Struct({ status: Schema.Literal("downloading"), version: Schema.String }),
Schema.Struct({ status: Schema.Literal("ready"), version: Schema.String }),
Schema.Struct({ status: Schema.Literal("up-to-date") }),
Schema.Struct({ status: Schema.Literal("installing"), version: Schema.String }),
Schema.Struct({ status: Schema.Literal("error"), message: Schema.String }),
])
export const UpdaterSubscribe = Rpc.make("UpdaterSubscribe")
export const UpdaterUnsubscribe = Rpc.make("UpdaterUnsubscribe")
export const UpdaterCheck = Rpc.make("UpdaterCheck", { success: UpdaterStateSchema })
export const UpdaterInstall = Rpc.make("UpdaterInstall")
export const UpdaterRpcs = RpcGroup.make(UpdaterSubscribe, UpdaterUnsubscribe, UpdaterCheck, UpdaterInstall)
@@ -0,0 +1,40 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
export const WindowGetId = Rpc.make("WindowGetId", { success: Schema.String })
export const WindowThemeReady = Rpc.make("WindowThemeReady")
export const WindowGetFocused = Rpc.make("WindowGetFocused", { success: Schema.Boolean })
export const WindowGetFullscreen = Rpc.make("WindowGetFullscreen", { success: Schema.Boolean })
export const WindowSetFocus = Rpc.make("WindowSetFocus")
export const WindowShow = Rpc.make("WindowShow")
export const WindowGetZoomFactor = Rpc.make("WindowGetZoomFactor", { success: Schema.Number })
export const WindowSetZoomFactor = Rpc.make("WindowSetZoomFactor", {
payload: { factor: Schema.Number },
})
export const WindowGetPinchZoomEnabled = Rpc.make("WindowGetPinchZoomEnabled", {
success: Schema.Boolean,
})
export const WindowSetPinchZoomEnabled = Rpc.make("WindowSetPinchZoomEnabled", {
payload: { enabled: Schema.Boolean },
})
export const WindowSetTitlebar = Rpc.make("WindowSetTitlebar", {
payload: {
theme: Schema.Struct({
mode: Schema.Literals(["light", "dark"]),
scheme: Schema.optionalKey(Schema.Literals(["system", "light", "dark"])),
}),
},
})
export const WindowRpcs = RpcGroup.make(
WindowGetId,
WindowThemeReady,
WindowGetFocused,
WindowGetFullscreen,
WindowSetFocus,
WindowShow,
WindowGetZoomFactor,
WindowSetZoomFactor,
WindowGetPinchZoomEnabled,
WindowSetPinchZoomEnabled,
WindowSetTitlebar,
)
+109
View File
@@ -0,0 +1,109 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
const WslServerConfig = Schema.Struct({ id: Schema.String, distro: Schema.String })
const WslServerRuntime = Schema.Union([
Schema.Struct({ kind: Schema.Literal("starting") }),
Schema.Struct({
kind: Schema.Literal("ready"),
url: Schema.String,
username: Schema.NullOr(Schema.String),
password: Schema.NullOr(Schema.String),
}),
Schema.Struct({ kind: Schema.Literal("failed"), message: Schema.String }),
Schema.Struct({ kind: Schema.Literal("stopped") }),
])
const WslJob = Schema.Union([
Schema.Struct({ kind: Schema.Literal("runtime"), startedAt: Schema.Number }),
Schema.Struct({ kind: Schema.Literal("distros"), startedAt: Schema.Number }),
Schema.Struct({ kind: Schema.Literal("install-wsl"), startedAt: Schema.Number }),
Schema.Struct({ kind: Schema.Literal("install-distro"), distro: Schema.String, startedAt: Schema.Number }),
Schema.Struct({
kind: Schema.Literal("probe-addable"),
distros: Schema.Array(Schema.String),
startedAt: Schema.Number,
}),
Schema.Struct({ kind: Schema.Literal("install-opencode"), distro: Schema.String, startedAt: Schema.Number }),
])
const WslServersState = Schema.Struct({
runtime: Schema.NullOr(
Schema.Struct({
available: Schema.Boolean,
version: Schema.NullOr(Schema.String),
error: Schema.NullOr(Schema.String),
}),
),
installed: Schema.Array(
Schema.Struct({ name: Schema.String, version: Schema.NullOr(Schema.Number), isDefault: Schema.Boolean }),
),
online: Schema.Array(Schema.Struct({ name: Schema.String, label: Schema.String })),
distroProbes: Schema.Record(
Schema.String,
Schema.Struct({
name: Schema.String,
canExecute: Schema.Boolean,
hasBash: Schema.Boolean,
hasCurl: Schema.Boolean,
error: Schema.NullOr(Schema.String),
}),
),
opencodeChecks: Schema.Record(
Schema.String,
Schema.Struct({
distro: Schema.String,
resolvedPath: Schema.NullOr(Schema.String),
version: Schema.NullOr(Schema.String),
expectedVersion: Schema.NullOr(Schema.String),
matchesDesktop: Schema.NullOr(Schema.Boolean),
error: Schema.NullOr(Schema.String),
}),
),
pendingRestart: Schema.Boolean,
servers: Schema.Array(Schema.Struct({ config: WslServerConfig, runtime: WslServerRuntime })),
job: Schema.NullOr(WslJob),
})
export const WslServersEventSchema = Schema.Struct({ type: Schema.Literal("state"), state: WslServersState })
export const WslSubscribe = Rpc.make("WslSubscribe")
export const WslUnsubscribe = Rpc.make("WslUnsubscribe")
export const WslGetState = Rpc.make("WslGetState", { success: WslServersState })
export const WslProbeRuntime = Rpc.make("WslProbeRuntime")
export const WslRefreshDistros = Rpc.make("WslRefreshDistros")
export const WslInstallWsl = Rpc.make("WslInstallWsl")
export const WslInstallDistro = Rpc.make("WslInstallDistro", {
payload: { name: Schema.String },
})
export const WslProbeAddable = Rpc.make("WslProbeAddable", {
payload: { distros: Schema.Array(Schema.String) },
})
export const WslInstallOpencode = Rpc.make("WslInstallOpencode", {
payload: { name: Schema.String },
})
export const WslOpenTerminal = Rpc.make("WslOpenTerminal", {
payload: { name: Schema.String },
})
export const WslAddServer = Rpc.make("WslAddServer", {
payload: { distro: Schema.String },
success: WslServerConfig,
})
export const WslRemoveServer = Rpc.make("WslRemoveServer", {
payload: { id: Schema.String },
})
export const WslStartServer = Rpc.make("WslStartServer", {
payload: { id: Schema.String },
})
export const WslRpcs = RpcGroup.make(
WslSubscribe,
WslUnsubscribe,
WslGetState,
WslProbeRuntime,
WslRefreshDistros,
WslInstallWsl,
WslInstallDistro,
WslProbeAddable,
WslInstallOpencode,
WslOpenTerminal,
WslAddServer,
WslRemoveServer,
WslStartServer,
)
@@ -0,0 +1 @@
export const IpcTransportPort = "desktop-rpc-port"
+5
View File
@@ -59,6 +59,11 @@
"node": "./src/attention-sounds.node.ts",
"default": "./src/attention-sounds.bun.ts"
},
"#terminal-win32": {
"bun": "./src/terminal-win32.bun.ts",
"node": "./src/terminal-win32.node.ts",
"default": "./src/terminal-win32.bun.ts"
},
"#string-width": {
"bun": "./src/util/string-width.bun.ts",
"node": "./src/util/string-width.node.ts",
+3
View File
@@ -96,6 +96,7 @@ import { CommandPaletteDialog } from "./component/command-palette"
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
import { DialogVariant } from "./component/dialog-variant"
import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
import { destroyRenderer } from "./util/renderer"
import { cliErrorMessage, errorFormat } from "./util/error"
import { AttentionProvider } from "./context/attention"
@@ -265,6 +266,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
Effect.catch((error) => Effect.sync(() => log("error", "Failed to dispose TUI clipboard", { error }))),
),
)
win32DisableProcessedInput()
const finalizers = new Set<() => Promise<void>>()
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
@@ -448,6 +450,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
}),
)
yield* Effect.sync(() => {
win32FlushInputBuffer()
if (result.reason !== undefined)
process.stderr.write((cliErrorMessage(result.reason) ?? errorFormat(result.reason)) + "\n")
if (result.epilogue) process.stdout.write(result.epilogue + "\n")
+130
View File
@@ -0,0 +1,130 @@
import { dlopen, ptr } from "bun:ffi"
import type { ReadStream } from "node:tty"
const STD_INPUT_HANDLE = -10
const ENABLE_PROCESSED_INPUT = 0x0001
const kernel = () =>
dlopen("kernel32.dll", {
GetStdHandle: { args: ["i32"], returns: "ptr" },
GetConsoleMode: { args: ["ptr", "ptr"], returns: "i32" },
SetConsoleMode: { args: ["ptr", "u32"], returns: "i32" },
FlushConsoleInputBuffer: { args: ["ptr"], returns: "i32" },
})
let k32: ReturnType<typeof kernel> | undefined
function load() {
if (process.platform !== "win32") return false
try {
k32 ??= kernel()
return true
} catch {
return false
}
}
/**
* Clear ENABLE_PROCESSED_INPUT on the console stdin handle.
*/
export function win32DisableProcessedInput() {
if (process.platform !== "win32") return
if (!process.stdin.isTTY) return
if (!load()) return
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
const buf = new Uint32Array(1)
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
const mode = buf[0]!
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
}
/**
* Discard any queued console input (mouse events, key presses, etc.).
*/
export function win32FlushInputBuffer() {
if (process.platform !== "win32") return
if (!process.stdin.isTTY) return
if (!load()) return
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
k32!.symbols.FlushConsoleInputBuffer(handle)
}
let unhook: (() => void) | undefined
/**
* Keep ENABLE_PROCESSED_INPUT disabled.
*
* On Windows, Ctrl+C becomes a CTRL_C_EVENT (instead of stdin input) when
* ENABLE_PROCESSED_INPUT is set. Various runtimes can re-apply console modes
* (sometimes on a later tick), and the flag is console-global, not per-process.
*
* We combine:
* - A `setRawMode(...)` hook to re-clear after known raw-mode toggles.
* - A low-frequency poll as a backstop for native/external mode changes.
*/
export function win32InstallCtrlCGuard() {
if (process.platform !== "win32") return
if (!process.stdin.isTTY) return
if (!load()) return
if (unhook) return unhook
const stdin = process.stdin as ReadStream
const original = stdin.setRawMode
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
const buf = new Uint32Array(1)
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
const initial = buf[0]!
const enforce = () => {
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
const mode = buf[0]!
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
}
// Some runtimes can re-apply console modes on the next tick; enforce twice.
const later = () => {
enforce()
setImmediate(enforce)
}
let wrapped: ReadStream["setRawMode"] | undefined
if (typeof original === "function") {
wrapped = (mode: boolean) => {
const result = original.call(stdin, mode)
later()
return result
}
stdin.setRawMode = wrapped
}
// Ensure it's cleared immediately too (covers any earlier mode changes).
later()
const interval = setInterval(enforce, 100)
interval.unref()
let done = false
unhook = () => {
if (done) return
done = true
clearInterval(interval)
if (wrapped && stdin.setRawMode === wrapped) {
stdin.setRawMode = original
}
k32!.symbols.SetConsoleMode(handle, initial)
unhook = undefined
}
return unhook
}
+77
View File
@@ -0,0 +1,77 @@
import { dlopen } from "node:ffi"
import type { ReadStream } from "node:tty"
const STD_INPUT_HANDLE = -10
const ENABLE_PROCESSED_INPUT = 0x0001
const kernel = () =>
dlopen("kernel32.dll", {
GetStdHandle: { arguments: ["i32"], return: "pointer" },
GetConsoleMode: { arguments: ["pointer", "pointer"], return: "i32" },
SetConsoleMode: { arguments: ["pointer", "u32"], return: "i32" },
FlushConsoleInputBuffer: { arguments: ["pointer"], return: "i32" },
}).functions
let k32: ReturnType<typeof kernel> | undefined
function load() {
if (process.platform !== "win32") return false
try {
k32 ??= kernel()
return true
} catch {
return false
}
}
export function win32DisableProcessedInput() {
if (process.platform !== "win32" || !process.stdin.isTTY || !load()) return
const handle = k32!.GetStdHandle(STD_INPUT_HANDLE)
const buffer = new Uint32Array(1)
if (k32!.GetConsoleMode(handle, buffer) === 0) return
const mode = buffer[0]!
if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
k32!.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
}
export function win32FlushInputBuffer() {
if (process.platform !== "win32" || !process.stdin.isTTY || !load()) return
k32!.FlushConsoleInputBuffer(k32!.GetStdHandle(STD_INPUT_HANDLE))
}
let unhook: (() => void) | undefined
export function win32InstallCtrlCGuard() {
if (process.platform !== "win32" || !process.stdin.isTTY || !load() || unhook) return unhook
const stdin = process.stdin as ReadStream
const original = stdin.setRawMode
const handle = k32!.GetStdHandle(STD_INPUT_HANDLE)
const buffer = new Uint32Array(1)
if (k32!.GetConsoleMode(handle, buffer) === 0) return
const initial = buffer[0]!
const enforce = () => {
if (k32!.GetConsoleMode(handle, buffer) === 0) return
const mode = buffer[0]!
if ((mode & ENABLE_PROCESSED_INPUT) !== 0) k32!.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
}
const later = () => {
enforce()
setImmediate(enforce)
}
const wrapped: ReadStream["setRawMode"] = (mode) => {
const result = original.call(stdin, mode)
later()
return result
}
stdin.setRawMode = wrapped
later()
const interval = setInterval(enforce, 100)
interval.unref()
unhook = () => {
clearInterval(interval)
if (stdin.setRawMode === wrapped) stdin.setRawMode = original
k32!.SetConsoleMode(handle, initial)
unhook = undefined
}
return unhook
}
+1
View File
@@ -0,0 +1 @@
export { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "#terminal-win32"