mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-19 07:16:26 +00:00
Compare commits
1
Commits
hook-audit
...
prepaint
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
547afa509e |
@@ -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()
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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))),
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user