Compare commits

...
Author SHA1 Message Date
LukeParkerDev 547afa509e perf(desktop): show the previous shell while the renderer boots
The first window now loads its document the moment Electron is ready:
the same index.html without its module scripts, plus a sanitized copy of
the shell captured at the end of the previous run. The renderer process,
the stylesheet and the fonts are ready while the main bundle and the
layers load, and the scripts are added when the window is adopted.

The renderer asks for its RPC MessagePort instead of receiving one on
did-finish-load, since that event fires before the scripts exist.

The startup splash and its 300 ms fade are skipped when a snapshot is
present; the snapshot is removed when the interface would have been
revealed, and re-captured once idle and after route changes.
2026-09-19 13:49:56 +10:00
17 changed files with 327 additions and 68 deletions
@@ -2,7 +2,14 @@ import { BrowserWindow } from "electron"
import { Effect } from "effect"
import { WindowRpcs } from "../../shared/ipc-rpc"
import { IpcPortHandoff } from "../ipc-transport"
import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, setWindowThemeReady, updateTitlebar } from "../windows"
import {
getPinchZoomEnabled,
saveWindowPrepaint,
setPinchZoomEnabled,
setTitlebar,
setWindowThemeReady,
updateTitlebar,
} from "../windows"
import { sender } from "./context"
export const windowHandlers = WindowRpcs.toLayer(
@@ -38,6 +45,11 @@ export const windowHandlers = WindowRpcs.toLayer(
const win = BrowserWindow.fromWebContents(sender(handoff, context))
if (win) setTitlebar(win, theme)
}),
WindowSavePrepaint: ({ html }, context) =>
Effect.promise(() => {
const win = BrowserWindow.fromWebContents(sender(handoff, context))
return win ? saveWindowPrepaint(win, html) : Promise.resolve()
}),
})
}),
)
+19 -9
View File
@@ -1,10 +1,10 @@
export * as Ipc from "./ipc"
import { app, BrowserWindow, MessageChannelMain } from "electron"
import { app, BrowserWindow, ipcMain, MessageChannelMain } from "electron"
import { Effect, Layer } from "effect"
import { RpcServer } from "effect/unstable/rpc"
import { DesktopRpcs } from "../shared/ipc-rpc"
import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport"
import { DragCancelEvent, IpcTransportPort, IpcTransportPortRequest } from "../shared/ipc-transport"
import { DesktopFiles, openExternalURL } from "./files"
import { appHandlers } from "./ipc-handlers/app"
import { eventHandlers } from "./ipc-handlers/events"
@@ -23,6 +23,7 @@ import { createMenu, sendMenuCommand } from "./native/menu"
import { DesktopCli } from "./service/desktop-cli"
import { Updater } from "./updater"
import { getLastFocusedWindow } from "./windows"
import { isRendererUrl } from "./windows/protocol"
import { Wsl } from "./wsl/start"
const services = Layer.mergeAll(DesktopFiles.layer, Wsl.layer, Ssh.layer)
@@ -65,18 +66,27 @@ export const registerIpcHandlers = Effect.gen(function* () {
if (input.type !== "keyDown" || input.key !== "Escape") return
win.webContents.send(DragCancelEvent)
})
win.webContents.on("did-finish-load", () => {
if (win.isDestroyed() || win.webContents.isDestroyed()) return
const channel = new MessageChannelMain()
handoff.bind(win.webContents, channel.port1)
win.webContents.postMessage(IpcTransportPort, null, [channel.port2])
})
}
// Each renderer document asks for its own port once its client is listening; see ipc-client.ts.
const handPort = (event: Electron.IpcMainEvent) => {
const contents = event.sender
if (contents.isDestroyed() || !isRendererUrl(contents.getURL())) return
const channel = new MessageChannelMain()
handoff.bind(contents, channel.port1)
contents.postMessage(IpcTransportPort, null, [channel.port2])
}
yield* Effect.sync(() => {
app.on("browser-window-created", wire)
BrowserWindow.getAllWindows().forEach((win) => wire({} as Electron.Event, win))
ipcMain.on(IpcTransportPortRequest, handPort)
})
yield* Effect.addFinalizer(() => Effect.sync(() => app.off("browser-window-created", wire)))
yield* Effect.addFinalizer(
() =>
Effect.sync(() => {
app.off("browser-window-created", wire)
ipcMain.off(IpcTransportPortRequest, handPort)
}),
)
return {
installMenu: () => createMenu(menu),
}
@@ -99,7 +99,9 @@ export function setZoomFactor(win: BrowserWindow, factor: number) {
export function wireZoom(win: BrowserWindow) {
pinchZoomEnabled.set(win, getPinchZoomEnabled())
win.webContents.setZoomFactor(1)
// Setting the factor forces a visual-properties round trip with the renderer, so leave it alone
// when it is already 1: the first window has a document on screen by now.
if (win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
win.webContents.on("zoom-changed", (event, direction) => {
event.preventDefault()
if (pinchZoomEnabled.get(win)) {
+13 -2
View File
@@ -6,9 +6,11 @@ import { windowIDArgument } from "../../shared/window-bootstrap"
import { WINDOW_IDS_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
import { storedBackgroundColor, titlebarOverlay } from "./defaults"
import { rendererHost, rendererProtocol } from "./scheme"
import { earlyQuery, serveRenderer } from "./serve"
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; loaded: boolean }
let pending: EarlyWindow | undefined
@@ -56,7 +58,16 @@ export function createEarlyWindow() {
pending = undefined
app.quit()
})
pending = { id, win, state, shownAt: Date.now() }
// In production the window starts its document now - the same index.html without its scripts,
// carrying the shell snapshot from the previous run - so the renderer process and the stylesheet
// are ready, and the user sees their UI, while the bundle and the layers load. The scripts are
// added when restoreWindows() adopts the window. Development keeps loading from the dev server.
const loaded = !process.env.ELECTRON_RENDERER_URL
if (loaded) {
serveRenderer(path.join(root, "../renderer"))
void win.loadURL(`${rendererProtocol}://${rendererHost}/index.html?${earlyQuery}=${encodeURIComponent(id)}`)
}
pending = { id, win, state, shownAt: Date.now(), loaded }
}
export function takeEarlyWindow() {
+18 -1
View File
@@ -22,6 +22,8 @@ import {
wireZoom,
} from "./appearance"
import { loadWindow, registerRendererProtocol } from "./protocol"
import { removePrepaint, writePrepaint } from "./prepaint"
import { releaseRenderer } from "./serve"
import { createWindowRegistry } from "./registry"
import { makeWindowRecovery } from "./recovery"
import { takeEarlyWindow, type EarlyWindow } from "./early"
@@ -29,6 +31,7 @@ import { manageWindowState, readWindowState, resolveWindowState, windowStateFile
import { allowRendererPermissions, wireNavigationPolicy, wireRendererHeaders } from "./security"
const themeReady = new WeakMap<BrowserWindow, () => void>()
const windowIDs = new WeakMap<BrowserWindow, string>()
const displays = {
all: () => screen.getAllDisplays().map((display) => display.bounds),
primary: () => screen.getPrimaryDisplay().bounds,
@@ -80,6 +83,12 @@ export function setWindowThemeReady(win: BrowserWindow) {
themeReady.get(win)?.()
}
export function saveWindowPrepaint(win: BrowserWindow, html: string) {
const id = windowIDs.get(win)
if (!id) return Promise.resolve()
return writePrepaint(id, html)
}
export const makeMainWindows = Effect.fn("Window.make")(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
@@ -126,7 +135,13 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
if (!early) manageWindowState(win, stateFile, state, displays)
register(win, id)
wireFullscreen(win)
loadWindow(win, "index.html")
if (early?.loaded) {
runFork(
Effect.tryPromise(() => releaseRenderer(win, paths.rendererRoot)).pipe(
Effect.catch((error) => scoped("window", Effect.logError("failed to release early renderer", { id, error }))),
),
)
} else loadWindow(win, "index.html")
wireZoom(win)
let contentReady = false
let appliedTheme = false
@@ -163,6 +178,7 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
const register = (win: BrowserWindow, id: string) => {
registry.register(id, win)
windowIDs.set(win, id)
win.on("focus", () => registry.focused(id))
// Windows emits session-end, but not before-quit, during shutdown and logoff.
win.on("session-end", () => registry.setQuitting())
@@ -172,6 +188,7 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
Effect.gen(function* () {
yield* Effect.try(() => storage.state.clear(windowDataFile(id)))
yield* fs.remove(path.join(app.getPath("userData"), windowStateFile(id)), { force: true })
yield* Effect.promise(() => removePrepaint(id))
}).pipe(
Effect.catch((error) => scoped("window", Effect.logError("failed to clean window state", { id, error }))),
),
@@ -0,0 +1,27 @@
import { mkdir, rename, rm, writeFile } from "node:fs/promises"
import path from "node:path"
import { app } from "electron"
// The shell snapshot a window shows before its renderer has booted. It is a file next to the
// window-state JSON rather than a row in the desktop database because the entry module serves it
// before the storage layers exist; a snapshot from another app version is ignored, since its class
// names may no longer match the bundled stylesheet.
export function prepaintFile(id: string) {
return `prepaint-${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.html`
}
export function prepaintMarker(version: string) {
return `<!-- opencode ${version} -->`
}
export async function writePrepaint(id: string, html: string) {
const file = path.join(app.getPath("userData"), prepaintFile(id))
await mkdir(path.dirname(file), { recursive: true })
await writeFile(`${file}.tmp`, `${prepaintMarker(app.getVersion())}\n${html}`)
await rename(`${file}.tmp`, file)
}
export function removePrepaint(id: string) {
return rm(path.join(app.getPath("userData"), prepaintFile(id)), { force: true })
}
+9 -51
View File
@@ -1,54 +1,19 @@
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 { rendererHost, rendererProtocol } from "./scheme"
import { serveRenderer, setRendererProtocolLogger } from "./serve"
// The entry module normally registers the handler before the bundle loads; this only wires its
// logging, and registers it when the entry module did not (development, or a window created later).
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 })
}
})
setRendererProtocolLogger((level, message, data) =>
runFork(scoped("protocol", level === "error" ? Effect.logError(message, data) : Effect.logWarning(message, data))),
)
serveRenderer(paths.rendererRoot)
})
export function loadWindow(win: BrowserWindow, html: string) {
@@ -68,11 +33,4 @@ export function isRendererUrl(value?: string, html = false) {
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 })
}
}
+114
View File
@@ -0,0 +1,114 @@
import { readFile } from "node:fs/promises"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { app, net, protocol } from "electron"
import type { BrowserWindow } from "electron"
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
import { prepaintFile, prepaintMarker } from "./prepaint"
import { rendererHost, rendererProtocol } from "./scheme"
// Serves the renderer bundle over oc://renderer. This module has no Effect dependency because the
// entry module registers it right after Electron is ready, before the main bundle has loaded, so
// the first window can start its document while the bundle and the layers evaluate.
export const earlyQuery = "early"
type Log = (level: "warning" | "error", message: string, data: Record<string, unknown>) => void
let log: Log = () => {}
export function setRendererProtocolLogger(logger: Log) {
log = logger
}
export function serveRenderer(rendererRoot: string) {
if (protocol.isProtocolHandled(rendererProtocol)) return
protocol.handle(rendererProtocol, async (request) => {
const url = new URL(request.url)
if (url.host !== rendererHost) {
log("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)) {
log("warning", "rejected path", { url: request.url, file })
return new Response("Not found", { status: 404 })
}
const early = url.pathname === "/index.html" ? url.searchParams.get(earlyQuery) : null
if (early !== null) return earlyDocument(file, early)
try {
const range = request.headers.get("range")
const response = await net.fetch(pathToFileURL(file).toString(), { headers: range ? { range } : undefined })
if (response.status >= 400) {
log("error", "fetch failed", {
url: request.url,
file,
status: response.status,
statusText: response.statusText,
})
}
return addDocumentPolicy(response, file)
} catch (error) {
log("error", "fetch error", { url: request.url, file, error })
return new Response("Not found", { status: 404 })
}
})
}
// The early window loads index.html?early=<window id>: the same document without its module
// scripts, plus the shell snapshot the window captured last time, so the user sees their own UI
// while the main process is still loading. releaseRenderer() adds the scripts back once the window
// has been adopted.
async function earlyDocument(file: string, id: string) {
const [html, prepaint] = await Promise.all([
readFile(file, "utf8"),
readFile(path.join(app.getPath("userData"), prepaintFile(id)), "utf8").catch(() => undefined),
])
const body = prepaint?.startsWith(prepaintMarker(app.getVersion()))
? `<div id="oc-prepaint" inert aria-hidden="true" style="position:fixed;inset:0;z-index:100;pointer-events:none;display:flex;flex-direction:column;background-color:var(--background-base)">${prepaint.slice(prepaint.indexOf("\n") + 1)}</div></body>`
: "</body>"
const text = html.replace(moduleScript, "").replace("</body>", body)
return new Response(text, {
headers: {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-store",
[documentPolicyHeader]: jsCallStacksDocumentPolicy,
},
})
}
const moduleScript = /<script type="module"[^>]*><\/script>\s*/g
export async function releaseRenderer(win: BrowserWindow, rendererRoot: string) {
const html = await readFile(path.join(rendererRoot, "index.html"), "utf8")
const srcs = [...html.matchAll(moduleScript)].flatMap((match) => {
const src = /\bsrc="([^"]+)"/.exec(match[0])
return src ? [src[1]] : []
})
if (win.isDestroyed()) return
// async = false keeps the runtime chunk ahead of the entry, as the static tags did. Dropping the
// query afterwards makes a reload load the full document.
await win.webContents.executeJavaScript(
`(() => {
for (const src of ${JSON.stringify(srcs)}) {
const script = document.createElement("script")
script.type = "module"
script.async = false
script.crossOrigin = ""
script.src = src
document.head.appendChild(script)
}
history.replaceState(history.state, "", "/index.html")
})()`,
)
}
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 })
}
+2 -1
View File
@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer, webUtils } from "electron"
import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport"
import { DragCancelEvent, IpcTransportPort, IpcTransportPortRequest } from "../shared/ipc-transport"
import { windowIDFromArguments } from "../shared/window-bootstrap"
ipcRenderer.on(IpcTransportPort, (event) => {
@@ -11,5 +11,6 @@ ipcRenderer.on(DragCancelEvent, () => window.dispatchEvent(new Event(DragCancelE
contextBridge.exposeInMainWorld("electron", {
windowID: windowIDFromArguments(process.argv),
requestRpcPort: () => ipcRenderer.send(IpcTransportPortRequest),
getPathForFile: (file: File) => webUtils.getPathForFile(file),
})
+1
View File
@@ -1,4 +1,5 @@
export type ElectronNative = {
windowID: string
requestRpcPort(): void
getPathForFile(file: File): string
}
@@ -54,6 +54,8 @@ export type ElectronAPI = {
draftBlobGet(id: string): Promise<ArrayBuffer | null>
getWindowID(): string
themeReady(): Promise<void>
// Persists the sanitized shell markup the next launch shows before the renderer boots.
savePrepaint(html: string): Promise<void>
onMenuCommand(cb: (id: string) => void): () => void
onDeepLink(cb: (urls: string[]) => void): () => void
openDirectoryPicker(opts?: DirectoryPickerOptions): Promise<string | string[] | null>
+1
View File
@@ -112,6 +112,7 @@ export const api: ElectronAPI = {
getWindowID: () => window.electron.windowID,
themeReady: () => invoke("WindowThemeReady"),
savePrepaint: (html) => invoke("WindowSavePrepaint", { html }),
onMenuCommand: (cb) => listen("MenuCommandTriggered", (event) => cb(event.id)),
onDeepLink: (cb) => listen("DeepLinksOpened", (event) => cb(mutable(event.urls))),
+25 -2
View File
@@ -19,7 +19,7 @@ import {
} from "@opencode/app/desktop"
import { useTheme } from "@opencode/ui/theme/context"
import type { BaseRouterProps } from "@solidjs/router"
import { createEffect, createMemo, createResource, lazy, Show, Suspense } from "solid-js"
import { createEffect, createMemo, createResource, lazy, on, Show, Suspense } from "solid-js"
import { createStore } from "solid-js/store"
import type { ElectronAPI } from "./api-types"
import { DesktopFirstLaunchOnboarding } from "./onboarding"
@@ -27,6 +27,7 @@ import { createDesktopPlatform } from "./platform"
import { bindDesktopMenu } from "./platform/menu"
import { createSidecarResolver, initializationData, sidecarHttp } from "./startup/initialization"
import { preloadStoredLocale } from "./startup/locale"
import { hasPrepaint, removePrepaint, schedulePrepaintCapture } from "./startup/prepaint"
import { LoadingSplash } from "./startup/splash"
import { getLastActiveUrl } from "./window/route-storage"
import { DesktopMemoryRouter } from "./window/router"
@@ -40,9 +41,12 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
const initialUrl = getLastActiveUrl(windowState.id)
const url = new URL(initialUrl, "http://localhost")
const route = currentRoute(url.pathname, url.search)
// With a shell snapshot on screen the splash is not needed; the snapshot is removed when the
// interface would otherwise be revealed.
const prepaint = hasPrepaint()
const [startup, setStartup] = createStore({
ready: false,
visible: true,
visible: !prepaint,
themeReady: false,
onboardingReady: false,
drawingReady: false,
@@ -72,6 +76,25 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
if (!startup.themeReady || firstLaunch.loading) return
void props.api.themeReady()
})
createEffect(() => {
if (!prepaint || firstLaunch() !== true) return
removePrepaint()
setStartup("visible", true)
})
createEffect(() => {
if (!readyToReveal()) return
removePrepaint()
schedulePrepaintCapture(props.api.savePrepaint)
})
createEffect(
on(
() => startup.route,
() => {
if (startup.ready) schedulePrepaintCapture(props.api.savePrepaint, 3000)
},
{ defer: true },
),
)
function ReadyApp() {
const wslServers = useWslServers()
@@ -13,6 +13,8 @@ type InvokeResult<Tag extends InvokeTag> =
ReturnType<DesktopRpcClient[Tag]> extends Effect.Effect<infer Value, unknown> ? Value : never
type EventValue<Tag extends EventTag> = Extract<DesktopEvent, { readonly _tag: Tag }>
// The renderer asks for its port rather than receiving one on load: the first window's document
// is on screen before its scripts, and before the main process has its IPC layer, exist.
const port = new Promise<MessagePort>((resolve) => {
const onMessage = (event: MessageEvent) => {
if (event.source !== window || event.data !== IpcTransportPort) return
@@ -22,6 +24,7 @@ const port = new Promise<MessagePort>((resolve) => {
resolve(value)
}
window.addEventListener("message", onMessage)
window.electron.requestRpcPort()
})
const ClientProtocolLive = Layer.unwrap(Effect.promise(() => port).pipe(Effect.map((value) => clientProtocol(value))))
@@ -0,0 +1,72 @@
// The early document carries a static copy of this window's shell from the previous run, served by
// the main process as #oc-prepaint, so the user sees their UI while the renderer boots. It is
// removed when the real interface is ready to be revealed, and re-captured from the live DOM so the
// next launch shows the current state.
const prepaintID = "oc-prepaint"
const maxBytes = 1_000_000
export function hasPrepaint() {
return document.getElementById(prepaintID) !== null
}
export function removePrepaint() {
document.getElementById(prepaintID)?.remove()
}
let timer: number | undefined
// Captures once the renderer is idle; `delay` coalesces bursts of route changes into one capture.
export function schedulePrepaintCapture(save: (html: string) => Promise<void>, delay = 0) {
clearTimeout(timer)
timer = window.setTimeout(() => {
requestIdleCallback(
() => {
const root = document.getElementById("root")
const html = root && capturePrepaint(root)
if (html) void save(html).catch(() => undefined)
},
{ timeout: 2000 },
)
}, delay)
}
// Elements that hold live or transient state and would look wrong, or leak, in a static copy.
const dropped =
"script, style, link, iframe, object, embed, canvas, video, audio, dialog, [popover], [role='dialog'], [role='menu'], [role='listbox'], [role='tooltip'], [data-component='startup-overlay']"
// Attributes that could act, be targeted, or collide with the live document once it mounts.
const stripped = new Set(["id", "href", "tabindex", "contenteditable", "autofocus", "for", "name", "action", "formaction"])
export function capturePrepaint(root: HTMLElement) {
const clone = root.cloneNode(true) as HTMLElement
clone.querySelectorAll(dropped).forEach((element) => element.remove())
const symbols = new Set<string>()
for (const element of clone.querySelectorAll("*")) {
if (element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement) element.removeAttribute("value")
if (element.hasAttribute("contenteditable")) element.replaceChildren()
for (const attribute of Array.from(element.attributes)) {
const name = attribute.name
if (name.startsWith("on")) element.removeAttribute(name)
// SVG keeps its ids and hrefs: gradients, clip paths and <use> references are local visuals.
if (element instanceof SVGElement) {
if (name === "href" && attribute.value.startsWith("#")) symbols.add(attribute.value.slice(1))
continue
}
if (stripped.has(name)) element.removeAttribute(name)
if (name === "src" && !/^(\.\/|\/|oc:|data:)/.test(attribute.value)) element.removeAttribute(name)
}
}
const root_ = document.documentElement
const html = `<div class="${document.body.className} flex flex-col h-dvh" lang="${root_.lang}" dir="${root_.dir}">${sprite(symbols)}${clone.innerHTML}</div>`
return html.length <= maxBytes ? html : undefined
}
// Icons are <use href="#symbol"> into a sprite outside #root; copy only the symbols the shell uses.
function sprite(symbols: Set<string>) {
const defs = [...symbols]
.map((id) => document.getElementById(id))
.filter((element) => element instanceof SVGSymbolElement)
.map((element) => element.outerHTML)
if (!defs.length) return ""
return `<svg aria-hidden="true" width="0" height="0" style="position:absolute;overflow:hidden">${defs.join("")}</svg>`
}
@@ -24,6 +24,9 @@ export const WindowSetTitlebar = Rpc.make("WindowSetTitlebar", {
}),
},
})
export const WindowSavePrepaint = Rpc.make("WindowSavePrepaint", {
payload: { html: Schema.String },
})
export const WindowRpcs = RpcGroup.make(
WindowThemeReady,
WindowGetFocused,
@@ -35,4 +38,5 @@ export const WindowRpcs = RpcGroup.make(
WindowGetPinchZoomEnabled,
WindowSetPinchZoomEnabled,
WindowSetTitlebar,
WindowSavePrepaint,
)
@@ -1,2 +1,3 @@
export const IpcTransportPort = "desktop-rpc-port"
export const IpcTransportPortRequest = "desktop-rpc-port-request"
export const DragCancelEvent = "opencode:drag-cancel"