feat: migrate from wsUrl to endpoints + add relay transport and pairing

This commit is contained in:
Mohamed Boudra
2026-01-21 14:55:23 +07:00
parent ae9a98223f
commit ddbcc272f1
29 changed files with 1729 additions and 280 deletions
+38 -5
View File
@@ -16,6 +16,7 @@ import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, useEffect, type ReactNode, useMemo } from "react";
import { Platform } from "react-native";
import * as Linking from "expo-linking";
import { SlidingSidebar } from "@/components/sliding-sidebar";
import { DownloadToast } from "@/components/download-toast";
import { usePanelStore } from "@/stores/panel-store";
@@ -184,7 +185,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
function ProvidersWrapper({ children }: { children: ReactNode }) {
const { settings, isLoading: settingsLoading } = useAppSettings();
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
const { daemons, isLoading: registryLoading, upsertDaemonFromOfferUrl } = useDaemonRegistry();
const isLoading = settingsLoading || registryLoading;
// Apply theme setting on mount and when it changes
@@ -202,11 +203,43 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
return <LoadingView />;
}
if (daemons.length === 0) {
return <MissingDaemonView />;
}
return (
<RealtimeProvider>
<OfferLinkListener upsertDaemonFromOfferUrl={upsertDaemonFromOfferUrl} />
{children}
</RealtimeProvider>
);
}
return <RealtimeProvider>{children}</RealtimeProvider>;
function OfferLinkListener({
upsertDaemonFromOfferUrl,
}: {
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<unknown>;
}) {
useEffect(() => {
let cancelled = false;
const handleUrl = (url: string | null) => {
if (!url) return;
if (!url.includes("#offer=")) return;
void upsertDaemonFromOfferUrl(url).catch((error) => {
if (cancelled) return;
console.warn("[Linking] Failed to import pairing offer", error);
});
};
void Linking.getInitialURL().then(handleUrl).catch(() => undefined);
const subscription = Linking.addEventListener("url", (event) => {
handleUrl(event.url);
});
return () => {
cancelled = true;
subscription.remove();
};
}, [upsertDaemonFromOfferUrl]);
return null;
}
function AppWithSidebar({ children }: { children: ReactNode }) {
@@ -30,7 +30,6 @@ import {
GitBranch,
Folder,
RotateCcw,
PlusIcon,
PanelRight,
} from "lucide-react-native";
import { MenuHeader } from "@/components/headers/menu-header";
@@ -561,27 +560,6 @@ function AgentScreenContent({
refreshAgent({ agentId: resolvedAgentId });
}, [handleCloseMenu, resolvedAgentId, refreshAgent]);
const handleCreateNewAgent = useCallback(() => {
if (!agent) {
return;
}
handleCloseMenu();
const params: Record<string, string> = { serverId };
if (agent.cwd) {
params.workingDir = agent.cwd;
}
if (agent.provider) {
params.provider = agent.provider;
}
if (agent.currentModeId) {
params.modeId = agent.currentModeId;
}
if (agentModel) {
params.model = agentModel;
}
router.push({ pathname: "/", params });
}, [agent, agentModel, handleCloseMenu, router, serverId]);
if (!agent) {
return (
<View style={styles.container}>
@@ -747,10 +725,6 @@ function AgentScreenContent({
<Folder size={16} color={theme.colors.foreground} />
<Text style={styles.menuItemText}>Browse Files</Text>
</Pressable>
<Pressable onPress={handleCreateNewAgent} style={styles.menuItem}>
<PlusIcon size={16} color={theme.colors.foreground} />
<Text style={styles.menuItemText}>New Agent</Text>
</Pressable>
<Pressable
onPress={handleRefreshAgent}
style={[
+1 -1
View File
@@ -571,7 +571,7 @@ export default function HomeScreen() {
<View style={styles.dropdownSheetList}>
{Array.from(connectionStates.values()).map(({ daemon, status }) => {
const isSelected = daemon.id === selectedServerId;
const label = daemon.label ?? daemon.wsUrl ?? daemon.id;
const label = daemon.label ?? daemon.endpoints?.[0] ?? daemon.id;
return (
<Pressable
key={daemon.id}
+154 -67
View File
@@ -23,6 +23,7 @@ import { theme as defaultTheme } from "@/styles/theme";
import { MenuHeader } from "@/components/headers/menu-header";
import { useSessionStore } from "@/stores/session-store";
import { DaemonClientV2 } from "@server/client/daemon-client-v2";
import { buildDaemonWebSocketUrl, normalizeHostPort } from "@/utils/daemon-endpoints";
const delay = (ms: number) =>
new Promise<void>((resolve) => {
@@ -380,10 +381,23 @@ type DaemonTestState = {
export default function SettingsScreen() {
const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings();
const { daemons, isLoading: daemonLoading, addDaemon, updateDaemon, removeDaemon } = useDaemonRegistry();
const {
daemons,
isLoading: daemonLoading,
addDaemon,
updateDaemon,
removeDaemon,
upsertDaemonFromOfferUrl,
} = useDaemonRegistry();
const { connectionStates, updateConnectionStatus } = useDaemonConnections();
const [isDaemonFormVisible, setIsDaemonFormVisible] = useState(false);
const [daemonForm, setDaemonForm] = useState<{ id: string | null; label: string; wsUrl: string }>({ id: null, label: "", wsUrl: "" });
const [daemonFormMode, setDaemonFormMode] = useState<"choose" | "manual" | "pair">("choose");
const [daemonForm, setDaemonForm] = useState<{
id: string | null;
label: string;
endpoint: string;
offerUrl: string;
}>({ id: null, label: "", endpoint: "", offerUrl: "" });
const [isSavingDaemon, setIsSavingDaemon] = useState(false);
const [daemonTestStates, setDaemonTestStates] = useState<Map<string, DaemonTestState>>(() => new Map());
const isLoading = settingsLoading || daemonLoading;
@@ -448,51 +462,86 @@ export default function SettingsScreen() {
const handleOpenDaemonForm = useCallback((profile?: DaemonProfile) => {
if (profile) {
setDaemonFormMode("manual");
setDaemonForm({
id: profile.id,
label: profile.label,
wsUrl: profile.wsUrl,
endpoint: profile.endpoints?.[0] ?? "",
offerUrl: "",
});
} else {
setDaemonForm({ id: null, label: "", wsUrl: "" });
setDaemonFormMode("choose");
setDaemonForm({ id: null, label: "", endpoint: "", offerUrl: "" });
}
setIsDaemonFormVisible(true);
}, []);
const handleCloseDaemonForm = useCallback(() => {
setIsDaemonFormVisible(false);
setDaemonForm({ id: null, label: "", wsUrl: "" });
setDaemonFormMode("choose");
setDaemonForm({ id: null, label: "", endpoint: "", offerUrl: "" });
}, []);
const handleSubmitDaemonForm = useCallback(async () => {
if (!daemonForm.label.trim()) {
Alert.alert("Label required", "Please enter a label for the host.");
return;
}
if (!validateServerUrl(daemonForm.wsUrl)) {
Alert.alert("Invalid URL", "Host URL must be ws:// or wss://");
if (daemonFormMode === "manual") {
const raw = daemonForm.endpoint.trim();
if (raw.includes("://") || raw.includes("/")) {
Alert.alert("Invalid host", "Manual hosts must be entered as host:port (no ws://, no /ws).");
return;
}
let endpoint: string;
try {
endpoint = normalizeHostPort(raw);
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid host:port";
Alert.alert("Invalid host", message);
return;
}
try {
setIsSavingDaemon(true);
const payload = {
label: daemonForm.label.trim(),
endpoints: [endpoint],
};
if (daemonForm.id) {
await updateDaemon(daemonForm.id, payload);
} else {
await addDaemon(payload);
}
handleCloseDaemonForm();
} catch (error) {
console.error("[Settings] Failed to save daemon", error);
Alert.alert("Error", "Unable to save host");
} finally {
setIsSavingDaemon(false);
}
return;
}
try {
setIsSavingDaemon(true);
const payload = {
label: daemonForm.label.trim(),
wsUrl: daemonForm.wsUrl.trim(),
};
if (daemonForm.id) {
await updateDaemon(daemonForm.id, payload);
} else {
await addDaemon(payload);
if (daemonFormMode === "pair") {
const offer = daemonForm.offerUrl.trim();
if (!offer) {
Alert.alert("Offer required", "Paste the pairing link (…/#offer=...)");
return;
}
handleCloseDaemonForm();
} catch (error) {
console.error("[Settings] Failed to save daemon", error);
Alert.alert("Error", "Unable to save host");
} finally {
setIsSavingDaemon(false);
try {
setIsSavingDaemon(true);
await upsertDaemonFromOfferUrl(offer);
handleCloseDaemonForm();
} catch (error) {
console.error("[Settings] Failed to pair host", error);
const message = error instanceof Error ? error.message : "Unable to pair host";
Alert.alert("Error", message);
} finally {
setIsSavingDaemon(false);
}
return;
}
}, [daemonForm, addDaemon, updateDaemon, handleCloseDaemonForm]);
Alert.alert("Choose a method", "Select Pair or Manual to add a host.");
}, [daemonForm, daemonFormMode, addDaemon, updateDaemon, upsertDaemonFromOfferUrl, handleCloseDaemonForm]);
const handleRemoveDaemon = useCallback(
(profile: DaemonProfile) => {
@@ -529,11 +578,12 @@ export default function SettingsScreen() {
const handleTestDaemonConnection = useCallback(
async (profile: DaemonProfile) => {
const url = profile.wsUrl;
if (!validateServerUrl(url)) {
Alert.alert("Invalid URL", "Host URL must be ws:// or wss://");
const endpoint = profile.endpoints?.[0] ?? null;
if (!endpoint) {
Alert.alert("Missing host", "This host has no endpoints configured.");
return;
}
const url = buildDaemonWebSocketUrl(endpoint);
updateDaemonTestState(profile.id, { status: "testing" });
updateConnectionStatus(profile.id, { status: "connecting" });
try {
@@ -576,15 +626,6 @@ export default function SettingsScreen() {
[updateSettings]
);
function validateServerUrl(url: string): boolean {
try {
const urlObj = new URL(url);
return urlObj.protocol === "ws:" || urlObj.protocol === "wss:";
} catch {
return false;
}
}
function handleReset() {
Alert.alert(
"Reset Settings",
@@ -666,40 +707,85 @@ export default function SettingsScreen() {
{isDaemonFormVisible ? (
<View style={styles.formCard}>
<Text style={styles.formTitle}>{daemonForm.id ? "Edit Host" : "Add Host"}</Text>
<View style={styles.formField}>
<Text style={styles.label}>Label</Text>
<TextInput
style={styles.input}
value={daemonForm.label}
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, label: text }))}
placeholder="My Host"
placeholderTextColor={defaultTheme.colors.mutedForeground}
/>
</View>
<View style={styles.formField}>
<Text style={styles.label}>Host URL</Text>
<TextInput
style={[styles.input, styles.inputUrl]}
value={daemonForm.wsUrl}
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, wsUrl: text }))}
placeholder="wss://example.com/ws"
placeholderTextColor={defaultTheme.colors.mutedForeground}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</View>
{daemonForm.id ? (
<View style={styles.formField}>
<Text style={styles.label}>Label</Text>
<TextInput
style={styles.input}
value={daemonForm.label}
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, label: text }))}
placeholder="My Host"
placeholderTextColor={defaultTheme.colors.mutedForeground}
/>
</View>
) : null}
{daemonForm.id ? null : daemonFormMode === "choose" ? (
<View style={styles.formActionsRow}>
<Pressable
style={[styles.formButton, styles.formButtonPrimary]}
onPress={() => setDaemonFormMode("pair")}
>
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>Pair</Text>
</Pressable>
<Pressable
style={[styles.formButton, styles.formButtonPrimary]}
onPress={() => setDaemonFormMode("manual")}
>
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>Manual</Text>
</Pressable>
</View>
) : null}
{daemonFormMode === "manual" ? (
<View style={styles.formField}>
<Text style={styles.label}>Host</Text>
<TextInput
style={[styles.input, styles.inputUrl]}
value={daemonForm.endpoint}
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, endpoint: text }))}
placeholder="localhost:6767"
placeholderTextColor={defaultTheme.colors.mutedForeground}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</View>
) : null}
{daemonFormMode === "pair" ? (
<View style={styles.formField}>
<Text style={styles.label}>Pairing Link</Text>
<TextInput
style={[styles.input, styles.inputUrl]}
value={daemonForm.offerUrl}
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, offerUrl: text }))}
placeholder="https://app.paseo.sh/#offer=..."
placeholderTextColor={defaultTheme.colors.mutedForeground}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</View>
) : null}
<View style={styles.formActionsRow}>
<Pressable style={styles.formButton} onPress={handleCloseDaemonForm}>
<Text style={styles.formButtonText}>Cancel</Text>
</Pressable>
{!daemonForm.id && daemonFormMode !== "choose" ? (
<Pressable style={styles.formButton} onPress={() => setDaemonFormMode("choose")}>
<Text style={styles.formButtonText}>Back</Text>
</Pressable>
) : null}
<Pressable
style={[styles.formButton, styles.formButtonPrimary, isSavingDaemon && styles.hostActionDisabled]}
onPress={handleSubmitDaemonForm}
disabled={isSavingDaemon}
>
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>
{isSavingDaemon ? "Saving..." : daemonForm.id ? "Save" : "Add"}
{isSavingDaemon ? "Saving..." : daemonForm.id ? "Save" : daemonFormMode === "pair" ? "Pair" : "Add"}
</Text>
</Pressable>
</View>
@@ -853,6 +939,7 @@ function DaemonCard({
isScreenMountedRef,
}: DaemonCardProps) {
const { theme } = useUnistyles();
const directWsUrl = buildDaemonWebSocketUrl(daemon.endpoints?.[0] ?? "localhost:6767");
const statusLabel = formatConnectionStatus(connectionStatus);
const statusTone = getConnectionStatusTone(connectionStatus);
const statusColor =
@@ -890,7 +977,7 @@ function DaemonCard({
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await testServerConnection(daemon.wsUrl);
await testServerConnection(directWsUrl);
const reconnected = await waitForCondition(() => isConnectedRef.current, reconnectTimeoutMs);
if (isScreenMountedRef.current) {
@@ -921,7 +1008,7 @@ function DaemonCard({
await delay(retryDelayMs);
}
}
}, [daemon.label, daemon.wsUrl, isScreenMountedRef, testServerConnection, waitForCondition]);
}, [daemon.label, directWsUrl, isScreenMountedRef, testServerConnection, waitForCondition]);
const beginServerRestart = useCallback(() => {
if (!restartServerFn) {
@@ -1017,7 +1104,7 @@ function DaemonCard({
<Text style={[styles.statusText, { color: statusColor }]}>{badgeText}</Text>
</View>
</View>
<Text style={styles.hostUrl}>{daemon.wsUrl}</Text>
<Text style={styles.hostUrl}>{daemon.endpoints?.[0] ?? ""}</Text>
{connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null}
{testState && testState.status !== "idle" ? (
<Text style={[styles.testResultText, { color: testResultColor }]}>
@@ -1,5 +1,126 @@
import { SessionProvider } from "@/contexts/session-context";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import {
useDaemonRegistry,
type HostProfile,
} from "@/contexts/daemon-registry-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints";
import { useEffect, useMemo, useRef, useState } from "react";
function buildCandidateUrls(daemon: HostProfile): string[] {
const endpoints = daemon.endpoints ?? [];
const sessionId = daemon.relay?.sessionId ?? null;
const lastKnownGood =
typeof daemon.metadata?.lastKnownGoodEndpoint === "string"
? (daemon.metadata.lastKnownGoodEndpoint as string)
: null;
const out: string[] = [];
const push = (url: string) => {
if (!out.includes(url)) out.push(url);
};
if (lastKnownGood) {
push(buildDaemonWebSocketUrl(lastKnownGood));
}
for (const endpoint of endpoints) {
push(buildDaemonWebSocketUrl(endpoint));
}
if (sessionId) {
if (lastKnownGood) {
push(buildRelayWebSocketUrl({ endpoint: lastKnownGood, sessionId }));
}
for (const endpoint of endpoints) {
push(buildRelayWebSocketUrl({ endpoint, sessionId }));
}
}
return out;
}
function extractEndpointFromUrl(url: string): string | null {
try {
const parsed = new URL(url);
const host = parsed.hostname;
const port = parsed.port ? Number(parsed.port) : parsed.protocol === "wss:" ? 443 : 80;
const isIpv6 = host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`;
} catch {
return null;
}
}
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const { connectionStates } = useDaemonConnections();
const { updateDaemon } = useDaemonRegistry();
const candidates = useMemo(() => buildCandidateUrls(daemon), [daemon]);
const [activeIndex, setActiveIndex] = useState(0);
const activeUrl = candidates[activeIndex] ?? candidates[0] ?? buildDaemonWebSocketUrl("localhost:6767");
const lastAttemptedUrlRef = useRef<string | null>(null);
const pendingMetadataWriteRef = useRef(false);
useEffect(() => {
// If the active URL fell out of the candidate set (e.g. endpoints updated), snap back.
const idx = candidates.indexOf(activeUrl);
if (idx === -1) {
setActiveIndex(0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [candidates.join("|")]);
const connection = connectionStates.get(daemon.id);
const status = connection?.status ?? "idle";
const lastError = connection?.lastError ?? null;
useEffect(() => {
if (!connection) return;
if (status === "online") {
if (pendingMetadataWriteRef.current) return;
const endpoint = extractEndpointFromUrl(activeUrl);
if (!endpoint) return;
if (daemon.metadata?.lastKnownGoodEndpoint === endpoint) return;
pendingMetadataWriteRef.current = true;
void updateDaemon(daemon.id, {
metadata: { ...(daemon.metadata ?? {}), lastKnownGoodEndpoint: endpoint },
}).finally(() => {
pendingMetadataWriteRef.current = false;
});
return;
}
if ((status === "error" || (status === "offline" && lastError)) && candidates.length > 1) {
if (lastAttemptedUrlRef.current === activeUrl) {
return;
}
lastAttemptedUrlRef.current = activeUrl;
if (activeIndex < candidates.length - 1) {
setActiveIndex((idx) => Math.min(idx + 1, candidates.length - 1));
}
}
}, [
activeIndex,
activeUrl,
candidates.length,
daemon.id,
daemon.metadata,
lastError,
status,
updateDaemon,
connection,
]);
return (
<SessionProvider key={`${daemon.id}:${activeUrl}`} serverUrl={activeUrl} serverId={daemon.id}>
{null}
</SessionProvider>
);
}
export function MultiDaemonSessionHost() {
const { daemons } = useDaemonRegistry();
@@ -10,9 +131,7 @@ export function MultiDaemonSessionHost() {
return (
<>
{daemons.map((daemon) => (
<SessionProvider key={daemon.id} serverUrl={daemon.wsUrl} serverId={daemon.id}>
{null}
</SessionProvider>
<ManagedDaemonSession key={daemon.id} daemon={daemon} />
))}
</>
);
+11 -36
View File
@@ -12,7 +12,6 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
import { Plus, Settings } from "lucide-react-native";
import { router } from "expo-router";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { AgentList } from "./agent-list";
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
@@ -25,30 +24,6 @@ interface SlidingSidebarProps {
selectedAgentId?: string;
}
function buildCreateAgentRouteFromSelectedAgentKey(selectedAgentKey?: string) {
if (!selectedAgentKey) {
return { pathname: "/" as const, params: {} as Record<string, string> };
}
const [serverId, agentId] = selectedAgentKey.split(":", 2);
if (!serverId || !agentId) {
return { pathname: "/" as const, params: {} as Record<string, string> };
}
const state = useSessionStore.getState();
const agent = state.sessions[serverId]?.agents.get(agentId);
if (!agent) {
return { pathname: "/" as const, params: {} as Record<string, string> };
}
const params: Record<string, string> = { serverId: agent.serverId };
if (agent.provider) params.provider = agent.provider;
if (agent.currentModeId) params.modeId = agent.currentModeId;
if (agent.model) params.model = agent.model;
if (agent.cwd) params.workingDir = agent.cwd;
return { pathname: "/" as const, params };
}
export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
@@ -106,20 +81,20 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
closeToAgent();
}, [closeToAgent]);
const handleCreateAgent = useCallback(() => {
router.push(buildCreateAgentRouteFromSelectedAgentKey(selectedAgentId));
}, [selectedAgentId]);
const handleCreateAgentClean = useCallback(() => {
router.push("/");
}, []);
// Mobile: close sidebar and navigate
const handleCreateAgentMobile = useCallback(() => {
const handleCreateAgentCleanMobile = useCallback(() => {
closeToAgent();
handleCreateAgent();
}, [closeToAgent, handleCreateAgent]);
handleCreateAgentClean();
}, [closeToAgent, handleCreateAgentClean]);
// Desktop: just navigate, don't close
const handleCreateAgentDesktop = useCallback(() => {
handleCreateAgent();
}, [handleCreateAgent]);
const handleCreateAgentCleanDesktop = useCallback(() => {
handleCreateAgentClean();
}, [handleCreateAgentClean]);
// Mobile: close sidebar and navigate
const handleSettingsMobile = useCallback(() => {
@@ -240,7 +215,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
hovered && styles.newAgentButtonHovered,
]}
testID="sidebar-new-agent"
onPress={handleCreateAgentMobile}
onPress={handleCreateAgentCleanMobile}
>
<Plus size={18} color={theme.colors.foreground} />
<Text style={styles.newAgentButtonText}>New Agent</Text>
@@ -292,7 +267,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
hovered && styles.newAgentButtonHovered,
]}
testID="sidebar-new-agent"
onPress={handleCreateAgentDesktop}
onPress={handleCreateAgentCleanDesktop}
>
<Plus size={18} color={theme.colors.foreground} />
<Text style={styles.newAgentButtonText}>New Agent</Text>
@@ -2,40 +2,67 @@ import { createContext, useCallback, useContext } from "react";
import type { ReactNode } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import {
buildDaemonWebSocketUrl,
decodeOfferFragmentPayload,
deriveLabelFromEndpoint,
extractHostPortFromWebSocketUrl,
normalizeHostPort,
} from "@/utils/daemon-endpoints";
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
const LEGACY_SETTINGS_KEY = "@paseo:settings";
const FALLBACK_DAEMON_URL = "ws://localhost:6767/ws";
const DEFAULT_DAEMONS: Array<{ label: string; wsUrl: string; restUrl?: string | null }> = [
{ label: "localhost", wsUrl: "ws://localhost:6767/ws" },
const FALLBACK_DAEMON_ENDPOINT = "localhost:6767";
const DEFAULT_HOSTS: Array<{ label: string; endpoint: string }> = [
{ label: "localhost", endpoint: "localhost:6767" },
];
const DAEMON_REGISTRY_QUERY_KEY = ["daemon-registry"];
export type DaemonProfile = {
export type HostRelayConfig = {
endpoint: string;
sessionId: string;
};
export type HostProfile = {
id: string;
label: string;
wsUrl: string;
restUrl?: string | null;
endpoints: string[];
daemonPublicKeyB64?: string;
relay?: HostRelayConfig | null;
createdAt: string;
updatedAt: string;
metadata?: Record<string, unknown> | null;
};
type CreateDaemonInput = {
// Backward compatibility with older imports.
export type DaemonProfile = HostProfile;
type CreateHostInput = {
label: string;
wsUrl: string;
restUrl?: string | null;
endpoints: string[];
};
type UpdateDaemonInput = Partial<Omit<DaemonProfile, "id" | "createdAt">>;
type UpdateHostInput = Partial<Omit<HostProfile, "id" | "createdAt">>;
const ConnectionOfferV1Schema = z.object({
v: z.literal(1),
sessionId: z.string().min(1),
endpoints: z.array(z.string().min(1)).min(1),
daemonPublicKeyB64: z.string().min(1),
});
export type ConnectionOfferV1 = z.infer<typeof ConnectionOfferV1Schema>;
interface DaemonRegistryContextValue {
daemons: DaemonProfile[];
daemons: HostProfile[];
isLoading: boolean;
error: unknown | null;
addDaemon: (input: CreateDaemonInput) => Promise<DaemonProfile>;
updateDaemon: (id: string, updates: UpdateDaemonInput) => Promise<void>;
addDaemon: (input: CreateHostInput) => Promise<HostProfile>;
updateDaemon: (id: string, updates: UpdateHostInput) => Promise<void>;
removeDaemon: (id: string) => Promise<void>;
upsertDaemonFromOffer: (offer: ConnectionOfferV1) => Promise<HostProfile>;
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<HostProfile>;
}
const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null);
@@ -58,25 +85,26 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
});
const persist = useCallback(
async (profiles: DaemonProfile[]) => {
queryClient.setQueryData<DaemonProfile[]>(DAEMON_REGISTRY_QUERY_KEY, profiles);
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<DaemonProfile[]>(DAEMON_REGISTRY_QUERY_KEY) ?? daemons;
return queryClient.getQueryData<HostProfile[]>(DAEMON_REGISTRY_QUERY_KEY) ?? daemons;
}, [queryClient, daemons]);
const addDaemon = useCallback(async (input: CreateDaemonInput) => {
const addDaemon = useCallback(async (input: CreateHostInput) => {
const existing = readDaemons();
const timestamp = new Date().toISOString();
const profile: DaemonProfile = {
const profile: HostProfile = {
id: generateDaemonId(),
label: input.label.trim() || deriveLabelFromUrl(input.wsUrl),
wsUrl: input.wsUrl,
restUrl: input.restUrl ?? null,
label: input.label.trim() || deriveLabelFromEndpoint(input.endpoints[0] ?? ""),
endpoints: input.endpoints.map((endpoint) => normalizeHostPort(endpoint)),
daemonPublicKeyB64: undefined,
relay: null,
createdAt: timestamp,
updatedAt: timestamp,
metadata: null,
@@ -87,7 +115,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
return profile;
}, [persist, readDaemons]);
const updateDaemon = useCallback(async (id: string, updates: UpdateDaemonInput) => {
const updateDaemon = useCallback(async (id: string, updates: UpdateHostInput) => {
const next = readDaemons().map((daemon) =>
daemon.id === id
? {
@@ -102,10 +130,67 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const removeDaemon = useCallback(async (id: string) => {
const remaining = readDaemons().filter((daemon) => daemon.id !== id);
const next = remaining.length > 0 ? remaining : [createProfile("Local Host", FALLBACK_DAEMON_URL)];
await persist(next);
await persist(remaining);
}, [persist, readDaemons]);
const upsertDaemonFromOffer = useCallback(
async (offer: ConnectionOfferV1) => {
const existing = readDaemons();
const now = new Date().toISOString();
const normalizedEndpoints = offer.endpoints.map((endpoint) => normalizeHostPort(endpoint));
const relayEndpoint = normalizedEndpoints[normalizedEndpoints.length - 1];
const matchIndex = existing.findIndex((daemon) => daemon.daemonPublicKeyB64 === offer.daemonPublicKeyB64);
if (matchIndex !== -1) {
const updated: HostProfile = {
...existing[matchIndex],
daemonPublicKeyB64: offer.daemonPublicKeyB64,
endpoints: normalizedEndpoints,
relay: { endpoint: relayEndpoint, sessionId: offer.sessionId },
updatedAt: now,
};
const next = [...existing];
next[matchIndex] = updated;
await persist(next);
return updated;
}
const profile: HostProfile = {
id: generateDaemonId(),
label: deriveLabelFromEndpoint(normalizedEndpoints[0] ?? "Unnamed Host"),
endpoints: normalizedEndpoints,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
relay: { endpoint: relayEndpoint, sessionId: offer.sessionId },
createdAt: now,
updatedAt: now,
metadata: null,
};
const next = [...existing, profile];
await persist(next);
return profile;
},
[persist, readDaemons]
);
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 = ConnectionOfferV1Schema.parse(payload);
return upsertDaemonFromOffer(offer);
},
[upsertDaemonFromOffer]
);
const value: DaemonRegistryContextValue = {
daemons,
isLoading: isPending,
@@ -113,6 +198,8 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
addDaemon,
updateDaemon,
removeDaemon,
upsertDaemonFromOffer,
upsertDaemonFromOfferUrl,
};
return (
@@ -127,22 +214,14 @@ function generateDaemonId(): string {
return `daemon_${Date.now().toString(36)}_${random}`;
}
function deriveLabelFromUrl(url: string): string {
try {
const parsed = new URL(url);
return parsed.hostname || "Unnamed Host";
} catch {
return "Unnamed Host";
}
}
function createProfile(label: string, wsUrl: string): DaemonProfile {
function createProfile(label: string, endpoint: string): HostProfile {
const timestamp = new Date().toISOString();
return {
id: generateDaemonId(),
label,
wsUrl,
restUrl: null,
endpoints: [normalizeHostPort(endpoint)],
daemonPublicKeyB64: undefined,
relay: null,
createdAt: timestamp,
updatedAt: timestamp,
metadata: null,
@@ -151,14 +230,14 @@ function createProfile(label: string, wsUrl: string): DaemonProfile {
type EnvDaemonConfig = {
label?: string;
wsUrl: string;
restUrl?: string | null;
endpoint?: string;
wsUrl?: string;
};
function parseEnvDaemonDefaults(): DaemonProfile[] {
function parseEnvDaemonDefaults(): HostProfile[] {
const envDaemons = (() => {
// Primary: allow a JSON array string like
// EXPO_PUBLIC_DAEMONS='[{"label":"Host","wsUrl":"ws://10.0.0.1:6767/ws"}]'
// EXPO_PUBLIC_DAEMONS='[{"label":"Host","endpoint":"10.0.0.1:6767"}]'
const jsonList = process.env.EXPO_PUBLIC_DAEMONS;
if (jsonList) {
try {
@@ -178,7 +257,6 @@ function parseEnvDaemonDefaults(): DaemonProfile[] {
{
label: process.env.EXPO_PUBLIC_DAEMON_LABEL,
wsUrl: singleWsUrl,
restUrl: process.env.EXPO_PUBLIC_DAEMON_REST_URL,
},
];
}
@@ -188,23 +266,120 @@ function parseEnvDaemonDefaults(): DaemonProfile[] {
const timestamp = new Date().toISOString();
return envDaemons
.filter((entry) => typeof entry?.wsUrl === "string" && entry.wsUrl.trim().length > 0)
.map((entry) => ({
id: generateDaemonId(),
label: entry.label?.trim() || deriveLabelFromUrl(entry.wsUrl),
wsUrl: entry.wsUrl.trim(),
restUrl: entry.restUrl ?? null,
createdAt: timestamp,
updatedAt: timestamp,
metadata: null,
}));
.map((entry): HostProfile | null => {
const endpoint = (() => {
if (typeof entry.endpoint === "string" && entry.endpoint.trim().length > 0) {
return entry.endpoint.trim();
}
if (typeof entry.wsUrl === "string" && entry.wsUrl.trim().length > 0) {
try {
return extractHostPortFromWebSocketUrl(entry.wsUrl.trim());
} catch {
return null;
}
}
return null;
})();
if (!endpoint) return null;
return {
id: generateDaemonId(),
label: entry.label?.trim() || deriveLabelFromEndpoint(endpoint),
endpoints: [normalizeHostPort(endpoint)],
createdAt: timestamp,
updatedAt: timestamp,
metadata: null,
};
})
.filter((entry): entry is HostProfile => entry !== null);
}
async function loadDaemonRegistryFromStorage(): Promise<DaemonProfile[]> {
type LegacyDaemonProfile = {
id: string;
label: string;
wsUrl: string;
restUrl?: string | null;
createdAt: string;
updatedAt: string;
metadata?: Record<string, unknown> | null;
};
function isLegacyDaemonProfile(value: unknown): value is LegacyDaemonProfile {
if (!value || typeof value !== "object") return false;
const obj = value as Record<string, unknown>;
return typeof obj.id === "string" && typeof obj.wsUrl === "string" && typeof obj.label === "string";
}
function isHostProfile(value: unknown): value is HostProfile {
if (!value || typeof value !== "object") return false;
const obj = value as Record<string, unknown>;
return typeof obj.id === "string" && typeof obj.label === "string" && Array.isArray(obj.endpoints);
}
function migrateLegacyToHostProfile(legacy: LegacyDaemonProfile): HostProfile {
const endpoint = extractHostPortFromWebSocketUrl(legacy.wsUrl);
return {
id: legacy.id,
label: legacy.label,
endpoints: [normalizeHostPort(endpoint)],
daemonPublicKeyB64: undefined,
relay: null,
createdAt: legacy.createdAt,
updatedAt: legacy.updatedAt,
metadata: legacy.metadata ?? null,
};
}
async function loadDaemonRegistryFromStorage(): Promise<HostProfile[]> {
try {
const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY);
if (stored) {
return JSON.parse(stored) as DaemonProfile[];
const parsed = JSON.parse(stored) as unknown;
if (Array.isArray(parsed)) {
if (parsed.length === 0) {
return [];
}
const hasLegacy = parsed.some((entry) => isLegacyDaemonProfile(entry));
const hasNew = parsed.some((entry) => isHostProfile(entry));
if (hasNew && !hasLegacy) {
return parsed as HostProfile[];
}
const migrated = parsed
.map((entry) => {
if (isHostProfile(entry)) {
const endpoints = entry.endpoints
.map((endpoint) => {
try {
return normalizeHostPort(String(endpoint));
} catch {
return null;
}
})
.filter((endpoint): endpoint is string => endpoint !== null);
if (endpoints.length === 0) {
return null;
}
return { ...entry, endpoints } as HostProfile;
}
if (isLegacyDaemonProfile(entry)) {
try {
return migrateLegacyToHostProfile(entry);
} catch {
return null;
}
}
return null;
})
.filter((entry): entry is HostProfile => entry !== null);
if (migrated.length > 0) {
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(migrated));
return migrated;
}
}
}
const legacy = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY);
@@ -212,7 +387,8 @@ async function loadDaemonRegistryFromStorage(): Promise<DaemonProfile[]> {
const legacyParsed = JSON.parse(legacy) as Record<string, unknown>;
const legacyUrl = typeof legacyParsed.serverUrl === "string" ? legacyParsed.serverUrl : null;
if (legacyUrl) {
const migrated = [createProfile("Primary Host", legacyUrl)];
const endpoint = extractHostPortFromWebSocketUrl(legacyUrl);
const migrated = [createProfile("Primary Host", endpoint)];
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(migrated));
return migrated;
}
@@ -224,9 +400,9 @@ async function loadDaemonRegistryFromStorage(): Promise<DaemonProfile[]> {
return envDefaults;
}
const fallback = DEFAULT_DAEMONS.length > 0
? DEFAULT_DAEMONS.map((entry) => createProfile(entry.label, entry.wsUrl))
: [createProfile("Local Host", FALLBACK_DAEMON_URL)];
const fallback = DEFAULT_HOSTS.length > 0
? DEFAULT_HOSTS.map((entry) => createProfile(entry.label, entry.endpoint))
: [createProfile("Local Host", FALLBACK_DAEMON_ENDPOINT)];
await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(fallback));
return fallback;
} catch (error) {
@@ -234,3 +410,8 @@ async function loadDaemonRegistryFromStorage(): Promise<DaemonProfile[]> {
throw error;
}
}
export function buildDirectDaemonWsUrl(profile: HostProfile): string {
const endpoint = profile.endpoints[0] ?? FALLBACK_DAEMON_ENDPOINT;
return buildDaemonWebSocketUrl(endpoint);
}
+21 -2
View File
@@ -289,8 +289,8 @@ export function useAgentFormState(
return;
}
// Wait for preferences to load before first resolution
if (isPreferencesLoading && !hasResolvedRef.current) {
// Wait for preferences to load before first resolution, unless explicit URL overrides exist.
if (isPreferencesLoading && !hasResolvedRef.current && !combinedInitialValues) {
return;
}
@@ -325,6 +325,25 @@ export function useAgentFormState(
formState,
]);
// Persist inferred serverId so reloads keep the selection (e.g. URL serverId or first-time load).
useEffect(() => {
if (!isVisible || !isCreateFlow) return;
if (isPreferencesLoading) return;
if (userModified.serverId) return;
const serverId = formState.serverId;
if (!serverId) return;
if (preferences?.serverId === serverId) return;
void updatePreferences({ serverId });
}, [
isVisible,
isCreateFlow,
isPreferencesLoading,
userModified.serverId,
formState.serverId,
preferences?.serverId,
updatePreferences,
]);
// Provider model request timers
const providerModelRequestTimersRef = useRef<
Map<string, ReturnType<typeof setTimeout>>
+9 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo } from "react";
import { AppState } from "react-native";
import { DaemonClientV2 } from "@server/client/daemon-client-v2";
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
function runDaemonRequest(label: string, promise: Promise<unknown>): void {
void promise.catch((error) => {
@@ -10,11 +11,16 @@ function runDaemonRequest(label: string, promise: Promise<unknown>): void {
export function useDaemonClient(url: string): DaemonClientV2 {
const client = useMemo(
() =>
new DaemonClientV2({
() => {
const tauriTransportFactory = createTauriWebSocketTransportFactory();
return new DaemonClientV2({
url,
suppressSendErrors: true,
}),
...(tauriTransportFactory
? { transportFactory: tauriTransportFactory }
: {}),
});
},
[url]
);
+4 -3
View File
@@ -4,6 +4,7 @@ import { File as FSFile, Paths } from "expo-file-system";
import * as LegacyFileSystem from "expo-file-system/legacy";
import * as Sharing from "expo-sharing";
import type { DaemonProfile } from "@/contexts/daemon-registry-context";
import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints";
interface DownloadProgress {
percent: number;
@@ -246,14 +247,14 @@ type DownloadTarget = {
};
function resolveDaemonDownloadTarget(daemon?: DaemonProfile): DownloadTarget {
const rawUrl = daemon?.restUrl ?? daemon?.wsUrl;
if (!rawUrl) {
const endpoint = daemon?.endpoints?.[0] ?? null;
if (!endpoint) {
return { baseUrl: null, authHeader: null, authCredentials: null };
}
let parsed: URL;
try {
parsed = new URL(rawUrl);
parsed = new URL(buildDaemonWebSocketUrl(endpoint));
} catch {
return { baseUrl: null, authHeader: null, authCredentials: null };
}
+114
View File
@@ -0,0 +1,114 @@
import { Buffer } from "buffer";
export type HostPortParts = {
host: string;
port: number;
isIpv6: boolean;
};
function decodeBase64UrlToUtf8(input: string): string {
const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
return Buffer.from(padded, "base64").toString("utf8");
}
export function decodeOfferFragmentPayload(encoded: string): unknown {
const json = decodeBase64UrlToUtf8(encoded);
return JSON.parse(json) as unknown;
}
export function parseHostPort(input: string): HostPortParts {
const trimmed = input.trim();
if (!trimmed) {
throw new Error("Host is required");
}
// IPv6: [::1]:6767
if (trimmed.startsWith("[")) {
const match = trimmed.match(/^\[([^\]]+)\]:(\d{1,5})$/);
if (!match) {
throw new Error("Invalid host:port (expected [::1]:6767)");
}
const host = match[1].trim();
const port = Number(match[2]);
if (!host) throw new Error("Host is required");
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("Port must be between 1 and 65535");
}
return { host, port, isIpv6: true };
}
const match = trimmed.match(/^(.+):(\d{1,5})$/);
if (!match) {
throw new Error("Invalid host:port (expected localhost:6767)");
}
const host = match[1].trim();
const port = Number(match[2]);
if (!host) throw new Error("Host is required");
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("Port must be between 1 and 65535");
}
return { host, port, isIpv6: false };
}
export function normalizeHostPort(input: string): string {
const { host, port, isIpv6 } = parseHostPort(input);
if (isIpv6) {
return `[${host}]:${port}`;
}
return `${host}:${port}`;
}
export function extractHostPortFromWebSocketUrl(wsUrl: string): string {
const parsed = new URL(wsUrl);
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
throw new Error("Invalid WebSocket URL protocol");
}
if (parsed.pathname.replace(/\/+$/, "") !== "/ws") {
throw new Error("Invalid WebSocket URL (expected /ws path)");
}
const host = parsed.hostname;
const port = parsed.port ? Number(parsed.port) : parsed.protocol === "wss:" ? 443 : 80;
if (!host) {
throw new Error("Invalid WebSocket URL (missing hostname)");
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("Invalid WebSocket URL (invalid port)");
}
const isIpv6 = host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
const hostPort = isIpv6 ? `[${host}]:${port}` : `${host}:${port}`;
return hostPort;
}
function shouldUseSecureWebSocket(port: number): boolean {
return port === 443;
}
export function buildDaemonWebSocketUrl(endpoint: string): string {
const { host, port, isIpv6 } = parseHostPort(endpoint);
const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
const hostPart = isIpv6 ? `[${host}]` : host;
return `${protocol}://${hostPart}:${port}/ws`;
}
export function buildRelayWebSocketUrl(params: { endpoint: string; sessionId: string }): string {
const { host, port, isIpv6 } = parseHostPort(params.endpoint);
const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
const hostPart = isIpv6 ? `[${host}]` : host;
const url = new URL(`${protocol}://${hostPart}:${port}/ws`);
url.searchParams.set("session", params.sessionId);
url.searchParams.set("role", "client");
return url.toString();
}
export function deriveLabelFromEndpoint(endpoint: string): string {
try {
const { host } = parseHostPort(endpoint);
return host || "Unnamed Host";
} catch {
return "Unnamed Host";
}
}
@@ -0,0 +1,232 @@
import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client-v2";
type TauriWebSocketMessage =
| { type: "Text"; data: string }
| { type: "Binary"; data: number[] }
| { type: "Ping"; data: number[] }
| { type: "Pong"; data: number[] }
| { type: "Close"; data: { code: number; reason: string } | null };
type TauriWebSocketConnection = {
addListener(cb: (msg: TauriWebSocketMessage) => void): () => void;
send(message: string | number[] | TauriWebSocketMessage): Promise<void>;
disconnect(): Promise<void>;
};
type TauriWebSocketModule = {
connect(url: string, config?: unknown): Promise<TauriWebSocketConnection>;
};
function isTauriEnvironment(): boolean {
return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined;
}
function getTauriWebSocketModule(): TauriWebSocketModule | null {
if (!isTauriEnvironment()) {
return null;
}
const ws = (window as any).__TAURI__?.websocket;
if (ws && typeof ws.connect === "function") {
return ws as TauriWebSocketModule;
}
return null;
}
export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | null {
if (!isTauriEnvironment()) {
return null;
}
return ({ url }) => {
let ws: TauriWebSocketConnection | null = null;
let wsListenerCleanup: (() => void) | null = null;
let disposed = false;
let opened = false;
let closeEmitted = false;
const openHandlers = new Set<() => void>();
const closeHandlers = new Set<(event?: unknown) => void>();
const errorHandlers = new Set<(event?: unknown) => void>();
const messageHandlers = new Set<(data: unknown) => void>();
const pendingSends: string[] = [];
let closeRequested: { code?: number; reason?: string } | null = null;
const emitOpen = () => {
if (disposed || opened) return;
opened = true;
for (const handler of openHandlers) {
try {
handler();
} catch {
// no-op
}
}
};
const emitClose = (event?: unknown) => {
if (disposed || closeEmitted) return;
closeEmitted = true;
for (const handler of closeHandlers) {
try {
handler(event);
} catch {
// no-op
}
}
};
const emitError = (event?: unknown) => {
if (disposed) return;
for (const handler of errorHandlers) {
try {
handler(event);
} catch {
// no-op
}
}
};
const emitMessage = (data: unknown) => {
if (disposed) return;
for (const handler of messageHandlers) {
try {
handler(data);
} catch {
// no-op
}
}
};
const connect = async () => {
try {
let module: TauriWebSocketModule | null = getTauriWebSocketModule();
if (!module) {
const start = Date.now();
while (!module && Date.now() - start < 2000) {
await new Promise((r) => setTimeout(r, 50));
module = getTauriWebSocketModule();
}
}
if (!module) {
throw new Error(
"Tauri WebSocket plugin is not available (expected window.__TAURI__.websocket). Did you enable tauri-plugin-websocket and websocket:default capability?"
);
}
const socket = await module.connect(url);
if (disposed) {
try {
await socket.disconnect();
} catch {
// no-op
}
return;
}
ws = socket;
wsListenerCleanup = ws.addListener((msg) => {
if (msg.type === "Text") {
emitMessage({ data: msg.data });
return;
}
if (msg.type === "Close") {
emitClose(msg.data ?? undefined);
return;
}
});
emitOpen();
while (pendingSends.length > 0) {
const next = pendingSends.shift();
if (!next) break;
try {
await ws.send(next);
} catch (error) {
emitError(error);
}
}
if (closeRequested) {
try {
await ws.disconnect();
} catch (error) {
emitError(error);
} finally {
emitClose(closeRequested);
}
}
} catch (error) {
emitError(error);
}
};
void connect();
const transport: DaemonTransport = {
send: (data) => {
if (disposed) return;
if (!ws) {
pendingSends.push(data);
return;
}
void ws.send(data).catch((error) => emitError(error));
},
close: (code?: number, reason?: string) => {
if (disposed) return;
closeRequested = { code, reason };
if (!ws) return;
void ws
.disconnect()
.catch((error) => emitError(error))
.finally(() => emitClose(closeRequested));
},
onMessage: (handler) => {
messageHandlers.add(handler);
return () => messageHandlers.delete(handler);
},
onOpen: (handler) => {
openHandlers.add(handler);
if (opened) {
try {
handler();
} catch {
// no-op
}
}
return () => openHandlers.delete(handler);
},
onClose: (handler) => {
closeHandlers.add(handler);
if (closeEmitted) {
try {
handler(closeRequested ?? undefined);
} catch {
// no-op
}
}
return () => closeHandlers.delete(handler);
},
onError: (handler) => {
errorHandlers.add(handler);
return () => errorHandlers.delete(handler);
},
};
return {
...transport,
close: (code?: number, reason?: string) => {
transport.close(code, reason);
disposed = true;
try {
wsListenerCleanup?.();
} catch {
// no-op
}
wsListenerCleanup = null;
},
};
};
}