From bb73147efd69b6d0d691541728fa6ee423009ea6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 29 Mar 2026 22:29:16 +0700 Subject: [PATCH] feat(app): desktop startup sequence with error recovery (#153) Replace the silent daemon bootstrap failure path with a multi-phase startup screen that shows progress and surfaces errors. Uses Expo Router Stack.Protected to gate app screens behind bootstrap completion, keeping the Stack mounted at all times to avoid layout remounts. - bootstrapDesktop() returns structured result instead of swallowing errors - addConnectionFromListenAndWaitForOnline() waits for real connection, not just probe - Startup screen shows stacked progress steps with checkmark transitions - Error state shows daemon logs, copy button, GitHub issue link, docs link, retry - External URLs open in system browser via openExternalUrl --- packages/app/src/app/_layout.tsx | 225 +++++++------ packages/app/src/app/index.tsx | 55 +++- packages/app/src/runtime/host-runtime.ts | 195 +++++++++-- .../app/src/screens/startup-splash-screen.tsx | 305 +++++++++++++++++- 4 files changed, 633 insertions(+), 147 deletions(-) diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 56ec7fb3c..24ada6f27 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -15,7 +15,7 @@ import { PortalProvider } from "@gorhom/portal"; import { VoiceProvider } from "@/contexts/voice-context"; import { useAppSettings } from "@/hooks/use-settings"; import { useFaviconStatus } from "@/hooks/use-favicon-status"; -import { View, ActivityIndicator, Text } from "react-native"; +import { View, Text } from "react-native"; import { UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { darkTheme } from "@/styles/theme"; import { QueryClientProvider } from "@tanstack/react-query"; @@ -26,13 +26,13 @@ import { useHostRuntimeClient, } from "@/runtime/host-runtime"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; -import { StartupSplashScreen } from "@/screens/startup-splash-screen"; import { loadSettingsFromStorage } from "@/hooks/use-settings"; import { useColorScheme } from "@/hooks/use-color-scheme"; import { SessionProvider } from "@/contexts/session-context"; import type { HostProfile } from "@/types/host-connection"; import { createContext, + useCallback, useContext, useState, useEffect, @@ -81,7 +81,18 @@ import { import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store"; polyfillCrypto(); -const HostRuntimeBootstrapContext = createContext(false); + +export type HostRuntimeBootstrapState = { + phase: "starting-daemon" | "connecting" | "online" | "error"; + error: string | null; + retry: () => void; +}; + +const HostRuntimeBootstrapContext = createContext({ + phase: "starting-daemon", + error: null, + retry: () => {}, +}); function PushNotificationRouter() { const router = useRouter(); @@ -209,49 +220,99 @@ function HostSessionManager() { } function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { - const [ready, setReady] = useState(false); + const [phase, setPhase] = useState("starting-daemon"); + const [error, setError] = useState(null); + const [retryToken, setRetryToken] = useState(0); + const retry = useCallback(() => { + setPhase("starting-daemon"); + setError(null); + setRetryToken((current) => current + 1); + }, []); useEffect(() => { let cancelled = false; + const shouldManageDesktop = shouldUseDesktopDaemon(); const store = getHostRuntimeStore(); const init = async () => { const settings = await loadSettingsFromStorage(); - const isDesktopManaged = shouldUseDesktopDaemon() && settings.manageBuiltInDaemon; + const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon; await store.loadFromStorage(); if (isDesktopManaged) { - await store.bootstrap({ manageBuiltInDaemon: true }); + setPhase("starting-daemon"); + setError(null); + const bootstrapResult = await store.bootstrapDesktop(); + if (!bootstrapResult.ok) { + if (!cancelled) { + setPhase("error"); + setError(bootstrapResult.error); + } + return; + } + + if (cancelled) { + return; + } + + setPhase("connecting"); + await store.addConnectionFromListenAndWaitForOnline({ + listenAddress: bootstrapResult.listenAddress, + serverId: bootstrapResult.serverId, + hostname: bootstrapResult.hostname, + }); + if (!cancelled) { + setPhase("online"); + setError(null); + } } else { void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon }); + if (!cancelled) { + setPhase("online"); + setError(null); + } } }; - void init() - .then(() => { - if (!cancelled) { - setReady(true); - } - }) - .catch((error) => { - console.error("[HostRuntime] Failed to initialize store", error); - if (!cancelled) { - setReady(true); - } - }); + void init().catch((bootstrapError) => { + console.error("[HostRuntime] Failed to initialize store", bootstrapError); + if (cancelled) { + return; + } + if (shouldManageDesktop) { + setPhase("error"); + setError(bootstrapError instanceof Error ? bootstrapError.message : String(bootstrapError)); + return; + } + setPhase("online"); + setError(null); + }); return () => { cancelled = true; }; - }, []); + }, [retryToken]); + + const state = useMemo( + () => ({ + phase, + error, + retry, + }), + [error, phase, retry], + ); return ( - + {children} ); } -function useStoreReady(): boolean { +export function useStoreReady(): boolean { + return useContext(HostRuntimeBootstrapContext).phase === "online"; +} + +export function useHostRuntimeBootstrapState(): HostRuntimeBootstrapState { return useContext(HostRuntimeBootstrapContext); } @@ -412,38 +473,30 @@ function MobileGestureWrapper({ function ProvidersWrapper({ children }: { children: ReactNode }) { const { settings, isLoading: settingsLoading } = useAppSettings(); - const storeReady = useStoreReady(); const { upsertConnectionFromOfferUrl } = useHostMutations(); const systemColorScheme = useColorScheme(); - const isLoading = settingsLoading || !storeReady; const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme; // Apply theme setting on mount and when it changes useEffect(() => { - if (isLoading) return; + if (settingsLoading) return; if (settings.theme === "auto") { UnistylesRuntime.setAdaptiveThemes(true); } else { UnistylesRuntime.setAdaptiveThemes(false); UnistylesRuntime.setTheme(settings.theme); } - }, [isLoading, settings.theme]); + }, [settingsLoading, settings.theme]); useEffect(() => { - if (isLoading || Platform.OS !== "web") { + if (settingsLoading || Platform.OS !== "web") { return; } void setDesktopTitleBarTheme(resolvedTheme).catch((error) => { console.warn("[DesktopWindow] Failed to update title bar theme", error); }); - }, [isLoading, resolvedTheme]); - - if (isLoading) { - const isDesktopManaged = - !settingsLoading && shouldUseDesktopDaemon() && settings.manageBuiltInDaemon; - return isDesktopManaged ? : ; - } + }, [settingsLoading, resolvedTheme]); return ( @@ -546,6 +599,38 @@ function FaviconStatusSync() { return null; } +function RootStack() { + const storeReady = useStoreReady(); + + return ( + + + + + + + + + + + + + + + ); +} + function NavigationActiveWorkspaceObserver() { const navigationRef = useNavigationContainerRef(); @@ -566,57 +651,6 @@ function NavigationActiveWorkspaceObserver() { return null; } -function LoadingView({ message }: { message?: string } = {}) { - return ( - - - {message ? ( - - {message} - - ) : null} - - ); -} - -function MissingDaemonView() { - return ( - - - - No host configured. Open Settings to add a server URL. - - - ); -} - export default function RootLayout() { return ( @@ -633,28 +667,7 @@ export default function RootLayout() { - - - - - - - - - - - + diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index bc2426cf1..c65cc841d 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -1,18 +1,65 @@ -import { useEffect } from "react"; +import { useEffect, useSyncExternalStore } from "react"; import { usePathname, useRouter } from "expo-router"; +import { StartupSplashScreen } from "@/screens/startup-splash-screen"; +import { + useHostRuntimeBootstrapState, + useStoreReady, +} from "@/app/_layout"; +import { + getHostRuntimeStore, + isHostRuntimeConnected, + useHosts, +} from "@/runtime/host-runtime"; +import { buildHostRootRoute } from "@/utils/host-routes"; const WELCOME_ROUTE = "/welcome"; +function useAnyOnlineHostServerId(serverIds: string[]): string | null { + const runtime = getHostRuntimeStore(); + + return useSyncExternalStore( + (onStoreChange) => runtime.subscribeAll(onStoreChange), + () => { + let firstOnlineServerId: string | null = null; + let firstOnlineAt: string | null = null; + for (const serverId of serverIds) { + const snapshot = runtime.getSnapshot(serverId); + const lastOnlineAt = snapshot?.lastOnlineAt ?? null; + if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) { + continue; + } + if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) { + firstOnlineAt = lastOnlineAt; + firstOnlineServerId = serverId; + } + } + return firstOnlineServerId; + }, + () => null, + ); +} + export default function Index() { const router = useRouter(); const pathname = usePathname(); + const bootstrapState = useHostRuntimeBootstrapState(); + const storeReady = useStoreReady(); + const hosts = useHosts(); + const anyOnlineServerId = useAnyOnlineHostServerId(hosts.map((host) => host.serverId)); useEffect(() => { + if (!storeReady) { + return; + } if (pathname !== "/" && pathname !== "") { return; } - router.replace(WELCOME_ROUTE as any); - }, [pathname, router]); - return null; + const targetRoute = anyOnlineServerId + ? buildHostRootRoute(anyOnlineServerId) + : WELCOME_ROUTE; + router.replace(targetRoute as any); + }, [anyOnlineServerId, pathname, router, storeReady]); + + return ; } diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 237a246e1..5c81b1c17 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -15,7 +15,10 @@ import { } from "@/types/host-connection"; import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints"; import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer"; -import { shouldUseDesktopDaemon, startDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; +import { + shouldUseDesktopDaemon, + startDesktopDaemon, +} from "@/desktop/daemon/desktop-daemon"; import { connectToDaemon } from "@/utils/test-daemon-connection"; import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints"; import { getOrCreateClientId } from "@/utils/client-id"; @@ -33,6 +36,10 @@ import { useSessionStore, type Agent } from "@/stores/session-store"; export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error"; +export type HostRuntimeBootstrapResult = + | { ok: true; listenAddress: string; serverId: string; hostname: string | null } + | { ok: false; error: string }; + export type ActiveConnection = | { type: "directTcp"; endpoint: string; display: string } | { type: "directSocket"; endpoint: string; display: "socket" } @@ -1073,6 +1080,7 @@ const DEFAULT_LOCALHOST_ENDPOINT = process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767"; const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = "@paseo:default-localhost-bootstrap-v1"; const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500; +const CONNECTION_ONLINE_TIMEOUT_MS = 15_000; const E2E_STORAGE_KEY = "@paseo:e2e"; export class HostRuntimeStore { @@ -1161,35 +1169,40 @@ export class HostRuntimeStore { } } - private async bootstrapDesktop(): Promise { - let lastError: unknown = null; - - for (let attempt = 1; attempt <= 3; attempt += 1) { - try { - const daemon = await startDesktopDaemon(); - const connection = connectionFromListen(daemon.listen); - if (!connection || !daemon.serverId) { - return; - } - await this.upsertHostConnection({ - serverId: daemon.serverId, - label: daemon.hostname ?? undefined, - connection, - }); - return; - } catch (error) { - lastError = error; - console.warn(`[HostRuntime] Failed to bootstrap desktop daemon (attempt ${attempt}/3)`, error); - if (attempt < 3) { - await new Promise((resolve) => { - setTimeout(resolve, attempt * 500); - }); - } + async bootstrapDesktop(): Promise { + try { + const daemon = await startDesktopDaemon(); + const listenAddress = daemon.listen.trim(); + const serverId = daemon.serverId.trim(); + if (!listenAddress) { + return { + ok: false, + error: "Desktop daemon did not return a listen address.", + }; } - } - - if (lastError) { - console.warn("[HostRuntime] Desktop daemon bootstrap exhausted retries", lastError); + if (!serverId) { + return { + ok: false, + error: "Desktop daemon did not return a server id.", + }; + } + if (!connectionFromListen(listenAddress)) { + return { + ok: false, + error: `Desktop daemon returned an unsupported listen address: ${listenAddress}`, + }; + } + return { + ok: true, + listenAddress, + serverId, + hostname: daemon.hostname, + }; + } catch (error) { + return { + ok: false, + error: toErrorMessage(error), + }; } } @@ -1295,6 +1308,36 @@ export class HostRuntimeStore { return this.upsertConnectionFromOffer(offer); } + async addConnectionFromListenAndWaitForOnline(input: { + listenAddress: string; + serverId: string; + hostname: string | null; + timeoutMs?: number; + }): Promise { + const normalizedListenAddress = input.listenAddress.trim(); + const serverId = input.serverId.trim(); + const connection = connectionFromListen(normalizedListenAddress); + if (!connection) { + throw new Error(`Unsupported listen address: ${input.listenAddress}`); + } + if (!serverId) { + throw new Error("Desktop daemon did not return a server id."); + } + const profile = await this.upsertHostConnection({ + serverId, + label: input.hostname ?? undefined, + connection, + }); + + await this.waitForConnectionOnline({ + serverId, + connectionId: connection.id, + timeoutMs: input.timeoutMs, + }); + + return profile; + } + async renameHost(serverId: string, label: string): Promise { const next = this.hosts.map((h) => h.serverId === serverId ? { ...h, label, updatedAt: new Date().toISOString() } : h, @@ -1456,6 +1499,100 @@ export class HostRuntimeStore { } } + private waitForConnectionOnline(input: { + serverId: string; + connectionId: string; + timeoutMs?: number; + }): Promise { + const { serverId, connectionId } = input; + const timeoutMs = input.timeoutMs ?? CONNECTION_ONLINE_TIMEOUT_MS; + + return new Promise((resolve, reject) => { + let settled = false; + let timeoutHandle: ReturnType | null = null; + + const cleanup = (unsubscribe: (() => void) | null): void => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = null; + } + unsubscribe?.(); + }; + + const settle = ( + unsubscribe: (() => void) | null, + outcome: { ok: true } | { ok: false; error: Error }, + ): void => { + if (settled) { + return; + } + settled = true; + cleanup(unsubscribe); + if (outcome.ok) { + resolve(); + } else { + reject(outcome.error); + } + }; + + const readSnapshot = (): { ok: true } | { ok: false; error: Error } | null => { + const snapshot = this.getSnapshot(serverId); + if (!snapshot) { + return { + ok: false, + error: new Error(`Unknown host runtime for serverId ${serverId}`), + }; + } + + if ( + snapshot.activeConnectionId === connectionId && + snapshot.connectionStatus === "online" + ) { + return { ok: true }; + } + + if ( + snapshot.activeConnectionId === connectionId && + snapshot.connectionStatus === "error" + ) { + return { + ok: false, + error: new Error(snapshot.lastError ?? "Connection failed before coming online."), + }; + } + + return null; + }; + + const unsubscribe = this.subscribe(serverId, () => { + const outcome = readSnapshot(); + if (outcome) { + settle(unsubscribe, outcome); + } + }); + + timeoutHandle = setTimeout(() => { + settle(unsubscribe, { + ok: false, + error: new Error(`Timed out waiting for connection ${connectionId} to come online.`), + }); + }, timeoutMs); + + const initialOutcome = readSnapshot(); + if (initialOutcome) { + settle(unsubscribe, initialOutcome); + return; + } + + void this.runProbeCycleNow(serverId).catch((error) => { + settle(unsubscribe, { + ok: false, + error: error instanceof Error ? error : new Error(String(error)), + }); + }); + }); + } + private maybeAutoBootstrapAgentDirectory(serverId: string): void { const controller = this.controllers.get(serverId); if (!controller) { diff --git a/packages/app/src/screens/startup-splash-screen.tsx b/packages/app/src/screens/startup-splash-screen.tsx index 507630af7..321b5b6eb 100644 --- a/packages/app/src/screens/startup-splash-screen.tsx +++ b/packages/app/src/screens/startup-splash-screen.tsx @@ -1,29 +1,318 @@ -import { Text, View } from "react-native"; -import { StyleSheet } from "react-native-unistyles"; +import { useEffect, useMemo, useState } from "react"; +import { ActivityIndicator, Platform, ScrollView, Text, View } from "react-native"; +import * as Clipboard from "expo-clipboard"; +import { openExternalUrl } from "@/utils/open-external-url"; +import { BookOpen, Check, Copy, RotateCw, TriangleAlert } from "lucide-react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { PaseoLogo } from "@/components/icons/paseo-logo"; +import { Button } from "@/components/ui/button"; +import { Fonts } from "@/constants/theme"; +import { + getDesktopDaemonLogs, + type DesktopDaemonLogs, +} from "@/desktop/daemon/desktop-daemon"; import { useDesktopDragHandlers } from "@/utils/desktop-window"; +type StartupSplashScreenProps = { + bootstrapState?: { + phase: "starting-daemon" | "connecting" | "online" | "error"; + error: string | null; + retry: () => void; + }; +}; + +const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new"; +const DOCS_URL = "https://paseo.sh/docs"; + const styles = StyleSheet.create((theme) => ({ container: { flex: 1, - justifyContent: "center", alignItems: "center", + justifyContent: "center", backgroundColor: theme.colors.surface0, + paddingHorizontal: theme.spacing[8], + paddingVertical: theme.spacing[8], }, - status: { + containerError: { + justifyContent: "flex-start", + paddingTop: theme.spacing[16], + }, + centeredContent: { + alignItems: "center", + justifyContent: "center", + maxWidth: 520, + width: "100%", + }, + errorContent: { + alignItems: "stretch", + maxWidth: 720, + width: "100%", + gap: theme.spacing[6], + }, + errorHeader: { + alignItems: "flex-start", + }, + title: { + marginTop: theme.spacing[8], + color: theme.colors.foreground, + fontSize: theme.fontSize["3xl"], + fontWeight: theme.fontWeight.semibold, + textAlign: "center", + }, + titleError: { + textAlign: "left", + }, + subtitleRow: { + marginTop: theme.spacing[4], + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + }, + progressSteps: { + marginTop: theme.spacing[4], + gap: theme.spacing[3], + width: "100%", + }, + progressStepRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: theme.spacing[3], + }, + subtitle: { marginTop: theme.spacing[8], color: theme.colors.foregroundMuted, fontSize: theme.fontSize.lg, + textAlign: "center", + }, + subtitleInline: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.lg, + textAlign: "center", + }, + errorDescription: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + lineHeight: 22, + }, + errorMessage: { + color: theme.colors.destructive, + fontSize: theme.fontSize.sm, + lineHeight: 20, + fontFamily: Fonts.mono, + }, + logsMeta: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + logsContainer: { + height: 200, + borderRadius: theme.borderRadius.xl, + backgroundColor: theme.colors.surface1, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + overflow: "hidden", + }, + logsScroll: { + flexGrow: 0, + }, + logsContent: { + padding: theme.spacing[4], + }, + logsText: { + fontFamily: Fonts.mono, + fontSize: theme.fontSize.xs, + color: theme.colors.foreground, + lineHeight: 18, + ...(Platform.OS === "web" + ? { + whiteSpace: "pre", + overflowWrap: "normal", + } + : null), + }, + actionRow: { + flexDirection: "row", + gap: theme.spacing[3], + flexWrap: "wrap", }, })); -export function StartupSplashScreen() { +export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) { + const { theme } = useUnistyles(); const dragHandlers = useDesktopDragHandlers(); + const [daemonLogs, setDaemonLogs] = useState(null); + const [logsError, setLogsError] = useState(null); + const [isLoadingLogs, setIsLoadingLogs] = useState(false); + + const phase = bootstrapState?.phase; + const isError = phase === "error"; + const isSimpleSplash = bootstrapState === undefined; + + useEffect(() => { + if (!isError) { + setDaemonLogs(null); + setLogsError(null); + setIsLoadingLogs(false); + return; + } + + let isCancelled = false; + setIsLoadingLogs(true); + setLogsError(null); + + void getDesktopDaemonLogs() + .then((logs) => { + if (isCancelled) { + return; + } + setDaemonLogs(logs); + }) + .catch((error) => { + if (isCancelled) { + return; + } + const message = error instanceof Error ? error.message : String(error); + setDaemonLogs(null); + setLogsError(`Unable to load daemon logs: ${message}`); + }) + .finally(() => { + if (!isCancelled) { + setIsLoadingLogs(false); + } + }); + + return () => { + isCancelled = true; + }; + }, [isError]); + + const progressSteps = + phase === "starting-daemon" + ? [{ key: "starting-daemon", label: "Starting local server...", status: "active" as const }] + : phase === "connecting" + ? [ + { key: "starting-daemon", label: "Started local server", status: "complete" as const }, + { key: "connecting", label: "Connecting to local server...", status: "active" as const }, + ] + : [ + { key: "starting-daemon", label: "Started local server", status: "complete" as const }, + { key: "connecting", label: "Connected to local server", status: "complete" as const }, + ]; + + const logsText = useMemo(() => { + if (isLoadingLogs) { + return "Loading daemon logs..."; + } + if (daemonLogs?.contents) { + return daemonLogs.contents; + } + if (logsError) { + return logsError; + } + return "No daemon logs available."; + }, [daemonLogs?.contents, isLoadingLogs, logsError]); + + const handleCopyLogs = () => { + const payload = daemonLogs?.logPath + ? `${daemonLogs.logPath}\n\n${daemonLogs.contents}` + : logsText; + void Clipboard.setStringAsync(payload); + }; + + if (isSimpleSplash) { + return ( + + + Starting up… + + ); + } + + if (!isError) { + return ( + + + + Welcome to Paseo + + {progressSteps.map((step) => ( + + {step.status === "complete" ? ( + + ) : ( + + )} + {step.label} + + ))} + + + + ); + } return ( - - - Starting up… + + + + + Something went wrong + + + + The local server failed to start. If this keeps happening, please report the issue on GitHub and include the logs below. + + + + {bootstrapState.error} + + + {daemonLogs?.logPath ? {daemonLogs.logPath} : null} + + + + + {logsText} + + + + + + + + + + + ); }