diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs index 639ae16da..fc874a015 100644 --- a/desktop/src-tauri/src/commands/pairing.rs +++ b/desktop/src-tauri/src/commands/pairing.rs @@ -1,3 +1,4 @@ +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -35,6 +36,7 @@ struct PairingErrorPayload { /// Managed Tauri state for an active pairing session. pub struct PairingHandle { session: Arc>>, + generation: Arc, cancel: std::sync::Mutex>, /// Send JSON-serialized events to the background WS task for relay publication. outbound_tx: std::sync::Mutex>>, @@ -47,6 +49,7 @@ impl PairingHandle { pub fn new() -> Self { Self { session: Arc::new(tokio::sync::Mutex::new(None)), + generation: Arc::new(AtomicU64::new(0)), cancel: std::sync::Mutex::new(None), outbound_tx: std::sync::Mutex::new(None), payload: std::sync::Mutex::new(None), @@ -71,10 +74,18 @@ pub async fn start_pairing( state: State<'_, AppState>, pairing: State<'_, PairingHandle>, ) -> Result { + let task_generation = pairing + .generation + .fetch_add(1, Ordering::SeqCst) + .wrapping_add(1); if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() { token.cancel(); } pairing.clear(); + { + let mut session = pairing.session.lock().await; + *session = None; + } let keys = state.signing_keys()?; let nsec = keys @@ -117,9 +128,12 @@ pub async fn start_pairing( *pairing.cancel.lock().map_err(|e| e.to_string())? = Some(cancel.clone()); let session_arc = Arc::clone(&pairing.session); + let generation = Arc::clone(&pairing.generation); tauri::async_runtime::spawn(pairing_ws_task( pairing_relay_url, session_arc, + generation, + task_generation, cancel, outbound_rx, app, @@ -199,6 +213,8 @@ pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), Str } } + pairing.generation.fetch_add(1, Ordering::SeqCst); + if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() { token.cancel(); } @@ -215,22 +231,35 @@ pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), Str async fn pairing_ws_task( relay_url: String, session: Arc>>, + generation: Arc, + task_generation: u64, cancel: CancellationToken, mut outbound_rx: mpsc::Receiver, app: AppHandle, ) { - if let Err(e) = - pairing_ws_task_inner(&relay_url, &session, &cancel, &mut outbound_rx, &app).await + if let Err(e) = pairing_ws_task_inner( + &relay_url, + &session, + &generation, + task_generation, + &cancel, + &mut outbound_rx, + &app, + ) + .await { - let _ = app.emit("pairing-error", PairingErrorPayload { message: e }); + if pairing_task_is_current(&generation, task_generation) { + let _ = app.emit("pairing-error", PairingErrorPayload { message: e }); + } } - let mut s = session.lock().await; - *s = None; + clear_pairing_session_if_current(&session, &generation, task_generation).await; } async fn pairing_ws_task_inner( relay_url: &str, session: &Arc>>, + generation: &AtomicU64, + task_generation: u64, cancel: &CancellationToken, outbound_rx: &mut mpsc::Receiver, app: &AppHandle, @@ -261,12 +290,18 @@ async fn pairing_ws_task_inner( tokio::pin!(hard_timeout); loop { + if !pairing_task_is_current(generation, task_generation) { + break; + } + tokio::select! { _ = cancel.cancelled() => break, _ = &mut hard_timeout => { - let _ = app.emit("pairing-error", PairingErrorPayload { - message: "Session timed out".into(), - }); + if pairing_task_is_current(generation, task_generation) { + let _ = app.emit("pairing-error", PairingErrorPayload { + message: "Session timed out".into(), + }); + } break; } Some(json_msg) = outbound_rx.recv() => { @@ -282,30 +317,42 @@ async fn pairing_ws_task_inner( let Message::Text(text) = msg else { continue }; if let Some(event) = parse_relay_event(text.as_str(), "pair") { + if !pairing_task_is_current(generation, task_generation) { + break; + } + let mut guard = session.lock().await; let Some(s) = guard.as_mut() else { break }; if let Ok(reason) = s.handle_abort(&event) { - let _ = app.emit("pairing-aborted", PairingAbortedPayload { - reason: format!("{reason:?}"), - }); + if pairing_task_is_current(generation, task_generation) { + let _ = app.emit("pairing-aborted", PairingAbortedPayload { + reason: format!("{reason:?}"), + }); + } break; } if let Ok(sas) = s.handle_offer(&event) { - let _ = app.emit("pairing-sas-received", PairingSasPayload { sas }); + if pairing_task_is_current(generation, task_generation) { + let _ = app.emit("pairing-sas-received", PairingSasPayload { sas }); + } continue; } match s.handle_complete(&event) { Ok(()) => { - let _ = app.emit("pairing-complete", serde_json::json!({})); + if pairing_task_is_current(generation, task_generation) { + let _ = app.emit("pairing-complete", serde_json::json!({})); + } break; } Err(ref e) if format!("{e}").contains("success=false") => { - let _ = app.emit("pairing-error", PairingErrorPayload { - message: "Mobile device reported failure importing credentials".into(), - }); + if pairing_task_is_current(generation, task_generation) { + let _ = app.emit("pairing-error", PairingErrorPayload { + message: "Mobile device reported failure importing credentials".into(), + }); + } break; } Err(_) => {} @@ -318,6 +365,21 @@ async fn pairing_ws_task_inner( Ok(()) } +fn pairing_task_is_current(generation: &AtomicU64, task_generation: u64) -> bool { + generation.load(Ordering::SeqCst) == task_generation +} + +async fn clear_pairing_session_if_current( + session: &Arc>>, + generation: &AtomicU64, + task_generation: u64, +) { + let mut session = session.lock().await; + if pairing_task_is_current(generation, task_generation) { + *session = None; + } +} + async fn handle_nip42_auth( read: &mut R, write: &mut W, @@ -527,6 +589,40 @@ where .map_err(|_| "timeout waiting for EOSE".to_string())? } +#[cfg(test)] +mod pairing_generation_tests { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Arc; + + use super::{clear_pairing_session_if_current, PairingSession}; + + #[tokio::test] + async fn stale_task_does_not_clear_replacement_session() { + let (initial, _) = PairingSession::new_source("ws://initial.example".to_string()); + let session = Arc::new(tokio::sync::Mutex::new(Some(initial))); + let generation = AtomicU64::new(1); + + generation.store(2, Ordering::SeqCst); + let (replacement, _) = PairingSession::new_source("ws://replacement.example".to_string()); + *session.lock().await = Some(replacement); + + clear_pairing_session_if_current(&session, &generation, 1).await; + + assert!(session.lock().await.is_some()); + } + + #[tokio::test] + async fn current_task_clears_its_session() { + let (active, _) = PairingSession::new_source("ws://active.example".to_string()); + let session = Arc::new(tokio::sync::Mutex::new(Some(active))); + let generation = AtomicU64::new(3); + + clear_pairing_session_if_current(&session, &generation, 3).await; + + assert!(session.lock().await.is_none()); + } +} + #[cfg(test)] mod pairing_relay_tests { use super::{ diff --git a/desktop/src/app/RelayConnectionOverlay.tsx b/desktop/src/app/RelayConnectionOverlay.tsx index faf28ee51..07c6933d0 100644 --- a/desktop/src/app/RelayConnectionOverlay.tsx +++ b/desktop/src/app/RelayConnectionOverlay.tsx @@ -60,7 +60,7 @@ export function RelayConnectionOverlay({ animate={{ opacity: 1, y: 0 }} className={cn( "pointer-events-none fixed z-50 w-[284px]", - hasCommunityRail ? "left-[60px]" : "left-3", + hasCommunityRail ? "left-[68px]" : "left-3", isHuddleDrawerOpen ? "bottom-[calc(var(--buzz-huddle-drawer-height,0px)+12px)]" : "bottom-3", @@ -86,7 +86,7 @@ export function RelayConnectionOverlay({ animate={{ opacity: 1, y: 0 }} className={cn( "pointer-events-none fixed z-50 w-[284px]", - hasCommunityRail ? "left-[60px]" : "left-3", + hasCommunityRail ? "left-[68px]" : "left-3", isHuddleDrawerOpen ? "bottom-[calc(var(--buzz-huddle-drawer-height,0px)+12px)]" : "bottom-3", diff --git a/desktop/src/features/settings/ui/MobilePairingCard.tsx b/desktop/src/features/settings/ui/MobilePairingCard.tsx index 2013d17de..e15f54316 100644 --- a/desktop/src/features/settings/ui/MobilePairingCard.tsx +++ b/desktop/src/features/settings/ui/MobilePairingCard.tsx @@ -2,16 +2,15 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Check, Copy, + LoaderCircle, + RefreshCw, ShieldCheck, - Smartphone, TriangleAlert, X, } from "lucide-react"; import { listen } from "@tauri-apps/api/event"; import { toast } from "sonner"; -import { Spinner } from "@/shared/ui/spinner"; - import { cancelPairing, confirmPairingSas, @@ -31,206 +30,74 @@ import { SettingsSectionHeader } from "./SettingsSectionHeader"; import { writeTextToClipboard } from "@/shared/lib/clipboard"; type PairingStep = + | "idle" | "generating" | "qr" + | "expired" | "sas" | "transferring" | "done" | "error"; -function PairingDialog({ - open, - onOpenChange, +function pairingErrorMessage(error: unknown) { + const message = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : ""; + + if (message.toLowerCase().includes("timeout waiting for eose")) { + return "Pairing took too long. Try again."; + } + + return message || "We couldn't start pairing. Try again."; +} + +function isPairingSessionTimeout(message: string) { + return message.toLowerCase().includes("session timed out"); +} + +function PairingStatusDialog({ + onClose, + onConfirm, + onDeny, + sasCode, + step, }: { - open: boolean; - onOpenChange: (open: boolean) => void; + onClose: () => void; + onConfirm: () => void; + onDeny: () => void; + sasCode: string | null; + step: PairingStep; }) { - const [step, setStep] = useState("generating"); - const [qrUri, setQrUri] = useState(null); - const [sasCode, setSasCode] = useState(null); - const [error, setError] = useState(null); - const stepRef = useRef(step); - stepRef.current = step; - - // Start pairing when dialog opens. - useEffect(() => { - if (!open) return; - - setStep("generating"); - setQrUri(null); - setSasCode(null); - setError(null); - let cancelled = false; - - startPairing().then( - (uri) => { - if (!cancelled) { - setQrUri(uri); - setStep("qr"); - } - }, - (err) => { - if (!cancelled) { - setError( - err instanceof Error - ? err.message - : "Failed to start pairing session", - ); - setStep("error"); - } - }, - ); - - return () => { - cancelled = true; - }; - }, [open]); - - // Listen for Tauri events from the pairing backend. - useEffect(() => { - if (!open) return; - - let cancelled = false; - const unlisteners: (() => void)[] = []; - - listen<{ sas: string }>("pairing-sas-received", (event) => { - if (!cancelled) { - setSasCode(event.payload.sas); - setStep("sas"); - } - }).then((fn) => { - if (cancelled) fn(); - else unlisteners.push(fn); - }); - - listen("pairing-complete", () => { - if (!cancelled) { - setStep("done"); - } - }).then((fn) => { - if (cancelled) fn(); - else unlisteners.push(fn); - }); - - listen<{ reason: string }>("pairing-aborted", (event) => { - if (!cancelled) { - setError(`Pairing aborted: ${event.payload.reason}`); - setStep("error"); - } - }).then((fn) => { - if (cancelled) fn(); - else unlisteners.push(fn); - }); - - listen<{ message: string }>("pairing-error", (event) => { - if (!cancelled) { - setError(event.payload.message); - setStep("error"); - } - }).then((fn) => { - if (cancelled) fn(); - else unlisteners.push(fn); - }); - - return () => { - cancelled = true; - for (const fn of unlisteners) fn(); - }; - }, [open]); - - // Cancel pairing when dialog closes before completion. - const handleOpenChange = useCallback( - (nextOpen: boolean) => { - if (!nextOpen && stepRef.current !== "done") { - cancelPairing().catch(() => {}); - } - onOpenChange(nextOpen); - }, - [onOpenChange], - ); - - async function handleConfirmSas() { - setStep("transferring"); - try { - await confirmPairingSas(); - } catch (err) { - setError( - err instanceof Error ? err.message : "Failed to send credentials", - ); - setStep("error"); - } - } - - function handleDenySas() { - cancelPairing().catch(() => {}); - setError("SAS code mismatch — pairing cancelled for security."); - setStep("error"); - } - - async function handleCopy() { - if (!qrUri) return; - await writeTextToClipboard(qrUri); - toast.success("Copied to clipboard"); - } + const open = step === "sas" || step === "transferring" || step === "done"; return ( - + { + if (!nextOpen) onClose(); + }} + open={open} + >
- Pair Mobile Device + Pair mobile device {step === "sas" ? "Verify the security code matches your mobile device." : step === "done" ? "Your mobile device is now paired." - : "Scan this QR code with the Buzz mobile app to securely pair."} + : "Securely sending your identity to the mobile app."}
- {step === "error" && error ? ( -
- - {error} -
- ) : step === "generating" ? ( -
- -

- Preparing secure pairing session... -

-
- ) : step === "qr" && qrUri ? ( -
-
- -
- - -
- ) : step === "sas" && sasCode ? ( + {step === "sas" && sasCode ? (
@@ -255,7 +122,7 @@ function PairingDialog({
) : step === "transferring" ? (
- +
) : step === "done" ? ( -
+
-

- Mobile device paired successfully -

+

Mobile device paired

Your mobile app is now connected to this relay.

@@ -303,7 +174,158 @@ export function MobilePairingCard({ }: { currentPubkey?: string; }) { - const [dialogOpen, setDialogOpen] = useState(false); + const [step, setStep] = useState("idle"); + const [qrUri, setQrUri] = useState(null); + const [sasCode, setSasCode] = useState(null); + const [error, setError] = useState(null); + const requestIdRef = useRef(0); + const pairingActiveRef = useRef(false); + const stepRef = useRef(step); + stepRef.current = step; + + const beginPairing = useCallback(() => { + const requestId = ++requestIdRef.current; + pairingActiveRef.current = true; + setStep("generating"); + setQrUri(null); + setSasCode(null); + setError(null); + + startPairing().then( + (uri) => { + if (requestId === requestIdRef.current) { + setQrUri(uri); + setStep("qr"); + } + }, + (err) => { + if (requestId === requestIdRef.current) { + pairingActiveRef.current = false; + setError(pairingErrorMessage(err)); + setStep("error"); + } + }, + ); + }, []); + + useEffect(() => { + ++requestIdRef.current; + pairingActiveRef.current = false; + setStep("idle"); + setQrUri(null); + setSasCode(null); + setError(null); + + if (!currentPubkey) { + return; + } + + let cancelled = false; + const unlisteners: (() => void)[] = []; + + listen<{ sas: string }>("pairing-sas-received", (event) => { + if (!cancelled && pairingActiveRef.current) { + setSasCode(event.payload.sas); + setStep("sas"); + } + }).then((fn) => { + if (cancelled) fn(); + else unlisteners.push(fn); + }); + + listen("pairing-complete", () => { + if (!cancelled && pairingActiveRef.current) { + pairingActiveRef.current = false; + setStep("done"); + } + }).then((fn) => { + if (cancelled) fn(); + else unlisteners.push(fn); + }); + + listen<{ reason: string }>("pairing-aborted", (event) => { + if (!cancelled && pairingActiveRef.current) { + pairingActiveRef.current = false; + setError(`Pairing stopped: ${event.payload.reason}`); + setStep("error"); + } + }).then((fn) => { + if (cancelled) fn(); + else unlisteners.push(fn); + }); + + listen<{ message: string }>("pairing-error", (event) => { + if (!cancelled && pairingActiveRef.current) { + pairingActiveRef.current = false; + if (isPairingSessionTimeout(event.payload.message)) { + setQrUri(null); + setSasCode(null); + setError(null); + setStep("expired"); + return; + } + + setError(pairingErrorMessage(event.payload.message)); + setStep("error"); + } + }).then((fn) => { + if (cancelled) fn(); + else unlisteners.push(fn); + }); + + return () => { + cancelled = true; + ++requestIdRef.current; + pairingActiveRef.current = false; + for (const fn of unlisteners) fn(); + if (stepRef.current !== "idle" && stepRef.current !== "done") { + cancelPairing().catch(() => {}); + } + }; + }, [currentPubkey]); + + async function handleCopy() { + if (!qrUri) return; + await writeTextToClipboard(qrUri); + toast.success("Copied to clipboard"); + } + + async function handleConfirmSas() { + setStep("transferring"); + try { + await confirmPairingSas(); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "We couldn't send your identity. Try again.", + ); + pairingActiveRef.current = false; + setStep("error"); + } + } + + function handleDenySas() { + pairingActiveRef.current = false; + cancelPairing().catch(() => {}); + setError("The codes didn't match. Pairing was canceled."); + setStep("error"); + } + + function handleStatusDialogClose() { + pairingActiveRef.current = false; + if (stepRef.current === "done") { + setStep("idle"); + setQrUri(null); + setSasCode(null); + setError(null); + return; + } + + cancelPairing().catch(() => {}); + setError("Pairing was canceled."); + setStep("error"); + } return (
@@ -318,29 +340,106 @@ export function MobilePairingCard({ } /> - - - -
-

Pair Mobile Device

-

- Securely transfer your identity via NIP-AB protocol -

-
- + {step === "qr" && qrUri ? ( + + ) : step === "expired" ? ( +
+

+ Pairing code expired. +

+ +
+ ) : step === "error" ? ( +
+ +

+ {error ?? "Pairing session ended."} +

+ +
+ ) : step === "idle" ? ( + currentPubkey ? ( + + ) : ( +

+ Sign in to generate a mobile pairing code. +

+ ) + ) : ( +
+
+ )} +
+ + {step === "qr" && qrUri ? ( + + ) : null} - {currentPubkey && ( - - )} + void handleConfirmSas()} + onDeny={handleDenySas} + sasCode={sasCode} + step={step} + /> ); } diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index b40f56b04..55f467f21 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -562,7 +562,9 @@ export function AppSidebar({ variant="sidebar" >
1 ? "md:-ml-[11px] md:w-[calc(100%+11px)]" : "" + }`} data-sidebar-background data-testid="app-sidebar-scroll-anchor" > diff --git a/desktop/src/features/sidebar/ui/CommunityRail.tsx b/desktop/src/features/sidebar/ui/CommunityRail.tsx index 86d632398..b15e0bab7 100644 --- a/desktop/src/features/sidebar/ui/CommunityRail.tsx +++ b/desktop/src/features/sidebar/ui/CommunityRail.tsx @@ -37,8 +37,6 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { cn } from "@/shared/lib/cn"; import { getInitials } from "@/shared/lib/initials"; -import { isMacPlatform } from "@/shared/lib/platform"; -import { useIsFullscreen } from "@/shared/lib/useIsFullscreen"; import { writeTextToClipboard } from "@/shared/lib/clipboard"; type CommunityRailProps = { @@ -315,7 +313,6 @@ export function CommunityRail({ activeCommunityId, ); const iconsByCommunity = useCommunityIcons(communities); - const isFullscreen = useIsFullscreen(); const { markAllChannelsRead, onOpenSettings } = useAppShell(); const myMembershipQuery = useMyRelayMembershipLookupQuery(); const activeRole = myMembershipQuery.data?.membership?.role; @@ -370,19 +367,10 @@ export function CommunityRail({ }); }; - // macOS traffic lights overlay the top-left, so start buttons below them (they hide in fullscreen). - const topPaddingClass = - isMacPlatform() && !isFullscreen - ? "pt-(--buzz-top-chrome-height,40px)" - : "pt-3"; - return (