fix(app): tighter startup sequence

This commit is contained in:
Adam
2026-04-08 13:29:01 -05:00
parent 988c9894f2
commit 10a43e0f6a
8 changed files with 82 additions and 41 deletions

View File

@@ -13,6 +13,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { type Duration, Effect } from "effect"
import {
type Component,
createEffect,
createMemo,
createResource,
createSignal,
@@ -161,7 +162,7 @@ const effectMinDuration =
<A, E, R>(e: Effect.Effect<A, E, R>) =>
Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; onReady?: () => void }>) {
const server = useServer()
const checkServerHealth = useCheckServerHealth()
@@ -189,6 +190,16 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
),
)
let sent = false
createEffect(() => {
if (sent) return
const ready = checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"
if (!ready) return
sent = true
props.onReady?.()
})
return (
<Show
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
@@ -281,6 +292,7 @@ export function AppInterface(props: {
servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
onReady?: () => void
}) {
return (
<ServerProvider
@@ -288,7 +300,7 @@ export function AppInterface(props: {
disableHealthCheck={props.disableHealthCheck}
servers={props.servers}
>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ConnectionGate disableHealthCheck={props.disableHealthCheck} onReady={props.onReady}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>

View File

@@ -41,8 +41,11 @@ const initEmitter = new EventEmitter()
let initStep: InitStep = { phase: "server_waiting" }
let mainWindow: BrowserWindow | null = null
let splash: BrowserWindow | null = null
let ready = false
let sidecar: CommandChild | null = null
const loadingComplete = defer<void>()
const mainReady = defer<void>()
const pendingDeepLinks: string[] = []
@@ -112,6 +115,11 @@ function emitDeepLinks(urls: string[]) {
}
function focusMainWindow() {
if (!ready) {
splash?.show()
splash?.focus()
return
}
if (!mainWindow) return
mainWindow.show()
mainWindow.focus()
@@ -121,12 +129,14 @@ function setInitStep(step: InitStep) {
initStep = step
logger.log("init step", { step })
initEmitter.emit("step", step)
BrowserWindow.getAllWindows().forEach((win) => {
win.webContents.send("init-step", step)
})
}
async function initialize() {
const needsMigration = !sqliteFileExists()
const sqliteDone = needsMigration ? defer<void>() : undefined
let overlay: BrowserWindow | null = null
const port = await getSidecarPort()
const hostname = "127.0.0.1"
@@ -147,7 +157,7 @@ async function initialize() {
events.on("sqlite", (progress: SqliteMigrationProgress) => {
setInitStep({ phase: "sqlite_waiting" })
if (overlay) sendSqliteMigrationProgress(overlay, progress)
if (splash) sendSqliteMigrationProgress(splash, progress)
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
if (progress.type === "Done") sqliteDone?.resolve()
})
@@ -173,25 +183,30 @@ async function initialize() {
deepLinks: pendingDeepLinks,
}
if (needsMigration) {
const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
if (show) {
overlay = createLoadingWindow(globals)
await delay(1_000)
}
}
const startup = (async () => {
await loadingTask
setInitStep({ phase: "app_waiting" })
mainWindow = createMainWindow(globals, { show: false })
await loadingTask
const ok = await Promise.race([mainReady.promise.then(() => true), delay(15_000).then(() => false)])
if (!ok) logger.warn("main window ready timed out")
})()
splash = createLoadingWindow(globals)
await startup
setInitStep({ phase: "done" })
if (overlay) {
if (splash) {
await loadingComplete.promise
splash.close()
splash = null
}
mainWindow = createMainWindow(globals)
wireMenu()
overlay?.close()
ready = true
mainWindow?.show()
mainWindow?.focus()
}
function wireMenu() {
@@ -240,6 +255,7 @@ registerIpcHandlers({
wslPath: async (path, mode) => wslPath(path, mode),
resolveAppPath: async (appName) => resolveAppPath(appName),
loadingWindowComplete: () => loadingComplete.resolve(),
mainWindowReady: () => mainReady.resolve(),
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail),
checkUpdate: async () => checkUpdate(),
installUpdate: async () => installUpdate(),

View File

@@ -26,6 +26,7 @@ type Deps = {
wslPath: (path: string, mode: "windows" | "linux" | null) => Promise<string>
resolveAppPath: (appName: string) => Promise<string | null>
loadingWindowComplete: () => void
mainWindowReady: () => void
runUpdater: (alertOnFail: boolean) => Promise<void> | void
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
installUpdate: () => Promise<void> | void
@@ -56,6 +57,7 @@ export function registerIpcHandlers(deps: Deps) {
)
ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
ipcMain.on("loading-window-complete", () => deps.loadingWindowComplete())
ipcMain.on("main-window-ready", () => deps.mainWindowReady())
ipcMain.handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail))
ipcMain.handle("check-update", () => deps.checkUpdate())
ipcMain.handle("install-update", () => deps.installUpdate())

View File

@@ -54,7 +54,7 @@ export function setDockIcon() {
if (!icon.isEmpty()) app.dock?.setIcon(icon)
}
export function createMainWindow(globals: Globals) {
export function createMainWindow(globals: Globals, opts: { show?: boolean } = {}) {
const state = windowState({
defaultWidth: 1280,
defaultHeight: 800,
@@ -66,7 +66,7 @@ export function createMainWindow(globals: Globals) {
y: state.y,
width: state.width,
height: state.height,
show: true,
show: opts.show ?? true,
title: "OpenCode",
icon: iconPath(),
backgroundColor,
@@ -98,23 +98,15 @@ export function createMainWindow(globals: Globals) {
}
export function createLoadingWindow(globals: Globals) {
const mode = tone()
const win = new BrowserWindow({
width: 640,
height: 480,
resizable: false,
center: true,
show: true,
frame: false,
icon: iconPath(),
backgroundColor,
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
...(process.platform === "win32"
? {
frame: false,
titleBarStyle: "hidden" as const,
titleBarOverlay: overlay({ mode }),
}
: {}),
webPreferences: {
preload: join(root, "../preload/index.mjs"),
sandbox: false,

View File

@@ -11,6 +11,11 @@ const api: ElectronAPI = {
ipcRenderer.removeListener("init-step", handler)
})
},
onInitStep: (cb) => {
const handler = (_: unknown, step: InitStep) => cb(step)
ipcRenderer.on("init-step", handler)
return () => ipcRenderer.removeListener("init-step", handler)
},
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
getWslConfig: () => ipcRenderer.invoke("get-wsl-config"),
@@ -60,6 +65,7 @@ const api: ElectronAPI = {
setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor),
setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"),
mainWindowReady: () => ipcRenderer.send("main-window-ready"),
runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
checkUpdate: () => ipcRenderer.invoke("check-update"),
installUpdate: () => ipcRenderer.invoke("install-update"),

View File

@@ -1,4 +1,8 @@
export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" } | { phase: "done" }
export type InitStep =
| { phase: "server_waiting" }
| { phase: "sqlite_waiting" }
| { phase: "app_waiting" }
| { phase: "done" }
export type ServerReadyData = {
url: string
@@ -19,6 +23,7 @@ export type ElectronAPI = {
killSidecar: () => Promise<void>
installCli: () => Promise<string>
awaitInitialization: (onStep: (step: InitStep) => void) => Promise<ServerReadyData>
onInitStep: (cb: (step: InitStep) => void) => () => void
getDefaultServerUrl: () => Promise<string | null>
setDefaultServerUrl: (url: string | null) => Promise<void>
getWslConfig: () => Promise<WslConfig>
@@ -66,6 +71,7 @@ export type ElectronAPI = {
setZoomFactor: (factor: number) => Promise<void>
setTitlebar: (theme: TitlebarTheme) => Promise<void>
loadingWindowComplete: () => void
mainWindowReady: () => void
runUpdater: (alertOnFail: boolean) => Promise<void>
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
installUpdate: () => Promise<void>

View File

@@ -332,6 +332,7 @@ render(() => {
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
servers={servers()}
router={MemoryRouter}
onReady={() => window.api.mainWindowReady()}
>
<Inner />
</AppInterface>

View File

@@ -25,6 +25,7 @@ render(() => {
})
window.api.awaitInitialization((next) => setStep(next as InitStep)).catch(() => undefined)
const off = window.api.onInitStep((next) => setStep(next))
onMount(() => {
setLine(0)
@@ -41,6 +42,7 @@ render(() => {
})
onCleanup(() => {
off()
listener()
timers.forEach(clearTimeout)
})
@@ -63,20 +65,24 @@ render(() => {
<MetaProvider>
<div class="w-screen h-screen bg-background-base flex items-center justify-center">
<Font />
<div class="flex flex-col items-center gap-11">
<Splash class="w-20 h-25 opacity-15" />
<div class="w-60 flex flex-col items-center gap-4" aria-live="polite">
<span class="w-full overflow-hidden text-center text-ellipsis whitespace-nowrap text-text-strong text-14-normal">
{status()}
</span>
<Progress
value={value()}
class="w-20 [&_[data-slot='progress-track']]:h-1 [&_[data-slot='progress-track']]:border-0 [&_[data-slot='progress-track']]:rounded-none [&_[data-slot='progress-track']]:bg-surface-weak [&_[data-slot='progress-fill']]:rounded-none [&_[data-slot='progress-fill']]:bg-icon-warning-base"
aria-label="Database migration progress"
getValueLabel={({ value }) => `${Math.round(value)}%`}
/>
{phase() === "sqlite_waiting" ? (
<div class="flex flex-col items-center gap-11">
<Splash class="w-20 h-25 opacity-15" />
<div class="w-60 flex flex-col items-center gap-4" aria-live="polite">
<span class="w-full overflow-hidden text-center text-ellipsis whitespace-nowrap text-text-strong text-14-normal">
{status()}
</span>
<Progress
value={value()}
class="w-20 [&_[data-slot='progress-track']]:h-1 [&_[data-slot='progress-track']]:border-0 [&_[data-slot='progress-track']]:rounded-none [&_[data-slot='progress-track']]:bg-surface-weak [&_[data-slot='progress-fill']]:rounded-none [&_[data-slot='progress-fill']]:bg-icon-warning-base"
aria-label="Database migration progress"
getValueLabel={({ value }) => `${Math.round(value)}%`}
/>
</div>
</div>
</div>
) : (
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
)}
</div>
</MetaProvider>
)