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