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