diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs index fc874a0150..c0ebc7990e 100644 --- a/desktop/src-tauri/src/commands/pairing.rs +++ b/desktop/src-tauri/src/commands/pairing.rs @@ -11,7 +11,10 @@ use nostr::ToBech32; use serde::Serialize; use tauri::{AppHandle, Emitter, State}; use tokio::sync::mpsc; -use tokio_tungstenite::{connect_async, tungstenite::Message}; +use tokio_tungstenite::{ + connect_async, + tungstenite::{Error as WebSocketError, Message}, +}; use tokio_util::sync::CancellationToken; use zeroize::Zeroizing; @@ -103,9 +106,13 @@ pub async fn start_pairing( // own NIP-11 declaration of NIP-43 support rather than `auth_required`, // which is also true for plain NIP-42 / NIP-OA relays where the main // relay is reachable. - let pairing_relay_url = resolve_pairing_relay_url(&ws_url, probe_pairing_relay(&ws_url).await)?; + let pairing_relay = probe_pairing_relay(&ws_url).await; + let pairing_endpoint = PairingEndpoint { + source: PairingEndpointSource::from(&pairing_relay), + url: resolve_pairing_relay_url(&ws_url, pairing_relay)?, + }; - let (session, qr_payload) = PairingSession::new_source(pairing_relay_url.clone()); + let (session, qr_payload) = PairingSession::new_source(pairing_endpoint.url.clone()); let qr_uri = encode_qr(&qr_payload); let payload_json = serde_json::json!({ @@ -130,7 +137,7 @@ pub async fn start_pairing( let session_arc = Arc::clone(&pairing.session); let generation = Arc::clone(&pairing.generation); tauri::async_runtime::spawn(pairing_ws_task( - pairing_relay_url, + pairing_endpoint, session_arc, generation, task_generation, @@ -229,7 +236,7 @@ pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), Str } async fn pairing_ws_task( - relay_url: String, + pairing_endpoint: PairingEndpoint, session: Arc>>, generation: Arc, task_generation: u64, @@ -238,7 +245,7 @@ async fn pairing_ws_task( app: AppHandle, ) { if let Err(e) = pairing_ws_task_inner( - &relay_url, + &pairing_endpoint, &session, &generation, task_generation, @@ -256,7 +263,7 @@ async fn pairing_ws_task( } async fn pairing_ws_task_inner( - relay_url: &str, + pairing_endpoint: &PairingEndpoint, session: &Arc>>, generation: &AtomicU64, task_generation: u64, @@ -264,12 +271,14 @@ async fn pairing_ws_task_inner( outbound_rx: &mut mpsc::Receiver, app: &AppHandle, ) -> Result<(), String> { - let (ws, _) = connect_async(relay_url) + let (ws, _) = connect_async(&pairing_endpoint.url) .await - .map_err(|e| format!("WebSocket connection failed: {e}"))?; + .map_err(|error| { + pairing_connection_error(&pairing_endpoint.url, pairing_endpoint.source, &error) + })?; let (mut write, mut read) = ws.split(); - handle_nip42_auth(&mut read, &mut write, session, relay_url).await?; + handle_nip42_auth(&mut read, &mut write, session, &pairing_endpoint.url).await?; let our_pk = { let guard = session.lock().await; @@ -476,6 +485,67 @@ enum PairingRelay { MainRelay, } +struct PairingEndpoint { + url: String, + source: PairingEndpointSource, +} + +#[derive(Clone, Copy)] +enum PairingEndpointSource { + Configured, + LegacyPath, + MainRelay, +} + +impl From<&PairingRelay> for PairingEndpointSource { + fn from(relay: &PairingRelay) -> Self { + match relay { + PairingRelay::Configured(_) => Self::Configured, + PairingRelay::LegacyPath => Self::LegacyPath, + PairingRelay::MainRelay => Self::MainRelay, + } + } +} + +fn pairing_connection_error( + relay_url: &str, + source: PairingEndpointSource, + error: &WebSocketError, +) -> String { + if matches!(source, PairingEndpointSource::LegacyPath) { + if let WebSocketError::Http(response) = error { + if response.status() == tokio_tungstenite::tungstenite::http::StatusCode::NOT_FOUND { + let endpoint = pairing_endpoint_for_display(relay_url, "/pair"); + return format!( + "Pairing endpoint {endpoint} returned 404. This relay advertises NIP-43 \ + without a pairing_relay_url, but nothing serves /pair. Set \ + BUZZ_PAIRING_RELAY_URL or route /pair to buzz-pair-relay, then try again." + ); + } + } + } + + if matches!(source, PairingEndpointSource::Configured) { + let endpoint = pairing_endpoint_for_display(relay_url, "the configured endpoint"); + return format!( + "Connection to configured pairing endpoint {endpoint} failed: {error}. Check the \ + relay's pairing_relay_url and that the pairing service is reachable, then try again." + ); + } + + format!("WebSocket connection failed: {error}") +} + +fn pairing_endpoint_for_display(relay_url: &str, fallback: &str) -> String { + url::Url::parse(relay_url) + .map(|mut url| { + url.set_query(None); + url.set_fragment(None); + url.to_string() + }) + .unwrap_or_else(|_| fallback.to_string()) +} + /// Prefer the relay-advertised dedicated pairing URL. The legacy `/pair` /// convention remains as a compatibility fallback for NIP-43 relays that do /// not advertise the extension yet. @@ -626,9 +696,20 @@ mod pairing_generation_tests { #[cfg(test)] mod pairing_relay_tests { use super::{ - pairing_relay_from_nip11, probe_pairing_relay, resolve_pairing_relay_url, PairingRelay, + pairing_connection_error, pairing_relay_from_nip11, probe_pairing_relay, + resolve_pairing_relay_url, PairingEndpointSource, PairingRelay, }; use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio_tungstenite::tungstenite::{http::Response, Error as WebSocketError}; + + fn websocket_http_error(status: u16) -> WebSocketError { + WebSocketError::Http(Box::new( + Response::builder() + .status(status) + .body(None) + .expect("build test HTTP response"), + )) + } #[tokio::test] async fn live_nip11_probe_discovers_configured_pairing_relay() { @@ -729,4 +810,55 @@ mod pairing_relay_tests { assert_eq!(resolved, "wss://sprout-oss.stage.blox.sqprod.co"); } + + #[test] + fn legacy_pairing_404_explains_the_missing_pairing_service() { + let error = websocket_http_error(404); + let message = pairing_connection_error( + "wss://relay.example.com/pair?token=do-not-display#fragment", + PairingEndpointSource::LegacyPath, + &error, + ); + + assert_eq!( + message, + "Pairing endpoint wss://relay.example.com/pair returned 404. This relay advertises \ + NIP-43 without a pairing_relay_url, but nothing serves /pair. Set \ + BUZZ_PAIRING_RELAY_URL or route /pair to buzz-pair-relay, then try again." + ); + assert!(!message.contains("do-not-display")); + } + + #[test] + fn configured_pairing_error_names_configured_endpoint_and_remedy() { + let error = websocket_http_error(404); + let message = pairing_connection_error( + "wss://pairing.example.com/connect?token=do-not-display#fragment", + PairingEndpointSource::Configured, + &error, + ); + + assert_eq!( + message, + "Connection to configured pairing endpoint wss://pairing.example.com/connect failed: \ + HTTP error: 404 Not Found. Check the relay's pairing_relay_url and that the pairing \ + service is reachable, then try again." + ); + assert!(!message.contains("do-not-display")); + assert!(!message.contains("route /pair")); + } + + #[test] + fn main_relay_pairing_404_keeps_the_generic_connection_error() { + let error = websocket_http_error(404); + + assert_eq!( + pairing_connection_error( + "wss://pairing.example.com", + PairingEndpointSource::MainRelay, + &error, + ), + "WebSocket connection failed: HTTP error: 404 Not Found" + ); + } } diff --git a/desktop/tests/e2e/mobile-pairing-qr.spec.ts b/desktop/tests/e2e/mobile-pairing-qr.spec.ts index 606777545f..cd98274728 100644 --- a/desktop/tests/e2e/mobile-pairing-qr.spec.ts +++ b/desktop/tests/e2e/mobile-pairing-qr.spec.ts @@ -183,3 +183,32 @@ test("late pairing events are ignored after canceling", async ({ page }) => { await expect(page.getByTestId("mobile-pairing-done")).toHaveCount(0); await expect(card.getByText("Pairing was canceled.")).toBeVisible(); }); + +test("legacy pairing 404 explains how to configure the relay", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("open-settings").click(); + await page.getByTestId("profile-popover-settings").click(); + await page.getByTestId("settings-nav-mobile").click(); + + mkdirSync(SCREENSHOT_DIR, { recursive: true }); + + const card = page.getByTestId("mobile-pairing-card"); + await card.getByTestId("start-pairing-button").click(); + await expect(page.getByTestId("mobile-pairing-qr")).toBeVisible(); + + await emitPairingEvent(page, "pairing-error", { + message: + "Pairing endpoint wss://relay.example.com/pair returned 404. This relay advertises NIP-43 without a pairing_relay_url, but nothing serves /pair. Set BUZZ_PAIRING_RELAY_URL or route /pair to buzz-pair-relay, then try again.", + }); + + await expect( + card.getByText(/Set BUZZ_PAIRING_RELAY_URL or route \/pair/), + ).toBeVisible(); + await expect(card.getByTestId("retry-pairing-button")).toBeVisible(); + await waitForAnimations(page); + await card.screenshot({ + path: `${SCREENSHOT_DIR}/pairing-legacy-404.png`, + }); +});