import { useCallback, useMemo, useState } from "react"; import { Text, TextInput, View } from "react-native"; import * as Clipboard from "expo-clipboard"; import * as QRCode from "qrcode"; import { SvgXml } from "react-native-svg"; import { useMutation } from "@tanstack/react-query"; import { Check, Copy, Network, RotateCw, ShieldCheck } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { Alert } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { ExternalLink } from "@/components/ui/external-link"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useFetchQuery } from "@/data/query"; import { daemonPairingOfferQueryKey } from "@/data/daemon-pairing"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime"; import type { Theme } from "@/styles/theme"; const RELAY_DOCS_URL = "https://paseo.sh/docs/security"; const FLEX_ONE_STYLE = { flex: 1 } as const; const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedShieldCheck = withUnistyles(ShieldCheck); const ThemedNetwork = withUnistyles(Network); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); const accentBrightColorMapping = (theme: Theme) => ({ color: theme.colors.accentBright }); export interface PairDeviceSectionProps { serverId: string; onClose: () => void; } export function PairDeviceSection({ serverId, onClose }: PairDeviceSectionProps) { const { t } = useTranslation(); const client = useHostRuntimeClient(serverId); const runtimeSnapshot = useHostRuntimeSnapshot(serverId); const isConnected = runtimeSnapshot?.connectionStatus === "online"; const isDisconnected = runtimeSnapshot?.connectionStatus === "offline" || runtimeSnapshot?.connectionStatus === "error"; const { patchConfig } = useDaemonConfig(serverId); const [copied, setCopied] = useState(false); const serverFeatures = client?.getLastServerInfoMessage()?.features; const supportsPairingRpc = serverFeatures?.daemonStatusRpc === true; const canConfigureRelay = supportsPairingRpc && serverFeatures?.relayConfig === true; const pairingQuery = useFetchQuery({ queryKey: daemonPairingOfferQueryKey(serverId), queryFn: async () => { if (!client) throw new Error(t("workspace.terminal.hostDisconnected")); return client.getDaemonPairingOffer(); }, enabled: supportsPairingRpc && Boolean(client && isConnected), dataShape: "value", staleTimeMs: 5 * 60 * 1000, retry: 1, }); const enableRelay = useMutation({ mutationFn: async () => { if (client?.getLastServerInfoMessage()?.features?.relayConfig !== true) { throw new Error(t("pairing.device.updateRequired")); } const config = await patchConfig({ relay: { enabled: true } }); if (!config) throw new Error(t("workspace.terminal.hostDisconnected")); return pairingQuery.refetch(); }, }); const qrQuery = useFetchQuery({ queryKey: ["daemon-pairing-offer-qr", pairingQuery.data?.url], queryFn: () => QRCode.toString(pairingQuery.data?.url ?? "", { type: "svg", errorCorrectionLevel: "M", margin: 1, width: 480, }), enabled: Boolean(pairingQuery.data?.url), dataShape: "value", staleTimeMs: 5 * 60 * 1000, }); const handleCopyLink = useCallback(async () => { if (!pairingQuery.data?.url) return; await Clipboard.setStringAsync(pairingQuery.data.url); setCopied(true); setTimeout(() => setCopied(false), 2000); }, [pairingQuery.data?.url]); const handleCopyPress = useCallback(() => { void handleCopyLink(); }, [handleCopyLink]); const handleRetry = useCallback(() => { void pairingQuery.refetch(); }, [pairingQuery]); const handleEnableRelay = useCallback(() => { enableRelay.mutate(); }, [enableRelay]); const qrSvg = useMemo(() => qrQuery.data ?? null, [qrQuery.data]); return ( ); } interface PairDeviceBodyProps { isPending: boolean; isDisconnected: boolean; error: Error | null; offer: { relayEnabled: boolean; url: string } | undefined; canConfigureRelay: boolean; enablePending: boolean; enableError: Error | null; qrSvg: string | null; qrError: boolean; copied: boolean; onRetry: () => void; onEnableRelay: () => void; onClose: () => void; onCopy: () => void; } function PairDeviceBody(props: PairDeviceBodyProps) { const { t } = useTranslation(); if (props.isDisconnected) { return ( ); } if (props.isPending) { return {t("pairing.device.loadingOffer")}; } if (props.error) { return ; } if (!props.offer?.relayEnabled) { return ; } if (!props.offer.url) { return {t("pairing.device.unavailable")}; } return ; } function OfferLoadError({ message, onRetry }: { message: string; onRetry: () => void }) { const { t } = useTranslation(); return ( ); } function RelayConsent(props: PairDeviceBodyProps) { const { t } = useTranslation(); let enableButtonLabel = t("pairing.device.enableRelay"); if (props.enablePending) { enableButtonLabel = t("pairing.device.enablingRelay"); } else if (props.enableError) { enableButtonLabel = t("pairing.device.retry"); } return ( {t("pairing.device.enableTitle")} {t("pairing.device.enableDescription")} {props.enableError ? : null} {!props.canConfigureRelay ? ( ) : null} {props.canConfigureRelay ? ( ) : null} {t("pairing.device.directConnectionHint")} ); } function RelayHeroBadge() { return ( ); } function PairingOffer(props: PairDeviceBodyProps & { offer: { url: string } }) { const { t } = useTranslation(); return ( {t("pairing.device.hint")} ); } function PairingQr({ svg, isError }: { svg: string | null; isError: boolean }) { const { t } = useTranslation(); if (svg) { return ( ); } if (isError) { return {t("pairing.device.qrUnavailable")}; } return ; } const styles = StyleSheet.create((theme) => ({ stateLine: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, textAlign: "center", paddingVertical: theme.spacing[6], }, consent: { gap: theme.spacing[4], }, hero: { alignItems: "flex-start", gap: theme.spacing[2], }, heroBadge: { width: 48, height: 48, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.surface2, borderWidth: 1, borderColor: theme.colors.border, alignItems: "center", justifyContent: "center", marginBottom: theme.spacing[1], }, consentTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.medium, }, consentDescription: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, lineHeight: theme.fontSize.sm * 1.5, }, actions: { flexDirection: "row", gap: theme.spacing[3], }, directRow: { flexDirection: "row", alignItems: "flex-start", gap: theme.spacing[2], borderTopWidth: 1, borderTopColor: theme.colors.border, paddingTop: theme.spacing[4], }, directIcon: { color: theme.colors.foregroundMuted, marginTop: 1, // optical: seats the glyph on the hint's first text line }, directHint: { flex: 1, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, lineHeight: theme.fontSize.xs * 1.5, }, offer: { gap: theme.spacing[4], }, offerHint: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, textAlign: "center", }, qrTile: { alignSelf: "center", alignItems: "center", justifyContent: "center", width: 304, maxWidth: "100%", aspectRatio: 1, padding: theme.spacing[3], borderRadius: theme.borderRadius.xl, borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.palette.white, }, qrImage: { width: "100%", height: "100%", }, linkRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, inputWrapper: { flex: 1, borderRadius: theme.borderRadius.lg, borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.surface2, overflow: "hidden", }, linkInput: { color: theme.colors.foregroundMuted, fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.xs, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], outlineStyle: "none", } as object, hint: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, }));