mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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
This commit is contained in:
@@ -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<HostRuntimeBootstrapState>({
|
||||
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<HostRuntimeBootstrapState["phase"]>("starting-daemon");
|
||||
const [error, setError] = useState<string | null>(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<HostRuntimeBootstrapState>(
|
||||
() => ({
|
||||
phase,
|
||||
error,
|
||||
retry,
|
||||
}),
|
||||
[error, phase, retry],
|
||||
);
|
||||
|
||||
return (
|
||||
<HostRuntimeBootstrapContext.Provider value={ready}>
|
||||
<HostRuntimeBootstrapContext.Provider value={state}>
|
||||
{children}
|
||||
</HostRuntimeBootstrapContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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 ? <StartupSplashScreen /> : <LoadingView />;
|
||||
}
|
||||
}, [settingsLoading, resolvedTheme]);
|
||||
|
||||
return (
|
||||
<VoiceProvider>
|
||||
@@ -546,6 +599,38 @@ function FaviconStatusSync() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function RootStack() {
|
||||
const storeReady = useStoreReady();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: "none",
|
||||
contentStyle: {
|
||||
backgroundColor: darkTheme.colors.surface0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Protected guard={storeReady}>
|
||||
<Stack.Screen name="welcome" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
||||
<Stack.Screen
|
||||
name="h/[serverId]/agent/[agentId]"
|
||||
options={{ gestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen name="h/[serverId]/index" />
|
||||
<Stack.Screen name="h/[serverId]/sessions" />
|
||||
<Stack.Screen name="h/[serverId]/open-project" />
|
||||
<Stack.Screen name="h/[serverId]/settings" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack.Protected>
|
||||
<Stack.Screen name="index" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationActiveWorkspaceObserver() {
|
||||
const navigationRef = useNavigationContainerRef();
|
||||
|
||||
@@ -566,57 +651,6 @@ function NavigationActiveWorkspaceObserver() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function LoadingView({ message }: { message?: string } = {}) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: darkTheme.colors.surface0,
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="large" color={darkTheme.colors.foreground} />
|
||||
{message ? (
|
||||
<Text
|
||||
style={{
|
||||
color: darkTheme.colors.foregroundMuted,
|
||||
marginTop: 16,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function MissingDaemonView() {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 24,
|
||||
backgroundColor: darkTheme.colors.surface0,
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="small" color={darkTheme.colors.foreground} />
|
||||
<Text
|
||||
style={{
|
||||
color: darkTheme.colors.foreground,
|
||||
marginTop: 16,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
No host configured. Open Settings to add a server URL.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}>
|
||||
@@ -633,28 +667,7 @@ export default function RootLayout() {
|
||||
<HorizontalScrollProvider>
|
||||
<ToastProvider>
|
||||
<AppWithSidebar>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: "none",
|
||||
contentStyle: {
|
||||
backgroundColor: darkTheme.colors.surface0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
||||
<Stack.Screen
|
||||
name="h/[serverId]/agent/[agentId]"
|
||||
options={{ gestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen name="h/[serverId]/index" />
|
||||
<Stack.Screen name="h/[serverId]/sessions" />
|
||||
<Stack.Screen name="h/[serverId]/open-project" />
|
||||
<Stack.Screen name="h/[serverId]/settings" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack>
|
||||
<RootStack />
|
||||
</AppWithSidebar>
|
||||
</ToastProvider>
|
||||
</HorizontalScrollProvider>
|
||||
|
||||
@@ -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 <StartupSplashScreen bootstrapState={bootstrapState} />;
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<HostRuntimeBootstrapResult> {
|
||||
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<HostProfile> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const { serverId, connectionId } = input;
|
||||
const timeoutMs = input.timeoutMs ?? CONNECTION_ONLINE_TIMEOUT_MS;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | 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) {
|
||||
|
||||
@@ -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<DesktopDaemonLogs | null>(null);
|
||||
const [logsError, setLogsError] = useState<string | null>(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 (
|
||||
<View style={styles.container} {...dragHandlers}>
|
||||
<PaseoLogo size={96} />
|
||||
<Text style={styles.subtitle}>Starting up…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isError) {
|
||||
return (
|
||||
<View style={styles.container} {...dragHandlers}>
|
||||
<View style={styles.centeredContent}>
|
||||
<PaseoLogo size={96} />
|
||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||
<View style={styles.progressSteps}>
|
||||
{progressSteps.map((step) => (
|
||||
<View key={step.key} style={styles.progressStepRow}>
|
||||
{step.status === "complete" ? (
|
||||
<Check size={18} color={theme.colors.success} />
|
||||
) : (
|
||||
<ActivityIndicator color={theme.colors.accent} />
|
||||
)}
|
||||
<Text style={styles.subtitleInline}>{step.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container} {...dragHandlers}>
|
||||
<PaseoLogo size={96} />
|
||||
<Text style={styles.status}>Starting up…</Text>
|
||||
<View style={[styles.container, styles.containerError]} {...dragHandlers}>
|
||||
<View style={styles.errorContent}>
|
||||
<View style={styles.errorHeader}>
|
||||
<PaseoLogo size={64} />
|
||||
<Text style={[styles.title, styles.titleError]}>Something went wrong</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.errorDescription}>
|
||||
The local server failed to start. If this keeps happening, please report the issue on GitHub and include the logs below.
|
||||
</Text>
|
||||
|
||||
<Text style={styles.errorMessage}>
|
||||
{bootstrapState.error}
|
||||
</Text>
|
||||
|
||||
{daemonLogs?.logPath ? <Text style={styles.logsMeta}>{daemonLogs.logPath}</Text> : null}
|
||||
|
||||
<View style={styles.logsContainer}>
|
||||
<ScrollView
|
||||
style={styles.logsScroll}
|
||||
contentContainerStyle={styles.logsContent}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<Text selectable style={styles.logsText}>
|
||||
{logsText}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
<View style={styles.actionRow}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
leftIcon={<Copy size={16} color={theme.colors.foreground} />}
|
||||
onPress={handleCopyLogs}
|
||||
>
|
||||
Copy logs
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<TriangleAlert size={16} color={theme.colors.foreground} />}
|
||||
onPress={() => void openExternalUrl(GITHUB_ISSUE_URL)}
|
||||
>
|
||||
Open GitHub issue
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<BookOpen size={16} color={theme.colors.foreground} />}
|
||||
onPress={() => void openExternalUrl(DOCS_URL)}
|
||||
>
|
||||
Docs
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
leftIcon={<RotateCw size={16} color={theme.colors.palette.white} />}
|
||||
onPress={bootstrapState.retry}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user