mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-21 16:14:45 +00:00
refactor(desktop): rework default server initialization and connection handling (#16965)
This commit is contained in:
2
bun.lock
2
bun.lock
@@ -46,6 +46,7 @@
|
|||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
|
"effect": "4.0.0-beta.29",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
@@ -226,6 +227,7 @@
|
|||||||
"@solid-primitives/storage": "catalog:",
|
"@solid-primitives/storage": "catalog:",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "0.15.4",
|
"@solidjs/router": "0.15.4",
|
||||||
|
"effect": "4.0.0-beta.29",
|
||||||
"electron-log": "^5",
|
"electron-log": "^5",
|
||||||
"electron-store": "^10",
|
"electron-store": "^10",
|
||||||
"electron-updater": "^6",
|
"electron-updater": "^6",
|
||||||
|
|||||||
@@ -45,8 +45,8 @@
|
|||||||
"@shikijs/transformers": "3.9.2",
|
"@shikijs/transformers": "3.9.2",
|
||||||
"@solid-primitives/active-element": "2.1.3",
|
"@solid-primitives/active-element": "2.1.3",
|
||||||
"@solid-primitives/audio": "1.4.2",
|
"@solid-primitives/audio": "1.4.2",
|
||||||
"@solid-primitives/i18n": "2.2.1",
|
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
|
"@solid-primitives/i18n": "2.2.1",
|
||||||
"@solid-primitives/media": "2.3.3",
|
"@solid-primitives/media": "2.3.3",
|
||||||
"@solid-primitives/resize-observer": "2.1.3",
|
"@solid-primitives/resize-observer": "2.1.3",
|
||||||
"@solid-primitives/scroll": "2.1.3",
|
"@solid-primitives/scroll": "2.1.3",
|
||||||
@@ -56,6 +56,7 @@
|
|||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
|
"effect": "4.0.0-beta.29",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
import "@/index.css"
|
import "@/index.css"
|
||||||
import { File } from "@opencode-ai/ui/file"
|
|
||||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||||
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||||
|
import { File } from "@opencode-ai/ui/file"
|
||||||
import { Font } from "@opencode-ai/ui/font"
|
import { Font } from "@opencode-ai/ui/font"
|
||||||
|
import { Splash } from "@opencode-ai/ui/logo"
|
||||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||||
import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
|
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 { CommandProvider } from "@/context/command"
|
||||||
import { CommentsProvider } from "@/context/comments"
|
import { CommentsProvider } from "@/context/comments"
|
||||||
import { FileProvider } from "@/context/file"
|
import { FileProvider } from "@/context/file"
|
||||||
@@ -22,13 +37,13 @@ import { NotificationProvider } from "@/context/notification"
|
|||||||
import { PermissionProvider } from "@/context/permission"
|
import { PermissionProvider } from "@/context/permission"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { PromptProvider } from "@/context/prompt"
|
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 { SettingsProvider } from "@/context/settings"
|
||||||
import { TerminalProvider } from "@/context/terminal"
|
import { TerminalProvider } from "@/context/terminal"
|
||||||
import DirectoryLayout from "@/pages/directory-layout"
|
import DirectoryLayout from "@/pages/directory-layout"
|
||||||
import Layout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
|
|
||||||
const Home = lazy(() => import("@/pages/home"))
|
const Home = lazy(() => import("@/pages/home"))
|
||||||
const Session = lazy(() => import("@/pages/session"))
|
const Session = lazy(() => import("@/pages/session"))
|
||||||
@@ -139,15 +154,108 @@ export function AppBaseProviders(props: ParentProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ServerKey(props: ParentProps) {
|
const effectMinDuration =
|
||||||
|
(duration: Duration.Input) =>
|
||||||
|
<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) {
|
||||||
const server = useServer()
|
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 (
|
return (
|
||||||
<Show when={server.key} keyed>
|
<Show
|
||||||
{props.children}
|
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
|
||||||
|
fallback={
|
||||||
|
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
||||||
|
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={startupHealthCheck()}
|
||||||
|
fallback={
|
||||||
|
<ConnectionError
|
||||||
|
onRetry={() => {
|
||||||
|
if (checkMode() === "background") healthCheckActions.refetch()
|
||||||
|
}}
|
||||||
|
onServerSelected={(key) => {
|
||||||
|
setCheckMode("blocking")
|
||||||
|
server.setActive(key)
|
||||||
|
healthCheckActions.refetch()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
|
||||||
|
<div class="flex flex-col items-center max-w-md text-center">
|
||||||
|
<Splash class="w-12 h-15 mb-4" />
|
||||||
|
<p class="text-14-regular text-text-base">
|
||||||
|
Could not reach <span class="text-text-strong font-medium">{server.name || server.key}</span>
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-12-regular text-text-weak">Retrying automatically...</p>
|
||||||
|
</div>
|
||||||
|
<Show when={others().length > 0}>
|
||||||
|
<div class="flex flex-col gap-2 w-full max-w-sm">
|
||||||
|
<span class="text-12-regular text-text-base text-center">Other servers</span>
|
||||||
|
<div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
|
||||||
|
<For each={others()}>
|
||||||
|
{(conn) => {
|
||||||
|
const key = ServerConnection.key(conn)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||||
|
onClick={() => props.onServerSelected?.(key)}
|
||||||
|
>
|
||||||
|
<span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function AppInterface(props: {
|
export function AppInterface(props: {
|
||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
defaultServer: ServerConnection.Key
|
defaultServer: ServerConnection.Key
|
||||||
@@ -156,7 +264,7 @@ export function AppInterface(props: {
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
||||||
<ServerKey>
|
<ConnectionGate>
|
||||||
<GlobalSDKProvider>
|
<GlobalSDKProvider>
|
||||||
<GlobalSyncProvider>
|
<GlobalSyncProvider>
|
||||||
<Dynamic
|
<Dynamic
|
||||||
@@ -171,7 +279,7 @@ export function AppInterface(props: {
|
|||||||
</Dynamic>
|
</Dynamic>
|
||||||
</GlobalSyncProvider>
|
</GlobalSyncProvider>
|
||||||
</GlobalSDKProvider>
|
</GlobalSDKProvider>
|
||||||
</ServerKey>
|
</ConnectionGate>
|
||||||
</ServerProvider>
|
</ServerProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
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"
|
const DEFAULT_USERNAME = "opencode"
|
||||||
|
|
||||||
@@ -43,13 +43,15 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
|
function useDefaultServer() {
|
||||||
const [defaultUrl, defaultUrlActions] = createResource(
|
const language = useLanguage()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const [defaultKey, defaultUrlActions] = createResource(
|
||||||
async () => {
|
async () => {
|
||||||
try {
|
try {
|
||||||
const url = await platform.getDefaultServerUrl?.()
|
const key = await platform.getDefaultServer?.()
|
||||||
if (!url) return null
|
if (!key) return null
|
||||||
return normalizeServerUrl(url) ?? null
|
return key
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showRequestError(language, err)
|
showRequestError(language, err)
|
||||||
return null
|
return null
|
||||||
@@ -58,20 +60,22 @@ function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: Re
|
|||||||
{ initialValue: null },
|
{ initialValue: null },
|
||||||
)
|
)
|
||||||
|
|
||||||
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
|
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
|
||||||
const setDefault = async (url: string | null) => {
|
const setDefault = async (key: ServerConnection.Key | null) => {
|
||||||
try {
|
try {
|
||||||
await platform.setDefaultServerUrl?.(url)
|
await platform.setDefaultServer?.(key)
|
||||||
defaultUrlActions.mutate(url)
|
defaultUrlActions.mutate(key)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showRequestError(language, 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 looksComplete = (value: string) => {
|
||||||
const normalized = normalizeServerUrl(value)
|
const normalized = normalizeServerUrl(value)
|
||||||
if (!normalized) return false
|
if (!normalized) return false
|
||||||
@@ -94,7 +98,7 @@ function useServerPreview(fetcher: typeof fetch) {
|
|||||||
const http: ServerConnection.HttpBase = { url: normalized }
|
const http: ServerConnection.HttpBase = { url: normalized }
|
||||||
if (username) http.username = username
|
if (username) http.username = username
|
||||||
if (password) http.password = password
|
if (password) http.password = password
|
||||||
const result = await checkServerHealth(http, fetcher)
|
const result = await checkServerHealth(http)
|
||||||
setStatus(result.healthy)
|
setStatus(result.healthy)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,9 +176,9 @@ export function DialogSelectServer() {
|
|||||||
const server = useServer()
|
const server = useServer()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const fetcher = platform.fetch ?? globalThis.fetch
|
const { defaultKey, canDefault, setDefault } = useDefaultServer()
|
||||||
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
|
const { previewStatus } = useServerPreview()
|
||||||
const { previewStatus } = useServerPreview(fetcher)
|
const checkServerHealth = useCheckServerHealth()
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||||
addServer: {
|
addServer: {
|
||||||
@@ -266,7 +270,7 @@ export function DialogSelectServer() {
|
|||||||
const results: Record<ServerConnection.Key, ServerHealth> = {}
|
const results: Record<ServerConnection.Key, ServerHealth> = {}
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
items().map(async (conn) => {
|
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))
|
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.name.trim()) conn.displayName = store.addServer.name.trim()
|
||||||
if (store.addServer.password) conn.http.password = store.addServer.password
|
if (store.addServer.password) conn.http.password = store.addServer.password
|
||||||
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
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 })
|
setStore("addServer", { adding: false })
|
||||||
if (!result.healthy) {
|
if (!result.healthy) {
|
||||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||||
@@ -406,7 +410,7 @@ export function DialogSelectServer() {
|
|||||||
displayName: name,
|
displayName: name,
|
||||||
http: { url: normalized, username, password },
|
http: { url: normalized, username, password },
|
||||||
}
|
}
|
||||||
const result = await checkServerHealth(conn.http, fetcher)
|
const result = await checkServerHealth(conn.http)
|
||||||
setStore("editServer", { busy: false })
|
setStore("editServer", { busy: false })
|
||||||
if (!result.healthy) {
|
if (!result.healthy) {
|
||||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||||
@@ -496,8 +500,8 @@ export function DialogSelectServer() {
|
|||||||
|
|
||||||
async function handleRemove(url: ServerConnection.Key) {
|
async function handleRemove(url: ServerConnection.Key) {
|
||||||
server.remove(url)
|
server.remove(url)
|
||||||
if ((await platform.getDefaultServerUrl?.()) === url) {
|
if ((await platform.getDefaultServer?.()) === url) {
|
||||||
platform.setDefaultServerUrl?.(null)
|
platform.setDefaultServer?.(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,7 +557,7 @@ export function DialogSelectServer() {
|
|||||||
status={store.status[key]}
|
status={store.status[key]}
|
||||||
class="flex items-center gap-3 min-w-0 flex-1"
|
class="flex items-center gap-3 min-w-0 flex-1"
|
||||||
badge={
|
badge={
|
||||||
<Show when={defaultUrl() === i.http.url}>
|
<Show when={defaultKey() === ServerConnection.key(i)}>
|
||||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||||
{language.t("dialog.server.status.default")}
|
{language.t("dialog.server.status.default")}
|
||||||
</span>
|
</span>
|
||||||
@@ -586,14 +590,14 @@ export function DialogSelectServer() {
|
|||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<Show when={canDefault() && defaultUrl() !== i.http.url}>
|
<Show when={canDefault() && defaultKey() !== key}>
|
||||||
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
|
<DropdownMenu.Item onSelect={() => setDefault(key)}>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.default")}
|
{language.t("dialog.server.menu.default")}
|
||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={canDefault() && defaultUrl() === i.http.url}>
|
<Show when={canDefault() && defaultKey() === key}>
|
||||||
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
{language.t("dialog.server.menu.defaultRemove")}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { usePlatform } from "@/context/platform"
|
|||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
import { useSync } from "@/context/sync"
|
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"
|
import { DialogSelectServer } from "./dialog-select-server"
|
||||||
|
|
||||||
const pollMs = 10_000
|
const pollMs = 10_000
|
||||||
@@ -53,7 +53,8 @@ const listServersByHealth = (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => {
|
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
|
||||||
|
const checkServerHealth = useCheckServerHealth()
|
||||||
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -64,7 +65,7 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typ
|
|||||||
const results: Record<string, ServerHealth> = {}
|
const results: Record<string, ServerHealth> = {}
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
list.map(async (conn) => {
|
list.map(async (conn) => {
|
||||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
|
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (dead) return
|
if (dead) return
|
||||||
@@ -168,7 +169,6 @@ export function StatusPopover() {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const fetcher = platform.fetch ?? globalThis.fetch
|
|
||||||
const servers = createMemo(() => {
|
const servers = createMemo(() => {
|
||||||
const current = server.current
|
const current = server.current
|
||||||
const list = server.list
|
const list = server.list
|
||||||
@@ -176,10 +176,10 @@ export function StatusPopover() {
|
|||||||
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
||||||
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
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 sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||||
const mcp = useMcpToggle({ sync, sdk, language })
|
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 mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
|
||||||
import type { Accessor } from "solid-js"
|
import type { Accessor } from "solid-js"
|
||||||
|
import { ServerConnection } from "./server"
|
||||||
|
|
||||||
type PickerPaths = string | string[] | null
|
type PickerPaths = string | string[] | null
|
||||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||||
@@ -58,10 +59,10 @@ export type Platform = {
|
|||||||
fetch?: typeof fetch
|
fetch?: typeof fetch
|
||||||
|
|
||||||
/** Get the configured default server URL (platform-specific) */
|
/** Get the configured default server URL (platform-specific) */
|
||||||
getDefaultServerUrl?(): Promise<string | null>
|
getDefaultServer?(): Promise<ServerConnection.Key | null>
|
||||||
|
|
||||||
/** Set the default server URL to use on app startup (platform-specific) */
|
/** Set the default server URL to use on app startup (platform-specific) */
|
||||||
setDefaultServerUrl?(url: string | null): Promise<void> | void
|
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
|
||||||
|
|
||||||
/** Get the configured WSL integration (desktop only) */
|
/** Get the configured WSL integration (desktop only) */
|
||||||
getWslEnabled?(): Promise<boolean>
|
getWslEnabled?(): Promise<boolean>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
|
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
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 StoredProject = { worktree: string; expanded: boolean }
|
||||||
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
|
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
|
||||||
@@ -96,7 +95,7 @@ export namespace ServerConnection {
|
|||||||
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
||||||
name: "Server",
|
name: "Server",
|
||||||
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
|
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
|
||||||
const platform = usePlatform()
|
const checkServerHealth = useCheckServerHealth()
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.global("server", ["server.v3"]),
|
Persist.global("server", ["server.v3"]),
|
||||||
@@ -197,8 +196,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
|
|
||||||
const isReady = createMemo(() => ready() && !!state.active)
|
const isReady = createMemo(() => ready() && !!state.active)
|
||||||
|
|
||||||
const fetcher = platform.fetch ?? globalThis.fetch
|
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy)
|
||||||
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy)
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const current_ = current()
|
const current_ = current()
|
||||||
|
|||||||
@@ -98,6 +98,19 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
|
|||||||
throw new Error(getRootNotFoundError())
|
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 = {
|
const platform: Platform = {
|
||||||
platform: "web",
|
platform: "web",
|
||||||
version: pkg.version,
|
version: pkg.version,
|
||||||
@@ -106,26 +119,20 @@ const platform: Platform = {
|
|||||||
forward,
|
forward,
|
||||||
restart,
|
restart,
|
||||||
notify,
|
notify,
|
||||||
getDefaultServerUrl: async () => readDefaultServerUrl(),
|
getDefaultServer: async () => {
|
||||||
setDefaultServerUrl: writeDefaultServerUrl,
|
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) {
|
if (root instanceof HTMLElement) {
|
||||||
const server: ServerConnection.Http = { type: "http", http: { url: defaultUrl } }
|
const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
|
||||||
render(
|
render(
|
||||||
() => (
|
() => (
|
||||||
<PlatformProvider value={platform}>
|
<PlatformProvider value={platform}>
|
||||||
<AppBaseProviders>
|
<AppBaseProviders>
|
||||||
<AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} />
|
<AppInterface defaultServer={ServerConnection.Key.make(getDefaultUrl())} servers={[server]} />
|
||||||
</AppBaseProviders>
|
</AppBaseProviders>
|
||||||
</PlatformProvider>
|
</PlatformProvider>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
import type { ServerConnection } from "@/context/server"
|
import type { ServerConnection } from "@/context/server"
|
||||||
import { createSdkForServer } from "./server"
|
import { createSdkForServer } from "./server"
|
||||||
|
|
||||||
@@ -81,3 +82,10 @@ export async function checkServerHealth(
|
|||||||
.catch((error) => next(count, error))
|
.catch((error) => next(count, error))
|
||||||
return attempt(0).finally(() => timeout?.clear?.())
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"@solid-primitives/storage": "catalog:",
|
"@solid-primitives/storage": "catalog:",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "0.15.4",
|
"@solidjs/router": "0.15.4",
|
||||||
|
"effect": "4.0.0-beta.29",
|
||||||
"electron-log": "^5",
|
"electron-log": "^5",
|
||||||
"electron-store": "^10",
|
"electron-store": "^10",
|
||||||
"electron-updater": "^6",
|
"electron-updater": "^6",
|
||||||
|
|||||||
@@ -31,35 +31,13 @@ import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigratio
|
|||||||
import { initLogging } from "./logging"
|
import { initLogging } from "./logging"
|
||||||
import { parseMarkdown } from "./markdown"
|
import { parseMarkdown } from "./markdown"
|
||||||
import { createMenu } from "./menu"
|
import { createMenu } from "./menu"
|
||||||
import {
|
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
|
||||||
checkHealth,
|
|
||||||
checkHealthOrAskRetry,
|
|
||||||
getDefaultServerUrl,
|
|
||||||
getSavedServerUrl,
|
|
||||||
getWslConfig,
|
|
||||||
setDefaultServerUrl,
|
|
||||||
setWslConfig,
|
|
||||||
spawnLocalServer,
|
|
||||||
} from "./server"
|
|
||||||
import { createLoadingWindow, createMainWindow, setDockIcon } from "./windows"
|
import { createLoadingWindow, createMainWindow, setDockIcon } from "./windows"
|
||||||
|
|
||||||
type ServerConnection =
|
|
||||||
| { variant: "existing"; url: string }
|
|
||||||
| {
|
|
||||||
variant: "cli"
|
|
||||||
url: string
|
|
||||||
password: null | string
|
|
||||||
health: {
|
|
||||||
wait: Promise<void>
|
|
||||||
}
|
|
||||||
events: any
|
|
||||||
}
|
|
||||||
|
|
||||||
const initEmitter = new EventEmitter()
|
const initEmitter = new EventEmitter()
|
||||||
let initStep: InitStep = { phase: "server_waiting" }
|
let initStep: InitStep = { phase: "server_waiting" }
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
const loadingWindow: BrowserWindow | null = null
|
|
||||||
let sidecar: CommandChild | null = null
|
let sidecar: CommandChild | null = null
|
||||||
const loadingComplete = defer<void>()
|
const loadingComplete = defer<void>()
|
||||||
|
|
||||||
@@ -131,77 +109,48 @@ function setInitStep(step: InitStep) {
|
|||||||
initEmitter.emit("step", step)
|
initEmitter.emit("step", step)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setupServerConnection(): Promise<ServerConnection> {
|
|
||||||
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() {
|
async function initialize() {
|
||||||
const needsMigration = !sqliteFileExists()
|
const needsMigration = !sqliteFileExists()
|
||||||
const sqliteDone = needsMigration ? defer<void>() : undefined
|
const sqliteDone = needsMigration ? defer<void>() : 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 () => {
|
const loadingTask = (async () => {
|
||||||
logger.log("setting up server connection")
|
logger.log("sidecar connection started", { url })
|
||||||
const serverConnection = await setupServerConnection()
|
|
||||||
logger.log("server connection ready", {
|
events.on("sqlite", (progress: SqliteMigrationProgress) => {
|
||||||
variant: serverConnection.variant,
|
setInitStep({ phase: "sqlite_waiting" })
|
||||||
url: serverConnection.url,
|
if (overlay) sendSqliteMigrationProgress(overlay, progress)
|
||||||
|
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
|
||||||
|
if (progress.type === "Done") sqliteDone?.resolve()
|
||||||
})
|
})
|
||||||
|
|
||||||
const cliHealthCheck = (() => {
|
if (needsMigration) {
|
||||||
if (serverConnection.variant == "cli") {
|
await sqliteDone?.promise
|
||||||
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?.()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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")
|
logger.log("loading task finished")
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@@ -211,32 +160,26 @@ async function initialize() {
|
|||||||
deepLinks: pendingDeepLinks,
|
deepLinks: pendingDeepLinks,
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadingWindow = await (async () => {
|
wireMenu()
|
||||||
if (needsMigration /** TOOD: 1 second timeout */) {
|
|
||||||
// showLoading = await Promise.race([init.then(() => false).catch(() => false), delay(1000).then(() => true)])
|
if (needsMigration) {
|
||||||
const loadingWindow = createLoadingWindow(globals)
|
const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
|
||||||
await delay(1000)
|
if (show) {
|
||||||
return loadingWindow
|
overlay = createLoadingWindow(globals)
|
||||||
} else {
|
await delay(1_000)
|
||||||
logger.log("showing main window without loading window")
|
|
||||||
mainWindow = createMainWindow(globals)
|
|
||||||
wireMenu()
|
|
||||||
}
|
}
|
||||||
})()
|
}
|
||||||
|
|
||||||
await loadingTask
|
await loadingTask
|
||||||
setInitStep({ phase: "done" })
|
setInitStep({ phase: "done" })
|
||||||
|
|
||||||
if (loadingWindow) {
|
if (overlay) {
|
||||||
await loadingComplete.promise
|
await loadingComplete.promise
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mainWindow) {
|
mainWindow = createMainWindow(globals)
|
||||||
mainWindow = createMainWindow(globals)
|
|
||||||
wireMenu()
|
|
||||||
}
|
|
||||||
|
|
||||||
loadingWindow?.close()
|
overlay?.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
function wireMenu() {
|
function wireMenu() {
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { dialog } from "electron"
|
import { serve, type CommandChild } from "./cli"
|
||||||
|
|
||||||
import { getConfig, serve, type CommandChild, type Config } from "./cli"
|
|
||||||
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
|
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
|
||||||
import { store } from "./store"
|
import { store } from "./store"
|
||||||
|
|
||||||
@@ -31,15 +29,6 @@ export function setWslConfig(config: WslConfig) {
|
|||||||
store.set(WSL_ENABLED_KEY, config.enabled)
|
store.set(WSL_ENABLED_KEY, config.enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSavedServerUrl(): Promise<string | null> {
|
|
||||||
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) {
|
export function spawnLocalServer(hostname: string, port: number, password: string) {
|
||||||
const { child, exit, events } = serve(hostname, port, password)
|
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<boolean> {
|
|
||||||
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 }
|
export type { CommandChild }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" }
|
|||||||
|
|
||||||
export type ServerReadyData = {
|
export type ServerReadyData = {
|
||||||
url: string
|
url: string
|
||||||
|
username: string | null
|
||||||
password: string | null
|
password: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ import {
|
|||||||
ServerConnection,
|
ServerConnection,
|
||||||
useCommand,
|
useCommand,
|
||||||
} from "@opencode-ai/app"
|
} from "@opencode-ai/app"
|
||||||
import { Splash } from "@opencode-ai/ui/logo"
|
|
||||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
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 { render } from "solid-js/web"
|
||||||
import { MemoryRouter } from "@solidjs/router"
|
import { MemoryRouter } from "@solidjs/router"
|
||||||
import pkg from "../../package.json"
|
import pkg from "../../package.json"
|
||||||
@@ -19,7 +18,6 @@ import { initI18n, t } from "./i18n"
|
|||||||
import { UPDATER_ENABLED } from "./updater"
|
import { UPDATER_ENABLED } from "./updater"
|
||||||
import { webviewZoom } from "./webview-zoom"
|
import { webviewZoom } from "./webview-zoom"
|
||||||
import "./styles.css"
|
import "./styles.css"
|
||||||
import type { ServerReadyData } from "../preload/types"
|
|
||||||
|
|
||||||
const root = document.getElementById("root")
|
const root = document.getElementById("root")
|
||||||
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||||
@@ -198,11 +196,13 @@ const createPlatform = (): Platform => {
|
|||||||
await window.api.setWslConfig({ enabled })
|
await window.api.setWslConfig({ enabled })
|
||||||
},
|
},
|
||||||
|
|
||||||
getDefaultServerUrl: async () => {
|
getDefaultServer: async () => {
|
||||||
return window.api.getDefaultServerUrl().catch(() => null)
|
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)
|
await window.api.setDefaultServerUrl(url)
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -240,6 +240,31 @@ listenForDeepLinks()
|
|||||||
render(() => {
|
render(() => {
|
||||||
const platform = createPlatform()
|
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) {
|
function handleClick(e: MouseEvent) {
|
||||||
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
|
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
|
||||||
if (link?.href) {
|
if (link?.href) {
|
||||||
@@ -248,6 +273,12 @@ render(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Inner() {
|
||||||
|
const cmd = useCommand()
|
||||||
|
menuTrigger = (id) => cmd.trigger(id)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
document.addEventListener("click", handleClick)
|
document.addEventListener("click", handleClick)
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
@@ -258,55 +289,20 @@ render(() => {
|
|||||||
return (
|
return (
|
||||||
<PlatformProvider value={platform}>
|
<PlatformProvider value={platform}>
|
||||||
<AppBaseProviders>
|
<AppBaseProviders>
|
||||||
<ServerGate>
|
<Show when={!defaultServer.loading && !sidecar.loading}>
|
||||||
{(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 (
|
return (
|
||||||
<AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} router={MemoryRouter}>
|
<AppInterface
|
||||||
|
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
|
||||||
|
servers={servers()}
|
||||||
|
router={MemoryRouter}
|
||||||
|
>
|
||||||
<Inner />
|
<Inner />
|
||||||
</AppInterface>
|
</AppInterface>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</ServerGate>
|
</Show>
|
||||||
</AppBaseProviders>
|
</AppBaseProviders>
|
||||||
</PlatformProvider>
|
</PlatformProvider>
|
||||||
)
|
)
|
||||||
}, root!)
|
}, root!)
|
||||||
|
|
||||||
// Gate component that waits for the server to be ready
|
|
||||||
function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.Element }) {
|
|
||||||
const [serverData] = createResource(() => window.api.awaitInitialization(() => undefined))
|
|
||||||
console.log({ serverData })
|
|
||||||
if (serverData.state === "errored") throw serverData.error
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show
|
|
||||||
when={serverData.state !== "pending" && serverData()}
|
|
||||||
fallback={
|
|
||||||
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
|
|
||||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(data) => props.children(data)}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { render } from "solid-js/web"
|
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
|
import { render } from "solid-js/web"
|
||||||
import "@opencode-ai/app/index.css"
|
import "@opencode-ai/app/index.css"
|
||||||
import { Font } from "@opencode-ai/ui/font"
|
import { Font } from "@opencode-ai/ui/font"
|
||||||
import { Splash } from "@opencode-ai/ui/logo"
|
import { Splash } from "@opencode-ai/ui/logo"
|
||||||
@@ -34,7 +34,10 @@ render(() => {
|
|||||||
|
|
||||||
const listener = window.api.onSqliteMigrationProgress((progress: SqliteMigrationProgress) => {
|
const listener = window.api.onSqliteMigrationProgress((progress: SqliteMigrationProgress) => {
|
||||||
if (progress.type === "InProgress") setPercent(Math.max(0, Math.min(100, progress.value)))
|
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(() => {
|
onCleanup(() => {
|
||||||
|
|||||||
@@ -12,12 +12,10 @@ mod window_customizer;
|
|||||||
mod windows;
|
mod windows;
|
||||||
|
|
||||||
use crate::cli::CommandChild;
|
use crate::cli::CommandChild;
|
||||||
use futures::{
|
use futures::{FutureExt, TryFutureExt};
|
||||||
FutureExt, TryFutureExt,
|
|
||||||
future::{self, Shared},
|
|
||||||
};
|
|
||||||
use std::{
|
use std::{
|
||||||
env,
|
env,
|
||||||
|
future::Future,
|
||||||
net::TcpListener,
|
net::TcpListener,
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
process::Command,
|
process::Command,
|
||||||
@@ -35,7 +33,6 @@ use tokio::{
|
|||||||
|
|
||||||
use crate::cli::{sqlite_migration::SqliteMigrationProgress, sync_cli};
|
use crate::cli::{sqlite_migration::SqliteMigrationProgress, sync_cli};
|
||||||
use crate::constants::*;
|
use crate::constants::*;
|
||||||
use crate::server::get_saved_server_url;
|
|
||||||
use crate::windows::{LoadingWindow, MainWindow};
|
use crate::windows::{LoadingWindow, MainWindow};
|
||||||
|
|
||||||
#[derive(Clone, serde::Serialize, specta::Type, Debug)]
|
#[derive(Clone, serde::Serialize, specta::Type, Debug)]
|
||||||
@@ -43,7 +40,6 @@ struct ServerReadyData {
|
|||||||
url: String,
|
url: String,
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
is_sidecar: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, serde::Serialize, specta::Type, Debug)]
|
#[derive(Clone, Copy, serde::Serialize, specta::Type, Debug)]
|
||||||
@@ -65,27 +61,12 @@ struct InitState {
|
|||||||
current: watch::Receiver<InitStep>,
|
current: watch::Receiver<InitStep>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct ServerState {
|
struct ServerState {
|
||||||
child: Arc<Mutex<Option<CommandChild>>>,
|
child: Arc<Mutex<Option<CommandChild>>>,
|
||||||
status: future::Shared<oneshot::Receiver<Result<ServerReadyData, String>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServerState {
|
/// Resolves with sidecar credentials as soon as the sidecar is spawned (before health check).
|
||||||
pub fn new(
|
struct SidecarReady(futures::future::Shared<oneshot::Receiver<ServerReadyData>>);
|
||||||
child: Option<CommandChild>,
|
|
||||||
status: Shared<oneshot::Receiver<Result<ServerReadyData, String>>>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
child: Arc::new(Mutex::new(child)),
|
|
||||||
status,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_child(&self, child: Option<CommandChild>) {
|
|
||||||
*self.child.lock().unwrap() = child;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
@@ -110,26 +91,21 @@ fn kill_sidecar(app: AppHandle) {
|
|||||||
tracing::info!("Killed server");
|
tracing::info!("Killed server");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_logs() -> String {
|
|
||||||
logging::tail()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
async fn await_initialization(
|
async fn await_initialization(
|
||||||
state: State<'_, ServerState>,
|
state: State<'_, SidecarReady>,
|
||||||
init_state: State<'_, InitState>,
|
init_state: State<'_, InitState>,
|
||||||
events: Channel<InitStep>,
|
events: Channel<InitStep>,
|
||||||
) -> Result<ServerReadyData, String> {
|
) -> Result<ServerReadyData, String> {
|
||||||
let mut rx = init_state.current.clone();
|
let mut rx = init_state.current.clone();
|
||||||
|
|
||||||
let events = async {
|
let stream = async {
|
||||||
let e = *rx.borrow();
|
let e = *rx.borrow();
|
||||||
let _ = events.send(e);
|
let _ = events.send(e);
|
||||||
|
|
||||||
while rx.changed().await.is_ok() {
|
while rx.changed().await.is_ok() {
|
||||||
let step = *rx.borrow_and_update();
|
let step = *rx.borrow_and_update();
|
||||||
|
|
||||||
let _ = events.send(step);
|
let _ = events.send(step);
|
||||||
|
|
||||||
if matches!(step, InitStep::Done) {
|
if matches!(step, InitStep::Done) {
|
||||||
@@ -138,10 +114,18 @@ async fn await_initialization(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
future::join(state.status.clone(), events)
|
// Wait for sidecar credentials (available immediately after spawn, before health check)
|
||||||
.await
|
let data = async {
|
||||||
.0
|
state
|
||||||
.map_err(|_| "Failed to get server status".to_string())?
|
.inner()
|
||||||
|
.0
|
||||||
|
.clone()
|
||||||
|
.await
|
||||||
|
.map_err(|_| "Failed to get sidecar data".to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
let (result, _) = futures::future::join(data, stream).await;
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -439,22 +423,35 @@ async fn initialize(app: AppHandle) {
|
|||||||
setup_app(&app, init_rx);
|
setup_app(&app, init_rx);
|
||||||
spawn_cli_sync_task(app.clone());
|
spawn_cli_sync_task(app.clone());
|
||||||
|
|
||||||
let (server_ready_tx, server_ready_rx) = oneshot::channel();
|
// Spawn sidecar immediately - credentials are known before health check
|
||||||
let server_ready_rx = server_ready_rx.shared();
|
let port = get_sidecar_port();
|
||||||
app.manage(ServerState::new(None, server_ready_rx.clone()));
|
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::<LoadingWindowComplete>(&app);
|
let loading_window_complete = event_once_fut::<LoadingWindowComplete>(&app);
|
||||||
|
|
||||||
tracing::info!("Main and loading windows created");
|
|
||||||
|
|
||||||
// SQLite migration handling:
|
// SQLite migration handling:
|
||||||
// We only do this if the sqlite db doesn't exist, and we're expecting the sidecar to create it
|
// 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
|
// A separate loading window is shown for long migrations.
|
||||||
// come from any invocation of the sidecar CLI. The progress is captured by a stdout stream interceptor.
|
let needs_migration = !sqlite_file_exists();
|
||||||
// Then in the loading task, we wait for sqlite migration to complete before
|
let sqlite_done = needs_migration.then(|| {
|
||||||
// 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(|| {
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
path = %opencode_db_path().expect("failed to get db path").display(),
|
path = %opencode_db_path().expect("failed to get db path").display(),
|
||||||
"Sqlite file not found, waiting for it to be generated"
|
"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 loading_task = tokio::spawn({
|
||||||
let app = app.clone();
|
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
tracing::info!("Setting up server connection");
|
if let Some(sqlite_done_rx) = sqlite_done {
|
||||||
let server_connection = setup_server_connection(app.clone()).await;
|
let _ = sqlite_done_rx.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::<ServerState>().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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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");
|
tracing::info!("Loading task finished");
|
||||||
}
|
}
|
||||||
@@ -561,7 +500,8 @@ async fn initialize(app: AppHandle) {
|
|||||||
.map_err(|_| ())
|
.map_err(|_| ())
|
||||||
.shared();
|
.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())
|
&& timeout(Duration::from_secs(1), loading_task.clone())
|
||||||
.await
|
.await
|
||||||
.is_err()
|
.is_err()
|
||||||
@@ -571,12 +511,12 @@ async fn initialize(app: AppHandle) {
|
|||||||
sleep(Duration::from_secs(1)).await;
|
sleep(Duration::from_secs(1)).await;
|
||||||
Some(loading_window)
|
Some(loading_window)
|
||||||
} else {
|
} else {
|
||||||
tracing::debug!("Showing main window without loading window");
|
|
||||||
MainWindow::create(&app).expect("Failed to create main window");
|
|
||||||
|
|
||||||
None
|
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;
|
let _ = loading_task.await;
|
||||||
|
|
||||||
tracing::info!("Loading done, completing initialisation");
|
tracing::info!("Loading done, completing initialisation");
|
||||||
@@ -584,12 +524,9 @@ async fn initialize(app: AppHandle) {
|
|||||||
|
|
||||||
if loading_window.is_some() {
|
if loading_window.is_some() {
|
||||||
loading_window_complete.await;
|
loading_window_complete.await;
|
||||||
|
|
||||||
tracing::info!("Loading window completed");
|
tracing::info!("Loading window completed");
|
||||||
}
|
}
|
||||||
|
|
||||||
MainWindow::create(&app).expect("Failed to create main window");
|
|
||||||
|
|
||||||
if let Some(loading_window) = loading_window {
|
if let Some(loading_window) = loading_window {
|
||||||
let _ = loading_window.close();
|
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<String>,
|
|
||||||
password: Option<String>,
|
|
||||||
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 {
|
fn get_sidecar_port() -> u32 {
|
||||||
option_env!("OPENCODE_PORT")
|
option_env!("OPENCODE_PORT")
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
|
|
||||||
use tauri_plugin_store::StoreExt;
|
use tauri_plugin_store::StoreExt;
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
@@ -85,22 +84,6 @@ pub fn set_wsl_config(app: AppHandle, config: WslConfig) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_saved_server_url(app: &tauri::AppHandle) -> Option<String> {
|
|
||||||
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(
|
pub fn spawn_local_server(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
hostname: String,
|
hostname: String,
|
||||||
@@ -145,19 +128,27 @@ pub fn spawn_local_server(
|
|||||||
|
|
||||||
pub struct HealthCheck(pub JoinHandle<Result<(), String>>);
|
pub struct HealthCheck(pub JoinHandle<Result<(), String>>);
|
||||||
|
|
||||||
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 {
|
let Ok(url) = reqwest::Url::parse(url) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(7));
|
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::<std::net::IpAddr>()
|
||||||
|
.is_ok_and(|ip| ip.is_loopback())
|
||||||
|
})
|
||||||
|
{
|
||||||
// Some environments set proxy variables (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY) without
|
// Some environments set proxy variables (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY) without
|
||||||
// excluding loopback. reqwest respects these by default, which can prevent the desktop
|
// excluding loopback. reqwest respects these by default, which can prevent the desktop
|
||||||
// app from reaching its own local sidecar server.
|
// app from reaching its own local sidecar server.
|
||||||
builder = builder.no_proxy();
|
builder = builder.no_proxy();
|
||||||
};
|
}
|
||||||
|
|
||||||
let Ok(client) = builder.build() else {
|
let Ok(client) = builder.build() else {
|
||||||
return false;
|
return false;
|
||||||
@@ -177,77 +168,3 @@ pub async fn check_health(url: &str, password: Option<&str>) -> bool {
|
|||||||
.map(|r| r.status().is_success())
|
.map(|r| r.status().is_success())
|
||||||
.unwrap_or(false)
|
.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::<std::net::IpAddr>()
|
|
||||||
.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<String> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ export type ServerReadyData = {
|
|||||||
url: string,
|
url: string,
|
||||||
username: string | null,
|
username: string | null,
|
||||||
password: string | null,
|
password: string | null,
|
||||||
is_sidecar: boolean,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" };
|
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" };
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
ServerConnection,
|
ServerConnection,
|
||||||
useCommand,
|
useCommand,
|
||||||
} from "@opencode-ai/app"
|
} from "@opencode-ai/app"
|
||||||
import { Splash } from "@opencode-ai/ui/logo"
|
|
||||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window"
|
import { getCurrentWindow } from "@tauri-apps/api/window"
|
||||||
import { readImage } from "@tauri-apps/plugin-clipboard-manager"
|
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 { open as shellOpen } from "@tauri-apps/plugin-shell"
|
||||||
import { Store } from "@tauri-apps/plugin-store"
|
import { Store } from "@tauri-apps/plugin-store"
|
||||||
import { check, type Update } from "@tauri-apps/plugin-updater"
|
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 { render } from "solid-js/web"
|
||||||
import pkg from "../package.json"
|
import pkg from "../package.json"
|
||||||
import { initI18n, t } from "./i18n"
|
import { initI18n, t } from "./i18n"
|
||||||
@@ -30,7 +29,7 @@ import { UPDATER_ENABLED } from "./updater"
|
|||||||
import { webviewZoom } from "./webview-zoom"
|
import { webviewZoom } from "./webview-zoom"
|
||||||
import "./styles.css"
|
import "./styles.css"
|
||||||
import { Channel } from "@tauri-apps/api/core"
|
import { Channel } from "@tauri-apps/api/core"
|
||||||
import { commands, ServerReadyData, type InitStep } from "./bindings"
|
import { commands, type InitStep } from "./bindings"
|
||||||
import { createMenu } from "./menu"
|
import { createMenu } from "./menu"
|
||||||
|
|
||||||
const root = document.getElementById("root")
|
const root = document.getElementById("root")
|
||||||
@@ -348,12 +347,13 @@ const createPlatform = (): Platform => {
|
|||||||
await commands.setWslConfig({ enabled })
|
await commands.setWslConfig({ enabled })
|
||||||
},
|
},
|
||||||
|
|
||||||
getDefaultServerUrl: async () => {
|
getDefaultServer: async () => {
|
||||||
const result = await commands.getDefaultServerUrl().catch(() => null)
|
const url = await commands.getDefaultServerUrl().catch(() => null)
|
||||||
return result
|
if (!url) return null
|
||||||
|
return ServerConnection.Key.make(url)
|
||||||
},
|
},
|
||||||
|
|
||||||
setDefaultServerUrl: async (url: string | null) => {
|
setDefaultServer: async (url: string | null) => {
|
||||||
await commands.setDefaultServerUrl(url)
|
await commands.setDefaultServerUrl(url)
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -412,12 +412,33 @@ void listenForDeepLinks()
|
|||||||
render(() => {
|
render(() => {
|
||||||
const platform = createPlatform()
|
const platform = createPlatform()
|
||||||
|
|
||||||
|
// Fetch sidecar credentials from Rust (available immediately, before health check)
|
||||||
|
const [sidecar] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
|
||||||
|
|
||||||
const [defaultServer] = createResource(() =>
|
const [defaultServer] = createResource(() =>
|
||||||
platform.getDefaultServerUrl?.().then((url) => {
|
platform.getDefaultServer?.().then((url) => {
|
||||||
if (url) return ServerConnection.key({ type: "http", http: { 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) {
|
function handleClick(e: MouseEvent) {
|
||||||
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
|
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
|
||||||
if (link?.href) {
|
if (link?.href) {
|
||||||
@@ -426,6 +447,12 @@ render(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Inner() {
|
||||||
|
const cmd = useCommand()
|
||||||
|
menuTrigger = (id) => cmd.trigger(id)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
document.addEventListener("click", handleClick)
|
document.addEventListener("click", handleClick)
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
@@ -436,60 +463,19 @@ render(() => {
|
|||||||
return (
|
return (
|
||||||
<PlatformProvider value={platform}>
|
<PlatformProvider value={platform}>
|
||||||
<AppBaseProviders>
|
<AppBaseProviders>
|
||||||
<ServerGate>
|
<Show when={!defaultServer.loading && !sidecar.loading}>
|
||||||
{(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 (
|
return (
|
||||||
<Show when={!defaultServer.loading}>
|
<AppInterface
|
||||||
<AppInterface defaultServer={defaultServer.latest ?? ServerConnection.key(server)} servers={[server]}>
|
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
|
||||||
<Inner />
|
servers={servers()}
|
||||||
</AppInterface>
|
>
|
||||||
</Show>
|
<Inner />
|
||||||
|
</AppInterface>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</ServerGate>
|
</Show>
|
||||||
</AppBaseProviders>
|
</AppBaseProviders>
|
||||||
</PlatformProvider>
|
</PlatformProvider>
|
||||||
)
|
)
|
||||||
}, root!)
|
}, 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<InitStep>() as any))
|
|
||||||
if (serverData.state === "errored") throw serverData.error
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show
|
|
||||||
when={serverData.state !== "pending" && serverData()}
|
|
||||||
fallback={
|
|
||||||
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
|
|
||||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
|
||||||
<div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(data) => props.children(data())}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user