Compare commits

...
Author SHA1 Message Date
LukeParkerDev e77baed670 wip(desktop): load the renderer into the early window before the layers
Measured interleaved against the window-only early start (5 runs):
renderer process 600 -> 259, first paint 814 -> 778, shell 1135 -> 1087,
idle 1676 -> 1578, but first main log line 457 -> 605 and service ready
578 -> 856: serving the renderer's chunks and the RenderProcessHost work
share the main thread with the bundle evaluation. Parked pending a
decision.
2026-09-19 10:26:30 +10:00
LukeParkerDev db4da867e1 perf(desktop): show the first window the moment Electron is ready
The entry module awaited the whole main bundle, which held back
Electron's ready event; windows were then created only after every
layer had built, hidden until the renderer had painted and reported its
theme. Users saw nothing for ~900 ms.

The entry is now a small module that configures the app, takes the
single-instance lock and registers the renderer scheme (all of which must
precede ready), then on ready creates and shows the first restored window
- bounds from its state file, background from the persisted theme colour
- and only then imports the rest of the main process, which adopts that
window in restoreWindows() and loads the renderer into it. Starting the
import before ready measured worse: the module graph evaluates on the
thread Chromium needs to finish initialising.

Configuration moves from an Effect in the lifecycle layer to a
synchronous configure.ts; the renderer scheme constants move to
scheme.ts; frame defaults shared by the early window and windowAppearance
live in defaults.ts. The bench reads the early window's shownAt from the
adoption log line.

Packaged build, warm service, interleaved with the v2 baseline, median
of 5: window visible 893 -> 251 ms, first paint 1065 -> 812 ms; the
first log line and the shell land ~65-100 ms later because the bundle now
starts after the window exists.
2026-09-19 10:07:19 +10:00
15 changed files with 394 additions and 220 deletions
+23 -4
View File
@@ -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
+14
View File
@@ -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<string, string> = {
dev: "OpenCode Dev",
beta: "OpenCode Beta",
prod: "OpenCode",
}
const appIDs: Record<string, string> = {
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"
+18 -2
View File
@@ -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")
})
}
+6 -2
View File
@@ -65,12 +65,16 @@ 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 connect = () => {
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", connect)
// The first window starts loading before this layer exists; a renderer that has already finished
// loading is waiting for its port right now.
if (!win.webContents.isLoading() && win.webContents.getURL()) connect()
}
yield* Effect.sync(() => {
app.on("browser-window-created", wire)
@@ -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
}
@@ -1,59 +1,12 @@
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<string, string> = {
dev: "OpenCode Dev",
beta: "OpenCode Beta",
prod: "OpenCode",
}
const appIDs: Record<string, string> = {
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
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 +44,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")])])
+1 -5
View File
@@ -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"))
@@ -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<BrowserWindow, Partial<TitlebarTheme>>()
const pinchZoomEnabled = new WeakMap<BrowserWindow, boolean>()
// 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,
@@ -137,17 +128,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<TitlebarTheme> = {}, 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) {
@@ -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)),
}
}
@@ -0,0 +1,79 @@
import { randomUUID } from "node:crypto"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { app, BrowserWindow, screen, shell } from "electron"
import { windowIDArgument } from "../../shared/window-bootstrap"
import { resolveExternalURL } from "../files/external-url"
import { WINDOW_IDS_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
import { storedBackgroundColor, titlebarOverlay } from "./defaults"
import { handleRendererProtocol, 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 }
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, and starts loading the renderer into it so the renderer process boots in
// parallel with the main bundle and its layers. The frame options mirror windowAppearance(); the
// persisted background colour stands in for the theme until the renderer applies it. The renderer's
// IPC connection waits for a port the IPC layer hands over once it exists. restoreWindows() adopts the
// window 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)
allowRendererPermissions(win)
wireNavigationPolicy(win, (url) => {
const target = resolveExternalURL(url)
if (target) void shell.openExternal(target)
})
wireRendererHeaders(win)
handleRendererProtocol(path.join(root, "../renderer"))
loadWindow(win, "index.html")
// 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
}
+45 -31
View File
@@ -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<BrowserWindow, () => void>()
@@ -88,48 +89,65 @@ 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)
// The early window arrives with its state, security wiring and renderer load already done.
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)
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
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 +185,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) {
+9 -83
View File
@@ -1,92 +1,18 @@
import { net, protocol } from "electron"
import type { BrowserWindow } from "electron"
import { pathToFileURL } from "node:url"
import { Effect, Path } from "effect"
import { Effect } from "effect"
import { scoped } from "../native/logging"
import { DesktopPaths } from "../paths"
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
import { handleRendererProtocol, setRendererProtocolReporter } from "./scheme"
const rendererProtocol = "oc"
const rendererHost = "renderer"
protocol.registerSchemesAsPrivileged([
{
scheme: rendererProtocol,
privileges: {
secure: true,
standard: true,
supportFetchAPI: true,
stream: true,
},
},
])
export { isRendererUrl, loadWindow } from "./scheme"
// The entry module usually installs the handler before this runs; either way, route its diagnostics
// through the application log from here on.
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<never>())
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 })
}
setRendererProtocolReporter({
warn: (message, data) => runFork(scoped("protocol", Effect.logWarning(message, data))),
error: (message, data) => runFork(scoped("protocol", Effect.logError(message, data))),
})
handleRendererProtocol(paths.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
}
function addDocumentPolicy(response: Response, file: string) {
if (!file.toLowerCase().endsWith(".html")) return response
const headers = new Headers(response.headers)
headers.set(documentPolicyHeader, jsCallStacksDocumentPolicy)
return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
}
@@ -0,0 +1,96 @@
import path from "node:path"
import { pathToFileURL } from "node:url"
import { net, protocol } from "electron"
import type { BrowserWindow } from "electron"
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
// The renderer scheme, its file handler and the window loader, kept free of Effect and logging so
// the entry module can register the scheme before ready and load the first window before the rest
// of the main process exists. Diagnostics go through a reporter the logging layer installs later.
export const rendererProtocol = "oc"
export const rendererHost = "renderer"
type Reporter = {
warn: (message: string, data: Record<string, unknown>) => void
error: (message: string, data: Record<string, unknown>) => void
}
let report: Reporter = {
warn: (message, data) => console.warn(message, data),
error: (message, data) => console.error(message, data),
}
export function setRendererProtocolReporter(reporter: Reporter) {
report = reporter
}
// 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,
},
},
])
}
export function handleRendererProtocol(rendererRoot: string) {
if (protocol.isProtocolHandled(rendererProtocol)) return
protocol.handle(rendererProtocol, async (request) => {
const url = new URL(request.url)
if (url.host !== rendererHost) {
report.warn("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.warn("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 })
}
})
}
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
}
function addDocumentPolicy(response: Response, file: string) {
if (!file.toLowerCase().endsWith(".html")) return response
const headers = new Headers(response.headers)
headers.set(documentPolicyHeader, jsCallStacksDocumentPolicy)
return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
}
@@ -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"])
@@ -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 {