From 7fc3f68007db34cd4ada8c185a198daa41627bfd Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:15:58 +1000 Subject: [PATCH] perf(desktop): boot the renderer alongside the main bundle (#50108) --- packages/desktop/scripts/bench-startup.ts | 21 +++- packages/desktop/scripts/bench-variant.ps1 | 10 ++ packages/desktop/src/main/desktop.ts | 5 +- .../desktop/src/main/ipc-handlers/events.ts | 2 +- packages/desktop/src/main/ipc.ts | 7 +- .../desktop/src/main/lifecycle/environment.ts | 9 +- packages/desktop/src/main/lifecycle/index.ts | 2 + packages/desktop/src/main/lifecycle/marks.ts | 9 +- packages/desktop/src/main/storage/index.ts | 2 + .../desktop/src/main/windows/bootstrap.ts | 20 ++++ packages/desktop/src/main/windows/early.ts | 38 ++++++- packages/desktop/src/main/windows/index.ts | 23 ++-- packages/desktop/src/main/windows/protocol.ts | 103 ++++++++---------- packages/desktop/src/main/windows/scheme.ts | 20 ++++ packages/desktop/src/main/windows/security.ts | 2 +- packages/desktop/src/preload/index.ts | 7 +- packages/desktop/src/preload/types.ts | 3 + packages/desktop/src/renderer/api-types.ts | 2 + packages/desktop/src/renderer/api.ts | 1 + packages/desktop/src/renderer/desktop-app.tsx | 18 ++- .../desktop/src/renderer/startup/locale.ts | 17 ++- .../src/shared/window-bootstrap.test.ts | 16 ++- .../desktop/src/shared/window-bootstrap.ts | 24 ++-- 23 files changed, 252 insertions(+), 109 deletions(-) create mode 100644 packages/desktop/scripts/bench-variant.ps1 create mode 100644 packages/desktop/src/main/windows/bootstrap.ts diff --git a/packages/desktop/scripts/bench-startup.ts b/packages/desktop/scripts/bench-startup.ts index 19d00c87930..a81128d0ad5 100644 --- a/packages/desktop/scripts/bench-startup.ts +++ b/packages/desktop/scripts/bench-startup.ts @@ -191,8 +191,14 @@ const phaseOrder = [ ["electron js init → entry", "nodeBootstrapped", "entryStart"], ["entry → chromium ready", "entryStart", "electronReady"], ["ready → window shown", "electronReady", "windowVisible"], - ["main bundle load + evaluate", "windowVisible", "bundleEvaluated"], - ["layers → first log line", "bundleEvaluated", "appStarting"], + ["window → renderer assets served", "windowVisible", "rendererAssetsServed"], + ["main bundle load + evaluate", "rendererAssetsServed", "bundleEvaluated"], + ["bundle → onboarding decided", "bundleEvaluated", "onboardingDecided"], + ["onboarding → logging ready", "onboardingDecided", "loggingReady"], + ["logging → first log line", "loggingReady", "appStarting"], + ["first log line → storage open", "appStarting", "storageOpen"], + ["storage → initialization done", "storageOpen", "initializationDone"], + ["initialization → layers ready", "initializationDone", "layersReady"], ["layers → renderer process", "appStarting", "rendererProcess"], ["renderer boot → first paint", "rendererProcess", "firstPaint"], ["first paint → shell", "firstPaint", "shellVisible"], @@ -397,8 +403,15 @@ async function launch(build: { label: string; exe: string }, run: number): Promi nodeBootstrapped: boot && Math.round(boot.origin + boot.bootstrapComplete - spawnAt), entryStart: main.marks.entry && main.marks.entry - spawnAt, electronReady: main.marks.ready && main.marks.ready - spawnAt, + rendererAssetsServed: main.marks.served && main.marks.served - spawnAt, bundleEvaluated: main.marks.bundle && main.marks.bundle - spawnAt, + onboardingDecided: main.marks.onboarding && main.marks.onboarding - spawnAt, + loggingReady: main.marks.logging && main.marks.logging - spawnAt, + crashReporterStarted: main.marks.crash && main.marks.crash - spawnAt, appStarting: main.appStarting && main.appStarting - spawnAt, + storageOpen: main.marks.storage && main.marks.storage - spawnAt, + initializationDone: main.marks.init && main.marks.init - spawnAt, + layersReady: main.marks.layers && main.marks.layers - spawnAt, cliVersionStart: main.versionStart && main.versionStart - spawnAt, cliVersionDone: main.versionDone && main.versionDone - spawnAt, serviceStarting: main.serviceStarting && main.serviceStarting - spawnAt, @@ -593,8 +606,8 @@ function mainLog() { // 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) - if (/app starting/.test(message)) - for (const [, key, value] of entry.matchAll(/\b(entry|ready|window|bundle): (\d{10,})/g)) marks[key] = Number(value) + if (/app starting|layers ready/.test(message)) + for (const [, key, value] of entry.matchAll(/\b(\w+): (\d{10,})/g)) marks[key] = Number(value) timeline.push([new Date(m[1].replace(" ", "T")).getTime(), name.replace(/\.log$/, ""), message]) } } diff --git a/packages/desktop/scripts/bench-variant.ps1 b/packages/desktop/scripts/bench-variant.ps1 new file mode 100644 index 00000000000..5f9b869aca4 --- /dev/null +++ b/packages/desktop/scripts/bench-variant.ps1 @@ -0,0 +1,10 @@ +param([Parameter(Mandatory)][string]$Name, [int]$Runs = 5) +$ErrorActionPreference = "Stop" +Set-Location $PSScriptRoot\.. +# electron-vite directly: the package's prebuild hook re-downloads the CLI, which the bench keeps fixed. +bunx electron-vite build 2>&1 | Select-String -Pattern "built in|error" | Select-Object -Last 3 +if (-not (Test-Path out\main\index.js)) { throw "build failed" } +bunx electron-builder --win --dir --config electron-builder.config.ts 2>&1 | Select-String -Pattern "error|signing with signtool.*OpenCode Dev" | Select-Object -Last 2 +if (Test-Path "dist\$Name-unpacked") { Remove-Item "dist\$Name-unpacked" -Recurse -Force } +Rename-Item -Path dist\win-unpacked -NewName "$Name-unpacked" +bun ./scripts/bench-startup.ts --exe "dist\base-unpacked\OpenCode Dev.exe" --compare "dist\$Name-unpacked\OpenCode Dev.exe" --runs $Runs --warmup 1 --window-at=-1700,20 --out "dist\bench-startup\$Name" 2>&1 | Select-String -Pattern "^warm service|^\s{2,}|^phases|^\S+\s+\d+\s+\(|^report|^warm-up|Error|error" | Select-Object -Last 45 diff --git a/packages/desktop/src/main/desktop.ts b/packages/desktop/src/main/desktop.ts index 33c88d23d50..bf7e72a5b8d 100644 --- a/packages/desktop/src/main/desktop.ts +++ b/packages/desktop/src/main/desktop.ts @@ -14,7 +14,9 @@ import { marks } from "./lifecycle/marks" marks.bundle = Date.now() const runIpc = Effect.fn("Desktop.runIpc")(function* () { - const lifecycle = yield* ApplicationLifecycle.Service + 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() yield* installContextMenu @@ -36,3 +38,4 @@ runIpc().pipe( Effect.scoped, NodeRuntime.runMain, ) + diff --git a/packages/desktop/src/main/ipc-handlers/events.ts b/packages/desktop/src/main/ipc-handlers/events.ts index fcd6bbae1cb..83eeebd2454 100644 --- a/packages/desktop/src/main/ipc-handlers/events.ts +++ b/packages/desktop/src/main/ipc-handlers/events.ts @@ -4,7 +4,7 @@ import { EventRpcs } from "../../shared/ipc-rpc" import { ipcEventStream } from "../ipc-events" import { IpcPortHandoff } from "../ipc-transport" import { Shutdown } from "../lifecycle/shutdown" -import { isRendererUrl } from "../windows/protocol" +import { isRendererUrl } from "../windows/scheme" import { DesktopStorage } from "../storage" import { sender } from "./context" diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index a79750d57c1..718902eb7f8 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -65,12 +65,15 @@ export const registerIpcHandlers = Effect.gen(function* () { if (input.type !== "keyDown" || input.key !== "Escape") return win.webContents.send(DragCancelEvent) }) - win.webContents.on("did-finish-load", () => { + const post = () => { if (win.isDestroyed() || win.webContents.isDestroyed()) return const channel = new MessageChannelMain() handoff.bind(win.webContents, channel.port1) win.webContents.postMessage(IpcTransportPort, null, [channel.port2]) - }) + } + win.webContents.on("did-finish-load", post) + // The first window starts loading before the layers exist and may already be done. + if (!win.webContents.isLoading() && win.webContents.getURL()) post() } yield* Effect.sync(() => { app.on("browser-window-created", wire) diff --git a/packages/desktop/src/main/lifecycle/environment.ts b/packages/desktop/src/main/lifecycle/environment.ts index 9b149a90b79..49ebdf0d965 100644 --- a/packages/desktop/src/main/lifecycle/environment.ts +++ b/packages/desktop/src/main/lifecycle/environment.ts @@ -4,7 +4,8 @@ import { app } from "electron" import { Effect, Path } from "effect" import { DesktopPaths } from "../paths" import { getUserShell, loadShellEnv } from "../service/shell-env" -import { registerRendererProtocol, setDockIcon } from "../windows" +import { registerRendererProtocol, setDockIcon, setProtocolReporter } from "../windows" +import { scoped } from "../native/logging" // 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. @@ -37,7 +38,11 @@ export const prepareDesktop = Effect.gen(function* () { const paths = yield* DesktopPaths.resolve if (app.isPackaged || process.env.OPENCODE_DESKTOP_DISABLE_PROTOCOL_REGISTRATION !== "1") app.setAsDefaultProtocolClient("opencode") - yield* registerRendererProtocol() + const runFork = Effect.runForkWith(yield* Effect.context()) + setProtocolReporter((level, message, data) => + runFork(scoped("protocol", level === "error" ? Effect.logError(message, data) : Effect.logWarning(message, data))), + ) + registerRendererProtocol(paths.rendererRoot) setDockIcon(path, paths) }) diff --git a/packages/desktop/src/main/lifecycle/index.ts b/packages/desktop/src/main/lifecycle/index.ts index 976dfed2ea6..c9cf1821aef 100644 --- a/packages/desktop/src/main/lifecycle/index.ts +++ b/packages/desktop/src/main/lifecycle/index.ts @@ -9,6 +9,7 @@ import { DesktopLogging, scoped } from "../native/logging" import { DesktopStorage } from "../storage" import { safeWebContentsURL } from "../windows/state" import { getLastFocusedWindow, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows" +import { marks } from "./marks" import { initializeFirstLaunchOnboarding } from "./onboarding" import { Shutdown } from "./shutdown" @@ -157,6 +158,7 @@ export const layer = Layer.unwrap( // 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")) + marks.onboarding = Date.now() return runtime.pipe(Layer.provideMerge(platform)) }), ) diff --git a/packages/desktop/src/main/lifecycle/marks.ts b/packages/desktop/src/main/lifecycle/marks.ts index 72b767bc78e..3aecd1cba5a 100644 --- a/packages/desktop/src/main/lifecycle/marks.ts +++ b/packages/desktop/src/main/lifecycle/marks.ts @@ -1,4 +1,7 @@ // Startup marks, epoch ms. The entry module records them before any logger exists; the logging -// layer reports them with "app starting" so the startup benchmark can split the time before the -// first log line into Electron's own initialisation, our entry, and the main bundle. -export const marks: { entry: number; ready?: number; window?: number; bundle?: number } = { entry: Date.now() } +// layer reports the early ones with "app starting" and the rest with "layers ready", so the startup +// benchmark can split the time before the renderer gets its IPC port into Electron's own +// initialisation, our entry, the main bundle and each layer. +export const marks: { entry: number } & Partial< + Record<"ready" | "window" | "served" | "bundle" | "onboarding" | "logging" | "crash" | "storage" | "init" | "layers", number> +> = { entry: Date.now() } diff --git a/packages/desktop/src/main/storage/index.ts b/packages/desktop/src/main/storage/index.ts index f75aea02dee..3baa8792620 100644 --- a/packages/desktop/src/main/storage/index.ts +++ b/packages/desktop/src/main/storage/index.ts @@ -2,6 +2,7 @@ export * as DesktopStorage from "./index" import { app, BrowserWindow } from "electron" import { Context, Effect, Layer, Path } from "effect" +import { marks } from "../lifecycle/marks" import { openDatabase } from "./database" import { createDraftStore } from "./drafts" import { importLegacyStores } from "./legacy" @@ -40,6 +41,7 @@ export const layer = Layer.effect( storage.close() }), ) + marks.storage = Date.now() return Service.of(storage) }), ) diff --git a/packages/desktop/src/main/windows/bootstrap.ts b/packages/desktop/src/main/windows/bootstrap.ts new file mode 100644 index 00000000000..19fea80de85 --- /dev/null +++ b/packages/desktop/src/main/windows/bootstrap.ts @@ -0,0 +1,20 @@ +import { windowBootstrapArgument, type WindowBootstrap } from "../../shared/window-bootstrap" +import { getDefaultServerUrl } from "../service/server-settings" +import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY } from "../storage/keys" +import { getStore } from "../storage/store" + +// The settings store is already in memory when a window is created, so the renderer gets the +// answers its shell gate would otherwise ask for over IPC. A fresh install has no onboarding +// decision yet; the renderer asks once the layers have made one. +export function windowBootstrap(id: string): WindowBootstrap { + const complete = getStore().get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY) + return { + id, + firstLaunchPending: typeof complete === "boolean" ? !complete : undefined, + defaultServerUrl: getDefaultServerUrl(), + } +} + +export function windowArguments(id: string) { + return [windowBootstrapArgument(windowBootstrap(id))] +} diff --git a/packages/desktop/src/main/windows/early.ts b/packages/desktop/src/main/windows/early.ts index 5477604d126..7a9cbb042b7 100644 --- a/packages/desktop/src/main/windows/early.ts +++ b/packages/desktop/src/main/windows/early.ts @@ -1,14 +1,25 @@ 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 { app, BrowserWindow, screen, shell } from "electron" +import { resolveExternalURL } from "../files/external-url" +import { windowArguments } from "./bootstrap" import { WINDOW_IDS_KEY } from "../storage/keys" import { getStore } from "../storage/store" import { storedBackgroundColor, titlebarOverlay } from "./defaults" +import { registerRendererProtocol } from "./protocol" +import { loadWindow } from "./scheme" +import { allowRendererPermissions, wireNavigationPolicy, wireRendererHeaders } from "./security" import { manageWindowState, readWindowState, resolveWindowState, windowStateFile, type WindowState } from "./window-state" -export type EarlyWindow = { id: string; win: BrowserWindow; state: WindowState; shownAt: number } +export type EarlyWindow = { + id: string + win: BrowserWindow + state: WindowState + shownAt: number + // Navigation policy is wired before the layers exist; the adopter swaps in the logged version. + openExternal: (url: string) => void +} let pending: EarlyWindow | undefined @@ -46,7 +57,7 @@ export function createEarlyWindow() { contextIsolation: true, nodeIntegration: false, sandbox: true, - additionalArguments: [windowIDArgument(id)], + additionalArguments: windowArguments(id), }, }) manageWindowState(win, file, state, displays) @@ -56,7 +67,24 @@ export function createEarlyWindow() { pending = undefined app.quit() }) - pending = { id, win, state, shownAt: Date.now() } + const record: EarlyWindow = { + id, + win, + state, + shownAt: Date.now(), + openExternal: (url) => { + const target = resolveExternalURL(url) + if (target) void shell.openExternal(target) + }, + } + pending = record + // The renderer boots while the main bundle and layers load, instead of after them. Everything the + // page needs before its first request is wired here; the IPC port arrives once the layers are up. + registerRendererProtocol(path.join(root, "../renderer")) + allowRendererPermissions(win) + wireNavigationPolicy(win, (url) => record.openExternal(url)) + wireRendererHeaders(win) + loadWindow(win, "index.html") } export function takeEarlyWindow() { diff --git a/packages/desktop/src/main/windows/index.ts b/packages/desktop/src/main/windows/index.ts index 5f98c9c496f..8663b9b5481 100644 --- a/packages/desktop/src/main/windows/index.ts +++ b/packages/desktop/src/main/windows/index.ts @@ -7,7 +7,7 @@ import { DesktopPaths } from "../paths" import { DesktopStorage } from "../storage" import { getStore } from "../storage/store" import { WINDOW_IDS_KEY } from "../storage/keys" -import { windowIDArgument } from "../../shared/window-bootstrap" +import { windowArguments } from "./bootstrap" import { getBackgroundColor, getPinchZoomEnabled, @@ -21,7 +21,8 @@ import { wireFullscreen, wireZoom, } from "./appearance" -import { loadWindow, registerRendererProtocol } from "./protocol" +import { registerRendererProtocol, setProtocolReporter } from "./protocol" +import { loadWindow } from "./scheme" import { createWindowRegistry } from "./registry" import { makeWindowRecovery } from "./recovery" import { takeEarlyWindow, type EarlyWindow } from "./early" @@ -48,6 +49,7 @@ export { getBackgroundColor, getPinchZoomEnabled, registerRendererProtocol, + setProtocolReporter, setBackgroundColor, setDockIcon, setPinchZoomEnabled, @@ -115,18 +117,23 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () { ...appearance, webPreferences: { ...appearance.webPreferences, - additionalArguments: [windowIDArgument(id)], + additionalArguments: windowArguments(id), }, }) - allowRendererPermissions(win) + // The early window was secured and loaded when it was created; only its external-URL policy is + // upgraded to the logged one. + if (early) early.openExternal = (url) => runFork(openExternalURL(url)) + if (!early) { + allowRendererPermissions(win) + wireNavigationPolicy(win, (url) => runFork(openExternalURL(url))) + wireRendererHeaders(win) + manageWindowState(win, stateFile, state, displays) + } wireWindowRecovery(win, id, () => relaunchHandler()) - wireNavigationPolicy(win, (url) => runFork(openExternalURL(url))) - wireRendererHeaders(win) - if (!early) manageWindowState(win, stateFile, state, displays) register(win, id) wireFullscreen(win) - loadWindow(win, "index.html") + if (!early) loadWindow(win, "index.html") wireZoom(win) let contentReady = false let appliedTheme = false diff --git a/packages/desktop/src/main/windows/protocol.ts b/packages/desktop/src/main/windows/protocol.ts index ae13c8b0578..95cad665e7c 100644 --- a/packages/desktop/src/main/windows/protocol.ts +++ b/packages/desktop/src/main/windows/protocol.ts @@ -1,73 +1,57 @@ import { net, protocol } from "electron" -import type { BrowserWindow } from "electron" +import path from "node:path" import { pathToFileURL } from "node:url" -import { Effect, Path } from "effect" -import { scoped } from "../native/logging" -import { DesktopPaths } from "../paths" import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers" import { rendererHost, rendererProtocol } from "./scheme" -export const registerRendererProtocol = Effect.fn("Window.registerRendererProtocol")(function* () { - const path = yield* Path.Path - const paths = yield* DesktopPaths.resolve - const runFork = Effect.runForkWith(yield* Effect.context()) +export type ProtocolReport = (level: "warning" | "error", message: string, data: Record) => void + +// The entry module registers the handler the moment the first window exists, before logging is up, +// so problems go to the console until the logging layer installs a reporter. +let report: ProtocolReport = (level, message, data) => console[level === "error" ? "error" : "warn"](message, data) + +export function setProtocolReporter(reporter: ProtocolReport) { + report = reporter +} + +export function registerRendererProtocol(rendererRoot: string) { if (protocol.isProtocolHandled(rendererProtocol)) return protocol.handle(rendererProtocol, async (request) => { - const url = new URL(request.url) - if (url.host !== rendererHost) { - runFork(scoped("protocol", Effect.logWarning("rejected host", { url: request.url }))) - return new Response("Not found", { status: 404 }) - } - - const file = path.resolve(paths.rendererRoot, `.${decodeURIComponent(url.pathname)}`) - const rel = path.relative(paths.rendererRoot, file) - if (rel.startsWith("..") || path.isAbsolute(rel)) { - runFork(scoped("protocol", Effect.logWarning("rejected path", { url: request.url, file }))) - return new Response("Not found", { status: 404 }) - } - - try { - const range = request.headers.get("range") - const response = await net.fetch(pathToFileURL(file).toString(), { headers: range ? { range } : undefined }) - if (response.status >= 400) { - runFork( - scoped( - "protocol", - Effect.logError("fetch failed", { - url: request.url, - file, - status: response.status, - statusText: response.statusText, - }), - ), - ) - } - return addDocumentPolicy(response, file) - } catch (error) { - runFork(scoped("protocol", Effect.logError("fetch error", { url: request.url, file, error }))) - return new Response("Not found", { status: 404 }) - } + return serve(request, rendererRoot) }) -}) - -export function loadWindow(win: BrowserWindow, html: string) { - const devUrl = process.env.ELECTRON_RENDERER_URL - if (devUrl) { - void win.loadURL(new URL(html, devUrl).toString()) - return - } - void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`) } -export function isRendererUrl(value?: string, html = false) { - if (!value || !URL.canParse(value)) return false - const url = new URL(value) - if (html && !url.pathname.endsWith(".html")) return false - if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true - const devUrl = process.env.ELECTRON_RENDERER_URL - if (!devUrl || !URL.canParse(devUrl)) return false - return url.origin === new URL(devUrl).origin +async function serve(request: Request, rendererRoot: string) { + const url = new URL(request.url) + if (url.host !== rendererHost) { + report("warning", "rejected host", { url: request.url }) + return new Response("Not found", { status: 404 }) + } + + const file = path.resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`) + const rel = path.relative(rendererRoot, file) + if (rel.startsWith("..") || path.isAbsolute(rel)) { + report("warning", "rejected path", { url: request.url, file }) + return new Response("Not found", { status: 404 }) + } + + try { + const range = request.headers.get("range") + const response = await net.fetch(pathToFileURL(file).toString(), { headers: range ? { range } : undefined }) + if (response.status >= 400) { + report("error", "fetch failed", { + url: request.url, + file, + status: response.status, + statusText: response.statusText, + }) + } + return addDocumentPolicy(response, file) + } catch (error) { + report("error", "fetch error", { url: request.url, file, error }) + return new Response("Not found", { status: 404 }) + } } function addDocumentPolicy(response: Response, file: string) { @@ -76,3 +60,4 @@ function addDocumentPolicy(response: Response, file: string) { headers.set(documentPolicyHeader, jsCallStacksDocumentPolicy) return new Response(response.body, { status: response.status, statusText: response.statusText, headers }) } + diff --git a/packages/desktop/src/main/windows/scheme.ts b/packages/desktop/src/main/windows/scheme.ts index 4edabbf85e6..345c75fa9c9 100644 --- a/packages/desktop/src/main/windows/scheme.ts +++ b/packages/desktop/src/main/windows/scheme.ts @@ -1,8 +1,28 @@ import { protocol } from "electron" +import type { BrowserWindow } from "electron" export const rendererProtocol = "oc" export const rendererHost = "renderer" +export function loadWindow(win: BrowserWindow, html: string) { + const devUrl = process.env.ELECTRON_RENDERER_URL + if (devUrl) { + void win.loadURL(new URL(html, devUrl).toString()) + return + } + void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`) +} + +export function isRendererUrl(value?: string, html = false) { + if (!value || !URL.canParse(value)) return false + const url = new URL(value) + if (html && !url.pathname.endsWith(".html")) return false + if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true + const devUrl = process.env.ELECTRON_RENDERER_URL + if (!devUrl || !URL.canParse(devUrl)) return false + return url.origin === new URL(devUrl).origin +} + // 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() { diff --git a/packages/desktop/src/main/windows/security.ts b/packages/desktop/src/main/windows/security.ts index 2387e997134..e9aa161ec9d 100644 --- a/packages/desktop/src/main/windows/security.ts +++ b/packages/desktop/src/main/windows/security.ts @@ -1,7 +1,7 @@ import type { BrowserWindow } from "electron" import { SidecarCredentials } from "../service/sidecar-credentials" import { addRendererHeaders, hasHeader, upsertHeader } from "./headers" -import { isRendererUrl } from "./protocol" +import { isRendererUrl } from "./scheme" const rendererPermissions = new Set(["clipboard-sanitized-write", "notifications"]) diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 2c7b4a165c1..e3181ff973c 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -1,6 +1,6 @@ import { contextBridge, ipcRenderer, webUtils } from "electron" import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport" -import { windowIDFromArguments } from "../shared/window-bootstrap" +import { windowBootstrapFromArguments } from "../shared/window-bootstrap" ipcRenderer.on(IpcTransportPort, (event) => { const port = event.ports[0] @@ -9,7 +9,10 @@ ipcRenderer.on(IpcTransportPort, (event) => { ipcRenderer.on(DragCancelEvent, () => window.dispatchEvent(new Event(DragCancelEvent))) +const bootstrap = windowBootstrapFromArguments(process.argv) + contextBridge.exposeInMainWorld("electron", { - windowID: windowIDFromArguments(process.argv), + windowID: bootstrap.id, + bootstrap, getPathForFile: (file: File) => webUtils.getPathForFile(file), }) diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 500c564de36..85be1519706 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -1,4 +1,7 @@ +import type { WindowBootstrap } from "../shared/window-bootstrap" + export type ElectronNative = { windowID: string + bootstrap: WindowBootstrap getPathForFile(file: File): string } diff --git a/packages/desktop/src/renderer/api-types.ts b/packages/desktop/src/renderer/api-types.ts index 3fe255855ea..e8f9ca5e8ea 100644 --- a/packages/desktop/src/renderer/api-types.ts +++ b/packages/desktop/src/renderer/api-types.ts @@ -5,6 +5,7 @@ import type { UpdaterState } from "@opencode/app/updater" import type { WslServersPlatform } from "@opencode/app/wsl/types" import type { SshPlatform } from "@opencode/app/ssh" import type { BrowserPaneRequest } from "../shared/ipc-rpc/browser" +import type { WindowBootstrap } from "../shared/window-bootstrap" import type { ClipboardImage, DirectoryPickerOptions, @@ -53,6 +54,7 @@ export type ElectronAPI = { draftBlobPut(data: ArrayBuffer): Promise draftBlobGet(id: string): Promise getWindowID(): string + getWindowBootstrap(): WindowBootstrap themeReady(): Promise onMenuCommand(cb: (id: string) => void): () => void onDeepLink(cb: (urls: string[]) => void): () => void diff --git a/packages/desktop/src/renderer/api.ts b/packages/desktop/src/renderer/api.ts index 8859413cb56..4d1f5c6dd00 100644 --- a/packages/desktop/src/renderer/api.ts +++ b/packages/desktop/src/renderer/api.ts @@ -111,6 +111,7 @@ export const api: ElectronAPI = { draftBlobGet: (id) => invoke("DraftsGetBlob", { id }).then((data) => (data ? toArrayBuffer(data) : null)), getWindowID: () => window.electron.windowID, + getWindowBootstrap: () => window.electron.bootstrap, themeReady: () => invoke("WindowThemeReady"), onMenuCommand: (cb) => listen("MenuCommandTriggered", (event) => cb(event.id)), onDeepLink: (cb) => listen("DeepLinksOpened", (event) => cb(mutable(event.urls))), diff --git a/packages/desktop/src/renderer/desktop-app.tsx b/packages/desktop/src/renderer/desktop-app.tsx index d334bad9596..a0182b77064 100644 --- a/packages/desktop/src/renderer/desktop-app.tsx +++ b/packages/desktop/src/renderer/desktop-app.tsx @@ -48,15 +48,23 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform; drawingReady: false, route, }) + // The window was created with the answers the shell gate needs; only a fresh install, which has no + // onboarding decision yet, asks over IPC and waits for the port. + const bootstrap = props.api.getWindowBootstrap() const [firstLaunch] = createResource(() => - props.api.isFirstLaunchOnboardingPending().catch((error) => { - console.error("[desktop-onboarding] first launch check failed", error) - return false - }), + bootstrap.firstLaunchPending !== undefined + ? Promise.resolve(bootstrap.firstLaunchPending) + : props.api.isFirstLaunchOnboardingPending().catch((error) => { + console.error("[desktop-onboarding] first launch check failed", error) + return false + }), ) const platform = createDesktopPlatform(props.api, windowState, props.updater) const [sidecar, { mutate: setSidecar }] = createResource(() => props.api.awaitInitialization()) - const [defaultServer] = createResource(() => platform.getDefaultServer?.()) + const [defaultServer] = createResource(async () => { + if (bootstrap.defaultServerUrl === undefined) return platform.getDefaultServer?.() + return bootstrap.defaultServerUrl ? ServerConnection.Key.make(bootstrap.defaultServerUrl) : null + }) const [locale] = createResource(() => preloadStoredLocale(platform)) const [initialRoute] = createResource( () => !firstLaunch.loading && (firstLaunch() && initialUrl === "/" ? "/new-session" : initialUrl), diff --git a/packages/desktop/src/renderer/startup/locale.ts b/packages/desktop/src/renderer/startup/locale.ts index 19eab71ef8d..a260f5cb574 100644 --- a/packages/desktop/src/renderer/startup/locale.ts +++ b/packages/desktop/src/renderer/startup/locale.ts @@ -1,9 +1,22 @@ import { loadLocaleDict, normalizeLocale, type Locale, type Platform } from "@opencode/app/desktop" import { storedLocaleValue } from "./locale-value" +// The stored language lives in the main process's SQLite store, behind the IPC port. A copy of the +// last answer in localStorage lets the shell mount without waiting for the port; the store is still +// asked every launch and the copy refreshed, and the language provider hydrates from the store +// itself, so a stale copy costs one visible switch, not a wrong language. +const cacheKey = "opencode.desktop.language" + export async function preloadStoredLocale(platform: Platform) { - const raw = await platform.storage?.("opencode.global.dat").getItem("language") - const locale = storedLocale(raw) + const fresh = Promise.resolve(platform.storage?.("opencode.global.dat").getItem("language")).then( + (raw) => { + localStorage.setItem(cacheKey, raw ?? "") + return raw + }, + () => undefined, + ) + const cached = localStorage.getItem(cacheKey) + const locale = storedLocale(cached ?? (await fresh)) if (!locale) return if (locale !== "en") await loadLocaleDict(locale) return locale diff --git a/packages/desktop/src/shared/window-bootstrap.test.ts b/packages/desktop/src/shared/window-bootstrap.test.ts index bfea76422aa..69597c80337 100644 --- a/packages/desktop/src/shared/window-bootstrap.test.ts +++ b/packages/desktop/src/shared/window-bootstrap.test.ts @@ -1,13 +1,17 @@ import { describe, expect, test } from "bun:test" -import { windowIDArgument, windowIDFromArguments } from "./window-bootstrap" +import { windowBootstrapArgument, windowBootstrapFromArguments } from "./window-bootstrap" describe("window bootstrap", () => { - test("round-trips the window ID through renderer arguments", () => { - const id = "window/id with spaces" - expect(windowIDFromArguments(["electron", windowIDArgument(id)])).toBe(id) + test("round-trips through argv", () => { + const bootstrap = { id: "win a/b ü", firstLaunchPending: false, defaultServerUrl: "http://127.0.0.1:1234" } + expect(windowBootstrapFromArguments(["electron", windowBootstrapArgument(bootstrap)])).toEqual(bootstrap) }) - test("requires a window ID argument", () => { - expect(() => windowIDFromArguments(["electron"])).toThrow("Window ID argument not found") + test("keeps unknown values absent", () => { + expect(windowBootstrapFromArguments([windowBootstrapArgument({ id: "x" })])).toEqual({ id: "x" }) + }) + + test("throws when the argument is missing", () => { + expect(() => windowBootstrapFromArguments(["electron"])).toThrow("Window bootstrap argument not found") }) }) diff --git a/packages/desktop/src/shared/window-bootstrap.ts b/packages/desktop/src/shared/window-bootstrap.ts index 79c4c47b6b4..17311533b44 100644 --- a/packages/desktop/src/shared/window-bootstrap.ts +++ b/packages/desktop/src/shared/window-bootstrap.ts @@ -1,11 +1,19 @@ -const windowIDPrefix = "--opencode-window-id=" - -export function windowIDArgument(id: string) { - return windowIDPrefix + encodeURIComponent(id) +// What the main process already knows when it creates a window, handed to the renderer through the +// preload's argv so the shell can mount before the IPC port exists. Undefined means "ask over IPC". +export type WindowBootstrap = { + id: string + firstLaunchPending?: boolean + defaultServerUrl?: string | null } -export function windowIDFromArguments(args: readonly string[]) { - const value = args.find((arg) => arg.startsWith(windowIDPrefix))?.slice(windowIDPrefix.length) - if (!value) throw new Error("Window ID argument not found") - return decodeURIComponent(value) +const prefix = "--opencode-window=" + +export function windowBootstrapArgument(bootstrap: WindowBootstrap) { + return prefix + encodeURIComponent(JSON.stringify(bootstrap)) +} + +export function windowBootstrapFromArguments(args: readonly string[]): WindowBootstrap { + const value = args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length) + if (!value) throw new Error("Window bootstrap argument not found") + return JSON.parse(decodeURIComponent(value)) }