Compare commits

..
1 Commits
Author SHA1 Message Date
LukeParkerDev 9abcab6643 perf(app): mount the shell before the servers are known
The desktop gated the whole interface on the local service, WSL and SSH
discovery. The shell (titlebar, tabs, layout) now mounts from local
state as soon as the quick lookups resolve, and the server list is
marked pending while connections are still being discovered: the tabs
store does not prune tabs of servers that have not appeared yet, the
layout does not fall back to the connect screen, and draft and session
routes hold their panel's place until their connection exists.
2026-09-19 13:58:11 +10:00
23 changed files with 148 additions and 367 deletions
+2
View File
@@ -102,6 +102,7 @@ export function AppInterface(props: {
defaultServer?: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
serversPending?: boolean
router?: Component<BaseRouterProps>
}) {
// The visual layout lives in the router root so it remains mounted across
@@ -128,6 +129,7 @@ export function AppInterface(props: {
defaultServer={props.defaultServer}
canonicalLocalServer={props.canonicalLocalServer}
servers={props.servers}
pending={props.serversPending}
>
<SettingsProvider>
<Dynamic component={props.router ?? Router} root={Root}>
+14 -2
View File
@@ -7,7 +7,7 @@ import { LocationProvider } from "@/workspaces/location"
import { ModelsProvider } from "@/providers/models/models"
import { ComposerPersistenceProvider } from "@/composer/persistence"
import { ServerProvider, useServer } from "@/runtime/server/current"
import { ServerConnection } from "@/runtime/server/registry"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
import { SessionUIProvider } from "@/shell/routes/session-ui-provider"
import NewSession from "@/new-session/screen"
@@ -30,11 +30,23 @@ export function DraftRoute() {
function ResolvedDraftRoute(props: { draft: DraftTab }) {
const global = useGlobal()
const servers = useServers()
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
return (
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
<Show when={conn()} keyed>
<Show
when={conn()}
keyed
fallback={
// The shell is up before the local service has connected; hold the panel's place.
<Show when={servers.pending}>
<SessionRouteFrame padded>
<SessionPanelFrame raised />
</SessionRouteFrame>
</Show>
}
>
{(conn) => (
<ServerProvider conn={conn}>
<ResolvedDraftContent draft={props.draft} />
@@ -199,6 +199,9 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
defaultServer?: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
// The host is still discovering connections (desktop: the local service, WSL, SSH). The shell
// renders meanwhile; nothing that depends on the list being complete may act on it yet.
pending?: boolean
}) => {
const [store, setStore, _] = persisted(
{
@@ -258,6 +261,9 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
get visible() {
return visibleServers()
},
get pending() {
return props.pending ?? false
},
isHidden(key: ServerConnection.Key) {
return store.hidden[key] ?? false
},
+13 -2
View File
@@ -62,12 +62,23 @@ export function AppRoutes() {
function TargetServerRoute(props: ParentProps) {
const params = useParams<{ serverKey: string }>()
const global = useGlobal()
const servers = useServers()
const connection = createMemo(() =>
global.servers.list().find((item) => ServerConnection.key(item) === requireServerKey(params.serverKey)),
)
return (
<Show when={connection()} keyed>
<Show
when={connection()}
keyed
fallback={
<Show when={servers.pending}>
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
</Show>
}
>
{(connection) => <ServerProvider conn={connection}>{props.children}</ServerProvider>}
</Show>
)
@@ -76,7 +87,7 @@ function TargetServerRoute(props: ParentProps) {
function AppLayout(props: ParentProps) {
const servers = useServers()
return (
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
<Show when={servers.list.length > 0 || servers.pending} fallback={<ConnectServerScreen />}>
<LayoutProvider>
<SettingsSurfaceProvider>
<BrowserAttachmentsProvider>
+4 -2
View File
@@ -138,8 +138,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
onCleanup(memory.dispose)
// Tabs of a server that is gone are dropped, but not while the host is still discovering its
// servers: the shell mounts before the local service has connected.
createEffect(() => {
if (!ready() || !recentReady()) return
if (!ready() || !recentReady() || servers.pending) return
const serversSet = new Set(servers.list.map(ServerConnection.key))
const next = store.filter((tab) => serversSet.has(tab.server))
if (next.length !== store.length) {
@@ -165,7 +167,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
})
createEffect(() => {
if (!closedReady()) return
if (!closedReady() || servers.pending) return
const serversSet = new Set(servers.list.map(ServerConnection.key))
const next = closed.filter((entry) => serversSet.has(entry.tab.server))
if (next.length !== closed.length) setClosed(() => next)
@@ -2,14 +2,7 @@ import { BrowserWindow } from "electron"
import { Effect } from "effect"
import { WindowRpcs } from "../../shared/ipc-rpc"
import { IpcPortHandoff } from "../ipc-transport"
import {
getPinchZoomEnabled,
saveWindowPrepaint,
setPinchZoomEnabled,
setTitlebar,
setWindowThemeReady,
updateTitlebar,
} from "../windows"
import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, setWindowThemeReady, updateTitlebar } from "../windows"
import { sender } from "./context"
export const windowHandlers = WindowRpcs.toLayer(
@@ -45,11 +38,6 @@ 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()
}),
})
}),
)
+9 -19
View File
@@ -1,10 +1,10 @@
export * as Ipc from "./ipc"
import { app, BrowserWindow, ipcMain, MessageChannelMain } from "electron"
import { app, BrowserWindow, MessageChannelMain } from "electron"
import { Effect, Layer } from "effect"
import { RpcServer } from "effect/unstable/rpc"
import { DesktopRpcs } from "../shared/ipc-rpc"
import { DragCancelEvent, IpcTransportPort, IpcTransportPortRequest } from "../shared/ipc-transport"
import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport"
import { DesktopFiles, openExternalURL } from "./files"
import { appHandlers } from "./ipc-handlers/app"
import { eventHandlers } from "./ipc-handlers/events"
@@ -23,7 +23,6 @@ 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)
@@ -66,27 +65,18 @@ export const registerIpcHandlers = Effect.gen(function* () {
if (input.type !== "keyDown" || input.key !== "Escape") return
win.webContents.send(DragCancelEvent)
})
}
// 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])
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])
})
}
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)
ipcMain.off(IpcTransportPortRequest, handPort)
}),
)
yield* Effect.addFinalizer(() => Effect.sync(() => app.off("browser-window-created", wire)))
return {
installMenu: () => createMenu(menu),
}
@@ -99,9 +99,7 @@ export function setZoomFactor(win: BrowserWindow, factor: number) {
export function wireZoom(win: BrowserWindow) {
pinchZoomEnabled.set(win, getPinchZoomEnabled())
// 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.setZoomFactor(1)
win.webContents.on("zoom-changed", (event, direction) => {
event.preventDefault()
if (pinchZoomEnabled.get(win)) {
+2 -13
View File
@@ -6,11 +6,9 @@ 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; loaded: boolean }
export type EarlyWindow = { id: string; win: BrowserWindow; state: WindowState; shownAt: number }
let pending: EarlyWindow | undefined
@@ -58,16 +56,7 @@ export function createEarlyWindow() {
pending = undefined
app.quit()
})
// 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 }
pending = { id, win, state, shownAt: Date.now() }
}
export function takeEarlyWindow() {
+1 -18
View File
@@ -22,8 +22,6 @@ 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"
@@ -31,7 +29,6 @@ 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,
@@ -83,12 +80,6 @@ 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
@@ -135,13 +126,7 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
if (!early) manageWindowState(win, stateFile, state, displays)
register(win, id)
wireFullscreen(win)
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")
loadWindow(win, "index.html")
wireZoom(win)
let contentReady = false
let appliedTheme = false
@@ -178,7 +163,6 @@ 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())
@@ -188,7 +172,6 @@ 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 }))),
),
@@ -1,27 +0,0 @@
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 })
}
+51 -9
View File
@@ -1,19 +1,54 @@
import { net, protocol } from "electron"
import type { BrowserWindow } from "electron"
import { Effect } from "effect"
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"
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>())
setRendererProtocolLogger((level, message, data) =>
runFork(scoped("protocol", level === "error" ? Effect.logError(message, data) : Effect.logWarning(message, data))),
)
serveRenderer(paths.rendererRoot)
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 })
}
})
})
export function loadWindow(win: BrowserWindow, html: string) {
@@ -33,4 +68,11 @@ 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
@@ -1,114 +0,0 @@
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 -2
View File
@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer, webUtils } from "electron"
import { DragCancelEvent, IpcTransportPort, IpcTransportPortRequest } from "../shared/ipc-transport"
import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport"
import { windowIDFromArguments } from "../shared/window-bootstrap"
ipcRenderer.on(IpcTransportPort, (event) => {
@@ -11,6 +11,5 @@ 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,5 +1,4 @@
export type ElectronNative = {
windowID: string
requestRpcPort(): void
getPathForFile(file: File): string
}
@@ -54,8 +54,6 @@ 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,7 +112,6 @@ 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))),
+32 -58
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, on, Show, Suspense } from "solid-js"
import { createEffect, createMemo, createResource, lazy, Show, Suspense } from "solid-js"
import { createStore } from "solid-js/store"
import type { ElectronAPI } from "./api-types"
import { DesktopFirstLaunchOnboarding } from "./onboarding"
@@ -27,7 +27,6 @@ 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"
@@ -41,12 +40,9 @@ 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: !prepaint,
visible: true,
themeReady: false,
onboardingReady: false,
drawingReady: false,
@@ -76,40 +72,17 @@ 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()
const ssh = useSsh()
const sshConnections = createSshConnections(props.api.sshServers)
const language = useLanguage()
const ready = createMemo(
() =>
!firstLaunch.loading &&
!defaultServer.loading &&
!sidecar.loading &&
!locale.loading &&
!wslServers.isLoading &&
!ssh.loading,
)
// The shell mounts from local state as soon as the quick lookups resolve. The local service,
// WSL and SSH connections are discovered meanwhile and appear in the server list when ready;
// until then the list is marked pending so nothing acts on it being empty.
const ready = createMemo(() => !firstLaunch.loading && !defaultServer.loading && !locale.loading)
const serversPending = createMemo(() => sidecar.loading || wslServers.isLoading || ssh.loading)
const servers = createMemo(() => {
const data = initializationData(sidecar)
const list: ServerConnection.Any[] = []
@@ -132,30 +105,31 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
return (
<Show when={ready()}>
<Show when={effectiveDefaultServer()} keyed>
{(key) => (
<AppInterface defaultServer={key} servers={servers()} router={router}>
<DesktopStartupReady
routeReady={!initialRoute.loading && startup.onboardingReady}
onReady={() => setStartup("ready", true)}
onRoute={(route) => setStartup("route", route)}
/>
<DesktopFirstLaunchOnboarding
api={props.api}
initialUrl={initialUrl}
serverKey={key}
pending={firstLaunch() ?? false}
onReady={() => setStartup("onboardingReady", true)}
/>
<DesktopEffects api={props.api} />
<Suspense fallback={null}>
<Show when={initializationData(sidecar)} keyed>
{(server) => <MigrationStatus server={server} />}
</Show>
</Suspense>
</AppInterface>
)}
</Show>
<AppInterface
defaultServer={effectiveDefaultServer()}
servers={servers()}
serversPending={serversPending()}
router={router}
>
<DesktopStartupReady
routeReady={!initialRoute.loading && startup.onboardingReady}
onReady={() => setStartup("ready", true)}
onRoute={(route) => setStartup("route", route)}
/>
<DesktopFirstLaunchOnboarding
api={props.api}
initialUrl={initialUrl}
serverKey={effectiveDefaultServer()}
pending={firstLaunch() ?? false}
onReady={() => setStartup("onboardingReady", true)}
/>
<DesktopEffects api={props.api} />
<Suspense fallback={null}>
<Show when={initializationData(sidecar)} keyed>
{(server) => <MigrationStatus server={server} />}
</Show>
</Suspense>
</AppInterface>
</Show>
)
}
@@ -13,8 +13,6 @@ 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
@@ -24,7 +22,6 @@ 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))))
@@ -1,5 +1,5 @@
import { ServerConnection, useCurrentRoute, useGlobal, useServers, useTabs } from "@opencode/app/desktop"
import { createResource } from "solid-js"
import { createEffect, createResource, createRoot } from "solid-js"
import type { ElectronAPI } from "../api-types"
export function DesktopFirstLaunchOnboarding(props: {
@@ -24,6 +24,16 @@ export function DesktopFirstLaunchOnboarding(props: {
if (!props.pending) return
await Promise.all([tabs.ready.promise, tabs.recentReady.promise].map((p) => p ?? Promise.resolve()))
// The shell mounts before the local service has connected; the decision needs the full list.
await new Promise<void>((resolve) =>
createRoot((dispose) =>
createEffect(() => {
if (server.pending) return
dispose()
resolve()
}),
),
)
const shouldTrigger =
props.initialUrl === "/" &&
@@ -1,72 +0,0 @@
// 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,9 +24,6 @@ export const WindowSetTitlebar = Rpc.make("WindowSetTitlebar", {
}),
},
})
export const WindowSavePrepaint = Rpc.make("WindowSavePrepaint", {
payload: { html: Schema.String },
})
export const WindowRpcs = RpcGroup.make(
WindowThemeReady,
WindowGetFocused,
@@ -38,5 +35,4 @@ export const WindowRpcs = RpcGroup.make(
WindowGetPinchZoomEnabled,
WindowSetPinchZoomEnabled,
WindowSetTitlebar,
WindowSavePrepaint,
)
@@ -1,3 +1,2 @@
export const IpcTransportPort = "desktop-rpc-port"
export const IpcTransportPortRequest = "desktop-rpc-port-request"
export const DragCancelEvent = "opencode:drag-cancel"