mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-19 15:17:51 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9abcab6643 |
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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,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,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>
|
||||
|
||||
@@ -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))),
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user