refactor(app): move startup routing logic into welcome screen and simplify index route

This commit is contained in:
Mohamed Boudra
2026-03-26 18:10:21 +07:00
parent a2aac3797f
commit 68df304867
5 changed files with 180 additions and 154 deletions
@@ -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;
}
+20 -7
View File
@@ -25,6 +25,9 @@ import {
useHostMutations, useHostMutations,
useHostRuntimeClient, useHostRuntimeClient,
} from "@/runtime/host-runtime"; } 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 { SessionProvider } from "@/contexts/session-context";
import type { HostProfile } from "@/types/host-connection"; import type { HostProfile } from "@/types/host-connection";
import { import {
@@ -210,14 +213,22 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
let cancelled = false; let cancelled = false;
const store = getHostRuntimeStore(); const store = getHostRuntimeStore();
void store const init = async () => {
.loadFromStorage() 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(() => { .then(() => {
if (cancelled) { if (!cancelled) {
return; setReady(true);
} }
setReady(true);
void store.bootstrap();
}) })
.catch((error) => { .catch((error) => {
console.error("[HostRuntime] Failed to initialize store", error); console.error("[HostRuntime] Failed to initialize store", error);
@@ -415,7 +426,9 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
}, [isLoading, settings.theme]); }, [isLoading, settings.theme]);
if (isLoading) { if (isLoading) {
return <LoadingView />; const isDesktopManaged =
!settingsLoading && shouldUseDesktopDaemon() && settings.manageBuiltInDaemon;
return isDesktopManaged ? <StartupSplashScreen /> : <LoadingView />;
} }
return ( return (
+3 -98
View File
@@ -1,113 +1,18 @@
import { useEffect, useSyncExternalStore, useState } from "react"; import { useEffect } from "react";
import { usePathname, useRouter } from "expo-router"; 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; const WELCOME_ROUTE = "/welcome";
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;
},
);
}
export default function Index() { export default function Index() {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); 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(() => { useEffect(() => {
if (!onlineServerId) {
return;
}
if (pathname !== "/" && pathname !== "") { if (pathname !== "/" && pathname !== "") {
return; 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); router.replace(WELCOME_ROUTE as any);
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]); }, [pathname, router]);
if (
shouldWaitOnStartupRace({
onlineServerId,
hasTimedOut,
isDesktopStartupRace,
daemonCount: daemons.length,
pathname,
})
) {
return <StartupSplashScreen />;
}
if (!onlineServerId) {
return <WelcomeScreen />;
}
return null; return null;
} }
+149 -3
View File
@@ -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 { Pressable, Text, View, Platform, ScrollView } from "react-native";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native"; import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection"; 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 { useSessionStore } from "@/stores/session-store";
import { AddHostModal } from "./add-host-modal"; import { AddHostModal } from "./add-host-modal";
import { PairLinkModal } from "./pair-link-modal"; import { PairLinkModal } from "./pair-link-modal";
@@ -79,6 +85,39 @@ const styles = StyleSheet.create((theme) => ({
actionTextPrimary: { actionTextPrimary: {
color: theme.colors.accentForeground, 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: { versionLabel: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, 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 (
<View style={styles.hostRow}>
<View style={[styles.statusDot, { backgroundColor: dotColor }]} />
<Text style={styles.hostLabel} numberOfLines={1}>
{label}
</Text>
<Text style={isError ? styles.hostStatusError : styles.hostStatus} numberOfLines={1}>
{statusText}
</Text>
</View>
);
}
export interface WelcomeScreenProps { export interface WelcomeScreenProps {
onHostAdded?: (profile: HostProfile) => void; onHostAdded?: (profile: HostProfile) => void;
} }
@@ -105,6 +227,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
hostname: string | null; hostname: string | null;
} | null>(null); } | null>(null);
const [pendingRedirectServerId, setPendingRedirectServerId] = useState<string | null>(null); const [pendingRedirectServerId, setPendingRedirectServerId] = useState<string | null>(null);
const hosts = useHosts();
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
const pendingNameHostname = useSessionStore( const pendingNameHostname = useSessionStore(
useCallback( useCallback(
(state) => { (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( const finishOnboarding = useCallback(
(serverId: string) => { (serverId: string) => {
router.replace(buildHostRootRoute(serverId) as any); router.replace(buildHostRootRoute(serverId) as any);
@@ -173,6 +307,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
}, },
]; ];
const showHostList = hosts.length > 0 && !anyOnlineServerId;
return ( return (
<ScrollView <ScrollView
style={{ flex: 1, backgroundColor: theme.colors.surface0 }} style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
@@ -186,7 +322,9 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<View style={styles.content}> <View style={styles.content}>
<PaseoLogo size={96} color={theme.colors.foreground} /> <PaseoLogo size={96} color={theme.colors.foreground} />
<Text style={styles.title}>Welcome to Paseo</Text> <Text style={styles.title}>Welcome to Paseo</Text>
<Text style={styles.subtitle}>Connect to your host to start</Text> <Text style={styles.subtitle}>
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
</Text>
<View style={styles.actions}> <View style={styles.actions}>
{actions.map((action) => { {actions.map((action) => {
@@ -209,6 +347,14 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
); );
})} })}
</View> </View>
{showHostList && (
<View style={styles.hostList}>
{hosts.map((host) => (
<HostStatusRow key={host.serverId} serverId={host.serverId} label={host.label} />
))}
</View>
)}
</View> </View>
<Text style={styles.versionLabel}>{appVersionText}</Text> <Text style={styles.versionLabel}>{appVersionText}</Text>
+8 -10
View File
@@ -1137,7 +1137,7 @@ export class HostRuntimeStore {
} }
} }
async bootstrap(): Promise<void> { async bootstrap(options?: { manageBuiltInDaemon?: boolean }): Promise<void> {
if (this.bootstrapAttempted) { if (this.bootstrapAttempted) {
return; return;
} }
@@ -1153,7 +1153,9 @@ export class HostRuntimeStore {
} }
if (shouldUseDesktopDaemon()) { if (shouldUseDesktopDaemon()) {
await this.bootstrapDesktop(); if (options?.manageBuiltInDaemon ?? true) {
await this.bootstrapDesktop();
}
} else { } else {
await this.bootstrapLocalhost(); await this.bootstrapLocalhost();
} }
@@ -1163,20 +1165,16 @@ export class HostRuntimeStore {
try { try {
const daemon = await startDesktopDaemon(); const daemon = await startDesktopDaemon();
const connection = connectionFromListen(daemon.listen); const connection = connectionFromListen(daemon.listen);
if (!connection) { if (!connection || !daemon.serverId) {
return; return;
} }
const { client, serverId, hostname } = await connectToDaemon(connection, {
timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
});
await this.upsertHostConnection({ await this.upsertHostConnection({
serverId, serverId: daemon.serverId,
label: hostname ?? daemon.hostname ?? undefined, label: daemon.hostname ?? undefined,
connection, connection,
existingClient: client,
}); });
} catch (error) { } catch (error) {
console.warn("[HostRuntime] Failed to bootstrap desktop daemon connection", error); console.warn("[HostRuntime] Failed to bootstrap desktop daemon", error);
} }
} }