mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-05-03 07:11:31 +08:00
Compare commits
1 Commits
kit/cli-ef
...
brendan/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46cd9c09cd |
@@ -1,12 +1,11 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { EventEmitter } from "node:events"
|
||||
import { existsSync } from "node:fs"
|
||||
import { createServer } from "node:net"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { Event } from "electron"
|
||||
import { app, BrowserWindow, dialog } from "electron"
|
||||
import pkg from "electron-updater"
|
||||
import { Data, Deferred, Effect, Fiber, Option, PubSub, Queue, Ref, Stream, SubscriptionRef } from "effect"
|
||||
|
||||
import contextMenu from "electron-context-menu"
|
||||
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
|
||||
@@ -34,14 +33,15 @@ app.setAppUserModelId(appId)
|
||||
app.setPath("userData", join(app.getPath("appData"), appId))
|
||||
const { autoUpdater } = pkg
|
||||
|
||||
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
|
||||
import { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
|
||||
import type { Server } from "virtual:opencode-server"
|
||||
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
|
||||
import { CHANNEL, UPDATER_ENABLED } from "./constants"
|
||||
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
|
||||
import { initLogging } from "./logging"
|
||||
import { parseMarkdown } from "./markdown"
|
||||
import { createMenu } from "./menu"
|
||||
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
|
||||
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServerEffect } from "./server"
|
||||
import {
|
||||
createLoadingWindow,
|
||||
createMainWindow,
|
||||
@@ -49,230 +49,207 @@ import {
|
||||
setBackgroundColor,
|
||||
setDockIcon,
|
||||
} from "./windows"
|
||||
import { drizzle } from "drizzle-orm/node-sqlite/driver"
|
||||
import type { Server } from "virtual:opencode-server"
|
||||
|
||||
const initEmitter = new EventEmitter()
|
||||
let initStep: InitStep = { phase: "server_waiting" }
|
||||
// ---------------------------------------------------------------------------
|
||||
// State — individual pieces, synchronously allocated at module load.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let server: Server.Listener | null = null
|
||||
const loadingComplete = defer<void>()
|
||||
const initStep = Effect.runSync(SubscriptionRef.make<InitStep>({ _tag: "ServerWaiting" }))
|
||||
const serverReady = Effect.runSync(Deferred.make<ServerReadyData>())
|
||||
const loadingComplete = Effect.runSync(Deferred.make<void>())
|
||||
const deepLinkQueue = Effect.runSync(Queue.unbounded<string[]>())
|
||||
const deepLinksConsumed = Effect.runSync(Deferred.make<void>())
|
||||
const server = Effect.runSync(Ref.make<Option.Option<Server.Listener>>(Option.none()))
|
||||
const menuCommands = Effect.runSync(PubSub.unbounded<string>())
|
||||
const sqliteProgress = Effect.runSync(PubSub.unbounded<SqliteMigrationProgress>())
|
||||
|
||||
const pendingDeepLinks: string[] = []
|
||||
// ---------------------------------------------------------------------------
|
||||
// App events (Data.TaggedEnum)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const serverReady = defer<ServerReadyData>()
|
||||
const logger = initLogging()
|
||||
type AppEvent = Data.TaggedEnum<{
|
||||
SecondInstance: { readonly argv: readonly string[] }
|
||||
OpenUrl: { readonly url: string }
|
||||
BeforeQuit: {}
|
||||
WillQuit: {}
|
||||
}>
|
||||
|
||||
logger.log("app starting", {
|
||||
version: app.getVersion(),
|
||||
packaged: app.isPackaged,
|
||||
})
|
||||
const appEvent = Data.taggedEnum<AppEvent>()
|
||||
|
||||
setupApp()
|
||||
const handleAppEvent = (
|
||||
event: AppEvent,
|
||||
deepLinkQueue: Queue.Queue<string[]>,
|
||||
mainWindow: BrowserWindow,
|
||||
server: Ref.Ref<Option.Option<Server.Listener>>,
|
||||
) =>
|
||||
appEvent.$match(event, {
|
||||
SecondInstance: ({ argv }) =>
|
||||
Effect.gen(function* () {
|
||||
const urls = argv.filter((arg) => arg.startsWith("opencode://"))
|
||||
if (urls.length) {
|
||||
logger.log("deep link received via second-instance", { urls })
|
||||
yield* Queue.offer(deepLinkQueue, urls)
|
||||
}
|
||||
focusMainWindow(mainWindow)
|
||||
}),
|
||||
OpenUrl: ({ url }) =>
|
||||
Effect.gen(function* () {
|
||||
logger.log("deep link received via open-url", { url })
|
||||
yield* Queue.offer(deepLinkQueue, [url])
|
||||
}),
|
||||
BeforeQuit: () => stopServer(server),
|
||||
WillQuit: () => stopServer(server),
|
||||
})
|
||||
|
||||
function setupApp() {
|
||||
ensureLoopbackNoProxy()
|
||||
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure state helpers (explicit parameters — no hidden closures)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
const focusMainWindow = (win: BrowserWindow) => {
|
||||
win.show()
|
||||
win.focus()
|
||||
}
|
||||
|
||||
app.on("second-instance", (_event: Event, argv: string[]) => {
|
||||
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
|
||||
if (urls.length) {
|
||||
logger.log("deep link received via second-instance", { urls })
|
||||
emitDeepLinks(urls)
|
||||
const stopServer = (ref: Ref.Ref<Option.Option<Server.Listener>>) =>
|
||||
Effect.gen(function* () {
|
||||
const srv = yield* Ref.get(ref)
|
||||
if (Option.isSome(srv)) {
|
||||
yield* Effect.promise(() => srv.value.stop())
|
||||
yield* Ref.set(ref, Option.none())
|
||||
}
|
||||
focusMainWindow()
|
||||
})
|
||||
|
||||
app.on("open-url", (event: Event, url: string) => {
|
||||
event.preventDefault()
|
||||
logger.log("deep link received via open-url", { url })
|
||||
emitDeepLinks([url])
|
||||
})
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialization flow (pure Effect — all state wired explicitly)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.on("before-quit", () => {
|
||||
killSidecar()
|
||||
})
|
||||
const initialize = Effect.fn("Main.initialize")(function* () {
|
||||
const needsMigration = !(yield* sqliteFileExists)
|
||||
const sqliteDone = needsMigration ? yield* Deferred.make<void>() : undefined
|
||||
|
||||
app.on("will-quit", () => {
|
||||
killSidecar()
|
||||
})
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(signal, () => {
|
||||
killSidecar()
|
||||
app.exit(0)
|
||||
})
|
||||
}
|
||||
|
||||
void app.whenReady().then(async () => {
|
||||
app.setAsDefaultProtocolClient("opencode")
|
||||
registerRendererProtocol()
|
||||
setDockIcon()
|
||||
setupAutoUpdater()
|
||||
await initialize()
|
||||
})
|
||||
}
|
||||
|
||||
function emitDeepLinks(urls: string[]) {
|
||||
if (urls.length === 0) return
|
||||
pendingDeepLinks.push(...urls)
|
||||
if (mainWindow) sendDeepLinks(mainWindow, urls)
|
||||
}
|
||||
|
||||
function focusMainWindow() {
|
||||
if (!mainWindow) return
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
|
||||
function setInitStep(step: InitStep) {
|
||||
initStep = step
|
||||
logger.log("init step", { step })
|
||||
initEmitter.emit("step", step)
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
const needsMigration = !sqliteFileExists()
|
||||
const sqliteDone = needsMigration ? defer<void>() : undefined
|
||||
let overlay: BrowserWindow | null = null
|
||||
|
||||
const port = await getSidecarPort()
|
||||
const port = yield* getSidecarPort
|
||||
const hostname = "127.0.0.1"
|
||||
const url = `http://${hostname}:${port}`
|
||||
const password = randomUUID()
|
||||
|
||||
const loadingTask = (async () => {
|
||||
const loadingFiber = yield* Effect.gen(function* () {
|
||||
logger.log("sidecar connection started", { url })
|
||||
|
||||
initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => {
|
||||
setInitStep({ phase: "sqlite_waiting" })
|
||||
if (overlay) sendSqliteMigrationProgress(overlay, progress)
|
||||
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
|
||||
if (progress.type === "Done") sqliteDone?.resolve()
|
||||
})
|
||||
if (needsMigration && sqliteDone) {
|
||||
yield* Effect.gen(function* () {
|
||||
const { Database, JsonMigration } = yield* Effect.promise(
|
||||
() => import("virtual:opencode-server") as Promise<typeof import("virtual:opencode-server")>,
|
||||
)
|
||||
const client = yield* Effect.sync(() => Database.Client().$client)
|
||||
const db = yield* Effect.promise(() =>
|
||||
import("drizzle-orm/node-sqlite/driver").then((m) => m.drizzle({ client })),
|
||||
)
|
||||
|
||||
if (needsMigration) {
|
||||
const { Database, JsonMigration } = await import("virtual:opencode-server")
|
||||
await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
|
||||
progress: (event: { current: number; total: number }) => {
|
||||
const percent = Math.round(event.current / event.total) * 100
|
||||
initEmitter.emit("sqlite", { type: "InProgress", value: percent })
|
||||
},
|
||||
yield* SubscriptionRef.set(initStep, InitStep.SqliteWaiting())
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
JsonMigration.run(db, {
|
||||
progress: (event: { current: number; total: number }) => {
|
||||
const percent = Math.round((event.current / event.total) * 100)
|
||||
const progress: SqliteMigrationProgress = { type: "InProgress", value: percent }
|
||||
if (Option.isSome(overlay)) sendSqliteMigrationProgress(overlay.value, progress)
|
||||
void Effect.runPromise(PubSub.publish(sqliteProgress, progress))
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
yield* PubSub.publish(sqliteProgress, { type: "Done" })
|
||||
yield* Deferred.succeed(sqliteDone, undefined)
|
||||
})
|
||||
initEmitter.emit("sqlite", { type: "Done" })
|
||||
|
||||
sqliteDone?.resolve()
|
||||
}
|
||||
|
||||
if (needsMigration) {
|
||||
await sqliteDone?.promise
|
||||
if (needsMigration && sqliteDone) {
|
||||
yield* Deferred.await(sqliteDone)
|
||||
}
|
||||
|
||||
logger.log("spawning sidecar", { url })
|
||||
const { listener, health } = await spawnLocalServer(hostname, port, password)
|
||||
server = listener
|
||||
serverReady.resolve({
|
||||
const { listener, health } = yield* spawnLocalServerEffect(hostname, port, password)
|
||||
yield* Ref.set(server, Option.some(listener))
|
||||
|
||||
yield* Deferred.succeed(serverReady, {
|
||||
url,
|
||||
username: "opencode",
|
||||
password,
|
||||
})
|
||||
|
||||
await Promise.race([
|
||||
health.wait,
|
||||
delay(30_000).then(() => {
|
||||
throw new Error("Sidecar health check timed out")
|
||||
}),
|
||||
]).catch((error) => {
|
||||
logger.error("sidecar health check failed", error)
|
||||
})
|
||||
yield* Effect.raceAll([
|
||||
health,
|
||||
Effect.sleep("30 seconds").pipe(Effect.flatMap(() => Effect.fail(new Error("Sidecar health check timed out")))),
|
||||
]).pipe(Effect.catch((error) => Effect.sync(() => logger.error("sidecar health check failed", error))))
|
||||
|
||||
logger.log("loading task finished")
|
||||
})()
|
||||
|
||||
if (needsMigration) {
|
||||
const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
|
||||
if (show) {
|
||||
overlay = createLoadingWindow()
|
||||
await delay(1_000)
|
||||
}
|
||||
}
|
||||
return listener
|
||||
}).pipe(Effect.forkChild)
|
||||
|
||||
await loadingTask
|
||||
setInitStep({ phase: "done" })
|
||||
const overlay = yield* Effect.gen(function* () {
|
||||
if (!needsMigration) return
|
||||
|
||||
if (overlay) {
|
||||
await loadingComplete.promise
|
||||
}
|
||||
const show = yield* Effect.raceAll([
|
||||
Fiber.join(loadingFiber).pipe(Effect.as(false)),
|
||||
Effect.sleep("1 second").pipe(Effect.as(true)),
|
||||
])
|
||||
if (!show) return
|
||||
|
||||
mainWindow = createMainWindow()
|
||||
wireMenu()
|
||||
const overlay = createLoadingWindow()
|
||||
yield* Effect.sleep("1 second")
|
||||
return overlay
|
||||
}).pipe(Effect.map(Option.fromNullishOr))
|
||||
|
||||
overlay?.close()
|
||||
}
|
||||
const listener = yield* Fiber.join(loadingFiber)
|
||||
yield* SubscriptionRef.set(initStep, InitStep.Done())
|
||||
|
||||
function wireMenu() {
|
||||
if (!mainWindow) return
|
||||
createMenu({
|
||||
trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id),
|
||||
checkForUpdates: () => {
|
||||
void checkForUpdates(true)
|
||||
},
|
||||
reload: () => mainWindow?.reload(),
|
||||
relaunch: () => {
|
||||
killSidecar()
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
},
|
||||
yield* Option.match(overlay, {
|
||||
onSome: Effect.fnUntraced(function* (overlay) {
|
||||
yield* Deferred.await(loadingComplete)
|
||||
overlay.close()
|
||||
}),
|
||||
onNone: () => Effect.void,
|
||||
})
|
||||
}
|
||||
|
||||
registerIpcHandlers({
|
||||
killSidecar: () => killSidecar(),
|
||||
awaitInitialization: async (sendStep) => {
|
||||
sendStep(initStep)
|
||||
const listener = (step: InitStep) => sendStep(step)
|
||||
initEmitter.on("step", listener)
|
||||
try {
|
||||
logger.log("awaiting server ready")
|
||||
const res = await serverReady.promise
|
||||
logger.log("server ready", { url: res.url })
|
||||
return res
|
||||
} finally {
|
||||
initEmitter.off("step", listener)
|
||||
}
|
||||
},
|
||||
getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }),
|
||||
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
|
||||
getDefaultServerUrl: () => getDefaultServerUrl(),
|
||||
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
|
||||
getWslConfig: () => Promise.resolve(getWslConfig()),
|
||||
setWslConfig: (config: WslConfig) => setWslConfig(config),
|
||||
getDisplayBackend: async () => null,
|
||||
setDisplayBackend: async () => undefined,
|
||||
parseMarkdown: async (markdown) => parseMarkdown(markdown),
|
||||
checkAppExists: async (appName) => checkAppExists(appName),
|
||||
wslPath: async (path, mode) => wslPath(path, mode),
|
||||
resolveAppPath: async (appName) => resolveAppPath(appName),
|
||||
loadingWindowComplete: () => loadingComplete.resolve(),
|
||||
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail),
|
||||
checkUpdate: async () => checkUpdate(),
|
||||
installUpdate: async () => installUpdate(),
|
||||
setBackgroundColor: (color) => setBackgroundColor(color),
|
||||
})
|
||||
|
||||
function killSidecar() {
|
||||
if (!server) return
|
||||
server.stop()
|
||||
server = null
|
||||
// ---------------------------------------------------------------------------
|
||||
// App lifecycle (imperative Electron shell, thin wrappers around Effects)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const logger = initLogging()
|
||||
|
||||
const shutdownEffect = Effect.gen(function* () {
|
||||
yield* stopServer(server)
|
||||
app.exit(0)
|
||||
})
|
||||
|
||||
const registerAppEventListeners = (appEvents: PubSub.PubSub<AppEvent>) => () => {
|
||||
app.on("second-instance", (_event, argv) => {
|
||||
PubSub.publishUnsafe(appEvents, appEvent.SecondInstance({ argv }))
|
||||
})
|
||||
|
||||
app.on("open-url", (event, url) => {
|
||||
event.preventDefault()
|
||||
PubSub.publishUnsafe(appEvents, appEvent.OpenUrl({ url }))
|
||||
})
|
||||
|
||||
app.on("before-quit", () => {
|
||||
PubSub.publishUnsafe(appEvents, appEvent.BeforeQuit())
|
||||
})
|
||||
|
||||
app.on("will-quit", () => {
|
||||
PubSub.publishUnsafe(appEvents, appEvent.WillQuit())
|
||||
})
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(signal, () => {
|
||||
void Effect.runPromise(shutdownEffect)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLoopbackNoProxy() {
|
||||
const ensureLoopbackNoProxy = () => {
|
||||
const loopback = ["127.0.0.1", "localhost", "::1"]
|
||||
const upsert = (key: string) => {
|
||||
const items = (process.env[key] ?? "")
|
||||
@@ -292,14 +269,154 @@ function ensureLoopbackNoProxy() {
|
||||
upsert("no_proxy")
|
||||
}
|
||||
|
||||
async function getSidecarPort() {
|
||||
const main = Effect.gen(function* () {
|
||||
logger.log("app starting", {
|
||||
version: app.getVersion(),
|
||||
packaged: app.isPackaged,
|
||||
})
|
||||
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
|
||||
ensureLoopbackNoProxy()
|
||||
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
|
||||
|
||||
const appEvents = yield* PubSub.unbounded<AppEvent>()
|
||||
registerAppEventListeners(appEvents)
|
||||
|
||||
yield* Effect.promise(() => app.whenReady())
|
||||
|
||||
app.setAsDefaultProtocolClient("opencode")
|
||||
registerRendererProtocol()
|
||||
setDockIcon()
|
||||
setupAutoUpdater()
|
||||
|
||||
registerIpcHandlersEffect()
|
||||
|
||||
yield* initialize()
|
||||
|
||||
const mainWindow = createMainWindow()
|
||||
wireMenu(mainWindow)
|
||||
|
||||
yield* Stream.fromPubSub(appEvents).pipe(
|
||||
Stream.runForEach((event) => handleAppEvent(event, deepLinkQueue, mainWindow, server)),
|
||||
Effect.forkChild,
|
||||
)
|
||||
|
||||
yield* Deferred.await(deepLinksConsumed).pipe(
|
||||
Effect.andThen(
|
||||
Stream.fromQueue(deepLinkQueue).pipe(
|
||||
Stream.runForEach((urls) => Effect.sync(() => sendDeepLinks(mainWindow, urls))),
|
||||
),
|
||||
),
|
||||
Effect.forkChild,
|
||||
)
|
||||
|
||||
yield* Stream.fromPubSub(menuCommands).pipe(
|
||||
Stream.runForEach((id) => Effect.sync(() => sendMenuCommand(mainWindow, id))),
|
||||
Effect.forkChild,
|
||||
)
|
||||
}).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
logger.error("initialization failed", error)
|
||||
app.exit(1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
void Effect.runPromise(main)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Menu wiring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const wireMenu = (win: BrowserWindow) => {
|
||||
createMenu({
|
||||
trigger: (id) => {
|
||||
sendMenuCommand(win, id)
|
||||
},
|
||||
checkForUpdates: () => {
|
||||
void checkForUpdates(true)
|
||||
},
|
||||
reload: () => win.reload(),
|
||||
relaunch: () => {
|
||||
void Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
yield* stopServer(server)
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const registerIpcHandlersEffect = () =>
|
||||
registerIpcHandlers({
|
||||
killSidecar: () => Effect.runPromise(stopServer(server)),
|
||||
awaitInitialization: (sendStep) =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const currentStep = SubscriptionRef.getUnsafe(initStep)
|
||||
sendStep(currentStep)
|
||||
|
||||
const fiber = yield* SubscriptionRef.changes(initStep).pipe(
|
||||
Stream.runForEach((step) => Effect.sync(() => sendStep(step))),
|
||||
Effect.forkChild,
|
||||
)
|
||||
|
||||
logger.log("awaiting server ready")
|
||||
const res = yield* Deferred.await(serverReady)
|
||||
logger.log("server ready", { url: res.url })
|
||||
|
||||
yield* Fiber.interrupt(fiber)
|
||||
return res
|
||||
}),
|
||||
),
|
||||
getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }),
|
||||
consumeInitialDeepLinks: () =>
|
||||
Effect.runPromise(
|
||||
Queue.clear(deepLinkQueue).pipe(
|
||||
Effect.map((links) => links.flat()),
|
||||
Effect.tap(() => Deferred.succeed(deepLinksConsumed, undefined)),
|
||||
),
|
||||
),
|
||||
getDefaultServerUrl: () => Effect.runPromise(getDefaultServerUrl),
|
||||
setDefaultServerUrl: (url) => Effect.runPromise(setDefaultServerUrl(url)),
|
||||
getWslConfig: () => Effect.runPromise(getWslConfig),
|
||||
setWslConfig: (config: WslConfig) => Effect.runPromise(setWslConfig(config)),
|
||||
getDisplayBackend: () => Promise.resolve(null),
|
||||
setDisplayBackend: () => Promise.resolve(undefined),
|
||||
parseMarkdown: (markdown) => Promise.resolve(parseMarkdown(markdown)),
|
||||
checkAppExists: (appName) => checkAppExists(appName),
|
||||
wslPath: (path, mode) => Promise.resolve(wslPath(path, mode)),
|
||||
resolveAppPath: (appName) => Promise.resolve(resolveAppPath(appName)),
|
||||
loadingWindowComplete: () => Effect.runPromise(Deferred.succeed(loadingComplete, undefined)),
|
||||
runUpdater: (alertOnFail) => checkForUpdates(alertOnFail),
|
||||
checkUpdate: () => checkUpdate(),
|
||||
installUpdate: () => installUpdate(),
|
||||
setBackgroundColor,
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getSidecarPort = Effect.promise(() => {
|
||||
const fromEnv = process.env.OPENCODE_PORT
|
||||
if (fromEnv) {
|
||||
const parsed = Number.parseInt(fromEnv, 10)
|
||||
if (!Number.isNaN(parsed)) return parsed
|
||||
if (!Number.isNaN(parsed)) return Promise.resolve(parsed)
|
||||
}
|
||||
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const server = createServer()
|
||||
server.on("error", reject)
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
@@ -313,13 +430,13 @@ async function getSidecarPort() {
|
||||
server.close(() => resolve(port))
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function sqliteFileExists() {
|
||||
const sqliteFileExists = Effect.sync(() => {
|
||||
const xdg = process.env.XDG_DATA_HOME
|
||||
const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share")
|
||||
return existsSync(join(base, "opencode", "opencode.db"))
|
||||
}
|
||||
})
|
||||
|
||||
function setupAutoUpdater() {
|
||||
if (!UPDATER_ENABLED) return
|
||||
@@ -390,7 +507,7 @@ async function installUpdate() {
|
||||
logger.log("installing downloaded update", {
|
||||
version: downloadedUpdateVersion,
|
||||
})
|
||||
killSidecar()
|
||||
void Effect.runPromise(stopServer(server))
|
||||
autoUpdater.quitAndInstall()
|
||||
}
|
||||
|
||||
@@ -436,17 +553,3 @@ async function checkForUpdates(alertOnFail: boolean) {
|
||||
await installUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function defer<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
@@ -191,14 +191,7 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
})
|
||||
}
|
||||
|
||||
export function sendSqliteMigrationProgress(win: BrowserWindow, progress: SqliteMigrationProgress) {
|
||||
export const sendSqliteMigrationProgress = (win: BrowserWindow, progress: SqliteMigrationProgress) =>
|
||||
win.webContents.send("sqlite-migration-progress", progress)
|
||||
}
|
||||
|
||||
export function sendMenuCommand(win: BrowserWindow, id: string) {
|
||||
win.webContents.send("menu-command", id)
|
||||
}
|
||||
|
||||
export function sendDeepLinks(win: BrowserWindow, urls: string[]) {
|
||||
win.webContents.send("deep-link", urls)
|
||||
}
|
||||
export const sendMenuCommand = (win: BrowserWindow, id: string) => win.webContents.send("menu-command", id)
|
||||
export const sendDeepLinks = (win: BrowserWindow, urls: string[]) => win.webContents.send("deep-link", urls)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Menu, shell } from "electron"
|
||||
|
||||
import { UPDATER_ENABLED } from "./constants"
|
||||
import { createMainWindow } from "./windows"
|
||||
|
||||
@@ -47,7 +46,9 @@ export function createMenu(deps: Deps) {
|
||||
{
|
||||
label: "New Window",
|
||||
accelerator: "Cmd+Shift+N",
|
||||
click: () => createMainWindow(),
|
||||
click: () => {
|
||||
void createMainWindow()
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "close" },
|
||||
|
||||
@@ -1,80 +1,81 @@
|
||||
import { app } from "electron"
|
||||
import { Effect } from "effect"
|
||||
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
|
||||
import { getUserShell, loadShellEnv } from "./shell-env"
|
||||
import { getStore } from "./store"
|
||||
|
||||
export type WslConfig = { enabled: boolean }
|
||||
|
||||
export type HealthCheck = { wait: Promise<void> }
|
||||
|
||||
export function getDefaultServerUrl(): string | null {
|
||||
export const getDefaultServerUrl = Effect.sync((): string | null => {
|
||||
const value = getStore().get(DEFAULT_SERVER_URL_KEY)
|
||||
return typeof value === "string" ? value : null
|
||||
}
|
||||
})
|
||||
|
||||
export function setDefaultServerUrl(url: string | null) {
|
||||
if (url) {
|
||||
getStore().set(DEFAULT_SERVER_URL_KEY, url)
|
||||
return
|
||||
}
|
||||
|
||||
getStore().delete(DEFAULT_SERVER_URL_KEY)
|
||||
}
|
||||
|
||||
export function getWslConfig(): WslConfig {
|
||||
const value = getStore().get(WSL_ENABLED_KEY)
|
||||
return { enabled: typeof value === "boolean" ? value : false }
|
||||
}
|
||||
|
||||
export function setWslConfig(config: WslConfig) {
|
||||
getStore().set(WSL_ENABLED_KEY, config.enabled)
|
||||
}
|
||||
|
||||
export async function spawnLocalServer(hostname: string, port: number, password: string) {
|
||||
prepareServerEnv(password)
|
||||
const { Log, Server } = await import("virtual:opencode-server")
|
||||
await Log.init({ level: "WARN" })
|
||||
const listener = await Server.listen({
|
||||
port,
|
||||
hostname,
|
||||
username: "opencode",
|
||||
password,
|
||||
cors: ["oc://renderer"],
|
||||
export const setDefaultServerUrl = (url: string | null) =>
|
||||
Effect.sync(() => {
|
||||
if (url) {
|
||||
getStore().set(DEFAULT_SERVER_URL_KEY, url)
|
||||
return
|
||||
}
|
||||
getStore().delete(DEFAULT_SERVER_URL_KEY)
|
||||
})
|
||||
|
||||
const wait = (async () => {
|
||||
const url = `http://${hostname}:${port}`
|
||||
export const getWslConfig = Effect.sync((): WslConfig => {
|
||||
const value = getStore().get(WSL_ENABLED_KEY)
|
||||
return { enabled: typeof value === "boolean" ? value : false }
|
||||
})
|
||||
|
||||
const ready = async () => {
|
||||
export const setWslConfig = (config: WslConfig) =>
|
||||
Effect.sync(() => getStore().set(WSL_ENABLED_KEY, config.enabled))
|
||||
|
||||
export const spawnLocalServerEffect = Effect.fn("Server.spawnLocalServer")(
|
||||
function* (hostname: string, port: number, password: string) {
|
||||
yield* prepareServerEnv(password)
|
||||
const { Log, Server } = yield* Effect.promise(() =>
|
||||
import("virtual:opencode-server") as Promise<typeof import("virtual:opencode-server")>,
|
||||
)
|
||||
yield* Effect.promise(() => Log.init({ level: "WARN" }))
|
||||
const listener = yield* Effect.promise(() =>
|
||||
Server.listen({
|
||||
port,
|
||||
hostname,
|
||||
username: "opencode",
|
||||
password,
|
||||
cors: ["oc://renderer"],
|
||||
}),
|
||||
)
|
||||
|
||||
const healthCheck = Effect.gen(function* () {
|
||||
const url = `http://${hostname}:${port}`
|
||||
while (true) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
if (await checkHealth(url, password)) return
|
||||
const healthy = yield* checkHealthEffect(url, password)
|
||||
if (healthy) return
|
||||
yield* Effect.sleep("100 millis")
|
||||
}
|
||||
})
|
||||
|
||||
return { listener, health: healthCheck }
|
||||
},
|
||||
)
|
||||
|
||||
const prepareServerEnv = (password: string) =>
|
||||
Effect.sync(() => {
|
||||
const shell = process.platform === "win32" ? null : getUserShell()
|
||||
const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {}
|
||||
const env = {
|
||||
...process.env,
|
||||
...shellEnv,
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
OPENCODE_SERVER_USERNAME: "opencode",
|
||||
OPENCODE_SERVER_PASSWORD: password,
|
||||
XDG_STATE_HOME: app.getPath("userData"),
|
||||
}
|
||||
Object.assign(process.env, env)
|
||||
})
|
||||
|
||||
await ready()
|
||||
})()
|
||||
|
||||
return { listener, health: { wait } }
|
||||
}
|
||||
|
||||
function prepareServerEnv(password: string) {
|
||||
const shell = process.platform === "win32" ? null : getUserShell()
|
||||
const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {}
|
||||
const env = {
|
||||
...process.env,
|
||||
...shellEnv,
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
OPENCODE_SERVER_USERNAME: "opencode",
|
||||
OPENCODE_SERVER_PASSWORD: password,
|
||||
XDG_STATE_HOME: app.getPath("userData"),
|
||||
}
|
||||
Object.assign(process.env, env)
|
||||
}
|
||||
|
||||
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
|
||||
export const checkHealthEffect = Effect.fn("Server.checkHealth")(function* (url: string, password?: string | null) {
|
||||
let healthUrl: URL
|
||||
try {
|
||||
healthUrl = new URL("/global/health", url)
|
||||
@@ -89,13 +90,15 @@ export async function checkHealth(url: string, password?: string | null): Promis
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(healthUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
const res = yield* Effect.promise(() =>
|
||||
fetch(healthUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(3000),
|
||||
}),
|
||||
)
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -20,15 +20,13 @@ protocol.registerSchemesAsPrivileged([
|
||||
},
|
||||
])
|
||||
|
||||
let backgroundColor: string | undefined
|
||||
let backgroundColor: string | undefined = undefined
|
||||
|
||||
export function setBackgroundColor(color: string) {
|
||||
export const setBackgroundColor = (color: string) => {
|
||||
backgroundColor = color
|
||||
}
|
||||
|
||||
export function getBackgroundColor(): string | undefined {
|
||||
return backgroundColor
|
||||
}
|
||||
export const getBackgroundColor = () => backgroundColor
|
||||
|
||||
function iconsDir() {
|
||||
return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons")
|
||||
@@ -52,7 +50,7 @@ function overlay(theme: Partial<TitlebarTheme> = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export function setTitlebar(win: BrowserWindow, theme: Partial<TitlebarTheme> = {}) {
|
||||
export const setTitlebar = (win: BrowserWindow, theme: Partial<TitlebarTheme> = {}) => {
|
||||
if (process.platform !== "win32") return
|
||||
win.setTitleBarOverlay(overlay(theme))
|
||||
}
|
||||
@@ -70,6 +68,7 @@ export function createMainWindow() {
|
||||
})
|
||||
|
||||
const mode = tone()
|
||||
const bg = getBackgroundColor()
|
||||
const win = new BrowserWindow({
|
||||
x: state.x,
|
||||
y: state.y,
|
||||
@@ -78,7 +77,7 @@ export function createMainWindow() {
|
||||
show: false,
|
||||
title: "OpenCode",
|
||||
icon: iconPath(),
|
||||
backgroundColor,
|
||||
backgroundColor: bg,
|
||||
...(process.platform === "darwin"
|
||||
? {
|
||||
titleBarStyle: "hidden" as const,
|
||||
@@ -126,6 +125,7 @@ export function createMainWindow() {
|
||||
|
||||
export function createLoadingWindow() {
|
||||
const mode = tone()
|
||||
const bg = getBackgroundColor()
|
||||
const win = new BrowserWindow({
|
||||
width: 640,
|
||||
height: 480,
|
||||
@@ -133,7 +133,7 @@ export function createLoadingWindow() {
|
||||
center: true,
|
||||
show: true,
|
||||
icon: iconPath(),
|
||||
backgroundColor,
|
||||
backgroundColor: bg,
|
||||
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
|
||||
...(process.platform === "win32"
|
||||
? {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" } | { phase: "done" }
|
||||
import { Data } from "effect"
|
||||
|
||||
export type InitStep = Data.TaggedEnum<{
|
||||
ServerWaiting: {}
|
||||
SqliteWaiting: {}
|
||||
Done: {}
|
||||
}>
|
||||
export const InitStep = Data.taggedEnum<InitStep>()
|
||||
|
||||
export type ServerReadyData = {
|
||||
url: string
|
||||
|
||||
@@ -17,14 +17,14 @@ render(() => {
|
||||
const [line, setLine] = createSignal(0)
|
||||
const [percent, setPercent] = createSignal(0)
|
||||
|
||||
const phase = createMemo(() => step()?.phase)
|
||||
const phase = createMemo(() => step()?._tag)
|
||||
|
||||
const value = createMemo(() => {
|
||||
if (phase() === "done") return 100
|
||||
if (phase() === "Done") return 100
|
||||
return Math.max(25, Math.min(100, percent()))
|
||||
})
|
||||
|
||||
window.api.awaitInitialization((next) => setStep(next as InitStep)).catch(() => undefined)
|
||||
window.api.awaitInitialization((next) => setStep(next)).catch(() => undefined)
|
||||
|
||||
onMount(() => {
|
||||
setLine(0)
|
||||
@@ -36,7 +36,7 @@ render(() => {
|
||||
if (progress.type === "InProgress") setPercent(Math.max(0, Math.min(100, progress.value)))
|
||||
if (progress.type === "Done") {
|
||||
setPercent(100)
|
||||
setStep({ phase: "done" })
|
||||
setStep({ _tag: "Done" })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -47,15 +47,15 @@ render(() => {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (phase() !== "done") return
|
||||
if (phase() !== "Done") return
|
||||
|
||||
const timer = setTimeout(() => window.api.loadingWindowComplete(), 1000)
|
||||
onCleanup(() => clearTimeout(timer))
|
||||
})
|
||||
|
||||
const status = createMemo(() => {
|
||||
if (phase() === "done") return "All done"
|
||||
if (phase() === "sqlite_waiting") return lines[line()]
|
||||
if (phase() === "Done") return "All done"
|
||||
if (phase() === "SqliteWaiting") return lines[line()]
|
||||
return "Just a moment..."
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user