Skip to content

Commit e1e2efe

Browse files
authored
perf(desktop): show the first window the moment Electron is ready (#49869)
1 parent c2b171d commit e1e2efe

14 files changed

Lines changed: 291 additions & 145 deletions

File tree

‎packages/desktop/scripts/bench-startup.ts‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,14 @@ console.log(`service: ${service}, runs: ${runs} (+${warmup} warm-up), cdp ${cdpP
142142
if (service === "warm") await warmService()
143143

144144
const samples: Sample[] = []
145+
// A launch that never produces a renderer would otherwise leave an instance behind that every later
146+
// launch hands off to through the single-instance lock.
147+
process.on("uncaughtException", async (error) => {
148+
console.error(error)
149+
await killApp()
150+
await stopService()
151+
process.exit(1)
152+
})
145153
for (let run = 1 - warmup; run <= runs; run++) {
146154
for (const build of builds) {
147155
const sample = await launch(build, run)
@@ -516,11 +524,18 @@ function mainLog() {
516524
const dirs = existsSync(paths.logs) ? readdirSync(paths.logs).sort().reverse() : []
517525
const dir = dirs.map((d) => join(paths.logs, d)).find((d) => existsSync(join(d, "main.log")))
518526
const timeline: [number, string, string][] = []
527+
let windowShownAt: number | undefined
519528
for (const name of dir ? readdirSync(dir).filter((f) => f.endsWith(".log")) : []) {
520-
for (const line of readFileSync(join(dir!, name), "utf8").split(/\r?\n/)) {
529+
const text = readFileSync(join(dir!, name), "utf8")
530+
// electron-log wraps long objects onto continuation lines; read them as part of the entry.
531+
for (const entry of text.split(/\r?\n(?=\[\d{4}-)/)) {
532+
const line = entry.split(/\r?\n/)[0]
521533
const m = line.match(/^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d{3})\]\s+\[\w+\]\s+(?:\([\w-]+\)\s+)?(.*)$/)
522534
if (!m) continue
523535
const message = m[2].replace(/\s*\{.*$/, "").trim()
536+
// A window shown before the logger existed reports when it was shown; the line itself is later.
537+
const shown = /main window visible/.test(message) ? entry.match(/shownAt: (\d+)/)?.[1] : undefined
538+
if (shown) windowShownAt = Number(shown)
524539
timeline.push([new Date(m[1].replace(" ", "T")).getTime(), name.replace(/\.log$/, ""), message])
525540
}
526541
}
@@ -533,7 +548,7 @@ function mainLog() {
533548
versionDone: at(/v2 CLI command completed/),
534549
serviceStarting: at(/v2 CLI background service starting/),
535550
serviceReady: at(/background service ready/),
536-
windowVisible: at(/main window visible/),
551+
windowVisible: windowShownAt ?? at(/main window visible/),
537552
}
538553
}
539554

@@ -592,8 +607,12 @@ function bundledCli(exe: string) {
592607
async function warmService() {
593608
await stopService()
594609
const clis = builds.map((build) => bundledCli(build.exe))
595-
if (new Set(clis.map((cli) => statSync(cli).size)).size > 1)
596-
console.warn("warning: the compared builds bundle different CLIs; the desktop will restart the service on the mismatch")
610+
const identity = (cli: string) => {
611+
const version = join(dirname(cli), "opencode-cli.version")
612+
return existsSync(version) ? readFileSync(version, "utf8").trim() : String(statSync(cli).size)
613+
}
614+
if (new Set(clis.map(identity)).size > 1)
615+
throw new Error("The compared builds bundle different CLIs; the desktop would restart the service on the mismatch")
597616
serviceProcess = spawn(clis[0], ["serve", "--service"], { env, detached: true, stdio: "ignore" })
598617
serviceProcess.unref()
599618
const deadline = Date.now() + 60_000

‎packages/desktop/src/main/constants.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,17 @@ export const CHANNEL: Channel = raw === "local" || raw === "dev" || raw === "bet
66
export const VERSION = app.isPackaged ? app.getVersion() : (process.env.OPENCODE_VERSION ?? app.getVersion())
77

88
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
9+
10+
const appNames: Record<string, string> = {
11+
dev: "OpenCode Dev",
12+
beta: "OpenCode Beta",
13+
prod: "OpenCode",
14+
}
15+
const appIDs: Record<string, string> = {
16+
dev: "ai.opencode.desktop.dev",
17+
beta: "ai.opencode.desktop.beta",
18+
prod: "ai.opencode.desktop",
19+
}
20+
// Local renderer/server mode keeps the dev application identity.
21+
export const APP_NAME = app.isPackaged ? appNames[CHANNEL] : "OpenCode Dev"
22+
export const APP_ID = app.isPackaged ? appIDs[CHANNEL] : "ai.opencode.desktop.dev"

‎packages/desktop/src/main/desktop.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { app } from "electron"
33
import { Effect, Layer } from "effect"
44
import { Ipc } from "./ipc"
55
import { DesktopInitialization } from "./lifecycle/desktop-initialization"
6+
import { installContextMenu } from "./lifecycle/environment"
67
import { ApplicationLifecycle } from "./lifecycle"
78
import { BackgroundService } from "./service/background-service"
89
import { DesktopCli } from "./service/desktop-cli"
@@ -12,6 +13,7 @@ const runIpc = Effect.fn("Desktop.runIpc")(function* () {
1213
const lifecycle = yield* ApplicationLifecycle.Service
1314
const ipc = yield* Ipc.registerIpcHandlers
1415
if (lifecycle.restoreWindows().length) ipc.installMenu()
16+
yield* installContextMenu
1517
yield* Effect.callback<void>((resume) => {
1618
const quit = () => resume(Effect.void)
1719
app.once("will-quit", quit)

‎packages/desktop/src/main/index.ts‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1-
export {}
1+
import { app } from "electron"
2+
import { acquireApplicationLock, configureApplication } from "./lifecycle/configure"
3+
import { createEarlyWindow } from "./windows/early"
4+
import { registerRendererScheme } from "./windows/scheme"
25

3-
await import("./desktop")
6+
// This module stays small on purpose. Electron holds the ready event until the entry module has
7+
// finished, and the first window should be on screen before the rest of the main process — a few
8+
// hundred milliseconds of module evaluation and layers — loads. Configuration and the scheme must
9+
// precede ready; the window is created the moment ready fires; everything else is imported after.
10+
configureApplication()
11+
if (acquireApplicationLock()) {
12+
registerRendererScheme()
13+
// Window first, then the bundle: starting the import before ready delays ready itself, because the
14+
// module graph evaluates on the same thread Chromium needs to finish initialising.
15+
void app.whenReady().then(() => {
16+
createEarlyWindow()
17+
return import("./desktop")
18+
})
19+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { randomUUID } from "node:crypto"
2+
import { mkdirSync, rmSync } from "node:fs"
3+
import { homedir, tmpdir } from "node:os"
4+
import path from "node:path"
5+
import { app } from "electron"
6+
import { APP_ID, APP_NAME } from "../constants"
7+
8+
const testOnboarding = process.env.OPENCODE_TEST_ONBOARDING === "1"
9+
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
10+
11+
// Runs synchronously from the entry module, before Chromium is ready: command-line switches only
12+
// take effect before ready, the single-instance lock is scoped to userData, and the early window
13+
// needs userData to find the persisted window list and state.
14+
export function configureApplication() {
15+
try {
16+
process.chdir(homedir())
17+
} catch {}
18+
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
19+
20+
app.setName(APP_NAME)
21+
app.setAppUserModelId(APP_ID)
22+
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
23+
const features = app.commandLine.getSwitchValue("enable-features")
24+
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
25+
if (!app.isPackaged)
26+
app.commandLine.appendSwitch("remote-debugging-port", process.env.OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT ?? "9222")
27+
28+
const testRoot = createTestRoot()
29+
app.setPath("userData", testRoot ? path.join(testRoot, "desktop") : path.join(app.getPath("appData"), APP_ID))
30+
if (testRoot) {
31+
app.setPath("sessionData", path.join(testRoot, "session"))
32+
if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents"))
33+
}
34+
}
35+
36+
export function acquireApplicationLock() {
37+
if (app.requestSingleInstanceLock()) return true
38+
app.quit()
39+
return false
40+
}
41+
42+
function createTestRoot() {
43+
const root = testOnboarding
44+
? path.join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
45+
: app.isPackaged
46+
? undefined
47+
: process.env.OPENCODE_DESKTOP_TEST_ROOT
48+
if (!root) return undefined
49+
if (testOnboarding) rmSync(root, { recursive: true, force: true })
50+
for (const dir of ["data", "config", "cache", "state", "desktop", "session", "documents"])
51+
mkdirSync(path.join(root, dir), { recursive: true })
52+
if (testOnboarding) process.env.OPENCODE_DB = ":memory:"
53+
process.env.XDG_DATA_HOME = path.join(root, "data")
54+
process.env.XDG_CONFIG_HOME = path.join(root, "config")
55+
process.env.XDG_CACHE_HOME = path.join(root, "cache")
56+
process.env.XDG_STATE_HOME = path.join(root, "state")
57+
return root
58+
}

‎packages/desktop/src/main/lifecycle/environment.ts‎

Lines changed: 5 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,18 @@
1-
import { randomUUID } from "node:crypto"
21
import http from "node:http"
3-
import { homedir, tmpdir } from "node:os"
42
import { getCACertificates, setDefaultCACertificates } from "node:tls"
53
import { app } from "electron"
6-
import contextMenu from "electron-context-menu"
7-
import { Effect, FileSystem, Path } from "effect"
8-
import { CHANNEL } from "../constants"
4+
import { Effect, Path } from "effect"
95
import { DesktopPaths } from "../paths"
106
import { getUserShell, loadShellEnv } from "../service/shell-env"
117
import { registerRendererProtocol, setDockIcon } from "../windows"
128

13-
const appNames: Record<string, string> = {
14-
dev: "OpenCode Dev",
15-
beta: "OpenCode Beta",
16-
prod: "OpenCode",
17-
}
18-
const appIDs: Record<string, string> = {
19-
dev: "ai.opencode.desktop.dev",
20-
beta: "ai.opencode.desktop.beta",
21-
prod: "ai.opencode.desktop",
22-
}
23-
const testOnboarding = process.env.OPENCODE_TEST_ONBOARDING === "1"
24-
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
25-
26-
export const configureApplication = Effect.fn("Application.configure")(function* () {
27-
const path = yield* Path.Path
9+
// electron-context-menu attaches to every existing and future window, so it can load once the first
10+
// window is up instead of holding up startup with its dependency tree.
11+
export const installContextMenu = Effect.gen(function* () {
12+
const { default: contextMenu } = yield* Effect.promise(() => import("electron-context-menu"))
2813
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
29-
try {
30-
process.chdir(homedir())
31-
} catch {}
32-
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
33-
34-
const appID = app.isPackaged ? appIDs[CHANNEL] : "ai.opencode.desktop.dev"
35-
app.setName(app.isPackaged ? appNames[CHANNEL] : "OpenCode Dev")
36-
app.setAppUserModelId(appID)
37-
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
38-
const features = app.commandLine.getSwitchValue("enable-features")
39-
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
40-
if (!app.isPackaged)
41-
app.commandLine.appendSwitch("remote-debugging-port", process.env.OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT ?? "9222")
42-
43-
const testRoot = yield* createTestRoot()
44-
app.setPath("userData", testRoot ? path.join(testRoot, "desktop") : path.join(app.getPath("appData"), appID))
45-
if (testRoot) {
46-
app.setPath("sessionData", path.join(testRoot, "session"))
47-
if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents"))
48-
}
4914
})
5015

51-
export function acquireApplicationLock() {
52-
if (app.requestSingleInstanceLock()) return true
53-
app.quit()
54-
return false
55-
}
56-
5716
export const prepareApplicationEnvironment = Effect.gen(function* () {
5817
yield* loadSystemCertificates
5918
yield* loadProxyEnvironment
@@ -91,29 +50,6 @@ export const loadProxyEnvironment = Effect.gen(function* () {
9150
}).pipe(Effect.catch((error) => Effect.logWarning("failed to load proxy environment", { error })))
9251
})
9352

94-
const createTestRoot = Effect.fn("Application.createTestRoot")(function* () {
95-
const fs = yield* FileSystem.FileSystem
96-
const path = yield* Path.Path
97-
const root = testOnboarding
98-
? path.join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
99-
: app.isPackaged
100-
? undefined
101-
: process.env.OPENCODE_DESKTOP_TEST_ROOT
102-
if (!root) return undefined
103-
if (testOnboarding) yield* fs.remove(root, { recursive: true, force: true })
104-
yield* Effect.forEach(
105-
["data", "config", "cache", "state", "desktop", "session", "documents"],
106-
(dir) => fs.makeDirectory(path.join(root, dir), { recursive: true }),
107-
{ discard: true },
108-
)
109-
if (testOnboarding) process.env.OPENCODE_DB = ":memory:"
110-
process.env.XDG_DATA_HOME = path.join(root, "data")
111-
process.env.XDG_CONFIG_HOME = path.join(root, "config")
112-
process.env.XDG_CACHE_HOME = path.join(root, "cache")
113-
process.env.XDG_STATE_HOME = path.join(root, "state")
114-
return root
115-
})
116-
11753
const loadSystemCertificates = Effect.try({
11854
try: () => {
11955
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])

‎packages/desktop/src/main/lifecycle/index.ts‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { DesktopLogging, scoped } from "../native/logging"
99
import { DesktopStorage } from "../storage"
1010
import { safeWebContentsURL } from "../windows/state"
1111
import { getLastFocusedWindow, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
12-
import { acquireApplicationLock, configureApplication } from "./environment"
1312
import { initializeFirstLaunchOnboarding } from "./onboarding"
1413
import { Shutdown } from "./shutdown"
1514

@@ -145,7 +144,7 @@ const runtime = Layer.effect(
145144
}),
146145
)
147146

148-
// Storage opens after configureApplication has set userData and before windows exist, so window
147+
// Storage opens after the entry module has set userData and before the renderer loads, so window
149148
// teardown can clear a window's persisted state and every renderer request finds it ready.
150149
const platform = Layer.mergeAll(
151150
DesktopLogging.layer,
@@ -155,9 +154,6 @@ const platform = Layer.mergeAll(
155154

156155
export const layer = Layer.unwrap(
157156
Effect.gen(function* () {
158-
// Electron scopes the single-instance lock to userData.
159-
yield* configureApplication()
160-
if (!acquireApplicationLock()) return yield* Effect.interrupt
161157
// Decide first-launch state before the storage layer creates drafts.sqlite, which would
162158
// otherwise read as evidence of an earlier launch on a fresh install.
163159
yield* initializeFirstLaunchOnboarding(app.getPath("userData"))

‎packages/desktop/src/main/windows/appearance.ts‎

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
import { resolveThemeVariant } from "@opencode/ui/theme/resolve"
2-
import type { DesktopTheme } from "@opencode/ui/theme/types"
3-
import oc2ThemeJson from "../../../../ui/src/theme/themes/oc-2.json"
41
import { app, BrowserWindow, nativeImage, nativeTheme } from "electron"
52
import type { Path } from "effect"
63
import { type TitlebarTheme } from "../../shared/ipc-contract"
@@ -9,16 +6,10 @@ import { emitIpcEvent } from "../ipc-events"
96
import type { DesktopPaths } from "../paths"
107
import { BACKGROUND_COLOR_KEY, PINCH_ZOOM_ENABLED_KEY } from "../storage/keys"
118
import { getStore } from "../storage/store"
9+
import { storedBackgroundColor, titlebarOverlay, tone } from "./defaults"
1210

13-
const oc2Theme = oc2ThemeJson as DesktopTheme
14-
const oc2Background = {
15-
light: resolveThemeVariant(oc2Theme.light, false)["background-base"],
16-
dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"],
17-
}
1811
const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
1912
const pinchZoomEnabled = new WeakMap<BrowserWindow, boolean>()
20-
// Match the renderer's 36px titlebar plus its former 8px content inset.
21-
const titlebarHeight = 44
2213
const maxZoomLevel = 10
2314
const minZoomLevel = 0.2
2415
let backgroundColor: string | undefined
@@ -28,7 +19,7 @@ export function windowAppearance(path: Path.Path, paths: DesktopPaths.Resolved)
2819
return {
2920
title: "OpenCode",
3021
icon: iconPath(path, paths),
31-
backgroundColor: getBackgroundColor() ?? oc2Background[mode],
22+
backgroundColor: backgroundColor ?? storedBackgroundColor(),
3223
...(process.platform === "darwin"
3324
? {
3425
titleBarStyle: "hidden" as const,
@@ -139,17 +130,8 @@ function iconPath(path: Path.Path, paths: DesktopPaths.Resolved) {
139130
return path.join(iconsDir(path, paths), `icon.${process.platform === "win32" ? "ico" : "png"}`)
140131
}
141132

142-
function tone() {
143-
return nativeTheme.shouldUseDarkColors ? "dark" : "light"
144-
}
145-
146133
function overlay(theme: Partial<TitlebarTheme> = {}, zoom = 1) {
147-
const mode = theme.mode ?? tone()
148-
return {
149-
color: "#00000000",
150-
symbolColor: mode === "dark" ? "white" : "black",
151-
height: Math.max(titlebarHeight, Math.round(titlebarHeight * zoom)),
152-
}
134+
return titlebarOverlay(theme.mode ?? tone(), zoom)
153135
}
154136

155137
function clampZoom(value: number) {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { resolveThemeVariant } from "@opencode/ui/theme/resolve"
2+
import type { DesktopTheme } from "@opencode/ui/theme/types"
3+
import { nativeTheme } from "electron"
4+
import oc2ThemeJson from "../../../../ui/src/theme/themes/oc-2.json"
5+
import { BACKGROUND_COLOR_KEY } from "../storage/keys"
6+
import { getStore } from "../storage/store"
7+
8+
// Frame defaults shared by the early window (created on ready, before the renderer exists) and the
9+
// full window setup in appearance.ts, so both draw the same frame.
10+
11+
const oc2Theme = oc2ThemeJson as DesktopTheme
12+
const oc2Background = {
13+
light: resolveThemeVariant(oc2Theme.light, false)["background-base"],
14+
dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"],
15+
}
16+
// Match the renderer's 36px titlebar plus its former 8px content inset.
17+
export const titlebarHeight = 44
18+
19+
export function tone() {
20+
return nativeTheme.shouldUseDarkColors ? "dark" : "light"
21+
}
22+
23+
// The colour the renderer reported on its last run, or the default theme's for the system tone, so
24+
// a window shown before the renderer paints already has the right background.
25+
export function storedBackgroundColor() {
26+
const stored = getStore().get(BACKGROUND_COLOR_KEY)
27+
return typeof stored === "string" ? stored : oc2Background[tone()]
28+
}
29+
30+
export function titlebarOverlay(mode: "light" | "dark" = tone(), zoom = 1) {
31+
return {
32+
color: "#00000000",
33+
symbolColor: mode === "dark" ? "white" : "black",
34+
height: Math.max(titlebarHeight, Math.round(titlebarHeight * zoom)),
35+
}
36+
}

0 commit comments

Comments
 (0)