mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-20 06:53:27 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d9603bd8a | |||
| d6625397d9 |
@@ -415,7 +415,6 @@
|
||||
"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,10 +8,13 @@ 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 } })
|
||||
|
||||
@@ -26,6 +29,23 @@ 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>()
|
||||
@@ -94,8 +114,10 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
sessions: [session(parentID, parentTitle, 1700000000000), childSession()],
|
||||
pageMessages: (sessionID) => ({ items: sessionID === parentID ? parentMessages() : [] }),
|
||||
sessions: [session(parentID, parentTitle, 1700000000000), childSession(), grandchildSession()],
|
||||
pageMessages: (sessionID) => ({
|
||||
items: sessionID === parentID ? parentMessages() : sessionID === childID ? childMessages() : [],
|
||||
}),
|
||||
events,
|
||||
eventRetry: events ? 16 : undefined,
|
||||
})
|
||||
@@ -145,6 +167,10 @@ 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"
|
||||
@@ -182,6 +208,43 @@ 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(
|
||||
|
||||
@@ -34,6 +34,7 @@ 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
|
||||
@@ -170,15 +171,43 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
const tabs = useTabs()
|
||||
const tabsStore = tabs.store
|
||||
const tabsStoreActions = tabs
|
||||
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 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 = () => {
|
||||
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
|
||||
@@ -186,19 +215,10 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
return tabsStore.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||
}
|
||||
if (route.type === "session") {
|
||||
const main = tabsStore.find(
|
||||
(item) =>
|
||||
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
|
||||
const sessionId = rootID() ?? route.sessionId
|
||||
return tabsStore.find(
|
||||
(item) => item.type === "session" && item.server === route.server && item.sessionId === 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,9 +234,8 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
}
|
||||
|
||||
if (route.type === "session") {
|
||||
const s = session()
|
||||
if (!s) return
|
||||
const sessionId = s.parentID ?? s.id
|
||||
const sessionId = rootID()
|
||||
if (!sessionId) return
|
||||
const next = { server: route.server, sessionId }
|
||||
tabsStoreActions.addSessionTab(next)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -1,100 +1,103 @@
|
||||
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)
|
||||
|
||||
export const checkAppExists = Effect.fn("DesktopFiles.checkAppExists")(function* (appName: string) {
|
||||
const exists = (path: string) =>
|
||||
access(path)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
export function checkAppExists(appName: string) {
|
||||
if (process.platform === "win32") return true
|
||||
if (process.platform === "linux") return true
|
||||
return yield* checkMacosApp(appName)
|
||||
})
|
||||
return checkMacosApp(appName)
|
||||
}
|
||||
|
||||
export const resolveAppPath = Effect.fn("DesktopFiles.resolveAppPath")(function* (appName: string) {
|
||||
export function resolveAppPath(appName: string) {
|
||||
if (process.platform !== "win32") return appName
|
||||
return yield* resolveWindowsAppPath(appName)
|
||||
})
|
||||
return resolveWindowsAppPath(appName)
|
||||
}
|
||||
|
||||
const checkMacosApp = Effect.fn("DesktopFiles.checkMacosApp")(function* (appName: string) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
async function checkMacosApp(appName: string) {
|
||||
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 (yield* exists(fs, location)) return true
|
||||
if (await exists(location)) return true
|
||||
}
|
||||
|
||||
return yield* Effect.tryPromise(() => execFilePromise("which", [appName])).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
)
|
||||
})
|
||||
return execFilePromise("which", [appName])
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
}
|
||||
|
||||
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
|
||||
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 paths = result.stdout
|
||||
.toString()
|
||||
const paths = output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
|
||||
const hasExt = (value: string, ext: string) => path.extname(value).toLowerCase() === `.${ext}`
|
||||
const hasExt = (path: string, ext: string) => extname(path).toLowerCase() === `.${ext}`
|
||||
|
||||
const exe = paths.find((path) => hasExt(path, "exe"))
|
||||
if (exe) return exe
|
||||
|
||||
const resolveCmd = Effect.fnUntraced(function* (file: string) {
|
||||
const content = yield* fs.readFileString(file)
|
||||
const resolveCmd = async (path: string) => {
|
||||
const content = await readFile(path, "utf8")
|
||||
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 = path.dirname(file)
|
||||
const base = dirname(path)
|
||||
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 path.dirname(current)
|
||||
return path.join(current, part)
|
||||
if (part === "..") return dirname(current)
|
||||
return join(current, part)
|
||||
}, base)
|
||||
|
||||
if (yield* exists(fs, resolved)) return resolved
|
||||
if (await exists(resolved)) return resolved
|
||||
}
|
||||
|
||||
if (yield* exists(fs, token)) return token
|
||||
if (await exists(token)) return token
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
for (const file of paths) {
|
||||
if (hasExt(file, "cmd") || hasExt(file, "bat")) {
|
||||
const resolved = yield* resolveCmd(file)
|
||||
for (const path of paths) {
|
||||
if (hasExt(path, "cmd") || hasExt(path, "bat")) {
|
||||
const resolved = await resolveCmd(path)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
|
||||
if (!path.extname(file)) {
|
||||
const cmd = `${file}.cmd`
|
||||
if (yield* exists(fs, cmd)) {
|
||||
const resolved = yield* resolveCmd(cmd)
|
||||
if (!extname(path)) {
|
||||
const cmd = `${path}.cmd`
|
||||
if (await exists(cmd)) {
|
||||
const resolved = await resolveCmd(cmd)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
|
||||
const bat = `${file}.bat`
|
||||
if (yield* exists(fs, bat)) {
|
||||
const resolved = yield* resolveCmd(bat)
|
||||
const bat = `${path}.bat`
|
||||
if (await exists(bat)) {
|
||||
const resolved = await resolveCmd(bat)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
}
|
||||
@@ -107,31 +110,27 @@ const resolveWindowsAppPath = Effect.fn("DesktopFiles.resolveWindowsAppPath")(fu
|
||||
.join("")
|
||||
|
||||
if (key) {
|
||||
for (const file of paths) {
|
||||
const dirs = [path.dirname(file), path.dirname(path.dirname(file)), path.dirname(path.dirname(path.dirname(file)))]
|
||||
for (const path of paths) {
|
||||
const dirs = [dirname(path), dirname(dirname(path)), dirname(dirname(dirname(path)))]
|
||||
for (const dir of dirs) {
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,9 +1,7 @@
|
||||
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,
|
||||
@@ -11,9 +9,6 @@ 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(() =>
|
||||
@@ -30,7 +25,7 @@ describe("assertAttachmentBudget", () => {
|
||||
const file = join(directory, "example.txt")
|
||||
try {
|
||||
await writeFile(file, "lorem ipsum")
|
||||
expect(new TextDecoder().decode(await run(readAttachment(file)))).toBe("lorem ipsum")
|
||||
expect(new TextDecoder().decode(await readAttachment(file))).toBe("lorem ipsum")
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
@@ -42,7 +37,7 @@ describe("assertAttachmentBudget", () => {
|
||||
try {
|
||||
await writeFile(file, "")
|
||||
await truncate(file, MAX_ATTACHMENT_BYTES + 1)
|
||||
await expect(run(readAttachment(file))).rejects.toThrow("20 MB limit")
|
||||
await expect(readAttachment(file)).rejects.toThrow("20 MB limit")
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
@@ -50,16 +45,16 @@ describe("assertAttachmentBudget", () => {
|
||||
})
|
||||
|
||||
describe("picked file authorizations", () => {
|
||||
const read = (path: string) => Effect.sync(() => new TextEncoder().encode(path).buffer)
|
||||
const read = async (path: string) => 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 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")
|
||||
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")
|
||||
})
|
||||
|
||||
test("releases unread files for one picker without affecting another", async () => {
|
||||
@@ -68,29 +63,25 @@ describe("picked file authorizations", () => {
|
||||
const second = authorizations.add(1, ["b.txt"])
|
||||
authorizations.release(1, first)
|
||||
|
||||
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")
|
||||
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")
|
||||
})
|
||||
|
||||
test("keeps picker tokens scoped to their renderer", async () => {
|
||||
const authorizations = createPickedFileAuthorizations(read)
|
||||
const token = authorizations.add(1, ["a.txt"])
|
||||
|
||||
await expect(run(authorizations.read(2, token, "a.txt"))).rejects.toThrow("not selected")
|
||||
await expect(authorizations.read(2, token, "a.txt")).rejects.toThrow("not selected")
|
||||
})
|
||||
|
||||
test("charges actual reads against the selection budget", async () => {
|
||||
const authorizations = createPickedFileAuthorizations(
|
||||
(_path, maxBytes) =>
|
||||
Effect.sync(() => {
|
||||
if (6 > maxBytes) throw new Error("budget exceeded")
|
||||
return new ArrayBuffer(6)
|
||||
}),
|
||||
10,
|
||||
)
|
||||
const authorizations = createPickedFileAuthorizations(async (_path, maxBytes) => {
|
||||
if (6 > maxBytes) throw new Error("budget exceeded")
|
||||
return new ArrayBuffer(6)
|
||||
}, 10)
|
||||
const token = authorizations.add(1, ["a.txt", "b.txt"])
|
||||
|
||||
await run(authorizations.read(1, token, "a.txt"))
|
||||
await expect(run(authorizations.read(1, token, "b.txt"))).rejects.toThrow("budget exceeded")
|
||||
await authorizations.read(1, token, "a.txt")
|
||||
await expect(authorizations.read(1, token, "b.txt")).rejects.toThrow("budget exceeded")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { open } from "node:fs/promises"
|
||||
import { nativeT } from "../native/translations"
|
||||
|
||||
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
export function createPickedFileAuthorizations(
|
||||
read: (path: string, maxBytes: number) => Effect.Effect<ArrayBuffer, unknown>,
|
||||
read: (path: string, maxBytes: number) => Promise<ArrayBuffer> = readAttachment,
|
||||
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
|
||||
},
|
||||
read: Effect.fn("DesktopFiles.readPickedFile")(function* (sender: number, token: string, path: string) {
|
||||
async read(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 = yield* read(path, selection.remaining)
|
||||
const bytes = await 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,23 +37,21 @@ export function assertAttachmentBudget(files: { size: number }[]) {
|
||||
throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 }))
|
||||
}
|
||||
|
||||
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)
|
||||
}),
|
||||
)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,98 +1,69 @@
|
||||
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 { scoped } from "../native/logging"
|
||||
import { writeLog } from "../native/logging"
|
||||
import { nativeT } from "../native/translations"
|
||||
import { assertAttachmentBudget, createPickedFileAuthorizations, readAttachment } from "./attachment-picker"
|
||||
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
|
||||
import { resolveExternalURL, resolveLocalFilePath } from "./external-url"
|
||||
|
||||
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)),
|
||||
)
|
||||
export function createFileCapabilities() {
|
||||
const pickedFiles = createPickedFileAuthorizations()
|
||||
|
||||
return {
|
||||
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,
|
||||
}),
|
||||
)
|
||||
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,
|
||||
})
|
||||
if (result.canceled) return null
|
||||
return options?.multiple ? result.filePaths : result.filePaths[0]
|
||||
}),
|
||||
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),
|
||||
}),
|
||||
)
|
||||
},
|
||||
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),
|
||||
})
|
||||
if (result.canceled) return null
|
||||
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" },
|
||||
const files = await Promise.all(
|
||||
result.filePaths.map(async (path) => ({ path, name: basename(path), size: (await stat(path)).size })),
|
||||
)
|
||||
assertAttachmentBudget(files)
|
||||
return { token: pickedFiles.add(sender, result.filePaths), files }
|
||||
}),
|
||||
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,
|
||||
}),
|
||||
)
|
||||
},
|
||||
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,
|
||||
})
|
||||
if (result.canceled) return null
|
||||
return result.filePath ?? null
|
||||
}),
|
||||
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)),
|
||||
},
|
||||
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,
|
||||
)
|
||||
if (!exists) return false
|
||||
shell.showItemInFolder(target)
|
||||
shell.showItemInFolder(path)
|
||||
return true
|
||||
}),
|
||||
},
|
||||
readClipboardImage() {
|
||||
const image = clipboard.readImage()
|
||||
if (image.isEmpty()) return null
|
||||
@@ -102,24 +73,25 @@ function make(fs: FileSystem.FileSystem, path: Path.Path) {
|
||||
}
|
||||
}
|
||||
|
||||
export const openExternalURL = Effect.fn("DesktopFiles.openExternalURL")(function* (value: string) {
|
||||
export function openExternalURL(value: string) {
|
||||
const url = resolveExternalURL(value)
|
||||
if (!url) {
|
||||
yield* scoped("window", Effect.logWarning("blocked external target", { url: value }))
|
||||
writeLog("window", "blocked external target", { url: value }, "warn")
|
||||
return
|
||||
}
|
||||
yield* Effect.promise(() => shell.openExternal(url))
|
||||
})
|
||||
void shell.openExternal(url)
|
||||
}
|
||||
|
||||
export const openLocalFileURL = Effect.fn("DesktopFiles.openLocalFileURL")(function* (value: string) {
|
||||
export function openLocalFileURL(value: string) {
|
||||
const path = resolveLocalFilePath(value)
|
||||
if (!path) {
|
||||
yield* scoped("window", Effect.logWarning("blocked local file target", { url: value }))
|
||||
writeLog("window", "blocked local file target", { url: value }, "warn")
|
||||
return
|
||||
}
|
||||
const error = yield* Effect.promise(() => shell.openPath(path))
|
||||
if (error) yield* scoped("window", Effect.logError("failed to open local file", { path, error }))
|
||||
})
|
||||
void shell.openPath(path).then((error) => {
|
||||
if (error) writeLog("window", "failed to open local file", { path, error }, "error")
|
||||
})
|
||||
}
|
||||
|
||||
function pickerFilters(extensions?: string[]) {
|
||||
if (!extensions?.length) return undefined
|
||||
|
||||
@@ -1,75 +1,111 @@
|
||||
import { NodeFileSystem, NodePath, NodeRuntime } from "@effect/platform-node"
|
||||
import { app } from "electron"
|
||||
import { Effect, Exit, Layer } from "effect"
|
||||
import { Deferred, Effect, Fiber } from "effect"
|
||||
import type { ServerReadyData } from "../shared/ipc-contract"
|
||||
import { Ipc } from "./ipc"
|
||||
import { checkAppExists, resolveAppPath } from "./files/apps"
|
||||
import {
|
||||
registerIpcHandlers,
|
||||
registerUpdaterIpcHandlers,
|
||||
registerWslInitialization,
|
||||
registerWslIpcHandlers,
|
||||
} from "./ipc"
|
||||
import {
|
||||
acquireApplicationLock,
|
||||
configureApplication,
|
||||
loadProxyEnvironment,
|
||||
preferApplicationEnvironment,
|
||||
prepareApplicationEnvironment,
|
||||
prepareDesktop,
|
||||
} from "./lifecycle/environment"
|
||||
import { ApplicationLifecycle } from "./lifecycle"
|
||||
import { initializeFirstLaunchOnboarding } from "./lifecycle/onboarding"
|
||||
import { Shutdown } from "./lifecycle/shutdown"
|
||||
import { DesktopLogging } from "./native/logging"
|
||||
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 { startBackgroundCli } from "./service/background-service"
|
||||
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
|
||||
yield* runDesktop.pipe(Effect.provide(Updater.layer))
|
||||
})
|
||||
|
||||
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)),
|
||||
)
|
||||
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
|
||||
yield* configureApplication()
|
||||
yield* runApplication
|
||||
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)
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
main.pipe(
|
||||
Effect.provide(ApplicationLifecycle.layer.pipe(Layer.provideMerge(platform))),
|
||||
Effect.scoped,
|
||||
NodeRuntime.runMain,
|
||||
)
|
||||
Effect.runFork(main)
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -1,42 +0,0 @@
|
||||
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
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -1,28 +0,0 @@
|
||||
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,
|
||||
}),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -1,29 +0,0 @@
|
||||
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
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -1,18 +0,0 @@
|
||||
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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -1,58 +0,0 @@
|
||||
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)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -1,27 +0,0 @@
|
||||
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),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -1,181 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
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))
|
||||
@@ -1,80 +1,201 @@
|
||||
export * as Ipc from "./ipc"
|
||||
import { BrowserWindow, ipcMain } from "electron"
|
||||
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
|
||||
import { parseDesktopNativeBundle, type DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
|
||||
|
||||
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"
|
||||
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"
|
||||
|
||||
export function layer(initialization: Effect.Effect<ServerReadyData>, cli?: Wsl.Cli) {
|
||||
const services = Layer.mergeAll(DesktopFiles.layer, DesktopStorage.layer, Wsl.layer(cli)).pipe(
|
||||
Layer.provideMerge(Initialization.layer(initialization)),
|
||||
)
|
||||
const handlers = Layer.mergeAll(
|
||||
appHandlers,
|
||||
storageHandlers,
|
||||
fileHandlers,
|
||||
windowHandlers,
|
||||
menuHandlers,
|
||||
updaterHandlers,
|
||||
wslHandlers,
|
||||
eventHandlers,
|
||||
)
|
||||
return RpcServer.layer(DesktopRpcs, { disableFatalDefects: true }).pipe(
|
||||
Layer.provide(handlers),
|
||||
Layer.provideMerge(IpcServerProtocolLive),
|
||||
Layer.provideMerge(services),
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
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))
|
||||
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),
|
||||
)
|
||||
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)
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => app.off("browser-window-created", wire)))
|
||||
return {
|
||||
installMenu: () => createMenu(menu),
|
||||
}
|
||||
})
|
||||
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))
|
||||
|
||||
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 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))
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
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, FileSystem, Path } from "effect"
|
||||
import { CHANNEL } from "../constants"
|
||||
import { DesktopPaths } from "../paths"
|
||||
import { Effect } from "effect"
|
||||
import { CHANNEL, VERSION } from "../constants"
|
||||
import { initCrashReporter, initLogging, type DesktopLogger } from "../native/logging"
|
||||
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",
|
||||
@@ -24,8 +27,7 @@ const appIDs: Record<string, string> = {
|
||||
const testOnboarding = process.env.OPENCODE_TEST_ONBOARDING === "1"
|
||||
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
|
||||
|
||||
export const configureApplication = Effect.fn("Application.configure")(function* () {
|
||||
const path = yield* Path.Path
|
||||
export function configureApplication() {
|
||||
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
|
||||
try {
|
||||
process.chdir(homedir())
|
||||
@@ -33,18 +35,30 @@ export const configureApplication = Effect.fn("Application.configure")(function*
|
||||
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")
|
||||
|
||||
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"))
|
||||
})
|
||||
return logger
|
||||
}
|
||||
|
||||
export function acquireApplicationLock() {
|
||||
if (app.requestSingleInstanceLock()) return true
|
||||
@@ -52,81 +66,73 @@ export function acquireApplicationLock() {
|
||||
return false
|
||||
}
|
||||
|
||||
export const prepareApplicationEnvironment = Effect.gen(function* () {
|
||||
yield* loadSystemCertificates
|
||||
yield* loadProxyEnvironment
|
||||
})
|
||||
|
||||
export const preferApplicationEnvironment = Effect.gen(function* () {
|
||||
export function preferApplicationEnvironment(logger: DesktopLogger) {
|
||||
const shell = process.platform === "win32" ? null : getUserShell()
|
||||
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",
|
||||
})
|
||||
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",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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 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 loadProxyEnvironment = Effect.gen(function* () {
|
||||
yield* Effect.try(() => {
|
||||
ensureLoopbackNoProxy()
|
||||
export function loadProxyEnvironment(logger: DesktopLogger) {
|
||||
ensureLoopbackNoProxy()
|
||||
try {
|
||||
// Electron 41.2 has a newer Node API than the current @types/node package.
|
||||
const proxyAwareHttp = http as typeof http & { setGlobalProxyFromEnv(): void }
|
||||
proxyAwareHttp.setGlobalProxyFromEnv()
|
||||
}).pipe(Effect.catch((error) => Effect.logWarning("failed to load proxy environment", { error })))
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn("failed to load proxy environment", error)
|
||||
}
|
||||
}
|
||||
|
||||
const createTestRoot = Effect.fn("Application.createTestRoot")(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
function createTestRoot() {
|
||||
const root = testOnboarding
|
||||
? path.join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
|
||||
? join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
|
||||
: app.isPackaged
|
||||
? undefined
|
||||
: process.env.OPENCODE_DESKTOP_TEST_ROOT
|
||||
if (!root) return undefined
|
||||
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) rmSync(root, { recursive: true, force: true })
|
||||
;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) =>
|
||||
mkdirSync(join(root, dir), { recursive: true }),
|
||||
)
|
||||
if (testOnboarding) process.env.OPENCODE_DB = ":memory:"
|
||||
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")
|
||||
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")
|
||||
return root
|
||||
})
|
||||
}
|
||||
|
||||
const loadSystemCertificates = Effect.try({
|
||||
try: () => {
|
||||
function loadSystemCertificates(logger: DesktopLogger) {
|
||||
try {
|
||||
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
|
||||
},
|
||||
catch: (error) => error,
|
||||
}).pipe(Effect.catch((error) => Effect.logWarning("failed to load system certificates", { error })))
|
||||
} catch (error) {
|
||||
logger.warn("failed to load system certificates", error)
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLoopbackNoProxy() {
|
||||
const loopback = ["127.0.0.1", "localhost", "::1"]
|
||||
|
||||
@@ -1,148 +1,80 @@
|
||||
export * as ApplicationLifecycle from "./index"
|
||||
|
||||
import { app, BrowserWindow } from "electron"
|
||||
import type { Event } from "electron"
|
||||
import { Context, Effect, FileSystem, Layer, Path } from "effect"
|
||||
import { DeepLinksOpened } from "../../shared/ipc-rpc/events"
|
||||
import { emitIpcEvent } from "../ipc-events"
|
||||
import { DesktopLogging, scoped } from "../native/logging"
|
||||
import { DesktopPaths } from "../paths"
|
||||
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
|
||||
import { writeLog, type DesktopLogger } from "../native/logging"
|
||||
import { safeWebContentsURL } from "../windows/state"
|
||||
import { createMainWindow, getLastFocusedWindow, restoreMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
|
||||
import { Shutdown } from "./shutdown"
|
||||
import { getLastFocusedWindow, restoreMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
|
||||
|
||||
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()
|
||||
},
|
||||
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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
import { app } from "electron"
|
||||
import { Effect, FileSystem, Option, Path } from "effect"
|
||||
import { scoped } from "../native/logging"
|
||||
import { writeLog } 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 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" }
|
||||
}),
|
||||
)
|
||||
export function initializeFirstLaunchOnboarding(userDataPath: string) {
|
||||
const entries = existsSync(userDataPath) ? readdirSync(userDataPath, { withFileTypes: true }) : []
|
||||
const store = getStore()
|
||||
const current = store.get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY)
|
||||
if (typeof current === "boolean") return current
|
||||
@@ -25,29 +18,24 @@ export const initializeFirstLaunchOnboarding = Effect.fn("Onboarding.initialize"
|
||||
const complete = hasExistingAppState(entries)
|
||||
store.set(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, complete)
|
||||
return complete
|
||||
})
|
||||
}
|
||||
|
||||
export const isFirstLaunchOnboardingPending = Effect.fn("Onboarding.isPending")(function* () {
|
||||
export function isFirstLaunchOnboardingPending() {
|
||||
const pending = getStore().get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY) !== true
|
||||
yield* scoped("onboarding", Effect.logInfo("first launch onboarding pending checked", { pending }))
|
||||
writeLog("onboarding", "first launch onboarding pending checked", { pending })
|
||||
return pending
|
||||
})
|
||||
}
|
||||
|
||||
export const finishFirstLaunchOnboarding = Effect.fn("Onboarding.finish")(function* (createDefaultProject: boolean) {
|
||||
if (!(yield* isFirstLaunchOnboardingPending())) {
|
||||
yield* scoped("onboarding", Effect.logInfo("first launch onboarding already completed"))
|
||||
export async function finishFirstLaunchOnboarding(createDefaultProject: boolean) {
|
||||
if (!isFirstLaunchOnboardingPending()) {
|
||||
writeLog("onboarding", "first launch onboarding already completed")
|
||||
return null
|
||||
}
|
||||
|
||||
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 })
|
||||
const defaultProject = createDefaultProject ? join(app.getPath("documents"), DEFAULT_PROJECT_DIR) : null
|
||||
if (defaultProject) await mkdir(defaultProject, { recursive: true })
|
||||
|
||||
getStore().set(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, true)
|
||||
yield* scoped(
|
||||
"onboarding",
|
||||
Effect.logInfo("first launch onboarding completed", { createDefaultProject, defaultProject }),
|
||||
)
|
||||
writeLog("onboarding", "first launch onboarding completed", { createDefaultProject, defaultProject })
|
||||
return defaultProject
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -1,8 +1,8 @@
|
||||
export * as DesktopLogging from "./logging"
|
||||
|
||||
import { MainLogger } from "electron-log"
|
||||
import log from "electron-log/main.js"
|
||||
import { app, crashReporter, netLog, shell } from "electron"
|
||||
import { Context, Effect, FileSystem, Layer, Logger, Option, Path, References } from "effect"
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
import { homedir } from "node:os"
|
||||
import { VERSION } from "../constants"
|
||||
|
||||
@@ -16,160 +16,97 @@ let root = ""
|
||||
let run = ""
|
||||
let netLogPath: string | undefined
|
||||
|
||||
export interface Interface {
|
||||
readonly startNetwork: Effect.Effect<void>
|
||||
readonly exportDebug: Effect.Effect<string>
|
||||
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 class Service extends Context.Service<Service, Interface>()("opencode/desktop/DesktopLogging") {}
|
||||
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 })
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
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 nativeLogger = Logger.make((options) => {
|
||||
try {
|
||||
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.
|
||||
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 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)
|
||||
})
|
||||
}
|
||||
|
||||
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 }))
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
const output = join(app.getPath("downloads"), `opencode-debug-${stamp()}.zip`)
|
||||
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) {
|
||||
yield* Effect.tryPromise(() => netLog.stopLogging()).pipe(
|
||||
Effect.catch((error) => scoped("network", Effect.logWarning("failed to stop net log", { error }))),
|
||||
)
|
||||
await startNetLog().catch((error) => writeLog("network", "failed to restart 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* () {
|
||||
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)
|
||||
}
|
||||
|
||||
export function tail(): string {
|
||||
try {
|
||||
const path = log.transports.file.getFile().path
|
||||
const contents = yield* fs.readFileString(path)
|
||||
const contents = readFileSync(path, "utf8")
|
||||
const lines = contents.split("\n")
|
||||
return lines.slice(Math.max(0, lines.length - TAIL_LINES)).join("\n")
|
||||
}).pipe(Effect.catch(() => Effect.succeed("")))
|
||||
})
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
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 initRunDirectory() {
|
||||
root = join(app.getPath("userData"), "logs")
|
||||
run = join(root, stamp())
|
||||
mkdirSync(run, { recursive: true })
|
||||
}
|
||||
|
||||
function stamp() {
|
||||
@@ -183,27 +120,22 @@ function safeLogName(name: string) {
|
||||
return name.replace(/[^a-z0-9_.-]/gi, "_") || "main"
|
||||
}
|
||||
|
||||
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 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 manifest(path: Path.Path) {
|
||||
function manifest() {
|
||||
return {
|
||||
generated: new Date().toISOString(),
|
||||
version: VERSION,
|
||||
@@ -217,55 +149,49 @@ function manifest(path: Path.Path) {
|
||||
logs: root,
|
||||
currentRun: run,
|
||||
crashDumps: app.getPath("crashDumps"),
|
||||
serverLogs: serverLogRoots(path),
|
||||
serverLogs: serverLogRoots(),
|
||||
netLog: netLogPath,
|
||||
}
|
||||
}
|
||||
|
||||
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")]),
|
||||
]
|
||||
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")])]
|
||||
}
|
||||
|
||||
type Entry = { name: string; path: string } | { name: string; data: Uint8Array }
|
||||
type Entry = { name: string; path?: string; data?: Buffer }
|
||||
|
||||
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)
|
||||
})
|
||||
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 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())))
|
||||
})
|
||||
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 initConsoleTransport() {
|
||||
@@ -288,7 +214,3 @@ 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,10 +1,9 @@
|
||||
import { BrowserWindow } from "electron"
|
||||
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
||||
import { updateTitlebar } from "../windows"
|
||||
import { createMainWindow, updateTitlebar } from "../windows"
|
||||
|
||||
export type DesktopMenuActionHandlers = Partial<{
|
||||
checkForUpdates: () => void
|
||||
createWindow: () => void
|
||||
relaunch: () => void
|
||||
}>
|
||||
|
||||
@@ -21,7 +20,7 @@ export function runDesktopMenuAction(
|
||||
handlers.relaunch?.()
|
||||
return
|
||||
case "window.new":
|
||||
handlers.createWindow?.()
|
||||
createMainWindow()
|
||||
return
|
||||
case "window.close":
|
||||
win?.close()
|
||||
|
||||
@@ -6,18 +6,16 @@ import {
|
||||
type DesktopMenuEntry,
|
||||
type DesktopMenuRole,
|
||||
} from "@opencode-ai/app/desktop-menu"
|
||||
import { MenuCommandTriggered } from "../../shared/ipc-rpc/events"
|
||||
import { emitIpcEvent } from "../ipc-events"
|
||||
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -38,7 +36,7 @@ export function createMenu(deps: Deps) {
|
||||
}
|
||||
|
||||
export function sendMenuCommand(win: BrowserWindow, id: string) {
|
||||
emitIpcEvent(win.webContents, new MenuCommandTriggered({ id }))
|
||||
sendIpcEvent(win.webContents, Ipc.menu.command, id)
|
||||
}
|
||||
|
||||
function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOptions {
|
||||
@@ -60,13 +58,12 @@ 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 = () => deps.openExternal(href)
|
||||
item.click = () => openExternalURL(href)
|
||||
}
|
||||
|
||||
return item
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
export * as DesktopPaths from "./paths"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
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)
|
||||
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")
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
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 { DesktopPaths } from "../paths"
|
||||
import { developmentResourcesRoot } 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 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)
|
||||
export async function startBackgroundCli(logger: Logger) {
|
||||
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"
|
||||
@@ -32,29 +31,26 @@ const start = Effect.fn("BackgroundService.startInternal")(function* () {
|
||||
],
|
||||
binary: undefined,
|
||||
}
|
||||
: yield* resolveBundledCli(isolated)
|
||||
: await resolveBundledCli(isolated, logger)
|
||||
if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData")
|
||||
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 })),
|
||||
}),
|
||||
)
|
||||
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 }),
|
||||
})
|
||||
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"
|
||||
yield* Effect.logInfo("v2 CLI background service ready", {
|
||||
logger.log("v2 CLI background service ready", {
|
||||
username: service.auth.username,
|
||||
version: cli.version,
|
||||
...endpoint(url.origin),
|
||||
})
|
||||
if (isolated && cli.binary) yield* cleanCliStages(cli.binary)
|
||||
if (isolated && cli.binary) await cleanCliStages(cli.binary, logger)
|
||||
return {
|
||||
url: url.origin,
|
||||
username: service.auth.username,
|
||||
@@ -68,80 +64,73 @@ const start = Effect.fn("BackgroundService.startInternal")(function* () {
|
||||
output: process.env.OPENCODE_DESKTOP_WSL_CLI_OUTPUT,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const resolveBundledCli = Effect.fn("BackgroundService.resolveBundledCli")(function* (isolated: boolean) {
|
||||
const path = yield* Path.Path
|
||||
const paths = yield* DesktopPaths.resolve
|
||||
async function resolveBundledCli(isolated: boolean, logger: Logger) {
|
||||
const bundled = app.isPackaged
|
||||
? 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
|
||||
? 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
|
||||
return { version, binary, command: [binary] }
|
||||
})
|
||||
}
|
||||
|
||||
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 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 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 })
|
||||
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 })
|
||||
return destination
|
||||
}
|
||||
|
||||
const temp = destination + `.${process.pid}.tmp`
|
||||
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 })
|
||||
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 })
|
||||
return destination
|
||||
})
|
||||
}
|
||||
|
||||
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) => {
|
||||
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 output = error as { stdout?: string; stderr?: string }
|
||||
return Effect.logError("v2 CLI command failed", {
|
||||
logger.error("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,16 +1,4 @@
|
||||
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 }))
|
||||
import { Deferred, Effect } from "effect"
|
||||
|
||||
export function forwardInitializationFailure<A>(initialization: Deferred.Deferred<A, unknown>) {
|
||||
return <B, E, R>(effect: Effect.Effect<B, E, R>) =>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
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"
|
||||
|
||||
@@ -44,10 +42,9 @@ describe("shell env", () => {
|
||||
})
|
||||
|
||||
test("isNushell handles path and binary name", () => {
|
||||
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)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { userInfo } from "node:os"
|
||||
import { Effect, Path } from "effect"
|
||||
import { basename } from "node:path"
|
||||
|
||||
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"
|
||||
@@ -29,7 +33,7 @@ export function parseShellEnv(out: Buffer) {
|
||||
return env
|
||||
}
|
||||
|
||||
const probe = Effect.fn("ShellEnv.probe")(function* (shell: string, mode: "-il" | "-l") {
|
||||
function probe(shell: string, mode: "-il" | "-l"): Probe {
|
||||
const out = spawnSync(shell, [mode, "-c", "env -0"], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: TIMEOUT,
|
||||
@@ -38,57 +42,56 @@ const probe = Effect.fn("ShellEnv.probe")(function* (shell: string, mode: "-il"
|
||||
|
||||
const err = out.error as NodeJS.ErrnoException | undefined
|
||||
if (err) {
|
||||
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 (err.code === "ETIMEDOUT") return { type: "Timeout" }
|
||||
console.log(`[server] Shell env probe failed for ${shell} ${mode}: ${err.message}`)
|
||||
return { type: "Unavailable" }
|
||||
}
|
||||
|
||||
if (out.status !== 0) {
|
||||
yield* Effect.logWarning(`[server] Shell env probe exited with non-zero status for ${shell} ${mode}`)
|
||||
return { type: "Unavailable" } satisfies Probe
|
||||
console.log(`[server] Shell env probe exited with non-zero status for ${shell} ${mode}`)
|
||||
return { type: "Unavailable" }
|
||||
}
|
||||
|
||||
const env = parseShellEnv(out.stdout)
|
||||
if (Object.keys(env).length === 0) {
|
||||
yield* Effect.logWarning(`[server] Shell env probe returned empty env for ${shell} ${mode}`)
|
||||
return { type: "Unavailable" } satisfies Probe
|
||||
console.log(`[server] Shell env probe returned empty env for ${shell} ${mode}`)
|
||||
return { type: "Unavailable" }
|
||||
}
|
||||
|
||||
return { type: "Loaded", value: env } satisfies Probe
|
||||
})
|
||||
return { type: "Loaded", value: env }
|
||||
}
|
||||
|
||||
export const isNushell = Effect.fn("ShellEnv.isNushell")(function* (shell: string) {
|
||||
const path = yield* Path.Path
|
||||
const name = path.basename(shell).toLowerCase()
|
||||
export function isNushell(shell: string) {
|
||||
const name = basename(shell).toLowerCase()
|
||||
const raw = shell.toLowerCase()
|
||||
return name === "nu" || name === "nu.exe" || raw.endsWith("\\nu.exe")
|
||||
})
|
||||
}
|
||||
|
||||
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}`)
|
||||
export function loadShellEnv(shell: string, logger: ShellEnvLogger) {
|
||||
if (isNushell(shell)) {
|
||||
logger.log(`[server] Skipping shell env probe for nushell: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const interactive = yield* probe(shell, "-il")
|
||||
const interactive = probe(shell, "-il")
|
||||
if (interactive.type === "Loaded") {
|
||||
yield* Effect.logInfo(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
|
||||
logger.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
|
||||
return interactive.value
|
||||
}
|
||||
if (interactive.type === "Timeout") {
|
||||
yield* Effect.logInfo(`[server] Interactive shell env probe timed out: ${shell}`)
|
||||
logger.log(`[server] Interactive shell env probe timed out: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const login = yield* probe(shell, "-l")
|
||||
const login = probe(shell, "-l")
|
||||
if (login.type === "Loaded") {
|
||||
yield* Effect.logInfo(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
|
||||
logger.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
|
||||
return login.value
|
||||
}
|
||||
|
||||
yield* Effect.logInfo(`[server] Falling back to app environment: ${shell}`)
|
||||
logger.log(`[server] Falling back to app environment: ${shell}`)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
export function mergeShellEnv(shell: Record<string, string> | null, env: Record<string, string>) {
|
||||
return {
|
||||
|
||||
@@ -1,140 +1,93 @@
|
||||
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 { Effect, FileSystem, Layer, Path } from "effect"
|
||||
import { join } from "node:path"
|
||||
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)))
|
||||
|
||||
const tempRoot = Effect.fn("StorageTest.tempRoot")(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const root = yield* fs.makeTempDirectory({ directory: tmpdir(), prefix: "opencode-store-cleanup-" })
|
||||
async function tempRoot() {
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-store-cleanup-"))
|
||||
roots.push(root)
|
||||
return root
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
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(() =>
|
||||
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,
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe("store cleanup", () => {
|
||||
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)
|
||||
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)
|
||||
|
||||
const result = yield* cleanupStoreFiles(root, now.getTime())
|
||||
const result = await cleanupStoreFiles(root, now.getTime())
|
||||
|
||||
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",
|
||||
])
|
||||
}),
|
||||
),
|
||||
)
|
||||
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"])
|
||||
})
|
||||
|
||||
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(
|
||||
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(
|
||||
root,
|
||||
"opencode.draft.old.dat",
|
||||
`opencode.draft.${index}.dat`,
|
||||
'{"draft:prompt":"hello"}',
|
||||
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)
|
||||
new Date(now.getTime() - index * 1000),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const result = yield* cleanupStoreFiles(root, now.getTime())
|
||||
const result = await cleanupStoreFiles(root, now.getTime())
|
||||
|
||||
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",
|
||||
])
|
||||
}),
|
||||
),
|
||||
)
|
||||
const remaining = await readdir(root)
|
||||
|
||||
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 },
|
||||
)
|
||||
expect(result.deleted.sort()).toEqual(["opencode.draft.100.dat", "opencode.draft.101.dat"])
|
||||
expect(remaining).toHaveLength(100)
|
||||
})
|
||||
|
||||
const result = yield* cleanupStoreFiles(root, now.getTime())
|
||||
const remaining = yield* fs.readDirectory(root)
|
||||
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"))
|
||||
|
||||
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"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
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"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Effect, FileSystem, Option, Path } from "effect"
|
||||
import { readdir, readFile, rm, stat } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
|
||||
const EMPTY_STORE_MAX_BYTES = 128
|
||||
const DRAFT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
|
||||
@@ -13,33 +14,30 @@ type StoreCandidate = {
|
||||
empty: boolean
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
const file = path.join(userDataPath, entry)
|
||||
const stats = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (stats?.type !== "File") return
|
||||
const file = join(userDataPath, entry.name)
|
||||
const stats = await stat(file).catch(() => undefined)
|
||||
if (!stats?.isFile()) return
|
||||
|
||||
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)
|
||||
return {
|
||||
name: entry.name,
|
||||
path: file,
|
||||
kind,
|
||||
modified: stats.mtimeMs,
|
||||
empty: await isEmptyStore(file, stats.size),
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter((candidate) => !!candidate)
|
||||
|
||||
const stale = new Set<StoreCandidate>()
|
||||
for (const candidate of candidates) {
|
||||
@@ -53,45 +51,37 @@ export const cleanupStoreFiles = Effect.fn("Storage.cleanupStoreFiles")(function
|
||||
.slice(DRAFT_KEEP_RECENT)
|
||||
.forEach((candidate) => stale.add(candidate))
|
||||
|
||||
const deleted = yield* Effect.forEach(
|
||||
stale,
|
||||
Effect.fnUntraced(function* (candidate) {
|
||||
yield* fs.remove(candidate.path, { force: true })
|
||||
const deleted = await Promise.all(
|
||||
[...stale].map(async (candidate) => {
|
||||
await rm(candidate.path, { force: true })
|
||||
return candidate.name
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
return { scanned: candidates.length, deleted }
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteStoreFileIfEmpty = Effect.fn("Storage.deleteStoreFileIfEmpty")(function* (
|
||||
userDataPath: string,
|
||||
name: string,
|
||||
) {
|
||||
export async function deleteStoreFileIfEmpty(userDataPath: string, name: string) {
|
||||
if (!storeKind(name)) 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
|
||||
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
|
||||
|
||||
yield* fs.remove(file, { force: true })
|
||||
await rm(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"
|
||||
}
|
||||
|
||||
const isEmptyStore = Effect.fn("Storage.isEmptyStore")(function* (file: string, size: FileSystem.Size) {
|
||||
if (size > FileSystem.Size(EMPTY_STORE_MAX_BYTES)) return false
|
||||
async function isEmptyStore(file: string, size: number) {
|
||||
if (size > EMPTY_STORE_MAX_BYTES) return false
|
||||
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const raw = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const raw = await readFile(file, "utf8").catch(() => undefined)
|
||||
if (raw === undefined) return false
|
||||
if (raw.trim() === "") return true
|
||||
|
||||
@@ -101,4 +91,4 @@ const isEmptyStore = Effect.fn("Storage.isEmptyStore")(function* (file: string,
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,46 +1,13 @@
|
||||
export * as DesktopStorage from "./index"
|
||||
|
||||
import { app, BrowserWindow } from "electron"
|
||||
import { Context, Effect, Layer, Path } from "effect"
|
||||
import { join } from "node:path"
|
||||
import { app } from "electron"
|
||||
import { createDesktopDraftStore } from "./drafts"
|
||||
import { getStore, removeStoreFileIfEmpty } from "./store"
|
||||
|
||||
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)
|
||||
})
|
||||
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()))
|
||||
|
||||
return {
|
||||
get(name: string, key: string) {
|
||||
@@ -53,8 +20,14 @@ function make(draftFile: string) {
|
||||
}
|
||||
},
|
||||
set: (name: string, key: string, value: string) => getStore(name).set(key, value),
|
||||
deleteValue,
|
||||
clear,
|
||||
deleteValue(name: string, key: string) {
|
||||
getStore(name).delete(key)
|
||||
void removeStoreFileIfEmpty(name)
|
||||
},
|
||||
clear(name: string) {
|
||||
getStore(name).clear()
|
||||
void removeStoreFileIfEmpty(name)
|
||||
},
|
||||
keys: (name: string) => Object.keys(getStore(name).store),
|
||||
length: (name: string) => Object.keys(getStore(name).store).length,
|
||||
drafts: {
|
||||
@@ -65,8 +38,6 @@ function make(draftFile: string) {
|
||||
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, directory: false })
|
||||
const directory = (name: string) => ({ name, directory: true })
|
||||
const file = (name: string) => ({ name, isDirectory: () => false })
|
||||
const directory = (name: string) => ({ name, isDirectory: () => true })
|
||||
|
||||
describe("hasExistingAppState", () => {
|
||||
test("ignores files Electron may create on a fresh install", () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export function hasExistingAppState(entries: Array<{ name: string; directory: boolean }>) {
|
||||
export function hasExistingAppState(entries: Array<{ name: string; isDirectory: () => 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.directory && entry.name === "opencode"
|
||||
return entry.isDirectory() && entry.name === "opencode"
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Store from "electron-store"
|
||||
import electron from "electron"
|
||||
import { Effect } from "effect"
|
||||
import { rmSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
import { deleteStoreFileIfEmpty } from "./cleanup"
|
||||
import { SETTINGS_STORE } from "./keys"
|
||||
@@ -24,10 +25,11 @@ export function getStore(name = SETTINGS_STORE) {
|
||||
return next
|
||||
}
|
||||
|
||||
export const removeStoreFileIfEmpty = Effect.fn("DesktopStorage.removeStoreFileIfEmpty")(function* (name: string) {
|
||||
if (yield* deleteStoreFileIfEmpty(electron.app.getPath("userData"), name)) cache.delete(name)
|
||||
})
|
||||
export async function removeStoreFileIfEmpty(name: string) {
|
||||
if (await deleteStoreFileIfEmpty(electron.app.getPath("userData"), name)) cache.delete(name)
|
||||
}
|
||||
|
||||
export function forgetStore(name: string) {
|
||||
export function removeStoreFile(name: string) {
|
||||
rmSync(join(electron.app.getPath("userData"), name), { force: true })
|
||||
cache.delete(name)
|
||||
}
|
||||
|
||||
@@ -1,134 +1,99 @@
|
||||
export * as Updater from "./index"
|
||||
|
||||
import { app, dialog } from "electron"
|
||||
import type { WebContents } from "electron"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import type { UpdaterState } from "@opencode-ai/app/updater"
|
||||
import { UpdaterStateChanged } from "../../shared/ipc-rpc/events"
|
||||
import { emitIpcEvent } from "../ipc-events"
|
||||
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
|
||||
import { UPDATER_ENABLED } from "../constants"
|
||||
import { ApplicationLifecycle } from "../lifecycle"
|
||||
import { getLogger } from "../native/logging"
|
||||
import { nativeT } from "../native/translations"
|
||||
import { getStore } from "../storage/store"
|
||||
import { createUpdaterController, type UpdaterReadyRecord } from "./controller"
|
||||
import { createUpdaterController, type UpdaterController, type UpdaterReadyRecord } from "./controller"
|
||||
|
||||
const key = "ready"
|
||||
|
||||
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 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
|
||||
},
|
||||
set: (value) => store.set(key, value),
|
||||
clear: () => store.delete(key),
|
||||
},
|
||||
log: (message, data) => logger.log(message, data),
|
||||
})
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/Updater") {}
|
||||
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 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),
|
||||
},
|
||||
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()
|
||||
}),
|
||||
)
|
||||
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 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))
|
||||
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)
|
||||
}),
|
||||
unsubscribe: (id) => Effect.sync(() => unsubscribe(id)),
|
||||
check: promise(() => controller.check()),
|
||||
install: promise(() => controller.install()),
|
||||
show: show(controller),
|
||||
})
|
||||
}),
|
||||
)
|
||||
)
|
||||
sender.once("destroyed", () => unsubscribe(id))
|
||||
},
|
||||
unsubscribe,
|
||||
check: () => controller.check(),
|
||||
install: () => controller.install(),
|
||||
}
|
||||
}
|
||||
|
||||
const show = Effect.fn("Updater.show")(function* (controller: ReturnType<typeof createUpdaterController>) {
|
||||
const state = yield* promise(() => controller.check())
|
||||
export type UpdaterIpc = ReturnType<typeof createUpdaterIpc>
|
||||
|
||||
export async function showUpdaterDialog(controller: UpdaterController) {
|
||||
const state = await 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"),
|
||||
}),
|
||||
)
|
||||
await 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") {
|
||||
yield* promise(() =>
|
||||
dialog.showMessageBox({
|
||||
type: "info",
|
||||
message: nativeT("desktop.updater.dialog.upToDate.message"),
|
||||
title: nativeT("desktop.updater.dialog.upToDate.title"),
|
||||
}),
|
||||
)
|
||||
await 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 = 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)
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { app, autoUpdater } from "electron"
|
||||
import pkg from "electron-updater"
|
||||
import { Effect } from "effect"
|
||||
import { getLogger } from "../native/logging"
|
||||
import { setAppQuitting } from "../windows"
|
||||
import type { UpdaterPlatform } from "./controller"
|
||||
|
||||
const updateClient = pkg.autoUpdater
|
||||
const restartTimeout = 10_000
|
||||
|
||||
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)
|
||||
export function createUpdaterPlatform(logger: ReturnType<typeof getLogger>): UpdaterPlatform {
|
||||
configureUpdater(logger)
|
||||
autoUpdater.on("before-quit-for-update", () => setAppQuitting())
|
||||
|
||||
return {
|
||||
async checkForUpdate() {
|
||||
@@ -21,31 +18,23 @@ export function createUpdaterPlatform(
|
||||
return result.updateInfo.version
|
||||
},
|
||||
stageUpdate,
|
||||
installAndRestart: () => installAndRestart(runFork),
|
||||
dispose: () => autoUpdater.off("before-quit-for-update", beforeQuit),
|
||||
installAndRestart: () => installAndRestart(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)),
|
||||
}
|
||||
function configureUpdater(logger: ReturnType<typeof getLogger>) {
|
||||
updateClient.logger = logger
|
||||
updateClient.channel = "latest"
|
||||
updateClient.allowPrerelease = false
|
||||
updateClient.allowDowngrade = true
|
||||
updateClient.autoDownload = false
|
||||
updateClient.autoInstallOnAppQuit = process.platform === "darwin"
|
||||
runFork(
|
||||
Effect.logInfo("auto updater configured", {
|
||||
channel: updateClient.channel,
|
||||
allowPrerelease: updateClient.allowPrerelease,
|
||||
allowDowngrade: updateClient.allowDowngrade,
|
||||
currentVersion: app.getVersion(),
|
||||
}),
|
||||
)
|
||||
logger.log("auto updater configured", {
|
||||
channel: updateClient.channel,
|
||||
allowPrerelease: updateClient.allowPrerelease,
|
||||
allowDowngrade: updateClient.allowDowngrade,
|
||||
currentVersion: app.getVersion(),
|
||||
})
|
||||
}
|
||||
|
||||
function stageUpdate() {
|
||||
@@ -71,10 +60,10 @@ function stageUpdate() {
|
||||
})
|
||||
}
|
||||
|
||||
function installAndRestart(runFork: (effect: Effect.Effect<void>) => unknown) {
|
||||
function installAndRestart(logger: ReturnType<typeof getLogger>) {
|
||||
return new Promise<never>((_resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
runFork(Effect.logError("update restart did not start"))
|
||||
logger.error("update restart did not start")
|
||||
fail(new Error())
|
||||
}, restartTimeout)
|
||||
const started = () => {
|
||||
|
||||
@@ -2,11 +2,9 @@ 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 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 { join } from "node:path"
|
||||
import { Ipc, sendIpcEvent, type TitlebarTheme } from "../../shared/ipc-contract"
|
||||
import { developmentResourcesRoot, preloadPath } from "../paths"
|
||||
import { BACKGROUND_COLOR_KEY, PINCH_ZOOM_ENABLED_KEY } from "../storage/keys"
|
||||
import { getStore } from "../storage/store"
|
||||
|
||||
@@ -22,12 +20,12 @@ const maxZoomLevel = 10
|
||||
const minZoomLevel = 0.2
|
||||
let backgroundColor: string | undefined
|
||||
|
||||
export function windowAppearance(path: Path.Path, paths: DesktopPaths.Resolved) {
|
||||
export function windowAppearance() {
|
||||
const mode = tone()
|
||||
const storedBackground = getStore().get(BACKGROUND_COLOR_KEY)
|
||||
return {
|
||||
title: "OpenCode",
|
||||
icon: iconPath(path, paths),
|
||||
icon: iconPath(),
|
||||
backgroundColor:
|
||||
backgroundColor ?? (typeof storedBackground === "string" ? storedBackground : undefined) ?? oc2Background[mode],
|
||||
...(process.platform === "darwin"
|
||||
@@ -44,7 +42,7 @@ export function windowAppearance(path: Path.Path, paths: DesktopPaths.Resolved)
|
||||
}
|
||||
: {}),
|
||||
webPreferences: {
|
||||
preload: paths.preloadPath,
|
||||
preload: preloadPath,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
@@ -52,9 +50,9 @@ export function windowAppearance(path: Path.Path, paths: DesktopPaths.Resolved)
|
||||
}
|
||||
}
|
||||
|
||||
export function setDockIcon(path: Path.Path, paths: DesktopPaths.Resolved) {
|
||||
export function setDockIcon() {
|
||||
if (process.platform !== "darwin") return
|
||||
const icon = nativeImage.createFromPath(path.join(iconsDir(path, paths), "dock.png"))
|
||||
const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png"))
|
||||
if (!icon.isEmpty()) app.dock?.setIcon(icon)
|
||||
}
|
||||
|
||||
@@ -88,7 +86,7 @@ export function setPinchZoomEnabled(enabled: boolean) {
|
||||
getStore().set(PINCH_ZOOM_ENABLED_KEY, enabled)
|
||||
BrowserWindow.getAllWindows().forEach((win) => {
|
||||
pinchZoomEnabled.set(win, enabled)
|
||||
emitIpcEvent(win.webContents, new WindowPinchZoomChanged({ enabled }))
|
||||
sendIpcEvent(win.webContents, Ipc.window.pinchZoomEnabledChanged, enabled)
|
||||
if (!enabled && win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
|
||||
updateZoom(win)
|
||||
})
|
||||
@@ -117,20 +115,18 @@ export function wireZoom(win: BrowserWindow) {
|
||||
export function wireFullscreen(win: BrowserWindow) {
|
||||
const send = (fullscreen: boolean) => {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) return
|
||||
emitIpcEvent(win.webContents, new WindowFullscreenChanged({ fullscreen }))
|
||||
sendIpcEvent(win.webContents, Ipc.window.fullscreenChanged, fullscreen)
|
||||
}
|
||||
win.on("enter-full-screen", () => send(true))
|
||||
win.on("leave-full-screen", () => send(false))
|
||||
}
|
||||
|
||||
function iconsDir(path: Path.Path, paths: DesktopPaths.Resolved) {
|
||||
return app.isPackaged
|
||||
? path.join(process.resourcesPath, "icons")
|
||||
: path.join(paths.developmentResourcesRoot, "icons")
|
||||
function iconsDir() {
|
||||
return app.isPackaged ? join(process.resourcesPath, "icons") : join(developmentResourcesRoot, "icons")
|
||||
}
|
||||
|
||||
function iconPath(path: Path.Path, paths: DesktopPaths.Resolved) {
|
||||
return path.join(iconsDir(path, paths), `icon.${process.platform === "win32" ? "ico" : "png"}`)
|
||||
function iconPath() {
|
||||
return join(iconsDir(), `icon.${process.platform === "win32" ? "ico" : "png"}`)
|
||||
}
|
||||
|
||||
function tone() {
|
||||
@@ -152,5 +148,5 @@ function clampZoom(value: number) {
|
||||
|
||||
function updateZoom(win: BrowserWindow) {
|
||||
updateTitlebar(win)
|
||||
emitIpcEvent(win.webContents, new WindowZoomChanged({ factor: win.webContents.getZoomFactor() }))
|
||||
sendIpcEvent(win.webContents, Ipc.window.zoomFactorChanged, win.webContents.getZoomFactor())
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
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 { 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 { writeLog } from "../native/logging"
|
||||
import { removeStoreFile, getStore } from "../storage/store"
|
||||
import { WINDOW_IDS_KEY } from "../storage/keys"
|
||||
import {
|
||||
getBackgroundColor,
|
||||
@@ -30,6 +28,10 @@ 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()
|
||||
@@ -49,11 +51,7 @@ export {
|
||||
}
|
||||
|
||||
export function setRelaunchHandler(handler: () => void) {
|
||||
const previous = relaunchHandler
|
||||
relaunchHandler = handler
|
||||
return () => {
|
||||
if (relaunchHandler === handler) relaunchHandler = previous
|
||||
}
|
||||
}
|
||||
|
||||
export function setAppQuitting(quitting = true) {
|
||||
@@ -76,20 +74,12 @@ export function setWindowThemeReady(win: BrowserWindow) {
|
||||
themeReady.get(win)?.()
|
||||
}
|
||||
|
||||
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 restoreMainWindows(deps: Dependencies) {
|
||||
export function restoreMainWindows() {
|
||||
const ids = registry.persisted()
|
||||
return (ids.length ? ids : [randomUUID()]).map((id) => createMainWindow(deps, id))
|
||||
return (ids.length ? ids : [randomUUID()]).map((id) => createMainWindow(id))
|
||||
}
|
||||
|
||||
export function createMainWindow(deps: Dependencies, id: string = randomUUID()) {
|
||||
export function createMainWindow(id: string = randomUUID()) {
|
||||
const state = windowState({ file: windowStateFile(id), defaultWidth: 1280, defaultHeight: 800 })
|
||||
const win = new BrowserWindow({
|
||||
x: state.x,
|
||||
@@ -98,17 +88,16 @@ export function createMainWindow(deps: Dependencies, id: string = randomUUID())
|
||||
height: state.height,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
...windowAppearance(deps.path, deps.paths),
|
||||
...windowAppearance(),
|
||||
})
|
||||
|
||||
allowRendererPermissions(win)
|
||||
wireWindowRecovery(win, id, () => relaunchHandler(), deps.exportDebug, deps.runFork)
|
||||
wireNavigationPolicy(win, (url) => deps.runFork(openExternalURL(url)))
|
||||
wireWindowRecovery(win, id, () => relaunchHandler())
|
||||
wireNavigationPolicy(win)
|
||||
wireRendererHeaders(win)
|
||||
state.manage(win)
|
||||
registerWindow(deps, win, id)
|
||||
registerWindow(win, id)
|
||||
wireFullscreen(win)
|
||||
loadWindow(win, "index.html")
|
||||
wireZoom(win)
|
||||
let contentReady = false
|
||||
let appliedTheme = false
|
||||
@@ -117,7 +106,7 @@ export function createMainWindow(deps: Dependencies, id: string = randomUUID())
|
||||
if (!contentReady || !appliedTheme || revealed || win.isDestroyed()) return
|
||||
revealed = true
|
||||
win.show()
|
||||
deps.runFork(Effect.logInfo("main window visible", { window: id }))
|
||||
writeLog("window", "main window visible", { window: id })
|
||||
}
|
||||
const ready = () => {
|
||||
contentReady = true
|
||||
@@ -130,28 +119,17 @@ export function createMainWindow(deps: Dependencies, 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(deps: Dependencies, win: BrowserWindow, id: string) {
|
||||
function registerWindow(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", () => {
|
||||
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 }))),
|
||||
),
|
||||
)
|
||||
})
|
||||
win.on("closed", () => registry.closed(id))
|
||||
}
|
||||
|
||||
function windowStateFile(id: string) {
|
||||
|
||||
@@ -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 { Effect } from "effect"
|
||||
import type { Path } from "effect"
|
||||
import { scoped } from "../native/logging"
|
||||
import { writeLog } from "../native/logging"
|
||||
import { rendererRoot } from "../paths"
|
||||
|
||||
const rendererProtocol = "oc"
|
||||
const rendererHost = "renderer"
|
||||
@@ -22,24 +22,20 @@ protocol.registerSchemesAsPrivileged([
|
||||
},
|
||||
])
|
||||
|
||||
export function registerRendererProtocol(
|
||||
path: Path.Path,
|
||||
rendererRoot: string,
|
||||
runFork: (effect: Effect.Effect<void>) => unknown,
|
||||
) {
|
||||
export function registerRendererProtocol() {
|
||||
if (protocol.isProtocolHandled(rendererProtocol)) return
|
||||
|
||||
protocol.handle(rendererProtocol, async (request) => {
|
||||
const url = new URL(request.url)
|
||||
if (url.host !== rendererHost) {
|
||||
runFork(scoped("protocol", Effect.logWarning("rejected host", { url: request.url })))
|
||||
writeLog("protocol", "rejected host", { url: request.url }, "warn")
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
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 })))
|
||||
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")
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
@@ -47,21 +43,16 @@ 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) {
|
||||
runFork(
|
||||
scoped(
|
||||
"protocol",
|
||||
Effect.logError("fetch failed", {
|
||||
url: request.url,
|
||||
file,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
}),
|
||||
),
|
||||
writeLog(
|
||||
"protocol",
|
||||
"fetch failed",
|
||||
{ url: request.url, file, status: response.status, statusText: response.statusText },
|
||||
"error",
|
||||
)
|
||||
}
|
||||
return addDocumentPolicy(response, file)
|
||||
} catch (error) {
|
||||
runFork(scoped("protocol", Effect.logError("fetch error", { url: request.url, file, error })))
|
||||
writeLog("protocol", "fetch error", { url: request.url, file, error }, "error")
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
import { app, dialog } from "electron"
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { Effect } from "effect"
|
||||
import { scoped } from "../native/logging"
|
||||
import { exportDebugLogs, writeLog } 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,
|
||||
exportDebugLogs: () => Promise<string>,
|
||||
runFork: (effect: Effect.Effect<void>) => unknown,
|
||||
) {
|
||||
export function wireWindowRecovery(win: BrowserWindow, name: string, relaunch: () => void) {
|
||||
let showing = false
|
||||
const sampler = createUnresponsiveSampler(win, name, runFork)
|
||||
const sampler = createUnresponsiveSampler(win, name)
|
||||
|
||||
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) => runFork(Effect.logError("failed to export debug logs", { error })))
|
||||
await exportDebugLogs().catch((error) => writeLog("main", "failed to export debug logs", { error }, "error"))
|
||||
if (wait && sampling) sampler.start()
|
||||
return true
|
||||
}
|
||||
@@ -75,19 +68,11 @@ export function wireWindowRecovery(
|
||||
validatedURL: string,
|
||||
isMainFrame: boolean,
|
||||
) => {
|
||||
runFork(
|
||||
scoped(
|
||||
"window",
|
||||
Effect.logError("renderer load failed", {
|
||||
window: name,
|
||||
event,
|
||||
errorCode,
|
||||
errorDescription,
|
||||
validatedURL,
|
||||
currentURL: safeWindowURL(win),
|
||||
isMainFrame,
|
||||
}),
|
||||
),
|
||||
writeLog(
|
||||
"window",
|
||||
"renderer load failed",
|
||||
{ window: name, event, errorCode, errorDescription, validatedURL, currentURL: safeWindowURL(win), isMainFrame },
|
||||
"error",
|
||||
)
|
||||
if (!isMainFrame || errorCode === -3) return
|
||||
void show(
|
||||
@@ -110,12 +95,7 @@ export function wireWindowRecovery(
|
||||
})
|
||||
win.webContents.on("render-process-gone", (_event, details) => {
|
||||
sampler.stopAndFlush()
|
||||
runFork(
|
||||
scoped(
|
||||
"window",
|
||||
Effect.logError("renderer process gone", { window: name, currentURL: safeWindowURL(win), details }),
|
||||
),
|
||||
)
|
||||
writeLog("window", "renderer process gone", { window: name, currentURL: safeWindowURL(win), details }, "error")
|
||||
void show(
|
||||
nativeT("desktop.recovery.terminated"),
|
||||
nativeT("desktop.recovery.terminated.detail", {
|
||||
@@ -127,27 +107,20 @@ export function wireWindowRecovery(
|
||||
)
|
||||
})
|
||||
win.on("unresponsive", () => {
|
||||
runFork(
|
||||
scoped(
|
||||
"window",
|
||||
Effect.logError("renderer unresponsive", { window: name, currentURL: safeWindowURL(win) }),
|
||||
),
|
||||
)
|
||||
writeLog("window", "renderer unresponsive", { window: name, currentURL: safeWindowURL(win) }, "error")
|
||||
sampler.start()
|
||||
void show(nativeT("desktop.recovery.unresponsive"), nativeT("desktop.recovery.unresponsive.detail"), true)
|
||||
})
|
||||
win.on("responsive", () => {
|
||||
runFork(
|
||||
scoped("window", Effect.logError("renderer responsive", { window: name, currentURL: safeWindowURL(win) })),
|
||||
)
|
||||
writeLog("window", "renderer responsive", { window: name, currentURL: safeWindowURL(win) }, "error")
|
||||
sampler.stopAndFlush()
|
||||
})
|
||||
win.webContents.on("console-message", (_event, level, message, line, sourceId) => {
|
||||
if (message.toLowerCase().includes("terminal") || sourceId.toLowerCase().includes("terminal")) {
|
||||
runFork(scoped("pty", Effect.logInfo("console", { window: name, level, message, line, sourceId })))
|
||||
writeLog("pty", "console", { window: name, level, message, line, sourceId })
|
||||
}
|
||||
})
|
||||
win.webContents.on("preload-error", (_event, path, error) => {
|
||||
runFork(scoped("preload", Effect.logError("preload error", { window: name, preloadPath: path, error })))
|
||||
writeLog("preload", "preload error", { window: name, preloadPath: path, error }, "error")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,13 +3,15 @@ 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 }
|
||||
return { registry, state, cleaned }
|
||||
}
|
||||
|
||||
describe("window registry", () => {
|
||||
@@ -31,21 +33,24 @@ describe("window registry", () => {
|
||||
const app = setup()
|
||||
app.registry.register("a", { name: "a" })
|
||||
app.registry.register("b", { name: "b" })
|
||||
expect(app.registry.closed("a")).toBe(true)
|
||||
app.registry.closed("a")
|
||||
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" })
|
||||
expect(app.registry.closed("a")).toBe(false)
|
||||
app.registry.closed("a")
|
||||
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"])
|
||||
})
|
||||
@@ -55,9 +60,10 @@ describe("window registry", () => {
|
||||
app.registry.register("a", { name: "a" })
|
||||
app.registry.register("b", { name: "b" })
|
||||
app.registry.setQuitting()
|
||||
expect(app.registry.closed("a")).toBe(false)
|
||||
expect(app.registry.closed("b")).toBe(false)
|
||||
app.registry.closed("a")
|
||||
app.registry.closed("b")
|
||||
expect(app.state.stored).toEqual(["a", "b"])
|
||||
expect(app.cleaned).toEqual([])
|
||||
})
|
||||
|
||||
test("tracks the last focused window and falls back on close", () => {
|
||||
@@ -78,7 +84,8 @@ describe("window registry", () => {
|
||||
app.registry.register("b", { name: "b" })
|
||||
app.registry.setQuitting()
|
||||
app.registry.setQuitting(false)
|
||||
expect(app.registry.closed("a")).toBe(true)
|
||||
app.registry.closed("a")
|
||||
expect(app.state.stored).toEqual(["b"])
|
||||
expect(app.cleaned).toEqual(["a"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
export function createWindowRegistry<W>(persistence: {
|
||||
read: () => unknown
|
||||
write: (ids: string[]) => void
|
||||
cleanup: (id: string) => void
|
||||
}) {
|
||||
const windows = new Map<string, W>()
|
||||
let quitting = false
|
||||
@@ -38,9 +39,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 false
|
||||
if (quitting || windows.size === 0) return
|
||||
persistence.write(persisted().filter((item) => item !== id))
|
||||
return true
|
||||
persistence.cleanup(id)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { openExternalURL } from "../files"
|
||||
import { addRendererHeaders, isRendererUrl, upsertHeader } from "./protocol"
|
||||
|
||||
const rendererPermissions = new Set(["clipboard-sanitized-write", "notifications"])
|
||||
@@ -17,7 +18,7 @@ export function allowRendererPermissions(win: BrowserWindow) {
|
||||
})
|
||||
}
|
||||
|
||||
export function wireNavigationPolicy(win: BrowserWindow, openExternalURL: (url: string) => unknown) {
|
||||
export function wireNavigationPolicy(win: BrowserWindow) {
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (!isRendererUrl(url)) openExternalURL(url)
|
||||
return { action: "deny" }
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { Effect } from "effect"
|
||||
import { scoped } from "../native/logging"
|
||||
import { writeLog } from "../native/logging"
|
||||
import { safeWindowURL } from "./state"
|
||||
|
||||
const sampleInterval = 1000
|
||||
const samplePeriod = 15000
|
||||
|
||||
export function createUnresponsiveSampler(
|
||||
win: BrowserWindow,
|
||||
name: string,
|
||||
runFork: (effect: Effect.Effect<void>) => unknown,
|
||||
) {
|
||||
export function createUnresponsiveSampler(win: BrowserWindow, name: string) {
|
||||
let sampleTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let stopTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let sampling = false
|
||||
@@ -33,7 +28,7 @@ export function createUnresponsiveSampler(
|
||||
const collect = async () => {
|
||||
if (!active()) return
|
||||
const stack = await win.webContents.mainFrame.collectJavaScriptCallStack().catch((error) => {
|
||||
runFork(scoped("window", Effect.logError("failed to collect unresponsive sample", { window: name, error })))
|
||||
writeLog("window", "failed to collect unresponsive sample", { window: name, error }, "error")
|
||||
return undefined
|
||||
})
|
||||
if (!active()) return
|
||||
@@ -56,7 +51,7 @@ export function createUnresponsiveSampler(
|
||||
...entries.map((entry) => `<${entry[1]}> ${entry[0]}`),
|
||||
`Total Samples: ${total}`,
|
||||
].join("\n")
|
||||
runFork(scoped("window", Effect.logError(message)))
|
||||
writeLog("window", message, undefined, "error")
|
||||
samples.clear()
|
||||
return wasSampling
|
||||
}
|
||||
|
||||
@@ -1,31 +1,27 @@
|
||||
export * as WslIpc from "./ipc"
|
||||
|
||||
import { app } from "electron"
|
||||
import type { WebContents } from "electron"
|
||||
import type { WslServerConfig, WslServersState } from "@opencode-ai/app/wsl/types"
|
||||
import { Effect } from "effect"
|
||||
import { WslServersChanged } from "../../shared/ipc-rpc/events"
|
||||
import { emitIpcEvent } from "../ipc-events"
|
||||
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
|
||||
import type { WslServersController } from "./servers"
|
||||
import { nativeT } from "../native/translations"
|
||||
|
||||
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 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 function create(controller?: WslServersController): Interface {
|
||||
export function createWslIpc(controller?: WslServersController): WslIpc {
|
||||
if (!controller) return createUnavailableWslIpc()
|
||||
|
||||
const subscriptions = new Map<number, () => void>()
|
||||
@@ -42,53 +38,45 @@ export function create(controller?: WslServersController): Interface {
|
||||
})
|
||||
|
||||
return {
|
||||
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))),
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
)
|
||||
function createUnavailableWslIpc(): WslIpc {
|
||||
const unavailable = () => {
|
||||
throw new Error(message)
|
||||
throw new Error(nativeT("desktop.wsl.error.windowsOnly"))
|
||||
}
|
||||
const state = (): WslServersState => ({
|
||||
runtime: {
|
||||
available: false,
|
||||
version: null,
|
||||
error: message,
|
||||
error: nativeT("desktop.wsl.error.windowsOnly"),
|
||||
},
|
||||
installed: [],
|
||||
online: [],
|
||||
@@ -100,20 +88,19 @@ function createUnavailableWslIpc(): Interface {
|
||||
})
|
||||
|
||||
return {
|
||||
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),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,48 +1,38 @@
|
||||
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 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,
|
||||
}),
|
||||
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 },
|
||||
)
|
||||
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)
|
||||
await copyFile(join(directory, `cli-${target}`, "bin", "opencode2"), input.output)
|
||||
return input.output
|
||||
})
|
||||
return yield* build.pipe(Effect.ensuring(fs.remove(directory, { recursive: true, force: true }).pipe(Effect.orDie)))
|
||||
})
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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"
|
||||
|
||||
@@ -260,35 +261,25 @@ export async function installWslRuntimeElevated(opts?: RunWslOptions) {
|
||||
requireSuccess(result, nativeT("desktop.wsl.error.installWsl"))
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
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,
|
||||
)
|
||||
requireSuccess(result, nativeT("desktop.wsl.error.installDistro", { distro }))
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
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,
|
||||
)
|
||||
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 --"
|
||||
@@ -416,14 +407,12 @@ export function shellEscape(value: string) {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`
|
||||
}
|
||||
|
||||
const resolveSystem32Command = Effect.fn("Wsl.resolveSystem32Command")(function* (command: string) {
|
||||
function resolveSystem32Command(command: string) {
|
||||
const root = process.env.SystemRoot ?? process.env.windir
|
||||
if (!root) return 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
|
||||
})
|
||||
const resolved = join(root, "System32", command)
|
||||
return existsSync(resolved) ? resolved : command
|
||||
}
|
||||
|
||||
function withTimeout(opts: RunWslOptions | undefined, timeoutMs: number): RunWslOptions {
|
||||
return {
|
||||
|
||||
@@ -77,33 +77,6 @@ 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[] = []
|
||||
@@ -175,8 +148,6 @@ 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,
|
||||
|
||||
@@ -12,6 +12,8 @@ import { nativeT } from "../native/translations"
|
||||
import { WSL_SERVERS_KEY } from "../storage/keys"
|
||||
import { getStore } from "../storage/store"
|
||||
import {
|
||||
installWslCli,
|
||||
installWslDistro,
|
||||
installWslRuntimeElevated,
|
||||
listInstalledWslDistros,
|
||||
listOnlineWslDistros,
|
||||
@@ -41,11 +43,10 @@ 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
|
||||
@@ -61,8 +62,6 @@ 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
|
||||
@@ -160,18 +159,10 @@ 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",
|
||||
@@ -189,8 +180,6 @@ 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 })
|
||||
@@ -198,7 +187,6 @@ export function createWslServersController(options: WslServersControllerOptions)
|
||||
}
|
||||
|
||||
const stopServer = async (id: string) => {
|
||||
starts.delete(id)
|
||||
const existing = sidecars.get(id)
|
||||
if (!existing) return
|
||||
sidecars.delete(id)
|
||||
@@ -225,7 +213,6 @@ export function createWslServersController(options: WslServersControllerOptions)
|
||||
},
|
||||
|
||||
startConfiguredServers() {
|
||||
closed = false
|
||||
refreshFromStore()
|
||||
void refreshCliChecks()
|
||||
state.servers.forEach((item) => void startServer(item.config.id))
|
||||
@@ -257,7 +244,7 @@ export function createWslServersController(options: WslServersControllerOptions)
|
||||
|
||||
async installDistro(distro: string) {
|
||||
await runJob({ kind: "install-distro", distro, startedAt: Date.now() }, async () => {
|
||||
await options.installDistro(distro)
|
||||
await installWslDistro(distro)
|
||||
const distros = await refreshDistroLists()
|
||||
const probe = await probeDistro(distro)
|
||||
setState({
|
||||
@@ -276,7 +263,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(distro, options.cli)
|
||||
await (options.installCli ?? installWslCli)(distro, options.cli)
|
||||
requireMatchingCli(await refreshCliCheck(distro), options.cli.version)
|
||||
if (id) await startServer(id)
|
||||
})
|
||||
@@ -317,8 +304,6 @@ export function createWslServersController(options: WslServersControllerOptions)
|
||||
startServer,
|
||||
|
||||
async stopServers() {
|
||||
closed = true
|
||||
starts.clear()
|
||||
await Promise.all([...sidecars.values()].map((sidecar) => sidecar.stop()))
|
||||
sidecars.clear()
|
||||
},
|
||||
|
||||
@@ -1,70 +1,47 @@
|
||||
export * as Wsl from "./start"
|
||||
import { createWslIpc } from "./ipc"
|
||||
|
||||
import { Context, Effect, FileSystem, Layer, Path } from "effect"
|
||||
import { Shutdown } from "../lifecycle/shutdown"
|
||||
import { WslIpc } from "./ipc"
|
||||
|
||||
export type Cli = {
|
||||
type Cli = {
|
||||
version: string
|
||||
wslBuild?: { script: string; output: string }
|
||||
}
|
||||
|
||||
export interface Interface extends WslIpc.Interface {
|
||||
readonly stop: Effect.Effect<void>
|
||||
type Logger = {
|
||||
log(message: string, meta?: unknown): void
|
||||
error(message: string, meta?: unknown): void
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/Wsl") {}
|
||||
export async function startWsl(cli: Cli, logger: Logger) {
|
||||
if (process.platform !== "win32") return { ipc: createWslIpc(), start: () => {}, stop: async () => {} }
|
||||
|
||||
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 { createWslServersController } = await import("./servers")
|
||||
const { spawnWslSidecar } = await import("./sidecar")
|
||||
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")
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const binary = yield* buildLocalWslCli({ ...local, version: cli.version })
|
||||
yield* installWslCli(distro, { version: cli.version, binary })
|
||||
}),
|
||||
)
|
||||
const { installWslCli } = await import("./runtime")
|
||||
await installWslCli(distro, {
|
||||
version: cli.version,
|
||||
binary: await buildLocalWslCli({ ...local, version: cli.version }),
|
||||
})
|
||||
}
|
||||
: (distro, build) => run(installWslCli(distro, build)),
|
||||
spawnSidecar: (distro) => {
|
||||
runFork(Effect.logInfo("spawning wsl sidecar", { distro }))
|
||||
: undefined,
|
||||
spawnSidecar: async (distro) => {
|
||||
logger.log("spawning wsl sidecar", { distro })
|
||||
return spawnWslSidecar(distro, {
|
||||
onLine: (line) => runFork(Effect.logInfo("wsl sidecar", { distro, stream: line.stream, text: line.text })),
|
||||
onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
|
||||
})
|
||||
},
|
||||
logger: {
|
||||
log: (message, meta) => runFork(Effect.logInfo(message, meta)),
|
||||
error: (message, meta) => runFork(Effect.logError(message, meta)),
|
||||
log: (message, meta) => logger.log(message, meta),
|
||||
error: (message, meta) => logger.error(message, meta),
|
||||
},
|
||||
})
|
||||
controller.startConfiguredServers()
|
||||
return {
|
||||
...WslIpc.create(controller),
|
||||
stop: Effect.tryPromise(() => controller.stopServers()).pipe(Effect.orDie),
|
||||
} satisfies Interface
|
||||
})
|
||||
ipc: createWslIpc(controller),
|
||||
start: () => controller.startConfiguredServers(),
|
||||
stop: () => controller.stopServers(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,146 @@
|
||||
import { contextBridge, ipcRenderer, webUtils } from "electron"
|
||||
import { IpcTransportPort } from "../shared/ipc-transport"
|
||||
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"
|
||||
|
||||
ipcRenderer.on(IpcTransportPort, (event) => {
|
||||
const port = event.ports[0]
|
||||
if (port) window.postMessage(IpcTransportPort, "*", [port])
|
||||
})
|
||||
function invoke<Channel extends keyof IpcInvoke>(channel: Channel, ...args: IpcInvokeArgs<Channel>) {
|
||||
return ipcRenderer.invoke(channel, ...args) as Promise<IpcInvokeResult<Channel>>
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electron", {
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
||||
})
|
||||
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)
|
||||
|
||||
@@ -1,3 +1,75 @@
|
||||
export type ElectronNative = {
|
||||
getPathForFile(file: File): string
|
||||
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>
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
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>
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
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,6 +5,7 @@ import {
|
||||
AppBaseProviders,
|
||||
AppInterface,
|
||||
PlatformProvider,
|
||||
preloadRoute,
|
||||
ServerConnection,
|
||||
useCommand,
|
||||
useLanguage,
|
||||
@@ -14,7 +15,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 "./api-types"
|
||||
import type { ElectronAPI } from "../preload/types"
|
||||
import { DesktopFirstLaunchOnboarding } from "./onboarding"
|
||||
import { createDesktopPlatform, type DesktopWindowState } from "./platform"
|
||||
import { bindDesktopMenu } from "./platform/menu"
|
||||
@@ -38,9 +39,11 @@ 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} />
|
||||
)
|
||||
@@ -48,9 +51,7 @@ 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 && !wslServers.isLoading,
|
||||
)
|
||||
const ready = createMemo(() => !defaultServer.loading && !sidecar.loading && !locale.loading && !route.loading)
|
||||
const servers = createMemo(() => {
|
||||
const data = initializationData(sidecar)
|
||||
const list: ServerConnection.Any[] = []
|
||||
@@ -80,7 +81,7 @@ function DesktopWindow(props: { api: ElectronAPI; updater: UpdaterPlatform; wind
|
||||
<AppInterface defaultServer={key} servers={servers()} router={router}>
|
||||
<DesktopFirstLaunchOnboarding
|
||||
api={props.api}
|
||||
initialUrl={getLastActiveUrl(props.windowState.id)}
|
||||
initialUrl={initialUrl}
|
||||
serverKey={key}
|
||||
/>
|
||||
<DesktopEffects api={props.api} />
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import type { ElectronNative } from "../preload/types"
|
||||
import type { ElectronAPI } from "../preload/types"
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronNative
|
||||
api: ElectronAPI
|
||||
__OPENCODE__?: {
|
||||
deepLinks?: string[]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
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"
|
||||
@@ -15,8 +14,8 @@ const root = requireRendererRoot()
|
||||
const version = desktopVersion()
|
||||
await initializeSentry(version)
|
||||
|
||||
const updater = startDesktopUpdater(api)
|
||||
startDesktopMenu(api)
|
||||
startDeepLinks(api)
|
||||
const updater = startDesktopUpdater(window.api)
|
||||
startDesktopMenu(window.api)
|
||||
startDeepLinks(window.api)
|
||||
|
||||
render(() => <DesktopApp api={api} updater={updater} version={version} />, root)
|
||||
render(() => <DesktopApp api={window.api} updater={updater} version={version} />, root)
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
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 "../api-types"
|
||||
import type { ElectronAPI } from "../../preload/types"
|
||||
|
||||
export function DesktopFirstLaunchOnboarding(props: {
|
||||
api: ElectronAPI
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Platform } from "@opencode-ai/app"
|
||||
import type { ElectronAPI } from "../api-types"
|
||||
import type { ElectronAPI } from "../../preload/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 "../api-types"
|
||||
import type { ElectronAPI } from "../../preload/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 "../api-types"
|
||||
import type { ElectronAPI } from "../../preload/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 "../api-types"
|
||||
import type { ElectronAPI } from "../../preload/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 "../api-types"
|
||||
import type { ElectronAPI } from "../../preload/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 "../api-types"
|
||||
import type { ElectronAPI } from "../../preload/types"
|
||||
|
||||
export function startDesktopUpdater(api: ElectronAPI): UpdaterPlatform {
|
||||
const [state, setState] = createSignal<UpdaterState>({ status: "disabled" })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ElectronAPI } from "../api-types"
|
||||
import type { ElectronAPI } from "../../preload/types"
|
||||
|
||||
const deepLinkEvent = "opencode:deep-link"
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Ipc } from "../../shared/ipc-contract"
|
||||
import { initializationData, initializationReady } from "./initialization"
|
||||
|
||||
describe("desktop renderer initialization", () => {
|
||||
@@ -14,8 +15,10 @@ describe("desktop renderer initialization", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves clean RPC startup errors", () => {
|
||||
const error = new Error("Cannot migrate session_message projections")
|
||||
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`,
|
||||
)
|
||||
|
||||
try {
|
||||
initializationData(Object.assign(() => undefined, { error }))
|
||||
|
||||
@@ -5,6 +5,12 @@ 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
|
||||
}
|
||||
@@ -14,3 +20,4 @@ export function initializationReady<A>(state: (() => A | undefined) & { error: u
|
||||
initializationData(state)
|
||||
return true
|
||||
}
|
||||
import { Ipc } from "../../shared/ipc-contract"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { api } from "../api"
|
||||
|
||||
const [windowFullscreen, setWindowFullscreen] = createSignal(false)
|
||||
|
||||
api.onWindowFullscreenChanged(setWindowFullscreen)
|
||||
void api.getWindowFullscreen().then(setWindowFullscreen)
|
||||
window.api.onWindowFullscreenChanged(setWindowFullscreen)
|
||||
void window.api.getWindowFullscreen().then(setWindowFullscreen)
|
||||
|
||||
export { windowFullscreen }
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import { api } from "../api"
|
||||
|
||||
const OS_NAME = (() => {
|
||||
if (navigator.userAgent.includes("Mac")) return "macos"
|
||||
@@ -34,7 +33,7 @@ const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_Z
|
||||
|
||||
const applyZoom = (next: number) => {
|
||||
requestedZoom = next
|
||||
void api
|
||||
void window.api
|
||||
.setZoomFactor(next)
|
||||
.then(() => {
|
||||
if (requestedZoom !== next) return
|
||||
@@ -46,16 +45,16 @@ const applyZoom = (next: number) => {
|
||||
})
|
||||
}
|
||||
|
||||
api.onZoomFactorChanged((factor) => {
|
||||
window.api.onZoomFactorChanged((factor) => {
|
||||
requestedZoom = clamp(factor)
|
||||
setWebviewZoom(requestedZoom)
|
||||
})
|
||||
|
||||
void api.getPinchZoomEnabled().then((enabled) => {
|
||||
void window.api.getPinchZoomEnabled().then((enabled) => {
|
||||
pinchZoomEnabled = enabled
|
||||
})
|
||||
|
||||
api.onPinchZoomEnabledChanged((enabled) => {
|
||||
window.api.onPinchZoomEnabledChanged((enabled) => {
|
||||
pinchZoomEnabled = enabled
|
||||
resetWheelPinch()
|
||||
})
|
||||
@@ -63,7 +62,7 @@ api.onPinchZoomEnabledChanged((enabled) => {
|
||||
const setPinchZoomEnabled = (enabled: boolean) => {
|
||||
pinchZoomEnabled = enabled
|
||||
resetWheelPinch()
|
||||
return api.setPinchZoomEnabled(enabled)
|
||||
return window.api.setPinchZoomEnabled(enabled)
|
||||
}
|
||||
|
||||
const resetZoom = () => applyZoom(1)
|
||||
|
||||
@@ -1,3 +1,99 @@
|
||||
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
|
||||
@@ -42,3 +138,114 @@ 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)
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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>
|
||||
@@ -1,72 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@@ -1,46 +0,0 @@
|
||||
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)
|
||||
@@ -1,71 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@@ -1,29 +0,0 @@
|
||||
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)
|
||||
@@ -1,52 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@@ -1,19 +0,0 @@
|
||||
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)
|
||||
@@ -1,40 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@@ -1,109 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
export const IpcTransportPort = "desktop-rpc-port"
|
||||
@@ -59,11 +59,6 @@
|
||||
"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",
|
||||
|
||||
@@ -96,7 +96,6 @@ 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"
|
||||
@@ -266,7 +265,6 @@ 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 () => {
|
||||
@@ -450,7 +448,6 @@ 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")
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
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 +0,0 @@
|
||||
export { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "#terminal-win32"
|
||||
Reference in New Issue
Block a user