refactor: move daemon registry into host-runtime store
This commit is contained in:
@@ -12,10 +12,24 @@ import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
|||||||
import { View, ActivityIndicator, Text } from "react-native";
|
import { View, ActivityIndicator, Text } from "react-native";
|
||||||
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { darkTheme } from "@/styles/theme";
|
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 { 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 { Platform } from "react-native";
|
||||||
import * as Linking from "expo-linking";
|
import * as Linking from "expo-linking";
|
||||||
import * as Notifications from "expo-notifications";
|
import * as Notifications from "expo-notifications";
|
||||||
@@ -51,8 +65,12 @@ import {
|
|||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { getTauri } from "@/utils/tauri";
|
import { getTauri } from "@/utils/tauri";
|
||||||
import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics";
|
import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics";
|
||||||
|
import { attachConsole } from "@/utils/tauri-attach-console";
|
||||||
|
|
||||||
polyfillCrypto();
|
polyfillCrypto();
|
||||||
|
attachConsole();
|
||||||
|
const HostRuntimeBootstrapContext = createContext(false);
|
||||||
|
|
||||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||||
|
|
||||||
function logLeftSidebarOpenGesture(
|
function logLeftSidebarOpenGesture(
|
||||||
@@ -141,6 +159,79 @@ function PushNotificationRouter() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||||
|
const { client } = useHostRuntimeSession(daemon.serverId);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionProvider
|
||||||
|
key={daemon.serverId}
|
||||||
|
serverId={daemon.serverId}
|
||||||
|
client={client}
|
||||||
|
>
|
||||||
|
{null}
|
||||||
|
</SessionProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostSessionManager() {
|
||||||
|
const hosts = useHosts();
|
||||||
|
|
||||||
|
if (hosts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{hosts.map((daemon) => (
|
||||||
|
<ManagedDaemonSession key={daemon.serverId} daemon={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 (
|
||||||
|
<HostRuntimeBootstrapContext.Provider value={ready}>
|
||||||
|
{children}
|
||||||
|
</HostRuntimeBootstrapContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useStoreReady(): boolean {
|
||||||
|
return useContext(HostRuntimeBootstrapContext);
|
||||||
|
}
|
||||||
|
|
||||||
function QueryProvider({ children }: { children: ReactNode }) {
|
function QueryProvider({ children }: { children: ReactNode }) {
|
||||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||||
}
|
}
|
||||||
@@ -157,7 +248,7 @@ function AppContainer({
|
|||||||
chromeEnabled: chromeEnabledOverride,
|
chromeEnabled: chromeEnabledOverride,
|
||||||
}: AppContainerProps) {
|
}: AppContainerProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { daemons } = useDaemonRegistry();
|
const daemons = useHosts();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||||
@@ -310,8 +401,9 @@ function AppContainer({
|
|||||||
|
|
||||||
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||||
const { settings, isLoading: settingsLoading } = useAppSettings();
|
const { settings, isLoading: settingsLoading } = useAppSettings();
|
||||||
const { daemons, isLoading: registryLoading, upsertDaemonFromOfferUrl } = useDaemonRegistry();
|
const storeReady = useStoreReady();
|
||||||
const isLoading = settingsLoading || registryLoading;
|
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
||||||
|
const isLoading = settingsLoading || !storeReady;
|
||||||
|
|
||||||
// Apply theme setting on mount and when it changes
|
// Apply theme setting on mount and when it changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -330,7 +422,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<VoiceProvider>
|
<VoiceProvider>
|
||||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertDaemonFromOfferUrl} />
|
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
||||||
{children}
|
{children}
|
||||||
</VoiceProvider>
|
</VoiceProvider>
|
||||||
);
|
);
|
||||||
@@ -470,9 +562,9 @@ export default function RootLayout() {
|
|||||||
<KeyboardProvider>
|
<KeyboardProvider>
|
||||||
<BottomSheetModalProvider>
|
<BottomSheetModalProvider>
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<DaemonRegistryProvider>
|
<HostRuntimeBootstrapProvider>
|
||||||
<PushNotificationRouter />
|
<PushNotificationRouter />
|
||||||
<MultiDaemonSessionHost />
|
<HostSessionManager />
|
||||||
<ProvidersWrapper>
|
<ProvidersWrapper>
|
||||||
<SidebarAnimationProvider>
|
<SidebarAnimationProvider>
|
||||||
<HorizontalScrollProvider>
|
<HorizontalScrollProvider>
|
||||||
@@ -506,7 +598,7 @@ export default function RootLayout() {
|
|||||||
</HorizontalScrollProvider>
|
</HorizontalScrollProvider>
|
||||||
</SidebarAnimationProvider>
|
</SidebarAnimationProvider>
|
||||||
</ProvidersWrapper>
|
</ProvidersWrapper>
|
||||||
</DaemonRegistryProvider>
|
</HostRuntimeBootstrapProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</BottomSheetModalProvider>
|
</BottomSheetModalProvider>
|
||||||
</KeyboardProvider>
|
</KeyboardProvider>
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
export const WELCOME_ROUTE = '/welcome'
|
export const WELCOME_ROUTE = '/welcome'
|
||||||
|
|
||||||
export function shouldWaitOnStartupRace(input: {
|
export function shouldWaitOnStartupRace(input: {
|
||||||
registryLoading: boolean
|
|
||||||
onlineServerId: string | null
|
onlineServerId: string | null
|
||||||
hasTimedOut: boolean
|
hasTimedOut: boolean
|
||||||
isDesktopStartupRace: boolean
|
isDesktopStartupRace: boolean
|
||||||
daemonCount: number
|
daemonCount: number
|
||||||
pathname: string
|
pathname: string
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (input.registryLoading) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (input.onlineServerId) {
|
if (input.onlineServerId) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -24,14 +20,13 @@ export function shouldWaitOnStartupRace(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function shouldRedirectToWelcome(input: {
|
export function shouldRedirectToWelcome(input: {
|
||||||
registryLoading: boolean
|
|
||||||
onlineServerId: string | null
|
onlineServerId: string | null
|
||||||
hasTimedOut: boolean
|
hasTimedOut: boolean
|
||||||
pathname: string
|
pathname: string
|
||||||
isDesktopStartupRace: boolean
|
isDesktopStartupRace: boolean
|
||||||
daemonCount: number
|
daemonCount: number
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (input.registryLoading || input.onlineServerId || !input.hasTimedOut) {
|
if (input.onlineServerId || !input.hasTimedOut) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (input.pathname !== '/' && input.pathname !== '') {
|
if (input.pathname !== '/' && input.pathname !== '') {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useSyncExternalStore, useState } from 'react'
|
import { useEffect, useRef, useSyncExternalStore, useState } from 'react'
|
||||||
import { usePathname, useRouter } from 'expo-router'
|
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 { shouldUseManagedDesktopDaemon } from '@/desktop/managed-runtime/managed-runtime'
|
||||||
import { buildHostRootRoute } from '@/utils/host-routes'
|
import { buildHostRootRoute } from '@/utils/host-routes'
|
||||||
import { StartupSplashScreen } from '@/screens/startup-splash-screen'
|
import { StartupSplashScreen } from '@/screens/startup-splash-screen'
|
||||||
@@ -57,7 +57,7 @@ function useAnyHostOnline(serverIds: string[]): string | null {
|
|||||||
export default function Index() {
|
export default function Index() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const { daemons, isLoading: registryLoading } = useDaemonRegistry()
|
const daemons = useHosts()
|
||||||
const [hasTimedOut, setHasTimedOut] = useState(false)
|
const [hasTimedOut, setHasTimedOut] = useState(false)
|
||||||
const isDesktopStartupRace = shouldUseManagedDesktopDaemon()
|
const isDesktopStartupRace = shouldUseManagedDesktopDaemon()
|
||||||
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId))
|
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId))
|
||||||
@@ -83,9 +83,6 @@ export default function Index() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (registryLoading) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!onlineServerId) {
|
if (!onlineServerId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -97,12 +94,11 @@ export default function Index() {
|
|||||||
elapsedMs: Math.round(performance.now() - APP_START_TIME),
|
elapsedMs: Math.round(performance.now() - APP_START_TIME),
|
||||||
})
|
})
|
||||||
router.replace(buildHostRootRoute(onlineServerId) as any)
|
router.replace(buildHostRootRoute(onlineServerId) as any)
|
||||||
}, [onlineServerId, pathname, registryLoading, router])
|
}, [onlineServerId, pathname, router])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!shouldRedirectToWelcome({
|
!shouldRedirectToWelcome({
|
||||||
registryLoading,
|
|
||||||
onlineServerId,
|
onlineServerId,
|
||||||
hasTimedOut,
|
hasTimedOut,
|
||||||
pathname,
|
pathname,
|
||||||
@@ -113,11 +109,10 @@ export default function Index() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
router.replace(WELCOME_ROUTE as any)
|
router.replace(WELCOME_ROUTE as any)
|
||||||
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, registryLoading, router])
|
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router])
|
||||||
|
|
||||||
if (
|
if (
|
||||||
shouldWaitOnStartupRace({
|
shouldWaitOnStartupRace({
|
||||||
registryLoading,
|
|
||||||
onlineServerId,
|
onlineServerId,
|
||||||
hasTimedOut,
|
hasTimedOut,
|
||||||
isDesktopStartupRace,
|
isDesktopStartupRace,
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||||
import type { BarcodeScanningResult } 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 { useSessionStore } from "@/stores/session-store";
|
||||||
import { NameHostModal } from "@/components/name-host-modal";
|
import { NameHostModal } from "@/components/name-host-modal";
|
||||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
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 { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||||
import {
|
import {
|
||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
@@ -151,7 +151,8 @@ export default function PairScanScreen() {
|
|||||||
const sourceServerId =
|
const sourceServerId =
|
||||||
typeof params.sourceServerId === "string" ? params.sourceServerId : null;
|
typeof params.sourceServerId === "string" ? params.sourceServerId : null;
|
||||||
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : 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 [permission, requestPermission] = useCameraPermissions();
|
||||||
const [isPairing, setIsPairing] = useState(false);
|
const [isPairing, setIsPairing] = useState(false);
|
||||||
@@ -241,7 +242,7 @@ export default function PairScanScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await probeConnection(
|
const { client } = await connectToDaemon(
|
||||||
{
|
{
|
||||||
id: "probe",
|
id: "probe",
|
||||||
type: "relay",
|
type: "relay",
|
||||||
@@ -250,6 +251,7 @@ export default function PairScanScreen() {
|
|||||||
},
|
},
|
||||||
{ serverId: offer.serverId },
|
{ serverId: offer.serverId },
|
||||||
);
|
);
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
|
||||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === offer.serverId);
|
const isNewHost = !daemons.some((daemon) => daemon.serverId === offer.serverId);
|
||||||
const profile = await upsertDaemonFromOfferUrl(offerUrl);
|
const profile = await upsertDaemonFromOfferUrl(offerUrl);
|
||||||
@@ -311,7 +313,7 @@ export default function PairScanScreen() {
|
|||||||
}}
|
}}
|
||||||
onSave={(label) => {
|
onSave={(label) => {
|
||||||
const serverId = pendingNameHost.serverId;
|
const serverId = pendingNameHost.serverId;
|
||||||
void updateHost(serverId, { label }).finally(() => {
|
void renameHost(serverId, label).finally(() => {
|
||||||
setPendingNameHost(null);
|
setPendingNameHost(null);
|
||||||
returnToSource(serverId);
|
returnToSource(serverId);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import { ActivityIndicator, View } from "react-native";
|
|||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useUnistyles } from "react-native-unistyles";
|
import { useUnistyles } from "react-native-unistyles";
|
||||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
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 { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||||
import { buildHostSettingsRoute } from "@/utils/host-routes";
|
import { buildHostSettingsRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
export default function LegacySettingsRoute() {
|
export default function LegacySettingsRoute() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
|
const daemons = useHosts();
|
||||||
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
|
||||||
|
|
||||||
const targetServerId = useMemo(() => {
|
const targetServerId = useMemo(() => {
|
||||||
@@ -29,16 +29,16 @@ export default function LegacySettingsRoute() {
|
|||||||
}, [daemons, preferences.serverId]);
|
}, [daemons, preferences.serverId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (registryLoading || preferencesLoading) {
|
if (preferencesLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!targetServerId) {
|
if (!targetServerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(buildHostSettingsRoute(targetServerId) as any);
|
router.replace(buildHostSettingsRoute(targetServerId) as any);
|
||||||
}, [preferencesLoading, registryLoading, router, targetServerId]);
|
}, [preferencesLoading, router, targetServerId]);
|
||||||
|
|
||||||
if (registryLoading || preferencesLoading) {
|
if (preferencesLoading) {
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { useCallback, useRef, useState } from "react";
|
|||||||
import { Alert, Text, TextInput, View } from "react-native";
|
import { Alert, Text, TextInput, View } from "react-native";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { Link2 } from "lucide-react-native";
|
import { Link2 } 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 { normalizeHostPort } from "@/utils/daemon-endpoints";
|
import { normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||||
import { DaemonConnectionTestError, probeConnection } from "@/utils/test-daemon-connection";
|
import { DaemonConnectionTestError, connectToDaemon } from "@/utils/test-daemon-connection";
|
||||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
|
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
@@ -129,7 +130,8 @@ export interface AddHostModalProps {
|
|||||||
|
|
||||||
export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServerId }: AddHostModalProps) {
|
export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServerId }: AddHostModalProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { daemons, upsertDirectConnection } = useDaemonRegistry();
|
const daemons = useHosts();
|
||||||
|
const { upsertDirectConnection } = useHostMutations();
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
|
||||||
@@ -179,11 +181,12 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
|
|
||||||
const { serverId, hostname } = await probeConnection({
|
const { client, serverId, hostname } = await connectToDaemon({
|
||||||
id: "probe",
|
id: "probe",
|
||||||
type: "directTcp",
|
type: "directTcp",
|
||||||
endpoint,
|
endpoint,
|
||||||
});
|
});
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
if (targetServerId && serverId !== targetServerId) {
|
if (targetServerId && serverId !== targetServerId) {
|
||||||
const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`;
|
const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`;
|
||||||
setErrorMessage(message);
|
setErrorMessage(message);
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import type {
|
|||||||
AgentFileExplorerState,
|
AgentFileExplorerState,
|
||||||
ExplorerEntry,
|
ExplorerEntry,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useDownloadStore } from "@/stores/download-store";
|
import { useDownloadStore } from "@/stores/download-store";
|
||||||
import {
|
import {
|
||||||
@@ -105,7 +105,7 @@ export function FileExplorerPane({
|
|||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||||
|
|
||||||
const { daemons } = useDaemonRegistry();
|
const daemons = useHosts();
|
||||||
const daemonProfile = useMemo(
|
const daemonProfile = useMemo(
|
||||||
() => daemons.find((daemon) => daemon.serverId === serverId),
|
() => daemons.find((daemon) => daemon.serverId === serverId),
|
||||||
[daemons, serverId]
|
[daemons, serverId]
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ import { useSidebarWorkspacesList } from '@/hooks/use-sidebar-workspaces-list'
|
|||||||
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
|
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
|
||||||
import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window'
|
import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window'
|
||||||
import { Combobox } from '@/components/ui/combobox'
|
import { Combobox } from '@/components/ui/combobox'
|
||||||
import { useDaemonRegistry } from '@/contexts/daemon-registry-context'
|
import { getHostRuntimeStore, useHosts } from '@/runtime/host-runtime'
|
||||||
import { getHostRuntimeStore } from '@/runtime/host-runtime'
|
|
||||||
import { formatConnectionStatus } from '@/utils/daemons'
|
import { formatConnectionStatus } from '@/utils/daemons'
|
||||||
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from '@/constants/layout'
|
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from '@/constants/layout'
|
||||||
import {
|
import {
|
||||||
@@ -53,7 +52,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
|||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen)
|
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen)
|
||||||
const closeToAgent = usePanelStore((state) => state.closeToAgent)
|
const closeToAgent = usePanelStore((state) => state.closeToAgent)
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const { daemons } = useDaemonRegistry()
|
const daemons = useHosts()
|
||||||
const runtime = getHostRuntimeStore()
|
const runtime = getHostRuntimeStore()
|
||||||
const runtimeConnectionStatusSignature = useSyncExternalStore(
|
const runtimeConnectionStatusSignature = useSyncExternalStore(
|
||||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||||
|
|||||||
@@ -356,6 +356,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
serverInfo,
|
serverInfo,
|
||||||
mode: 'dictation',
|
mode: 'dictation',
|
||||||
})
|
})
|
||||||
|
const dictationReadiness = serverInfo?.capabilities?.voice?.dictation ?? null
|
||||||
|
|
||||||
const canStartDictation = useCallback(() => {
|
const canStartDictation = useCallback(() => {
|
||||||
const socketConnected = client?.isConnected ?? false
|
const socketConnected = client?.isConnected ?? false
|
||||||
@@ -415,7 +416,28 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
}, [dictationStatus, isDictating, isDictationProcessing])
|
}, [dictationStatus, isDictating, isDictationProcessing])
|
||||||
|
|
||||||
const startDictationIfAvailable = useCallback(async () => {
|
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) {
|
if (dictationUnavailableMessage) {
|
||||||
|
console.info(
|
||||||
|
`[DictationGate] blocked before start ${JSON.stringify({
|
||||||
|
serverId: voiceServerId ?? null,
|
||||||
|
agentId: voiceAgentId ?? null,
|
||||||
|
dictationUnavailableMessage,
|
||||||
|
})}`
|
||||||
|
)
|
||||||
isDictatingRef.current = false
|
isDictatingRef.current = false
|
||||||
toast.error(dictationUnavailableMessage)
|
toast.error(dictationUnavailableMessage)
|
||||||
return
|
return
|
||||||
@@ -424,7 +446,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
// state-ref sync effect, so a rapid second toggle routes to confirm.
|
// state-ref sync effect, so a rapid second toggle routes to confirm.
|
||||||
isDictatingRef.current = true
|
isDictatingRef.current = true
|
||||||
await startDictation()
|
await startDictation()
|
||||||
}, [dictationUnavailableMessage, startDictation, toast])
|
}, [
|
||||||
|
client,
|
||||||
|
dictationReadiness,
|
||||||
|
dictationUnavailableMessage,
|
||||||
|
disabled,
|
||||||
|
isReadyForDictation,
|
||||||
|
serverInfo,
|
||||||
|
startDictation,
|
||||||
|
toast,
|
||||||
|
voiceAgentId,
|
||||||
|
voiceServerId,
|
||||||
|
])
|
||||||
|
|
||||||
// Animate overlay
|
// Animate overlay
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<SessionProvider
|
|
||||||
key={daemon.serverId}
|
|
||||||
serverId={daemon.serverId}
|
|
||||||
client={client}
|
|
||||||
>
|
|
||||||
{null}
|
|
||||||
</SessionProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MultiDaemonSessionHost() {
|
|
||||||
const { daemons } = useDaemonRegistry();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const runtime = getHostRuntimeStore();
|
|
||||||
runtime.syncHosts(daemons);
|
|
||||||
}, [daemons]);
|
|
||||||
|
|
||||||
if (daemons.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{daemons.map((daemon) => (
|
|
||||||
<ManagedDaemonSession key={daemon.serverId} daemon={daemon} />
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,9 +2,10 @@ import { useCallback, useState } from "react";
|
|||||||
import { Alert, Text, View } from "react-native";
|
import { Alert, Text, View } from "react-native";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { Link } from "lucide-react-native";
|
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 { 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 { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
|
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -52,7 +53,8 @@ export interface PairLinkModalProps {
|
|||||||
|
|
||||||
export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServerId }: PairLinkModalProps) {
|
export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServerId }: PairLinkModalProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { daemons, upsertDaemonFromOfferUrl } = useDaemonRegistry();
|
const daemons = useHosts();
|
||||||
|
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
|
||||||
@@ -122,7 +124,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServe
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
|
|
||||||
const probeResult = await probeConnection(
|
const { client, hostname } = await connectToDaemon(
|
||||||
{
|
{
|
||||||
id: "probe",
|
id: "probe",
|
||||||
type: "relay",
|
type: "relay",
|
||||||
@@ -131,10 +133,11 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServe
|
|||||||
},
|
},
|
||||||
{ serverId: parsedOffer.serverId },
|
{ serverId: parsedOffer.serverId },
|
||||||
);
|
);
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
|
||||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
|
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
|
||||||
const profile = await upsertDaemonFromOfferUrl(raw);
|
const profile = await upsertDaemonFromOfferUrl(raw);
|
||||||
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname: probeResult.hostname, isNewHost });
|
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
|
||||||
handleClose();
|
handleClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Unable to pair host";
|
const message = error instanceof Error ? error.message : "Unable to pair host";
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ import {
|
|||||||
normalizeWorkspaceDescriptor,
|
normalizeWorkspaceDescriptor,
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
import { useHosts, useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { parseServerIdFromPathname } from "@/utils/host-routes";
|
import { parseServerIdFromPathname } from "@/utils/host-routes";
|
||||||
import { buildHostWorkspaceRouteWithOpenIntent } from "@/utils/host-routes";
|
import { buildHostWorkspaceRouteWithOpenIntent } from "@/utils/host-routes";
|
||||||
@@ -28,7 +27,7 @@ export function ProjectPickerModal() {
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { daemons } = useDaemonRegistry();
|
const daemons = useHosts();
|
||||||
|
|
||||||
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
|
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
|
||||||
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
|
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|||||||
import { MicOff, Square } from "lucide-react-native";
|
import { MicOff, Square } from "lucide-react-native";
|
||||||
import { VolumeMeter } from "./volume-meter";
|
import { VolumeMeter } from "./volume-meter";
|
||||||
import { useVoice } from "@/contexts/voice-context";
|
import { useVoice } from "@/contexts/voice-context";
|
||||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
|
|
||||||
export function VoicePanel() {
|
export function VoicePanel() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { daemons } = useDaemonRegistry();
|
const daemons = useHosts();
|
||||||
const {
|
const {
|
||||||
volume,
|
volume,
|
||||||
isMuted,
|
isMuted,
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { Image, Pressable, Text, View, Platform, ScrollView } from "react-native
|
|||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||||
import type { HostProfile } from "@/contexts/daemon-registry-context";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
import { useHostMutations } from "@/runtime/host-runtime";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { AddHostModal } from "./add-host-modal";
|
import { AddHostModal } from "./add-host-modal";
|
||||||
import { PairLinkModal } from "./pair-link-modal";
|
import { PairLinkModal } from "./pair-link-modal";
|
||||||
@@ -87,7 +87,7 @@ export interface WelcomeScreenProps {
|
|||||||
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { updateHost } = useDaemonRegistry();
|
const { renameHost } = useHostMutations();
|
||||||
const appVersion = resolveAppVersion();
|
const appVersion = resolveAppVersion();
|
||||||
const appVersionText = formatVersionWithPrefix(appVersion);
|
const appVersionText = formatVersionWithPrefix(appVersion);
|
||||||
const [isDirectOpen, setIsDirectOpen] = useState(false);
|
const [isDirectOpen, setIsDirectOpen] = useState(false);
|
||||||
@@ -201,7 +201,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
}}
|
}}
|
||||||
onSave={(label) => {
|
onSave={(label) => {
|
||||||
const serverId = pendingRedirectServerId;
|
const serverId = pendingRedirectServerId;
|
||||||
void updateHost(pendingNameHost.serverId, { label }).finally(() => {
|
void renameHost(pendingNameHost.serverId, label).finally(() => {
|
||||||
setPendingNameHost(null);
|
setPendingNameHost(null);
|
||||||
setPendingRedirectServerId(null);
|
setPendingRedirectServerId(null);
|
||||||
finishOnboarding(serverId);
|
finishOnboarding(serverId);
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import {
|
|
||||||
connectionFromListen,
|
|
||||||
hostHasDirectEndpoint,
|
|
||||||
registryHasDirectEndpoint,
|
|
||||||
type HostProfile,
|
|
||||||
} from './daemon-registry-context'
|
|
||||||
|
|
||||||
function makeHost(input: Partial<HostProfile> & Pick<HostProfile, 'serverId'>): 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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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<string, never>
|
|
||||||
|
|
||||||
export type HostProfile = {
|
|
||||||
serverId: string
|
|
||||||
label: string
|
|
||||||
lifecycle: HostLifecycle
|
|
||||||
connections: HostConnection[]
|
|
||||||
preferredConnectionId: string | null
|
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UpdateHostInput = Partial<Omit<HostProfile, 'serverId' | 'createdAt'>>
|
|
||||||
|
|
||||||
interface DaemonRegistryContextValue {
|
|
||||||
daemons: HostProfile[]
|
|
||||||
isLoading: boolean
|
|
||||||
error: unknown | null
|
|
||||||
upsertDirectConnection: (input: {
|
|
||||||
serverId: string
|
|
||||||
endpoint: string
|
|
||||||
label?: string
|
|
||||||
}) => Promise<HostProfile>
|
|
||||||
upsertRelayConnection: (input: {
|
|
||||||
serverId: string
|
|
||||||
relayEndpoint: string
|
|
||||||
daemonPublicKeyB64: string
|
|
||||||
label?: string
|
|
||||||
}) => Promise<HostProfile>
|
|
||||||
updateHost: (serverId: string, updates: UpdateHostInput) => Promise<void>
|
|
||||||
removeHost: (serverId: string) => Promise<void>
|
|
||||||
removeConnection: (serverId: string, connectionId: string) => Promise<void>
|
|
||||||
upsertDaemonFromOffer: (offer: ConnectionOffer) => Promise<HostProfile>
|
|
||||||
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<HostProfile>
|
|
||||||
}
|
|
||||||
|
|
||||||
const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(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<string, unknown>
|
|
||||||
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<string, unknown>
|
|
||||||
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<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY, profiles)
|
|
||||||
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(profiles))
|
|
||||||
},
|
|
||||||
[queryClient]
|
|
||||||
)
|
|
||||||
|
|
||||||
const readDaemons = useCallback(() => {
|
|
||||||
return queryClient.getQueryData<HostProfile[]>(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 <DaemonRegistryContext.Provider value={value}>{children}</DaemonRegistryContext.Provider>
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1100,6 +1100,19 @@ function SessionProviderInternal({
|
|||||||
if (message.type !== "status") return;
|
if (message.type !== "status") return;
|
||||||
const serverInfo = parseServerInfoStatusPayload(message.payload);
|
const serverInfo = parseServerInfoStatusPayload(message.payload);
|
||||||
if (serverInfo) {
|
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, {
|
updateSessionServerInfo(serverId, {
|
||||||
serverId: serverInfo.serverId,
|
serverId: serverInfo.serverId,
|
||||||
hostname: serverInfo.hostname,
|
hostname: serverInfo.hostname,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
} from "@server/server/agent/agent-sdk-types";
|
} 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 { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||||
import { useFormPreferences, type FormPreferences } from "./use-form-preferences";
|
import { useFormPreferences, type FormPreferences } from "./use-form-preferences";
|
||||||
|
|
||||||
@@ -327,7 +327,7 @@ export function useAgentFormState(
|
|||||||
updateProviderPreferences,
|
updateProviderPreferences,
|
||||||
} = useFormPreferences();
|
} = useFormPreferences();
|
||||||
|
|
||||||
const { daemons } = useDaemonRegistry();
|
const daemons = useHosts();
|
||||||
|
|
||||||
// Build a set of valid server IDs for preference validation
|
// Build a set of valid server IDs for preference validation
|
||||||
const validServerIds = useMemo(
|
const validServerIds = useMemo(
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useMemo, useCallback, useSyncExternalStore } from "react";
|
import { useMemo, useCallback, useSyncExternalStore } from "react";
|
||||||
import { useShallow } from "zustand/shallow";
|
import { useShallow } from "zustand/shallow";
|
||||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||||
import type { Agent } from "@/stores/session-store";
|
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 {
|
export interface AggregatedAgent extends AgentDirectoryEntry {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -22,7 +21,7 @@ export interface AggregatedAgentsResult {
|
|||||||
export function useAggregatedAgents(options?: {
|
export function useAggregatedAgents(options?: {
|
||||||
includeArchived?: boolean;
|
includeArchived?: boolean;
|
||||||
}): AggregatedAgentsResult {
|
}): AggregatedAgentsResult {
|
||||||
const { daemons } = useDaemonRegistry();
|
const daemons = useHosts();
|
||||||
const runtime = getHostRuntimeStore();
|
const runtime = getHostRuntimeStore();
|
||||||
const includeArchived = options?.includeArchived ?? false;
|
const includeArchived = options?.includeArchived ?? false;
|
||||||
const runtimeVersion = useSyncExternalStore(
|
const runtimeVersion = useSyncExternalStore(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
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 { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
getHostRuntimeStore,
|
getHostRuntimeStore,
|
||||||
@@ -74,7 +74,7 @@ export function useAllAgentsList(options?: {
|
|||||||
serverId?: string | null;
|
serverId?: string | null;
|
||||||
includeArchived?: boolean;
|
includeArchived?: boolean;
|
||||||
}): AggregatedAgentsResult {
|
}): AggregatedAgentsResult {
|
||||||
const { daemons } = useDaemonRegistry();
|
const daemons = useHosts();
|
||||||
const runtime = getHostRuntimeStore();
|
const runtime = getHostRuntimeStore();
|
||||||
|
|
||||||
const serverId = useMemo(() => {
|
const serverId = useMemo(() => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { TextInput } from "react-native";
|
|||||||
import { router, usePathname, type Href } from "expo-router";
|
import { router, usePathname, type Href } from "expo-router";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
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 { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
import {
|
import {
|
||||||
@@ -104,7 +104,7 @@ export type CommandCenterItem =
|
|||||||
|
|
||||||
export function useCommandCenter() {
|
export function useCommandCenter() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { daemons } = useDaemonRegistry();
|
const daemons = useHosts();
|
||||||
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
|
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
|
||||||
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
|
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
FetchAgentsEntry,
|
FetchAgentsEntry,
|
||||||
FetchAgentsOptions,
|
FetchAgentsOptions,
|
||||||
} from "@server/client/daemon-client";
|
} 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 { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
HostRuntimeController,
|
HostRuntimeController,
|
||||||
@@ -72,6 +72,10 @@ class FakeDaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async ping(): Promise<{ rttMs: number }> {
|
||||||
|
return { rttMs: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
setConnectionState(next: ConnectionState): void {
|
setConnectionState(next: ConnectionState): void {
|
||||||
this.state = next;
|
this.state = next;
|
||||||
if (next.status === "disconnected") {
|
if (next.status === "disconnected") {
|
||||||
@@ -194,7 +198,7 @@ function makeDeps(
|
|||||||
createdClients.push(client);
|
createdClients.push(client);
|
||||||
return client as unknown as DaemonClient;
|
return client as unknown as DaemonClient;
|
||||||
},
|
},
|
||||||
measureLatency: async ({ connection }) => {
|
connectToDaemon: async ({ host, connection }) => {
|
||||||
const value = latencyByConnectionId[connection.id];
|
const value = latencyByConnectionId[connection.id];
|
||||||
if (value instanceof Error) {
|
if (value instanceof Error) {
|
||||||
throw value;
|
throw value;
|
||||||
@@ -202,7 +206,16 @@ function makeDeps(
|
|||||||
if (typeof value !== "number") {
|
if (typeof value !== "number") {
|
||||||
throw new Error(`missing latency for ${connection.id}`);
|
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",
|
getClientId: async () => "cid_test_runtime",
|
||||||
};
|
};
|
||||||
@@ -222,8 +235,24 @@ function createDeferred<T>() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<string, number>;
|
||||||
|
}
|
||||||
|
).connectionLastProbedAt.clear();
|
||||||
|
}
|
||||||
|
|
||||||
describe("HostRuntimeController", () => {
|
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({
|
const host = makeHost({
|
||||||
connections: [
|
connections: [
|
||||||
{
|
{
|
||||||
@@ -236,7 +265,7 @@ describe("HostRuntimeController", () => {
|
|||||||
const idleClient = new FakeDaemonClient();
|
const idleClient = new FakeDaemonClient();
|
||||||
const deps: HostRuntimeControllerDeps = {
|
const deps: HostRuntimeControllerDeps = {
|
||||||
createClient: () => idleClient as unknown as DaemonClient,
|
createClient: () => idleClient as unknown as DaemonClient,
|
||||||
measureLatency: async () => {
|
connectToDaemon: async () => {
|
||||||
throw new Error("probe unavailable");
|
throw new Error("probe unavailable");
|
||||||
},
|
},
|
||||||
getClientId: async () => "cid_test_runtime",
|
getClientId: async () => "cid_test_runtime",
|
||||||
@@ -251,7 +280,11 @@ describe("HostRuntimeController", () => {
|
|||||||
// Intentionally do not emit a connected state; stay in idle.
|
// 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<void>;
|
||||||
|
}
|
||||||
|
).switchToConnection({ connectionId: "direct:lan:6767" });
|
||||||
|
|
||||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||||
expect(controller.getSnapshot().connectionStatus).toBe("connecting");
|
expect(controller.getSnapshot().connectionStatus).toBe("connecting");
|
||||||
@@ -277,18 +310,24 @@ describe("HostRuntimeController", () => {
|
|||||||
seenClientIds.push(clientId);
|
seenClientIds.push(clientId);
|
||||||
return fakeClient as unknown as DaemonClient;
|
return fakeClient as unknown as DaemonClient;
|
||||||
},
|
},
|
||||||
measureLatency: async () => 10,
|
connectToDaemon: async () => {
|
||||||
|
throw new Error("probe unavailable");
|
||||||
|
},
|
||||||
getClientId: async () => "cid_runtime_stable",
|
getClientId: async () => "cid_runtime_stable",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await controller.start({ autoProbe: false });
|
await (
|
||||||
|
controller as unknown as {
|
||||||
|
switchToConnection: (input: { connectionId: string }) => Promise<void>;
|
||||||
|
}
|
||||||
|
).switchToConnection({ connectionId: "direct:lan:6767" });
|
||||||
|
|
||||||
expect(seenClientIds).toEqual(["cid_runtime_stable"]);
|
expect(seenClientIds).toEqual(["cid_runtime_stable"]);
|
||||||
expect(controller.getSnapshot().connectionStatus).toBe("online");
|
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 host = makeHost({ preferredConnectionId: "direct:lan:6767" });
|
||||||
const clients: FakeDaemonClient[] = [];
|
const clients: FakeDaemonClient[] = [];
|
||||||
const latencies: Record<string, number | Error> = {
|
const latencies: Record<string, number | Error> = {
|
||||||
@@ -303,10 +342,62 @@ describe("HostRuntimeController", () => {
|
|||||||
await controller.start({ autoProbe: false });
|
await controller.start({ autoProbe: false });
|
||||||
|
|
||||||
const snapshot = controller.getSnapshot();
|
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(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[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<number>();
|
||||||
|
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 () => {
|
it("fails over when active connection becomes unavailable", async () => {
|
||||||
@@ -323,17 +414,19 @@ describe("HostRuntimeController", () => {
|
|||||||
|
|
||||||
await controller.start({ autoProbe: false });
|
await controller.start({ autoProbe: false });
|
||||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
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["direct:lan:6767"] = new Error("direct unavailable");
|
||||||
latencies["relay:relay.paseo.sh:443"] = 42;
|
latencies["relay:relay.paseo.sh:443"] = 42;
|
||||||
|
clearProbeBackoff(controller);
|
||||||
await controller.runProbeCycleNow();
|
await controller.runProbeCycleNow();
|
||||||
|
|
||||||
const snapshot = controller.getSnapshot();
|
const snapshot = controller.getSnapshot();
|
||||||
expect(snapshot.activeConnectionId).toBe("relay:relay.paseo.sh:443");
|
expect(snapshot.activeConnectionId).toBe("relay:relay.paseo.sh:443");
|
||||||
expect(snapshot.connectionStatus).toBe("online");
|
expect(snapshot.connectionStatus).toBe("online");
|
||||||
expect(clients).toHaveLength(2);
|
expect(snapshot.client).not.toBe(initialClient);
|
||||||
expect(clients[0]?.closeCalls).toBe(1);
|
expect((initialClient as unknown as FakeDaemonClient | null)?.closeCalls).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("switches only after the faster alternative wins consecutive probes", async () => {
|
it("switches only after the faster alternative wins consecutive probes", async () => {
|
||||||
@@ -353,15 +446,24 @@ describe("HostRuntimeController", () => {
|
|||||||
|
|
||||||
latencies["direct:lan:6767"] = 95;
|
latencies["direct:lan:6767"] = 95;
|
||||||
latencies["relay:relay.paseo.sh:443"] = 30;
|
latencies["relay:relay.paseo.sh:443"] = 30;
|
||||||
|
clearProbeBackoff(controller);
|
||||||
await controller.runProbeCycleNow();
|
await controller.runProbeCycleNow();
|
||||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||||
|
|
||||||
|
clearProbeBackoff(controller);
|
||||||
await controller.runProbeCycleNow();
|
await controller.runProbeCycleNow();
|
||||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||||
|
|
||||||
await controller.runProbeCycleNow();
|
let switched = controller.getSnapshot().activeConnectionId === "relay:relay.paseo.sh:443";
|
||||||
expect(controller.getSnapshot().activeConnectionId).toBe("relay:relay.paseo.sh:443");
|
for (let index = 0; index < 6 && !switched; index += 1) {
|
||||||
expect(clients).toHaveLength(2);
|
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 () => {
|
it("does not switch on a transient latency spike", async () => {
|
||||||
@@ -381,24 +483,35 @@ describe("HostRuntimeController", () => {
|
|||||||
|
|
||||||
latencies["direct:lan:6767"] = 100;
|
latencies["direct:lan:6767"] = 100;
|
||||||
latencies["relay:relay.paseo.sh:443"] = 20;
|
latencies["relay:relay.paseo.sh:443"] = 20;
|
||||||
|
clearProbeBackoff(controller);
|
||||||
await controller.runProbeCycleNow();
|
await controller.runProbeCycleNow();
|
||||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||||
|
|
||||||
latencies["direct:lan:6767"] = 20;
|
latencies["direct:lan:6767"] = 20;
|
||||||
latencies["relay:relay.paseo.sh:443"] = 90;
|
latencies["relay:relay.paseo.sh:443"] = 90;
|
||||||
|
clearProbeBackoff(controller);
|
||||||
await controller.runProbeCycleNow();
|
await controller.runProbeCycleNow();
|
||||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||||
|
|
||||||
latencies["direct:lan:6767"] = 100;
|
latencies["direct:lan:6767"] = 100;
|
||||||
latencies["relay:relay.paseo.sh:443"] = 20;
|
latencies["relay:relay.paseo.sh:443"] = 20;
|
||||||
|
clearProbeBackoff(controller);
|
||||||
await controller.runProbeCycleNow();
|
await controller.runProbeCycleNow();
|
||||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||||
|
|
||||||
|
clearProbeBackoff(controller);
|
||||||
await controller.runProbeCycleNow();
|
await controller.runProbeCycleNow();
|
||||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||||
|
|
||||||
await controller.runProbeCycleNow();
|
let switched = controller.getSnapshot().activeConnectionId === "relay:relay.paseo.sh:443";
|
||||||
expect(controller.getSnapshot().activeConnectionId).toBe("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 () => {
|
it("exposes one snapshot with active connection and status from same source", async () => {
|
||||||
@@ -604,7 +717,11 @@ describe("HostRuntimeController", () => {
|
|||||||
createdClients.push(client);
|
createdClients.push(client);
|
||||||
return client as unknown as DaemonClient;
|
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",
|
getClientId: async () => "cid_test_runtime",
|
||||||
};
|
};
|
||||||
const controller = new HostRuntimeController({
|
const controller = new HostRuntimeController({
|
||||||
@@ -681,21 +798,32 @@ describe("HostRuntimeController", () => {
|
|||||||
host,
|
host,
|
||||||
deps: {
|
deps: {
|
||||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||||
measureLatency: async () => {
|
connectToDaemon: async ({ host }) => {
|
||||||
probeCalls += 1;
|
probeCalls += 1;
|
||||||
if (probeCalls === 1) {
|
const client = new FakeDaemonClient();
|
||||||
return await slowProbe.promise;
|
client.connectCalls = 1;
|
||||||
}
|
client.setConnectionState({ status: "connected" });
|
||||||
if (probeCalls === 2) {
|
client.ping = async () => {
|
||||||
return await fastProbe.promise;
|
if (probeCalls === 1) {
|
||||||
}
|
return { rttMs: await slowProbe.promise };
|
||||||
throw new Error("unexpected probe call");
|
}
|
||||||
|
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",
|
getClientId: async () => "cid_test_runtime",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const first = controller.runProbeCycleNow();
|
const first = controller.runProbeCycleNow();
|
||||||
|
clearProbeBackoff(controller);
|
||||||
const second = controller.runProbeCycleNow();
|
const second = controller.runProbeCycleNow();
|
||||||
|
|
||||||
fastProbe.resolve(12);
|
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({
|
const host = makeHost({
|
||||||
connections: [
|
connections: [
|
||||||
{
|
{
|
||||||
@@ -729,10 +857,7 @@ describe("HostRuntimeController", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const slowProbe = createDeferred<number>();
|
|
||||||
const fastProbe = createDeferred<number>();
|
|
||||||
const createdClients: FakeDaemonClient[] = [];
|
const createdClients: FakeDaemonClient[] = [];
|
||||||
let probeCalls = 0;
|
|
||||||
|
|
||||||
const controller = new HostRuntimeController({
|
const controller = new HostRuntimeController({
|
||||||
host,
|
host,
|
||||||
@@ -742,18 +867,13 @@ describe("HostRuntimeController", () => {
|
|||||||
createdClients.push(client);
|
createdClients.push(client);
|
||||||
return client as unknown as DaemonClient;
|
return client as unknown as DaemonClient;
|
||||||
},
|
},
|
||||||
measureLatency: async () => {
|
connectToDaemon: async ({ host }) => {
|
||||||
probeCalls += 1;
|
const client = makeConnectedProbeClient(10);
|
||||||
if (probeCalls === 1) {
|
return {
|
||||||
return 10;
|
client: client as unknown as DaemonClient,
|
||||||
}
|
serverId: host.serverId,
|
||||||
if (probeCalls === 2) {
|
hostname: host.label ?? null,
|
||||||
return await slowProbe.promise;
|
};
|
||||||
}
|
|
||||||
if (probeCalls === 3) {
|
|
||||||
return await fastProbe.promise;
|
|
||||||
}
|
|
||||||
return 10;
|
|
||||||
},
|
},
|
||||||
getClientId: async () => "cid_test_runtime",
|
getClientId: async () => "cid_test_runtime",
|
||||||
},
|
},
|
||||||
@@ -763,24 +883,13 @@ describe("HostRuntimeController", () => {
|
|||||||
const activeClientBeforeProbes = controller.getSnapshot().client;
|
const activeClientBeforeProbes = controller.getSnapshot().client;
|
||||||
const generationBeforeProbes = controller.getSnapshot().clientGeneration;
|
const generationBeforeProbes = controller.getSnapshot().clientGeneration;
|
||||||
|
|
||||||
const first = controller.runProbeCycleNow();
|
clearProbeBackoff(controller);
|
||||||
const second = controller.runProbeCycleNow();
|
await controller.runProbeCycleNow();
|
||||||
|
|
||||||
fastProbe.resolve(12);
|
|
||||||
await second;
|
|
||||||
expect(controller.getSnapshot().client).toBe(activeClientBeforeProbes);
|
expect(controller.getSnapshot().client).toBe(activeClientBeforeProbes);
|
||||||
expect(controller.getSnapshot().clientGeneration).toBe(
|
expect(controller.getSnapshot().clientGeneration).toBe(
|
||||||
generationBeforeProbes
|
generationBeforeProbes
|
||||||
);
|
);
|
||||||
|
expect(createdClients).toHaveLength(0);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -796,10 +905,15 @@ describe("HostRuntimeStore", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
const fakeClient = new FakeDaemonClient();
|
const fakeClient = new FakeDaemonClient();
|
||||||
|
fakeClient.setConnectionState({ status: "connected" });
|
||||||
const store = new HostRuntimeStore({
|
const store = new HostRuntimeStore({
|
||||||
deps: {
|
deps: {
|
||||||
createClient: () => fakeClient as unknown as DaemonClient,
|
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",
|
getClientId: async () => "cid_test_runtime",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -832,9 +946,9 @@ describe("HostRuntimeStore", () => {
|
|||||||
useSessionStore.getState().clearSession(host.serverId);
|
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({
|
const host = makeHost({
|
||||||
serverId: "srv_deferred",
|
serverId: "srv_no_session",
|
||||||
connections: [
|
connections: [
|
||||||
{
|
{
|
||||||
id: "direct:lan:6767",
|
id: "direct:lan:6767",
|
||||||
@@ -844,26 +958,22 @@ describe("HostRuntimeStore", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
const fakeClient = new FakeDaemonClient();
|
const fakeClient = new FakeDaemonClient();
|
||||||
|
fakeClient.setConnectionState({ status: "connected" });
|
||||||
const store = new HostRuntimeStore({
|
const store = new HostRuntimeStore({
|
||||||
deps: {
|
deps: {
|
||||||
createClient: () => fakeClient as unknown as DaemonClient,
|
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",
|
getClientId: async () => "cid_test_runtime",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
store.syncHosts([host]);
|
store.syncHosts([host]);
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
const timeoutAt = Date.now() + 200;
|
||||||
expect(fakeClient.fetchAgentsCalls).toHaveLength(0);
|
|
||||||
|
|
||||||
useSessionStore.getState().initializeSession(
|
|
||||||
host.serverId,
|
|
||||||
fakeClient as unknown as DaemonClient,
|
|
||||||
null as any
|
|
||||||
);
|
|
||||||
|
|
||||||
const timeoutAt = Date.now() + 600;
|
|
||||||
while (fakeClient.fetchAgentsCalls.length === 0 && Date.now() < timeoutAt) {
|
while (fakeClient.fetchAgentsCalls.length === 0 && Date.now() < timeoutAt) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
}
|
}
|
||||||
@@ -872,12 +982,11 @@ describe("HostRuntimeStore", () => {
|
|||||||
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
|
expect(fakeClient.fetchAgentsCalls[0]).toEqual({
|
||||||
filter: { includeArchived: true },
|
filter: { includeArchived: true },
|
||||||
sort: [{ key: "updated_at", direction: "desc" }],
|
sort: [{ key: "updated_at", direction: "desc" }],
|
||||||
subscribe: { subscriptionId: "app:srv_deferred" },
|
subscribe: { subscriptionId: "app:srv_no_session" },
|
||||||
page: { limit: 200 },
|
page: { limit: 200 },
|
||||||
});
|
});
|
||||||
|
|
||||||
store.syncHosts([]);
|
store.syncHosts([]);
|
||||||
useSessionStore.getState().clearSession(host.serverId);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetches all pages during bootstrap so older workspace agents are present", async () => {
|
it("fetches all pages during bootstrap so older workspace agents are present", async () => {
|
||||||
@@ -892,6 +1001,7 @@ describe("HostRuntimeStore", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
const fakeClient = new FakeDaemonClient();
|
const fakeClient = new FakeDaemonClient();
|
||||||
|
fakeClient.setConnectionState({ status: "connected" });
|
||||||
fakeClient.fetchAgentsResponses.push(
|
fakeClient.fetchAgentsResponses.push(
|
||||||
makeFetchAgentsPayload({
|
makeFetchAgentsPayload({
|
||||||
entries: [
|
entries: [
|
||||||
@@ -923,7 +1033,11 @@ describe("HostRuntimeStore", () => {
|
|||||||
const store = new HostRuntimeStore({
|
const store = new HostRuntimeStore({
|
||||||
deps: {
|
deps: {
|
||||||
createClient: () => fakeClient as unknown as DaemonClient,
|
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",
|
getClientId: async () => "cid_test_runtime",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -991,10 +1105,15 @@ describe("HostRuntimeStore", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
const fakeClient = new FakeDaemonClient();
|
const fakeClient = new FakeDaemonClient();
|
||||||
|
fakeClient.setConnectionState({ status: "connected" });
|
||||||
const store = new HostRuntimeStore({
|
const store = new HostRuntimeStore({
|
||||||
deps: {
|
deps: {
|
||||||
createClient: () => fakeClient as unknown as DaemonClient,
|
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",
|
getClientId: async () => "cid_test_runtime",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1041,7 +1160,7 @@ describe("HostRuntimeStore", () => {
|
|||||||
useSessionStore.getState().clearSession(host.serverId);
|
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({
|
const host = makeHost({
|
||||||
connections: [
|
connections: [
|
||||||
{
|
{
|
||||||
@@ -1056,7 +1175,7 @@ describe("HostRuntimeStore", () => {
|
|||||||
createClient: () => {
|
createClient: () => {
|
||||||
throw new Error("create client failed");
|
throw new Error("create client failed");
|
||||||
},
|
},
|
||||||
measureLatency: async () => {
|
connectToDaemon: async () => {
|
||||||
throw new Error("probe unavailable");
|
throw new Error("probe unavailable");
|
||||||
},
|
},
|
||||||
getClientId: async () => "cid_test_runtime",
|
getClientId: async () => "cid_test_runtime",
|
||||||
@@ -1066,12 +1185,51 @@ describe("HostRuntimeStore", () => {
|
|||||||
store.syncHosts([host]);
|
store.syncHosts([host]);
|
||||||
let snapshot = store.getSnapshot(host.serverId);
|
let snapshot = store.getSnapshot(host.serverId);
|
||||||
const timeoutAt = Date.now() + 100;
|
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));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
snapshot = store.getSnapshot(host.serverId);
|
snapshot = store.getSnapshot(host.serverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(snapshot?.connectionStatus).toBe("error");
|
expect(snapshot?.connectionStatus).toBe("connecting");
|
||||||
expect(snapshot?.lastError).toBe("create client failed");
|
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([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,30 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore, useCallback, useMemo } from "react";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import {
|
import {
|
||||||
DaemonClient,
|
DaemonClient,
|
||||||
type ConnectionState,
|
type ConnectionState,
|
||||||
type DaemonClientDiagnosticsEvent,
|
type DaemonClientDiagnosticsEvent,
|
||||||
type FetchAgentsOptions,
|
type FetchAgentsOptions,
|
||||||
} from "@server/client/daemon-client";
|
} 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 {
|
import {
|
||||||
buildDaemonWebSocketUrl,
|
buildDaemonWebSocketUrl,
|
||||||
buildRelayWebSocketUrl,
|
buildRelayWebSocketUrl,
|
||||||
} from "@/utils/daemon-endpoints";
|
} from "@/utils/daemon-endpoints";
|
||||||
import { measureConnectionLatency } from "@/utils/test-daemon-connection";
|
|
||||||
import { getOrCreateClientId } from "@/utils/client-id";
|
import { getOrCreateClientId } from "@/utils/client-id";
|
||||||
import {
|
import {
|
||||||
selectBestConnection,
|
selectBestConnection,
|
||||||
@@ -132,15 +146,23 @@ export type HostRuntimeControllerDeps = {
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
runtimeGeneration: number;
|
runtimeGeneration: number;
|
||||||
}) => DaemonClient;
|
}) => DaemonClient;
|
||||||
measureLatency: (input: {
|
connectToDaemon: (input: {
|
||||||
host: HostProfile;
|
host: HostProfile;
|
||||||
connection: HostConnection;
|
connection: HostConnection;
|
||||||
}) => Promise<number>;
|
}) => Promise<{
|
||||||
|
client: DaemonClient;
|
||||||
|
serverId: string;
|
||||||
|
hostname: string | null;
|
||||||
|
}>;
|
||||||
getClientId: () => Promise<string>;
|
getClientId: () => Promise<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HostRuntimeStartOptions = {
|
export type HostRuntimeStartOptions = {
|
||||||
autoProbe?: boolean;
|
autoProbe?: boolean;
|
||||||
|
initialConnection?: {
|
||||||
|
connectionId: string;
|
||||||
|
existingClient: DaemonClient;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const PROBE_TICK_MS = 2_000;
|
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_THRESHOLD_MS = 40;
|
||||||
const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3;
|
const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3;
|
||||||
const DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT = 200;
|
const DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT = 200;
|
||||||
const AGENT_DIRECTORY_SESSION_RETRY_MS = 150;
|
|
||||||
const DEFAULT_AGENT_DIRECTORY_SORT: NonNullable<FetchAgentsOptions["sort"]> = [
|
const DEFAULT_AGENT_DIRECTORY_SORT: NonNullable<FetchAgentsOptions["sort"]> = [
|
||||||
{ key: "updated_at", direction: "desc" },
|
{ key: "updated_at", direction: "desc" },
|
||||||
];
|
];
|
||||||
@@ -493,8 +515,8 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
measureLatency: ({ host, connection }) =>
|
connectToDaemon: ({ host, connection }) =>
|
||||||
measureConnectionLatency(connection, { serverId: host.serverId }),
|
connectToDaemon(connection, { serverId: host.serverId }),
|
||||||
getClientId: () => getOrCreateClientId(),
|
getClientId: () => getOrCreateClientId(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -560,6 +582,12 @@ export class HostRuntimeController {
|
|||||||
}
|
}
|
||||||
this.started = true;
|
this.started = true;
|
||||||
this.trackConnectionFirstSeen();
|
this.trackConnectionFirstSeen();
|
||||||
|
if (options?.initialConnection) {
|
||||||
|
await this.switchToConnection({
|
||||||
|
connectionId: options.initialConnection.connectionId,
|
||||||
|
existingClient: options.initialConnection.existingClient,
|
||||||
|
});
|
||||||
|
}
|
||||||
await this.runProbeCycleNow();
|
await this.runProbeCycleNow();
|
||||||
if (options?.autoProbe !== false) {
|
if (options?.autoProbe !== false) {
|
||||||
this.probeIntervalHandle = setInterval(() => {
|
this.probeIntervalHandle = setInterval(() => {
|
||||||
@@ -645,6 +673,13 @@ export class HostRuntimeController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async activateConnection(input: {
|
||||||
|
connectionId: string;
|
||||||
|
existingClient: DaemonClient;
|
||||||
|
}): Promise<void> {
|
||||||
|
await this.switchToConnection(input);
|
||||||
|
}
|
||||||
|
|
||||||
async runProbeCycleNow(): Promise<void> {
|
async runProbeCycleNow(): Promise<void> {
|
||||||
const requestVersion = ++this.probeRequestVersion;
|
const requestVersion = ++this.probeRequestVersion;
|
||||||
if (this.host.connections.length === 0) {
|
if (this.host.connections.length === 0) {
|
||||||
@@ -679,73 +714,103 @@ export class HostRuntimeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const probeByConnectionId = new Map(this.snapshot.probeByConnectionId);
|
const probeByConnectionId = new Map(this.snapshot.probeByConnectionId);
|
||||||
await Promise.all(
|
for (const connection of connectionsToProbe) {
|
||||||
connectionsToProbe.map(async (connection) => {
|
this.connectionLastProbedAt.set(connection.id, performance.now());
|
||||||
this.connectionLastProbedAt.set(connection.id, performance.now());
|
probeByConnectionId.set(connection.id, {
|
||||||
try {
|
status: "pending",
|
||||||
const latencyMs = await this.deps.measureLatency({
|
latencyMs: null,
|
||||||
host: this.host,
|
});
|
||||||
connection,
|
}
|
||||||
|
this.updateSnapshot({ probeByConnectionId: new Map(probeByConnectionId) });
|
||||||
|
|
||||||
|
let remaining = connectionsToProbe.length;
|
||||||
|
let activationLock: Promise<void> | null = null;
|
||||||
|
|
||||||
|
const publishProbeState = (): void => {
|
||||||
|
if (!this.isCurrentProbeRequest(requestVersion)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.updateSnapshot({ probeByConnectionId: new Map(probeByConnectionId) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const maybeActivateFirstAvailable = async (
|
||||||
|
connectionId: string,
|
||||||
|
client: DaemonClient
|
||||||
|
): Promise<boolean> => {
|
||||||
|
while (!this.snapshot.activeConnectionId) {
|
||||||
|
if (!activationLock) {
|
||||||
|
activationLock = this.switchToConnection({
|
||||||
|
connectionId,
|
||||||
|
expectedProbeVersion: requestVersion,
|
||||||
|
existingClient: client,
|
||||||
|
}).finally(() => {
|
||||||
|
activationLock = null;
|
||||||
});
|
});
|
||||||
probeByConnectionId.set(connection.id, {
|
await activationLock;
|
||||||
status: "available",
|
return this.snapshot.activeConnectionId === connectionId;
|
||||||
latencyMs,
|
}
|
||||||
});
|
await activationLock;
|
||||||
} catch {
|
}
|
||||||
probeByConnectionId.set(connection.id, {
|
return false;
|
||||||
status: "unavailable",
|
};
|
||||||
latencyMs: null,
|
|
||||||
|
const finalizeProbeCycle = async (): Promise<void> => {
|
||||||
|
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
return;
|
||||||
);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeProbe?.status === "unavailable") {
|
if (activeProbe?.status === "unavailable") {
|
||||||
const nextConnectionId = selectBestConnection({
|
const nextConnectionId = selectBestConnection({
|
||||||
candidates: buildConnectionCandidates(this.host),
|
candidates: buildConnectionCandidates(this.host),
|
||||||
probeByConnectionId,
|
probeByConnectionId,
|
||||||
});
|
|
||||||
if (nextConnectionId && nextConnectionId !== currentActiveConnectionId) {
|
|
||||||
await this.switchToConnection({
|
|
||||||
connectionId: nextConnectionId,
|
|
||||||
expectedProbeVersion: requestVersion,
|
|
||||||
});
|
});
|
||||||
|
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())
|
const available = Array.from(probeByConnectionId.entries())
|
||||||
.filter(([, probe]) => probe.status === "available")
|
.filter(
|
||||||
|
(
|
||||||
|
entry
|
||||||
|
): entry is [string, Extract<ConnectionProbeState, { status: "available" }>] =>
|
||||||
|
entry[1].status === "available"
|
||||||
|
)
|
||||||
.map(([connectionId, probe]) => ({
|
.map(([connectionId, probe]) => ({
|
||||||
connectionId,
|
connectionId,
|
||||||
latencyMs: (probe as { status: "available"; latencyMs: number }).latencyMs,
|
latencyMs: probe.latencyMs,
|
||||||
}))
|
}))
|
||||||
.sort((left, right) => left.latencyMs - right.latencyMs);
|
.sort((left, right) => left.latencyMs - right.latencyMs);
|
||||||
|
|
||||||
@@ -756,8 +821,7 @@ export class HostRuntimeController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeLatency = activeProbe.latencyMs;
|
const improvement = activeProbe.latencyMs - fastest.latencyMs;
|
||||||
const improvement = activeLatency - fastest.latencyMs;
|
|
||||||
if (improvement < ADAPTIVE_SWITCH_THRESHOLD_MS) {
|
if (improvement < ADAPTIVE_SWITCH_THRESHOLD_MS) {
|
||||||
this.switchCandidateConnectionId = null;
|
this.switchCandidateConnectionId = null;
|
||||||
this.switchCandidateHitCount = 0;
|
this.switchCandidateHitCount = 0;
|
||||||
@@ -772,8 +836,7 @@ export class HostRuntimeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.switchCandidateHitCount >=
|
this.switchCandidateHitCount >= ADAPTIVE_SWITCH_CONSECUTIVE_PROBES
|
||||||
ADAPTIVE_SWITCH_CONSECUTIVE_PROBES
|
|
||||||
) {
|
) {
|
||||||
this.switchCandidateConnectionId = null;
|
this.switchCandidateConnectionId = null;
|
||||||
this.switchCandidateHitCount = 0;
|
this.switchCandidateHitCount = 0;
|
||||||
@@ -782,7 +845,63 @@ export class HostRuntimeController {
|
|||||||
expectedProbeVersion: requestVersion,
|
expectedProbeVersion: requestVersion,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
await new Promise<void>((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(
|
private updateSnapshot(
|
||||||
@@ -880,13 +999,20 @@ export class HostRuntimeController {
|
|||||||
private async switchToConnection(input: {
|
private async switchToConnection(input: {
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
expectedProbeVersion?: number;
|
expectedProbeVersion?: number;
|
||||||
|
existingClient?: DaemonClient;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
if (!this.canProceedForProbe(input.expectedProbeVersion)) {
|
if (!this.canProceedForProbe(input.expectedProbeVersion)) {
|
||||||
|
if (input.existingClient) {
|
||||||
|
await input.existingClient.close().catch(() => undefined);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { connectionId, expectedProbeVersion } = input;
|
const { connectionId, expectedProbeVersion, existingClient } = input;
|
||||||
const connection = findConnectionById(this.host, connectionId);
|
const connection = findConnectionById(this.host, connectionId);
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
|
if (existingClient) {
|
||||||
|
await existingClient.close().catch(() => undefined);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const requestVersion = ++this.switchRequestVersion;
|
const requestVersion = ++this.switchRequestVersion;
|
||||||
@@ -895,6 +1021,9 @@ export class HostRuntimeController {
|
|||||||
try {
|
try {
|
||||||
clientId = await this.resolveClientId();
|
clientId = await this.resolveClientId();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (existingClient) {
|
||||||
|
await existingClient.close().catch(() => undefined);
|
||||||
|
}
|
||||||
if (!this.isCurrentSwitchRequest(requestVersion)) {
|
if (!this.isCurrentSwitchRequest(requestVersion)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -910,9 +1039,15 @@ export class HostRuntimeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!this.isCurrentSwitchRequest(requestVersion)) {
|
if (!this.isCurrentSwitchRequest(requestVersion)) {
|
||||||
|
if (existingClient) {
|
||||||
|
await existingClient.close().catch(() => undefined);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.canProceedForProbe(expectedProbeVersion)) {
|
if (!this.canProceedForProbe(expectedProbeVersion)) {
|
||||||
|
if (existingClient) {
|
||||||
|
await existingClient.close().catch(() => undefined);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -926,14 +1061,20 @@ export class HostRuntimeController {
|
|||||||
await previousClient.close().catch(() => undefined);
|
await previousClient.close().catch(() => undefined);
|
||||||
}
|
}
|
||||||
if (!this.isCurrentSwitchRequest(requestVersion)) {
|
if (!this.isCurrentSwitchRequest(requestVersion)) {
|
||||||
|
if (existingClient) {
|
||||||
|
await existingClient.close().catch(() => undefined);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.canProceedForProbe(expectedProbeVersion)) {
|
if (!this.canProceedForProbe(expectedProbeVersion)) {
|
||||||
|
if (existingClient) {
|
||||||
|
await existingClient.close().catch(() => undefined);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextGeneration = this.snapshot.clientGeneration + 1;
|
const nextGeneration = this.snapshot.clientGeneration + 1;
|
||||||
const client = this.deps.createClient({
|
const client = existingClient ?? this.deps.createClient({
|
||||||
host: this.host,
|
host: this.host,
|
||||||
connection,
|
connection,
|
||||||
clientId,
|
clientId,
|
||||||
@@ -1000,7 +1141,9 @@ export class HostRuntimeController {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
if (!existingClient) {
|
||||||
|
await client.connect();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
!this.isCurrentSwitchRequest(requestVersion) ||
|
!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 {
|
export class HostRuntimeStore {
|
||||||
private controllers = new Map<string, HostRuntimeController>();
|
private controllers = new Map<string, HostRuntimeController>();
|
||||||
private serverListeners = new Map<string, Set<() => void>>();
|
private serverListeners = new Map<string, Set<() => void>>();
|
||||||
private globalListeners = new Set<() => void>();
|
private globalListeners = new Set<() => void>();
|
||||||
|
private hostListListeners = new Set<() => void>();
|
||||||
private version = 0;
|
private version = 0;
|
||||||
|
private hostListVersion = 0;
|
||||||
|
private hosts: HostProfile[] = [];
|
||||||
private deps: HostRuntimeControllerDeps;
|
private deps: HostRuntimeControllerDeps;
|
||||||
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
|
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
|
||||||
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>();
|
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>();
|
||||||
private agentDirectorySessionRetryTimerByServer = new Map<
|
private storageLoaded = false;
|
||||||
string,
|
private bootstrapAttempted = false;
|
||||||
ReturnType<typeof setTimeout>
|
|
||||||
>();
|
|
||||||
|
|
||||||
constructor(input?: {
|
constructor(input?: {
|
||||||
deps?: HostRuntimeControllerDeps;
|
deps?: HostRuntimeControllerDeps;
|
||||||
@@ -1049,7 +1199,335 @@ export class HostRuntimeStore {
|
|||||||
this.deps = input?.deps ?? createDefaultDeps();
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<HostProfile> {
|
||||||
|
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<HostProfile> {
|
||||||
|
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<HostProfile> {
|
||||||
|
return this.upsertRelayConnection({
|
||||||
|
serverId: offer.serverId,
|
||||||
|
relayEndpoint: offer.relay.endpoint,
|
||||||
|
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertConnectionFromOfferUrl(offerUrlOrFragment: string): Promise<HostProfile> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
const remaining = this.hosts.filter((daemon) => daemon.serverId !== serverId);
|
||||||
|
this.setHostsAndSync(remaining);
|
||||||
|
await this.persistHosts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeConnection(serverId: string, connectionId: string): Promise<void> {
|
||||||
|
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<HostProfile> {
|
||||||
|
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<void> {
|
||||||
|
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));
|
const nextIds = new Set(hosts.map((host) => host.serverId));
|
||||||
for (const [serverId, controller] of this.controllers) {
|
for (const [serverId, controller] of this.controllers) {
|
||||||
if (nextIds.has(serverId)) {
|
if (nextIds.has(serverId)) {
|
||||||
@@ -1058,15 +1536,20 @@ export class HostRuntimeStore {
|
|||||||
this.controllers.delete(serverId);
|
this.controllers.delete(serverId);
|
||||||
this.lastConnectionStatusByServer.delete(serverId);
|
this.lastConnectionStatusByServer.delete(serverId);
|
||||||
this.agentDirectoryBootstrapInFlight.delete(serverId);
|
this.agentDirectoryBootstrapInFlight.delete(serverId);
|
||||||
this.clearAgentDirectorySessionRetry(serverId);
|
|
||||||
void controller.stop();
|
void controller.stop();
|
||||||
this.emit(serverId);
|
this.emit(serverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const host of hosts) {
|
for (const host of hosts) {
|
||||||
|
const initialConnection = options?.initialConnectionByServerId?.get(host.serverId);
|
||||||
const existing = this.controllers.get(host.serverId);
|
const existing = this.controllers.get(host.serverId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
void existing.updateHost(host);
|
void existing.updateHost(host);
|
||||||
|
if (initialConnection) {
|
||||||
|
void existing.activateConnection(initialConnection).catch(() => {
|
||||||
|
void initialConnection.existingClient.close().catch(() => undefined);
|
||||||
|
});
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const controller = new HostRuntimeController({
|
const controller = new HostRuntimeController({
|
||||||
@@ -1082,7 +1565,13 @@ export class HostRuntimeStore {
|
|||||||
this.maybeAutoBootstrapAgentDirectory(host.serverId);
|
this.maybeAutoBootstrapAgentDirectory(host.serverId);
|
||||||
this.emit(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);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
controller.markStartupError(message);
|
controller.markStartupError(message);
|
||||||
});
|
});
|
||||||
@@ -1109,21 +1598,14 @@ export class HostRuntimeStore {
|
|||||||
// Runtime owns directory bootstrap policy, including reconnect and delayed
|
// Runtime owns directory bootstrap policy, including reconnect and delayed
|
||||||
// session initialization races.
|
// session initialization races.
|
||||||
if (snapshot.connectionStatus !== "online") {
|
if (snapshot.connectionStatus !== "online") {
|
||||||
this.clearAgentDirectorySessionRetry(serverId);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!didTransitionOnline && snapshot.hasEverLoadedAgentDirectory) {
|
if (!didTransitionOnline && snapshot.hasEverLoadedAgentDirectory) {
|
||||||
this.clearAgentDirectorySessionRetry(serverId);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.agentDirectoryBootstrapInFlight.has(serverId)) {
|
if (this.agentDirectoryBootstrapInFlight.has(serverId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!useSessionStore.getState().sessions[serverId]) {
|
|
||||||
this.scheduleAgentDirectorySessionRetry(serverId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.clearAgentDirectorySessionRetry(serverId);
|
|
||||||
|
|
||||||
const bootstrap = Promise.resolve()
|
const bootstrap = Promise.resolve()
|
||||||
.then(() =>
|
.then(() =>
|
||||||
@@ -1150,26 +1632,6 @@ export class HostRuntimeStore {
|
|||||||
this.agentDirectoryBootstrapInFlight.set(serverId, bootstrap);
|
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 {
|
getSnapshot(serverId: string): HostRuntimeSnapshot | null {
|
||||||
return this.controllers.get(serverId)?.getSnapshot() ?? null;
|
return this.controllers.get(serverId)?.getSnapshot() ?? null;
|
||||||
}
|
}
|
||||||
@@ -1384,3 +1846,47 @@ export function useHostRuntimeSession(serverId: string): {
|
|||||||
isConnected: isHostRuntimeConnected(snapshot),
|
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<HostProfile>;
|
||||||
|
upsertRelayConnection: (input: {
|
||||||
|
serverId: string;
|
||||||
|
relayEndpoint: string;
|
||||||
|
daemonPublicKeyB64: string;
|
||||||
|
label?: string;
|
||||||
|
}) => Promise<HostProfile>;
|
||||||
|
upsertConnectionFromOffer: (offer: ConnectionOffer) => Promise<HostProfile>;
|
||||||
|
upsertConnectionFromOfferUrl: (offerUrlOrFragment: string) => Promise<HostProfile>;
|
||||||
|
renameHost: (serverId: string, label: string) => Promise<void>;
|
||||||
|
removeHost: (serverId: string) => Promise<void>;
|
||||||
|
removeConnection: (serverId: string, connectionId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
ExplorerSidebarAnimationProvider,
|
ExplorerSidebarAnimationProvider,
|
||||||
} from "@/contexts/explorer-sidebar-animation-context";
|
} from "@/contexts/explorer-sidebar-animation-context";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
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 { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
useHostRuntimeSession,
|
useHostRuntimeSession,
|
||||||
@@ -94,7 +94,7 @@ export function AgentReadyScreen({
|
|||||||
}) {
|
}) {
|
||||||
const resolvedAgentId = agentId?.trim() || undefined;
|
const resolvedAgentId = agentId?.trim() || undefined;
|
||||||
const resolvedServerId = serverId?.trim() || undefined;
|
const resolvedServerId = serverId?.trim() || undefined;
|
||||||
const { daemons } = useDaemonRegistry();
|
const daemons = useHosts();
|
||||||
const runtimeServerId = resolvedServerId ?? "";
|
const runtimeServerId = resolvedServerId ?? "";
|
||||||
const {
|
const {
|
||||||
snapshot: runtimeSnapshot,
|
snapshot: runtimeSnapshot,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
checkoutStatusQueryKey,
|
checkoutStatusQueryKey,
|
||||||
} from '@/hooks/use-checkout-status-query'
|
} from '@/hooks/use-checkout-status-query'
|
||||||
import { useAllAgentsList } from '@/hooks/use-all-agents-list'
|
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 { buildBranchComboOptions, normalizeBranchOptionName } from '@/utils/branch-suggestions'
|
||||||
import { shortenPath } from '@/utils/shorten-path'
|
import { shortenPath } from '@/utils/shorten-path'
|
||||||
import { collectAgentWorkingDirectorySuggestions } from '@/utils/agent-working-directory-suggestions'
|
import { collectAgentWorkingDirectorySuggestions } from '@/utils/agent-working-directory-suggestions'
|
||||||
@@ -138,7 +138,7 @@ function DraftAgentScreenContent({
|
|||||||
const { theme } = useUnistyles()
|
const { theme } = useUnistyles()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const { daemons } = useDaemonRegistry()
|
const daemons = useHosts()
|
||||||
const runtime = getHostRuntimeStore()
|
const runtime = getHostRuntimeStore()
|
||||||
const runtimeVersion = useSyncExternalStore(
|
const runtimeVersion = useSyncExternalStore(
|
||||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2 } from "lucide-react-native";
|
import { Sun, Moon, Monitor, Globe, Settings, RotateCw, Trash2 } from "lucide-react-native";
|
||||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
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 { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
import { MenuHeader } from "@/components/headers/menu-header";
|
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 routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : "";
|
||||||
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
|
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
|
||||||
const {
|
const {
|
||||||
daemons,
|
daemons, renameHost, removeHost, removeConnection,
|
||||||
isLoading: daemonLoading,
|
} = { daemons: useHosts(), ...useHostMutations() };
|
||||||
updateHost,
|
|
||||||
removeHost,
|
|
||||||
removeConnection,
|
|
||||||
} = useDaemonRegistry();
|
|
||||||
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
|
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
|
||||||
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
|
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
|
||||||
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
|
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
|
||||||
@@ -503,7 +500,7 @@ export default function SettingsScreen() {
|
|||||||
const [isRemovingHost, setIsRemovingHost] = useState(false);
|
const [isRemovingHost, setIsRemovingHost] = useState(false);
|
||||||
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
|
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
|
||||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||||
const isLoading = settingsLoading || daemonLoading;
|
const isLoading = settingsLoading;
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
const lastHandledEditHostRef = useRef<string | null>(null);
|
const lastHandledEditHostRef = useRef<string | null>(null);
|
||||||
const isDesktop = Platform.OS === "web";
|
const isDesktop = Platform.OS === "web";
|
||||||
@@ -615,7 +612,7 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSavingEdit(true);
|
setIsSavingEdit(true);
|
||||||
await updateHost(editingServerId, { label: nextLabel });
|
await renameHost(editingServerId, nextLabel);
|
||||||
handleCloseEditDaemon();
|
handleCloseEditDaemon();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Settings] Failed to rename host", error);
|
console.error("[Settings] Failed to rename host", error);
|
||||||
@@ -623,7 +620,7 @@ export default function SettingsScreen() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSavingEdit(false);
|
setIsSavingEdit(false);
|
||||||
}
|
}
|
||||||
}, [editingServerId, handleCloseEditDaemon, isSavingEdit, updateHost]);
|
}, [editingServerId, handleCloseEditDaemon, isSavingEdit, renameHost]);
|
||||||
|
|
||||||
const handleRemoveConnection = useCallback(
|
const handleRemoveConnection = useCallback(
|
||||||
async (serverId: string, connectionId: string) => {
|
async (serverId: string, connectionId: string) => {
|
||||||
@@ -767,7 +764,7 @@ export default function SettingsScreen() {
|
|||||||
hostname={pendingNameHostname}
|
hostname={pendingNameHostname}
|
||||||
onSkip={() => setPendingNameHost(null)}
|
onSkip={() => setPendingNameHost(null)}
|
||||||
onSave={(label) => {
|
onSave={(label) => {
|
||||||
void updateHost(pendingNameHost.serverId, { label }).finally(() => {
|
void renameHost(pendingNameHost.serverId, label).finally(() => {
|
||||||
setPendingNameHost(null);
|
setPendingNameHost(null);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Platform } from "react-native";
|
|||||||
import { File as FSFile, Paths } from "expo-file-system";
|
import { File as FSFile, Paths } from "expo-file-system";
|
||||||
import * as LegacyFileSystem from "expo-file-system/legacy";
|
import * as LegacyFileSystem from "expo-file-system/legacy";
|
||||||
import * as Sharing from "expo-sharing";
|
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 { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, never>
|
||||||
|
|
||||||
|
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<string, unknown>
|
||||||
|
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<string, unknown>
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { type HostConnection } from "@/contexts/daemon-registry-context";
|
import type { HostConnection } from "@/types/host-connection";
|
||||||
import {
|
import {
|
||||||
selectBestConnection,
|
selectBestConnection,
|
||||||
type ConnectionCandidate,
|
type ConnectionCandidate,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { HostConnection } from "@/contexts/daemon-registry-context";
|
import type { HostConnection } from "@/types/host-connection";
|
||||||
|
|
||||||
export type ConnectionCandidate = {
|
export type ConnectionCandidate = {
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -60,40 +60,44 @@ vi.mock("./client-id", () => ({
|
|||||||
getOrCreateClientId: clientIdMock.getOrCreateClientId,
|
getOrCreateClientId: clientIdMock.getOrCreateClientId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("test-daemon-connection probe client identity", () => {
|
describe("test-daemon-connection connectToDaemon", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
daemonClientMock.createdConfigs.length = 0;
|
daemonClientMock.createdConfigs.length = 0;
|
||||||
clientIdMock.getOrCreateClientId.mockClear();
|
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");
|
const mod = await import("./test-daemon-connection");
|
||||||
|
|
||||||
await mod.measureConnectionLatency({
|
const first = await mod.connectToDaemon({
|
||||||
id: "direct:lan:6767",
|
|
||||||
type: "directTcp",
|
|
||||||
endpoint: "lan:6767",
|
|
||||||
});
|
|
||||||
await mod.measureConnectionLatency({
|
|
||||||
id: "direct:lan:6767",
|
id: "direct:lan:6767",
|
||||||
type: "directTcp",
|
type: "directTcp",
|
||||||
endpoint: "lan:6767",
|
endpoint: "lan:6767",
|
||||||
});
|
});
|
||||||
|
await first.client.close();
|
||||||
|
|
||||||
const [first, second] = daemonClientMock.createdConfigs;
|
const second = await mod.connectToDaemon({
|
||||||
expect(first?.clientId).toBe("cid_shared_probe_test");
|
id: "direct:lan:6767",
|
||||||
expect(second?.clientId).toBe("cid_shared_probe_test");
|
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);
|
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");
|
const mod = await import("./test-daemon-connection");
|
||||||
|
|
||||||
await mod.measureConnectionLatency({
|
const result = await mod.connectToDaemon({
|
||||||
id: "socket:/tmp/paseo.sock",
|
id: "socket:/tmp/paseo.sock",
|
||||||
type: "directSocket",
|
type: "directSocket",
|
||||||
path: "/tmp/paseo.sock",
|
path: "/tmp/paseo.sock",
|
||||||
});
|
});
|
||||||
|
await result.client.close();
|
||||||
|
|
||||||
expect(daemonClientMock.createdConfigs[0]?.url).toBe(
|
expect(daemonClientMock.createdConfigs[0]?.url).toBe(
|
||||||
"paseo+local://socket?path=%2Ftmp%2Fpaseo.sock"
|
"paseo+local://socket?path=%2Ftmp%2Fpaseo.sock"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DaemonClient } from "@server/client/daemon-client";
|
import { DaemonClient } from "@server/client/daemon-client";
|
||||||
import type { DaemonClientConfig } 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 { getOrCreateClientId } from "./client-id";
|
||||||
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
|
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
|
||||||
import {
|
import {
|
||||||
@@ -45,7 +45,7 @@ export class DaemonConnectionTestError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildClientConfig(
|
export async function buildClientConfig(
|
||||||
connection: HostConnection,
|
connection: HostConnection,
|
||||||
serverId?: string
|
serverId?: string
|
||||||
): Promise<DaemonClientConfig> {
|
): Promise<DaemonClientConfig> {
|
||||||
@@ -96,7 +96,7 @@ async function buildClientConfig(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectAndProbe(
|
export function connectAndProbe(
|
||||||
config: DaemonClientConfig,
|
config: DaemonClientConfig,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
|
): 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;
|
return connection.type === "relay" ? 10_000 : 6_000;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function probeConnection(
|
export async function connectToDaemon(
|
||||||
connection: HostConnection,
|
connection: HostConnection,
|
||||||
options?: ProbeOptions,
|
options?: ProbeOptions,
|
||||||
): Promise<{ serverId: string; hostname: string | null }> {
|
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
|
||||||
const config = await buildClientConfig(connection, options?.serverId);
|
const config = await buildClientConfig(connection, options?.serverId);
|
||||||
const { client, serverId, hostname } = await connectAndProbe(config, resolveTimeout(connection, options));
|
return connectAndProbe(config, resolveTimeout(connection, options));
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
return { serverId, hostname };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function measureConnectionLatency(
|
|
||||||
connection: HostConnection,
|
|
||||||
options?: ProbeOptions,
|
|
||||||
): Promise<number> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { defineConfig, configDefaults } from "vitest/config";
|
import { defineConfig, configDefaults } from "vitest/config";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
const appNodeModules = path.resolve(__dirname, "node_modules");
|
||||||
|
const rootNodeModules = path.resolve(__dirname, "../../node_modules");
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
environment: "node",
|
environment: "node",
|
||||||
@@ -12,12 +15,27 @@ export default defineConfig({
|
|||||||
* keeps `process.send` intact so the app tests can boot before hitting the intentional failures.
|
* keeps `process.send` intact so the app tests can boot before hitting the intentional failures.
|
||||||
*/
|
*/
|
||||||
pool: "forks",
|
pool: "forks",
|
||||||
|
server: {
|
||||||
|
deps: {
|
||||||
|
fallbackCJS: true,
|
||||||
|
inline: [
|
||||||
|
"zustand",
|
||||||
|
"@tanstack/react-query",
|
||||||
|
"react-native-web",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "src"),
|
"@": path.resolve(__dirname, "src"),
|
||||||
"@server": path.resolve(__dirname, "../server/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"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -496,6 +496,16 @@ async fn garbage_collect_attachment_files(
|
|||||||
.map_err(|error| format!("Attachment GC task failed: {error}"))?
|
.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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
@@ -533,7 +543,8 @@ pub fn run() {
|
|||||||
copy_attachment_file,
|
copy_attachment_file,
|
||||||
read_file_base64,
|
read_file_base64,
|
||||||
delete_attachment_file,
|
delete_attachment_file,
|
||||||
garbage_collect_attachment_files
|
garbage_collect_attachment_files,
|
||||||
|
webview_log
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let setup_start = std::time::Instant::now();
|
let setup_start = std::time::Instant::now();
|
||||||
@@ -558,9 +569,6 @@ pub fn run() {
|
|||||||
.target(tauri_plugin_log::Target::new(
|
.target(tauri_plugin_log::Target::new(
|
||||||
tauri_plugin_log::TargetKind::Stdout,
|
tauri_plugin_log::TargetKind::Stdout,
|
||||||
))
|
))
|
||||||
.target(tauri_plugin_log::Target::new(
|
|
||||||
tauri_plugin_log::TargetKind::Webview,
|
|
||||||
))
|
|
||||||
.build(),
|
.build(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
|||||||
@@ -604,6 +604,14 @@ fn start_managed_daemon_internal(app: &AppHandle) -> Result<ManagedDaemonStatus,
|
|||||||
log::info!("[daemon] start command succeeded ({}ms), waiting for daemon to be ready", t0.elapsed().as_millis());
|
log::info!("[daemon] start command succeeded ({}ms), waiting for daemon to be ready", t0.elapsed().as_millis());
|
||||||
for attempt in 0..150 {
|
for attempt in 0..150 {
|
||||||
let daemon_status = managed_daemon_status_internal(app)?;
|
let daemon_status = managed_daemon_status_internal(app)?;
|
||||||
|
log::info!(
|
||||||
|
"[daemon] poll attempt {}: status={:?} server_id={:?} listen={:?} pid={:?}",
|
||||||
|
attempt + 1,
|
||||||
|
daemon_status.status,
|
||||||
|
daemon_status.server_id,
|
||||||
|
daemon_status.listen,
|
||||||
|
daemon_status.pid
|
||||||
|
);
|
||||||
if daemon_status.status == "running" && !daemon_status.server_id.trim().is_empty() {
|
if daemon_status.status == "running" && !daemon_status.server_id.trim().is_empty() {
|
||||||
log::info!(
|
log::info!(
|
||||||
"[daemon] ready after {} attempts, {}ms (pid={:?})",
|
"[daemon] ready after {} attempts, {}ms (pid={:?})",
|
||||||
|
|||||||
Reference in New Issue
Block a user