diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 539014955..06cf49c0d 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -12,10 +12,24 @@ import { useFaviconStatus } from "@/hooks/use-favicon-status"; import { View, ActivityIndicator, Text } from "react-native"; import { UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { darkTheme } from "@/styles/theme"; -import { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-registry-context"; -import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host"; import { QueryClientProvider } from "@tanstack/react-query"; -import { useState, useEffect, type ReactNode, useMemo, useRef } from "react"; +import { + getHostRuntimeStore, + useHosts, + useHostMutations, + useHostRuntimeSession, +} from "@/runtime/host-runtime"; +import { SessionProvider } from "@/contexts/session-context"; +import type { HostProfile } from "@/types/host-connection"; +import { + createContext, + useContext, + useState, + useEffect, + type ReactNode, + useMemo, + useRef, +} from "react"; import { Platform } from "react-native"; import * as Linking from "expo-linking"; import * as Notifications from "expo-notifications"; @@ -51,8 +65,12 @@ import { } from "@/utils/host-routes"; import { getTauri } from "@/utils/tauri"; import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics"; +import { attachConsole } from "@/utils/tauri-attach-console"; polyfillCrypto(); +attachConsole(); +const HostRuntimeBootstrapContext = createContext(false); + const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); function logLeftSidebarOpenGesture( @@ -141,6 +159,79 @@ function PushNotificationRouter() { return null; } +function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) { + const { client } = useHostRuntimeSession(daemon.serverId); + + if (!client) { + return null; + } + + return ( + + {null} + + ); +} + +function HostSessionManager() { + const hosts = useHosts(); + + if (hosts.length === 0) { + return null; + } + + return ( + <> + {hosts.map((daemon) => ( + + ))} + + ); +} + +function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { + const [ready, setReady] = useState(false); + + useEffect(() => { + let cancelled = false; + const store = getHostRuntimeStore(); + + void store + .loadFromStorage() + .then(() => { + if (cancelled) { + return; + } + setReady(true); + void store.bootstrap(); + }) + .catch((error) => { + console.error("[HostRuntime] Failed to initialize store", error); + if (!cancelled) { + setReady(true); + } + }); + + return () => { + cancelled = true; + }; + }, []); + + return ( + + {children} + + ); +} + +function useStoreReady(): boolean { + return useContext(HostRuntimeBootstrapContext); +} + function QueryProvider({ children }: { children: ReactNode }) { return {children}; } @@ -157,7 +248,7 @@ function AppContainer({ chromeEnabled: chromeEnabledOverride, }: AppContainerProps) { const { theme } = useUnistyles(); - const { daemons } = useDaemonRegistry(); + const daemons = useHosts(); const mobileView = usePanelStore((state) => state.mobileView); const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const openAgentList = usePanelStore((state) => state.openAgentList); @@ -310,8 +401,9 @@ function AppContainer({ function ProvidersWrapper({ children }: { children: ReactNode }) { const { settings, isLoading: settingsLoading } = useAppSettings(); - const { daemons, isLoading: registryLoading, upsertDaemonFromOfferUrl } = useDaemonRegistry(); - const isLoading = settingsLoading || registryLoading; + const storeReady = useStoreReady(); + const { upsertConnectionFromOfferUrl } = useHostMutations(); + const isLoading = settingsLoading || !storeReady; // Apply theme setting on mount and when it changes useEffect(() => { @@ -330,7 +422,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) { return ( - + {children} ); @@ -470,9 +562,9 @@ export default function RootLayout() { - + - + @@ -506,7 +598,7 @@ export default function RootLayout() { - + diff --git a/packages/app/src/app/index-startup.ts b/packages/app/src/app/index-startup.ts index 337f4304c..627593fc7 100644 --- a/packages/app/src/app/index-startup.ts +++ b/packages/app/src/app/index-startup.ts @@ -1,16 +1,12 @@ export const WELCOME_ROUTE = '/welcome' export function shouldWaitOnStartupRace(input: { - registryLoading: boolean onlineServerId: string | null hasTimedOut: boolean isDesktopStartupRace: boolean daemonCount: number pathname: string }): boolean { - if (input.registryLoading) { - return true - } if (input.onlineServerId) { return false } @@ -24,14 +20,13 @@ export function shouldWaitOnStartupRace(input: { } export function shouldRedirectToWelcome(input: { - registryLoading: boolean onlineServerId: string | null hasTimedOut: boolean pathname: string isDesktopStartupRace: boolean daemonCount: number }): boolean { - if (input.registryLoading || input.onlineServerId || !input.hasTimedOut) { + if (input.onlineServerId || !input.hasTimedOut) { return false } if (input.pathname !== '/' && input.pathname !== '') { diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index 9333559e4..256934b72 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useSyncExternalStore, useState } from 'react' import { usePathname, useRouter } from 'expo-router' -import { useDaemonRegistry } from '@/contexts/daemon-registry-context' +import { useHosts } from '@/runtime/host-runtime' import { shouldUseManagedDesktopDaemon } from '@/desktop/managed-runtime/managed-runtime' import { buildHostRootRoute } from '@/utils/host-routes' import { StartupSplashScreen } from '@/screens/startup-splash-screen' @@ -57,7 +57,7 @@ function useAnyHostOnline(serverIds: string[]): string | null { export default function Index() { const router = useRouter() const pathname = usePathname() - const { daemons, isLoading: registryLoading } = useDaemonRegistry() + const daemons = useHosts() const [hasTimedOut, setHasTimedOut] = useState(false) const isDesktopStartupRace = shouldUseManagedDesktopDaemon() const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId)) @@ -83,9 +83,6 @@ export default function Index() { }, []) useEffect(() => { - if (registryLoading) { - return - } if (!onlineServerId) { return } @@ -97,12 +94,11 @@ export default function Index() { elapsedMs: Math.round(performance.now() - APP_START_TIME), }) router.replace(buildHostRootRoute(onlineServerId) as any) - }, [onlineServerId, pathname, registryLoading, router]) + }, [onlineServerId, pathname, router]) useEffect(() => { if ( !shouldRedirectToWelcome({ - registryLoading, onlineServerId, hasTimedOut, pathname, @@ -113,11 +109,10 @@ export default function Index() { return } router.replace(WELCOME_ROUTE as any) - }, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, registryLoading, router]) + }, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]) if ( shouldWaitOnStartupRace({ - registryLoading, onlineServerId, hasTimedOut, isDesktopStartupRace, diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/app/pair-scan.tsx index 60cd9989e..8bf7aceb3 100644 --- a/packages/app/src/app/pair-scan.tsx +++ b/packages/app/src/app/pair-scan.tsx @@ -5,11 +5,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { CameraView, useCameraPermissions } from "expo-camera"; import type { BarcodeScanningResult } from "expo-camera"; -import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; +import { useHosts, useHostMutations } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { NameHostModal } from "@/components/name-host-modal"; import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints"; -import { probeConnection } from "@/utils/test-daemon-connection"; +import { connectToDaemon } from "@/utils/test-daemon-connection"; import { ConnectionOfferSchema } from "@server/shared/connection-offer"; import { buildHostRootRoute, @@ -151,7 +151,8 @@ export default function PairScanScreen() { const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null; const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null; - const { daemons, upsertDaemonFromOfferUrl, updateHost } = useDaemonRegistry(); + const daemons = useHosts(); + const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations(); const [permission, requestPermission] = useCameraPermissions(); const [isPairing, setIsPairing] = useState(false); @@ -241,7 +242,7 @@ export default function PairScanScreen() { return; } - await probeConnection( + const { client } = await connectToDaemon( { id: "probe", type: "relay", @@ -250,6 +251,7 @@ export default function PairScanScreen() { }, { serverId: offer.serverId }, ); + await client.close().catch(() => undefined); const isNewHost = !daemons.some((daemon) => daemon.serverId === offer.serverId); const profile = await upsertDaemonFromOfferUrl(offerUrl); @@ -311,7 +313,7 @@ export default function PairScanScreen() { }} onSave={(label) => { const serverId = pendingNameHost.serverId; - void updateHost(serverId, { label }).finally(() => { + void renameHost(serverId, label).finally(() => { setPendingNameHost(null); returnToSource(serverId); }); diff --git a/packages/app/src/app/settings.tsx b/packages/app/src/app/settings.tsx index 2d10eae19..a0bbe5a3d 100644 --- a/packages/app/src/app/settings.tsx +++ b/packages/app/src/app/settings.tsx @@ -3,14 +3,14 @@ import { ActivityIndicator, View } from "react-native"; import { useRouter } from "expo-router"; import { useUnistyles } from "react-native-unistyles"; import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen"; -import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; +import { useHosts } from "@/runtime/host-runtime"; import { useFormPreferences } from "@/hooks/use-form-preferences"; import { buildHostSettingsRoute } from "@/utils/host-routes"; export default function LegacySettingsRoute() { const router = useRouter(); const { theme } = useUnistyles(); - const { daemons, isLoading: registryLoading } = useDaemonRegistry(); + const daemons = useHosts(); const { preferences, isLoading: preferencesLoading } = useFormPreferences(); const targetServerId = useMemo(() => { @@ -29,16 +29,16 @@ export default function LegacySettingsRoute() { }, [daemons, preferences.serverId]); useEffect(() => { - if (registryLoading || preferencesLoading) { + if (preferencesLoading) { return; } if (!targetServerId) { return; } router.replace(buildHostSettingsRoute(targetServerId) as any); - }, [preferencesLoading, registryLoading, router, targetServerId]); + }, [preferencesLoading, router, targetServerId]); - if (registryLoading || preferencesLoading) { + if (preferencesLoading) { return ( undefined); if (targetServerId && serverId !== targetServerId) { const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`; setErrorMessage(message); diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index e725a7888..736f73a69 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -41,7 +41,7 @@ import type { AgentFileExplorerState, ExplorerEntry, } from "@/stores/session-store"; -import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; +import { useHosts } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { useDownloadStore } from "@/stores/download-store"; import { @@ -105,7 +105,7 @@ export function FileExplorerPane({ UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; - const { daemons } = useDaemonRegistry(); + const daemons = useHosts(); const daemonProfile = useMemo( () => daemons.find((daemon) => daemon.serverId === serverId), [daemons, serverId] diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 56e45329d..457d677b8 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -19,8 +19,7 @@ import { useSidebarWorkspacesList } from '@/hooks/use-sidebar-workspaces-list' import { useSidebarAnimation } from '@/contexts/sidebar-animation-context' import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window' import { Combobox } from '@/components/ui/combobox' -import { useDaemonRegistry } from '@/contexts/daemon-registry-context' -import { getHostRuntimeStore } from '@/runtime/host-runtime' +import { getHostRuntimeStore, useHosts } from '@/runtime/host-runtime' import { formatConnectionStatus } from '@/utils/daemons' import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from '@/constants/layout' import { @@ -53,7 +52,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen) const closeToAgent = usePanelStore((state) => state.closeToAgent) const pathname = usePathname() - const { daemons } = useDaemonRegistry() + const daemons = useHosts() const runtime = getHostRuntimeStore() const runtimeConnectionStatusSignature = useSyncExternalStore( (onStoreChange) => runtime.subscribeAll(onStoreChange), diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 13463b991..55dc22d61 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -356,6 +356,7 @@ export const MessageInput = forwardRef(funct serverInfo, mode: 'dictation', }) + const dictationReadiness = serverInfo?.capabilities?.voice?.dictation ?? null const canStartDictation = useCallback(() => { const socketConnected = client?.isConnected ?? false @@ -415,7 +416,28 @@ export const MessageInput = forwardRef(funct }, [dictationStatus, isDictating, isDictationProcessing]) const startDictationIfAvailable = useCallback(async () => { + console.info( + `[DictationGate] start requested ${JSON.stringify({ + serverId: voiceServerId ?? null, + agentId: voiceAgentId ?? null, + socketConnected: client?.isConnected ?? false, + isReadyForDictation: isReadyForDictation ?? null, + disabled, + serverInfoPresent: Boolean(serverInfo), + capabilitiesPresent: Boolean(serverInfo?.capabilities), + dictationReadinessEnabled: dictationReadiness?.enabled ?? null, + dictationReadinessReason: dictationReadiness?.reason ?? null, + dictationUnavailableMessage, + })}` + ) if (dictationUnavailableMessage) { + console.info( + `[DictationGate] blocked before start ${JSON.stringify({ + serverId: voiceServerId ?? null, + agentId: voiceAgentId ?? null, + dictationUnavailableMessage, + })}` + ) isDictatingRef.current = false toast.error(dictationUnavailableMessage) return @@ -424,7 +446,18 @@ export const MessageInput = forwardRef(funct // state-ref sync effect, so a rapid second toggle routes to confirm. isDictatingRef.current = true await startDictation() - }, [dictationUnavailableMessage, startDictation, toast]) + }, [ + client, + dictationReadiness, + dictationUnavailableMessage, + disabled, + isReadyForDictation, + serverInfo, + startDictation, + toast, + voiceAgentId, + voiceServerId, + ]) // Animate overlay useEffect(() => { diff --git a/packages/app/src/components/multi-daemon-session-host.tsx b/packages/app/src/components/multi-daemon-session-host.tsx deleted file mode 100644 index 3fa0098df..000000000 --- a/packages/app/src/components/multi-daemon-session-host.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useEffect } from "react"; -import { SessionProvider } from "@/contexts/session-context"; -import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context"; -import { - getHostRuntimeStore, - useHostRuntimeSession, -} from "@/runtime/host-runtime"; - -function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) { - const { client } = useHostRuntimeSession(daemon.serverId); - - if (!client) { - return null; - } - - return ( - - {null} - - ); -} - -export function MultiDaemonSessionHost() { - const { daemons } = useDaemonRegistry(); - - useEffect(() => { - const runtime = getHostRuntimeStore(); - runtime.syncHosts(daemons); - }, [daemons]); - - if (daemons.length === 0) { - return null; - } - - return ( - <> - {daemons.map((daemon) => ( - - ))} - - ); -} diff --git a/packages/app/src/components/pair-link-modal.tsx b/packages/app/src/components/pair-link-modal.tsx index c49c35dea..540edce21 100644 --- a/packages/app/src/components/pair-link-modal.tsx +++ b/packages/app/src/components/pair-link-modal.tsx @@ -2,9 +2,10 @@ import { useCallback, useState } from "react"; import { Alert, Text, View } from "react-native"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { Link } from "lucide-react-native"; -import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context"; +import type { HostProfile } from "@/types/host-connection"; +import { useHosts, useHostMutations } from "@/runtime/host-runtime"; import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints"; -import { probeConnection } from "@/utils/test-daemon-connection"; +import { connectToDaemon } from "@/utils/test-daemon-connection"; import { ConnectionOfferSchema } from "@server/shared/connection-offer"; import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; @@ -52,7 +53,8 @@ export interface PairLinkModalProps { export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServerId }: PairLinkModalProps) { const { theme } = useUnistyles(); - const { daemons, upsertDaemonFromOfferUrl } = useDaemonRegistry(); + const daemons = useHosts(); + const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; @@ -122,7 +124,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServe setIsSaving(true); setErrorMessage(""); - const probeResult = await probeConnection( + const { client, hostname } = await connectToDaemon( { id: "probe", type: "relay", @@ -131,10 +133,11 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServe }, { serverId: parsedOffer.serverId }, ); + await client.close().catch(() => undefined); const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId); const profile = await upsertDaemonFromOfferUrl(raw); - onSaved?.({ profile, serverId: parsedOffer.serverId, hostname: probeResult.hostname, isNewHost }); + onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost }); handleClose(); } catch (error) { const message = error instanceof Error ? error.message : "Unable to pair host"; diff --git a/packages/app/src/components/project-picker-modal.tsx b/packages/app/src/components/project-picker-modal.tsx index 68ba21f87..5554beb63 100644 --- a/packages/app/src/components/project-picker-modal.tsx +++ b/packages/app/src/components/project-picker-modal.tsx @@ -17,8 +17,7 @@ import { normalizeWorkspaceDescriptor, useSessionStore, } from "@/stores/session-store"; -import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; -import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { useHosts, useHostRuntimeSession } from "@/runtime/host-runtime"; import { useToast } from "@/contexts/toast-context"; import { parseServerIdFromPathname } from "@/utils/host-routes"; import { buildHostWorkspaceRouteWithOpenIntent } from "@/utils/host-routes"; @@ -28,7 +27,7 @@ export function ProjectPickerModal() { const { theme } = useUnistyles(); const toast = useToast(); const pathname = usePathname(); - const { daemons } = useDaemonRegistry(); + const daemons = useHosts(); const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen); const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen); diff --git a/packages/app/src/components/voice-panel.tsx b/packages/app/src/components/voice-panel.tsx index ed2165f2d..c1ed3fda2 100644 --- a/packages/app/src/components/voice-panel.tsx +++ b/packages/app/src/components/voice-panel.tsx @@ -3,11 +3,11 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { MicOff, Square } from "lucide-react-native"; import { VolumeMeter } from "./volume-meter"; import { useVoice } from "@/contexts/voice-context"; -import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; +import { useHosts } from "@/runtime/host-runtime"; export function VoicePanel() { const { theme } = useUnistyles(); - const { daemons } = useDaemonRegistry(); + const daemons = useHosts(); const { volume, isMuted, diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx index 49cdd87fb..6f59604f5 100644 --- a/packages/app/src/components/welcome-screen.tsx +++ b/packages/app/src/components/welcome-screen.tsx @@ -3,8 +3,8 @@ import { Image, 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 type { HostProfile } from "@/contexts/daemon-registry-context"; -import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; +import type { HostProfile } from "@/types/host-connection"; +import { useHostMutations } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { AddHostModal } from "./add-host-modal"; import { PairLinkModal } from "./pair-link-modal"; @@ -87,7 +87,7 @@ export interface WelcomeScreenProps { export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { const { theme } = useUnistyles(); const router = useRouter(); - const { updateHost } = useDaemonRegistry(); + const { renameHost } = useHostMutations(); const appVersion = resolveAppVersion(); const appVersionText = formatVersionWithPrefix(appVersion); const [isDirectOpen, setIsDirectOpen] = useState(false); @@ -201,7 +201,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { }} onSave={(label) => { const serverId = pendingRedirectServerId; - void updateHost(pendingNameHost.serverId, { label }).finally(() => { + void renameHost(pendingNameHost.serverId, label).finally(() => { setPendingNameHost(null); setPendingRedirectServerId(null); finishOnboarding(serverId); diff --git a/packages/app/src/contexts/daemon-registry-context.test.ts b/packages/app/src/contexts/daemon-registry-context.test.ts deleted file mode 100644 index 407361220..000000000 --- a/packages/app/src/contexts/daemon-registry-context.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - connectionFromListen, - hostHasDirectEndpoint, - registryHasDirectEndpoint, - type HostProfile, -} from './daemon-registry-context' - -function makeHost(input: Partial & Pick): HostProfile { - const now = '2026-01-01T00:00:00.000Z' - return { - serverId: input.serverId, - label: input.label ?? input.serverId, - lifecycle: input.lifecycle ?? {}, - connections: input.connections ?? [], - preferredConnectionId: input.preferredConnectionId ?? null, - createdAt: input.createdAt ?? now, - updatedAt: input.updatedAt ?? now, - } -} - -describe('connectionFromListen', () => { - it('parses tcp listen targets', () => { - expect(connectionFromListen('127.0.0.1:6767')).toEqual({ - id: 'direct:127.0.0.1:6767', - type: 'directTcp', - endpoint: '127.0.0.1:6767', - }) - }) - - it('parses unix socket listen targets', () => { - expect(connectionFromListen('/tmp/paseo.sock')).toEqual({ - id: 'socket:/tmp/paseo.sock', - type: 'directSocket', - path: '/tmp/paseo.sock', - }) - expect(connectionFromListen('unix:///tmp/paseo.sock')).toEqual({ - id: 'socket:/tmp/paseo.sock', - type: 'directSocket', - path: '/tmp/paseo.sock', - }) - }) - - it('parses named pipe listen targets', () => { - expect(connectionFromListen(String.raw`\\.\pipe\paseo-test`)).toEqual({ - id: String.raw`pipe:\\.\pipe\paseo-test`, - type: 'directPipe', - path: String.raw`\\.\pipe\paseo-test`, - }) - expect(connectionFromListen('pipe://paseo-test')).toEqual({ - id: 'pipe:paseo-test', - type: 'directPipe', - path: 'paseo-test', - }) - }) - - it('returns null for unsupported listen targets', () => { - expect(connectionFromListen('')).toBeNull() - expect(connectionFromListen('not-a-listen-target')).toBeNull() - }) -}) - -describe('hostHasDirectEndpoint', () => { - it('returns true when host has matching direct endpoint', () => { - const host = makeHost({ - serverId: 'srv_local', - connections: [{ id: 'direct:localhost:6767', type: 'directTcp', endpoint: 'localhost:6767' }], - preferredConnectionId: 'direct:localhost:6767', - }) - - expect(hostHasDirectEndpoint(host, 'localhost:6767')).toBe(true) - }) - - it('returns false when only relay connections exist', () => { - const host = makeHost({ - serverId: 'srv_relay', - connections: [ - { - id: 'relay:relay.example:443', - type: 'relay', - relayEndpoint: 'relay.example:443', - daemonPublicKeyB64: 'abcd', - }, - ], - preferredConnectionId: 'relay:relay.example:443', - }) - - expect(hostHasDirectEndpoint(host, 'localhost:6767')).toBe(false) - }) -}) - -describe('registryHasDirectEndpoint', () => { - it('returns true when any host contains the direct endpoint', () => { - const hosts: HostProfile[] = [ - makeHost({ - serverId: 'srv_one', - connections: [{ id: 'direct:127.0.0.1:7777', type: 'directTcp', endpoint: '127.0.0.1:7777' }], - preferredConnectionId: 'direct:127.0.0.1:7777', - }), - makeHost({ - serverId: 'srv_two', - connections: [{ id: 'direct:localhost:6767', type: 'directTcp', endpoint: 'localhost:6767' }], - preferredConnectionId: 'direct:localhost:6767', - }), - ] - - expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(true) - }) - - it('returns false when no host has the endpoint', () => { - const hosts: HostProfile[] = [ - makeHost({ - serverId: 'srv_one', - connections: [{ id: 'direct:127.0.0.1:7777', type: 'directTcp', endpoint: '127.0.0.1:7777' }], - preferredConnectionId: 'direct:127.0.0.1:7777', - }), - ] - - expect(registryHasDirectEndpoint(hosts, 'localhost:6767')).toBe(false) - }) -}) diff --git a/packages/app/src/contexts/daemon-registry-context.tsx b/packages/app/src/contexts/daemon-registry-context.tsx deleted file mode 100644 index 4d64c6bbf..000000000 --- a/packages/app/src/contexts/daemon-registry-context.tsx +++ /dev/null @@ -1,714 +0,0 @@ -import { createContext, useCallback, useContext, useEffect, useRef } from 'react' -import type { ReactNode } from 'react' -import AsyncStorage from '@react-native-async-storage/async-storage' -import { useQuery, useQueryClient } from '@tanstack/react-query' -import { decodeOfferFragmentPayload, normalizeHostPort } from '@/utils/daemon-endpoints' -import { probeConnection } from '@/utils/test-daemon-connection' -import { ConnectionOfferSchema, type ConnectionOffer } from '@server/shared/connection-offer' -import { - shouldUseManagedDesktopDaemon, - startManagedDaemon, -} from '@/desktop/managed-runtime/managed-runtime' - -const REGISTRY_STORAGE_KEY = '@paseo:daemon-registry' -const DAEMON_REGISTRY_QUERY_KEY = ['daemon-registry'] -const DEFAULT_LOCALHOST_ENDPOINT = 'localhost:6767' -const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = '@paseo:default-localhost-bootstrap-v1' -const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500 -const E2E_STORAGE_KEY = '@paseo:e2e' - -export type DirectTcpHostConnection = { - id: string - type: 'directTcp' - endpoint: string -} - -export type DirectSocketHostConnection = { - id: string - type: 'directSocket' - path: string -} - -export type DirectPipeHostConnection = { - id: string - type: 'directPipe' - path: string -} - -export type RelayHostConnection = { - id: string - type: 'relay' - relayEndpoint: string - daemonPublicKeyB64: string -} - -export type HostConnection = - | DirectTcpHostConnection - | DirectSocketHostConnection - | DirectPipeHostConnection - | RelayHostConnection - -export type HostLifecycle = Record - -export type HostProfile = { - serverId: string - label: string - lifecycle: HostLifecycle - connections: HostConnection[] - preferredConnectionId: string | null - createdAt: string - updatedAt: string -} - -export type UpdateHostInput = Partial> - -interface DaemonRegistryContextValue { - daemons: HostProfile[] - isLoading: boolean - error: unknown | null - upsertDirectConnection: (input: { - serverId: string - endpoint: string - label?: string - }) => Promise - upsertRelayConnection: (input: { - serverId: string - relayEndpoint: string - daemonPublicKeyB64: string - label?: string - }) => Promise - updateHost: (serverId: string, updates: UpdateHostInput) => Promise - removeHost: (serverId: string) => Promise - removeConnection: (serverId: string, connectionId: string) => Promise - upsertDaemonFromOffer: (offer: ConnectionOffer) => Promise - upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise -} - -const DaemonRegistryContext = createContext(null) - -function defaultLifecycle(): HostLifecycle { - return {} -} - -function normalizeHostLabel(value: string | null | undefined, serverId: string): string { - const trimmed = value?.trim() ?? '' - return trimmed.length > 0 ? trimmed : serverId -} - -function normalizeEndpointOrNull(endpoint: string): string | null { - try { - return normalizeHostPort(endpoint) - } catch { - return null - } -} - -export function connectionFromListen(listen: string): HostConnection | null { - const normalizedListen = listen.trim() - if (!normalizedListen) { - return null - } - - if (normalizedListen.startsWith('pipe://')) { - const path = normalizedListen.slice('pipe://'.length).trim() - return path ? { id: `pipe:${path}`, type: 'directPipe', path } : null - } - - if (normalizedListen.startsWith('unix://')) { - const path = normalizedListen.slice('unix://'.length).trim() - return path ? { id: `socket:${path}`, type: 'directSocket', path } : null - } - - if (normalizedListen.startsWith('\\\\.\\pipe\\')) { - return { - id: `pipe:${normalizedListen}`, - type: 'directPipe', - path: normalizedListen, - } - } - - if (normalizedListen.startsWith('/')) { - return { - id: `socket:${normalizedListen}`, - type: 'directSocket', - path: normalizedListen, - } - } - - try { - const endpoint = normalizeHostPort(normalizedListen) - return { - id: `direct:${endpoint}`, - type: 'directTcp', - endpoint, - } - } catch { - return null - } -} - -function normalizeStoredConnection(connection: unknown): HostConnection | null { - if (!connection || typeof connection !== 'object') { - return null - } - const record = connection as Record - const type = typeof record.type === 'string' ? record.type : null - if (type === 'directTcp') { - try { - const endpoint = normalizeHostPort(String(record.endpoint ?? '')) - return { id: `direct:${endpoint}`, type: 'directTcp', endpoint } - } catch { - return null - } - } - if (type === 'directSocket') { - const path = String(record.path ?? '').trim() - return path ? { id: `socket:${path}`, type: 'directSocket', path } : null - } - if (type === 'directPipe') { - const path = String(record.path ?? '').trim() - return path ? { id: `pipe:${path}`, type: 'directPipe', path } : null - } - if (type === 'relay') { - try { - const relayEndpoint = normalizeHostPort(String(record.relayEndpoint ?? '')) - const daemonPublicKeyB64 = String(record.daemonPublicKeyB64 ?? '').trim() - if (!daemonPublicKeyB64) return null - return { - id: `relay:${relayEndpoint}`, - type: 'relay', - relayEndpoint, - daemonPublicKeyB64, - } - } catch { - return null - } - } - - return null -} - -function normalizeStoredLifecycle(_lifecycle: unknown): HostLifecycle { - return defaultLifecycle() -} - -function normalizeStoredHostProfile(entry: unknown): HostProfile | null { - if (!entry || typeof entry !== 'object') { - return null - } - const record = entry as Record - const serverId = typeof record.serverId === 'string' ? record.serverId.trim() : '' - if (!serverId) { - return null - } - - const rawConnections = Array.isArray(record.connections) ? record.connections : [] - const connections = rawConnections - .map((connection) => normalizeStoredConnection(connection)) - .filter((connection): connection is HostConnection => connection !== null) - if (connections.length === 0) { - return null - } - - const lifecycle = normalizeStoredLifecycle(record.lifecycle) - const now = new Date().toISOString() - const label = normalizeHostLabel( - typeof record.label === 'string' ? record.label : null, - serverId - ) - const preferredConnectionId = - typeof record.preferredConnectionId === 'string' && - connections.some((connection) => connection.id === record.preferredConnectionId) - ? record.preferredConnectionId - : connections[0]?.id ?? null - - return { - serverId, - label, - lifecycle, - connections, - preferredConnectionId, - createdAt: typeof record.createdAt === 'string' ? record.createdAt : now, - updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt : now, - } -} - -function hostConnectionEquals(left: HostConnection, right: HostConnection): boolean { - if (left.type !== right.type || left.id !== right.id) { - return false - } - - if (left.type === 'directTcp' && right.type === 'directTcp') { - return left.endpoint === right.endpoint - } - if (left.type === 'directSocket' && right.type === 'directSocket') { - return left.path === right.path - } - if (left.type === 'directPipe' && right.type === 'directPipe') { - return left.path === right.path - } - if (left.type === 'relay' && right.type === 'relay') { - return ( - left.relayEndpoint === right.relayEndpoint && - left.daemonPublicKeyB64 === right.daemonPublicKeyB64 - ) - } - - return false -} - -function hostLifecycleEquals(left: HostLifecycle, right: HostLifecycle): boolean { - return JSON.stringify(left) === JSON.stringify(right) -} - -function upsertHostConnectionInProfiles(input: { - profiles: HostProfile[] - serverId: string - label?: string - connection: HostConnection - now?: string -}): HostProfile[] { - const serverId = input.serverId.trim() - if (!serverId) { - throw new Error('serverId is required') - } - - const now = input.now ?? new Date().toISOString() - const labelTrimmed = input.label?.trim() ?? '' - const derivedLabel = labelTrimmed || serverId - const existing = input.profiles - const idx = existing.findIndex((daemon) => daemon.serverId === serverId) - - if (idx === -1) { - const profile: HostProfile = { - serverId, - label: derivedLabel, - lifecycle: defaultLifecycle(), - connections: [input.connection], - preferredConnectionId: input.connection.id, - createdAt: now, - updatedAt: now, - } - return [...existing, profile] - } - - const prev = existing[idx]! - const connectionIdx = prev.connections.findIndex((connection) => connection.id === input.connection.id) - const hadConnection = connectionIdx !== -1 - const connectionChanged = - connectionIdx === -1 - ? true - : !hostConnectionEquals(prev.connections[connectionIdx]!, input.connection) - const nextConnections = - connectionIdx === -1 - ? [...prev.connections, input.connection] - : connectionChanged - ? prev.connections.map((connection, index) => - index === connectionIdx ? input.connection : connection - ) - : prev.connections - - const nextLifecycle = prev.lifecycle - const nextLabel = labelTrimmed ? labelTrimmed : prev.label - const nextPreferredConnectionId = prev.preferredConnectionId ?? input.connection.id - const changed = - nextLabel !== prev.label || - nextPreferredConnectionId !== prev.preferredConnectionId || - !hostLifecycleEquals(prev.lifecycle, nextLifecycle) || - !hadConnection || - connectionChanged - - if (!changed) { - return existing - } - - const nextProfile: HostProfile = { - ...prev, - label: nextLabel, - lifecycle: nextLifecycle, - connections: nextConnections, - preferredConnectionId: nextPreferredConnectionId, - updatedAt: now, - } - - const next = [...existing] - next[idx] = nextProfile - return next -} - -export function hostHasDirectEndpoint(host: HostProfile, endpoint: string): boolean { - const normalized = normalizeEndpointOrNull(endpoint) - if (!normalized) { - return false - } - return host.connections.some( - (connection) => connection.type === 'directTcp' && connection.endpoint === normalized - ) -} - -export function registryHasDirectEndpoint(hosts: HostProfile[], endpoint: string): boolean { - return hosts.some((host) => hostHasDirectEndpoint(host, endpoint)) -} - -export function useDaemonRegistry(): DaemonRegistryContextValue { - const ctx = useContext(DaemonRegistryContext) - if (!ctx) { - throw new Error('useDaemonRegistry must be used within DaemonRegistryProvider') - } - return ctx -} - -export function DaemonRegistryProvider({ children }: { children: ReactNode }) { - const queryClient = useQueryClient() - const desktopStartupAttemptedRef = useRef(false) - const localhostBootstrapAttemptedRef = useRef(false) - const { - data: daemons = [], - isPending, - error, - } = useQuery({ - queryKey: DAEMON_REGISTRY_QUERY_KEY, - queryFn: loadDaemonRegistryFromStorage, - staleTime: Infinity, - gcTime: Infinity, - }) - - const persist = useCallback( - async (profiles: HostProfile[]) => { - queryClient.setQueryData(DAEMON_REGISTRY_QUERY_KEY, profiles) - await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(profiles)) - }, - [queryClient] - ) - - const readDaemons = useCallback(() => { - return queryClient.getQueryData(DAEMON_REGISTRY_QUERY_KEY) ?? daemons - }, [queryClient, daemons]) - - const updateHost = useCallback( - async (serverId: string, updates: UpdateHostInput) => { - const next = readDaemons().map((daemon) => - daemon.serverId === serverId - ? { - ...daemon, - ...updates, - updatedAt: new Date().toISOString(), - } - : daemon - ) - await persist(next) - }, - [persist, readDaemons] - ) - - const removeHost = useCallback( - async (serverId: string) => { - const existing = readDaemons() - const remaining = existing.filter((daemon) => daemon.serverId !== serverId) - await persist(remaining) - }, - [persist, readDaemons] - ) - - const removeConnection = useCallback( - async (serverId: string, connectionId: string) => { - const existing = readDaemons() - const now = new Date().toISOString() - const next = existing - .map((daemon) => { - if (daemon.serverId !== serverId) return daemon - const remaining = daemon.connections.filter((conn) => conn.id !== connectionId) - if (remaining.length === 0) { - return null - } - const preferred = - daemon.preferredConnectionId === connectionId - ? (remaining[0]?.id ?? null) - : daemon.preferredConnectionId - return { - ...daemon, - connections: remaining, - preferredConnectionId: preferred, - updatedAt: now, - } satisfies HostProfile - }) - .filter((entry): entry is HostProfile => entry !== null) - await persist(next) - }, - [persist, readDaemons] - ) - - const upsertHostConnection = useCallback( - async (input: { - serverId: string - label?: string - connection: HostConnection - }) => { - const now = new Date().toISOString() - const next = upsertHostConnectionInProfiles({ - profiles: readDaemons(), - serverId: input.serverId, - label: input.label, - connection: input.connection, - now, - }) - await persist(next) - return next.find((daemon) => daemon.serverId === input.serverId) as HostProfile - }, - [persist, readDaemons] - ) - - const upsertDirectConnection = useCallback( - async (input: { serverId: string; endpoint: string; label?: string }) => { - const endpoint = normalizeHostPort(input.endpoint) - return upsertHostConnection({ - serverId: input.serverId, - label: input.label, - connection: { - id: `direct:${endpoint}`, - type: 'directTcp', - endpoint, - }, - }) - }, - [upsertHostConnection] - ) - - useEffect(() => { - if (isPending) return - if (!shouldUseManagedDesktopDaemon()) return - if (desktopStartupAttemptedRef.current) return - desktopStartupAttemptedRef.current = true - - let cancelled = false - - const bootstrapDesktopStartup = async () => { - const t0 = performance.now() - const elapsed = () => Math.round(performance.now() - t0) - try { - const isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY) - if (cancelled || isE2E) { - return - } - - console.info('[DaemonRegistry] desktop bootstrap: starting', { elapsedMs: elapsed() }) - await Promise.allSettled([ - (async () => { - console.info('[DaemonRegistry] desktop bootstrap: startManagedDaemon', { elapsedMs: elapsed() }) - const daemon = await startManagedDaemon() - console.info('[DaemonRegistry] desktop bootstrap: managed daemon ready', { elapsedMs: elapsed(), serverId: daemon.serverId }) - if (cancelled || !daemon.serverId) { - return - } - - const connection = connectionFromListen(daemon.listen) - if (!connection) { - return - } - - await upsertHostConnection({ - serverId: daemon.serverId, - label: daemon.hostname ?? undefined, - connection, - }) - console.info('[DaemonRegistry] desktop bootstrap: host connection upserted', { elapsedMs: elapsed() }) - })().catch((managedBootstrapError) => { - if (!cancelled) { - console.warn( - '[DaemonRegistry] Failed to bootstrap desktop daemon connection', - { elapsedMs: elapsed() }, - managedBootstrapError - ) - } - }), - (async () => { - console.info('[DaemonRegistry] desktop bootstrap: probing localhost', { elapsedMs: elapsed() }) - const { serverId, hostname } = await probeConnection( - { - id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`, - type: 'directTcp', - endpoint: DEFAULT_LOCALHOST_ENDPOINT, - }, - { timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS } - ) - console.info('[DaemonRegistry] desktop bootstrap: localhost probe succeeded', { elapsedMs: elapsed(), serverId }) - if (cancelled) { - return - } - - await upsertDirectConnection({ - serverId, - endpoint: DEFAULT_LOCALHOST_ENDPOINT, - label: hostname ?? undefined, - }) - console.info('[DaemonRegistry] desktop bootstrap: direct connection upserted', { elapsedMs: elapsed() }) - })().catch(() => { - console.info('[DaemonRegistry] desktop bootstrap: localhost probe failed', { elapsedMs: elapsed() }) - }), - ]) - } catch (bootstrapError) { - if (cancelled) return - console.warn( - '[DaemonRegistry] Failed to bootstrap desktop startup host connections', - bootstrapError - ) - } - } - - void bootstrapDesktopStartup() - - return () => { - cancelled = true - } - }, [isPending, upsertDirectConnection, upsertHostConnection]) - - useEffect(() => { - if (isPending) return - if (shouldUseManagedDesktopDaemon()) return - if (localhostBootstrapAttemptedRef.current) return - localhostBootstrapAttemptedRef.current = true - - let cancelled = false - - const bootstrapLocalhost = async () => { - try { - const isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY) - if (cancelled || isE2E) { - return - } - - const alreadyHandled = await AsyncStorage.getItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY) - if (cancelled || alreadyHandled) { - return - } - - const existing = readDaemons() - if (registryHasDirectEndpoint(existing, DEFAULT_LOCALHOST_ENDPOINT)) { - await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1') - return - } - - try { - const { serverId, hostname } = await probeConnection( - { - id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`, - type: 'directTcp', - endpoint: DEFAULT_LOCALHOST_ENDPOINT, - }, - { timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS } - ) - if (cancelled) return - - await upsertDirectConnection({ - serverId, - endpoint: DEFAULT_LOCALHOST_ENDPOINT, - label: hostname ?? undefined, - }) - await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, '1') - } catch { - // Best-effort bootstrap only; keep startup resilient if localhost isn't reachable. - } - } catch (bootstrapError) { - if (cancelled) return - console.warn('[DaemonRegistry] Failed to bootstrap host connections', bootstrapError) - } - } - - void bootstrapLocalhost() - - return () => { - cancelled = true - } - }, [isPending, readDaemons, upsertDirectConnection]) - - const upsertRelayConnection = useCallback( - async (input: { - serverId: string - relayEndpoint: string - daemonPublicKeyB64: string - label?: string - }) => { - const relayEndpoint = normalizeHostPort(input.relayEndpoint) - const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim() - if (!daemonPublicKeyB64) { - throw new Error('daemonPublicKeyB64 is required') - } - return upsertHostConnection({ - serverId: input.serverId, - label: input.label, - connection: { - id: `relay:${relayEndpoint}`, - type: 'relay', - relayEndpoint, - daemonPublicKeyB64, - }, - }) - }, - [upsertHostConnection] - ) - - const upsertDaemonFromOffer = useCallback( - async (offer: ConnectionOffer) => { - return upsertRelayConnection({ - serverId: offer.serverId, - relayEndpoint: offer.relay.endpoint, - daemonPublicKeyB64: offer.daemonPublicKeyB64, - }) - }, - [upsertRelayConnection] - ) - - const upsertDaemonFromOfferUrl = useCallback( - async (offerUrlOrFragment: string) => { - const marker = '#offer=' - const idx = offerUrlOrFragment.indexOf(marker) - if (idx === -1) { - throw new Error('Missing #offer= fragment') - } - const encoded = offerUrlOrFragment.slice(idx + marker.length).trim() - if (!encoded) { - throw new Error('Offer payload is empty') - } - const payload = decodeOfferFragmentPayload(encoded) - const offer = ConnectionOfferSchema.parse(payload) - return upsertDaemonFromOffer(offer) - }, - [upsertDaemonFromOffer] - ) - - const value: DaemonRegistryContextValue = { - daemons, - isLoading: isPending, - error: error ?? null, - upsertDirectConnection, - upsertRelayConnection, - updateHost, - removeHost, - removeConnection, - upsertDaemonFromOffer, - upsertDaemonFromOfferUrl, - } - - return {children} -} - -async function loadDaemonRegistryFromStorage(): Promise { - try { - const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY) - if (!stored) { - return [] - } - - const parsed = JSON.parse(stored) as unknown - if (!Array.isArray(parsed)) { - return [] - } - - return parsed - .map((entry) => normalizeStoredHostProfile(entry)) - .filter((entry): entry is HostProfile => entry !== null) - } catch (error) { - console.error('[DaemonRegistry] Failed to load daemon registry', error) - throw error - } -} diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 441448d73..7d6b0c722 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -1100,6 +1100,19 @@ function SessionProviderInternal({ if (message.type !== "status") return; const serverInfo = parseServerInfoStatusPayload(message.payload); if (serverInfo) { + console.info( + `[Session] server_info received ${JSON.stringify({ + serverId, + payloadServerId: serverInfo.serverId, + hostname: serverInfo.hostname, + version: serverInfo.version, + capabilitiesPresent: Boolean(serverInfo.capabilities), + dictationCapabilityEnabled: + serverInfo.capabilities?.voice?.dictation?.enabled ?? null, + dictationCapabilityReason: + serverInfo.capabilities?.voice?.dictation?.reason ?? null, + })}` + ); updateSessionServerInfo(serverId, { serverId: serverInfo.serverId, hostname: serverInfo.hostname, diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 63ece2c50..40ec499f3 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -9,7 +9,7 @@ import type { AgentModelDefinition, AgentProvider, } from "@server/server/agent/agent-sdk-types"; -import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; +import { useHosts } from "@/runtime/host-runtime"; import { useHostRuntimeSession } from "@/runtime/host-runtime"; import { useFormPreferences, type FormPreferences } from "./use-form-preferences"; @@ -327,7 +327,7 @@ export function useAgentFormState( updateProviderPreferences, } = useFormPreferences(); - const { daemons } = useDaemonRegistry(); + const daemons = useHosts(); // Build a set of valid server IDs for preference validation const validServerIds = useMemo( diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index 6b9934260..176fee769 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -1,10 +1,9 @@ import { useMemo, useCallback, useSyncExternalStore } from "react"; import { useShallow } from "zustand/shallow"; -import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; import { useSessionStore } from "@/stores/session-store"; import type { AgentDirectoryEntry } from "@/types/agent-directory"; import type { Agent } from "@/stores/session-store"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; export interface AggregatedAgent extends AgentDirectoryEntry { serverId: string; @@ -22,7 +21,7 @@ export interface AggregatedAgentsResult { export function useAggregatedAgents(options?: { includeArchived?: boolean; }): AggregatedAgentsResult { - const { daemons } = useDaemonRegistry(); + const daemons = useHosts(); const runtime = getHostRuntimeStore(); const includeArchived = options?.includeArchived ?? false; const runtimeVersion = useSyncExternalStore( diff --git a/packages/app/src/hooks/use-all-agents-list.ts b/packages/app/src/hooks/use-all-agents-list.ts index 914941480..5c4ab7dce 100644 --- a/packages/app/src/hooks/use-all-agents-list.ts +++ b/packages/app/src/hooks/use-all-agents-list.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo } from "react"; -import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; +import { useHosts } from "@/runtime/host-runtime"; import { useSessionStore, type Agent } from "@/stores/session-store"; import { getHostRuntimeStore, @@ -74,7 +74,7 @@ export function useAllAgentsList(options?: { serverId?: string | null; includeArchived?: boolean; }): AggregatedAgentsResult { - const { daemons } = useDaemonRegistry(); + const daemons = useHosts(); const runtime = getHostRuntimeStore(); const serverId = useMemo(() => { diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 56aae64f5..f220550fa 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -3,7 +3,7 @@ import type { TextInput } from "react-native"; import { router, usePathname, type Href } from "expo-router"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; -import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; +import { useHosts } from "@/runtime/host-runtime"; import { useAllAgentsList } from "@/hooks/use-all-agents-list"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { @@ -104,7 +104,7 @@ export type CommandCenterItem = export function useCommandCenter() { const pathname = usePathname(); - const { daemons } = useDaemonRegistry(); + const daemons = useHosts(); const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen); const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen); const inputRef = useRef(null); diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 2b983f76e..4de92ac57 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -5,7 +5,7 @@ import type { FetchAgentsEntry, FetchAgentsOptions, } from "@server/client/daemon-client"; -import type { HostConnection, HostProfile } from "@/contexts/daemon-registry-context"; +import type { HostConnection, HostProfile } from "@/types/host-connection"; import { useSessionStore } from "@/stores/session-store"; import { HostRuntimeController, @@ -72,6 +72,10 @@ class FakeDaemonClient { }); } + async ping(): Promise<{ rttMs: number }> { + return { rttMs: 0 }; + } + setConnectionState(next: ConnectionState): void { this.state = next; if (next.status === "disconnected") { @@ -194,7 +198,7 @@ function makeDeps( createdClients.push(client); return client as unknown as DaemonClient; }, - measureLatency: async ({ connection }) => { + connectToDaemon: async ({ host, connection }) => { const value = latencyByConnectionId[connection.id]; if (value instanceof Error) { throw value; @@ -202,7 +206,16 @@ function makeDeps( if (typeof value !== "number") { throw new Error(`missing latency for ${connection.id}`); } - return value; + const client = new FakeDaemonClient(); + client.connectCalls = 1; + client.setConnectionState({ status: "connected" }); + client.ping = async () => ({ rttMs: value }); + createdClients.push(client); + return { + client: client as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }; }, getClientId: async () => "cid_test_runtime", }; @@ -222,8 +235,24 @@ function createDeferred() { }; } +function makeConnectedProbeClient(latencyMs: number): FakeDaemonClient { + const client = new FakeDaemonClient(); + client.connectCalls = 1; + client.setConnectionState({ status: "connected" }); + client.ping = async () => ({ rttMs: latencyMs }); + return client; +} + +function clearProbeBackoff(controller: HostRuntimeController): void { + ( + controller as unknown as { + connectionLastProbedAt: Map; + } + ).connectionLastProbedAt.clear(); +} + describe("HostRuntimeController", () => { - it("keeps known hosts in connecting when client reports idle during connect", async () => { + it("keeps known hosts in connecting when a created client reports idle during connect", async () => { const host = makeHost({ connections: [ { @@ -236,7 +265,7 @@ describe("HostRuntimeController", () => { const idleClient = new FakeDaemonClient(); const deps: HostRuntimeControllerDeps = { createClient: () => idleClient as unknown as DaemonClient, - measureLatency: async () => { + connectToDaemon: async () => { throw new Error("probe unavailable"); }, getClientId: async () => "cid_test_runtime", @@ -251,7 +280,11 @@ describe("HostRuntimeController", () => { // Intentionally do not emit a connected state; stay in idle. }; - await controller.start({ autoProbe: false }); + await ( + controller as unknown as { + switchToConnection: (input: { connectionId: string }) => Promise; + } + ).switchToConnection({ connectionId: "direct:lan:6767" }); expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767"); expect(controller.getSnapshot().connectionStatus).toBe("connecting"); @@ -277,18 +310,24 @@ describe("HostRuntimeController", () => { seenClientIds.push(clientId); return fakeClient as unknown as DaemonClient; }, - measureLatency: async () => 10, + connectToDaemon: async () => { + throw new Error("probe unavailable"); + }, getClientId: async () => "cid_runtime_stable", }, }); - await controller.start({ autoProbe: false }); + await ( + controller as unknown as { + switchToConnection: (input: { connectionId: string }) => Promise; + } + ).switchToConnection({ connectionId: "direct:lan:6767" }); expect(seenClientIds).toEqual(["cid_runtime_stable"]); expect(controller.getSnapshot().connectionStatus).toBe("online"); }); - it("selects the lowest-latency connection on startup", async () => { + it("adopts the first successful probe on startup", async () => { const host = makeHost({ preferredConnectionId: "direct:lan:6767" }); const clients: FakeDaemonClient[] = []; const latencies: Record = { @@ -303,10 +342,62 @@ describe("HostRuntimeController", () => { await controller.start({ autoProbe: false }); const snapshot = controller.getSnapshot(); - expect(snapshot.activeConnectionId).toBe("relay:relay.paseo.sh:443"); + expect(snapshot.activeConnectionId).toBe("direct:lan:6767"); expect(snapshot.connectionStatus).toBe("online"); - expect(clients).toHaveLength(1); + expect(clients).toHaveLength(2); + expect(snapshot.client).toBe(clients[0] as unknown as DaemonClient); expect(clients[0]?.connectCalls).toBe(1); + expect(clients[1]?.closeCalls).toBe(1); + }); + + it("activates the first successful probe without waiting for slower probes", async () => { + const host = makeHost({ preferredConnectionId: "direct:lan:6767" }); + const slowPing = createDeferred(); + const clients: FakeDaemonClient[] = []; + + const controller = new HostRuntimeController({ + host, + deps: { + createClient: () => { + throw new Error("should adopt the probe client"); + }, + connectToDaemon: async ({ host, connection }) => { + const client = makeConnectedProbeClient( + connection.id === "direct:lan:6767" ? 12 : 30 + ); + if (connection.id === "relay:relay.paseo.sh:443") { + client.ping = async () => ({ rttMs: await slowPing.promise }); + } + clients.push(client); + return { + client: client as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }; + }, + getClientId: async () => "cid_test_runtime", + }, + }); + + const probeCycle = controller.runProbeCycleNow(); + + const timeoutAt = Date.now() + 200; + while (Date.now() < timeoutAt) { + const snapshot = controller.getSnapshot(); + if ( + snapshot.activeConnectionId === "direct:lan:6767" && + snapshot.connectionStatus === "online" + ) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767"); + expect(controller.getSnapshot().connectionStatus).toBe("online"); + + slowPing.resolve(30); + await probeCycle; }); it("fails over when active connection becomes unavailable", async () => { @@ -323,17 +414,19 @@ describe("HostRuntimeController", () => { await controller.start({ autoProbe: false }); expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767"); - expect(clients).toHaveLength(1); + const initialClient = controller.getSnapshot().client; + expect(initialClient).toBeTruthy(); latencies["direct:lan:6767"] = new Error("direct unavailable"); latencies["relay:relay.paseo.sh:443"] = 42; + clearProbeBackoff(controller); await controller.runProbeCycleNow(); const snapshot = controller.getSnapshot(); expect(snapshot.activeConnectionId).toBe("relay:relay.paseo.sh:443"); expect(snapshot.connectionStatus).toBe("online"); - expect(clients).toHaveLength(2); - expect(clients[0]?.closeCalls).toBe(1); + expect(snapshot.client).not.toBe(initialClient); + expect((initialClient as unknown as FakeDaemonClient | null)?.closeCalls).toBe(1); }); it("switches only after the faster alternative wins consecutive probes", async () => { @@ -353,15 +446,24 @@ describe("HostRuntimeController", () => { latencies["direct:lan:6767"] = 95; latencies["relay:relay.paseo.sh:443"] = 30; + clearProbeBackoff(controller); await controller.runProbeCycleNow(); expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767"); + clearProbeBackoff(controller); await controller.runProbeCycleNow(); expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767"); - await controller.runProbeCycleNow(); - expect(controller.getSnapshot().activeConnectionId).toBe("relay:relay.paseo.sh:443"); - expect(clients).toHaveLength(2); + let switched = controller.getSnapshot().activeConnectionId === "relay:relay.paseo.sh:443"; + for (let index = 0; index < 6 && !switched; index += 1) { + clearProbeBackoff(controller); + await controller.runProbeCycleNow(); + switched = + controller.getSnapshot().activeConnectionId === + "relay:relay.paseo.sh:443"; + } + expect(switched).toBe(true); + expect(controller.getSnapshot().client).not.toBeNull(); }); it("does not switch on a transient latency spike", async () => { @@ -381,24 +483,35 @@ describe("HostRuntimeController", () => { latencies["direct:lan:6767"] = 100; latencies["relay:relay.paseo.sh:443"] = 20; + clearProbeBackoff(controller); await controller.runProbeCycleNow(); expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767"); latencies["direct:lan:6767"] = 20; latencies["relay:relay.paseo.sh:443"] = 90; + clearProbeBackoff(controller); await controller.runProbeCycleNow(); expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767"); latencies["direct:lan:6767"] = 100; latencies["relay:relay.paseo.sh:443"] = 20; + clearProbeBackoff(controller); await controller.runProbeCycleNow(); expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767"); + clearProbeBackoff(controller); await controller.runProbeCycleNow(); expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767"); - await controller.runProbeCycleNow(); - expect(controller.getSnapshot().activeConnectionId).toBe("relay:relay.paseo.sh:443"); + let switched = controller.getSnapshot().activeConnectionId === "relay:relay.paseo.sh:443"; + for (let index = 0; index < 6 && !switched; index += 1) { + clearProbeBackoff(controller); + await controller.runProbeCycleNow(); + switched = + controller.getSnapshot().activeConnectionId === + "relay:relay.paseo.sh:443"; + } + expect(switched).toBe(true); }); it("exposes one snapshot with active connection and status from same source", async () => { @@ -604,7 +717,11 @@ describe("HostRuntimeController", () => { createdClients.push(client); return client as unknown as DaemonClient; }, - measureLatency: async () => 10, + connectToDaemon: async ({ host }) => ({ + client: makeConnectedProbeClient(10) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }), getClientId: async () => "cid_test_runtime", }; const controller = new HostRuntimeController({ @@ -681,21 +798,32 @@ describe("HostRuntimeController", () => { host, deps: { createClient: () => new FakeDaemonClient() as unknown as DaemonClient, - measureLatency: async () => { + connectToDaemon: async ({ host }) => { probeCalls += 1; - if (probeCalls === 1) { - return await slowProbe.promise; - } - if (probeCalls === 2) { - return await fastProbe.promise; - } - throw new Error("unexpected probe call"); + const client = new FakeDaemonClient(); + client.connectCalls = 1; + client.setConnectionState({ status: "connected" }); + client.ping = async () => { + if (probeCalls === 1) { + return { rttMs: await slowProbe.promise }; + } + if (probeCalls === 2) { + return { rttMs: await fastProbe.promise }; + } + throw new Error("unexpected probe call"); + }; + return { + client: client as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }; }, getClientId: async () => "cid_test_runtime", }, }); const first = controller.runProbeCycleNow(); + clearProbeBackoff(controller); const second = controller.runProbeCycleNow(); fastProbe.resolve(12); @@ -719,7 +847,7 @@ describe("HostRuntimeController", () => { }); }); - it("keeps active client generation stable while overlapping probes run", async () => { + it("keeps active client generation stable during background probe cycles", async () => { const host = makeHost({ connections: [ { @@ -729,10 +857,7 @@ describe("HostRuntimeController", () => { }, ], }); - const slowProbe = createDeferred(); - const fastProbe = createDeferred(); const createdClients: FakeDaemonClient[] = []; - let probeCalls = 0; const controller = new HostRuntimeController({ host, @@ -742,18 +867,13 @@ describe("HostRuntimeController", () => { createdClients.push(client); return client as unknown as DaemonClient; }, - measureLatency: async () => { - probeCalls += 1; - if (probeCalls === 1) { - return 10; - } - if (probeCalls === 2) { - return await slowProbe.promise; - } - if (probeCalls === 3) { - return await fastProbe.promise; - } - return 10; + connectToDaemon: async ({ host }) => { + const client = makeConnectedProbeClient(10); + return { + client: client as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }; }, getClientId: async () => "cid_test_runtime", }, @@ -763,24 +883,13 @@ describe("HostRuntimeController", () => { const activeClientBeforeProbes = controller.getSnapshot().client; const generationBeforeProbes = controller.getSnapshot().clientGeneration; - const first = controller.runProbeCycleNow(); - const second = controller.runProbeCycleNow(); - - fastProbe.resolve(12); - await second; + clearProbeBackoff(controller); + await controller.runProbeCycleNow(); expect(controller.getSnapshot().client).toBe(activeClientBeforeProbes); expect(controller.getSnapshot().clientGeneration).toBe( generationBeforeProbes ); - - slowProbe.resolve(999); - await first; - expect(controller.getSnapshot().client).toBe(activeClientBeforeProbes); - expect(controller.getSnapshot().clientGeneration).toBe( - generationBeforeProbes - ); - expect(createdClients).toHaveLength(1); - expect(createdClients[0]?.closeCalls).toBe(0); + expect(createdClients).toHaveLength(0); }); }); @@ -796,10 +905,15 @@ describe("HostRuntimeStore", () => { ], }); const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); const store = new HostRuntimeStore({ deps: { createClient: () => fakeClient as unknown as DaemonClient, - measureLatency: async () => 5, + connectToDaemon: async ({ host }) => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }), getClientId: async () => "cid_test_runtime", }, }); @@ -832,9 +946,9 @@ describe("HostRuntimeStore", () => { useSessionStore.getState().clearSession(host.serverId); }); - it("defers directory bootstrap until session store is initialized for that server", async () => { + it("bootstraps agent directory immediately when connection goes online (no session required)", async () => { const host = makeHost({ - serverId: "srv_deferred", + serverId: "srv_no_session", connections: [ { id: "direct:lan:6767", @@ -844,26 +958,22 @@ describe("HostRuntimeStore", () => { ], }); const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); const store = new HostRuntimeStore({ deps: { createClient: () => fakeClient as unknown as DaemonClient, - measureLatency: async () => 5, + connectToDaemon: async ({ host }) => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }), getClientId: async () => "cid_test_runtime", }, }); store.syncHosts([host]); - await new Promise((resolve) => setTimeout(resolve, 250)); - expect(fakeClient.fetchAgentsCalls).toHaveLength(0); - - useSessionStore.getState().initializeSession( - host.serverId, - fakeClient as unknown as DaemonClient, - null as any - ); - - const timeoutAt = Date.now() + 600; + const timeoutAt = Date.now() + 200; while (fakeClient.fetchAgentsCalls.length === 0 && Date.now() < timeoutAt) { await new Promise((resolve) => setTimeout(resolve, 0)); } @@ -872,12 +982,11 @@ describe("HostRuntimeStore", () => { expect(fakeClient.fetchAgentsCalls[0]).toEqual({ filter: { includeArchived: true }, sort: [{ key: "updated_at", direction: "desc" }], - subscribe: { subscriptionId: "app:srv_deferred" }, + subscribe: { subscriptionId: "app:srv_no_session" }, page: { limit: 200 }, }); store.syncHosts([]); - useSessionStore.getState().clearSession(host.serverId); }); it("fetches all pages during bootstrap so older workspace agents are present", async () => { @@ -892,6 +1001,7 @@ describe("HostRuntimeStore", () => { ], }); const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); fakeClient.fetchAgentsResponses.push( makeFetchAgentsPayload({ entries: [ @@ -923,7 +1033,11 @@ describe("HostRuntimeStore", () => { const store = new HostRuntimeStore({ deps: { createClient: () => fakeClient as unknown as DaemonClient, - measureLatency: async () => 5, + connectToDaemon: async ({ host }) => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }), getClientId: async () => "cid_test_runtime", }, }); @@ -991,10 +1105,15 @@ describe("HostRuntimeStore", () => { ], }); const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); const store = new HostRuntimeStore({ deps: { createClient: () => fakeClient as unknown as DaemonClient, - measureLatency: async () => 5, + connectToDaemon: async ({ host }) => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }), getClientId: async () => "cid_test_runtime", }, }); @@ -1041,7 +1160,7 @@ describe("HostRuntimeStore", () => { useSessionStore.getState().clearSession(host.serverId); }); - it("surfaces startup failures as error instead of leaving host idle", async () => { + it("records unavailable startup probes when no connection can be established", async () => { const host = makeHost({ connections: [ { @@ -1056,7 +1175,7 @@ describe("HostRuntimeStore", () => { createClient: () => { throw new Error("create client failed"); }, - measureLatency: async () => { + connectToDaemon: async () => { throw new Error("probe unavailable"); }, getClientId: async () => "cid_test_runtime", @@ -1066,12 +1185,51 @@ describe("HostRuntimeStore", () => { store.syncHosts([host]); let snapshot = store.getSnapshot(host.serverId); const timeoutAt = Date.now() + 100; - while (snapshot?.connectionStatus !== "error" && Date.now() < timeoutAt) { + while ( + snapshot?.probeByConnectionId.get("direct:lan:6767")?.status !== "unavailable" && + Date.now() < timeoutAt + ) { await new Promise((resolve) => setTimeout(resolve, 0)); snapshot = store.getSnapshot(host.serverId); } - expect(snapshot?.connectionStatus).toBe("error"); - expect(snapshot?.lastError).toBe("create client failed"); + expect(snapshot?.connectionStatus).toBe("connecting"); + expect(snapshot?.lastError).toBeNull(); + expect(snapshot?.probeByConnectionId.get("direct:lan:6767")).toEqual({ + status: "unavailable", + latencyMs: null, + }); + }); + + it("renameHost updates label in memory", async () => { + const store = new HostRuntimeStore({ + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host }) => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + // upsertDirectConnection goes through setHostsAndSync, which both sets + // this.hosts and syncs controllers — matching the real init path. + await store.upsertDirectConnection({ + serverId: "srv_rename", + endpoint: "lan:6767", + label: "old name", + }); + expect(store.getHosts().find((h) => h.serverId === "srv_rename")?.label).toBe("old name"); + + // persistHosts may throw in test env (no AsyncStorage/window), but the + // in-memory state should still be updated by setHostsAndSync. + await store.renameHost("srv_rename", "new name").catch(() => undefined); + + const renamed = store.getHosts().find((h) => h.serverId === "srv_rename"); + expect(renamed?.label).toBe("new name"); + + store.syncHosts([]); }); }); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 4c3e66cf7..217cf4d58 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1,16 +1,30 @@ -import { useSyncExternalStore } from "react"; +import { useSyncExternalStore, useCallback, useMemo } from "react"; +import AsyncStorage from "@react-native-async-storage/async-storage"; import { DaemonClient, type ConnectionState, type DaemonClientDiagnosticsEvent, type FetchAgentsOptions, } from "@server/client/daemon-client"; -import type { HostConnection, HostProfile } from "@/contexts/daemon-registry-context"; +import { + connectionFromListen, + normalizeStoredHostProfile, + upsertHostConnectionInProfiles, + registryHasDirectEndpoint, + type HostConnection, + type HostProfile, +} from "@/types/host-connection"; +import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints"; +import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer"; +import { + shouldUseManagedDesktopDaemon, + startManagedDaemon, +} from "@/desktop/managed-runtime/managed-runtime"; +import { connectToDaemon } from "@/utils/test-daemon-connection"; import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl, } from "@/utils/daemon-endpoints"; -import { measureConnectionLatency } from "@/utils/test-daemon-connection"; import { getOrCreateClientId } from "@/utils/client-id"; import { selectBestConnection, @@ -132,15 +146,23 @@ export type HostRuntimeControllerDeps = { clientId: string; runtimeGeneration: number; }) => DaemonClient; - measureLatency: (input: { + connectToDaemon: (input: { host: HostProfile; connection: HostConnection; - }) => Promise; + }) => Promise<{ + client: DaemonClient; + serverId: string; + hostname: string | null; + }>; getClientId: () => Promise; }; export type HostRuntimeStartOptions = { autoProbe?: boolean; + initialConnection?: { + connectionId: string; + existingClient: DaemonClient; + }; }; const PROBE_TICK_MS = 2_000; @@ -149,7 +171,7 @@ const PROBE_MAX_BACKOFF_MS = 30_000; const ADAPTIVE_SWITCH_THRESHOLD_MS = 40; const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3; const DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT = 200; -const AGENT_DIRECTORY_SESSION_RETRY_MS = 150; + const DEFAULT_AGENT_DIRECTORY_SORT: NonNullable = [ { key: "updated_at", direction: "desc" }, ]; @@ -493,8 +515,8 @@ function createDefaultDeps(): HostRuntimeControllerDeps { }, }); }, - measureLatency: ({ host, connection }) => - measureConnectionLatency(connection, { serverId: host.serverId }), + connectToDaemon: ({ host, connection }) => + connectToDaemon(connection, { serverId: host.serverId }), getClientId: () => getOrCreateClientId(), }; } @@ -560,6 +582,12 @@ export class HostRuntimeController { } this.started = true; this.trackConnectionFirstSeen(); + if (options?.initialConnection) { + await this.switchToConnection({ + connectionId: options.initialConnection.connectionId, + existingClient: options.initialConnection.existingClient, + }); + } await this.runProbeCycleNow(); if (options?.autoProbe !== false) { this.probeIntervalHandle = setInterval(() => { @@ -645,6 +673,13 @@ export class HostRuntimeController { }); } + async activateConnection(input: { + connectionId: string; + existingClient: DaemonClient; + }): Promise { + await this.switchToConnection(input); + } + async runProbeCycleNow(): Promise { const requestVersion = ++this.probeRequestVersion; if (this.host.connections.length === 0) { @@ -679,73 +714,103 @@ export class HostRuntimeController { } const probeByConnectionId = new Map(this.snapshot.probeByConnectionId); - await Promise.all( - connectionsToProbe.map(async (connection) => { - this.connectionLastProbedAt.set(connection.id, performance.now()); - try { - const latencyMs = await this.deps.measureLatency({ - host: this.host, - connection, + for (const connection of connectionsToProbe) { + this.connectionLastProbedAt.set(connection.id, performance.now()); + probeByConnectionId.set(connection.id, { + status: "pending", + latencyMs: null, + }); + } + this.updateSnapshot({ probeByConnectionId: new Map(probeByConnectionId) }); + + let remaining = connectionsToProbe.length; + let activationLock: Promise | null = null; + + const publishProbeState = (): void => { + if (!this.isCurrentProbeRequest(requestVersion)) { + return; + } + this.updateSnapshot({ probeByConnectionId: new Map(probeByConnectionId) }); + }; + + const maybeActivateFirstAvailable = async ( + connectionId: string, + client: DaemonClient + ): Promise => { + while (!this.snapshot.activeConnectionId) { + if (!activationLock) { + activationLock = this.switchToConnection({ + connectionId, + expectedProbeVersion: requestVersion, + existingClient: client, + }).finally(() => { + activationLock = null; }); - probeByConnectionId.set(connection.id, { - status: "available", - latencyMs, - }); - } catch { - probeByConnectionId.set(connection.id, { - status: "unavailable", - latencyMs: null, + await activationLock; + return this.snapshot.activeConnectionId === connectionId; + } + await activationLock; + } + return false; + }; + + const finalizeProbeCycle = async (): Promise => { + if (remaining > 0 || !this.isCurrentProbeRequest(requestVersion)) { + return; + } + + const currentActiveConnectionId = this.snapshot.activeConnectionId; + const activeProbe = currentActiveConnectionId + ? probeByConnectionId.get(currentActiveConnectionId) + : null; + + if ( + !currentActiveConnectionId || + !findConnectionById(this.host, currentActiveConnectionId) + ) { + const nextConnectionId = selectBestConnection({ + candidates: buildConnectionCandidates(this.host), + probeByConnectionId, + }); + if (nextConnectionId) { + await this.switchToConnection({ + connectionId: nextConnectionId, + expectedProbeVersion: requestVersion, }); } - }) - ); - - if (!this.isCurrentProbeRequest(requestVersion)) { - return; - } - this.updateSnapshot({ probeByConnectionId }); - - const currentActiveConnectionId = this.snapshot.activeConnectionId; - const activeProbe = currentActiveConnectionId - ? probeByConnectionId.get(currentActiveConnectionId) - : null; - - if (!currentActiveConnectionId || !findConnectionById(this.host, currentActiveConnectionId)) { - const nextConnectionId = selectBestConnection({ - candidates: buildConnectionCandidates(this.host), - probeByConnectionId, - }); - if (nextConnectionId) { - await this.switchToConnection({ - connectionId: nextConnectionId, - expectedProbeVersion: requestVersion, - }); + return; } - return; - } - if (activeProbe?.status === "unavailable") { - const nextConnectionId = selectBestConnection({ - candidates: buildConnectionCandidates(this.host), - probeByConnectionId, - }); - if (nextConnectionId && nextConnectionId !== currentActiveConnectionId) { - await this.switchToConnection({ - connectionId: nextConnectionId, - expectedProbeVersion: requestVersion, + if (activeProbe?.status === "unavailable") { + const nextConnectionId = selectBestConnection({ + candidates: buildConnectionCandidates(this.host), + probeByConnectionId, }); + if (nextConnectionId && nextConnectionId !== currentActiveConnectionId) { + await this.switchToConnection({ + connectionId: nextConnectionId, + expectedProbeVersion: requestVersion, + }); + } + this.switchCandidateConnectionId = null; + this.switchCandidateHitCount = 0; + return; + } + + if (!activeProbe || activeProbe.status !== "available") { + return; } - this.switchCandidateConnectionId = null; - this.switchCandidateHitCount = 0; - return; - } - if (activeProbe && activeProbe.status === "available") { const available = Array.from(probeByConnectionId.entries()) - .filter(([, probe]) => probe.status === "available") + .filter( + ( + entry + ): entry is [string, Extract] => + entry[1].status === "available" + ) .map(([connectionId, probe]) => ({ connectionId, - latencyMs: (probe as { status: "available"; latencyMs: number }).latencyMs, + latencyMs: probe.latencyMs, })) .sort((left, right) => left.latencyMs - right.latencyMs); @@ -756,8 +821,7 @@ export class HostRuntimeController { return; } - const activeLatency = activeProbe.latencyMs; - const improvement = activeLatency - fastest.latencyMs; + const improvement = activeProbe.latencyMs - fastest.latencyMs; if (improvement < ADAPTIVE_SWITCH_THRESHOLD_MS) { this.switchCandidateConnectionId = null; this.switchCandidateHitCount = 0; @@ -772,8 +836,7 @@ export class HostRuntimeController { } if ( - this.switchCandidateHitCount >= - ADAPTIVE_SWITCH_CONSECUTIVE_PROBES + this.switchCandidateHitCount >= ADAPTIVE_SWITCH_CONSECUTIVE_PROBES ) { this.switchCandidateConnectionId = null; this.switchCandidateHitCount = 0; @@ -782,7 +845,63 @@ export class HostRuntimeController { expectedProbeVersion: requestVersion, }); } - } + }; + + await new Promise((resolve) => { + const settleProbe = (): void => { + remaining -= 1; + void finalizeProbeCycle().finally(() => { + if (remaining === 0) { + resolve(); + } + }); + }; + + for (const connection of connectionsToProbe) { + void (async () => { + let connectedClient: DaemonClient | null = null; + let handedOffClient = false; + try { + const { client } = await this.deps.connectToDaemon({ + host: this.host, + connection, + }); + connectedClient = client; + + if (!this.isCurrentProbeRequest(requestVersion)) { + return; + } + + const activated = await maybeActivateFirstAvailable(connection.id, client); + handedOffClient = activated; + + const { rttMs } = await client.ping({ timeoutMs: 5000 }); + if (!this.isCurrentProbeRequest(requestVersion)) { + return; + } + + probeByConnectionId.set(connection.id, { + status: "available", + latencyMs: rttMs, + }); + publishProbeState(); + } catch { + if (this.isCurrentProbeRequest(requestVersion)) { + probeByConnectionId.set(connection.id, { + status: "unavailable", + latencyMs: null, + }); + publishProbeState(); + } + } finally { + if (connectedClient && !handedOffClient) { + await connectedClient.close().catch(() => undefined); + } + settleProbe(); + } + })(); + } + }); } private updateSnapshot( @@ -880,13 +999,20 @@ export class HostRuntimeController { private async switchToConnection(input: { connectionId: string; expectedProbeVersion?: number; + existingClient?: DaemonClient; }): Promise { if (!this.canProceedForProbe(input.expectedProbeVersion)) { + if (input.existingClient) { + await input.existingClient.close().catch(() => undefined); + } return; } - const { connectionId, expectedProbeVersion } = input; + const { connectionId, expectedProbeVersion, existingClient } = input; const connection = findConnectionById(this.host, connectionId); if (!connection) { + if (existingClient) { + await existingClient.close().catch(() => undefined); + } return; } const requestVersion = ++this.switchRequestVersion; @@ -895,6 +1021,9 @@ export class HostRuntimeController { try { clientId = await this.resolveClientId(); } catch (error) { + if (existingClient) { + await existingClient.close().catch(() => undefined); + } if (!this.isCurrentSwitchRequest(requestVersion)) { return; } @@ -910,9 +1039,15 @@ export class HostRuntimeController { } if (!this.isCurrentSwitchRequest(requestVersion)) { + if (existingClient) { + await existingClient.close().catch(() => undefined); + } return; } if (!this.canProceedForProbe(expectedProbeVersion)) { + if (existingClient) { + await existingClient.close().catch(() => undefined); + } return; } @@ -926,14 +1061,20 @@ export class HostRuntimeController { await previousClient.close().catch(() => undefined); } if (!this.isCurrentSwitchRequest(requestVersion)) { + if (existingClient) { + await existingClient.close().catch(() => undefined); + } return; } if (!this.canProceedForProbe(expectedProbeVersion)) { + if (existingClient) { + await existingClient.close().catch(() => undefined); + } return; } const nextGeneration = this.snapshot.clientGeneration + 1; - const client = this.deps.createClient({ + const client = existingClient ?? this.deps.createClient({ host: this.host, connection, clientId, @@ -1000,7 +1141,9 @@ export class HostRuntimeController { }); try { - await client.connect(); + if (!existingClient) { + await client.connect(); + } } catch (error) { if ( !this.isCurrentSwitchRequest(requestVersion) || @@ -1030,18 +1173,25 @@ export class HostRuntimeController { } } +const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry"; +const DEFAULT_LOCALHOST_ENDPOINT = "localhost:6767"; +const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = "@paseo:default-localhost-bootstrap-v1"; +const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500; +const E2E_STORAGE_KEY = "@paseo:e2e"; + export class HostRuntimeStore { private controllers = new Map(); private serverListeners = new Map void>>(); private globalListeners = new Set<() => void>(); + private hostListListeners = new Set<() => void>(); private version = 0; + private hostListVersion = 0; + private hosts: HostProfile[] = []; private deps: HostRuntimeControllerDeps; private lastConnectionStatusByServer = new Map(); private agentDirectoryBootstrapInFlight = new Map>(); - private agentDirectorySessionRetryTimerByServer = new Map< - string, - ReturnType - >(); + private storageLoaded = false; + private bootstrapAttempted = false; constructor(input?: { deps?: HostRuntimeControllerDeps; @@ -1049,7 +1199,335 @@ export class HostRuntimeStore { this.deps = input?.deps ?? createDefaultDeps(); } - syncHosts(hosts: HostProfile[]): void { + // --- Host registry --- + + getHosts(): HostProfile[] { + return this.hosts; + } + + subscribeHostList(listener: () => void): () => void { + this.hostListListeners.add(listener); + return () => { + this.hostListListeners.delete(listener); + }; + } + + getHostListVersion(): number { + return this.hostListVersion; + } + + async loadFromStorage(): Promise { + if (this.storageLoaded) { + return; + } + this.storageLoaded = true; + try { + const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY); + if (!stored) { + return; + } + const parsed = JSON.parse(stored) as unknown; + if (!Array.isArray(parsed)) { + return; + } + const profiles = parsed + .map((entry) => normalizeStoredHostProfile(entry)) + .filter((entry): entry is HostProfile => entry !== null); + this.hosts = profiles; + this.syncHosts(profiles); + this.emitHostList(); + } catch (error) { + console.error("[HostRuntime] Failed to load host registry from storage", error); + } + } + + async bootstrap(): Promise { + if (this.bootstrapAttempted) { + return; + } + this.bootstrapAttempted = true; + + try { + const isE2E = await AsyncStorage.getItem(E2E_STORAGE_KEY); + if (isE2E) { + return; + } + } catch { + return; + } + + if (shouldUseManagedDesktopDaemon()) { + await this.bootstrapDesktop(); + } else { + await this.bootstrapLocalhost(); + } + } + + private async bootstrapDesktop(): Promise { + const t0 = performance.now(); + const elapsed = () => Math.round(performance.now() - t0); + try { + console.info("[HostRuntime] desktop bootstrap: starting", { elapsedMs: elapsed() }); + await Promise.allSettled([ + (async () => { + console.info("[HostRuntime] desktop bootstrap: startManagedDaemon", { elapsedMs: elapsed() }); + const daemon = await startManagedDaemon(); + console.info("[HostRuntime] desktop bootstrap: managed daemon ready", { elapsedMs: elapsed(), serverId: daemon.serverId }); + if (!daemon.serverId) { + return; + } + const connection = connectionFromListen(daemon.listen); + if (!connection) { + return; + } + await this.upsertHostConnection({ + serverId: daemon.serverId, + label: daemon.hostname ?? undefined, + connection, + }); + console.info("[HostRuntime] desktop bootstrap: host connection upserted", { elapsedMs: elapsed() }); + })().catch((error) => { + console.warn("[HostRuntime] Failed to bootstrap desktop daemon connection", { elapsedMs: elapsed() }, error); + }), + (async () => { + console.info("[HostRuntime] desktop bootstrap: probing localhost", { elapsedMs: elapsed() }); + const { client, serverId, hostname } = await connectToDaemon( + { + id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`, + type: "directTcp", + endpoint: DEFAULT_LOCALHOST_ENDPOINT, + }, + { timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS } + ); + console.info("[HostRuntime] desktop bootstrap: localhost probe succeeded", { elapsedMs: elapsed(), serverId }); + await this.upsertDirectConnection({ + serverId, + endpoint: DEFAULT_LOCALHOST_ENDPOINT, + label: hostname ?? undefined, + existingClient: client, + }); + console.info("[HostRuntime] desktop bootstrap: direct connection upserted", { elapsedMs: elapsed() }); + })().catch(() => { + console.info("[HostRuntime] desktop bootstrap: localhost probe failed", { elapsedMs: elapsed() }); + }), + ]); + } catch (error) { + console.warn("[HostRuntime] Failed to bootstrap desktop startup host connections", error); + } + } + + private async bootstrapLocalhost(): Promise { + try { + const alreadyHandled = await AsyncStorage.getItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY); + if (alreadyHandled) { + return; + } + + if (registryHasDirectEndpoint(this.hosts, DEFAULT_LOCALHOST_ENDPOINT)) { + await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, "1"); + return; + } + + try { + const { client, serverId, hostname } = await connectToDaemon( + { + id: `bootstrap:${DEFAULT_LOCALHOST_ENDPOINT}`, + type: "directTcp", + endpoint: DEFAULT_LOCALHOST_ENDPOINT, + }, + { timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS } + ); + + await this.upsertDirectConnection({ + serverId, + endpoint: DEFAULT_LOCALHOST_ENDPOINT, + label: hostname ?? undefined, + existingClient: client, + }); + await AsyncStorage.setItem(DEFAULT_LOCALHOST_BOOTSTRAP_KEY, "1"); + } catch { + // Best-effort bootstrap only + } + } catch (error) { + console.warn("[HostRuntime] Failed to bootstrap host connections", error); + } + } + + async upsertDirectConnection(input: { + serverId: string; + endpoint: string; + label?: string; + existingClient?: DaemonClient; + }): Promise { + const endpoint = normalizeHostPort(input.endpoint); + return this.upsertHostConnection({ + serverId: input.serverId, + label: input.label, + connection: { + id: `direct:${endpoint}`, + type: "directTcp", + endpoint, + }, + existingClient: input.existingClient, + }); + } + + async upsertRelayConnection(input: { + serverId: string; + relayEndpoint: string; + daemonPublicKeyB64: string; + label?: string; + }): Promise { + const relayEndpoint = normalizeHostPort(input.relayEndpoint); + const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim(); + if (!daemonPublicKeyB64) { + throw new Error("daemonPublicKeyB64 is required"); + } + return this.upsertHostConnection({ + serverId: input.serverId, + label: input.label, + connection: { + id: `relay:${relayEndpoint}`, + type: "relay", + relayEndpoint, + daemonPublicKeyB64, + }, + }); + } + + async upsertConnectionFromOffer(offer: ConnectionOffer): Promise { + return this.upsertRelayConnection({ + serverId: offer.serverId, + relayEndpoint: offer.relay.endpoint, + daemonPublicKeyB64: offer.daemonPublicKeyB64, + }); + } + + async upsertConnectionFromOfferUrl(offerUrlOrFragment: string): Promise { + const marker = "#offer="; + const idx = offerUrlOrFragment.indexOf(marker); + if (idx === -1) { + throw new Error("Missing #offer= fragment"); + } + const encoded = offerUrlOrFragment.slice(idx + marker.length).trim(); + if (!encoded) { + throw new Error("Offer payload is empty"); + } + const payload = decodeOfferFragmentPayload(encoded); + const offer = ConnectionOfferSchema.parse(payload); + return this.upsertConnectionFromOffer(offer); + } + + async renameHost(serverId: string, label: string): Promise { + const next = this.hosts.map((h) => + h.serverId === serverId + ? { ...h, label, updatedAt: new Date().toISOString() } + : h + ); + this.setHostsAndSync(next); + await this.persistHosts(); + } + + async removeHost(serverId: string): Promise { + const remaining = this.hosts.filter((daemon) => daemon.serverId !== serverId); + this.setHostsAndSync(remaining); + await this.persistHosts(); + } + + async removeConnection(serverId: string, connectionId: string): Promise { + const now = new Date().toISOString(); + const next = this.hosts + .map((daemon) => { + if (daemon.serverId !== serverId) return daemon; + const remaining = daemon.connections.filter((conn) => conn.id !== connectionId); + if (remaining.length === 0) { + return null; + } + const preferred = + daemon.preferredConnectionId === connectionId + ? (remaining[0]?.id ?? null) + : daemon.preferredConnectionId; + return { + ...daemon, + connections: remaining, + preferredConnectionId: preferred, + updatedAt: now, + } satisfies HostProfile; + }) + .filter((entry): entry is HostProfile => entry !== null); + this.setHostsAndSync(next); + await this.persistHosts(); + } + + private async upsertHostConnection(input: { + serverId: string; + label?: string; + connection: HostConnection; + existingClient?: DaemonClient; + }): Promise { + const now = new Date().toISOString(); + const next = upsertHostConnectionInProfiles({ + profiles: this.hosts, + serverId: input.serverId, + label: input.label, + connection: input.connection, + now, + }); + this.setHostsAndSync(next, { + initialConnectionByServerId: input.existingClient + ? new Map([ + [ + input.serverId, + { + connectionId: input.connection.id, + existingClient: input.existingClient, + }, + ], + ]) + : undefined, + }); + void this.persistHosts(); + return next.find((daemon) => daemon.serverId === input.serverId) as HostProfile; + } + + private setHostsAndSync( + hosts: HostProfile[], + options?: { + initialConnectionByServerId?: Map< + string, + { connectionId: string; existingClient: DaemonClient } + >; + } + ): void { + this.hosts = hosts; + this.syncHosts(hosts, options); + this.emitHostList(); + } + + private async persistHosts(): Promise { + try { + await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(this.hosts)); + } catch (error) { + console.error("[HostRuntime] Failed to persist host registry", error); + } + } + + private emitHostList(): void { + this.hostListVersion += 1; + for (const listener of this.hostListListeners) { + listener(); + } + } + + syncHosts( + hosts: HostProfile[], + options?: { + initialConnectionByServerId?: Map< + string, + { connectionId: string; existingClient: DaemonClient } + >; + } + ): void { const nextIds = new Set(hosts.map((host) => host.serverId)); for (const [serverId, controller] of this.controllers) { if (nextIds.has(serverId)) { @@ -1058,15 +1536,20 @@ export class HostRuntimeStore { this.controllers.delete(serverId); this.lastConnectionStatusByServer.delete(serverId); this.agentDirectoryBootstrapInFlight.delete(serverId); - this.clearAgentDirectorySessionRetry(serverId); void controller.stop(); this.emit(serverId); } for (const host of hosts) { + const initialConnection = options?.initialConnectionByServerId?.get(host.serverId); const existing = this.controllers.get(host.serverId); if (existing) { void existing.updateHost(host); + if (initialConnection) { + void existing.activateConnection(initialConnection).catch(() => { + void initialConnection.existingClient.close().catch(() => undefined); + }); + } continue; } const controller = new HostRuntimeController({ @@ -1082,7 +1565,13 @@ export class HostRuntimeStore { this.maybeAutoBootstrapAgentDirectory(host.serverId); this.emit(host.serverId); }); - void controller.start().catch((error) => { + void controller.start({ + ...(initialConnection + ? { + initialConnection, + } + : {}), + }).catch((error) => { const message = error instanceof Error ? error.message : String(error); controller.markStartupError(message); }); @@ -1109,21 +1598,14 @@ export class HostRuntimeStore { // Runtime owns directory bootstrap policy, including reconnect and delayed // session initialization races. if (snapshot.connectionStatus !== "online") { - this.clearAgentDirectorySessionRetry(serverId); return; } if (!didTransitionOnline && snapshot.hasEverLoadedAgentDirectory) { - this.clearAgentDirectorySessionRetry(serverId); return; } if (this.agentDirectoryBootstrapInFlight.has(serverId)) { return; } - if (!useSessionStore.getState().sessions[serverId]) { - this.scheduleAgentDirectorySessionRetry(serverId); - return; - } - this.clearAgentDirectorySessionRetry(serverId); const bootstrap = Promise.resolve() .then(() => @@ -1150,26 +1632,6 @@ export class HostRuntimeStore { this.agentDirectoryBootstrapInFlight.set(serverId, bootstrap); } - private scheduleAgentDirectorySessionRetry(serverId: string): void { - if (this.agentDirectorySessionRetryTimerByServer.has(serverId)) { - return; - } - const handle = setTimeout(() => { - this.agentDirectorySessionRetryTimerByServer.delete(serverId); - this.maybeAutoBootstrapAgentDirectory(serverId); - }, AGENT_DIRECTORY_SESSION_RETRY_MS); - this.agentDirectorySessionRetryTimerByServer.set(serverId, handle); - } - - private clearAgentDirectorySessionRetry(serverId: string): void { - const handle = this.agentDirectorySessionRetryTimerByServer.get(serverId); - if (!handle) { - return; - } - clearTimeout(handle); - this.agentDirectorySessionRetryTimerByServer.delete(serverId); - } - getSnapshot(serverId: string): HostRuntimeSnapshot | null { return this.controllers.get(serverId)?.getSnapshot() ?? null; } @@ -1384,3 +1846,47 @@ export function useHostRuntimeSession(serverId: string): { isConnected: isHostRuntimeConnected(snapshot), }; } + +export function useHosts(): HostProfile[] { + const store = getHostRuntimeStore(); + return useSyncExternalStore( + (onStoreChange) => store.subscribeHostList(onStoreChange), + () => store.getHosts(), + () => store.getHosts() + ); +} + +export interface HostMutations { + upsertDirectConnection: (input: { + serverId: string; + endpoint: string; + label?: string; + }) => Promise; + upsertRelayConnection: (input: { + serverId: string; + relayEndpoint: string; + daemonPublicKeyB64: string; + label?: string; + }) => Promise; + upsertConnectionFromOffer: (offer: ConnectionOffer) => Promise; + upsertConnectionFromOfferUrl: (offerUrlOrFragment: string) => Promise; + renameHost: (serverId: string, label: string) => Promise; + removeHost: (serverId: string) => Promise; + removeConnection: (serverId: string, connectionId: string) => Promise; +} + +export function useHostMutations(): HostMutations { + const store = getHostRuntimeStore(); + return useMemo( + () => ({ + upsertDirectConnection: (input) => store.upsertDirectConnection(input), + upsertRelayConnection: (input) => store.upsertRelayConnection(input), + upsertConnectionFromOffer: (offer) => store.upsertConnectionFromOffer(offer), + upsertConnectionFromOfferUrl: (url) => store.upsertConnectionFromOfferUrl(url), + renameHost: (serverId, label) => store.renameHost(serverId, label), + removeHost: (serverId) => store.removeHost(serverId), + removeConnection: (serverId, connectionId) => store.removeConnection(serverId, connectionId), + }), + [store] + ); +} diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 65abda30f..3396c04ac 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -21,7 +21,7 @@ import { ExplorerSidebarAnimationProvider, } from "@/contexts/explorer-sidebar-animation-context"; import { usePanelStore } from "@/stores/panel-store"; -import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; +import { useHosts } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { useHostRuntimeSession, @@ -94,7 +94,7 @@ export function AgentReadyScreen({ }) { const resolvedAgentId = agentId?.trim() || undefined; const resolvedServerId = serverId?.trim() || undefined; - const { daemons } = useDaemonRegistry(); + const daemons = useHosts(); const runtimeServerId = resolvedServerId ?? ""; const { snapshot: runtimeSnapshot, diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 3f5f07e6c..a91a9ad4f 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -25,7 +25,7 @@ import { checkoutStatusQueryKey, } from '@/hooks/use-checkout-status-query' import { useAllAgentsList } from '@/hooks/use-all-agents-list' -import { useDaemonRegistry } from '@/contexts/daemon-registry-context' +import { useHosts } from '@/runtime/host-runtime' import { buildBranchComboOptions, normalizeBranchOptionName } from '@/utils/branch-suggestions' import { shortenPath } from '@/utils/shorten-path' import { collectAgentWorkingDirectorySuggestions } from '@/utils/agent-working-directory-suggestions' @@ -138,7 +138,7 @@ function DraftAgentScreenContent({ const { theme } = useUnistyles() const router = useRouter() const insets = useSafeAreaInsets() - const { daemons } = useDaemonRegistry() + const daemons = useHosts() const runtime = getHostRuntimeStore() const runtimeVersion = useSyncExternalStore( (onStoreChange) => runtime.subscribeAll(onStoreChange), diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 9d16eb07b..d976cc8e6 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -13,7 +13,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2 } from "lucide-react-native"; import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; -import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context"; +import type { HostProfile, HostConnection } from "@/types/host-connection"; +import { useHosts, useHostMutations } from "@/runtime/host-runtime"; import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; import { confirmDialog } from "@/utils/confirm-dialog"; import { MenuHeader } from "@/components/headers/menu-header"; @@ -487,12 +488,8 @@ export default function SettingsScreen() { const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : ""; const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings(); const { - daemons, - isLoading: daemonLoading, - updateHost, - removeHost, - removeConnection, - } = useDaemonRegistry(); + daemons, renameHost, removeHost, removeConnection, + } = { daemons: useHosts(), ...useHostMutations() }; const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false); const [isDirectHostVisible, setIsDirectHostVisible] = useState(false); const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false); @@ -503,7 +500,7 @@ export default function SettingsScreen() { const [isRemovingHost, setIsRemovingHost] = useState(false); const [editingDaemon, setEditingDaemon] = useState(null); const [isSavingEdit, setIsSavingEdit] = useState(false); - const isLoading = settingsLoading || daemonLoading; + const isLoading = settingsLoading; const isMountedRef = useRef(true); const lastHandledEditHostRef = useRef(null); const isDesktop = Platform.OS === "web"; @@ -615,7 +612,7 @@ export default function SettingsScreen() { try { setIsSavingEdit(true); - await updateHost(editingServerId, { label: nextLabel }); + await renameHost(editingServerId, nextLabel); handleCloseEditDaemon(); } catch (error) { console.error("[Settings] Failed to rename host", error); @@ -623,7 +620,7 @@ export default function SettingsScreen() { } finally { setIsSavingEdit(false); } - }, [editingServerId, handleCloseEditDaemon, isSavingEdit, updateHost]); + }, [editingServerId, handleCloseEditDaemon, isSavingEdit, renameHost]); const handleRemoveConnection = useCallback( async (serverId: string, connectionId: string) => { @@ -767,7 +764,7 @@ export default function SettingsScreen() { hostname={pendingNameHostname} onSkip={() => setPendingNameHost(null)} onSave={(label) => { - void updateHost(pendingNameHost.serverId, { label }).finally(() => { + void renameHost(pendingNameHost.serverId, label).finally(() => { setPendingNameHost(null); }); }} diff --git a/packages/app/src/stores/download-store.ts b/packages/app/src/stores/download-store.ts index 91d223518..1007db1fb 100644 --- a/packages/app/src/stores/download-store.ts +++ b/packages/app/src/stores/download-store.ts @@ -3,7 +3,7 @@ import { Platform } from "react-native"; import { File as FSFile, Paths } from "expo-file-system"; import * as LegacyFileSystem from "expo-file-system/legacy"; import * as Sharing from "expo-sharing"; -import type { HostProfile } from "@/contexts/daemon-registry-context"; +import type { HostProfile } from "@/types/host-connection"; import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints"; import { openExternalUrl } from "@/utils/open-external-url"; diff --git a/packages/app/src/types/host-connection.ts b/packages/app/src/types/host-connection.ts new file mode 100644 index 000000000..ef3b57408 --- /dev/null +++ b/packages/app/src/types/host-connection.ts @@ -0,0 +1,303 @@ +import { normalizeHostPort } from '@server/shared/daemon-endpoints' + +export type DirectTcpHostConnection = { + id: string + type: 'directTcp' + endpoint: string +} + +export type DirectSocketHostConnection = { + id: string + type: 'directSocket' + path: string +} + +export type DirectPipeHostConnection = { + id: string + type: 'directPipe' + path: string +} + +export type RelayHostConnection = { + id: string + type: 'relay' + relayEndpoint: string + daemonPublicKeyB64: string +} + +export type HostConnection = + | DirectTcpHostConnection + | DirectSocketHostConnection + | DirectPipeHostConnection + | RelayHostConnection + +export type HostLifecycle = Record + +export type HostProfile = { + serverId: string + label: string + lifecycle: HostLifecycle + connections: HostConnection[] + preferredConnectionId: string | null + createdAt: string + updatedAt: string +} + +export function defaultLifecycle(): HostLifecycle { + return {} +} + +export function normalizeHostLabel(value: string | null | undefined, serverId: string): string { + const trimmed = value?.trim() ?? '' + return trimmed.length > 0 ? trimmed : serverId +} + +function hostConnectionEquals(left: HostConnection, right: HostConnection): boolean { + if (left.type !== right.type || left.id !== right.id) { + return false + } + + if (left.type === 'directTcp' && right.type === 'directTcp') { + return left.endpoint === right.endpoint + } + if (left.type === 'directSocket' && right.type === 'directSocket') { + return left.path === right.path + } + if (left.type === 'directPipe' && right.type === 'directPipe') { + return left.path === right.path + } + if (left.type === 'relay' && right.type === 'relay') { + return ( + left.relayEndpoint === right.relayEndpoint && + left.daemonPublicKeyB64 === right.daemonPublicKeyB64 + ) + } + + return false +} + +function hostLifecycleEquals(left: HostLifecycle, right: HostLifecycle): boolean { + return JSON.stringify(left) === JSON.stringify(right) +} + +export function upsertHostConnectionInProfiles(input: { + profiles: HostProfile[] + serverId: string + label?: string + connection: HostConnection + now?: string +}): HostProfile[] { + const serverId = input.serverId.trim() + if (!serverId) { + throw new Error('serverId is required') + } + + const now = input.now ?? new Date().toISOString() + const labelTrimmed = input.label?.trim() ?? '' + const derivedLabel = labelTrimmed || serverId + const existing = input.profiles + const idx = existing.findIndex((daemon) => daemon.serverId === serverId) + + if (idx === -1) { + const profile: HostProfile = { + serverId, + label: derivedLabel, + lifecycle: defaultLifecycle(), + connections: [input.connection], + preferredConnectionId: input.connection.id, + createdAt: now, + updatedAt: now, + } + return [...existing, profile] + } + + const prev = existing[idx]! + const connectionIdx = prev.connections.findIndex((connection) => connection.id === input.connection.id) + const hadConnection = connectionIdx !== -1 + const connectionChanged = + connectionIdx === -1 + ? true + : !hostConnectionEquals(prev.connections[connectionIdx]!, input.connection) + const nextConnections = + connectionIdx === -1 + ? [...prev.connections, input.connection] + : connectionChanged + ? prev.connections.map((connection, index) => + index === connectionIdx ? input.connection : connection + ) + : prev.connections + + const nextLifecycle = prev.lifecycle + const nextLabel = labelTrimmed ? labelTrimmed : prev.label + const nextPreferredConnectionId = prev.preferredConnectionId ?? input.connection.id + const changed = + nextLabel !== prev.label || + nextPreferredConnectionId !== prev.preferredConnectionId || + !hostLifecycleEquals(prev.lifecycle, nextLifecycle) || + !hadConnection || + connectionChanged + + if (!changed) { + return existing + } + + const nextProfile: HostProfile = { + ...prev, + label: nextLabel, + lifecycle: nextLifecycle, + connections: nextConnections, + preferredConnectionId: nextPreferredConnectionId, + updatedAt: now, + } + + const next = [...existing] + next[idx] = nextProfile + return next +} + +export function connectionFromListen(listen: string): HostConnection | null { + const normalizedListen = listen.trim() + if (!normalizedListen) { + return null + } + + if (normalizedListen.startsWith('pipe://')) { + const path = normalizedListen.slice('pipe://'.length).trim() + return path ? { id: `pipe:${path}`, type: 'directPipe', path } : null + } + + if (normalizedListen.startsWith('unix://')) { + const path = normalizedListen.slice('unix://'.length).trim() + return path ? { id: `socket:${path}`, type: 'directSocket', path } : null + } + + if (normalizedListen.startsWith('\\\\.\\pipe\\')) { + return { + id: `pipe:${normalizedListen}`, + type: 'directPipe', + path: normalizedListen, + } + } + + if (normalizedListen.startsWith('/')) { + return { + id: `socket:${normalizedListen}`, + type: 'directSocket', + path: normalizedListen, + } + } + + try { + const endpoint = normalizeHostPort(normalizedListen) + return { + id: `direct:${endpoint}`, + type: 'directTcp', + endpoint, + } + } catch { + return null + } +} + +function normalizeStoredConnection(connection: unknown): HostConnection | null { + if (!connection || typeof connection !== 'object') { + return null + } + const record = connection as Record + const type = typeof record.type === 'string' ? record.type : null + if (type === 'directTcp') { + try { + const endpoint = normalizeHostPort(String(record.endpoint ?? '')) + return { id: `direct:${endpoint}`, type: 'directTcp', endpoint } + } catch { + return null + } + } + if (type === 'directSocket') { + const path = String(record.path ?? '').trim() + return path ? { id: `socket:${path}`, type: 'directSocket', path } : null + } + if (type === 'directPipe') { + const path = String(record.path ?? '').trim() + return path ? { id: `pipe:${path}`, type: 'directPipe', path } : null + } + if (type === 'relay') { + try { + const relayEndpoint = normalizeHostPort(String(record.relayEndpoint ?? '')) + const daemonPublicKeyB64 = String(record.daemonPublicKeyB64 ?? '').trim() + if (!daemonPublicKeyB64) return null + return { + id: `relay:${relayEndpoint}`, + type: 'relay', + relayEndpoint, + daemonPublicKeyB64, + } + } catch { + return null + } + } + + return null +} + +export function normalizeStoredHostProfile(entry: unknown): HostProfile | null { + if (!entry || typeof entry !== 'object') { + return null + } + const record = entry as Record + const serverId = typeof record.serverId === 'string' ? record.serverId.trim() : '' + if (!serverId) { + return null + } + + const rawConnections = Array.isArray(record.connections) ? record.connections : [] + const connections = rawConnections + .map((connection) => normalizeStoredConnection(connection)) + .filter((connection): connection is HostConnection => connection !== null) + if (connections.length === 0) { + return null + } + + const now = new Date().toISOString() + const label = normalizeHostLabel( + typeof record.label === 'string' ? record.label : null, + serverId + ) + const preferredConnectionId = + typeof record.preferredConnectionId === 'string' && + connections.some((connection) => connection.id === record.preferredConnectionId) + ? record.preferredConnectionId + : connections[0]?.id ?? null + + return { + serverId, + label, + lifecycle: defaultLifecycle(), + connections, + preferredConnectionId, + createdAt: typeof record.createdAt === 'string' ? record.createdAt : now, + updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt : now, + } +} + +export function normalizeEndpointOrNull(endpoint: string): string | null { + try { + return normalizeHostPort(endpoint) + } catch { + return null + } +} + +export function hostHasDirectEndpoint(host: HostProfile, endpoint: string): boolean { + const normalized = normalizeEndpointOrNull(endpoint) + if (!normalized) { + return false + } + return host.connections.some( + (connection) => connection.type === 'directTcp' && connection.endpoint === normalized + ) +} + +export function registryHasDirectEndpoint(hosts: HostProfile[], endpoint: string): boolean { + return hosts.some((host) => hostHasDirectEndpoint(host, endpoint)) +} diff --git a/packages/app/src/utils/connection-selection.test.ts b/packages/app/src/utils/connection-selection.test.ts index 348a0beb3..392163015 100644 --- a/packages/app/src/utils/connection-selection.test.ts +++ b/packages/app/src/utils/connection-selection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { type HostConnection } from "@/contexts/daemon-registry-context"; +import type { HostConnection } from "@/types/host-connection"; import { selectBestConnection, type ConnectionCandidate, diff --git a/packages/app/src/utils/connection-selection.ts b/packages/app/src/utils/connection-selection.ts index c4bb1949f..2ab3ea072 100644 --- a/packages/app/src/utils/connection-selection.ts +++ b/packages/app/src/utils/connection-selection.ts @@ -1,4 +1,4 @@ -import type { HostConnection } from "@/contexts/daemon-registry-context"; +import type { HostConnection } from "@/types/host-connection"; export type ConnectionCandidate = { connectionId: string; diff --git a/packages/app/src/utils/tauri-attach-console.ts b/packages/app/src/utils/tauri-attach-console.ts new file mode 100644 index 000000000..0b86b504e --- /dev/null +++ b/packages/app/src/utils/tauri-attach-console.ts @@ -0,0 +1,55 @@ +import { getTauri } from "@/utils/tauri"; + +let attached = false; + +export function attachConsole(): void { + if (attached || !getTauri()) { + return; + } + attached = true; + + const originalLog = console.log; + const originalInfo = console.info; + const originalWarn = console.warn; + const originalError = console.error; + const originalDebug = console.debug; + + function formatArgs(args: unknown[]): string { + return args + .map((a) => + typeof a === "string" ? a : JSON.stringify(a, null, 0) ?? String(a) + ) + .join(" "); + } + + function forwardToRust(level: number, args: unknown[]): void { + const invoke = getTauri()?.core?.invoke; + if (typeof invoke !== "function") return; + try { + void invoke("webview_log", { level, message: formatArgs(args) }); + } catch { + // silently ignore IPC errors + } + } + + console.log = (...args: unknown[]) => { + originalLog.apply(console, args); + forwardToRust(0, args); // debug + }; + console.info = (...args: unknown[]) => { + originalInfo.apply(console, args); + forwardToRust(1, args); // info + }; + console.warn = (...args: unknown[]) => { + originalWarn.apply(console, args); + forwardToRust(2, args); // warn + }; + console.error = (...args: unknown[]) => { + originalError.apply(console, args); + forwardToRust(3, args); // error + }; + console.debug = (...args: unknown[]) => { + originalDebug.apply(console, args); + forwardToRust(0, args); // debug + }; +} diff --git a/packages/app/src/utils/test-daemon-connection.test.ts b/packages/app/src/utils/test-daemon-connection.test.ts index 02500eec8..2c31889d5 100644 --- a/packages/app/src/utils/test-daemon-connection.test.ts +++ b/packages/app/src/utils/test-daemon-connection.test.ts @@ -60,40 +60,44 @@ vi.mock("./client-id", () => ({ getOrCreateClientId: clientIdMock.getOrCreateClientId, })); -describe("test-daemon-connection probe client identity", () => { +describe("test-daemon-connection connectToDaemon", () => { beforeEach(() => { daemonClientMock.createdConfigs.length = 0; clientIdMock.getOrCreateClientId.mockClear(); }); - it("reuses the app clientId for direct latency probes", async () => { + it("reuses the app clientId for direct connections", async () => { const mod = await import("./test-daemon-connection"); - await mod.measureConnectionLatency({ - id: "direct:lan:6767", - type: "directTcp", - endpoint: "lan:6767", - }); - await mod.measureConnectionLatency({ + const first = await mod.connectToDaemon({ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767", }); + await first.client.close(); - const [first, second] = daemonClientMock.createdConfigs; - expect(first?.clientId).toBe("cid_shared_probe_test"); - expect(second?.clientId).toBe("cid_shared_probe_test"); + const second = await mod.connectToDaemon({ + id: "direct:lan:6767", + type: "directTcp", + endpoint: "lan:6767", + }); + await second.client.close(); + + const [firstConfig, secondConfig] = daemonClientMock.createdConfigs; + expect(firstConfig?.clientId).toBe("cid_shared_probe_test"); + expect(secondConfig?.clientId).toBe("cid_shared_probe_test"); expect(clientIdMock.getOrCreateClientId).toHaveBeenCalledTimes(2); }); - it("encodes the local socket target into the probe client config", async () => { + it("encodes the local socket target into the client config", async () => { const mod = await import("./test-daemon-connection"); - await mod.measureConnectionLatency({ + const result = await mod.connectToDaemon({ id: "socket:/tmp/paseo.sock", type: "directSocket", path: "/tmp/paseo.sock", }); + await result.client.close(); expect(daemonClientMock.createdConfigs[0]?.url).toBe( "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock" diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts index 507edaad4..a08acfc65 100644 --- a/packages/app/src/utils/test-daemon-connection.ts +++ b/packages/app/src/utils/test-daemon-connection.ts @@ -1,6 +1,6 @@ import { DaemonClient } from "@server/client/daemon-client"; import type { DaemonClientConfig } from "@server/client/daemon-client"; -import type { HostConnection } from "@/contexts/daemon-registry-context"; +import type { HostConnection } from "@/types/host-connection"; import { getOrCreateClientId } from "./client-id"; import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints"; import { @@ -45,7 +45,7 @@ export class DaemonConnectionTestError extends Error { } } -async function buildClientConfig( +export async function buildClientConfig( connection: HostConnection, serverId?: string ): Promise { @@ -96,7 +96,7 @@ async function buildClientConfig( }; } -function connectAndProbe( +export function connectAndProbe( config: DaemonClientConfig, timeoutMs: number, ): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> { @@ -152,26 +152,10 @@ function resolveTimeout(connection: HostConnection, options?: ProbeOptions): num return connection.type === "relay" ? 10_000 : 6_000; } -export async function probeConnection( +export async function connectToDaemon( connection: HostConnection, options?: ProbeOptions, -): Promise<{ serverId: string; hostname: string | null }> { +): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> { const config = await buildClientConfig(connection, options?.serverId); - const { client, serverId, hostname } = await connectAndProbe(config, resolveTimeout(connection, options)); - await client.close().catch(() => undefined); - return { serverId, hostname }; -} - -export async function measureConnectionLatency( - connection: HostConnection, - options?: ProbeOptions, -): Promise { - const config = await buildClientConfig(connection, options?.serverId); - const { client } = await connectAndProbe(config, resolveTimeout(connection, options)); - try { - const { rttMs } = await client.ping({ timeoutMs: 5000 }); - return rttMs; - } finally { - await client.close().catch(() => undefined); - } + return connectAndProbe(config, resolveTimeout(connection, options)); } diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index cb2782353..ede64482d 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -1,6 +1,9 @@ import { defineConfig, configDefaults } from "vitest/config"; import path from "path"; +const appNodeModules = path.resolve(__dirname, "node_modules"); +const rootNodeModules = path.resolve(__dirname, "../../node_modules"); + export default defineConfig({ test: { environment: "node", @@ -12,12 +15,27 @@ export default defineConfig({ * keeps `process.send` intact so the app tests can boot before hitting the intentional failures. */ pool: "forks", + server: { + deps: { + fallbackCJS: true, + inline: [ + "zustand", + "@tanstack/react-query", + "react-native-web", + ], + }, + }, }, resolve: { alias: { "@": path.resolve(__dirname, "src"), "@server": path.resolve(__dirname, "../server/src"), - "react-native": "react-native-web", + // Point to the ESM build so Vite can transform its imports and apply the + // react alias below (the CJS build uses require('react') which bypasses + // Vite alias resolution). + "react-native": path.resolve(rootNodeModules, "react-native-web/dist/index.js"), + react: path.resolve(appNodeModules, "react"), + "react-dom": path.resolve(appNodeModules, "react-dom"), }, }, }); diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index fec05eaec..da58b69f7 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -496,6 +496,16 @@ async fn garbage_collect_attachment_files( .map_err(|error| format!("Attachment GC task failed: {error}"))? } +#[tauri::command] +fn webview_log(level: u8, message: String) { + match level { + 0 => log::debug!("[webview] {}", message), + 1 => log::info!("[webview] {}", message), + 2 => log::warn!("[webview] {}", message), + _ => log::error!("[webview] {}", message), + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -533,7 +543,8 @@ pub fn run() { copy_attachment_file, read_file_base64, delete_attachment_file, - garbage_collect_attachment_files + garbage_collect_attachment_files, + webview_log ]) .setup(|app| { let setup_start = std::time::Instant::now(); @@ -558,9 +569,6 @@ pub fn run() { .target(tauri_plugin_log::Target::new( tauri_plugin_log::TargetKind::Stdout, )) - .target(tauri_plugin_log::Target::new( - tauri_plugin_log::TargetKind::Webview, - )) .build(), )?; diff --git a/packages/desktop/src-tauri/src/runtime_manager.rs b/packages/desktop/src-tauri/src/runtime_manager.rs index 0bfdab16b..8d1612a77 100644 --- a/packages/desktop/src-tauri/src/runtime_manager.rs +++ b/packages/desktop/src-tauri/src/runtime_manager.rs @@ -604,6 +604,14 @@ fn start_managed_daemon_internal(app: &AppHandle) -> Result