diff --git a/docs/core-protocol.md b/docs/core-protocol.md index 5de0c880e..29ea9a6a7 100644 --- a/docs/core-protocol.md +++ b/docs/core-protocol.md @@ -621,6 +621,11 @@ attempts; only a presented first frame resets this budget. Ending the session ca recovery. A native stop stalled for 30 seconds reports an error without launching another transport over the still-owned resources. +An exhausted video SETUP negotiation (`missing-video-peer`) or an explicitly +unsupported legacy transport (`nvst-legacy-transport-unsupported`) stops automatic +recovery and preserves the original error and owned seat for an explicit retry or +stop. These compatibility failures do not trigger another claim of the same seat. + For the embedded Qt client, `session.create` also accepts an optional numeric `maxEntitledFps`: the highest frame rate the signed-in membership entitles at the requested resolution, or `0` or absent when that is not confirmed. Qt derives it from the normalized `entitledResolutions` diff --git a/docs/qt-acceptance.md b/docs/qt-acceptance.md index 11b8b5a80..58cb6031e 100644 --- a/docs/qt-acceptance.md +++ b/docs/qt-acceptance.md @@ -84,6 +84,38 @@ start new closes it only after you choose that action. Repeat with the session i different region and while the network is unavailable. A failed lookup must offer a retry rather than create another session. Verify windowed and fullscreen presentation. +## Alliance login and stream negotiation + +Run the native negotiation and Qt orchestration checks before testing an affected provider: + +```sh +cargo test --manifest-path native/opennow-streamer/Cargo.toml -p opennow-streamer-core nvst_rtsp +ctest --test-dir build/opennow-qt --output-on-failure -R 'embedded-orchestration|alliance|auth|stream-recovery' +``` + +Verify provider-list timeout recovery, a fresh login after an expired device challenge, +and adding another provider account while already signed in. A failed profile switch must +leave the original account active and show an error on the account page. Test both desktop +and console modes; synthetic account fixtures do not prove a provider accepts login. + +Video SETUP tries a bounded set of control-URI and Transport forms within one request +budget. A successful response must still supply a usable server-authored video endpoint. +After all forms fail to supply one, `missing-video-peer` is a terminal negotiation error, +not a reason to repeatedly reclaim the same seat. Unsupported legacy transport is also +terminal. Transient network failures retain the existing bounded session recovery. + +For a partner that still cannot start, reproduce once and export diagnostics. Keep the +`video-setup` and `video-setup-transport` lines. They describe response status, field +presence, source/port shape, quoting and key spacing without logging raw addresses, +credentials or Transport values. Do not add unredacted headers or SDP to bug reports. +The field-shape diagnostics distinguish a parser incompatibility from missing server +metadata; a SETUP `200` alone does not prove that an endpoint was negotiated. + +Confirm login, catalog load, launch through the first video frame, stop, and reconnect +with the affected provider before claiming its compatibility issue is fixed. Repeat +with an NVIDIA account to check the unchanged first SETUP request. Passing synthetic +fallback and parser tests is not a substitute for this live check. + ## Required live matrix | Platform | Architecture | Window system | Required package | diff --git a/locales/en.json b/locales/en.json index 99b5bc8ad..7a5080861 100644 --- a/locales/en.json +++ b/locales/en.json @@ -156,6 +156,8 @@ "lastSyncCount": "%1 games reported by the last store sync" }, "providerRouting": { + "empty": "No providers are available. Refresh to try again.", + "contacting": "Contacting provider…", "unavailable": "Provider discovery is unavailable. Known providers are shown. Refresh to try again.", "knownProviders": "Provider discovery is unavailable. Known providers are shown.", "refresh": "Refresh providers", diff --git a/native/opennow-streamer/crates/opennow-streamer-core/src/nvst_rtsp.rs b/native/opennow-streamer/crates/opennow-streamer-core/src/nvst_rtsp.rs index 5826c3ed7..196cfcb32 100644 --- a/native/opennow-streamer/crates/opennow-streamer-core/src/nvst_rtsp.rs +++ b/native/opennow-streamer/crates/opennow-streamer-core/src/nvst_rtsp.rs @@ -18,6 +18,8 @@ use tungstenite::{Message, WebSocket, connect}; #[path = "nvst_rtsp_color.rs"] mod color; +#[path = "nvst_rtsp_transport_diagnostics.rs"] +mod transport_diagnostics; use color::NvstColorNegotiation; const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); @@ -67,6 +69,11 @@ struct RtspClient { buffer: String, } +struct VideoSetup { + response: RtspResponse, + peer: (String, u16, u16), +} + #[derive(Clone, Default)] struct NvstControlPing { sample: Arc>>, @@ -100,29 +107,105 @@ impl NvstControlPing { } impl RtspClient { - fn connect(endpoint: &str, session_id: &str) -> Result<(Self, String), NvstRtspError> { - let translated = endpoint - .replacen("rtsps://", "https://", 1) - .replacen("rtsp://", "http://", 1); - let parsed = translated - .parse::() - .map_err(|_| NvstRtspError::new("invalid-rtsps-endpoint", "Invalid RTSPS endpoint"))?; - let host = parsed.host().ok_or_else(|| { - NvstRtspError::new("invalid-rtsps-endpoint", "RTSPS endpoint has no host") - })?; - if !trusted_nvst_host(host) { - return Err(NvstRtspError::new( - "untrusted-rtsps-endpoint", - "Refusing an untrusted RTSPS endpoint", - )); + fn setup_video( + &mut self, + control: &str, + target: &str, + headers: &[(&str, String)], + client_port: u16, + ) -> Result { + let candidates = video_setup_candidates(control, target); + let deadline = Instant::now() + REQUEST_TIMEOUT; + let mut headers = headers.to_vec(); + headers.push(("Transport", String::new())); + let transport_index = headers.len() - 1; + let mut missing_peer = false; + let mut last_status = 0; + for transport in [ + String::new(), + format!( + "unicast;X-GS-ClientPort={client_port}-{}", + client_port.saturating_add(1) + ), + ] { + let transport_form = if transport.is_empty() { + "empty" + } else { + "client-udp" + }; + headers[transport_index].1 = transport; + for (index, candidate) in candidates.iter().enumerate() { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(NvstRtspError::new( + "nvst-rtsp-timeout", + "RTSPS video SETUP timed out", + )); + } + let response = + self.request_with_timeout("SETUP", candidate, &headers, "", remaining)?; + let transport = header_value(&response, "transport"); + let peer = transport + .and_then(parse_video_peer) + .filter(|(ip, _, _)| ip.parse::().is_ok()); + opennow_streamer_protocol::log::log_line( + "INFO", + "rtsps", + &format!( + "video-setup candidate={}/{} transport_form={transport_form} status={} transport_present={} video_peer_valid={} ping_version_present={} ping_payload_present={}", + index + 1, + candidates.len(), + response.status, + transport.is_some(), + peer.is_some(), + header_value(&response, "x-nv-ping").is_some(), + header_value(&response, "x-nv-ping-payload").is_some(), + ), + ); + last_status = response.status; + match response.status { + 200 => { + if let Some(peer) = peer { + return Ok(VideoSetup { response, peer }); + } + if let Some(transport) = transport { + opennow_streamer_protocol::log::log_line( + "WARN", + "rtsps", + &format!( + "video-setup-transport candidate={} {}", + index + 1, + transport_diagnostics::summarize(transport), + ), + ); + } + missing_peer = true; + } + 400 | 404 | 459 | 460 | 461 => {} + _ => { + return Err(NvstRtspError::new( + "nvst-rtsp-failed", + format!("SETUP failed with status {}", response.status), + )); + } + } + } } - let port = parsed.port_u16().unwrap_or(322); - let authority_host = if host.contains(':') { - format!("[{host}]") - } else { - host.to_owned() - }; - let wss = format!("wss://{authority_host}:{port}/rtsp"); + Err(NvstRtspError::new( + if missing_peer { + "missing-video-peer" + } else { + "nvst-rtsp-failed" + }, + format!( + "SETUP did not return a usable NVST video peer after {} URI forms and 2 Transport forms (last status {last_status})", + candidates.len(), + ), + )) + } + + fn connect(endpoint: &str, session_id: &str) -> Result<(Self, String), NvstRtspError> { + let (wss, target) = rtsp_endpoint_urls(endpoint)?; let mut request = wss .into_client_request() .map_err(|error| NvstRtspError::new("nvst-connect-failed", error.to_string()))?; @@ -147,7 +230,7 @@ impl RtspClient { cseq: 0, buffer: String::new(), }, - format!("rtsps://{host}:{port}"), + target, )) } @@ -170,6 +253,8 @@ impl RtspClient { timeout: Duration, ) -> Result { let mut stage = opennow_streamer_protocol::log::Stage::begin("rtsps.request"); + let deadline = Instant::now() + timeout; + set_io_timeout(&mut self.socket, timeout); self.socket.set_config(|config| { config.max_message_size = Some(MAX_REQUEST_RESPONSE_BYTES); config.max_frame_size = Some(MAX_REQUEST_RESPONSE_BYTES); @@ -185,7 +270,6 @@ impl RtspClient { body.len() ), ); - let deadline = Instant::now() + timeout; loop { if self.buffer.len() > MAX_REQUEST_RESPONSE_BYTES { return Err(NvstRtspError::new( @@ -225,6 +309,12 @@ impl RtspClient { stage.complete(); return Ok(response); } + set_io_timeout( + &mut self.socket, + deadline + .saturating_duration_since(Instant::now()) + .max(Duration::from_millis(1)), + ); match self.socket.read() { Ok(Message::Text(text)) => self.buffer.push_str(text.as_str()), Ok(Message::Binary(bytes)) => { @@ -607,7 +697,6 @@ pub fn prepare_owned_nvst( "DESCRIBE did not include a video control stream", ) })?; - let video_setup = official_video_setup_control(&video_control); let described_ping_version = sdp_attribute(&describe.body, "general.pingVersion") .and_then(|value| value.parse::().ok()) .unwrap_or(6); @@ -635,17 +724,10 @@ pub fn prepare_owned_nvst( let mut setup_headers = common_headers.clone(); setup_headers.push(("Session", rtsp_session.clone())); setup_headers.push(("x-nv-ping", described_ping_version.to_string())); - setup_headers.push(("Transport", String::new())); - let setup = client.request("SETUP", &video_setup, &setup_headers, "")?; - ensure_rtsp_ok("SETUP", &setup)?; - let transport = header_value(&setup, "transport").unwrap_or_default(); - let (video_peer_ip, video_peer_port, video_peer_port_end) = parse_video_peer(transport) - .ok_or_else(|| { - NvstRtspError::new( - "missing-video-peer", - "SETUP did not return the NVST video peer", - ) - })?; + let VideoSetup { + response: setup, + peer: (video_peer_ip, video_peer_port, video_peer_port_end), + } = client.setup_video(&video_control, &target, &setup_headers, mjolnir_port)?; let (bundle_peer_ip, bundle_peer_port) = context .session .media_connection_info @@ -1243,16 +1325,54 @@ fn take_rtsp_response( })) } +fn rtsp_endpoint_urls(endpoint: &str) -> Result<(String, String), NvstRtspError> { + let translated = endpoint + .replacen("rtsps://", "https://", 1) + .replacen("rtsp://", "http://", 1); + let parsed = translated + .parse::() + .map_err(|_| NvstRtspError::new("invalid-rtsps-endpoint", "Invalid RTSPS endpoint"))?; + let host = parsed.host().ok_or_else(|| { + NvstRtspError::new("invalid-rtsps-endpoint", "RTSPS endpoint has no host") + })?; + let address_host = host + .strip_prefix('[') + .and_then(|host| host.strip_suffix(']')) + .filter(|host| host.parse::().is_ok()) + .unwrap_or(host); + if !trusted_nvst_host(address_host) { + return Err(NvstRtspError::new( + "untrusted-rtsps-endpoint", + "Refusing an untrusted RTSPS endpoint", + )); + } + let port = parsed.port_u16().unwrap_or(322); + Ok(( + format!("wss://{host}:{port}/rtsp"), + format!("rtsps://{host}:{port}"), + )) +} + fn trusted_nvst_host(host: &str) -> bool { let host = host.trim_end_matches('.').to_ascii_lowercase(); if host == "nvidiagrid.net" || host.ends_with(".nvidiagrid.net") { return true; } + let trusted_ipv4 = |ip: std::net::Ipv4Addr| { + !ip.is_private() && !ip.is_loopback() && !ip.is_link_local() && !ip.is_unspecified() + }; host.parse::().is_ok_and(|ip| match ip { - IpAddr::V4(ip) => { - !ip.is_private() && !ip.is_loopback() && !ip.is_link_local() && !ip.is_unspecified() - } - IpAddr::V6(ip) => !ip.is_loopback() && !ip.is_unicast_link_local() && !ip.is_unspecified(), + IpAddr::V4(ip) => trusted_ipv4(ip), + IpAddr::V6(ip) => ip.to_ipv4_mapped().map_or_else( + || { + !ip.is_loopback() + && !ip.is_unicast_link_local() + && !ip.is_unspecified() + && !ip.is_unique_local() + && !ip.is_multicast() + }, + trusted_ipv4, + ), }) } @@ -1320,6 +1440,29 @@ fn official_video_setup_control(control: &str) -> String { } } +fn video_setup_candidates(control: &str, target: &str) -> Vec { + let mut candidates = vec![official_video_setup_control(control)]; + if candidates[0] != control { + candidates.push(control.to_owned()); + } + for index in 0..candidates.len() { + let control = &candidates[index]; + let lower = control.to_ascii_lowercase(); + if lower.starts_with("rtsps://") || lower.starts_with("rtsp://") { + continue; + } + let absolute = format!( + "{}/{}", + target.trim_end_matches('/'), + control.trim_start_matches('/') + ); + if !candidates.contains(&absolute) { + candidates.push(absolute); + } + } + candidates +} + fn parse_video_peer(transport: &str) -> Option<(String, u16, u16)> { let mut ip = None; let mut port = None; @@ -1442,6 +1585,10 @@ mod control_ping_tests; #[path = "nvst_rtsp_tls_tests.rs"] mod tls_tests; +#[cfg(test)] +#[path = "nvst_rtsp_setup_tests.rs"] +mod setup_tests; + #[cfg(test)] mod tests { use super::*; @@ -2007,6 +2154,80 @@ mod tests { assert!(!trusted_nvst_host("10.0.0.8")); } + #[test] + fn endpoint_urls_preserve_one_ipv6_bracket_pair_and_the_selected_port() { + for (endpoint, port) in [ + ("rtsps://[2001:4860:4860::8888]:48322/session", 48322), + ("rtsps://[2001:4860:4860::8888]/session", 322), + ("rtsp://[2001:4860:4860::8888]:48322/session", 48322), + ] { + let (wss, target) = rtsp_endpoint_urls(endpoint).unwrap(); + let authority = format!("[2001:4860:4860::8888]:{port}"); + assert_eq!(wss, format!("wss://{authority}/rtsp")); + assert_eq!(target, format!("rtsps://{authority}")); + let request = wss.into_client_request().unwrap(); + assert_eq!(request.headers()["host"], authority); + assert_eq!(request.uri().host(), Some("[2001:4860:4860::8888]")); + assert_eq!(request.uri().port_u16(), Some(port)); + let target = target.parse::().unwrap(); + assert_eq!(target.authority().unwrap().as_str(), authority); + } + } + + #[test] + fn endpoint_urls_preserve_dns_and_ipv4_behavior() { + for (endpoint, authority) in [ + ( + "rtsps://seat.nvidiagrid.net/session", + "seat.nvidiagrid.net:322", + ), + ("rtsps://8.8.8.8:48322/session", "8.8.8.8:48322"), + ] { + assert_eq!( + rtsp_endpoint_urls(endpoint).unwrap(), + ( + format!("wss://{authority}/rtsp"), + format!("rtsps://{authority}"), + ) + ); + } + } + + #[test] + fn endpoint_urls_preserve_host_policy_for_ipv6_and_bracketed_non_ipv6() { + for endpoint in [ + "rtsps://[::1]:322", + "rtsps://[::]:322", + "rtsps://[fe80::1]:322", + "rtsps://[fc00::1]:322", + "rtsps://[fd00::1]:322", + "rtsps://[ff02::1]:322", + "rtsps://[::ffff:127.0.0.1]:322", + "rtsps://[::ffff:10.0.0.1]:322", + "rtsps://[::ffff:169.254.1.1]:322", + "rtsps://[::ffff:0.0.0.0]:322", + "rtsps://[seat.nvidiagrid.net]:322", + "rtsps://[8.8.8.8]:322", + "rtsps://[[2001:4860:4860::8888]]:322", + "rtsps://partner.example:322", + "rtsps://127.0.0.1:322", + "rtsps://10.0.0.8:322", + ] { + assert!(rtsp_endpoint_urls(endpoint).is_err(), "{endpoint}"); + } + } + + #[test] + fn endpoint_urls_accept_ipv4_mapped_public_addresses() { + assert_eq!( + rtsp_endpoint_urls("rtsps://[::ffff:8.8.8.8]:48322/session").unwrap(), + ( + "wss://[::ffff:8.8.8.8]:48322/rtsp".to_owned(), + "rtsps://[::ffff:8.8.8.8]:48322".to_owned(), + ) + ); + } + #[test] fn ping_identity_increment_preserves_width_and_carry() { assert_eq!(increment_hex("00ff").as_deref(), Some("0100")); diff --git a/native/opennow-streamer/crates/opennow-streamer-core/src/nvst_rtsp_setup_tests.rs b/native/opennow-streamer/crates/opennow-streamer-core/src/nvst_rtsp_setup_tests.rs new file mode 100644 index 000000000..cc5f022a6 --- /dev/null +++ b/native/opennow-streamer/crates/opennow-streamer-core/src/nvst_rtsp_setup_tests.rs @@ -0,0 +1,307 @@ +use super::*; +use std::net::TcpListener; + +const TARGET: &str = "rtsps://seat.nvidiagrid.net:322"; +const VALID_PEER: &str = "Transport: unicast;source=192.0.2.10;X-GS-ServerPort=5004-5005\r\nX-Nv-Ping: 6\r\nX-Nv-Ping-Payload: 00ff\r\n"; +const EMPTY_TRANSPORT_URIS: [&str; 4] = [ + "streamid=video/0/0", + "streamid=video/0", + "rtsps://seat.nvidiagrid.net:322/streamid=video/0/0", + "rtsps://seat.nvidiagrid.net:322/streamid=video/0", +]; + +struct Reply { + uri: &'static str, + transport: &'static str, + status: u16, + headers: &'static str, +} + +fn scripted_setup(replies: Vec) -> Result { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let stream = TcpStream::connect(address).unwrap(); + let (server_stream, _) = listener.accept().unwrap(); + for socket in [&stream, &server_stream] { + socket + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + socket + .set_write_timeout(Some(Duration::from_secs(2))) + .unwrap(); + } + let server = thread::spawn(move || { + let mut socket = tungstenite::accept(server_stream).unwrap(); + for (index, reply) in replies.iter().enumerate() { + let Message::Text(request) = socket.read().unwrap() else { + panic!("expected a SETUP request"); + }; + assert!( + request.starts_with(&format!("SETUP {} RTSP/1.0\r\n", reply.uri)), + "{request}" + ); + assert!(request.contains(&format!("\r\nTransport: {}\r\n", reply.transport))); + assert!(request.contains("\r\nSession: rtsp-session\r\n")); + assert!(request.contains("\r\nx-nv-sessionid: nv-session\r\n")); + assert!(request.contains("\r\nx-nv-ping: 6\r\n")); + let cseq = index + 3; + assert!(request.contains(&format!("\r\nCSeq: {cseq}\r\n"))); + socket + .send(Message::Text( + format!( + "RTSP/1.0 {} Response\r\nCSeq: {cseq}\r\n{}Content-Length: 0\r\n\r\n", + reply.status, reply.headers + ) + .into(), + )) + .unwrap(); + } + }); + let (socket, _) = tungstenite::client( + format!("ws://{address}/rtsp"), + MaybeTlsStream::Plain(stream), + ) + .unwrap(); + let mut client = RtspClient { + socket, + cseq: 2, + buffer: String::new(), + }; + let result = client.setup_video( + "streamid=video/0", + TARGET, + &[ + ("Session", "rtsp-session".to_owned()), + ("x-nv-sessionid", "nv-session".to_owned()), + ("x-nv-ping", "6".to_owned()), + ], + 49005, + ); + drop(client); + server.join().unwrap(); + result +} + +#[test] +fn video_setup_keeps_the_official_first_attempt_and_its_metadata() { + let setup = scripted_setup(vec![Reply { + uri: EMPTY_TRANSPORT_URIS[0], + transport: "", + status: 200, + headers: VALID_PEER, + }]) + .unwrap(); + assert_eq!(setup.peer, ("192.0.2.10".to_owned(), 5004, 5005)); + assert_eq!( + header_value(&setup.response, "x-nv-ping-payload"), + Some("00ff") + ); +} + +#[test] +fn video_setup_retries_advertised_control_after_success_without_a_peer() { + let setup = scripted_setup(vec![ + Reply { + uri: EMPTY_TRANSPORT_URIS[0], + transport: "", + status: 200, + headers: "", + }, + Reply { + uri: EMPTY_TRANSPORT_URIS[1], + transport: "", + status: 200, + headers: VALID_PEER, + }, + ]) + .unwrap(); + assert_eq!(setup.peer, ("192.0.2.10".to_owned(), 5004, 5005)); +} + +#[test] +fn video_setup_tries_absolute_forms_after_uri_rejections() { + let replies = EMPTY_TRANSPORT_URIS + .iter() + .enumerate() + .map(|(index, uri)| Reply { + uri, + transport: "", + status: if index == 3 { 200 } else { 404 }, + headers: if index == 3 { VALID_PEER } else { "" }, + }) + .collect(); + let setup = scripted_setup(replies).unwrap(); + assert_eq!(setup.peer.1, 5004); +} + +#[test] +fn video_setup_tries_client_udp_transport_after_all_empty_transport_forms() { + let mut replies: Vec<_> = EMPTY_TRANSPORT_URIS + .iter() + .map(|uri| Reply { + uri, + transport: "", + status: 461, + headers: "", + }) + .collect(); + replies.extend( + EMPTY_TRANSPORT_URIS + .iter() + .enumerate() + .map(|(index, uri)| Reply { + uri, + transport: "unicast;X-GS-ClientPort=49005-49006", + status: if index == 3 { 200 } else { 400 }, + headers: if index == 3 { VALID_PEER } else { "" }, + }), + ); + let setup = scripted_setup(replies).unwrap(); + assert_eq!(setup.peer.1, 5004); + assert_eq!( + header_value(&setup.response, "x-nv-ping-payload"), + Some("00ff") + ); +} + +#[test] +fn video_setup_never_invents_a_peer_when_all_successes_omit_transport() { + let replies = ["", "unicast;X-GS-ClientPort=49005-49006"] + .iter() + .flat_map(|transport| { + EMPTY_TRANSPORT_URIS.iter().map(move |uri| Reply { + uri, + transport, + status: 200, + headers: "", + }) + }) + .collect(); + let error = match scripted_setup(replies) { + Ok(_) => panic!("SETUP without a peer must not succeed"), + Err(error) => error, + }; + assert_eq!(error.code, "missing-video-peer"); + assert!(error.message.contains("4 URI forms and 2 Transport forms")); +} + +#[test] +fn video_setup_retries_partial_and_invalid_transport_metadata() { + let replies = [ + "Transport: unicast;X-GS-ServerPort=5004\r\n", + "Transport: unicast;source=192.0.2.10\r\n", + "Transport: unicast;source=not-an-ip;X-GS-ServerPort=5004\r\n", + VALID_PEER, + ] + .iter() + .zip(EMPTY_TRANSPORT_URIS) + .map(|(headers, uri)| Reply { + uri, + transport: "", + status: 200, + headers, + }) + .collect(); + assert_eq!(scripted_setup(replies).unwrap().peer.1, 5004); +} + +#[test] +fn video_setup_stops_on_auth_session_and_server_errors() { + for status in [401, 403, 454, 455, 500, 503] { + let result = scripted_setup(vec![Reply { + uri: EMPTY_TRANSPORT_URIS[0], + transport: "", + status, + headers: "", + }]); + let error = match result { + Ok(_) => panic!("a fatal SETUP response must not succeed"), + Err(error) => error, + }; + assert_eq!(error.code, "nvst-rtsp-failed"); + assert!(error.message.contains(&status.to_string())); + } +} + +#[test] +fn video_setup_candidates_match_mac_order_without_duplicates() { + assert_eq!( + video_setup_candidates("streamid=video/0", TARGET), + EMPTY_TRANSPORT_URIS + ); + assert_eq!( + video_setup_candidates("streamid=video/0/0", TARGET), + vec![ + "streamid=video/0/0", + "rtsps://seat.nvidiagrid.net:322/streamid=video/0/0", + ] + ); + assert_eq!( + video_setup_candidates("/streamid=video/0", TARGET), + vec![ + "/streamid=video/0", + "rtsps://seat.nvidiagrid.net:322/streamid=video/0", + ] + ); + for absolute in [ + "rtsp://seat.nvidiagrid.net:322/video", + "rtsps://seat.nvidiagrid.net:322/video", + ] { + assert_eq!(video_setup_candidates(absolute, TARGET), vec![absolute]); + } +} + +#[test] +fn rtsp_request_deadline_bounds_a_partial_response() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let stream = TcpStream::connect(address).unwrap(); + let (server_stream, _) = listener.accept().unwrap(); + for socket in [&stream, &server_stream] { + socket + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + socket + .set_write_timeout(Some(Duration::from_secs(5))) + .unwrap(); + } + let (release, released) = mpsc::channel(); + let server = thread::spawn(move || { + let mut socket = tungstenite::accept(server_stream).unwrap(); + assert!(matches!(socket.read().unwrap(), Message::Text(_))); + socket + .send(Message::Text("RTSP/1.0 200 OK\r\nCSeq: 1\r\n".into())) + .unwrap(); + let _ = released.recv_timeout(Duration::from_secs(2)); + }); + let (socket, _) = tungstenite::client( + format!("ws://{address}/rtsp"), + MaybeTlsStream::Plain(stream), + ) + .unwrap(); + let mut client = RtspClient { + socket, + cseq: 0, + buffer: String::new(), + }; + let start = Instant::now(); + let result = client.request_with_timeout( + "SETUP", + "streamid=video/0/0", + &[], + "", + Duration::from_millis(80), + ); + let elapsed = start.elapsed(); + let _ = release.send(()); + server.join().unwrap(); + let error = match result { + Ok(_) => panic!("an incomplete RTSP response must time out"), + Err(error) => error, + }; + assert_eq!(error.code, "nvst-rtsp-timeout"); + assert!( + elapsed < Duration::from_secs(1), + "deadline exceeded: {elapsed:?}" + ); +} diff --git a/native/opennow-streamer/crates/opennow-streamer-core/src/nvst_rtsp_transport_diagnostics.rs b/native/opennow-streamer/crates/opennow-streamer-core/src/nvst_rtsp_transport_diagnostics.rs new file mode 100644 index 000000000..54c66e5dc --- /dev/null +++ b/native/opennow-streamer/crates/opennow-streamer-core/src/nvst_rtsp_transport_diagnostics.rs @@ -0,0 +1,176 @@ +use std::fmt::Write; +use std::net::{IpAddr, SocketAddr}; + +use opennow_streamer_transport::nvst::MAX_NVST_VIDEO_PEER_PORTS; + +pub(super) fn summarize(transport: &str) -> String { + let bytes = transport.as_bytes(); + let prefix = String::from_utf8_lossy(&bytes[..bytes.len().min(2048)]); + let lower = prefix.to_ascii_lowercase(); + let mut summary = format!( + "bytes={} input_truncated={} source_marker={} xgs_port_marker={} standard_port_marker={}", + bytes.len(), + bytes.len() > 2048, + lower.contains("source="), + lower.contains("x-gs-serverport="), + lower.contains("server_port="), + ); + let mut fields = prefix.split([';', ',']); + for (index, field) in fields.by_ref().take(16).enumerate() { + let Some((name, value)) = field.trim().split_once('=') else { + let kind = match field.trim().to_ascii_lowercase().as_str() { + "unicast" => "unicast", + "multicast" => "multicast", + "rtp/avp" | "rtp/avp/udp" => "rtp-avp", + "rtp/savp" | "rtp/savp/udp" => "rtp-savp", + "" => "empty", + _ => "other-redacted", + }; + let _ = write!(summary, " field{index}={kind}"); + continue; + }; + let (name_kind, value_kind) = match name.trim().to_ascii_lowercase().as_str() { + "source" => ("source", source_kind(value.trim())), + "x-gs-serverport" => ("xgs-port", port_kind(value.trim())), + "server_port" => ("standard-port", port_kind(value.trim())), + _ => ("other", "redacted"), + }; + let _ = write!( + summary, + " field{index}={name_kind}:{value_kind}:key-spacing-{}", + name != name.trim(), + ); + } + let _ = write!(summary, " fields_truncated={}", fields.next().is_some()); + summary +} + +fn source_kind(value: &str) -> &'static str { + if value.is_empty() { + return "empty"; + } + if let Ok(ip) = value.parse::() { + return if ip.is_ipv4() { "ipv4" } else { "ipv6" }; + } + if value.starts_with('"') && value.ends_with('"') { + return "quoted"; + } + if value + .strip_prefix('[') + .and_then(|v| v.strip_suffix(']')) + .is_some_and(|v| v.parse::().is_ok()) + { + return "bracketed-ip"; + } + if value.parse::().is_ok() { + return "ip-with-port"; + } + if value + .split_whitespace() + .next() + .is_some_and(|v| v.parse::().is_ok()) + { + return "ip-with-trailing-data"; + } + "non-ip" +} + +fn port_kind(value: &str) -> &'static str { + if value.is_empty() { + return "empty"; + } + if value.starts_with('"') && value.ends_with('"') { + return "quoted"; + } + if value.chars().any(char::is_whitespace) { + return "internal-whitespace"; + } + let (first, last) = value.split_once('-').unwrap_or((value, value)); + let Ok(first) = first.parse::() else { + return "invalid-start"; + }; + if first == 0 || first > u64::from(u16::MAX) { + return "start-out-of-range"; + } + if !value.contains('-') { + return "single-port"; + } + if last.parse::().ok().is_some_and(|last| { + last <= u64::from(u16::MAX) + && last >= first + && last - first < u64::from(MAX_NVST_VIDEO_PEER_PORTS) + }) { + "valid-range" + } else { + "range-falls-back-to-start" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transport_diagnostics_identify_expected_fields_without_their_values() { + let summary = summarize("unicast;source=192.0.2.10;X-GS-ServerPort=5004-5005"); + assert!( + summary.contains("source_marker=true xgs_port_marker=true standard_port_marker=false") + ); + assert!(summary.contains("field0=unicast field1=source:ipv4:key-spacing-false field2=xgs-port:valid-range:key-spacing-false")); + assert!(!summary.contains("192.0.2.10")); + assert!(!summary.contains("5004")); + } + + #[test] + fn transport_diagnostics_separate_source_and_port_incompatibilities() { + for (value, expected) in [ + ("source=", "source:empty"), + ("source=seat.example.test", "source:non-ip"), + ("source=\"192.0.2.10\"", "source:quoted"), + ("source=[2001:db8::1]", "source:bracketed-ip"), + ("source=192.0.2.10:5004", "source:ip-with-port"), + ("source=192.0.2.10 extra", "source:ip-with-trailing-data"), + ("source =192.0.2.10", "source:ipv4:key-spacing-true"), + ("server_port=5004", "standard-port:single-port"), + ("X-GS-ServerPort=0", "xgs-port:start-out-of-range"), + ("X-GS-ServerPort=65536", "xgs-port:start-out-of-range"), + ("X-GS-ServerPort=not-a-port", "xgs-port:invalid-start"), + ( + "X-GS-ServerPort=5004 - 5005", + "xgs-port:internal-whitespace", + ), + ("X-GS-ServerPort=\"5004\"", "xgs-port:quoted"), + ( + "X-GS-ServerPort=5004-65535", + "xgs-port:range-falls-back-to-start", + ), + ] { + assert!(summarize(value).contains(expected), "{expected}"); + } + } + + #[test] + fn transport_diagnostics_never_echo_unknown_fields_or_sensitive_values() { + let summary = summarize( + "secret-flag;secret-key=secret-value;source=secret-host;X-GS-ServerPort=secret-port;token=Bearer-secret\r\nAuthorization: secret-auth", + ); + assert!(!summary.contains("secret")); + assert!(!summary.contains("Bearer")); + assert!(!summary.contains("Authorization")); + assert!(!summary.contains('\r')); + assert!(!summary.contains('\n')); + } + + #[test] + fn transport_diagnostics_bound_input_fields_and_unicode_output() { + let summary = summarize(&"source=secret;".repeat(1000)); + assert!(summary.contains("input_truncated=true")); + assert!(summary.contains("fields_truncated=true")); + assert!(!summary.contains("field16=")); + assert!(summary.len() < 2048); + let unicode = summarize(&format!("{}🦀", "a".repeat(2047))); + assert!(unicode.contains("input_truncated=true")); + assert!(!unicode.contains('🦀')); + assert!(unicode.len() < 2048); + } +} diff --git a/opennow-qt/qml/Main.qml b/opennow-qt/qml/Main.qml index d8eef4ce3..aa54c9f85 100644 --- a/opennow-qt/qml/Main.qml +++ b/opennow-qt/qml/Main.qml @@ -339,7 +339,7 @@ ApplicationWindow { window.lockedStreamDesktopSurface = !enabled window.streamSurfaceLocked = true } - if (ShellStore.signedIn && AppController.route === "sign-in") + if (ShellStore.signedIn && !ShellStore.addingAccount && AppController.route === "sign-in") AppController.navigate("home") window.synchronizeRenderedSurface() } @@ -727,7 +727,7 @@ ApplicationWindow { PauseAnimation { duration: AppController.reducedMotion ? 0 : 160 } ScriptAction { script: { - if (ShellStore.signedIn && AppController.route === "sign-in") + if (ShellStore.signedIn && !ShellStore.addingAccount && AppController.route === "sign-in") AppController.navigate("home") } } diff --git a/opennow-qt/qml/desktop/auth/DesktopSignInScreen.qml b/opennow-qt/qml/desktop/auth/DesktopSignInScreen.qml index a5dd9108a..6e453dfc9 100644 --- a/opennow-qt/qml/desktop/auth/DesktopSignInScreen.qml +++ b/opennow-qt/qml/desktop/auth/DesktopSignInScreen.qml @@ -15,8 +15,7 @@ FocusScope { property double clockMs: Date.now() property double challengeReceivedAt: Date.now() readonly property var challenge: ShellStore.authChallenge - readonly property var providers: ShellStore.providers && ShellStore.providers.length - ? ShellStore.providers : [{displayName:"NVIDIA · GeForce NOW", idpId:"", region:"GLOBAL"}] + readonly property var providers: ShellStore.providers || [] readonly property var selectedProvider: ShellStore.selectedProvider || {displayName:qsTr("Select a provider"), idpId:ShellStore.selectedProviderIdpId, region:""} readonly property bool waiting: ShellStore.authState === "starting" || ShellStore.authState === "waiting" || ShellStore.authState === "completing" readonly property bool failed: ShellStore.authState === "error" @@ -316,14 +315,17 @@ FocusScope { objectName: "providerDiscoveryNotice" width: parent.width visible: ShellStore.providerDiscoveryDegraded - text: qsTr("Provider discovery is unavailable. Known providers are shown. Refresh to try again.") + text: root.providers.length + ? qsTr("Provider discovery is unavailable. Known providers are shown. Refresh to try again.") + : qsTr("No providers are available. Refresh to try again.") color: DesktopTokens.textMuted } AuthButton { width: parent.width visible: ShellStore.providerDiscoveryDegraded text: qsTr("Refresh providers") - onClicked: ShellStore.refreshProviders() + enabled: ShellStore.ready && ShellStore.providersRequestId === "" + onClicked: ShellStore.refreshProviders(true) } ItemDelegate { id: providerButton @@ -458,7 +460,7 @@ FocusScope { glyph: "desktop-qr.svg" glyphSize: DesktopTokens.px(14) text: qsTr("Sign in with a QR code") - enabled: ShellStore.ready + enabled: ShellStore.ready && ShellStore.selectedProvider !== null onClicked: { root.qrRequested = true; ShellStore.startDeviceLogin(root.selectedProvider.idpId || "", root.staySignedIn) } } } @@ -745,7 +747,7 @@ FocusScope { Connections { target: ShellStore - function onSignedInChanged() { if (ShellStore.signedIn) root.signedIn() } + function onSignedInChanged() { if (ShellStore.signedIn && !ShellStore.addingAccount) root.signedIn() } function onAuthChallengeChanged() { root.challengeReceivedAt = Date.now() root.clockMs = root.challengeReceivedAt diff --git a/opennow-qt/qml/screens/AccountsScreen.qml b/opennow-qt/qml/screens/AccountsScreen.qml index dab6ba365..ecc1ae56e 100644 --- a/opennow-qt/qml/screens/AccountsScreen.qml +++ b/opennow-qt/qml/screens/AccountsScreen.qml @@ -60,18 +60,20 @@ FocusScope { } GlassPanel { - x: root.width * 0.65; y: 180; width: root.width * 0.27; height: 510; panelRadius: 36; strong: true + x: root.width * 0.65; y: 180; width: root.width * 0.27; height: accountActions.implicitHeight + 56; panelRadius: 36; strong: true Column { + id: accountActions anchors.fill: parent; anchors.margins: 28; spacing: 13 Text { text: root.selectedAccount ? root.selectedAccount.displayName : qsTr("Add a profile"); color: Theme.label; font.family: Theme.displayFont; font.pixelSize: 28; font.weight: Font.Black } Text { width: parent.width; wrapMode: Text.WordWrap; text: root.selectedAccount && root.selectedAccount.hasPin ? qsTr("A four-digit living-room PIN is required before this account can become active.") : qsTr("Profile PINs are local to this device and never sent to NVIDIA."); color: Theme.textMuted; font.family: Theme.bodyFont; font.pixelSize: 15; lineHeight: 1.2 } GlassButton { id: switchButton; width: parent.width; glyph: "A"; primary: true text: !root.selectedAccount ? qsTr("Add NVIDIA account") : (root.selectedAccount.userId === root.activeUserId ? qsTr("Currently active") : qsTr("Switch profile")) - enabled: !root.selectedAccount || root.selectedAccount.userId !== root.activeUserId + enabled: ShellStore.ready && ShellStore.accountSwitchRequestId === "" + && (!root.selectedAccount || root.selectedAccount.userId !== root.activeUserId) onClicked: { if (!root.selectedAccount) - AppController.navigate("sign-in") + ShellStore.beginAddAccount() else if (root.selectedAccount.hasPin) ShellStore.openPin("unlock", root.selectedAccount) else @@ -87,7 +89,7 @@ FocusScope { } GlassButton { width: parent.width; glyph: "+"; text: qsTr("Add another account") - onClicked: AppController.navigate("sign-in") + onClicked: ShellStore.beginAddAccount() } GlassButton { width: parent.width; glyph: "×"; text: qsTr("Forget this profile") @@ -100,6 +102,18 @@ FocusScope { onClicked: root.confirmLogoutAll = true } GlassButton { width: parent.width; glyph: "B"; text: qsTr("Back to account settings"); onClicked: AppController.navigate("settings-account") } + Text { + objectName: "accountActionError" + width: parent.width + visible: text !== "" + text: ShellStore.accountMessage + color: Theme.coral + font.family: Theme.bodyFont + font.pixelSize: 15 + wrapMode: Text.WordWrap + Accessible.role: Accessible.AlertMessage + Accessible.name: text + } } } diff --git a/opennow-qt/qml/screens/SignInScreen.qml b/opennow-qt/qml/screens/SignInScreen.qml index ff975c702..8f85dbbf1 100644 --- a/opennow-qt/qml/screens/SignInScreen.qml +++ b/opennow-qt/qml/screens/SignInScreen.qml @@ -4,6 +4,7 @@ import OpenNOW FocusScope { id: root property double clockMs: Date.now() + readonly property bool connected: ShellStore.signedIn && !ShellStore.addingAccount readonly property var challenge: ShellStore.authChallenge readonly property var qrRows: challenge && challenge.qrRows ? challenge.qrRows : [] readonly property int qrSize: qrRows.length @@ -19,18 +20,19 @@ FocusScope { GlassPanel { width: 720 - height: 532 + height: Math.max(532, loginControls.implicitHeight + 88) panelRadius: 40 strong: true Column { + id: loginControls anchors.fill: parent anchors.margins: 44 spacing: 22 Text { width: parent.width - text: ShellStore.signedIn ? qsTr("You’re ready to play.") : qsTr("Bring your games to the big screen.") + text: root.connected ? qsTr("You’re ready to play.") : qsTr("Bring your games to the big screen.") color: Theme.label font.family: Theme.displayFont font.pixelSize: 38 @@ -39,9 +41,11 @@ FocusScope { Text { width: parent.width wrapMode: Text.WordWrap - text: ShellStore.signedIn + text: root.connected ? qsTr("Signed in as %1. Your NVIDIA password never passes through OpenNOW.").arg(ShellStore.authSession.user.displayName) - : ShellStore.providerDiscoveryDegraded ? qsTr("Provider discovery is unavailable. Known providers are shown.") + : ShellStore.providerDiscoveryDegraded + ? ShellStore.providers.length ? qsTr("Provider discovery is unavailable. Known providers are shown.") + : qsTr("No providers are available. Refresh to try again.") : qsTr("OpenNOW connects to your GeForce NOW account without storing your NVIDIA password. Sign in from your phone, then come straight back to the controller.") color: Theme.textMuted font.family: Theme.bodyFont @@ -51,26 +55,28 @@ FocusScope { GlassButton { id: signIn width: parent.width - text: ShellStore.signedIn ? qsTr("Continue to your games") - : ShellStore.authState === "starting" ? qsTr("Contacting NVIDIA…") + text: root.connected ? qsTr("Continue to your games") + : ShellStore.authState === "starting" ? qsTr("Contacting provider…") : ShellStore.authState === "completing" ? qsTr("Loading your profile…") - : root.challenge ? qsTr("Open NVIDIA sign-in") : qsTr("Start device sign-in") + : ShellStore.authState === "error" ? qsTr("Try again") + : root.challenge ? qsTr("Open provider page") : qsTr("Start device sign-in") glyph: "A" primary: true enabled: ShellStore.ready && ShellStore.authState !== "starting" && ShellStore.authState !== "completing" + && (root.connected || root.challenge !== null || ShellStore.selectedProvider !== null) Component.onCompleted: forceActiveFocus() onClicked: { - if (ShellStore.signedIn) + if (root.connected) AppController.navigate("library") else if (root.challenge) - Qt.openUrlExternally(root.challenge.verificationUriComplete) + Qt.openUrlExternally(root.challenge.verificationUriComplete || root.challenge.verificationUri) else ShellStore.startDeviceLogin(ShellStore.selectedProvider ? ShellStore.selectedProvider.idpId : "") } } GlassButton { width: parent.width - visible: !ShellStore.signedIn + visible: !root.connected text: root.challenge ? qsTr("Cancel this sign-in") : qsTr("Provider · %1").arg(ShellStore.selectedProvider ? ShellStore.selectedProvider.displayName : qsTr("Select a provider")) glyph: root.challenge ? "B" : "X" @@ -85,8 +91,16 @@ FocusScope { } } GlassButton { + objectName: "consoleRefreshProviders" width: parent.width - visible: ShellStore.signedIn + visible: !root.connected && ShellStore.providerDiscoveryDegraded && !root.challenge + text: qsTr("Refresh providers") + enabled: ShellStore.ready && ShellStore.providersRequestId === "" + onClicked: ShellStore.refreshProviders(true) + } + GlassButton { + width: parent.width + visible: root.connected text: qsTr("Sign out") glyph: "B" danger: true @@ -145,13 +159,13 @@ FocusScope { anchors.centerIn: parent spacing: 10 visible: root.qrSize === 0 - Text { anchors.horizontalCenter: parent.horizontalCenter; text: ShellStore.signedIn ? "✓" : "◎"; color: "#111827"; font.pixelSize: 72; font.weight: Font.Black } - Text { anchors.horizontalCenter: parent.horizontalCenter; text: ShellStore.signedIn ? qsTr("Connected") : qsTr("Ready when you are"); color: "#111827"; font.family: Theme.bodyFont; font.pixelSize: 16; font.weight: Font.Bold } + Text { anchors.horizontalCenter: parent.horizontalCenter; text: root.connected ? "✓" : "◎"; color: "#111827"; font.pixelSize: 72; font.weight: Font.Black } + Text { anchors.horizontalCenter: parent.horizontalCenter; text: root.connected ? qsTr("Connected") : qsTr("Ready when you are"); color: "#111827"; font.family: Theme.bodyFont; font.pixelSize: 16; font.weight: Font.Bold } } } Text { anchors.horizontalCenter: parent.horizontalCenter - text: root.challenge ? root.challenge.userCode : ShellStore.signedIn ? ShellStore.authSession.user.membershipTier : qsTr("Scan with your phone") + text: root.challenge ? root.challenge.userCode : root.connected ? ShellStore.authSession.user.membershipTier : qsTr("Scan with your phone") color: Theme.label font.family: Theme.displayFont font.pixelSize: root.challenge ? 24 : 18 @@ -161,7 +175,7 @@ FocusScope { Text { anchors.horizontalCenter: parent.horizontalCenter text: root.challenge ? qsTr("Expires in %1 · %2").arg(root.timeLeft).arg(root.challenge.verificationUri.replace(/^https?:\/\//, "")) - : ShellStore.signedIn ? qsTr("GeForce NOW account") : qsTr("A real QR code appears after sign-in starts") + : root.connected ? qsTr("GeForce NOW account") : qsTr("A real QR code appears after sign-in starts") color: Theme.textMuted font.family: Theme.bodyFont font.pixelSize: 13 diff --git a/opennow-qt/qml/state/ShellStore.qml b/opennow-qt/qml/state/ShellStore.qml index a51cd87a3..e8293dab4 100644 --- a/opennow-qt/qml/state/ShellStore.qml +++ b/opennow-qt/qml/state/ShellStore.qml @@ -174,14 +174,35 @@ QtObject { } } - function refreshProviders() { - if (ready && providersRequestId === "") - providersRequestId = CoreClient.request("auth.providers.list", {}, 10000) + function refreshProviders(manual) { + if (!ready || providersRequestId !== "") return + if (manual === true) { + providerRetryAttempts = 0 + providerRetryTimer.stop() + } + providersRequestId = CoreClient.request("auth.providers.list", {}, 10000) + if (providersRequestId === "") scheduleProviderRetry(0) + } + + function scheduleProviderRetry(retryAfterMs) { + providerDiscoveryDegraded = true + if (ready && providerRetryAttempts < 3) { + providerRetryTimer.interval = Math.max(31000, Number(retryAfterMs || 0) + 1000) + providerRetryTimer.restart() + } } property var authSession: null property var authChallenge: null property string authState: "idle" property string authMessage: "" + property bool addingAccount: false + property string accountMessage: "" + property Connections accountNavigation: Connections { + target: AppController + function onRouteChanged() { + if (AppController.route !== "sign-in") root.addingAccount = false + } + } property alias catalogGames: catalogOwner.catalogGames readonly property var gameCollections: catalogOwner.gameCollections property alias activeCollectionId: catalogOwner.activeCollectionId @@ -1429,16 +1450,20 @@ QtObject { function switchAccount(userId, pin) { cancelDeviceLogin() - if (ready && accountSwitchRequestId === "") + if (ready && accountSwitchRequestId === "") { + accountMessage = "" accountSwitchRequestId = CoreClient.request("auth.accounts.switch", { userId: userId, pin: pin || "" }, 30000) + } } function removeAccount(userId) { - if (ready && accountRemoveRequestId === "") + if (ready && accountRemoveRequestId === "") { + accountMessage = "" accountRemoveRequestId = CoreClient.request("auth.accounts.remove", { userId: userId }) + } } function openPin(mode, account) { @@ -2068,6 +2093,14 @@ QtObject { ? qsTr("Native media startup failed") : qsTr("The native media runtime stopped unexpectedly")) if (!activeSession) return + if (streamer.errorCode === "missing-video-peer" + || streamer.errorCode === "nvst-legacy-transport-unsupported") { + cancelSessionRecovery() + streamerRecoveryExhausted = true + streamState = "error" + lastError = streamMessage + return + } if (sessionRecoveryPending || streamerRestartTimer.running) return scheduleSessionRecovery(streamMessage) @@ -2592,6 +2625,14 @@ QtObject { }, 35000) } + function beginAddAccount() { + cancelDeviceLogin() + addingAccount = true + authState = "idle" + authMessage = "" + AppController.navigate("sign-in") + } + function startDeviceLogin(providerIdpId, staySignedIn) { if (!ready || deviceStartRequestId !== "") return @@ -2604,7 +2645,7 @@ QtObject { pendingStaySignedIn = staySignedIn !== false cancelDeviceLogin() lastError = qsTr("") - authMessage = qsTr("Contacting NVIDIA…") + authMessage = qsTr("Contacting provider…") authState = "starting" const params = providerIdpId ? { providerIdpId: providerIdpId } : {} deviceStartRequestId = CoreClient.request("auth.device.start", params, 30000) @@ -2637,8 +2678,10 @@ QtObject { if (authChallenge && ready) CoreClient.request("auth.device.cancel", { attemptId: authChallenge.attemptId }) authChallenge = null - if (!signedIn) + if (!signedIn || addingAccount) { authState = "idle" + authMessage = "" + } } function logout() { @@ -2649,8 +2692,10 @@ QtObject { function logoutAll() { cancelDeviceLogin() - if (ready && logoutAllRequestId === "") + if (ready && logoutAllRequestId === "") { + accountMessage = "" logoutAllRequestId = CoreClient.request("auth.accounts.logoutAll", {}) + } } function acceptAuthEnvelope(payload) { @@ -3217,10 +3262,8 @@ QtObject { if (Number(result.generation || 0) > root.authGeneration && root.authSessionRequestId === "") root.authSessionRequestId = CoreClient.request("auth.session.get", {}) root.providerDiscoveryDegraded = Boolean(result.discovery && result.discovery.state === "degraded") - if (root.providerDiscoveryDegraded && root.providerRetryAttempts < 3) { - root.providerRetryTimer.interval = Math.max(31000, Number(result.discovery.retryAfterMs || 0) + 1000) - root.providerRetryTimer.restart() - } + if (root.providerDiscoveryDegraded) + root.scheduleProviderRetry(result.discovery.retryAfterMs) else if (!root.providerDiscoveryDegraded) { root.providerRetryAttempts = 0 root.providerRetryTimer.stop() @@ -3275,7 +3318,7 @@ QtObject { root.devicePollTimer.interval = Math.max(1000, Number(result.retryAfterMs || Number(result.intervalSeconds || 5) * 1000)) root.devicePollTimer.restart() } else { - root.devicePollTimer.stop() + root.cancelDeviceLogin() root.authState = "error" root.authMessage = result.error || qsTr("Device sign-in failed") } @@ -3296,6 +3339,10 @@ QtObject { root.reloadCatalogForSession() if (root.authSession) root.refreshAccountServices() + if (root.authSession) { + root.addingAccount = false + if (AppController.route === "sign-in") AppController.navigate("home") + } root.resolveDirectLaunch() } else if (requestId === root.logoutRequestId) { root.authSession = result.session || null @@ -3594,6 +3641,7 @@ QtObject { catalogOwner.failStore(message) } else if (requestId === root.providersRequestId) { root.providersRequestId = "" + if (code !== "cancelled") root.scheduleProviderRetry(0) } else if (requestId === root.authSessionRequestId) { root.authSessionRequestId = "" root.authRestorePending = false @@ -3603,18 +3651,19 @@ QtObject { } else if (requestId === root.deviceStartRequestId || requestId === root.devicePollRequestId || requestId === root.deviceCompleteRequestId) { - root.devicePollTimer.stop() - root.authState = code === "cancelled" ? "idle" : "error" - root.authMessage = code === "cancelled" ? "" : message root.deviceStartRequestId = "" root.devicePollRequestId = "" root.deviceCompleteRequestId = "" + root.cancelDeviceLogin() + root.authState = code === "cancelled" ? "idle" : "error" + root.authMessage = code === "cancelled" ? "" : message } else if (requestId === root.logoutRequestId) { root.logoutRequestId = "" root.authMessage = message } else if (requestId === root.logoutAllRequestId) { root.logoutAllRequestId = "" root.authMessage = message + root.accountMessage = message } else if (requestId === root.subscriptionRequestId) { accountServicesOwner.failSubscription(message) } else if (requestId === root.regionsRequestId) { @@ -3626,8 +3675,10 @@ QtObject { } else if (requestId === root.accountSwitchRequestId) { root.accountSwitchRequestId = "" root.pinMessage = message + root.accountMessage = message } else if (requestId === root.accountRemoveRequestId) { root.accountRemoveRequestId = "" + root.accountMessage = message } else if (requestId === root.pinRequestId) { root.pinRequestId = "" root.pinMessage = message diff --git a/opennow-qt/tests/tst_embeddedorchestration.cpp b/opennow-qt/tests/tst_embeddedorchestration.cpp index 9da9fd009..b894ecea5 100644 --- a/opennow-qt/tests/tst_embeddedorchestration.cpp +++ b/opennow-qt/tests/tst_embeddedorchestration.cpp @@ -31,6 +31,60 @@ bool prepareLaunchGuards(QJSEngine &engine) } return true; } + +bool loadShellFunction(QJSEngine &engine, const QString &name, int indentation = 4) +{ + const auto prefix = QString(indentation, u' '); + const auto shell = source(QStringLiteral("qml/state/ShellStore.qml")); + const auto match = QRegularExpression(prefix + QStringLiteral("function %1\\([^\\n]*\\) \\{\\n.*?\\n").arg(name) + + prefix + u'}', QRegularExpression::DotMatchesEverythingOption) + .match(indentation == 8 ? shell.section(QStringLiteral("property Connections coreConnections:"), 1) : shell); + if (!match.hasMatch()) return false; + const auto result = engine.evaluate(match.captured()); + if (result.isError()) qWarning().noquote() << result.toString(); + return !result.isError(); +} + +bool prepareAuthentication(QJSEngine &engine) +{ + const auto setup = engine.evaluate(QStringLiteral(R"JS( + var root = this, ready = true, providersRequestId = '', providerRetryAttempts = 0; + var providers = [{idpId:'alliance',displayName:'Alliance'}], selectedProviderIdpId = 'alliance'; + var providerDiscoveryDegraded = false, authGeneration = 0, authSessionRequestId = ''; + var authSession = {user:{userId:'old',displayName:'Old'},provider:{idpId:'alliance'}}; + Object.defineProperty(root, 'signedIn', {get: function() { return authSession !== null; }}); + var addingAccount = false, authState = 'signed-in', authMessage = '', accountMessage = ''; + var accountSwitchRequestId = '', accountRemoveRequestId = '', logoutAllRequestId = ''; + var deviceStartRequestId = '', devicePollRequestId = '', deviceCompleteRequestId = ''; + var pendingStaySignedIn = true, authChallenge = null, sessionPersistence = 'secure-store'; + var sessionPersistenceMessage = '', pinMessage = '', lastError = '', requests = [], cancelled = []; + var providerRetryTimer = {running:false,interval:31000,restarts:0, + restart:function() {this.running=true;this.restarts++;},stop:function() {this.running=false;}}; + var devicePollTimer = {running:false,interval:1000, + restart:function() {this.running=true;},stop:function() {this.running=false;}}; + var CoreClient = {request:function(method,params) { + requests.push({method:method,params:params});return 'request-' + requests.length; + },cancel:function(id) {cancelled.push(id);}}; + var AppController = {route:'accounts',navigate:function(route) {this.route=route;}}; + var settingsOwner = {acceptResponse:function() {return false;},acceptFailure:function() {return false;}}; + var onboardingOwner = {acceptResponse:function() {return false;},acceptFailure:function() {return false;}}; + function ownedSessionTermination() {return null;} + function finishArtworkRequest() {return false;} + function acceptAuthEnvelope() {return true;} + function reloadCatalogForSession() {} + function refreshAccountServices() {} + function resolveDirectLaunch() {} + function qsTr(text) {return text;} + String.prototype.arg = function(value) {return this.replace(/%[12]/,String(value));}; + )JS")); + if (setup.isError()) return false; + for (const auto &name : {"refreshProviders", "scheduleProviderRetry", "beginAddAccount", + "startDeviceLogin", "cancelDeviceLogin", "switchAccount"}) { + if (!loadShellFunction(engine, QString::fromLatin1(name))) return false; + } + return loadShellFunction(engine, QStringLiteral("onResponseReceived"), 8) + && loadShellFunction(engine, QStringLiteral("onRequestFailed"), 8); +} } class EmbeddedOrchestrationTest final : public QObject @@ -38,6 +92,208 @@ class EmbeddedOrchestrationTest final : public QObject Q_OBJECT private slots: + void providerRpcFailureOffersBoundedAndManualRecovery() + { + QJSEngine engine; + QVERIFY(prepareAuthentication(engine)); + QVERIFY(!engine.evaluate(QStringLiteral(R"JS( + providers = []; + refreshProviders(); + onRequestFailed(providersRequestId,'deadline_exceeded','Provider lookup timed out'); + )JS")).isError()); + QVERIFY(engine.evaluate(QStringLiteral("providerDiscoveryDegraded && providerRetryTimer.running && providersRequestId === ''")).toBool()); + for (int attempt = 1; attempt <= 3; ++attempt) { + QVERIFY(!engine.evaluate(QStringLiteral(R"JS( + providerRetryTimer.running = false; + providerRetryAttempts++; + refreshProviders(); + onRequestFailed(providersRequestId,'network_error','Offline'); + )JS")).isError()); + QCOMPARE(engine.evaluate(QStringLiteral("providerRetryTimer.running")).toBool(), attempt < 3); + } + QCOMPARE(engine.evaluate(QStringLiteral("requests.length")).toInt(), 4); + QVERIFY(!engine.evaluate(QStringLiteral(R"JS( + refreshProviders(true); + var manualRequest = providersRequestId; + refreshProviders(true); + )JS")).isError()); + QCOMPARE(engine.evaluate(QStringLiteral("requests.length")).toInt(), 5); + QCOMPARE(engine.evaluate(QStringLiteral("providerRetryAttempts")).toInt(), 0); + QVERIFY(!engine.evaluate(QStringLiteral(R"JS( + onResponseReceived(manualRequest,{providers:[{idpId:'alliance'}],discovery:{state:'ready'}}); + )JS")).isError()); + QVERIFY(engine.evaluate(QStringLiteral("!providerDiscoveryDegraded && !providerRetryTimer.running")).toBool()); + const auto desktop = source(QStringLiteral("qml/desktop/auth/DesktopSignInScreen.qml")); + const auto console = source(QStringLiteral("qml/screens/SignInScreen.qml")); + QVERIFY(desktop.contains(QStringLiteral("onClicked: ShellStore.refreshProviders(true)"))); + QVERIFY(console.contains(QStringLiteral("onClicked: ShellStore.refreshProviders(true)"))); + } + + void failedDeviceLoginClearsTheChallengeAndCanRestart_data() + { + QTest::addColumn("phase"); + for (const auto &phase : {"expired", "denied", "start-rpc", "poll-rpc", "complete-rpc"}) + QTest::newRow(phase) << QString::fromLatin1(phase); + } + + void failedDeviceLoginClearsTheChallengeAndCanRestart() + { + QFETCH(QString, phase); + QJSEngine engine; + QVERIFY(prepareAuthentication(engine)); + QVERIFY(!engine.evaluate(QStringLiteral(R"JS( + authSession = null; + authChallenge = {attemptId:'expired-attempt',verificationUriComplete:'https://example.invalid/expired'}; + authState = 'waiting'; + devicePollTimer.running = true; + )JS")).isError()); + QString failure; + if (phase.endsWith(QStringLiteral("-rpc"))) { + const auto field = phase == QStringLiteral("start-rpc") ? QStringLiteral("deviceStartRequestId") + : phase == QStringLiteral("poll-rpc") ? QStringLiteral("devicePollRequestId") : QStringLiteral("deviceCompleteRequestId"); + failure = QStringLiteral("%1='failed'; onRequestFailed('failed','network_error','Login unavailable');").arg(field); + } else { + failure = QStringLiteral("devicePollRequestId='failed'; onResponseReceived('failed',{status:'%1',error:'Login unavailable'});").arg(phase); + } + const auto result = engine.evaluate(failure); + QVERIFY2(!result.isError(), qPrintable(result.toString())); + QVERIFY(engine.evaluate(QStringLiteral("authChallenge === null && authState === 'error' && !devicePollTimer.running")).toBool()); + QVERIFY(!engine.evaluate(QStringLiteral("startDeviceLogin('alliance',false)")).isError()); + QCOMPARE(engine.evaluate(QStringLiteral("requests[requests.length-1].method")).toString(), QStringLiteral("auth.device.start")); + QCOMPARE(engine.evaluate(QStringLiteral("authState")).toString(), QStringLiteral("starting")); + QVERIFY(!engine.evaluate(QStringLiteral("pendingStaySignedIn")).toBool()); + } + + void addingAnAccountFinishesWithoutSignedInChanging() + { + QJSEngine engine; + QVERIFY(prepareAuthentication(engine)); + QVERIFY(!engine.evaluate(QStringLiteral("beginAddAccount()")).isError()); + QVERIFY(engine.evaluate(QStringLiteral("addingAccount && signedIn && authSession.user.userId === 'old' && AppController.route === 'sign-in'")).toBool()); + const auto console = source(QStringLiteral("qml/screens/SignInScreen.qml")); + const auto connected = QRegularExpression(QStringLiteral("readonly property bool connected: ([^\\n]+)")).match(console); + QVERIFY(connected.hasMatch()); + engine.globalObject().setProperty(QStringLiteral("ShellStore"), engine.globalObject()); + QVERIFY(!engine.evaluate(connected.captured(1)).toBool()); + const auto completion = engine.evaluate(QStringLiteral(R"JS( + deviceCompleteRequestId='complete'; + onResponseReceived('complete',{session:{user:{userId:'new',displayName:'New'},provider:{idpId:'alliance'}},persistence:'secure-store'}); + )JS")); + QVERIFY2(!completion.isError(), qPrintable(completion.toString())); + QVERIFY(engine.evaluate(QStringLiteral("signedIn && !addingAccount && authSession.user.userId === 'new'")).toBool()); + QCOMPARE(engine.evaluate(QStringLiteral("AppController.route")).toString(), QStringLiteral("home")); + QVERIFY(source(QStringLiteral("qml/screens/AccountsScreen.qml")).contains(QStringLiteral("onClicked: ShellStore.beginAddAccount()"))); + } + + void cancellingAnAddedAccountReturnsToIdleInsteadOfWaiting() + { + QJSEngine engine; + QVERIFY(prepareAuthentication(engine)); + QVERIFY(!engine.evaluate(QStringLiteral(R"JS( + beginAddAccount(); + authChallenge = {attemptId:'add-attempt',verificationUriComplete:'https://example.invalid/add'}; + authState = 'waiting'; + authMessage = 'Scan the QR code'; + devicePollTimer.running = true; + cancelDeviceLogin(); + )JS")).isError()); + QVERIFY(engine.evaluate(QStringLiteral("authChallenge === null && authState === 'idle' && authMessage === ''")).toBool()); + QVERIFY(engine.evaluate(QStringLiteral("addingAccount && signedIn && authSession.user.userId === 'old'")).toBool()); + QVERIFY(!engine.evaluate(QStringLiteral("devicePollTimer.running")).toBool()); + const auto desktop = source(QStringLiteral("qml/desktop/auth/DesktopSignInScreen.qml")); + const auto waiting = QRegularExpression(QStringLiteral("readonly property bool waiting: ([^\\n]+)")).match(desktop); + QVERIFY(waiting.hasMatch()); + engine.globalObject().setProperty(QStringLiteral("ShellStore"), engine.globalObject()); + QVERIFY(!engine.evaluate(waiting.captured(1)).toBool()); + QVERIFY(!engine.evaluate(QStringLiteral("startDeviceLogin('alliance',false)")).isError()); + QCOMPARE(engine.evaluate(QStringLiteral("requests[requests.length-1].method")).toString(), QStringLiteral("auth.device.start")); + QCOMPARE(engine.evaluate(QStringLiteral("authState")).toString(), QStringLiteral("starting")); + } + + void accountSwitchFailureIsVisibleAndClearedOnRetry() + { + QJSEngine engine; + QVERIFY(prepareAuthentication(engine)); + const auto failure = engine.evaluate(QStringLiteral(R"JS( + switchAccount('new',''); + onRequestFailed(accountSwitchRequestId,'network_error','Cannot reach your provider'); + )JS")); + QVERIFY2(!failure.isError(), qPrintable(failure.toString())); + QCOMPARE(engine.evaluate(QStringLiteral("accountMessage")).toString(), QStringLiteral("Cannot reach your provider")); + QCOMPARE(engine.evaluate(QStringLiteral("pinMessage")).toString(), QStringLiteral("Cannot reach your provider")); + QCOMPARE(engine.evaluate(QStringLiteral("authSession.user.userId")).toString(), QStringLiteral("old")); + QVERIFY(!engine.evaluate(QStringLiteral("switchAccount('new','')")).isError()); + QCOMPARE(engine.evaluate(QStringLiteral("accountMessage")).toString(), QString()); + const auto accounts = source(QStringLiteral("qml/screens/AccountsScreen.qml")); + QVERIFY(accounts.contains(QStringLiteral("objectName: \"accountActionError\""))); + QVERIFY(accounts.contains(QStringLiteral("text: ShellStore.accountMessage"))); + } + + void exhaustedTransportNegotiationDoesNotReclaimTheSeat_data() + { + QTest::addColumn("code"); + QTest::addColumn("terminal"); + QTest::addColumn("recoveryPending"); + QTest::newRow("missing-video-peer") << QStringLiteral("missing-video-peer") << true << false; + QTest::newRow("legacy-unsupported") << QStringLiteral("nvst-legacy-transport-unsupported") << true << false; + QTest::newRow("missing-video-peer-during-recovery") << QStringLiteral("missing-video-peer") << true << true; + QTest::newRow("legacy-unsupported-during-recovery") << QStringLiteral("nvst-legacy-transport-unsupported") << true << true; + QTest::newRow("transient-network") << QStringLiteral("network_error") << false << false; + QTest::newRow("unknown-error") << QStringLiteral("new_native_error") << false << false; + } + + void exhaustedTransportNegotiationDoesNotReclaimTheSeat() + { + QFETCH(QString, code); + QFETCH(bool, terminal); + QFETCH(bool, recoveryPending); + QJSEngine engine; + QVERIFY(!engine.evaluate(QStringLiteral(R"JS( + var root=this, activeSession={sessionId:'seat'},streamer={status:'starting'},runtimeStreamProfile={}; + var streamInputStateKnown=true,streamReplayEnabled=false,mediaClipTargetRequestId='',streamClipRequestId=''; + var streamRecordingActive=false,streamerStopExpected=false,sessionRecoveryPending=false; + var streamState='starting',streamMessage='',sessionReconnectAttempts=0,maximumSessionReconnectAttempts=8; + var streamStopRequestId='',streamerRecoveryExhausted=false,lastError='',streamerRestartAttempts=0; + var recoveryDiscoveryRequestId='',sessionClaimRequestId='',streamerStopRequestId='',recoverySessionId=''; + var sessionClaimIsRecovery=false,resumePollAttempts=0,resumePollDeadlineMs=0,ready=true; + var NativeStreamRuntime={running:false},requests=[]; + var CoreClient={cancel:function() {},request:function(method,params) {requests.push({method:method,params:params});return 'recovery';}}; + var streamerRestartTimer={running:false,restart:function() {this.running=true;},stop:function() {this.running=false;}}; + var streamPollTimer={stop:function() {}},streamerPrepareRequestId='',streamPollRequestId=''; + function inspectStreamerOverlayRequest() {} function inspectStreamerScreenshotRequest() {} + function inspectStreamerRecordingRequest() {} function inspectStreamerShortcutAction() {} + function qsTr(text) {return text;} + )JS")).isError()); + for (const auto &name : {"acceptStreamerSnapshot", "isRemoteSessionTermination", "cancelSessionRecovery", + "scheduleSessionRecovery", "retryNativeStreamer", "recoverStreamingSession", "discoverRecoverySession"}) + QVERIFY(loadShellFunction(engine, QString::fromLatin1(name))); + engine.globalObject().setProperty(QStringLiteral("failureCode"), code); + if (recoveryPending) { + QVERIFY(!engine.evaluate(QStringLiteral(R"JS( + sessionRecoveryPending=true; recoveryDiscoveryRequestId='old-discovery'; + sessionClaimRequestId='old-claim'; streamerRestartTimer.running=true; + )JS")).isError()); + } + const auto result = engine.evaluate(QStringLiteral(R"JS( + acceptStreamerSnapshot({status:'error',errorCode:failureCode,message:'Original transport failure'}); + acceptStreamerSnapshot({status:'stopped',message:'Later stopped event'}); + )JS")); + QVERIFY2(!result.isError(), qPrintable(result.toString())); + QCOMPARE(engine.evaluate(QStringLiteral("streamerRecoveryExhausted")).toBool(), terminal); + QCOMPARE(engine.evaluate(QStringLiteral("streamerRestartTimer.running")).toBool(), !terminal); + QCOMPARE(engine.evaluate(QStringLiteral("streamState")).toString(), terminal ? QStringLiteral("error") : QStringLiteral("reconnecting")); + QCOMPARE(engine.evaluate(QStringLiteral("streamMessage")).toString(), QStringLiteral("Original transport failure")); + QCOMPARE(engine.evaluate(QStringLiteral("activeSession.sessionId")).toString(), QStringLiteral("seat")); + QCOMPARE(engine.evaluate(QStringLiteral("requests.length")).toInt(), 0); + if (terminal) { + QVERIFY(engine.evaluate(QStringLiteral("!sessionRecoveryPending && recoveryDiscoveryRequestId === '' && sessionClaimRequestId === ''")).toBool()); + } + QVERIFY(!engine.evaluate(QStringLiteral("retryNativeStreamer()")).isError()); + QVERIFY(!engine.evaluate(QStringLiteral("streamerRecoveryExhausted")).toBool()); + QCOMPARE(engine.evaluate(QStringLiteral("requests[0].method")).toString(), QStringLiteral("session.poll")); + QCOMPARE(engine.evaluate(QStringLiteral("requests[0].params.sessionId")).toString(), QStringLiteral("seat")); + } + void allianceAccountInvalidationRejectsOldRegionsAndKeepsProviderPreferences() { const auto account = source(QStringLiteral("qml/state/account/AccountServicesState.qml"));