mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
perf(desktop): boot the renderer alongside the main bundle (#50108)
This commit is contained in:
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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))]
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<never>())
|
||||
export type ProtocolReport = (level: "warning" | "error", message: string, data: Record<string, unknown>) => 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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { WindowBootstrap } from "../shared/window-bootstrap"
|
||||
|
||||
export type ElectronNative = {
|
||||
windowID: string
|
||||
bootstrap: WindowBootstrap
|
||||
getPathForFile(file: File): string
|
||||
}
|
||||
|
||||
@@ -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<string>
|
||||
draftBlobGet(id: string): Promise<ArrayBuffer | null>
|
||||
getWindowID(): string
|
||||
getWindowBootstrap(): WindowBootstrap
|
||||
themeReady(): Promise<void>
|
||||
onMenuCommand(cb: (id: string) => void): () => void
|
||||
onDeepLink(cb: (urls: string[]) => void): () => void
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user