Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 143 additions & 11 deletions desktop/src-tauri/src/commands/pairing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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!({
Expand All @@ -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,
Expand Down Expand Up @@ -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<tokio::sync::Mutex<Option<PairingSession>>>,
generation: Arc<AtomicU64>,
task_generation: u64,
Expand All @@ -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,
Expand All @@ -256,20 +263,22 @@ async fn pairing_ws_task(
}

async fn pairing_ws_task_inner(
relay_url: &str,
pairing_endpoint: &PairingEndpoint,
session: &Arc<tokio::sync::Mutex<Option<PairingSession>>>,
generation: &AtomicU64,
task_generation: u64,
cancel: &CancellationToken,
outbound_rx: &mut mpsc::Receiver<String>,
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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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"
);
}
}
29 changes: 29 additions & 0 deletions desktop/tests/e2e/mobile-pairing-qr.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
});
});