diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 6c4e6d5ca1..8ec39c3c9c 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -42,7 +42,7 @@ import { initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" -import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows" +import { createLoadingWindow, createMainWindow, registerRendererProtocol, setBackgroundColor, setDockIcon } from "./windows" import { drizzle } from "drizzle-orm/node-sqlite/driver" import type { Server } from "virtual:opencode-server" @@ -106,6 +106,7 @@ function setupApp() { void app.whenReady().then(async () => { app.setAsDefaultProtocolClient("opencode") + registerRendererProtocol() setDockIcon() setupAutoUpdater() await initialize() diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index 03b9644948..79a463a3da 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -51,6 +51,7 @@ export async function spawnLocalServer(hostname: string, port: number, password: hostname, username: "opencode", password, + cors: ["oc://renderer"], }) const relayURL = (process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ?? DEFAULT_RELAY_URL).trim() diff --git a/packages/desktop-electron/src/main/windows.ts b/packages/desktop-electron/src/main/windows.ts index 95f80c1240..892e9d40d1 100644 --- a/packages/desktop-electron/src/main/windows.ts +++ b/packages/desktop-electron/src/main/windows.ts @@ -1,7 +1,7 @@ import windowState from "electron-window-state" -import { app, BrowserWindow, nativeImage, nativeTheme } from "electron" -import { dirname, join } from "node:path" -import { fileURLToPath } from "node:url" +import { app, BrowserWindow, net, nativeImage, nativeTheme, protocol } from "electron" +import { dirname, isAbsolute, join, relative, resolve } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" import type { TitlebarTheme } from "../preload/types" type Globals = { @@ -10,6 +10,20 @@ type Globals = { } const root = dirname(fileURLToPath(import.meta.url)) +const rendererRoot = join(root, "../renderer") +const rendererProtocol = "oc" +const rendererHost = "renderer" + +protocol.registerSchemesAsPrivileged([ + { + scheme: rendererProtocol, + privileges: { + secure: true, + standard: true, + supportFetchAPI: true, + }, + }, +]) let backgroundColor: string | undefined @@ -131,6 +145,25 @@ export function createLoadingWindow(globals: Globals) { return win } +export function registerRendererProtocol() { + if (protocol.isProtocolHandled(rendererProtocol)) return + + protocol.handle(rendererProtocol, (request) => { + const url = new URL(request.url) + if (url.host !== rendererHost) { + return new Response("Not found", { status: 404 }) + } + + const file = resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`) + const rel = relative(rendererRoot, file) + if (rel.startsWith("..") || isAbsolute(rel)) { + return new Response("Not found", { status: 404 }) + } + + return net.fetch(pathToFileURL(file).toString()) + }) +} + function loadWindow(win: BrowserWindow, html: string) { const devUrl = process.env.ELECTRON_RENDERER_URL if (devUrl) { @@ -139,7 +172,7 @@ function loadWindow(win: BrowserWindow, html: string) { return } - void win.loadFile(join(root, `../renderer/${html}`)) + void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`) } function injectGlobals(win: BrowserWindow, globals: Globals) { diff --git a/packages/desktop-electron/src/renderer/html.test.ts b/packages/desktop-electron/src/renderer/html.test.ts index bd8281c2fb..1fc5c87178 100644 --- a/packages/desktop-electron/src/renderer/html.test.ts +++ b/packages/desktop-electron/src/renderer/html.test.ts @@ -9,9 +9,9 @@ const root = resolve(dir, "../..") const html = async (name: string) => Bun.file(join(dir, name)).text() /** - * Electron loads renderer HTML via `win.loadFile()` which uses the `file://` - * protocol. Absolute paths like `src="/foo.js"` resolve to the filesystem root - * (e.g. `file:///C:/foo.js` on Windows) instead of relative to the app bundle. + * Packaged Electron windows load renderer HTML via the privileged `oc://` + * protocol. Root-relative asset paths like `src="/foo.js"` would resolve from + * the protocol origin root instead of relative to the current HTML entrypoint. * * All local resource references must use relative paths (`./`). */