diff --git a/bun.lock b/bun.lock index 5e66e3e16..248caffa8 100644 --- a/bun.lock +++ b/bun.lock @@ -46,6 +46,7 @@ "@solidjs/router": "catalog:", "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", + "effect": "4.0.0-beta.29", "fuzzysort": "catalog:", "ghostty-web": "github:anomalyco/ghostty-web#main", "luxon": "catalog:", @@ -226,6 +227,7 @@ "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", "@solidjs/router": "0.15.4", + "effect": "4.0.0-beta.29", "electron-log": "^5", "electron-store": "^10", "electron-updater": "^6", diff --git a/packages/app/package.json b/packages/app/package.json index 10ef17d1b..f8e2bda51 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -45,8 +45,8 @@ "@shikijs/transformers": "3.9.2", "@solid-primitives/active-element": "2.1.3", "@solid-primitives/audio": "1.4.2", - "@solid-primitives/i18n": "2.2.1", "@solid-primitives/event-bus": "1.1.2", + "@solid-primitives/i18n": "2.2.1", "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/scroll": "2.1.3", @@ -56,6 +56,7 @@ "@solidjs/router": "catalog:", "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", + "effect": "4.0.0-beta.29", "fuzzysort": "catalog:", "ghostty-web": "github:anomalyco/ghostty-web#main", "luxon": "catalog:", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 2790e7d3c..1b7ffde46 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,14 +1,29 @@ import "@/index.css" -import { File } from "@opencode-ai/ui/file" import { I18nProvider } from "@opencode-ai/ui/context" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { FileComponentProvider } from "@opencode-ai/ui/context/file" import { MarkedProvider } from "@opencode-ai/ui/context/marked" +import { File } from "@opencode-ai/ui/file" import { Font } from "@opencode-ai/ui/font" +import { Splash } from "@opencode-ai/ui/logo" import { ThemeProvider } from "@opencode-ai/ui/theme" import { MetaProvider } from "@solidjs/meta" -import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" -import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js" +import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" +import { type Duration, Effect } from "effect" +import { + type Component, + createResource, + createSignal, + ErrorBoundary, + For, + type JSX, + lazy, + onCleanup, + type ParentProps, + Show, + Suspense, +} from "solid-js" +import { Dynamic } from "solid-js/web" import { CommandProvider } from "@/context/command" import { CommentsProvider } from "@/context/comments" import { FileProvider } from "@/context/file" @@ -22,13 +37,13 @@ import { NotificationProvider } from "@/context/notification" import { PermissionProvider } from "@/context/permission" import { usePlatform } from "@/context/platform" import { PromptProvider } from "@/context/prompt" -import { type ServerConnection, ServerProvider, useServer } from "@/context/server" +import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { SettingsProvider } from "@/context/settings" import { TerminalProvider } from "@/context/terminal" import DirectoryLayout from "@/pages/directory-layout" import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" -import { Dynamic } from "solid-js/web" +import { useCheckServerHealth } from "./utils/server-health" const Home = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) @@ -139,15 +154,108 @@ export function AppBaseProviders(props: ParentProps) { ) } -function ServerKey(props: ParentProps) { +const effectMinDuration = + (duration: Duration.Input) => + (e: Effect.Effect) => + Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0])) + +function ConnectionGate(props: ParentProps) { const server = useServer() + const checkServerHealth = useCheckServerHealth() + + const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking") + + // performs repeated health check with a grace period for + // non-http connections, otherwise fails instantly + const [startupHealthCheck, healthCheckActions] = createResource(() => + Effect.gen(function* () { + if (!server.current) return true + const { http, type } = server.current + + while (true) { + const res = yield* Effect.promise(() => checkServerHealth(http)) + if (res.healthy) return true + if (checkMode() === "background" || type === "http") return false + } + }).pipe( + effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0), + Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }), + Effect.ensuring(Effect.sync(() => setCheckMode("background"))), + Effect.runPromise, + ), + ) + return ( - - {props.children} + + + + } + > + { + if (checkMode() === "background") healthCheckActions.refetch() + }} + onServerSelected={(key) => { + setCheckMode("blocking") + server.setActive(key) + healthCheckActions.refetch() + }} + /> + } + > + {props.children} + ) } +function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) { + const server = useServer() + const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key) + + const timer = setInterval(() => props.onRetry?.(), 1000) + onCleanup(() => clearInterval(timer)) + + return ( +
+
+ +

+ Could not reach {server.name || server.key} +

+

Retrying automatically...

+
+ 0}> +
+ Other servers +
+ + {(conn) => { + const key = ServerConnection.key(conn) + return ( + + ) + }} + +
+
+
+
+ ) +} + export function AppInterface(props: { children?: JSX.Element defaultServer: ServerConnection.Key @@ -156,7 +264,7 @@ export function AppInterface(props: { }) { return ( - + - + ) } diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 3ecd26910..cba401a46 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -14,7 +14,7 @@ import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" -import { checkServerHealth, type ServerHealth } from "@/utils/server-health" +import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" const DEFAULT_USERNAME = "opencode" @@ -43,13 +43,15 @@ function showRequestError(language: ReturnType, err: unknown }) } -function useDefaultServer(platform: ReturnType, language: ReturnType) { - const [defaultUrl, defaultUrlActions] = createResource( +function useDefaultServer() { + const language = useLanguage() + const platform = usePlatform() + const [defaultKey, defaultUrlActions] = createResource( async () => { try { - const url = await platform.getDefaultServerUrl?.() - if (!url) return null - return normalizeServerUrl(url) ?? null + const key = await platform.getDefaultServer?.() + if (!key) return null + return key } catch (err) { showRequestError(language, err) return null @@ -58,20 +60,22 @@ function useDefaultServer(platform: ReturnType, language: Re { initialValue: null }, ) - const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl) - const setDefault = async (url: string | null) => { + const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer) + const setDefault = async (key: ServerConnection.Key | null) => { try { - await platform.setDefaultServerUrl?.(url) - defaultUrlActions.mutate(url) + await platform.setDefaultServer?.(key) + defaultUrlActions.mutate(key) } catch (err) { showRequestError(language, err) } } - return { defaultUrl, canDefault, setDefault } + return { defaultKey, canDefault, setDefault } } -function useServerPreview(fetcher: typeof fetch) { +function useServerPreview() { + const checkServerHealth = useCheckServerHealth() + const looksComplete = (value: string) => { const normalized = normalizeServerUrl(value) if (!normalized) return false @@ -94,7 +98,7 @@ function useServerPreview(fetcher: typeof fetch) { const http: ServerConnection.HttpBase = { url: normalized } if (username) http.username = username if (password) http.password = password - const result = await checkServerHealth(http, fetcher) + const result = await checkServerHealth(http) setStatus(result.healthy) } @@ -172,9 +176,9 @@ export function DialogSelectServer() { const server = useServer() const platform = usePlatform() const language = useLanguage() - const fetcher = platform.fetch ?? globalThis.fetch - const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language) - const { previewStatus } = useServerPreview(fetcher) + const { defaultKey, canDefault, setDefault } = useDefaultServer() + const { previewStatus } = useServerPreview() + const checkServerHealth = useCheckServerHealth() const [store, setStore] = createStore({ status: {} as Record, addServer: { @@ -266,7 +270,7 @@ export function DialogSelectServer() { const results: Record = {} await Promise.all( items().map(async (conn) => { - results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher) + results[ServerConnection.key(conn)] = await checkServerHealth(conn.http) }), ) setStore("status", reconcile(results)) @@ -366,7 +370,7 @@ export function DialogSelectServer() { if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim() if (store.addServer.password) conn.http.password = store.addServer.password if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username - const result = await checkServerHealth(conn.http, fetcher) + const result = await checkServerHealth(conn.http) setStore("addServer", { adding: false }) if (!result.healthy) { setStore("addServer", { error: language.t("dialog.server.add.error") }) @@ -406,7 +410,7 @@ export function DialogSelectServer() { displayName: name, http: { url: normalized, username, password }, } - const result = await checkServerHealth(conn.http, fetcher) + const result = await checkServerHealth(conn.http) setStore("editServer", { busy: false }) if (!result.healthy) { setStore("editServer", { error: language.t("dialog.server.add.error") }) @@ -496,8 +500,8 @@ export function DialogSelectServer() { async function handleRemove(url: ServerConnection.Key) { server.remove(url) - if ((await platform.getDefaultServerUrl?.()) === url) { - platform.setDefaultServerUrl?.(null) + if ((await platform.getDefaultServer?.()) === url) { + platform.setDefaultServer?.(null) } } @@ -553,7 +557,7 @@ export function DialogSelectServer() { status={store.status[key]} class="flex items-center gap-3 min-w-0 flex-1" badge={ - + {language.t("dialog.server.status.default")} @@ -586,14 +590,14 @@ export function DialogSelectServer() { > {language.t("dialog.server.menu.edit")} - - setDefault(i.http.url)}> + + setDefault(key)}> {language.t("dialog.server.menu.default")} - + setDefault(null)}> {language.t("dialog.server.menu.defaultRemove")} diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index c61b31958..8073746c9 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -14,7 +14,7 @@ import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" -import { checkServerHealth, type ServerHealth } from "@/utils/server-health" +import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" import { DialogSelectServer } from "./dialog-select-server" const pollMs = 10_000 @@ -53,7 +53,8 @@ const listServersByHealth = ( }) } -const useServerHealth = (servers: Accessor, fetcher: typeof fetch) => { +const useServerHealth = (servers: Accessor) => { + const checkServerHealth = useCheckServerHealth() const [status, setStatus] = createStore({} as Record) createEffect(() => { @@ -64,7 +65,7 @@ const useServerHealth = (servers: Accessor, fetcher: typ const results: Record = {} await Promise.all( list.map(async (conn) => { - results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher) + results[ServerConnection.key(conn)] = await checkServerHealth(conn.http) }), ) if (dead) return @@ -168,7 +169,6 @@ export function StatusPopover() { const language = useLanguage() const navigate = useNavigate() - const fetcher = platform.fetch ?? globalThis.fetch const servers = createMemo(() => { const current = server.current const list = server.list @@ -176,10 +176,10 @@ export function StatusPopover() { if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list] return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))] }) - const health = useServerHealth(servers, fetcher) + const health = useServerHealth(servers) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const mcp = useMcpToggle({ sync, sdk, language }) - const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl) + const defaultServer = useDefaultServerKey(platform.getDefaultServer) const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 86f3321e4..b8ed58e34 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -1,6 +1,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage" import type { Accessor } from "solid-js" +import { ServerConnection } from "./server" type PickerPaths = string | string[] | null type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean } @@ -58,10 +59,10 @@ export type Platform = { fetch?: typeof fetch /** Get the configured default server URL (platform-specific) */ - getDefaultServerUrl?(): Promise + getDefaultServer?(): Promise /** Set the default server URL to use on app startup (platform-specific) */ - setDefaultServerUrl?(url: string | null): Promise | void + setDefaultServer?(url: ServerConnection.Key | null): Promise | void /** Get the configured WSL integration (desktop only) */ getWslEnabled?(): Promise diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 4ff777e2e..1171ca905 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -1,9 +1,8 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" -import { usePlatform } from "@/context/platform" import { Persist, persisted } from "@/utils/persist" -import { checkServerHealth } from "@/utils/server-health" +import { useCheckServerHealth } from "@/utils/server-health" type StoredProject = { worktree: string; expanded: boolean } type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http @@ -96,7 +95,7 @@ export namespace ServerConnection { export const { use: useServer, provider: ServerProvider } = createSimpleContext({ name: "Server", init: (props: { defaultServer: ServerConnection.Key; servers?: Array }) => { - const platform = usePlatform() + const checkServerHealth = useCheckServerHealth() const [store, setStore, _, ready] = persisted( Persist.global("server", ["server.v3"]), @@ -197,8 +196,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const isReady = createMemo(() => ready() && !!state.active) - const fetcher = platform.fetch ?? globalThis.fetch - const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy) + const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy) createEffect(() => { const current_ = current() diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index e9c0a4397..c62baccba 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -98,6 +98,19 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) { throw new Error(getRootNotFoundError()) } +const getCurrentUrl = () => { + if (location.hostname.includes("opencode.ai")) return "http://localhost:4096" + if (import.meta.env.DEV) + return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` + return location.origin +} + +const getDefaultUrl = () => { + const lsDefault = readDefaultServerUrl() + if (lsDefault) return lsDefault + return getCurrentUrl() +} + const platform: Platform = { platform: "web", version: pkg.version, @@ -106,26 +119,20 @@ const platform: Platform = { forward, restart, notify, - getDefaultServerUrl: async () => readDefaultServerUrl(), - setDefaultServerUrl: writeDefaultServerUrl, + getDefaultServer: async () => { + const stored = readDefaultServerUrl() + return stored ? ServerConnection.Key.make(stored) : null + }, + setDefaultServer: writeDefaultServerUrl, } -const defaultUrl = iife(() => { - const lsDefault = readDefaultServerUrl() - if (lsDefault) return lsDefault - if (location.hostname.includes("opencode.ai")) return "http://localhost:4096" - if (import.meta.env.DEV) - return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` - return location.origin -}) - if (root instanceof HTMLElement) { - const server: ServerConnection.Http = { type: "http", http: { url: defaultUrl } } + const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } } render( () => ( - + ), diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts index db4aa89bd..45a323c7b 100644 --- a/packages/app/src/utils/server-health.ts +++ b/packages/app/src/utils/server-health.ts @@ -1,3 +1,4 @@ +import { usePlatform } from "@/context/platform" import type { ServerConnection } from "@/context/server" import { createSdkForServer } from "./server" @@ -81,3 +82,10 @@ export async function checkServerHealth( .catch((error) => next(count, error)) return attempt(0).finally(() => timeout?.clear?.()) } + +export function useCheckServerHealth() { + const platform = usePlatform() + const fetcher = platform.fetch ?? globalThis.fetch + + return (http: ServerConnection.HttpBase) => checkServerHealth(http, fetcher) +} diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 45fa7355f..4f67f81a6 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -30,6 +30,7 @@ "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", "@solidjs/router": "0.15.4", + "effect": "4.0.0-beta.29", "electron-log": "^5", "electron-store": "^10", "electron-updater": "^6", diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 7b6acd147..64c2eb10f 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -31,35 +31,13 @@ import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigratio import { initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" -import { - checkHealth, - checkHealthOrAskRetry, - getDefaultServerUrl, - getSavedServerUrl, - getWslConfig, - setDefaultServerUrl, - setWslConfig, - spawnLocalServer, -} from "./server" +import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" import { createLoadingWindow, createMainWindow, setDockIcon } from "./windows" -type ServerConnection = - | { variant: "existing"; url: string } - | { - variant: "cli" - url: string - password: null | string - health: { - wait: Promise - } - events: any - } - const initEmitter = new EventEmitter() let initStep: InitStep = { phase: "server_waiting" } let mainWindow: BrowserWindow | null = null -const loadingWindow: BrowserWindow | null = null let sidecar: CommandChild | null = null const loadingComplete = defer() @@ -131,77 +109,48 @@ function setInitStep(step: InitStep) { initEmitter.emit("step", step) } -async function setupServerConnection(): Promise { - const customUrl = await getSavedServerUrl() - - if (customUrl && (await checkHealthOrAskRetry(customUrl))) { - serverReady.resolve({ url: customUrl, password: null }) - return { variant: "existing", url: customUrl } - } - - const port = await getSidecarPort() - const hostname = "127.0.0.1" - const localUrl = `http://${hostname}:${port}` - - if (await checkHealth(localUrl)) { - serverReady.resolve({ url: localUrl, password: null }) - return { variant: "existing", url: localUrl } - } - - const password = randomUUID() - const { child, health, events } = spawnLocalServer(hostname, port, password) - sidecar = child - - return { - variant: "cli", - url: localUrl, - password, - health, - events, - } -} - async function initialize() { const needsMigration = !sqliteFileExists() const sqliteDone = needsMigration ? defer() : undefined + let overlay: BrowserWindow | null = null + + const port = await getSidecarPort() + const hostname = "127.0.0.1" + const url = `http://${hostname}:${port}` + const password = randomUUID() + + logger.log("spawning sidecar", { url }) + const { child, health, events } = spawnLocalServer(hostname, port, password) + sidecar = child + serverReady.resolve({ + url, + username: "opencode", + password, + }) const loadingTask = (async () => { - logger.log("setting up server connection") - const serverConnection = await setupServerConnection() - logger.log("server connection ready", { - variant: serverConnection.variant, - url: serverConnection.url, + logger.log("sidecar connection started", { url }) + + events.on("sqlite", (progress: SqliteMigrationProgress) => { + setInitStep({ phase: "sqlite_waiting" }) + if (overlay) sendSqliteMigrationProgress(overlay, progress) + if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress) + if (progress.type === "Done") sqliteDone?.resolve() }) - const cliHealthCheck = (() => { - if (serverConnection.variant == "cli") { - return async () => { - const { events, health } = serverConnection - events.on("sqlite", (progress: SqliteMigrationProgress) => { - setInitStep({ phase: "sqlite_waiting" }) - if (loadingWindow) sendSqliteMigrationProgress(loadingWindow, progress) - if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress) - if (progress.type === "Done") sqliteDone?.resolve() - }) - await health.wait - serverReady.resolve({ - url: serverConnection.url, - password: serverConnection.password, - }) - } - } else { - serverReady.resolve({ url: serverConnection.url, password: null }) - return null - } - })() - - logger.log("server connection started") - - if (cliHealthCheck) { - if (needsMigration) await sqliteDone?.promise - cliHealthCheck?.() + if (needsMigration) { + await sqliteDone?.promise } + await Promise.race([ + health.wait, + delay(30_000).then(() => { + throw new Error("Sidecar health check timed out") + }), + ]).catch((error) => { + logger.error("sidecar health check failed", error) + }) + logger.log("loading task finished") })() @@ -211,32 +160,26 @@ async function initialize() { deepLinks: pendingDeepLinks, } - const loadingWindow = await (async () => { - if (needsMigration /** TOOD: 1 second timeout */) { - // showLoading = await Promise.race([init.then(() => false).catch(() => false), delay(1000).then(() => true)]) - const loadingWindow = createLoadingWindow(globals) - await delay(1000) - return loadingWindow - } else { - logger.log("showing main window without loading window") - mainWindow = createMainWindow(globals) - wireMenu() + wireMenu() + + if (needsMigration) { + const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)]) + if (show) { + overlay = createLoadingWindow(globals) + await delay(1_000) } - })() + } await loadingTask setInitStep({ phase: "done" }) - if (loadingWindow) { + if (overlay) { await loadingComplete.promise } - if (!mainWindow) { - mainWindow = createMainWindow(globals) - wireMenu() - } + mainWindow = createMainWindow(globals) - loadingWindow?.close() + overlay?.close() } function wireMenu() { diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index 92018e72e..2d09d119f 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -1,6 +1,4 @@ -import { dialog } from "electron" - -import { getConfig, serve, type CommandChild, type Config } from "./cli" +import { serve, type CommandChild } from "./cli" import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants" import { store } from "./store" @@ -31,15 +29,6 @@ export function setWslConfig(config: WslConfig) { store.set(WSL_ENABLED_KEY, config.enabled) } -export async function getSavedServerUrl(): Promise { - const direct = getDefaultServerUrl() - if (direct) return direct - - const config = await getConfig().catch(() => null) - if (!config) return null - return getServerUrlFromConfig(config) -} - export function spawnLocalServer(hostname: string, port: number, password: string) { const { child, exit, events } = serve(hostname, port, password) @@ -94,36 +83,4 @@ export async function checkHealth(url: string, password?: string | null): Promis } } -export async function checkHealthOrAskRetry(url: string): Promise { - while (true) { - if (await checkHealth(url)) return true - - const result = await dialog.showMessageBox({ - type: "warning", - message: `Could not connect to configured server:\n${url}\n\nWould you like to retry or start a local server instead?`, - title: "Connection Failed", - buttons: ["Retry", "Start Local"], - defaultId: 0, - cancelId: 1, - }) - - if (result.response === 0) continue - return false - } -} - -export function normalizeHostnameForUrl(hostname: string) { - if (hostname === "0.0.0.0") return "127.0.0.1" - if (hostname === "::") return "[::1]" - if (hostname.includes(":") && !hostname.startsWith("[")) return `[${hostname}]` - return hostname -} - -export function getServerUrlFromConfig(config: Config) { - const server = config.server - if (!server?.port) return null - const host = server.hostname ? normalizeHostnameForUrl(server.hostname) : "127.0.0.1" - return `http://${host}:${server.port}` -} - export type { CommandChild } diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index 43bdf1e6c..ae4ca213d 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -2,6 +2,7 @@ export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" } export type ServerReadyData = { url: string + username: string | null password: string | null } diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index b5193d626..e313d5594 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -9,9 +9,8 @@ import { ServerConnection, useCommand, } from "@opencode-ai/app" -import { Splash } from "@opencode-ai/ui/logo" import type { AsyncStorage } from "@solid-primitives/storage" -import { type Accessor, createResource, type JSX, onCleanup, onMount, Show } from "solid-js" +import { createResource, onCleanup, onMount, Show } from "solid-js" import { render } from "solid-js/web" import { MemoryRouter } from "@solidjs/router" import pkg from "../../package.json" @@ -19,7 +18,6 @@ import { initI18n, t } from "./i18n" import { UPDATER_ENABLED } from "./updater" import { webviewZoom } from "./webview-zoom" import "./styles.css" -import type { ServerReadyData } from "../preload/types" const root = document.getElementById("root") if (import.meta.env.DEV && !(root instanceof HTMLElement)) { @@ -198,11 +196,13 @@ const createPlatform = (): Platform => { await window.api.setWslConfig({ enabled }) }, - getDefaultServerUrl: async () => { - return window.api.getDefaultServerUrl().catch(() => null) + getDefaultServer: async () => { + const url = await window.api.getDefaultServerUrl().catch(() => null) + if (!url) return null + return ServerConnection.Key.make(url) }, - setDefaultServerUrl: async (url: string | null) => { + setDefaultServer: async (url: string | null) => { await window.api.setDefaultServerUrl(url) }, @@ -240,6 +240,31 @@ listenForDeepLinks() render(() => { const platform = createPlatform() + // Fetch sidecar credentials (available immediately, before health check) + const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined)) + + const [defaultServer] = createResource(() => + platform.getDefaultServer?.().then((url) => { + if (url) return ServerConnection.key({ type: "http", http: { url } }) + }), + ) + + const servers = () => { + const data = sidecar() + if (!data) return [] + const server: ServerConnection.Sidecar = { + displayName: "Local Server", + type: "sidecar", + variant: "base", + http: { + url: data.url, + username: data.username ?? undefined, + password: data.password ?? undefined, + }, + } + return [server] as ServerConnection.Any[] + } + function handleClick(e: MouseEvent) { const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null if (link?.href) { @@ -248,6 +273,12 @@ render(() => { } } + function Inner() { + const cmd = useCommand() + menuTrigger = (id) => cmd.trigger(id) + return null + } + onMount(() => { document.addEventListener("click", handleClick) onCleanup(() => { @@ -258,55 +289,20 @@ render(() => { return ( - - {(data) => { - const server: ServerConnection.Sidecar = { - displayName: "Local Server", - type: "sidecar", - variant: "base", - http: { - url: data().url, - username: "opencode", - password: data().password ?? undefined, - }, - } - - function Inner() { - const cmd = useCommand() - - menuTrigger = (id) => cmd.trigger(id) - - return null - } - + + {(_) => { return ( - + ) }} - + ) }, root!) - -// Gate component that waits for the server to be ready -function ServerGate(props: { children: (data: Accessor) => JSX.Element }) { - const [serverData] = createResource(() => window.api.awaitInitialization(() => undefined)) - console.log({ serverData }) - if (serverData.state === "errored") throw serverData.error - - return ( - - - - } - > - {(data) => props.children(data)} - - ) -} diff --git a/packages/desktop-electron/src/renderer/loading.tsx b/packages/desktop-electron/src/renderer/loading.tsx index 165950352..000057e0a 100644 --- a/packages/desktop-electron/src/renderer/loading.tsx +++ b/packages/desktop-electron/src/renderer/loading.tsx @@ -1,5 +1,5 @@ -import { render } from "solid-js/web" import { MetaProvider } from "@solidjs/meta" +import { render } from "solid-js/web" import "@opencode-ai/app/index.css" import { Font } from "@opencode-ai/ui/font" import { Splash } from "@opencode-ai/ui/logo" @@ -34,7 +34,10 @@ render(() => { const listener = window.api.onSqliteMigrationProgress((progress: SqliteMigrationProgress) => { if (progress.type === "InProgress") setPercent(Math.max(0, Math.min(100, progress.value))) - if (progress.type === "Done") setPercent(100) + if (progress.type === "Done") { + setPercent(100) + setStep({ phase: "done" }) + } }) onCleanup(() => { diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 137692cdf..a843ac817 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -12,12 +12,10 @@ mod window_customizer; mod windows; use crate::cli::CommandChild; -use futures::{ - FutureExt, TryFutureExt, - future::{self, Shared}, -}; +use futures::{FutureExt, TryFutureExt}; use std::{ env, + future::Future, net::TcpListener, path::PathBuf, process::Command, @@ -35,7 +33,6 @@ use tokio::{ use crate::cli::{sqlite_migration::SqliteMigrationProgress, sync_cli}; use crate::constants::*; -use crate::server::get_saved_server_url; use crate::windows::{LoadingWindow, MainWindow}; #[derive(Clone, serde::Serialize, specta::Type, Debug)] @@ -43,7 +40,6 @@ struct ServerReadyData { url: String, username: Option, password: Option, - is_sidecar: bool, } #[derive(Clone, Copy, serde::Serialize, specta::Type, Debug)] @@ -65,27 +61,12 @@ struct InitState { current: watch::Receiver, } -#[derive(Clone)] struct ServerState { child: Arc>>, - status: future::Shared>>, } -impl ServerState { - pub fn new( - child: Option, - status: Shared>>, - ) -> Self { - Self { - child: Arc::new(Mutex::new(child)), - status, - } - } - - pub fn set_child(&self, child: Option) { - *self.child.lock().unwrap() = child; - } -} +/// Resolves with sidecar credentials as soon as the sidecar is spawned (before health check). +struct SidecarReady(futures::future::Shared>); #[tauri::command] #[specta::specta] @@ -110,26 +91,21 @@ fn kill_sidecar(app: AppHandle) { tracing::info!("Killed server"); } -fn get_logs() -> String { - logging::tail() -} - #[tauri::command] #[specta::specta] async fn await_initialization( - state: State<'_, ServerState>, + state: State<'_, SidecarReady>, init_state: State<'_, InitState>, events: Channel, ) -> Result { let mut rx = init_state.current.clone(); - let events = async { + let stream = async { let e = *rx.borrow(); let _ = events.send(e); while rx.changed().await.is_ok() { let step = *rx.borrow_and_update(); - let _ = events.send(step); if matches!(step, InitStep::Done) { @@ -138,10 +114,18 @@ async fn await_initialization( } }; - future::join(state.status.clone(), events) - .await - .0 - .map_err(|_| "Failed to get server status".to_string())? + // Wait for sidecar credentials (available immediately after spawn, before health check) + let data = async { + state + .inner() + .0 + .clone() + .await + .map_err(|_| "Failed to get sidecar data".to_string()) + }; + + let (result, _) = futures::future::join(data, stream).await; + result } #[tauri::command] @@ -439,22 +423,35 @@ async fn initialize(app: AppHandle) { setup_app(&app, init_rx); spawn_cli_sync_task(app.clone()); - let (server_ready_tx, server_ready_rx) = oneshot::channel(); - let server_ready_rx = server_ready_rx.shared(); - app.manage(ServerState::new(None, server_ready_rx.clone())); + // Spawn sidecar immediately - credentials are known before health check + let port = get_sidecar_port(); + let hostname = "127.0.0.1"; + let url = format!("http://{hostname}:{port}"); + let password = uuid::Uuid::new_v4().to_string(); + + tracing::info!("Spawning sidecar on {url}"); + let (child, health_check) = + server::spawn_local_server(app.clone(), hostname.to_string(), port, password.clone()); + + // Make sidecar credentials available immediately (before health check completes) + let (ready_tx, ready_rx) = oneshot::channel(); + let _ = ready_tx.send(ServerReadyData { + url: url.clone(), + username: Some("opencode".to_string()), + password: Some(password), + }); + app.manage(SidecarReady(ready_rx.shared())); + app.manage(ServerState { + child: Arc::new(Mutex::new(Some(child))), + }); let loading_window_complete = event_once_fut::(&app); - tracing::info!("Main and loading windows created"); - // SQLite migration handling: - // We only do this if the sqlite db doesn't exist, and we're expecting the sidecar to create it - // First, we spawn a task that listens for SqliteMigrationProgress events that can - // come from any invocation of the sidecar CLI. The progress is captured by a stdout stream interceptor. - // Then in the loading task, we wait for sqlite migration to complete before - // starting our health check against the server, otherwise long migrations could result in a timeout. - let needs_sqlite_migration = !sqlite_file_exists(); - let sqlite_done = needs_sqlite_migration.then(|| { + // We only do this if the sqlite db doesn't exist, and we're expecting the sidecar to create it. + // A separate loading window is shown for long migrations. + let needs_migration = !sqlite_file_exists(); + let sqlite_done = needs_migration.then(|| { tracing::info!( path = %opencode_db_path().expect("failed to get db path").display(), "Sqlite file not found, waiting for it to be generated" @@ -480,80 +477,22 @@ async fn initialize(app: AppHandle) { })) }); + // The loading task waits for SQLite migration (if needed) then for the sidecar health check. + // This is only used to drive the loading window progress - the main window is shown immediately. let loading_task = tokio::spawn({ - let app = app.clone(); - async move { - tracing::info!("Setting up server connection"); - let server_connection = setup_server_connection(app.clone()).await; - tracing::info!("Server connection setup"); - - // we delay spawning this future so that the timeout is created lazily - let cli_health_check = match server_connection { - ServerConnection::CLI { - child, - health_check, - url, - username, - password, - } => { - let app = app.clone(); - Some( - async move { - let res = timeout(Duration::from_secs(30), health_check.0).await; - let err = match res { - Ok(Ok(Ok(()))) => None, - Ok(Ok(Err(e))) => Some(e), - Ok(Err(e)) => Some(format!("Health check task failed: {e}")), - Err(_) => Some("Health check timed out".to_string()), - }; - - if let Some(err) = err { - let _ = child.kill(); - - return Err(format!( - "Failed to spawn OpenCode Server ({err}). Logs:\n{}", - get_logs() - )); - } - - tracing::info!("CLI health check OK"); - - app.state::().set_child(Some(child)); - - Ok(ServerReadyData { - url, - username, - password, - is_sidecar: true, - }) - } - .map(move |res| { - let _ = server_ready_tx.send(res); - }), - ) - } - ServerConnection::Existing { url } => { - let _ = server_ready_tx.send(Ok(ServerReadyData { - url: url.to_string(), - username: None, - password: None, - is_sidecar: false, - })); - None - } - }; - - tracing::info!("server connection started"); - - if let Some(cli_health_check) = cli_health_check { - if let Some(sqlite_done_rx) = sqlite_done { - let _ = sqlite_done_rx.await; - } - tokio::spawn(cli_health_check); + if let Some(sqlite_done_rx) = sqlite_done { + let _ = sqlite_done_rx.await; } - let _ = server_ready_rx.await; + // Wait for sidecar to become healthy (for loading window progress) + let res = timeout(Duration::from_secs(30), health_check.0).await; + match res { + Ok(Ok(Ok(()))) => tracing::info!("Sidecar health check OK"), + Ok(Ok(Err(e))) => tracing::error!("Sidecar health check failed: {e}"), + Ok(Err(e)) => tracing::error!("Sidecar health check task failed: {e}"), + Err(_) => tracing::error!("Sidecar health check timed out"), + } tracing::info!("Loading task finished"); } @@ -561,7 +500,8 @@ async fn initialize(app: AppHandle) { .map_err(|_| ()) .shared(); - let loading_window = if needs_sqlite_migration + // Show loading window for SQLite migrations if they take >1s + let loading_window = if needs_migration && timeout(Duration::from_secs(1), loading_task.clone()) .await .is_err() @@ -571,12 +511,12 @@ async fn initialize(app: AppHandle) { sleep(Duration::from_secs(1)).await; Some(loading_window) } else { - tracing::debug!("Showing main window without loading window"); - MainWindow::create(&app).expect("Failed to create main window"); - None }; + // Create main window immediately - the web app handles its own loading/health gate + MainWindow::create(&app).expect("Failed to create main window"); + let _ = loading_task.await; tracing::info!("Loading done, completing initialisation"); @@ -584,12 +524,9 @@ async fn initialize(app: AppHandle) { if loading_window.is_some() { loading_window_complete.await; - tracing::info!("Loading window completed"); } - MainWindow::create(&app).expect("Failed to create main window"); - if let Some(loading_window) = loading_window { let _ = loading_window.close(); } @@ -610,59 +547,6 @@ fn spawn_cli_sync_task(app: AppHandle) { }); } -enum ServerConnection { - Existing { - url: String, - }, - CLI { - url: String, - username: Option, - password: Option, - child: CommandChild, - health_check: server::HealthCheck, - }, -} - -async fn setup_server_connection(app: AppHandle) -> ServerConnection { - let custom_url = get_saved_server_url(&app).await; - - tracing::info!(?custom_url, "Attempting server connection"); - - if let Some(url) = &custom_url - && server::check_health_or_ask_retry(&app, url).await - { - tracing::info!(%url, "Connected to custom server"); - // If the default server is already local, no need to also spawn a sidecar - if server::is_localhost_url(url) { - return ServerConnection::Existing { url: url.clone() }; - } - // Remote default server: fall through and also spawn a local sidecar - } - - let local_port = get_sidecar_port(); - let hostname = "127.0.0.1"; - let local_url = format!("http://{hostname}:{local_port}"); - - tracing::debug!(url = %local_url, "Checking health of local server"); - if server::check_health(&local_url, None).await { - tracing::info!(url = %local_url, "Health check OK, using existing server"); - return ServerConnection::Existing { url: local_url }; - } - - let password = uuid::Uuid::new_v4().to_string(); - - tracing::info!("Spawning new local server"); - let (child, health_check) = - server::spawn_local_server(app, hostname.to_string(), local_port, password.clone()); - - ServerConnection::CLI { - url: local_url, - username: Some("opencode".to_string()), - password: Some(password), - child, - health_check, - } -} fn get_sidecar_port() -> u32 { option_env!("OPENCODE_PORT") diff --git a/packages/desktop/src-tauri/src/server.rs b/packages/desktop/src-tauri/src/server.rs index 2c43c1cc8..070d0c71f 100644 --- a/packages/desktop/src-tauri/src/server.rs +++ b/packages/desktop/src-tauri/src/server.rs @@ -1,7 +1,6 @@ use std::time::{Duration, Instant}; use tauri::AppHandle; -use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult}; use tauri_plugin_store::StoreExt; use tokio::task::JoinHandle; @@ -85,22 +84,6 @@ pub fn set_wsl_config(app: AppHandle, config: WslConfig) -> Result<(), String> { Ok(()) } -pub async fn get_saved_server_url(app: &tauri::AppHandle) -> Option { - if let Some(url) = get_default_server_url(app.clone()).ok().flatten() { - tracing::info!(%url, "Using desktop-specific custom URL"); - return Some(url); - } - - if let Some(cli_config) = cli::get_config(app).await - && let Some(url) = get_server_url_from_config(&cli_config) - { - tracing::info!(%url, "Using custom server URL from config"); - return Some(url); - } - - None -} - pub fn spawn_local_server( app: AppHandle, hostname: String, @@ -145,19 +128,27 @@ pub fn spawn_local_server( pub struct HealthCheck(pub JoinHandle>); -pub async fn check_health(url: &str, password: Option<&str>) -> bool { +async fn check_health(url: &str, password: Option<&str>) -> bool { let Ok(url) = reqwest::Url::parse(url) else { return false; }; let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(7)); - if url_is_localhost(&url) { + if url + .host_str() + .is_some_and(|host| { + host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|ip| ip.is_loopback()) + }) + { // Some environments set proxy variables (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY) without // excluding loopback. reqwest respects these by default, which can prevent the desktop // app from reaching its own local sidecar server. builder = builder.no_proxy(); - }; + } let Ok(client) = builder.build() else { return false; @@ -177,77 +168,3 @@ pub async fn check_health(url: &str, password: Option<&str>) -> bool { .map(|r| r.status().is_success()) .unwrap_or(false) } - -pub fn is_localhost_url(url: &str) -> bool { - reqwest::Url::parse(url).is_ok_and(|u| url_is_localhost(&u)) -} - -fn url_is_localhost(url: &reqwest::Url) -> bool { - url.host_str().is_some_and(|host| { - host.eq_ignore_ascii_case("localhost") - || host - .parse::() - .is_ok_and(|ip| ip.is_loopback()) - }) -} - -/// Converts a bind address hostname to a valid URL hostname for connection. -/// - `0.0.0.0` and `::` are wildcard bind addresses, not valid connect targets -/// - IPv6 addresses need brackets in URLs (e.g., `::1` -> `[::1]`) -fn normalize_hostname_for_url(hostname: &str) -> String { - // Wildcard bind addresses -> localhost equivalents - if hostname == "0.0.0.0" { - return "127.0.0.1".to_string(); - } - if hostname == "::" { - return "[::1]".to_string(); - } - - // IPv6 addresses need brackets in URLs - if hostname.contains(':') && !hostname.starts_with('[') { - return format!("[{}]", hostname); - } - - hostname.to_string() -} - -fn get_server_url_from_config(config: &cli::Config) -> Option { - let server = config.server.as_ref()?; - let port = server.port?; - tracing::debug!(port, "server.port found in OC config"); - let hostname = server - .hostname - .as_ref() - .map(|v| normalize_hostname_for_url(v)) - .unwrap_or_else(|| "127.0.0.1".to_string()); - - Some(format!("http://{}:{}", hostname, port)) -} - -pub async fn check_health_or_ask_retry(app: &AppHandle, url: &str) -> bool { - tracing::debug!(%url, "Checking health"); - loop { - if check_health(url, None).await { - return true; - } - - const RETRY: &str = "Retry"; - - let res = app.dialog() - .message(format!("Could not connect to configured server:\n{}\n\nWould you like to retry or start a local server instead?", url)) - .title("Connection Failed") - .buttons(MessageDialogButtons::OkCancelCustom(RETRY.to_string(), "Start Local".to_string())) - .blocking_show_with_result(); - - match res { - MessageDialogResult::Custom(name) if name == RETRY => { - continue; - } - _ => { - break; - } - } - } - - false -} diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index 80548173e..d434d3b35 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -38,7 +38,6 @@ export type ServerReadyData = { url: string, username: string | null, password: string | null, - is_sidecar: boolean, }; export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }; diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 9afabe918..65149f34b 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -9,7 +9,6 @@ import { ServerConnection, useCommand, } from "@opencode-ai/app" -import { Splash } from "@opencode-ai/ui/logo" import type { AsyncStorage } from "@solid-primitives/storage" import { getCurrentWindow } from "@tauri-apps/api/window" import { readImage } from "@tauri-apps/plugin-clipboard-manager" @@ -22,7 +21,7 @@ import { relaunch } from "@tauri-apps/plugin-process" import { open as shellOpen } from "@tauri-apps/plugin-shell" import { Store } from "@tauri-apps/plugin-store" import { check, type Update } from "@tauri-apps/plugin-updater" -import { createResource, type JSX, onCleanup, onMount, Show } from "solid-js" +import { createResource, onCleanup, onMount, Show } from "solid-js" import { render } from "solid-js/web" import pkg from "../package.json" import { initI18n, t } from "./i18n" @@ -30,7 +29,7 @@ import { UPDATER_ENABLED } from "./updater" import { webviewZoom } from "./webview-zoom" import "./styles.css" import { Channel } from "@tauri-apps/api/core" -import { commands, ServerReadyData, type InitStep } from "./bindings" +import { commands, type InitStep } from "./bindings" import { createMenu } from "./menu" const root = document.getElementById("root") @@ -348,12 +347,13 @@ const createPlatform = (): Platform => { await commands.setWslConfig({ enabled }) }, - getDefaultServerUrl: async () => { - const result = await commands.getDefaultServerUrl().catch(() => null) - return result + getDefaultServer: async () => { + const url = await commands.getDefaultServerUrl().catch(() => null) + if (!url) return null + return ServerConnection.Key.make(url) }, - setDefaultServerUrl: async (url: string | null) => { + setDefaultServer: async (url: string | null) => { await commands.setDefaultServerUrl(url) }, @@ -412,12 +412,33 @@ void listenForDeepLinks() render(() => { const platform = createPlatform() + // Fetch sidecar credentials from Rust (available immediately, before health check) + const [sidecar] = createResource(() => commands.awaitInitialization(new Channel() as any)) + const [defaultServer] = createResource(() => - platform.getDefaultServerUrl?.().then((url) => { + platform.getDefaultServer?.().then((url) => { if (url) return ServerConnection.key({ type: "http", http: { url } }) }), ) + // Build the sidecar server connection once credentials arrive + const servers = () => { + const data = sidecar() + if (!data) return [] + const http = { + url: data.url, + username: data.username ?? undefined, + password: data.password ?? undefined, + } + const server: ServerConnection.Sidecar = { + displayName: t("desktop.server.local"), + type: "sidecar", + variant: "base", + http, + } + return [server] as ServerConnection.Any[] + } + function handleClick(e: MouseEvent) { const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null if (link?.href) { @@ -426,6 +447,12 @@ render(() => { } } + function Inner() { + const cmd = useCommand() + menuTrigger = (id) => cmd.trigger(id) + return null + } + onMount(() => { document.addEventListener("click", handleClick) onCleanup(() => { @@ -436,60 +463,19 @@ render(() => { return ( - - {(data) => { - const http = { - url: data.url, - username: data.username ?? undefined, - password: data.password ?? undefined, - } - const server: ServerConnection.Any = data.is_sidecar - ? { - displayName: t("desktop.server.local"), - type: "sidecar", - variant: "base", - http, - } - : { type: "http", http } - - function Inner() { - const cmd = useCommand() - - menuTrigger = (id) => cmd.trigger(id) - - return null - } - + + {(_) => { return ( - - - - - + + + ) }} - + ) }, root!) - -// Gate component that waits for the server to be ready -function ServerGate(props: { children: (data: ServerReadyData) => JSX.Element }) { - const [serverData] = createResource(() => commands.awaitInitialization(new Channel() as any)) - if (serverData.state === "errored") throw serverData.error - - return ( - - -
-
- } - > - {(data) => props.children(data())} -
- ) -}