diff --git a/apps/app/src/react-app/shell/debug-logger.ts b/apps/app/src/react-app/shell/debug-logger.ts index c1ddff6e..b375ff2f 100644 --- a/apps/app/src/react-app/shell/debug-logger.ts +++ b/apps/app/src/react-app/shell/debug-logger.ts @@ -31,7 +31,7 @@ type DevLogEntry = { let started = false; let flushTimer: ReturnType | null = null; let queue: DevLogEntry[] = []; -let serverUrlRef: () => string = () => readFallbackServerUrl(); +let serverUrlRef: () => string | Promise = () => readFallbackServerUrl(); const pendingFetches = new Map(); let nextFetchId = 1; let lastHeartbeat = Date.now(); @@ -137,7 +137,7 @@ function scheduleFlush() { async function flushQueue() { if (queue.length === 0) return; - const base = serverUrlRef(); + const base = await serverUrlRef(); if (!base) return; // Skip the POST entirely when we know the sink is disabled, otherwise @@ -196,7 +196,7 @@ function isEnabled(): boolean { return true; } -export function startDebugLogger(opts?: { serverUrl?: () => string }) { +export function startDebugLogger(opts?: { serverUrl?: () => string | Promise }) { if (started) return; if (!isEnabled()) return; started = true; diff --git a/apps/app/src/react-app/shell/desktop-runtime-boot.ts b/apps/app/src/react-app/shell/desktop-runtime-boot.ts index 46a5e303..38eaa0e0 100644 --- a/apps/app/src/react-app/shell/desktop-runtime-boot.ts +++ b/apps/app/src/react-app/shell/desktop-runtime-boot.ts @@ -27,8 +27,9 @@ let BOOT_STARTED = false; * 2) if a local workspace is selected, restart the embedded OpenWork server * 3) start the OpenCode engine pointed at the workspace * 4) activate the workspace in the orchestrator - * 5) persist the resulting base URL + token into local OpenWork settings so the - * React routes (session-route / settings-route) see a live `readOpenworkServerSettings()` + * 5) notify React routes that fresh desktop runtime info is available. Electron + * routes read live runtime info directly instead of persisting ephemeral + * localhost ports/tokens into OpenWork settings. * * Safe to call multiple times — gated by a `didBoot` ref so it runs once per mount. */ @@ -112,15 +113,6 @@ export function useDesktopRuntimeBoot() { } const serverInfo = boot.openworkServer; if (serverInfo?.baseUrl) { - writeOpenworkServerSettings({ - urlOverride: serverInfo.baseUrl, - token: - serverInfo.ownerToken?.trim() || - serverInfo.clientToken?.trim() || - undefined, - portOverride: serverInfo.port ?? undefined, - remoteAccessEnabled: serverInfo.remoteAccessEnabled === true, - }); try { window.dispatchEvent(new CustomEvent("openwork-server-settings-changed")); } catch { diff --git a/apps/app/src/react-app/shell/openwork-connection.ts b/apps/app/src/react-app/shell/openwork-connection.ts new file mode 100644 index 00000000..2be5eb8d --- /dev/null +++ b/apps/app/src/react-app/shell/openwork-connection.ts @@ -0,0 +1,56 @@ +import { + normalizeOpenworkServerUrl, + readOpenworkServerSettings, +} from "../../app/lib/openwork-server"; +import { openworkServerInfo, type OpenworkServerInfo } from "../../app/lib/desktop"; +import { isDesktopRuntime } from "../../app/utils"; + +export type OpenworkConnectionSource = "desktop-runtime" | "stored-settings" | "empty"; + +export type ResolvedOpenworkConnection = { + normalizedBaseUrl: string; + resolvedToken: string; + hostInfo: OpenworkServerInfo | null; + source: OpenworkConnectionSource; +}; + +/** + * Resolve the OpenWork server connection for routes that consume the server API. + * + * Local desktop-hosted servers expose ephemeral loopback ports and freshly + * minted tokens on every boot, so live runtime info is the source of truth + * there. Stored settings remain the fallback for remote/manual server + * connections and for desktop cases where the runtime bridge is unavailable. + */ +export async function resolveOpenworkConnection(): Promise { + if (isDesktopRuntime()) { + try { + const info = await openworkServerInfo(); + const normalizedBaseUrl = + normalizeOpenworkServerUrl(info.connectUrl ?? info.baseUrl ?? info.lanUrl ?? info.mdnsUrl ?? "") ?? + ""; + const resolvedToken = info.ownerToken?.trim() || info.clientToken?.trim() || ""; + if (normalizedBaseUrl || resolvedToken) { + return { + normalizedBaseUrl, + resolvedToken, + hostInfo: info, + source: "desktop-runtime", + }; + } + } catch { + // Fall through to stored settings for remote/manual connections. + } + } + + const settings = readOpenworkServerSettings(); + const normalizedBaseUrl = normalizeOpenworkServerUrl(settings.urlOverride ?? "") ?? ""; + const resolvedToken = settings.token?.trim() ?? ""; + + return { + normalizedBaseUrl, + resolvedToken, + hostInfo: null, + source: normalizedBaseUrl || resolvedToken ? "stored-settings" : "empty", + }; +} diff --git a/apps/app/src/react-app/shell/providers.tsx b/apps/app/src/react-app/shell/providers.tsx index 4bbe26a1..aeee8833 100644 --- a/apps/app/src/react-app/shell/providers.tsx +++ b/apps/app/src/react-app/shell/providers.tsx @@ -2,7 +2,7 @@ import { useEffect, type ReactNode } from "react"; import { isWebDeployment } from "../../app/lib/openwork-deployment"; -import { hydrateOpenworkServerSettingsFromEnv, readOpenworkServerSettings } from "../../app/lib/openwork-server"; +import { hydrateOpenworkServerSettingsFromEnv } from "../../app/lib/openwork-server"; import { isDesktopRuntime } from "../../app/utils"; import { DenAuthProvider } from "../domains/cloud/den-auth-provider"; import { DesktopConfigProvider } from "../domains/cloud/desktop-config-provider"; @@ -13,6 +13,7 @@ import { BootStateProvider } from "./boot-state"; import { DesktopRuntimeBoot } from "./desktop-runtime-boot"; import { startDebugLogger, stopDebugLogger } from "./debug-logger"; import { MigrationPrompt } from "./migration-prompt"; +import { resolveOpenworkConnection } from "./openwork-connection"; import { ReloadCoordinatorProvider } from "./reload-coordinator"; function resolveDefaultServerUrl(): string { @@ -49,7 +50,7 @@ export function AppProviders({ children }: AppProvidersProps) { // URL on every flush so reconnects after port changes still work. In prod // builds `startDebugLogger` is a no-op. startDebugLogger({ - serverUrl: () => readOpenworkServerSettings().urlOverride?.trim() ?? "", + serverUrl: async () => (await resolveOpenworkConnection()).normalizedBaseUrl, }); return () => { stopDebugLogger(); diff --git a/apps/app/src/react-app/shell/session-route.tsx b/apps/app/src/react-app/shell/session-route.tsx index b660479c..ecfdd5dc 100644 --- a/apps/app/src/react-app/shell/session-route.tsx +++ b/apps/app/src/react-app/shell/session-route.tsx @@ -14,7 +14,6 @@ import { listCommands, shellInSession } from "../../app/lib/opencode-session"; import { buildOpenworkWorkspaceBaseUrl, createOpenworkServerClient, - normalizeOpenworkServerUrl, readOpenworkServerSettings, writeOpenworkServerSettings, type OpenworkServerClient, @@ -23,7 +22,6 @@ import { import { engineInfo, revealDesktopItemInDir, - openworkServerInfo, openworkServerRestart, pickDirectory, resolveWorkspaceListSelectedId, @@ -85,6 +83,7 @@ import { useReactRenderWatchdog } from "./react-render-watchdog"; import { getModelBehaviorSummary } from "../../app/lib/model-behavior"; import { filterProviderList, mapConfigProvidersToList } from "../../app/utils/providers"; import { ensureDesktopLocalOpenworkConnection } from "./desktop-local-openwork"; +import { resolveOpenworkConnection } from "./openwork-connection"; import { useReloadCoordinator } from "./reload-coordinator"; type RouteWorkspace = OpenworkWorkspaceInfo & { @@ -108,32 +107,6 @@ function folderNameFromPath(path: string) { return parts[parts.length - 1] ?? "workspace"; } -async function resolveRouteOpenworkConnection() { - const settings = readOpenworkServerSettings(); - let normalizedBaseUrl = normalizeOpenworkServerUrl(settings.urlOverride ?? "") ?? ""; - let resolvedToken = settings.token?.trim() ?? ""; - let hostInfo: OpenworkServerInfo | null = null; - - if (isDesktopRuntime()) { - try { - const info = await openworkServerInfo(); - hostInfo = info; - // Desktop-hosted servers use a fresh loopback port and freshly minted - // owner token per boot. Prefer the live runtime info over localStorage; - // the stored URL/token can point at the previous process and produce - // ERR_CONNECTION_REFUSED or 401 before the boot event refreshes settings. - normalizedBaseUrl = - normalizeOpenworkServerUrl(info.connectUrl ?? info.baseUrl ?? info.lanUrl ?? info.mdnsUrl ?? "") ?? - normalizedBaseUrl; - resolvedToken = info.ownerToken?.trim() || info.clientToken?.trim() || resolvedToken; - } catch { - // ignore and fall back to stored settings only - } - } - - return { normalizedBaseUrl, resolvedToken, hostInfo }; -} - function isTransientStartupError(message: string | null | undefined) { const value = (message ?? "").toLowerCase(); return ( @@ -483,7 +456,7 @@ export function SessionRoute() { } } - const { normalizedBaseUrl, resolvedToken, hostInfo } = await resolveRouteOpenworkConnection(); + const { normalizedBaseUrl, resolvedToken, hostInfo } = await resolveOpenworkConnection(); setOpenworkServerHostInfoState(hostInfo); if (!normalizedBaseUrl || !resolvedToken) { setClient(null); diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx index 115fa5b9..9026f416 100644 --- a/apps/app/src/react-app/shell/settings-route.tsx +++ b/apps/app/src/react-app/shell/settings-route.tsx @@ -7,7 +7,6 @@ import { createClient } from "../../app/lib/opencode"; import { buildOpenworkWorkspaceBaseUrl, createOpenworkServerClient, - normalizeOpenworkServerUrl, readOpenworkServerSettings, type OpenworkServerCapabilities, type OpenworkServerClient, @@ -46,7 +45,6 @@ import { useWorkspaceShellLayout, } from "./workspace-shell-layout"; import { - openworkServerInfo, pickDirectory, resolveWorkspaceListSelectedId, workspaceBootstrap, @@ -65,6 +63,7 @@ import { ModelPickerModal } from "../domains/session/modals/model-picker-modal"; import type { ModelOption, ModelRef } from "../../app/types"; import { recordInspectorEvent } from "./app-inspector"; import { ensureDesktopLocalOpenworkConnection } from "./desktop-local-openwork"; +import { resolveOpenworkConnection } from "./openwork-connection"; import { abortSessionSafe } from "../../app/lib/opencode-session"; import { useReloadCoordinator } from "./reload-coordinator"; @@ -173,27 +172,13 @@ function folderNameFromPath(path: string) { return parts[parts.length - 1] ?? "workspace"; } -async function resolveRouteOpenworkConnection() { - const settings = readOpenworkServerSettings(); - let normalizedBaseUrl = normalizeOpenworkServerUrl(settings.urlOverride ?? "") ?? ""; - let resolvedToken = settings.token?.trim() ?? ""; - - if (isDesktopRuntime()) { - try { - const info = await openworkServerInfo(); - // Desktop-hosted servers use a fresh loopback port and freshly minted - // owner token per boot. Prefer the live runtime info over localStorage; - // stored settings may belong to a previous server process. - normalizedBaseUrl = - normalizeOpenworkServerUrl(info.connectUrl ?? info.baseUrl ?? info.lanUrl ?? info.mdnsUrl ?? "") ?? - normalizedBaseUrl; - resolvedToken = info.ownerToken?.trim() || info.clientToken?.trim() || resolvedToken; - } catch { - // ignore and fall back to stored settings only - } +function isLoopbackServerUrl(raw: string) { + try { + const parsed = new URL(raw); + return parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" || parsed.hostname === "::1"; + } catch { + return false; } - - return { normalizedBaseUrl, resolvedToken }; } type PersistedThemeMode = "light" | "dark" | "system"; @@ -482,11 +467,13 @@ export function SettingsRoute() { () => createOpenworkServerStore({ startupPreference: () => { - // In Tauri desktop mode, prefer the embedded host server (hostInfo.baseUrl) - // unless the user has explicitly pinned a remote urlOverride. + // In desktop mode, loopback URLs are ephemeral local runtime details. + // Only non-loopback stored URLs indicate an explicit remote/manual + // server connection preference. if (!isDesktopRuntime()) return "server"; const stored = readOpenworkServerSettings(); - return stored.urlOverride?.trim() ? "server" : "local"; + const urlOverride = stored.urlOverride?.trim() ?? ""; + return urlOverride && !isLoopbackServerUrl(urlOverride) ? "server" : "local"; }, documentVisible: () => typeof document === "undefined" || document.visibilityState === "visible", developerMode: () => routeStateRef.current.developerMode, @@ -703,7 +690,7 @@ export function SettingsRoute() { desktopWorkspaces = workspacesRef.current; } } - const { normalizedBaseUrl, resolvedToken } = await resolveRouteOpenworkConnection(); + const { normalizedBaseUrl, resolvedToken } = await resolveOpenworkConnection(); if (!normalizedBaseUrl || !resolvedToken) { setOpenworkClient(null);