Relay pairing offer v2 (E2EE) + host connections

This commit is contained in:
Mohamed Boudra
2026-02-04 22:53:00 +07:00
parent 9ce4ad9758
commit 0218729316
59 changed files with 2022 additions and 1547 deletions
@@ -5,7 +5,7 @@ import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { Link2 } from "lucide-react-native";
import { useDaemonRegistry, type HostProfile } from "@/contexts/daemon-registry-context";
import { normalizeHostPort } from "@/utils/daemon-endpoints";
import { DaemonConnectionTestError, testDaemonEndpointConnection } from "@/utils/test-daemon-connection";
import { DaemonConnectionTestError, probeDaemonEndpoint } from "@/utils/test-daemon-connection";
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
const styles = StyleSheet.create((theme) => ({
@@ -151,7 +151,7 @@ export interface AddHostModalProps {
export function AddHostModal({ visible, onClose, onSaved }: AddHostModalProps) {
const { theme } = useUnistyles();
const { addDaemon } = useDaemonRegistry();
const { upsertDirectConnection } = useDaemonRegistry();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -198,11 +198,11 @@ export function AddHostModal({ visible, onClose, onSaved }: AddHostModalProps) {
setIsSaving(true);
setErrorMessage("");
await testDaemonEndpointConnection(endpoint);
const profile = await addDaemon({
const { serverId } = await probeDaemonEndpoint(endpoint);
const profile = await upsertDirectConnection({
serverId,
endpoint,
label: label.trim(),
endpoints: [endpoint],
});
onSaved?.(profile);
@@ -223,7 +223,7 @@ export function AddHostModal({ visible, onClose, onSaved }: AddHostModalProps) {
} finally {
setIsSaving(false);
}
}, [addDaemon, endpointRaw, handleClose, isMobile, isSaving, label, onSaved]);
}, [endpointRaw, handleClose, isMobile, isSaving, label, onSaved, upsertDirectConnection]);
return (
<AdaptiveModalSheet title="Direct connection" visible={visible} onClose={handleClose} testID="add-host-modal">
@@ -28,7 +28,17 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
<ScreenHeader
left={
<>
<Pressable onPress={toggleAgentList} style={styles.menuButton}>
<Pressable
onPress={toggleAgentList}
style={styles.menuButton}
testID="menu-button"
nativeID="menu-button"
collapsable={false}
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
accessibilityState={{ expanded: isOpen }}
>
<MenuIcon size={isMobile ? 20 : 16} color={menuIconColor} />
</Pressable>
{title && (
@@ -4,38 +4,63 @@ import {
type HostProfile,
} from "@/contexts/daemon-registry-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl, extractHostPortFromWebSocketUrl } from "@/utils/daemon-endpoints";
import {
buildDaemonWebSocketUrl,
buildRelayWebSocketUrl,
} from "@/utils/daemon-endpoints";
import { probeDaemonEndpoint } from "@/utils/test-daemon-connection";
import { useEffect, useMemo, useRef, useState } from "react";
import type { ActiveConnection } from "@/contexts/daemon-connections-context";
function buildCandidateUrls(daemon: HostProfile): string[] {
const endpoints = daemon.endpoints ?? [];
const sessionId = daemon.relay?.sessionId ?? null;
const relayEndpoint = daemon.relay?.endpoint ?? null;
const lastKnownGood =
typeof daemon.metadata?.lastKnownGoodEndpoint === "string"
? (daemon.metadata.lastKnownGoodEndpoint as string)
: null;
type Candidate = {
connectionId: string;
url: string;
activeConnection: ActiveConnection;
daemonPublicKeyB64?: string;
};
const out: string[] = [];
const push = (url: string) => {
if (!out.includes(url)) out.push(url);
};
function sortConnectionsByPreference<T extends { id: string }>(
connections: T[],
preferredId: string | null
): T[] {
if (!preferredId) return connections;
const idx = connections.findIndex((c) => c.id === preferredId);
if (idx === -1) return connections;
return [connections[idx]!, ...connections.slice(0, idx), ...connections.slice(idx + 1)];
}
const isLastKnownRelay = !!relayEndpoint && lastKnownGood === relayEndpoint;
const directEndpoints = endpoints;
function buildCandidates(host: HostProfile): Candidate[] {
const preferred = host.preferredConnectionId ?? null;
if (sessionId && relayEndpoint && isLastKnownRelay) {
push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId }));
} else if (lastKnownGood) {
push(buildDaemonWebSocketUrl(lastKnownGood));
const direct = sortConnectionsByPreference(
host.connections.filter((c) => c.type === "direct"),
preferred
);
const relay = sortConnectionsByPreference(
host.connections.filter((c) => c.type === "relay"),
preferred
);
const out: Candidate[] = [];
for (const conn of direct) {
out.push({
connectionId: conn.id,
url: buildDaemonWebSocketUrl(conn.endpoint),
activeConnection: { type: "direct", endpoint: conn.endpoint, display: conn.endpoint },
});
}
for (const endpoint of directEndpoints) {
push(buildDaemonWebSocketUrl(endpoint));
}
if (sessionId && relayEndpoint) {
push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId }));
for (const conn of relay) {
out.push({
connectionId: conn.id,
url: buildRelayWebSocketUrl({
endpoint: conn.relayEndpoint,
serverId: host.serverId,
}),
activeConnection: { type: "relay", endpoint: conn.relayEndpoint, display: "relay" },
daemonPublicKeyB64: conn.daemonPublicKeyB64,
});
}
return out;
@@ -43,55 +68,50 @@ function buildCandidateUrls(daemon: HostProfile): string[] {
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const { connectionStates } = useDaemonConnections();
const { updateDaemon } = useDaemonRegistry();
const { updateHost } = useDaemonRegistry();
const candidates = useMemo(() => buildCandidateUrls(daemon), [daemon]);
const candidates = useMemo(() => buildCandidates(daemon), [daemon]);
const [activeIndex, setActiveIndex] = useState(0);
const activeUrl = candidates[activeIndex] ?? candidates[0] ?? null;
const active = candidates[activeIndex] ?? candidates[0] ?? null;
const activeUrl = active?.url ?? null;
const lastAttemptedUrlRef = useRef<string | null>(null);
const pendingMetadataWriteRef = useRef(false);
const pendingPreferenceWriteRef = useRef(false);
const upgradeProbeInFlightRef = useRef(false);
useEffect(() => {
if (!activeUrl) {
return;
}
// If the active URL fell out of the candidate set (e.g. endpoints updated), snap back.
const idx = candidates.indexOf(activeUrl);
const idx = candidates.findIndex((c) => c.url === activeUrl);
if (idx === -1) {
setActiveIndex(0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [candidates.join("|")]);
}, [candidates.map((c) => c.url).join("|")]);
if (!activeUrl) {
return null;
}
const connection = connectionStates.get(daemon.id);
const connection = connectionStates.get(daemon.serverId);
const status = connection?.status ?? "idle";
const lastError = connection?.lastError ?? null;
useEffect(() => {
if (!connection) return;
if (status === "online") {
if (pendingMetadataWriteRef.current) return;
const endpoint = (() => {
try {
return extractHostPortFromWebSocketUrl(activeUrl);
} catch {
return null;
}
})();
if (!endpoint) return;
if (daemon.metadata?.lastKnownGoodEndpoint === endpoint) return;
if (status === "online") {
if (!active) return;
if (pendingPreferenceWriteRef.current) return;
if (daemon.preferredConnectionId === active.connectionId) return;
pendingMetadataWriteRef.current = true;
void updateDaemon(daemon.id, {
metadata: { ...(daemon.metadata ?? {}), lastKnownGoodEndpoint: endpoint },
pendingPreferenceWriteRef.current = true;
void updateHost(daemon.serverId, {
preferredConnectionId: active.connectionId,
}).finally(() => {
pendingMetadataWriteRef.current = false;
pendingPreferenceWriteRef.current = false;
});
return;
}
@@ -106,23 +126,67 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
}
}
}, [
active,
activeIndex,
activeUrl,
candidates.length,
daemon.id,
daemon.metadata,
daemon.preferredConnectionId,
daemon.serverId,
lastError,
status,
updateDaemon,
updateHost,
connection,
]);
useEffect(() => {
if (!connection) return;
if (status !== "online") return;
if (activeIndex === 0) return;
const best = candidates[0];
if (!best) return;
if (best.activeConnection.type !== "direct") return;
const intervalMs = 15_000;
let cancelled = false;
const attemptUpgrade = async () => {
if (cancelled) return;
if (upgradeProbeInFlightRef.current) return;
if (activeIndex === 0) return;
upgradeProbeInFlightRef.current = true;
try {
const { serverId } = await probeDaemonEndpoint(best.activeConnection.endpoint, {
timeoutMs: 2000,
});
if (cancelled) return;
if (serverId !== daemon.serverId) return;
lastAttemptedUrlRef.current = null;
setActiveIndex(0);
} catch {
// ignore - we'll retry periodically
} finally {
upgradeProbeInFlightRef.current = false;
}
};
void attemptUpgrade();
const interval = setInterval(() => void attemptUpgrade(), intervalMs);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [activeIndex, candidates, connection, daemon.serverId, status]);
return (
<SessionProvider
key={`${daemon.id}:${activeUrl}`}
key={`${daemon.serverId}:${activeUrl}`}
serverUrl={activeUrl}
serverId={daemon.id}
daemonPublicKeyB64={daemon.daemonPublicKeyB64}
serverId={daemon.serverId}
activeConnection={active?.activeConnection ?? null}
daemonPublicKeyB64={active?.daemonPublicKeyB64}
>
{null}
</SessionProvider>
@@ -138,7 +202,7 @@ export function MultiDaemonSessionHost() {
return (
<>
{daemons.map((daemon) => (
<ManagedDaemonSession key={daemon.id} daemon={daemon} />
<ManagedDaemonSession key={daemon.serverId} daemon={daemon} />
))}
</>
);
@@ -123,6 +123,7 @@ export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps)
<View style={styles.field}>
<Text style={styles.label}>Pairing link</Text>
<InputComponent
testID="pair-link-input"
value={offerUrl}
onChangeText={setOfferUrl}
placeholder="https://app.paseo.sh/#offer=..."
@@ -136,13 +137,14 @@ export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps)
</View>
<View style={styles.actions}>
<Pressable style={styles.button} onPress={handleClose} disabled={isSaving}>
<Pressable style={styles.button} onPress={handleClose} disabled={isSaving} testID="pair-link-cancel">
<Text style={styles.buttonText}>Cancel</Text>
</Pressable>
<Pressable
style={[styles.button, styles.primaryButton, isSaving ? { opacity: 0.7 } : null]}
onPress={() => void handleSave()}
disabled={isSaving}
testID="pair-link-submit"
>
<View style={styles.connectRow}>
<Link size={16} color={theme.colors.palette.white} />
+37 -15
View File
@@ -177,7 +177,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
}
if (voiceEligibleHosts.length === 1) {
void startVoice(voiceEligibleHosts[0].daemon.id).catch((error) => {
void startVoice(voiceEligibleHosts[0].daemon.serverId).catch((error) => {
console.error("[SlidingSidebar] Failed to start voice", error);
Alert.alert("Voice failed", "Unable to start Voice mode for this host.");
});
@@ -188,9 +188,9 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
}, [hasAnyConfiguredHosts, isVoiceMode, startVoice, stopVoice, voiceEligibleHosts]);
const handleSelectVoiceHost = useCallback(
(daemonId: string) => {
(serverId: string) => {
setShowVoiceHostPicker(false);
void startVoice(daemonId).catch((error) => {
void startVoice(serverId).catch((error) => {
console.error("[SlidingSidebar] Failed to start voice", error);
Alert.alert("Voice failed", "Unable to start Voice mode for this host.");
});
@@ -320,14 +320,19 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
)}
</Pressable>
<View style={styles.footerIconRow}>
<Pressable
style={styles.footerIconButton}
testID="sidebar-voice"
onPress={handleToggleVoice}
>
{({ hovered }) => (
<AudioLines
size={20}
<Pressable
style={styles.footerIconButton}
testID="sidebar-voice"
nativeID="sidebar-voice"
collapsable={false}
accessible
accessibilityLabel="Voice"
accessibilityRole="button"
onPress={handleToggleVoice}
>
{({ hovered }) => (
<AudioLines
size={20}
color={
isVoiceMode
? theme.colors.foreground
@@ -340,6 +345,12 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
</Pressable>
<Pressable
style={styles.footerIconButton}
testID="sidebar-settings"
nativeID="sidebar-settings"
collapsable={false}
accessible
accessibilityLabel="Settings"
accessibilityRole="button"
onPress={handleSettingsMobile}
>
{({ hovered }) => (
@@ -362,9 +373,9 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
<View style={styles.hostPickerList}>
{voiceEligibleHosts.map((entry) => (
<Pressable
key={entry.daemon.id}
key={entry.daemon.serverId}
style={styles.hostPickerOption}
onPress={() => handleSelectVoiceHost(entry.daemon.id)}
onPress={() => handleSelectVoiceHost(entry.daemon.serverId)}
>
<Text style={styles.hostPickerOptionText}>{entry.daemon.label}</Text>
</Pressable>
@@ -436,6 +447,11 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
<Pressable
style={styles.footerIconButton}
testID="sidebar-voice"
nativeID="sidebar-voice"
collapsable={false}
accessible
accessibilityLabel="Voice"
accessibilityRole="button"
onPress={handleToggleVoice}
>
{({ hovered }) => (
@@ -453,6 +469,12 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
</Pressable>
<Pressable
style={styles.footerIconButton}
testID="sidebar-settings"
nativeID="sidebar-settings"
collapsable={false}
accessible
accessibilityLabel="Settings"
accessibilityRole="button"
onPress={handleSettingsDesktop}
>
{({ hovered }) => (
@@ -472,9 +494,9 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
<View style={styles.hostPickerList}>
{voiceEligibleHosts.map((entry) => (
<Pressable
key={entry.daemon.id}
key={entry.daemon.serverId}
style={styles.hostPickerOption}
onPress={() => handleSelectVoiceHost(entry.daemon.id)}
onPress={() => handleSelectVoiceHost(entry.daemon.serverId)}
>
<Text style={styles.hostPickerOptionText}>{entry.daemon.label}</Text>
</Pressable>
+33 -6
View File
@@ -1,16 +1,17 @@
import { useState } from "react";
import { Image, Pressable, Text, View, Platform } from "react-native";
import { Image, Pressable, Text, View, Platform, ScrollView } from "react-native";
import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2 } from "lucide-react-native";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import type { HostProfile } from "@/contexts/daemon-registry-context";
import { AddHostModal } from "./add-host-modal";
import { PairLinkModal } from "./pair-link-modal";
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
padding: theme.spacing[8],
padding: theme.spacing[6],
justifyContent: "center",
alignItems: "center",
},
@@ -70,9 +71,15 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const { theme } = useUnistyles();
const router = useRouter();
const [isDirectOpen, setIsDirectOpen] = useState(false);
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
return (
<View style={styles.container} testID="welcome-screen">
<ScrollView
style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
contentContainerStyle={styles.container}
showsVerticalScrollIndicator={false}
testID="welcome-screen"
>
<Image
source={require("../../assets/images/icon.png")}
style={styles.logo}
@@ -85,15 +92,26 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<Pressable
style={[styles.actionButton, styles.actionButtonPrimary]}
onPress={() => setIsDirectOpen(true)}
testID="welcome-direct-connection"
>
<Link2 size={18} color={theme.colors.palette.white} />
<Text style={[styles.actionText, styles.actionTextPrimary]}>Direct connection</Text>
</Pressable>
<Pressable
style={styles.actionButton}
onPress={() => setIsPasteLinkOpen(true)}
testID="welcome-paste-pairing-link"
>
<ClipboardPaste size={18} color={theme.colors.foreground} />
<Text style={styles.actionText}>Paste pairing link</Text>
</Pressable>
{Platform.OS !== "web" ? (
<Pressable
style={styles.actionButton}
onPress={() => router.push("/pair-scan")}
testID="welcome-scan-qr"
>
<QrCode size={18} color={theme.colors.foreground} />
<Text style={styles.actionText}>Scan QR code</Text>
@@ -106,9 +124,18 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
onClose={() => setIsDirectOpen(false)}
onSaved={(profile) => {
onHostAdded?.(profile);
router.replace({ pathname: "/", params: { serverId: profile.id } });
router.replace({ pathname: "/", params: { serverId: profile.serverId } });
}}
/>
</View>
<PairLinkModal
visible={isPasteLinkOpen}
onClose={() => setIsPasteLinkOpen(false)}
onSaved={(profile) => {
onHostAdded?.(profile);
router.replace({ pathname: "/", params: { serverId: profile.serverId } });
}}
/>
</ScrollView>
);
}