From e1e2efe8ec791a9cb6d83c53954a354039d708c1 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:41:27 +1000 Subject: [PATCH] perf(desktop): show the first window the moment Electron is ready (#49869) --- packages/desktop/scripts/bench-startup.ts | 27 ++++++- packages/desktop/src/main/constants.ts | 14 ++++ packages/desktop/src/main/desktop.ts | 2 + packages/desktop/src/main/index.ts | 20 ++++- .../desktop/src/main/lifecycle/configure.ts | 58 +++++++++++++++ .../desktop/src/main/lifecycle/environment.ts | 74 ++----------------- packages/desktop/src/main/lifecycle/index.ts | 6 +- .../desktop/src/main/windows/appearance.ts | 24 +----- packages/desktop/src/main/windows/defaults.ts | 36 +++++++++ packages/desktop/src/main/windows/early.ts | 66 +++++++++++++++++ packages/desktop/src/main/windows/index.ts | 65 +++++++++------- packages/desktop/src/main/windows/protocol.ts | 18 +---- packages/desktop/src/main/windows/scheme.ts | 22 ++++++ .../desktop/src/main/windows/window-state.ts | 4 + 14 files changed, 291 insertions(+), 145 deletions(-) create mode 100644 packages/desktop/src/main/lifecycle/configure.ts create mode 100644 packages/desktop/src/main/windows/defaults.ts create mode 100644 packages/desktop/src/main/windows/early.ts create mode 100644 packages/desktop/src/main/windows/scheme.ts diff --git a/packages/desktop/scripts/bench-startup.ts b/packages/desktop/scripts/bench-startup.ts index 764e039be04..9c8ee733b87 100644 --- a/packages/desktop/scripts/bench-startup.ts +++ b/packages/desktop/scripts/bench-startup.ts @@ -142,6 +142,14 @@ console.log(`service: ${service}, runs: ${runs} (+${warmup} warm-up), cdp ${cdpP if (service === "warm") await warmService() const samples: Sample[] = [] +// A launch that never produces a renderer would otherwise leave an instance behind that every later +// launch hands off to through the single-instance lock. +process.on("uncaughtException", async (error) => { + console.error(error) + await killApp() + await stopService() + process.exit(1) +}) for (let run = 1 - warmup; run <= runs; run++) { for (const build of builds) { const sample = await launch(build, run) @@ -516,11 +524,18 @@ function mainLog() { const dirs = existsSync(paths.logs) ? readdirSync(paths.logs).sort().reverse() : [] const dir = dirs.map((d) => join(paths.logs, d)).find((d) => existsSync(join(d, "main.log"))) const timeline: [number, string, string][] = [] + let windowShownAt: number | undefined for (const name of dir ? readdirSync(dir).filter((f) => f.endsWith(".log")) : []) { - for (const line of readFileSync(join(dir!, name), "utf8").split(/\r?\n/)) { + const text = readFileSync(join(dir!, name), "utf8") + // electron-log wraps long objects onto continuation lines; read them as part of the entry. + for (const entry of text.split(/\r?\n(?=\[\d{4}-)/)) { + const line = entry.split(/\r?\n/)[0] const m = line.match(/^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d{3})\]\s+\[\w+\]\s+(?:\([\w-]+\)\s+)?(.*)$/) if (!m) continue const message = m[2].replace(/\s*\{.*$/, "").trim() + // A window shown before the logger existed reports when it was shown; the line itself is later. + const shown = /main window visible/.test(message) ? entry.match(/shownAt: (\d+)/)?.[1] : undefined + if (shown) windowShownAt = Number(shown) timeline.push([new Date(m[1].replace(" ", "T")).getTime(), name.replace(/\.log$/, ""), message]) } } @@ -533,7 +548,7 @@ function mainLog() { versionDone: at(/v2 CLI command completed/), serviceStarting: at(/v2 CLI background service starting/), serviceReady: at(/background service ready/), - windowVisible: at(/main window visible/), + windowVisible: windowShownAt ?? at(/main window visible/), } } @@ -592,8 +607,12 @@ function bundledCli(exe: string) { async function warmService() { await stopService() const clis = builds.map((build) => bundledCli(build.exe)) - if (new Set(clis.map((cli) => statSync(cli).size)).size > 1) - console.warn("warning: the compared builds bundle different CLIs; the desktop will restart the service on the mismatch") + const identity = (cli: string) => { + const version = join(dirname(cli), "opencode-cli.version") + return existsSync(version) ? readFileSync(version, "utf8").trim() : String(statSync(cli).size) + } + if (new Set(clis.map(identity)).size > 1) + throw new Error("The compared builds bundle different CLIs; the desktop would restart the service on the mismatch") serviceProcess = spawn(clis[0], ["serve", "--service"], { env, detached: true, stdio: "ignore" }) serviceProcess.unref() const deadline = Date.now() + 60_000 diff --git a/packages/desktop/src/main/constants.ts b/packages/desktop/src/main/constants.ts index 64159494d6e..bc33c5bb271 100644 --- a/packages/desktop/src/main/constants.ts +++ b/packages/desktop/src/main/constants.ts @@ -6,3 +6,17 @@ export const CHANNEL: Channel = raw === "local" || raw === "dev" || raw === "bet export const VERSION = app.isPackaged ? app.getVersion() : (process.env.OPENCODE_VERSION ?? app.getVersion()) export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev" + +const appNames: Record = { + dev: "OpenCode Dev", + beta: "OpenCode Beta", + prod: "OpenCode", +} +const appIDs: Record = { + dev: "ai.opencode.desktop.dev", + beta: "ai.opencode.desktop.beta", + prod: "ai.opencode.desktop", +} +// Local renderer/server mode keeps the dev application identity. +export const APP_NAME = app.isPackaged ? appNames[CHANNEL] : "OpenCode Dev" +export const APP_ID = app.isPackaged ? appIDs[CHANNEL] : "ai.opencode.desktop.dev" diff --git a/packages/desktop/src/main/desktop.ts b/packages/desktop/src/main/desktop.ts index 38169f573d5..4f94c34ec86 100644 --- a/packages/desktop/src/main/desktop.ts +++ b/packages/desktop/src/main/desktop.ts @@ -3,6 +3,7 @@ import { app } from "electron" import { Effect, Layer } from "effect" import { Ipc } from "./ipc" import { DesktopInitialization } from "./lifecycle/desktop-initialization" +import { installContextMenu } from "./lifecycle/environment" import { ApplicationLifecycle } from "./lifecycle" import { BackgroundService } from "./service/background-service" import { DesktopCli } from "./service/desktop-cli" @@ -12,6 +13,7 @@ const runIpc = Effect.fn("Desktop.runIpc")(function* () { const lifecycle = yield* ApplicationLifecycle.Service const ipc = yield* Ipc.registerIpcHandlers if (lifecycle.restoreWindows().length) ipc.installMenu() + yield* installContextMenu yield* Effect.callback((resume) => { const quit = () => resume(Effect.void) app.once("will-quit", quit) diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 8f34744a1ed..8e24a3f5e2d 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -1,3 +1,19 @@ -export {} +import { app } from "electron" +import { acquireApplicationLock, configureApplication } from "./lifecycle/configure" +import { createEarlyWindow } from "./windows/early" +import { registerRendererScheme } from "./windows/scheme" -await import("./desktop") +// This module stays small on purpose. Electron holds the ready event until the entry module has +// finished, and the first window should be on screen before the rest of the main process — a few +// hundred milliseconds of module evaluation and layers — loads. Configuration and the scheme must +// precede ready; the window is created the moment ready fires; everything else is imported after. +configureApplication() +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(() => { + createEarlyWindow() + return import("./desktop") + }) +} diff --git a/packages/desktop/src/main/lifecycle/configure.ts b/packages/desktop/src/main/lifecycle/configure.ts new file mode 100644 index 00000000000..675af90dcc3 --- /dev/null +++ b/packages/desktop/src/main/lifecycle/configure.ts @@ -0,0 +1,58 @@ +import { randomUUID } from "node:crypto" +import { mkdirSync, rmSync } from "node:fs" +import { homedir, tmpdir } from "node:os" +import path from "node:path" +import { app } from "electron" +import { APP_ID, APP_NAME } from "../constants" + +const testOnboarding = process.env.OPENCODE_TEST_ONBOARDING === "1" +const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports" + +// Runs synchronously from the entry module, before Chromium is ready: command-line switches only +// take effect before ready, the single-instance lock is scoped to userData, and the early window +// needs userData to find the persisted window list and state. +export function configureApplication() { + try { + process.chdir(homedir()) + } catch {} + process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true" + + app.setName(APP_NAME) + app.setAppUserModelId(APP_ID) + app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") + const features = app.commandLine.getSwitchValue("enable-features") + app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature) + if (!app.isPackaged) + app.commandLine.appendSwitch("remote-debugging-port", process.env.OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT ?? "9222") + + const testRoot = createTestRoot() + app.setPath("userData", testRoot ? path.join(testRoot, "desktop") : path.join(app.getPath("appData"), APP_ID)) + if (testRoot) { + app.setPath("sessionData", path.join(testRoot, "session")) + if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents")) + } +} + +export function acquireApplicationLock() { + if (app.requestSingleInstanceLock()) return true + app.quit() + return false +} + +function createTestRoot() { + const root = testOnboarding + ? path.join(tmpdir(), `opencode-onboarding-${randomUUID()}`) + : app.isPackaged + ? undefined + : process.env.OPENCODE_DESKTOP_TEST_ROOT + if (!root) return undefined + if (testOnboarding) rmSync(root, { recursive: true, force: true }) + for (const dir of ["data", "config", "cache", "state", "desktop", "session", "documents"]) + mkdirSync(path.join(root, dir), { recursive: true }) + if (testOnboarding) process.env.OPENCODE_DB = ":memory:" + process.env.XDG_DATA_HOME = path.join(root, "data") + process.env.XDG_CONFIG_HOME = path.join(root, "config") + process.env.XDG_CACHE_HOME = path.join(root, "cache") + process.env.XDG_STATE_HOME = path.join(root, "state") + return root +} diff --git a/packages/desktop/src/main/lifecycle/environment.ts b/packages/desktop/src/main/lifecycle/environment.ts index ab07ef2a0fa..9b149a90b79 100644 --- a/packages/desktop/src/main/lifecycle/environment.ts +++ b/packages/desktop/src/main/lifecycle/environment.ts @@ -1,59 +1,18 @@ -import { randomUUID } from "node:crypto" import http from "node:http" -import { homedir, tmpdir } from "node:os" import { getCACertificates, setDefaultCACertificates } from "node:tls" import { app } from "electron" -import contextMenu from "electron-context-menu" -import { Effect, FileSystem, Path } from "effect" -import { CHANNEL } from "../constants" +import { Effect, Path } from "effect" import { DesktopPaths } from "../paths" import { getUserShell, loadShellEnv } from "../service/shell-env" import { registerRendererProtocol, setDockIcon } from "../windows" -const appNames: Record = { - dev: "OpenCode Dev", - beta: "OpenCode Beta", - prod: "OpenCode", -} -const appIDs: Record = { - dev: "ai.opencode.desktop.dev", - beta: "ai.opencode.desktop.beta", - prod: "ai.opencode.desktop", -} -const testOnboarding = process.env.OPENCODE_TEST_ONBOARDING === "1" -const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports" - -export const configureApplication = Effect.fn("Application.configure")(function* () { - const path = yield* Path.Path +// electron-context-menu attaches to every existing and future window, so it can load once the first +// window is up instead of holding up startup with its dependency tree. +export const installContextMenu = Effect.gen(function* () { + const { default: contextMenu } = yield* Effect.promise(() => import("electron-context-menu")) contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false }) - try { - process.chdir(homedir()) - } catch {} - process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true" - - const appID = app.isPackaged ? appIDs[CHANNEL] : "ai.opencode.desktop.dev" - app.setName(app.isPackaged ? appNames[CHANNEL] : "OpenCode Dev") - app.setAppUserModelId(appID) - app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") - const features = app.commandLine.getSwitchValue("enable-features") - app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature) - if (!app.isPackaged) - app.commandLine.appendSwitch("remote-debugging-port", process.env.OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT ?? "9222") - - const testRoot = yield* createTestRoot() - app.setPath("userData", testRoot ? path.join(testRoot, "desktop") : path.join(app.getPath("appData"), appID)) - if (testRoot) { - app.setPath("sessionData", path.join(testRoot, "session")) - if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents")) - } }) -export function acquireApplicationLock() { - if (app.requestSingleInstanceLock()) return true - app.quit() - return false -} - export const prepareApplicationEnvironment = Effect.gen(function* () { yield* loadSystemCertificates yield* loadProxyEnvironment @@ -91,29 +50,6 @@ export const loadProxyEnvironment = Effect.gen(function* () { }).pipe(Effect.catch((error) => Effect.logWarning("failed to load proxy environment", { error }))) }) -const createTestRoot = Effect.fn("Application.createTestRoot")(function* () { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - const root = testOnboarding - ? path.join(tmpdir(), `opencode-onboarding-${randomUUID()}`) - : app.isPackaged - ? undefined - : process.env.OPENCODE_DESKTOP_TEST_ROOT - if (!root) return undefined - if (testOnboarding) yield* fs.remove(root, { recursive: true, force: true }) - yield* Effect.forEach( - ["data", "config", "cache", "state", "desktop", "session", "documents"], - (dir) => fs.makeDirectory(path.join(root, dir), { recursive: true }), - { discard: true }, - ) - if (testOnboarding) process.env.OPENCODE_DB = ":memory:" - process.env.XDG_DATA_HOME = path.join(root, "data") - process.env.XDG_CONFIG_HOME = path.join(root, "config") - process.env.XDG_CACHE_HOME = path.join(root, "cache") - process.env.XDG_STATE_HOME = path.join(root, "state") - return root -}) - const loadSystemCertificates = Effect.try({ try: () => { setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])]) diff --git a/packages/desktop/src/main/lifecycle/index.ts b/packages/desktop/src/main/lifecycle/index.ts index e41bdb4edcd..976dfed2ea6 100644 --- a/packages/desktop/src/main/lifecycle/index.ts +++ b/packages/desktop/src/main/lifecycle/index.ts @@ -9,7 +9,6 @@ import { DesktopLogging, scoped } from "../native/logging" import { DesktopStorage } from "../storage" import { safeWebContentsURL } from "../windows/state" import { getLastFocusedWindow, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows" -import { acquireApplicationLock, configureApplication } from "./environment" import { initializeFirstLaunchOnboarding } from "./onboarding" import { Shutdown } from "./shutdown" @@ -145,7 +144,7 @@ const runtime = Layer.effect( }), ) -// Storage opens after configureApplication has set userData and before windows exist, so window +// Storage opens after the entry module has set userData and before the renderer loads, so window // teardown can clear a window's persisted state and every renderer request finds it ready. const platform = Layer.mergeAll( DesktopLogging.layer, @@ -155,9 +154,6 @@ const platform = Layer.mergeAll( export const layer = Layer.unwrap( Effect.gen(function* () { - // Electron scopes the single-instance lock to userData. - yield* configureApplication() - if (!acquireApplicationLock()) return yield* Effect.interrupt // Decide first-launch state before the storage layer creates drafts.sqlite, which would // otherwise read as evidence of an earlier launch on a fresh install. yield* initializeFirstLaunchOnboarding(app.getPath("userData")) diff --git a/packages/desktop/src/main/windows/appearance.ts b/packages/desktop/src/main/windows/appearance.ts index 965dac1febd..3602a5af1f5 100644 --- a/packages/desktop/src/main/windows/appearance.ts +++ b/packages/desktop/src/main/windows/appearance.ts @@ -1,6 +1,3 @@ -import { resolveThemeVariant } from "@opencode/ui/theme/resolve" -import type { DesktopTheme } from "@opencode/ui/theme/types" -import oc2ThemeJson from "../../../../ui/src/theme/themes/oc-2.json" import { app, BrowserWindow, nativeImage, nativeTheme } from "electron" import type { Path } from "effect" import { type TitlebarTheme } from "../../shared/ipc-contract" @@ -9,16 +6,10 @@ import { emitIpcEvent } from "../ipc-events" import type { DesktopPaths } from "../paths" import { BACKGROUND_COLOR_KEY, PINCH_ZOOM_ENABLED_KEY } from "../storage/keys" import { getStore } from "../storage/store" +import { storedBackgroundColor, titlebarOverlay, tone } from "./defaults" -const oc2Theme = oc2ThemeJson as DesktopTheme -const oc2Background = { - light: resolveThemeVariant(oc2Theme.light, false)["background-base"], - dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"], -} const titlebarThemes = new WeakMap>() const pinchZoomEnabled = new WeakMap() -// Match the renderer's 36px titlebar plus its former 8px content inset. -const titlebarHeight = 44 const maxZoomLevel = 10 const minZoomLevel = 0.2 let backgroundColor: string | undefined @@ -28,7 +19,7 @@ export function windowAppearance(path: Path.Path, paths: DesktopPaths.Resolved) return { title: "OpenCode", icon: iconPath(path, paths), - backgroundColor: getBackgroundColor() ?? oc2Background[mode], + backgroundColor: backgroundColor ?? storedBackgroundColor(), ...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const, @@ -139,17 +130,8 @@ function iconPath(path: Path.Path, paths: DesktopPaths.Resolved) { return path.join(iconsDir(path, paths), `icon.${process.platform === "win32" ? "ico" : "png"}`) } -function tone() { - return nativeTheme.shouldUseDarkColors ? "dark" : "light" -} - function overlay(theme: Partial = {}, zoom = 1) { - const mode = theme.mode ?? tone() - return { - color: "#00000000", - symbolColor: mode === "dark" ? "white" : "black", - height: Math.max(titlebarHeight, Math.round(titlebarHeight * zoom)), - } + return titlebarOverlay(theme.mode ?? tone(), zoom) } function clampZoom(value: number) { diff --git a/packages/desktop/src/main/windows/defaults.ts b/packages/desktop/src/main/windows/defaults.ts new file mode 100644 index 00000000000..6e36c5336c0 --- /dev/null +++ b/packages/desktop/src/main/windows/defaults.ts @@ -0,0 +1,36 @@ +import { resolveThemeVariant } from "@opencode/ui/theme/resolve" +import type { DesktopTheme } from "@opencode/ui/theme/types" +import { nativeTheme } from "electron" +import oc2ThemeJson from "../../../../ui/src/theme/themes/oc-2.json" +import { BACKGROUND_COLOR_KEY } from "../storage/keys" +import { getStore } from "../storage/store" + +// Frame defaults shared by the early window (created on ready, before the renderer exists) and the +// 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 + +export function tone() { + return nativeTheme.shouldUseDarkColors ? "dark" : "light" +} + +// 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. +export function storedBackgroundColor() { + const stored = getStore().get(BACKGROUND_COLOR_KEY) + return typeof stored === "string" ? stored : oc2Background[tone()] +} + +export function titlebarOverlay(mode: "light" | "dark" = tone(), zoom = 1) { + return { + color: "#00000000", + symbolColor: mode === "dark" ? "white" : "black", + height: Math.max(titlebarHeight, Math.round(titlebarHeight * zoom)), + } +} \ No newline at end of file diff --git a/packages/desktop/src/main/windows/early.ts b/packages/desktop/src/main/windows/early.ts new file mode 100644 index 00000000000..5477604d126 --- /dev/null +++ b/packages/desktop/src/main/windows/early.ts @@ -0,0 +1,66 @@ +import { randomUUID } from "node:crypto" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { app, BrowserWindow, screen } from "electron" +import { windowIDArgument } from "../../shared/window-bootstrap" +import { WINDOW_IDS_KEY } from "../storage/keys" +import { getStore } from "../storage/store" +import { storedBackgroundColor, titlebarOverlay } from "./defaults" +import { manageWindowState, readWindowState, resolveWindowState, windowStateFile, type WindowState } from "./window-state" + +export type EarlyWindow = { id: string; win: BrowserWindow; state: WindowState; shownAt: number } + +let pending: EarlyWindow | undefined + +const displays = { + all: () => screen.getAllDisplays().map((display) => display.bounds), + primary: () => screen.getPrimaryDisplay().bounds, + matching: (bounds: Electron.Rectangle) => screen.getDisplayMatching(bounds).bounds, +} + +// Creates and shows the first restored window the moment Electron is ready, before the rest of the +// main process has loaded. The frame options mirror windowAppearance(); the persisted background +// colour stands in for the theme until the renderer applies it, so the window is on screen while the +// bundle, the layers and the renderer boot. restoreWindows() adopts it through takeEarlyWindow(). +export function createEarlyWindow() { + const ids = getStore().get(WINDOW_IDS_KEY) + const id = Array.isArray(ids) && typeof ids[0] === "string" ? ids[0] : randomUUID() + const root = path.dirname(fileURLToPath(import.meta.url)) + const file = path.join(app.getPath("userData"), windowStateFile(id)) + const state = resolveWindowState(readWindowState(file), { width: 1280, height: 800 }, displays) + const icons = app.isPackaged ? path.join(process.resourcesPath, "icons") : path.join(root, "../../resources/icons") + const win = new BrowserWindow({ + x: state.x, + y: state.y, + width: state.width, + height: state.height, + show: true, + autoHideMenuBar: true, + title: "OpenCode", + icon: path.join(icons, `icon.${process.platform === "win32" ? "ico" : "png"}`), + backgroundColor: storedBackgroundColor(), + ...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const, trafficLightPosition: { x: 14, y: 14 } } : {}), + ...(process.platform === "win32" ? { frame: false, titleBarStyle: "hidden" as const, titleBarOverlay: titlebarOverlay() } : {}), + webPreferences: { + preload: path.join(root, "../preload/index.cjs"), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + additionalArguments: [windowIDArgument(id)], + }, + }) + manageWindowState(win, file, state, displays) + // Closing the only window before the rest of the app has adopted it is a quit. + win.once("closed", () => { + if (pending?.win !== win) return + pending = undefined + app.quit() + }) + pending = { id, win, state, shownAt: Date.now() } +} + +export function takeEarlyWindow() { + const taken = pending + pending = undefined + return taken +} diff --git a/packages/desktop/src/main/windows/index.ts b/packages/desktop/src/main/windows/index.ts index 27bd708280b..5f98c9c496f 100644 --- a/packages/desktop/src/main/windows/index.ts +++ b/packages/desktop/src/main/windows/index.ts @@ -24,7 +24,8 @@ import { import { loadWindow, registerRendererProtocol } from "./protocol" import { createWindowRegistry } from "./registry" import { makeWindowRecovery } from "./recovery" -import { manageWindowState, readWindowState, resolveWindowState } from "./window-state" +import { takeEarlyWindow, type EarlyWindow } from "./early" +import { manageWindowState, readWindowState, resolveWindowState, windowStateFile } from "./window-state" import { allowRendererPermissions, wireNavigationPolicy, wireRendererHeaders } from "./security" const themeReady = new WeakMap void>() @@ -88,48 +89,62 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () { const wireWindowRecovery = yield* makeWindowRecovery const restore = () => { + // The entry module created and showed the first restored window on ready; it is adopted here, + // before any renderer loads, so the user never waited for the layers to see a window. + const early = takeEarlyWindow() + const usable = early && !early.win.isDestroyed() ? early : undefined const ids = registry.persisted() - return (ids.length ? ids : [randomUUID()]).map((id) => create(id)) + const list = ids.length ? ids : [usable?.id ?? randomUUID()] + if (usable && !list.includes(usable.id)) usable.win.destroy() + return list.map((id) => create(id, usable?.id === id ? usable : undefined)) } - const create = (id: string = randomUUID()) => { + const create = (id: string = randomUUID(), early?: EarlyWindow) => { const stateFile = path.join(app.getPath("userData"), windowStateFile(id)) - const state = resolveWindowState(readWindowState(stateFile), { width: 1280, height: 800 }, displays) + const state = early?.state ?? resolveWindowState(readWindowState(stateFile), { width: 1280, height: 800 }, displays) const appearance = windowAppearance(path, paths) - const win = new BrowserWindow({ - x: state.x, - y: state.y, - width: state.width, - height: state.height, - show: false, - autoHideMenuBar: true, - ...appearance, - webPreferences: { - ...appearance.webPreferences, - additionalArguments: [windowIDArgument(id)], - }, - }) + const win = + early?.win ?? + new BrowserWindow({ + x: state.x, + y: state.y, + width: state.width, + height: state.height, + show: false, + autoHideMenuBar: true, + ...appearance, + webPreferences: { + ...appearance.webPreferences, + additionalArguments: [windowIDArgument(id)], + }, + }) allowRendererPermissions(win) wireWindowRecovery(win, id, () => relaunchHandler()) wireNavigationPolicy(win, (url) => runFork(openExternalURL(url))) wireRendererHeaders(win) - manageWindowState(win, stateFile, state, displays) + if (!early) manageWindowState(win, stateFile, state, displays) register(win, id) wireFullscreen(win) loadWindow(win, "index.html") wireZoom(win) let contentReady = false let appliedTheme = false - let revealed = false + let revealed = !!early + const focusForTests = () => { + if (app.isPackaged || process.env.OPENCODE_TEST_ONBOARDING !== "1") return + if (process.platform === "darwin") app.focus({ steal: true }) + win.focus() + } + if (early) { + focusForTests() + runFork(Effect.logInfo("main window visible", { window: id, shownAt: early.shownAt })) + } const reveal = () => { if (!contentReady || !appliedTheme || revealed || win.isDestroyed()) return revealed = true win.show() - if (!app.isPackaged && process.env.OPENCODE_TEST_ONBOARDING === "1") { - if (process.platform === "darwin") app.focus({ steal: true }) - win.focus() - } + focusForTests() runFork(Effect.logInfo("main window visible", { window: id })) } const ready = () => { @@ -167,10 +182,6 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () { return { create, restore } }) -function windowStateFile(id: string) { - return `window-state-${safeWindowID(id)}.json` -} - // Mirrors windowStorage() in packages/app/src/runtime/persistence/storage.ts; it is the state // namespace the renderer persists this window's tabs under. function windowDataFile(id: string) { diff --git a/packages/desktop/src/main/windows/protocol.ts b/packages/desktop/src/main/windows/protocol.ts index 7349957c8e8..ae13c8b0578 100644 --- a/packages/desktop/src/main/windows/protocol.ts +++ b/packages/desktop/src/main/windows/protocol.ts @@ -5,23 +5,7 @@ import { Effect, Path } from "effect" import { scoped } from "../native/logging" import { DesktopPaths } from "../paths" import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers" - -const rendererProtocol = "oc" -const rendererHost = "renderer" - -protocol.registerSchemesAsPrivileged([ - { - scheme: rendererProtocol, - privileges: { - secure: true, - standard: true, - supportFetchAPI: true, - stream: true, - // Let Chromium keep V8 bytecode for the renderer bundle between launches. - codeCache: true, - }, - }, -]) +import { rendererHost, rendererProtocol } from "./scheme" export const registerRendererProtocol = Effect.fn("Window.registerRendererProtocol")(function* () { const path = yield* Path.Path diff --git a/packages/desktop/src/main/windows/scheme.ts b/packages/desktop/src/main/windows/scheme.ts new file mode 100644 index 00000000000..4edabbf85e6 --- /dev/null +++ b/packages/desktop/src/main/windows/scheme.ts @@ -0,0 +1,22 @@ +import { protocol } from "electron" + +export const rendererProtocol = "oc" +export const rendererHost = "renderer" + +// Scheme privileges can only be granted before the app is ready, so the entry module calls this +// before it loads anything else. +export function registerRendererScheme() { + protocol.registerSchemesAsPrivileged([ + { + scheme: rendererProtocol, + privileges: { + secure: true, + standard: true, + supportFetchAPI: true, + stream: true, + // Let Chromium keep V8 bytecode for the renderer bundle between launches. + codeCache: true, + }, + }, + ]) +} diff --git a/packages/desktop/src/main/windows/window-state.ts b/packages/desktop/src/main/windows/window-state.ts index 52731b8cc88..c0bda5a8373 100644 --- a/packages/desktop/src/main/windows/window-state.ts +++ b/packages/desktop/src/main/windows/window-state.ts @@ -44,6 +44,10 @@ export function resolveWindowState(saved: unknown, defaults: { width: number; he } satisfies WindowState } +export function windowStateFile(id: string) { + return `window-state-${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.json` +} + export function readWindowState(file: string): unknown { if (!existsSync(file)) return undefined try {