mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
perf(desktop): move startup work that the first window does not need off its path (#50111)
This commit is contained in:
@@ -5,6 +5,7 @@ import { Ipc } from "./ipc"
|
||||
import { DesktopInitialization } from "./lifecycle/desktop-initialization"
|
||||
import { installContextMenu } from "./lifecycle/environment"
|
||||
import { ApplicationLifecycle } from "./lifecycle"
|
||||
import { DesktopLogging } from "./native/logging"
|
||||
import { BackgroundService } from "./service/background-service"
|
||||
import { DesktopCli } from "./service/desktop-cli"
|
||||
import { UpdaterLive } from "./updater/live"
|
||||
@@ -14,11 +15,16 @@ import { marks } from "./lifecycle/marks"
|
||||
marks.bundle = Date.now()
|
||||
|
||||
const runIpc = Effect.fn("Desktop.runIpc")(function* () {
|
||||
const lifecycle = yield* ApplicationLifecycle.Service
|
||||
marks.layers = Date.now()
|
||||
yield* Effect.logInfo("layers ready", { marks })
|
||||
const lifecycle = yield* ApplicationLifecycle.Service
|
||||
marks.layers = Date.now()
|
||||
yield* Effect.logInfo("layers ready", { marks })
|
||||
const ipc = yield* Ipc.registerIpcHandlers
|
||||
if (lifecycle.restoreWindows().length) ipc.installMenu()
|
||||
// The first window's renderer now has its IPC port and is hydrating its stores over it. The crash
|
||||
// reporter (spawns a process) and the context menu (a dependency tree) are not worth answering late.
|
||||
yield* Effect.sleep("500 millis")
|
||||
const logging = yield* DesktopLogging.Service
|
||||
yield* logging.startCrashReporter
|
||||
yield* installContextMenu
|
||||
yield* Effect.callback<void>((resume) => {
|
||||
const quit = () => resume(Effect.void)
|
||||
@@ -38,4 +44,3 @@ runIpc().pipe(
|
||||
Effect.scoped,
|
||||
NodeRuntime.runMain,
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { marks } from "./lifecycle/marks"
|
||||
import { app } from "electron"
|
||||
import { acquireApplicationLock, configureApplication } from "./lifecycle/configure"
|
||||
import { createEarlyWindow } from "./windows/early"
|
||||
import { startSidecarProbe } from "./service/sidecar-probe"
|
||||
import { createEarlyWindow } from "./windows/early"
|
||||
import { rendererAssetsServed } from "./windows/protocol"
|
||||
import { registerRendererScheme } from "./windows/scheme"
|
||||
|
||||
@@ -15,15 +16,15 @@ if (acquireApplicationLock()) {
|
||||
registerRendererScheme()
|
||||
// Window first, then the bundle: starting the import before ready delays ready itself, because the
|
||||
// module graph evaluates on the same thread Chromium needs to finish initialising.
|
||||
void app.whenReady().then(async () => {
|
||||
marks.ready = Date.now()
|
||||
createEarlyWindow()
|
||||
marks.window = Date.now()
|
||||
// The window's renderer is already loading. Its HTML and preloaded chunks are served from this
|
||||
// thread, so the bundle waits for that burst to be answered (or a cap) before it evaluates.
|
||||
if (!process.env.ELECTRON_RENDERER_URL) await rendererAssetsServed({ quietMs: 40, capMs: 400 })
|
||||
marks.served = Date.now()
|
||||
void app.whenReady().then(async () => {
|
||||
marks.ready = Date.now()
|
||||
createEarlyWindow()
|
||||
marks.window = Date.now()
|
||||
startSidecarProbe()
|
||||
// The window's renderer is already loading. Its HTML and preloaded chunks are served from this
|
||||
// thread, so the bundle waits for that burst to be answered (or a cap) before it evaluates.
|
||||
if (!process.env.ELECTRON_RENDERER_URL) await rendererAssetsServed({ quietMs: 40, capMs: 400 })
|
||||
marks.served = Date.now()
|
||||
return import("./desktop")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdirSync, rmSync } from "node:fs"
|
||||
import { enableCompileCache } from "node:module"
|
||||
import { homedir, tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { app } from "electron"
|
||||
@@ -31,6 +32,8 @@ export function configureApplication() {
|
||||
app.setPath("sessionData", path.join(testRoot, "session"))
|
||||
if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents"))
|
||||
}
|
||||
// V8 bytecode for the main bundle survives between launches, like the renderer's code cache.
|
||||
enableCompileCache(path.join(app.getPath("userData"), "compile-cache"))
|
||||
}
|
||||
|
||||
export function acquireApplicationLock() {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { app } from "electron"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { DesktopLogging } from "../native/logging"
|
||||
import { getStore } from "../storage/store"
|
||||
import { marks } from "./marks"
|
||||
import {
|
||||
loadProxyEnvironment,
|
||||
preferApplicationEnvironment,
|
||||
@@ -22,12 +23,15 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const logging = yield* DesktopLogging.Service
|
||||
yield* prepareApplicationEnvironment
|
||||
yield* preferApplicationEnvironment
|
||||
yield* loadProxyEnvironment
|
||||
// System certificates, the proxy and the net log serve later network work; the first window and
|
||||
// its IPC port do not wait for them.
|
||||
yield* Effect.forkScoped(
|
||||
prepareApplicationEnvironment.pipe(Effect.andThen(loadProxyEnvironment), Effect.andThen(logging.startNetwork)),
|
||||
)
|
||||
yield* Effect.promise(() => app.whenReady())
|
||||
yield* logging.startNetwork
|
||||
yield* prepareDesktop
|
||||
marks.init = Date.now()
|
||||
return Service.of({
|
||||
version: app.getVersion(),
|
||||
updaterStore: getStore("opencode.updater"),
|
||||
|
||||
@@ -8,6 +8,10 @@ import { getStore } from "../storage/store"
|
||||
const DEFAULT_PROJECT_DIR = "Default Project"
|
||||
|
||||
export const initializeFirstLaunchOnboarding = Effect.fn("Onboarding.initialize")(function* (userDataPath: string) {
|
||||
const store = getStore()
|
||||
const current = store.get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY)
|
||||
if (typeof current === "boolean") return current
|
||||
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
const names = (yield* fs.exists(userDataPath)) ? yield* fs.readDirectory(userDataPath) : []
|
||||
@@ -17,11 +21,8 @@ export const initializeFirstLaunchOnboarding = Effect.fn("Onboarding.initialize"
|
||||
const info = yield* fs.stat(path.join(userDataPath, name)).pipe(Effect.option)
|
||||
return { name, directory: Option.isSome(info) && info.value.type === "Directory" }
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const store = getStore()
|
||||
const current = store.get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY)
|
||||
if (typeof current === "boolean") return current
|
||||
|
||||
const complete = hasExistingAppState(entries)
|
||||
store.set(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, complete)
|
||||
return complete
|
||||
|
||||
@@ -19,6 +19,7 @@ let netLogPath: string | undefined
|
||||
|
||||
export interface Interface {
|
||||
readonly startNetwork: Effect.Effect<void>
|
||||
readonly startCrashReporter: Effect.Effect<void>
|
||||
readonly exportDebug: Effect.Effect<string>
|
||||
}
|
||||
|
||||
@@ -30,7 +31,9 @@ const serviceLayer = Layer.effect(
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
yield* initLogging(fs, path).pipe(Effect.orDie)
|
||||
yield* initCrashReporter(fs, path).pipe(Effect.orDie)
|
||||
// Old run directories go away in the background; listing them is not worth a wait at startup.
|
||||
yield* Effect.forkScoped(cleanup(fs, path).pipe(Effect.catch(() => Effect.void)))
|
||||
marks.logging = Date.now()
|
||||
yield* Effect.logInfo("app starting", {
|
||||
version: VERSION,
|
||||
packaged: app.isPackaged,
|
||||
@@ -42,6 +45,12 @@ const serviceLayer = Layer.effect(
|
||||
startNetwork: startNetLog(path).pipe(
|
||||
Effect.catch((error) => Effect.logWarning("failed to start net log", { error })),
|
||||
),
|
||||
// Starting crashpad spawns its handler process, ~60 ms on the main thread, so the first window
|
||||
// and its IPC port come first.
|
||||
startCrashReporter: initCrashReporter(fs, path).pipe(
|
||||
Effect.tap(() => Effect.sync(() => (marks.crash = Date.now()))),
|
||||
Effect.catch((error) => Effect.logWarning("failed to start crash reporter", { error })),
|
||||
),
|
||||
exportDebug,
|
||||
})
|
||||
}),
|
||||
@@ -99,7 +108,6 @@ function initLogging(fs: FileSystem.FileSystem, path: Path.Path) {
|
||||
log.initialize({ preload: false, spyRendererConsole: true })
|
||||
initConsoleTransport()
|
||||
})
|
||||
yield* cleanup(fs, path)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Context, Effect, FileSystem, Layer, Path } from "effect"
|
||||
import { BackgroundServiceState } from "./background-service-state"
|
||||
import { cleanStages, DesktopCli } from "./desktop-cli"
|
||||
import { SidecarCredentials } from "./sidecar-credentials"
|
||||
import { sidecarProbe } from "./sidecar-probe"
|
||||
|
||||
export * as BackgroundService from "./background-service"
|
||||
|
||||
@@ -36,7 +37,7 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
|
||||
const version = mode === "initial" ? cli.version : undefined
|
||||
if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData")
|
||||
const client = yield* Effect.promise(() => import("@opencode/client/service"))
|
||||
const service = yield* Effect.tryPromise(() =>
|
||||
const ensure = () =>
|
||||
client.Service.ensure({
|
||||
file:
|
||||
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
|
||||
@@ -46,13 +47,18 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
|
||||
command: [...cli.command, "serve", "--service", ...(isolated ? ["--port", "0"] : [])],
|
||||
onStart: (reason, previousVersion) =>
|
||||
runFork(Effect.logInfo("v2 CLI background service starting", { reason, previousVersion })),
|
||||
}),
|
||||
)
|
||||
})
|
||||
// A compatible service the entry module already found is adopted at once; ensure() still runs
|
||||
// afterwards for its side effects (terminal handoff completion), off the renderer's path.
|
||||
const early = mode === "initial" && !isolated ? yield* Effect.promise(sidecarProbe) : undefined
|
||||
if (early) yield* Effect.sync(() => void ensure().catch(() => undefined))
|
||||
const service = early ?? (yield* Effect.tryPromise(ensure))
|
||||
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
|
||||
const url = new URL(service.url)
|
||||
if (url.hostname === "0.0.0.0") url.hostname = "127.0.0.1"
|
||||
yield* Effect.logInfo("v2 CLI background service ready", {
|
||||
version,
|
||||
probed: !!early,
|
||||
...endpoint(url.origin),
|
||||
})
|
||||
if (mode === "initial" && isolated && cli.binary) yield* cleanStages(cli.binary).pipe(Effect.orDie)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as DesktopCli from "./desktop-cli"
|
||||
|
||||
import { execFile, spawn } from "node:child_process"
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { promisify } from "node:util"
|
||||
import { app } from "electron"
|
||||
import { Context, Effect, FileSystem, Layer, Option, Path } from "effect"
|
||||
@@ -93,9 +94,15 @@ const resolveBundledCli = Effect.fn("DesktopCli.resolveBundled")(function* (isol
|
||||
const bundledVersion = Effect.fn("DesktopCli.bundledVersion")(function* (bundled: string) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
const shipped = yield* fs
|
||||
.readFileString(path.join(path.dirname(bundled), "opencode-cli.version"))
|
||||
.pipe(Effect.map((text) => text.trim()), Effect.orElseSucceed(() => ""))
|
||||
// Synchronous on purpose: this sits on the path to the first window's IPC port, and a queued
|
||||
// async read waits behind everything else the main thread is doing at that moment.
|
||||
const shipped = yield* Effect.sync(() => {
|
||||
try {
|
||||
return readFileSync(path.join(path.dirname(bundled), "opencode-cli.version"), "utf8").trim()
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
})
|
||||
if (shipped) {
|
||||
yield* Effect.logInfo("v2 CLI version bundled", { version: shipped })
|
||||
return shipped
|
||||
@@ -147,7 +154,7 @@ const installCli = Effect.fn("DesktopCli.install")(function* (source: string, ve
|
||||
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)) {
|
||||
if (existsSync(destination)) {
|
||||
yield* Effect.logInfo("v2 CLI staged executable reused", { path: destination, version })
|
||||
return destination
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { app } from "electron"
|
||||
import type { Endpoint } from "@opencode/client/service"
|
||||
|
||||
// The main thread idles between showing the first window and evaluating the main bundle, waiting
|
||||
// for the renderer's asset requests. That slot is long enough to find out whether a compatible
|
||||
// background service is already running, so the renderer's first data request is not the first
|
||||
// moment anyone asks. The probe only looks; a service that has to be started waits for the layers,
|
||||
// which set the environment the CLI expects.
|
||||
let probe: Promise<Endpoint | undefined> | undefined
|
||||
|
||||
export function startSidecarProbe() {
|
||||
if (!app.isPackaged) return
|
||||
const version = bundledVersion()
|
||||
if (!version) return
|
||||
probe = import("@opencode/client/service")
|
||||
.then(({ Service }) => Service.discover({ version }))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
export function sidecarProbe() {
|
||||
return probe ?? Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
function bundledVersion() {
|
||||
try {
|
||||
return readFileSync(path.join(process.resourcesPath, "opencode-cli.version"), "utf8").trim()
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,6 @@ import { getStore } from "../storage/store"
|
||||
// full window setup in appearance.ts, so both draw the same frame.
|
||||
|
||||
const oc2Theme = oc2ThemeJson as DesktopTheme
|
||||
const oc2Background = {
|
||||
light: resolveThemeVariant(oc2Theme.light, false)["background-base"],
|
||||
dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"],
|
||||
}
|
||||
// Match the renderer's 36px titlebar plus its former 8px content inset.
|
||||
export const titlebarHeight = 44
|
||||
|
||||
@@ -21,10 +17,13 @@ export function tone() {
|
||||
}
|
||||
|
||||
// The colour the renderer reported on its last run, or the default theme's for the system tone, so
|
||||
// a window shown before the renderer paints already has the right background.
|
||||
// a window shown before the renderer paints already has the right background. Resolving a palette
|
||||
// costs tens of milliseconds before the first window, so it only happens when nothing is stored.
|
||||
export function storedBackgroundColor() {
|
||||
const stored = getStore().get(BACKGROUND_COLOR_KEY)
|
||||
return typeof stored === "string" ? stored : oc2Background[tone()]
|
||||
if (typeof stored === "string") return stored
|
||||
const dark = tone() === "dark"
|
||||
return resolveThemeVariant(dark ? oc2Theme.dark : oc2Theme.light, dark)["background-base"]
|
||||
}
|
||||
|
||||
export function titlebarOverlay(mode: "light" | "dark" = tone(), zoom = 1) {
|
||||
|
||||
Reference in New Issue
Block a user