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} + + + + + + + + + + + ); }