From cf304085c1b8e363318903b613b29f37f6f4e250 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Sat, 7 Nov 2026 16:33:31 -0500 Subject: [PATCH 1/3] feat: send a User-Agent on relay WebSocket upgrades buzz-acp, buzz-cli, buzz-ws-client, buzz-pairing-cli, buzz-audio-client and the mobile app now name themselves and their version on every relay WebSocket upgrade. buzz-ws-client and buzz-audio-client default to their own name and keep a User-Agent the caller supplies. Signed-off-by: Will Pfleger Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> --- crates/buzz-acp/src/relay.rs | 42 ++++++++++- crates/buzz-audio-client/src/connection.rs | 20 ++++- crates/buzz-audio-client/tests/connection.rs | 17 +++-- crates/buzz-cli/src/client.rs | 24 +++++- crates/buzz-pairing-cli/src/main.rs | 33 +++++++- crates/buzz-ws-client/src/connection.rs | 75 +++++++++++++++++-- mobile/lib/main.dart | 8 ++ mobile/lib/shared/relay/relay_websocket.dart | 14 ++++ .../relay/relay_socket_liveness_test.dart | 24 ++++++ 9 files changed, 239 insertions(+), 18 deletions(-) diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 0538233267e..59a9bbef4db 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -133,6 +133,8 @@ use nostr::{Event, EventBuilder, Keys, Kind, RelayUrl, Tag}; use serde_json::{json, Value}; use tokio::sync::mpsc; use tokio::time::timeout; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::{header::USER_AGENT, HeaderValue}; use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; use tracing::{debug, info, warn}; use uuid::Uuid; @@ -4204,6 +4206,9 @@ where Err(last_err.unwrap_or(RelayError::ConnectionClosed)) } +/// `User-Agent` sent on every relay WebSocket upgrade. +const RELAY_USER_AGENT: &str = concat!("buzz-acp/", env!("CARGO_PKG_VERSION")); + /// Perform a single WebSocket connect + NIP-42 auth handshake. /// /// Returns `(ws, buffer)` on success. @@ -4216,7 +4221,14 @@ async fn do_connect( .parse::() .map_err(|e| RelayError::Http(format!("invalid relay URL: {e}")))?; - let (ws, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(parsed.as_str())) + let mut request = parsed + .as_str() + .into_client_request() + .map_err(|e| RelayError::WebSocket(Box::new(e)))?; + request + .headers_mut() + .insert(USER_AGENT, HeaderValue::from_static(RELAY_USER_AGENT)); + let (ws, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(request)) .await .map_err(|_| RelayError::ConnectionClosed)? // timeout → treat as connection failure .map_err(|e| RelayError::WebSocket(Box::new(e)))?; @@ -6375,6 +6387,34 @@ mod tests { /// WebSocket endpoint, and tungstenite returns `Error::Http` (non-101 /// response) or `Error::Url(UnsupportedUrlScheme)` depending on how far /// the handshake gets. Either way it must not be retried. + /// The upgrade `do_connect()` sends names buzz-acp and its version. + #[allow(clippy::result_large_err)] // tungstenite's handshake callback type + #[tokio::test] + async fn do_connect_sends_user_agent() { + use tokio_tungstenite::tungstenite::handshake::server::{Request, Response}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("ws://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut received = None; + let _ws = tokio_tungstenite::accept_hdr_async( + stream, + |request: &Request, response: Response| { + received = request.headers().get(USER_AGENT).cloned(); + Ok(response) + }, + ) + .await + .unwrap(); + received + }); + // The server hangs up after the upgrade, so the AUTH handshake fails. + let _ = do_connect(&url, &nostr::Keys::generate(), None).await; + assert_eq!(server.await.unwrap().unwrap(), RELAY_USER_AGENT); + assert!(RELAY_USER_AGENT.starts_with("buzz-acp/")); + } + #[tokio::test] async fn do_connect_wrong_scheme_is_terminal() { let keys = nostr::Keys::generate(); diff --git a/crates/buzz-audio-client/src/connection.rs b/crates/buzz-audio-client/src/connection.rs index 6413b41742b..21b4b22b02b 100644 --- a/crates/buzz-audio-client/src/connection.rs +++ b/crates/buzz-audio-client/src/connection.rs @@ -5,7 +5,12 @@ use serde_json::{json, Value}; use std::{collections::BTreeSet, future::Future, time::Duration}; use tokio_tungstenite::{ connect_async_tls_with_config, - tungstenite::{client::IntoClientRequest, protocol::WebSocketConfig, Message}, + tungstenite::{ + client::IntoClientRequest, + http::{header::USER_AGENT, HeaderValue}, + protocol::WebSocketConfig, + Message, + }, Connector, MaybeTlsStream, WebSocketStream, }; @@ -96,11 +101,15 @@ async fn control(socket: &mut AudioSocket) -> Result { } } +/// `User-Agent` sent on the upgrade unless the request already carries one. +const DEFAULT_USER_AGENT: &str = concat!("buzz-audio-client/", env!("CARGO_PKG_VERSION")); + /// Connect and authenticate using a host-provided asynchronous signer. /// /// The host validates the destination before calling, and constructs the NIP-42 /// event (including its exact relay tag) from the challenge. `request` is the -/// audio URL, or a request carrying host-supplied upgrade headers. No private key is +/// audio URL, or a request carrying host-supplied upgrade headers; without a +/// `User-Agent` it sends `buzz-audio-client/`. No private key is /// accepted. Dropping this future cancels connection/admission; the entire /// operation is bounded to 15 seconds, including signing. pub async fn connect( @@ -117,6 +126,13 @@ where let config = WebSocketConfig::default() .max_message_size(Some(65_536)) .max_frame_size(Some(65_536)); + let mut request = request + .into_client_request() + .map_err(|e| format!("audio WS connect failed: {e}"))?; + request + .headers_mut() + .entry(USER_AGENT) + .or_insert(HeaderValue::from_static(DEFAULT_USER_AGENT)); let (mut socket, _) = connect_async_tls_with_config(request, Some(config), false, connector) .await diff --git a/crates/buzz-audio-client/tests/connection.rs b/crates/buzz-audio-client/tests/connection.rs index 31e9cb66f5a..f4e010e59f6 100644 --- a/crates/buzz-audio-client/tests/connection.rs +++ b/crates/buzz-audio-client/tests/connection.rs @@ -149,7 +149,11 @@ async fn request_headers_reach_the_upgrade() { let (stream, _) = listener.accept().await.unwrap(); let (seen, header) = oneshot::channel(); let mut socket = accept_hdr_async(stream, |request: &Request, response: Response| { - let _ = seen.send(request.headers().get("authorization").cloned()); + let headers = request.headers(); + let _ = seen.send(( + headers.get("authorization").cloned(), + headers.get("user-agent").cloned(), + )); Ok(response) }) .await @@ -182,8 +186,11 @@ async fn request_headers_reach_the_upgrade() { }) .await .unwrap(); - assert_eq!( - server.await.unwrap().unwrap().to_str().unwrap(), - "Bearer upgrade-token" - ); + let (authorization, user_agent) = server.await.unwrap(); + assert_eq!(authorization.unwrap(), "Bearer upgrade-token"); + let user_agent = user_agent.unwrap(); + assert!(user_agent + .to_str() + .unwrap() + .starts_with("buzz-audio-client/")); } diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index bed59b5ae04..babf710b5a9 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -12,6 +12,8 @@ use crate::error::CliError; /// NIP-FI assertion header (`docs/nips/NIP-FI.md` §Client-attached transport). const FEDERATED_IDENTITY_HEADER: &str = "Nostr-Federated-Identity"; +/// `User-Agent` sent on every WebSocket upgrade. +const USER_AGENT: &str = concat!("buzz-cli/", env!("CARGO_PKG_VERSION")); /// Descriptor returned by the relay after a successful upload. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -704,10 +706,14 @@ impl BuzzClient { }) } - /// The NIP-FI assertion as WebSocket upgrade headers (empty without an - /// evidence source). + /// The WebSocket upgrade headers: the CLI's `User-Agent`, and the NIP-FI + /// assertion when there is an evidence source. async fn upgrade_headers(&self) -> Result { let mut headers = buzz_ws_client::HeaderMap::new(); + headers.insert( + "user-agent", + buzz_ws_client::HeaderValue::from_static(USER_AGENT), + ); if let Some(assertion) = self.evidence().await? { let mut value = buzz_ws_client::HeaderValue::from_str(&format!("Bearer {assertion}")) @@ -4095,6 +4101,20 @@ mod ws_deadline_tests { pub(super) type Log = Arc>>; + /// Every upgrade names the CLI and its version. + #[tokio::test] + async fn upgrade_headers_carry_the_cli_user_agent() { + let keys = Keys::generate(); + let c = BuzzClient::new( + "http://127.0.0.1:1".into(), + Credentials::from_signer(Arc::new(keys.clone()), keys.public_key()), + ) + .unwrap(); + let headers = c.upgrade_headers().await.unwrap(); + assert_eq!(headers.get("user-agent").unwrap(), super::USER_AGENT); + assert!(super::USER_AGENT.starts_with("buzz-cli/")); + } + /// A sign-in signature that comes back after the relay's 5 s deadline /// reconnects and signs the new challenge, never the old one. #[tokio::test] diff --git a/crates/buzz-pairing-cli/src/main.rs b/crates/buzz-pairing-cli/src/main.rs index 2d03d594a8a..8341fb097df 100644 --- a/crates/buzz-pairing-cli/src/main.rs +++ b/crates/buzz-pairing-cli/src/main.rs @@ -27,6 +27,11 @@ use clap::{Parser, Subcommand}; use futures_util::{SinkExt, StreamExt}; use nostr::{Event, EventBuilder, Keys, RelayUrl, SecretKey, ToBech32}; use tokio::time::timeout; +use tokio_tungstenite::tungstenite::{ + client::IntoClientRequest, + handshake::client::Request, + http::{header::USER_AGENT, HeaderValue}, +}; use tokio_tungstenite::{connect_async, tungstenite::Message}; use zeroize::Zeroizing; @@ -133,7 +138,7 @@ async fn cmd_source(relay_url: String, nsec: Option) -> Result<(), CliEr // Connect to relay and handle NIP-42 auth if required. // Auth uses the session's ephemeral keys so the relay accepts our events. - let (ws, _) = connect_async(&relay_url).await?; + let (ws, _) = connect_async(relay_request(&relay_url)?).await?; let (mut write, mut read) = ws.split(); handle_nip42_auth(&mut read, &mut write, &session, &relay_url).await?; @@ -235,7 +240,7 @@ async fn cmd_target(relay_override: Option, show_secret: bool) -> Result let (mut session, offer_event) = PairingSession::new_target(&qr)?; // Connect to relay and handle NIP-42 auth if required. - let (ws, _) = connect_async(&relay_url).await?; + let (ws, _) = connect_async(relay_request(&relay_url)?).await?; let (mut write, mut read) = ws.split(); handle_nip42_auth(&mut read, &mut write, &session, &relay_url).await?; @@ -629,3 +634,27 @@ fn hex_to_32(s: &str) -> Result<[u8; 32], CliError> { .try_into() .map_err(|_| CliError::Other(format!("expected 32 bytes, got wrong length for '{s}'"))) } + +/// `User-Agent` sent on the relay WebSocket upgrade. +const RELAY_USER_AGENT: &str = concat!("buzz-pairing-cli/", env!("CARGO_PKG_VERSION")); + +/// The relay upgrade request for `url`, naming this tool and its version. +fn relay_request(url: &str) -> Result { + let mut request = url.into_client_request()?; + request + .headers_mut() + .insert(USER_AGENT, HeaderValue::from_static(RELAY_USER_AGENT)); + Ok(request) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relay_request_carries_user_agent() { + let request = relay_request("wss://relay.example.com").unwrap(); + assert_eq!(request.headers()[USER_AGENT], RELAY_USER_AGENT); + assert!(RELAY_USER_AGENT.starts_with("buzz-pairing-cli/")); + } +} diff --git a/crates/buzz-ws-client/src/connection.rs b/crates/buzz-ws-client/src/connection.rs index e69941293a3..918f971f101 100644 --- a/crates/buzz-ws-client/src/connection.rs +++ b/crates/buzz-ws-client/src/connection.rs @@ -6,7 +6,8 @@ use nostr::{Event, NostrSigner, Tag}; use serde_json::{json, Value}; use tokio::time::timeout; use tokio_tungstenite::tungstenite::client::IntoClientRequest; -use tokio_tungstenite::tungstenite::http::HeaderMap; +use tokio_tungstenite::tungstenite::handshake::client::Request; +use tokio_tungstenite::tungstenite::http::{header::USER_AGENT, HeaderMap, HeaderValue}; use tokio_tungstenite::{ client_async, connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream, }; @@ -40,6 +41,19 @@ async fn phase( .map_err(|_| WsClientError::Timeout)? } +/// `User-Agent` sent on the upgrade unless the caller's headers supply one. +const DEFAULT_USER_AGENT: &str = concat!("buzz-ws-client/", env!("CARGO_PKG_VERSION")); + +/// The upgrade request for `url`: the default `User-Agent`, then `headers`. +fn upgrade_request(url: &url::Url, headers: HeaderMap) -> Result { + let mut request = url.as_str().into_client_request()?; + request + .headers_mut() + .insert(USER_AGENT, HeaderValue::from_static(DEFAULT_USER_AGENT)); + request.headers_mut().extend(headers); + Ok(request) +} + /// A NIP-42-capable WebSocket connection to a Nostr relay. pub struct NostrWsConnection { ws: WsStream, @@ -85,7 +99,8 @@ impl NostrWsConnection { /// [`Self::connect`], sending `headers` on the WebSocket upgrade request. /// /// The headers are added after the handshake headers, so supplying a handshake - /// header such as `Sec-WebSocket-Key` replaces it and breaks the upgrade. + /// header such as `Sec-WebSocket-Key` replaces it and breaks the upgrade. A + /// `User-Agent` among them replaces the default `buzz-ws-client/`. pub async fn connect_with_headers( url: &str, headers: HeaderMap, @@ -94,8 +109,7 @@ impl NostrWsConnection { .parse::() .map_err(|e| WsClientError::Url(e.to_string()))?; - let mut request = parsed.as_str().into_client_request()?; - request.headers_mut().extend(headers); + let request = upgrade_request(&parsed, headers)?; let (ws, _response) = connect_async(request) .await @@ -117,7 +131,7 @@ impl NostrWsConnection { let parsed = url .parse::() .map_err(|e| WsClientError::Url(e.to_string()))?; - let request = parsed.as_str().into_client_request()?; + let request = upgrade_request(&parsed, HeaderMap::new())?; let tcp = tokio::net::TcpStream::connect(addr) .await .map_err(|e| WsClientError::WebSocket(e.into()))?; @@ -426,7 +440,6 @@ mod tests { #[tokio::test] async fn connect_with_headers_sends_headers_on_upgrade() { use tokio_tungstenite::tungstenite::handshake::server::{Request, Response}; - use tokio_tungstenite::tungstenite::http::HeaderValue; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("ws://{}", listener.local_addr().unwrap()); @@ -460,6 +473,56 @@ mod tests { ); } + /// The `User-Agent` a relay sees on an upgrade sent with `headers`, through + /// `connect_with_headers` or, with `via_addr`, `connect_to_addr`. + #[allow(clippy::result_large_err)] + async fn upgrade_user_agent(headers: HeaderMap, via_addr: bool) -> Option { + use tokio_tungstenite::tungstenite::handshake::server::{Request, Response}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut received = None; + let _ws = tokio_tungstenite::accept_hdr_async( + stream, + |request: &Request, response: Response| { + received = request.headers().get(USER_AGENT).cloned(); + Ok(response) + }, + ) + .await + .unwrap(); + received + }); + let url = format!("ws://{addr}"); + let _conn = if via_addr { + NostrWsConnection::connect_to_addr(&url, &addr).await + } else { + NostrWsConnection::connect_with_headers(&url, headers).await + } + .unwrap(); + server.await.unwrap() + } + + #[tokio::test] + async fn upgrade_sends_default_user_agent() { + let expected = Some(HeaderValue::from_static(DEFAULT_USER_AGENT)); + assert_eq!(upgrade_user_agent(HeaderMap::new(), false).await, expected); + assert_eq!(upgrade_user_agent(HeaderMap::new(), true).await, expected); + assert!(DEFAULT_USER_AGENT.starts_with("buzz-ws-client/")); + } + + #[tokio::test] + async fn caller_user_agent_replaces_default() { + let mut headers = HeaderMap::new(); + headers.insert(USER_AGENT, HeaderValue::from_static("example/1.0")); + assert_eq!( + upgrade_user_agent(headers, false).await, + Some(HeaderValue::from_static("example/1.0")) + ); + } + /// A relay that answers NIP-42 AUTH by closing with `code`, as the relay /// does on a NIP-FI key mismatch (1008). async fn close_after_auth(code: u16) -> WsClientError { diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index c771ef56b8b..69262a7f599 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; @@ -11,6 +12,7 @@ import 'features/invites/invite_join_provider.dart'; import 'shared/agents/agents_surface.dart'; import 'shared/push/push_bridge.dart'; import 'shared/relay/relay_closed_policy.dart'; +import 'shared/relay/relay_websocket.dart'; import 'shared/theme/theme_provider.dart'; void main() => runBuzzApp(const App()); @@ -18,6 +20,12 @@ void main() => runBuzzApp(const App()); Future runBuzzApp(Widget app) async { WidgetsFlutterBinding.ensureInitialized(); installBuzzPushMethodHandler(); + // Relay upgrades name the app version once the platform reports it. + unawaited( + PackageInfo.fromPlatform() + .then((info) => setRelayUserAgent(info.version)) + .catchError((Object _) {}), + ); // Pre-load preferences so the first frame uses the saved theme/accent. final prefs = await SharedPreferences.getInstance(); diff --git a/mobile/lib/shared/relay/relay_websocket.dart b/mobile/lib/shared/relay/relay_websocket.dart index 0e4b91fc774..ce9e264d048 100644 --- a/mobile/lib/shared/relay/relay_websocket.dart +++ b/mobile/lib/shared/relay/relay_websocket.dart @@ -8,6 +8,13 @@ import 'dart:io'; /// leaves the app reconnecting for as long as the network stays stalled. const relayWebSocketConnectTimeout = Duration(seconds: 10); +/// `User-Agent` sent on every relay WebSocket upgrade; [setRelayUserAgent] +/// adds the app version once it is known. +String relayUserAgent = 'Buzz-Mobile'; + +void setRelayUserAgent(String version) => + relayUserAgent = 'Buzz-Mobile/$version'; + /// WebSocket's default HTTP client follows redirects, including custom headers. /// Keep key-bound assertions at their original origin and bound upgrade time. /// @@ -20,6 +27,7 @@ Future openRelayWebSocket( Duration connectTimeout = relayWebSocketConnectTimeout, }) async { final HttpClient client = headers.isEmpty ? HttpClient() : _UpgradeClient(); + client.userAgent = relayUserAgent; try { return await WebSocket.connect( uri.toString(), @@ -51,6 +59,12 @@ class _UpgradeClient implements HttpClient { return request; } + @override + String? get userAgent => _inner.userAgent; + + @override + set userAgent(String? value) => _inner.userAgent = value; + @override void close({bool force = false}) => _inner.close(force: force); diff --git a/mobile/test/shared/relay/relay_socket_liveness_test.dart b/mobile/test/shared/relay/relay_socket_liveness_test.dart index 22d07c2a959..ae670be3dc8 100644 --- a/mobile/test/shared/relay/relay_socket_liveness_test.dart +++ b/mobile/test/shared/relay/relay_socket_liveness_test.dart @@ -305,4 +305,28 @@ void main() { await sink.close(force: true); }, ); + + test('upgrades name the app and its version', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final agents = []; + server.listen((request) async { + agents.add(request.headers.value(HttpHeaders.userAgentHeader)); + final ws = await WebSocketTransformer.upgrade(request); + ws.listen((_) {}, onError: (_) {}, onDone: () {}); + }); + setRelayUserAgent('1.2.3'); + for (final headers in [ + {}, + {'Nostr-Federated-Identity': 'Bearer synthetic'}, + ]) { + final socket = await openRelayWebSocket( + Uri.parse('ws://127.0.0.1:${server.port}'), + headers: headers, + pingInterval: const Duration(seconds: 30), + ); + await socket.close(); + } + expect(agents, ['Buzz-Mobile/1.2.3', 'Buzz-Mobile/1.2.3']); + await server.close(force: true); + }); } From d25a20604c0fa605b5959e0e1fcc7c6692927608 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Sat, 7 Nov 2026 17:30:28 -0500 Subject: [PATCH 2/3] fix(mobile): wait briefly for the app version before relay sockets open; run ws-client and pairing tests in test-unit Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Signed-off-by: Will Pfleger --- Justfile | 2 ++ mobile/lib/main.dart | 9 +++-- mobile/lib/shared/relay/relay_websocket.dart | 13 ++++++++ .../relay/relay_socket_liveness_test.dart | 33 +++++++++++++++++++ scripts/run-tests.sh | 6 ++++ 5 files changed, 58 insertions(+), 5 deletions(-) diff --git a/Justfile b/Justfile index 66fe30e07b4..39019f63ad0 100644 --- a/Justfile +++ b/Justfile @@ -423,6 +423,8 @@ test-unit: # never dialed). Keep the Cargo fallback in step. cargo nextest run -p buzz-relay-mesh cargo nextest run -p buzz-cli + cargo nextest run -p buzz-ws-client + cargo nextest run -p buzz-pairing-cli # buzz-sdk builder/validation unit tests: pure event-builder and input # validation (e.g. the canvas writer-discipline/skew guard and the # canvas_write_survived predicate), no infra. `--lib` runs all unit diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 69262a7f599..db7c4cd744c 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -20,15 +20,14 @@ void main() => runBuzzApp(const App()); Future runBuzzApp(Widget app) async { WidgetsFlutterBinding.ensureInitialized(); installBuzzPushMethodHandler(); - // Relay upgrades name the app version once the platform reports it. - unawaited( - PackageInfo.fromPlatform() - .then((info) => setRelayUserAgent(info.version)) - .catchError((Object _) {}), + // Relay upgrades name the app version, so learn it before any socket opens. + final userAgent = loadRelayUserAgent( + () async => (await PackageInfo.fromPlatform()).version, ); // Pre-load preferences so the first frame uses the saved theme/accent. final prefs = await SharedPreferences.getInstance(); + await userAgent; runApp(buildRootProviderScope(prefs: prefs, child: app)); } diff --git a/mobile/lib/shared/relay/relay_websocket.dart b/mobile/lib/shared/relay/relay_websocket.dart index ce9e264d048..0f42ca1008c 100644 --- a/mobile/lib/shared/relay/relay_websocket.dart +++ b/mobile/lib/shared/relay/relay_websocket.dart @@ -15,6 +15,19 @@ String relayUserAgent = 'Buzz-Mobile'; void setRelayUserAgent(String version) => relayUserAgent = 'Buzz-Mobile/$version'; +/// Sets [relayUserAgent] from [lookupVersion] before any socket opens, giving +/// up after [timeout] so a hung or failed lookup cannot stall launch. +Future loadRelayUserAgent( + Future Function() lookupVersion, { + Duration timeout = const Duration(milliseconds: 500), +}) async { + try { + setRelayUserAgent(await lookupVersion().timeout(timeout)); + } catch (_) { + // Keep the unversioned fallback. + } +} + /// WebSocket's default HTTP client follows redirects, including custom headers. /// Keep key-bound assertions at their original origin and bound upgrade time. /// diff --git a/mobile/test/shared/relay/relay_socket_liveness_test.dart b/mobile/test/shared/relay/relay_socket_liveness_test.dart index ae670be3dc8..3b4466a581b 100644 --- a/mobile/test/shared/relay/relay_socket_liveness_test.dart +++ b/mobile/test/shared/relay/relay_socket_liveness_test.dart @@ -329,4 +329,37 @@ void main() { expect(agents, ['Buzz-Mobile/1.2.3', 'Buzz-Mobile/1.2.3']); await server.close(force: true); }); + + test('first upgrade waits for a delayed version lookup', () async { + final agents = []; + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen((request) async { + agents.add(request.headers.value(HttpHeaders.userAgentHeader)); + final ws = await WebSocketTransformer.upgrade(request); + ws.listen((_) {}, onError: (_) {}, onDone: () {}); + }); + relayUserAgent = 'Buzz-Mobile'; + await loadRelayUserAgent( + () => Future.delayed(const Duration(milliseconds: 100), () => '4.5.6'), + ); + final socket = await openRelayWebSocket( + Uri.parse('ws://127.0.0.1:${server.port}'), + headers: const {}, + pingInterval: const Duration(seconds: 30), + ); + await socket.close(); + expect(agents, ['Buzz-Mobile/4.5.6']); + await server.close(force: true); + }); + + test('a hung or failed version lookup keeps the unversioned agent', () async { + relayUserAgent = 'Buzz-Mobile'; + await loadRelayUserAgent( + () => Completer().future, + timeout: const Duration(milliseconds: 10), + ); + expect(relayUserAgent, 'Buzz-Mobile'); + await loadRelayUserAgent(() async => throw StateError('no platform')); + expect(relayUserAgent, 'Buzz-Mobile'); + }); } diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index b49a0ca4447..4762c8c6aaf 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -113,6 +113,12 @@ run_unit_tests() { run_test_step "buzz-cli tests" \ cargo test -p buzz-cli -- --nocapture + run_test_step "buzz-ws-client tests" \ + cargo test -p buzz-ws-client -- --nocapture + + run_test_step "buzz-pairing-cli tests" \ + cargo test -p buzz-pairing-cli -- --nocapture + # buzz-sdk builder/validation unit tests: pure event-builder and input # validation, no infra. Mirrors the nextest path in `just test-unit` — the # two lists must stay in step. `--lib` matches the nextest invocation and From 07e9da8cd0c5c099bdb1c7b475838c3b1efc5181 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Sat, 7 Nov 2026 17:57:12 -0500 Subject: [PATCH 3/3] test(mobile): give the cold-start test a platform app version Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Signed-off-by: Will Pfleger --- mobile/test/shared/push/push_root_isolation_test.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mobile/test/shared/push/push_root_isolation_test.dart b/mobile/test/shared/push/push_root_isolation_test.dart index f945f206603..21b2cc31730 100644 --- a/mobile/test/shared/push/push_root_isolation_test.dart +++ b/mobile/test/shared/push/push_root_isolation_test.dart @@ -20,6 +20,7 @@ import 'package:buzz/shared/push/push_bootstrap.dart'; import 'package:buzz/shared/push/push_bridge.dart'; import 'package:buzz/shared/push/push_subscription.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:buzz/shared/push/push_lease_revocation_outbox.dart'; import 'package:buzz/shared/push/push_relay_capability_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; @@ -339,6 +340,13 @@ void main() { ) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; SharedPreferences.setMockInitialValues({}); + PackageInfo.setMockInitialValues( + appName: 'Buzz', + packageName: 'example.buzz', + version: '1.0.0', + buildNumber: '1', + buildSignature: '', + ); const channel = MethodChannel('buzz/push'); final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;