From 68df3048677065c3ba283fb2d284e6b252558f62 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 26 Mar 2026 18:10:21 +0700 Subject: [PATCH] refactor(app): move startup routing logic into welcome screen and simplify index route --- packages/app/src/app-support/index-startup.ts | 36 ----- packages/app/src/app/_layout.tsx | 27 +++- packages/app/src/app/index.tsx | 101 +----------- .../app/src/components/welcome-screen.tsx | 152 +++++++++++++++++- packages/app/src/runtime/host-runtime.ts | 18 +-- 5 files changed, 180 insertions(+), 154 deletions(-) delete mode 100644 packages/app/src/app-support/index-startup.ts diff --git a/packages/app/src/app-support/index-startup.ts b/packages/app/src/app-support/index-startup.ts deleted file mode 100644 index b0ffffe41..000000000 --- a/packages/app/src/app-support/index-startup.ts +++ /dev/null @@ -1,36 +0,0 @@ -export const WELCOME_ROUTE = "/welcome"; - -export function shouldWaitOnStartupRace(input: { - onlineServerId: string | null; - hasTimedOut: boolean; - isDesktopStartupRace: boolean; - daemonCount: number; - pathname: string; -}): boolean { - if (input.onlineServerId) { - return false; - } - if (input.pathname === WELCOME_ROUTE) { - return false; - } - if (input.hasTimedOut) { - return false; - } - return input.isDesktopStartupRace || input.daemonCount > 0; -} - -export function shouldRedirectToWelcome(input: { - onlineServerId: string | null; - hasTimedOut: boolean; - pathname: string; - isDesktopStartupRace: boolean; - daemonCount: number; -}): boolean { - if (input.onlineServerId || !input.hasTimedOut) { - return false; - } - if (input.pathname !== "/" && input.pathname !== "") { - return false; - } - return input.isDesktopStartupRace || input.daemonCount > 0; -} diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 164b9de5e..5dcc3819b 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -25,6 +25,9 @@ import { useHostMutations, 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 { SessionProvider } from "@/contexts/session-context"; import type { HostProfile } from "@/types/host-connection"; import { @@ -210,14 +213,22 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { let cancelled = false; const store = getHostRuntimeStore(); - void store - .loadFromStorage() + const init = async () => { + const settings = await loadSettingsFromStorage(); + const isDesktopManaged = shouldUseDesktopDaemon() && settings.manageBuiltInDaemon; + await store.loadFromStorage(); + if (isDesktopManaged) { + await store.bootstrap({ manageBuiltInDaemon: true }); + } else { + void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon }); + } + }; + + void init() .then(() => { - if (cancelled) { - return; + if (!cancelled) { + setReady(true); } - setReady(true); - void store.bootstrap(); }) .catch((error) => { console.error("[HostRuntime] Failed to initialize store", error); @@ -415,7 +426,9 @@ function ProvidersWrapper({ children }: { children: ReactNode }) { }, [isLoading, settings.theme]); if (isLoading) { - return ; + const isDesktopManaged = + !settingsLoading && shouldUseDesktopDaemon() && settings.manageBuiltInDaemon; + return isDesktopManaged ? : ; } return ( diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index bab7d68c7..bc2426cf1 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -1,113 +1,18 @@ -import { useEffect, useSyncExternalStore, useState } from "react"; +import { useEffect } from "react"; import { usePathname, useRouter } from "expo-router"; -import { useHosts } from "@/runtime/host-runtime"; -import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; -import { buildHostRootRoute } from "@/utils/host-routes"; -import { StartupSplashScreen } from "@/screens/startup-splash-screen"; -import { WelcomeScreen } from "@/components/welcome-screen"; -import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime"; -import { - shouldRedirectToWelcome, - shouldWaitOnStartupRace, - WELCOME_ROUTE, -} from "@/app-support/index-startup"; -const STARTUP_TIMEOUT_MS = 30_000; -function useAnyHostOnline(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; - }, - () => { - 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; - }, - ); -} +const WELCOME_ROUTE = "/welcome"; export default function Index() { const router = useRouter(); const pathname = usePathname(); - const daemons = useHosts(); - const [hasTimedOut, setHasTimedOut] = useState(false); - const isDesktopStartupRace = shouldUseDesktopDaemon(); - const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId)); - useEffect(() => { - const timer = setTimeout(() => { - setHasTimedOut(true); - }, STARTUP_TIMEOUT_MS); - return () => { - clearTimeout(timer); - }; - }, []); useEffect(() => { - if (!onlineServerId) { - return; - } if (pathname !== "/" && pathname !== "") { return; } - router.replace(buildHostRootRoute(onlineServerId) as any); - }, [onlineServerId, pathname, router]); - - useEffect(() => { - if ( - !shouldRedirectToWelcome({ - onlineServerId, - hasTimedOut, - pathname, - isDesktopStartupRace, - daemonCount: daemons.length, - }) - ) { - return; - } router.replace(WELCOME_ROUTE as any); - }, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]); - - if ( - shouldWaitOnStartupRace({ - onlineServerId, - hasTimedOut, - isDesktopStartupRace, - daemonCount: daemons.length, - pathname, - }) - ) { - return ; - } - - if (!onlineServerId) { - return ; - } + }, [pathname, router]); return null; } diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx index 1b6d65ec2..8ed402b76 100644 --- a/packages/app/src/components/welcome-screen.tsx +++ b/packages/app/src/components/welcome-screen.tsx @@ -1,11 +1,17 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { Pressable, Text, View, Platform, ScrollView } from "react-native"; import { useRouter } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { QrCode, Link2, ClipboardPaste } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { HostProfile } from "@/types/host-connection"; -import { useHostMutations } from "@/runtime/host-runtime"; +import { + getHostRuntimeStore, + isHostRuntimeConnected, + useHostMutations, + useHostRuntimeSnapshot, + useHosts, +} from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { AddHostModal } from "./add-host-modal"; import { PairLinkModal } from "./pair-link-modal"; @@ -79,6 +85,39 @@ const styles = StyleSheet.create((theme) => ({ actionTextPrimary: { color: theme.colors.accentForeground, }, + hostList: { + width: "100%", + maxWidth: 420, + marginTop: theme.spacing[6], + borderTopWidth: 1, + borderTopColor: theme.colors.border, + paddingTop: theme.spacing[4], + gap: theme.spacing[2], + }, + hostRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + paddingVertical: theme.spacing[2], + }, + statusDot: { + width: 8, + height: 8, + borderRadius: 4, + }, + hostLabel: { + flex: 1, + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, + hostStatus: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + hostStatusError: { + color: theme.colors.destructive, + fontSize: theme.fontSize.sm, + }, versionLabel: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, @@ -87,6 +126,89 @@ const styles = StyleSheet.create((theme) => ({ }, })); +function useAnyHostOnline(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; + }, + () => { + 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; + }, + ); +} + +function HostStatusRow({ serverId, label }: { serverId: string; label: string }) { + const { theme } = useUnistyles(); + const snapshot = useHostRuntimeSnapshot(serverId); + const status = snapshot?.connectionStatus ?? "connecting"; + const lastError = snapshot?.lastError ?? null; + + let dotColor: string; + let statusText: string; + let isError = false; + + switch (status) { + case "online": + dotColor = theme.colors.success; + statusText = "Online"; + break; + case "connecting": + case "idle": + dotColor = theme.colors.foregroundMuted; + statusText = "Connecting…"; + break; + case "offline": + dotColor = theme.colors.foregroundMuted; + statusText = "Offline"; + break; + case "error": + dotColor = theme.colors.destructive; + statusText = lastError ? lastError.slice(0, 40) : "Connection error"; + isError = true; + break; + } + + return ( + + + + {label} + + + {statusText} + + + ); +} + export interface WelcomeScreenProps { onHostAdded?: (profile: HostProfile) => void; } @@ -105,6 +227,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { hostname: string | null; } | null>(null); const [pendingRedirectServerId, setPendingRedirectServerId] = useState(null); + const hosts = useHosts(); + const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId)); const pendingNameHostname = useSessionStore( useCallback( (state) => { @@ -119,6 +243,16 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { ), ); + useEffect(() => { + if (!anyOnlineServerId) { + return; + } + if (pendingNameHost) { + return; + } + router.replace(buildHostRootRoute(anyOnlineServerId) as any); + }, [anyOnlineServerId, pendingNameHost, router]); + const finishOnboarding = useCallback( (serverId: string) => { router.replace(buildHostRootRoute(serverId) as any); @@ -173,6 +307,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { }, ]; + const showHostList = hosts.length > 0 && !anyOnlineServerId; + return ( Welcome to Paseo - Connect to your host to start + + {showHostList ? "Connecting to your hosts…" : "Connect to your host to start"} + {actions.map((action) => { @@ -209,6 +347,14 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { ); })} + + {showHostList && ( + + {hosts.map((host) => ( + + ))} + + )} {appVersionText} diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index affd60523..c228273bd 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1137,7 +1137,7 @@ export class HostRuntimeStore { } } - async bootstrap(): Promise { + async bootstrap(options?: { manageBuiltInDaemon?: boolean }): Promise { if (this.bootstrapAttempted) { return; } @@ -1153,7 +1153,9 @@ export class HostRuntimeStore { } if (shouldUseDesktopDaemon()) { - await this.bootstrapDesktop(); + if (options?.manageBuiltInDaemon ?? true) { + await this.bootstrapDesktop(); + } } else { await this.bootstrapLocalhost(); } @@ -1163,20 +1165,16 @@ export class HostRuntimeStore { try { const daemon = await startDesktopDaemon(); const connection = connectionFromListen(daemon.listen); - if (!connection) { + if (!connection || !daemon.serverId) { return; } - const { client, serverId, hostname } = await connectToDaemon(connection, { - timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS, - }); await this.upsertHostConnection({ - serverId, - label: hostname ?? daemon.hostname ?? undefined, + serverId: daemon.serverId, + label: daemon.hostname ?? undefined, connection, - existingClient: client, }); } catch (error) { - console.warn("[HostRuntime] Failed to bootstrap desktop daemon connection", error); + console.warn("[HostRuntime] Failed to bootstrap desktop daemon", error); } }