diff --git a/app/src/main/app/PluviaApp.kt b/app/src/main/app/PluviaApp.kt index 850d6e69d..3d59b66e3 100644 --- a/app/src/main/app/PluviaApp.kt +++ b/app/src/main/app/PluviaApp.kt @@ -56,12 +56,13 @@ class PluviaApp : Application() { } } - // Initialize Timber in debug builds for app wide logging. - // If 'BuildConfig' give error, ignore it. - // The file needed will be autogenerated when building the apk. - if (com.winlator.cmod.BuildConfig.DEBUG) { - Timber.plant(Timber.DebugTree()); - } + // Plant Timber in every build, not just debug ones. + // Release builds used to log nothing through Timber, which made the bugs + // users actually report (Steam logon and download failures) impossible to + // diagnose from logcat: the interesting state transitions simply vanished + // once the APK was minified. DebugTree goes to logcat and costs nothing + // when nobody reads it. + Timber.plant(Timber.DebugTree()) // Initialize the LogManager context in case a fallback is needed. LogManager.init(this.applicationContext) diff --git a/app/src/main/cpp/wn-steam-client/rust/src/cdn_client.rs b/app/src/main/cpp/wn-steam-client/rust/src/cdn_client.rs index 9c5bc9b56..efe134c21 100644 --- a/app/src/main/cpp/wn-steam-client/rust/src/cdn_client.rs +++ b/app/src/main/cpp/wn-steam-client/rust/src/cdn_client.rs @@ -292,7 +292,7 @@ impl CdnClient { if http_status != 200 { return CdnManifestResult { http_status, - error: "non-200 HTTP status".to_string(), + error: format!("non-200 HTTP status ({http_status})"), ..Default::default() }; } @@ -327,7 +327,7 @@ impl CdnClient { if http_status != 200 { return CdnChunkResult { http_status, - error: "non-200 HTTP status".to_string(), + error: format!("non-200 HTTP status ({http_status})"), ..Default::default() }; } @@ -388,7 +388,7 @@ impl CdnClient { .get(url) .timeout(timeout) .send() - .map_err(|err| format!("http get: {err}"))?; + .map_err(|err| format_http_error("http get", &err))?; let http_status = response.status().as_u16() as i32; let content_length = response.content_length(); let body = response @@ -440,6 +440,24 @@ struct HttpResponse { body: Vec, } +/// Expand a reqwest error into its full source chain so a TLS/connect failure +/// on a restricted network is diagnosable from a single log line (reqwest's +/// `Display` collapses everything into a terse "error sending request"). +fn format_http_error(context: &str, err: &reqwest::Error) -> String { + let mut out = format!("{context}: {err}"); + let mut source = std::error::Error::source(err); + let mut depth = 0; + while let Some(next) = source { + out.push_str(&format!(" | caused by: {next}")); + source = next.source(); + depth += 1; + if depth > 8 { + break; + } + } + out +} + impl CdnChunkResult { pub fn ok(&self) -> bool { self.error.is_empty() && !self.data.is_empty() @@ -636,7 +654,7 @@ mod tests { assert!(chunk.ok()); assert_eq!( CdnClient::validate_chunk_response(404, vec![], None).error, - "non-200 HTTP status" + "non-200 HTTP status (404)" ); } diff --git a/app/src/main/cpp/wn-steam-client/rust/src/cm_client.rs b/app/src/main/cpp/wn-steam-client/rust/src/cm_client.rs index b4e8d3fe5..488674028 100644 --- a/app/src/main/cpp/wn-steam-client/rust/src/cm_client.rs +++ b/app/src/main/cpp/wn-steam-client/rust/src/cm_client.rs @@ -142,6 +142,7 @@ pub struct CMClientCore { session_id: AtomicI32, family_group_id: AtomicU64, server_realtime: AtomicU32, + cell_id: AtomicU32, playing_blocked: AtomicBool, license_list: Mutex>, friends: Mutex>, @@ -170,6 +171,7 @@ impl Default for CMClientCore { session_id: AtomicI32::new(0), family_group_id: AtomicU64::new(0), server_realtime: AtomicU32::new(0), + cell_id: AtomicU32::new(0), playing_blocked: AtomicBool::new(false), license_list: Mutex::new(Vec::new()), friends: Mutex::new(HashMap::new()), @@ -202,6 +204,7 @@ impl CMClientCore { self.session_id.store(0, Ordering::Relaxed); self.family_group_id.store(0, Ordering::Relaxed); self.server_realtime.store(0, Ordering::Relaxed); + self.cell_id.store(0, Ordering::Relaxed); self.outbound_wires .lock() .expect("outbound queue poisoned") @@ -224,6 +227,10 @@ impl CMClientCore { self.server_realtime.load(Ordering::Relaxed) } + pub fn cell_id(&self) -> u32 { + self.cell_id.load(Ordering::Relaxed) + } + pub fn is_playing_blocked(&self) -> bool { self.playing_blocked.load(Ordering::Relaxed) } @@ -724,6 +731,28 @@ impl CMClientCore { ) } + pub fn build_get_cdn_auth_token( + &self, + app_id: u32, + depot_id: u32, + host_name: &str, + job_id: u64, + ) -> Option { + if app_id == 0 || depot_id == 0 || host_name.is_empty() { + return None; + } + self.build_authed_service_call( + "ContentServerDirectory.GetCDNAuthToken#1", + job_id, + crate::pb::cmsg_client_get_cdn_auth_token::CMsgClientGetCdnAuthToken { + app_id, + depot_id, + host_name: host_name.to_string(), + } + .serialize(), + ) + } + pub fn build_manifest_request_code_call( &self, app_id: u32, @@ -1359,6 +1388,7 @@ impl CMClientCore { .store(resp.family_group_id, Ordering::Relaxed); self.server_realtime .store(resp.rtime32_server_time, Ordering::Relaxed); + self.cell_id.store(resp.cell_id, Ordering::Relaxed); InboundAction::LogonOk } else { InboundAction::LoggedOff @@ -1373,6 +1403,7 @@ impl CMClientCore { self.session_id.store(0, Ordering::Relaxed); self.family_group_id.store(0, Ordering::Relaxed); self.server_realtime.store(0, Ordering::Relaxed); + self.cell_id.store(0, Ordering::Relaxed); InboundAction::LoggedOff } EMsg::CLIENT_LICENSE_LIST => { diff --git a/app/src/main/cpp/wn-steam-client/rust/src/cm_selector.rs b/app/src/main/cpp/wn-steam-client/rust/src/cm_selector.rs new file mode 100644 index 000000000..10bc85094 --- /dev/null +++ b/app/src/main/cpp/wn-steam-client/rust/src/cm_selector.rs @@ -0,0 +1,348 @@ +//! Picks the connection manager to log on to, the way the official client does. +//! +//! The Steam client does not take the first entry of the CM directory: it pings +//! every advertised WebSocket CM (port 443 and the 27018/27019 alternates) and +//! connects to the endpoint with the lowest measured round-trip time, using the +//! advertised load only to break ties. Picking by load alone - or rotating +//! blindly - sends clients to far-away CMs on networks where only some of the +//! advertised datacenters are reachable, which is exactly what a mainland-China +//! connection sees: `sha1`/Perfect World CMs are a few milliseconds away while +//! the `sea1`/`hkg1` entries time out. +//! +//! Two practical differences from a naive probe implementation: +//! +//! * Endpoints that recently failed are skipped instead of being probed again on +//! every reconnect, and being asked to pick again within [`RECONNECT_WINDOW`] +//! counts as a failure of the previous pick (the client only re-picks when the +//! connection did not come up). +//! * If every probe fails we still return something - the least-loaded endpoint - +//! because a wrong guess is better than not trying at all. + +use crate::cm_server::CmServer; +use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +/// How long a single endpoint probe may take before it counts as unreachable. +pub const PROBE_TIMEOUT: Duration = Duration::from_millis(1500); + +/// Endpoints that failed this recently are skipped while other options remain. +pub const FAILURE_MEMORY: Duration = Duration::from_secs(120); + +/// Being asked to pick again this soon after the previous pick means the client +/// could not establish a session, so the previous endpoint is remembered as bad. +pub const RECONNECT_WINDOW: Duration = Duration::from_secs(45); + +/// Ports the official client probes for a WebSocket CM, in addition to whatever +/// the directory advertises. +const ALTERNATE_PORTS: [u16; 3] = [443, 27018, 27019]; + +#[derive(Clone, Debug, PartialEq)] +pub struct CmCandidate { + pub host: String, + pub port: u16, + pub weighted_load: f32, +} + +impl CmCandidate { + pub fn url(&self) -> String { + format!("wss://{}:{}/cmsocket/", self.host, self.port) + } + + fn key(&self) -> String { + format!("{}:{}", self.host, self.port) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ProbeOutcome { + pub index: usize, + pub rtt: Duration, +} + +/// Builds the candidate list: every advertised endpoint plus the alternate ports +/// the official client also probes, de-duplicated by `host:port`. +pub fn candidates_for(servers: &[CmServer]) -> Vec { + let mut out: Vec = Vec::new(); + for server in servers { + if server.host.is_empty() { + continue; + } + let mut ports: Vec = Vec::new(); + if server.port != 0 { + ports.push(server.port); + } + for port in ALTERNATE_PORTS { + if !ports.contains(&port) { + ports.push(port); + } + } + for port in ports { + let candidate = CmCandidate { + host: server.host.clone(), + port, + weighted_load: server.weighted_load, + }; + if !out.iter().any(|existing| existing.key() == candidate.key()) { + out.push(candidate); + } + } + } + out +} + +struct BadEndpoint { + key: String, + at: Instant, +} + +#[derive(Default)] +struct Memory { + bad: Vec, + last_pick: Option<(String, Instant)>, +} + +static MEMORY: Mutex> = Mutex::new(None); + +fn with_memory(f: impl FnOnce(&mut Memory) -> T) -> T { + let mut guard = MEMORY.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let memory = guard.get_or_insert_with(Memory::default); + f(memory) +} + +fn prune(memory: &mut Memory, now: Instant) { + memory + .bad + .retain(|entry| now.duration_since(entry.at) < FAILURE_MEMORY); +} + +fn is_bad(key: &str) -> bool { + let now = Instant::now(); + with_memory(|memory| { + prune(memory, now); + memory.bad.iter().any(|entry| entry.key == key) + }) +} + +/// Notes that we are about to connect to `key`. If this happens inside +/// [`RECONNECT_WINDOW`] the previous endpoint evidently did not work, so it is +/// remembered as bad and the next pick will avoid it. +fn note_pick(key: &str) { + let now = Instant::now(); + with_memory(|memory| { + if let Some((previous, at)) = memory.last_pick.take() { + if previous != key && now.duration_since(at) < RECONNECT_WINDOW { + prune(memory, now); + memory.bad.push(BadEndpoint { + key: previous, + at: now, + }); + } + } + memory.last_pick = Some((key.to_string(), now)); + }); +} + +fn resolve(host: &str, port: u16) -> Vec { + let Ok(addrs) = (host, port).to_socket_addrs() else { + return Vec::new(); + }; + addrs.collect() +} + +/// Opens a TCP connection to measure reachability and round-trip time. +fn probe_one(host: &str, port: u16) -> Option { + let started = Instant::now(); + let addrs = resolve(host, port); + if addrs.is_empty() { + return None; + } + for addr in addrs { + if TcpStream::connect_timeout(&addr, PROBE_TIMEOUT).is_ok() { + return Some(started.elapsed()); + } + } + None +} + +/// Probes `candidates` in parallel and returns the fastest reachable one. +/// +/// Candidates that recently failed are only probed when nothing else works, and +/// the advertised load breaks ties between endpoints with similar latency. +pub fn pick(candidates: &[CmCandidate]) -> Option<(usize, Duration)> { + if candidates.is_empty() { + return None; + } + let preferred: Vec = (0..candidates.len()) + .filter(|index| !is_bad(&candidates[*index].key())) + .collect(); + let indices = if preferred.is_empty() { + (0..candidates.len()).collect::>() + } else { + preferred + }; + + // (candidate index, measured rtt when the probe connected) + let measurements: Vec<(usize, Option)> = std::thread::scope(|scope| { + let handles: Vec<_> = indices + .iter() + .map(|index| { + let candidate = &candidates[*index]; + scope.spawn(move || (*index, probe_one(&candidate.host, candidate.port))) + }) + .collect(); + handles + .into_iter() + .map(|handle| handle.join().unwrap_or((0, None))) + .collect() + }); + + let now = Instant::now(); + with_memory(|memory| { + prune(memory, now); + for (index, rtt) in &measurements { + if rtt.is_none() { + let key = candidates[*index].key(); + if !memory.bad.iter().any(|entry| entry.key == key) { + memory.bad.push(BadEndpoint { key, at: now }); + } + } + } + }); + + let mut best: Option<(usize, Duration)> = None; + for (index, rtt) in measurements { + let Some(rtt) = rtt else { continue }; + match best { + None => best = Some((index, rtt)), + Some((best_index, best_rtt)) => { + // Latency wins unless the two endpoints are within 25 ms, in which + // case the less loaded datacenter is preferred. + let close = rtt.abs_diff(best_rtt) < Duration::from_millis(25); + let better = if close { + candidates[index].weighted_load < candidates[best_index].weighted_load + } else { + rtt < best_rtt + }; + if better { + best = Some((index, rtt)); + } + } + } + } + + match best { + Some((index, rtt)) => { + note_pick(&candidates[index].key()); + Some((index, rtt)) + } + None => { + // Nothing answered. Still return an endpoint so the client attempts a + // connection instead of giving up, but do not treat it as a success. + let fallback = indices.iter().copied().min_by(|a, b| { + candidates[*a] + .weighted_load + .partial_cmp(&candidates[*b].weighted_load) + .unwrap_or(std::cmp::Ordering::Equal) + })?; + note_pick(&candidates[fallback].key()); + None + } + } +} + +/// Human-readable candidate list, used only for logging. +pub fn describe(candidates: &[CmCandidate]) -> String { + candidates + .iter() + .map(|candidate| format!("{}:{} load={:.1}", candidate.host, candidate.port, candidate.weighted_load)) + .collect::>() + .join(", ") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cm_server::CmTransport; + + fn server(host: &str, port: u16, load: f32) -> CmServer { + CmServer { + endpoint: format!("{host}:{port}"), + host: host.to_string(), + port, + transport: CmTransport::WebSocket, + realm: "steamglobal".to_string(), + datacenter: String::new(), + load: 0, + weighted_load: load, + } + } + + #[test] + fn candidates_expand_alternate_ports_once() { + let candidates = candidates_for(&[server("cmp2-sha1.steamchina.com", 443, 30.0)]); + assert_eq!(candidates.len(), 3); + assert_eq!(candidates[0].port, 443); + assert!(candidates.iter().any(|candidate| candidate.port == 27018)); + assert!(candidates.iter().any(|candidate| candidate.port == 27019)); + } + + #[test] + fn candidates_deduplicate_hosts() { + let candidates = candidates_for(&[ + server("a.example.com", 443, 1.0), + server("a.example.com", 443, 1.0), + server("b.example.com", 27018, 2.0), + ]); + assert_eq!( + candidates + .iter() + .filter(|candidate| candidate.host == "a.example.com") + .count(), + 3 + ); + assert_eq!( + candidates + .iter() + .filter(|candidate| candidate.host == "b.example.com") + .count(), + 3 + ); + } + + #[test] + fn url_is_the_websocket_endpoint() { + let candidate = CmCandidate { + host: "cmp2-sha1.steamchina.com".to_string(), + port: 27018, + weighted_load: 0.0, + }; + assert_eq!(candidate.url(), "wss://cmp2-sha1.steamchina.com:27018/cmsocket/"); + } + + #[test] + fn unreachable_candidates_are_reported_as_none() { + // 192.0.2.0/24 is TEST-NET-1: guaranteed not to answer. + let mut candidates = candidates_for(&[server("192.0.2.1", 443, 1.0)]); + candidates.truncate(1); + assert_eq!(pick(&candidates), None); + } + + #[test] + fn picking_again_quickly_marks_the_previous_endpoint_bad() { + let first = CmCandidate { + host: "192.0.2.10".to_string(), + port: 443, + weighted_load: 0.0, + }; + let second = CmCandidate { + host: "192.0.2.11".to_string(), + port: 443, + weighted_load: 0.0, + }; + note_pick(&first.key()); + note_pick(&second.key()); + assert!(is_bad(&first.key())); + assert!(!is_bad(&second.key())); + } +} diff --git a/app/src/main/cpp/wn-steam-client/rust/src/cm_server_list.rs b/app/src/main/cpp/wn-steam-client/rust/src/cm_server_list.rs index b4f0a495c..f3f8473c4 100644 --- a/app/src/main/cpp/wn-steam-client/rust/src/cm_server_list.rs +++ b/app/src/main/cpp/wn-steam-client/rust/src/cm_server_list.rs @@ -131,23 +131,32 @@ fn promote_expired(entries: &mut [Entry], bad_memory: Duration) { } } +/// Connection managers used when the directory request fails. +/// +/// These must be real CM pool members (`cmp*`), not the `ext*` websocket edge +/// hosts: the `ext*` entries only relay a client towards a CM and are not +/// usable as the session endpoint, so logging on through them never completes. +/// The ports are the ones the directory advertises for these datacenters; the +/// selector additionally probes 443/27018/27019 for every host. pub fn hardcoded_fallback_servers() -> Vec { - const FALLBACK: &[(&str, &str)] = &[ - ("ext1-sea1.steamserver.net", "sea1"), - ("ext2-sea1.steamserver.net", "sea1"), - ("ext1-iad1.steamserver.net", "iad1"), - ("ext2-iad1.steamserver.net", "iad1"), - ("ext1-fra1.steamserver.net", "fra1"), - ("ext2-fra1.steamserver.net", "fra1"), - ("ext1-lax1.steamserver.net", "lax1"), - ("ext1-sgp1.steamserver.net", "sgp1"), + const FALLBACK: &[(&str, &str, u16)] = &[ + ("cmp1-hkg1.steamserver.net", "hkg1", 27023), + ("cmp2-hkg1.steamserver.net", "hkg1", 27023), + ("cmp3-hkg1.steamserver.net", "hkg1", 27020), + ("cmp1-hkg1.steamserver.net", "hkg1", 443), + ("cmp2-hkg1.steamserver.net", "hkg1", 443), + ("cmp3-hkg1.steamserver.net", "hkg1", 443), + ("cmp1-sgp1.steamserver.net", "sgp1", 27023), + ("cmp2-sgp1.steamserver.net", "sgp1", 27020), + ("cmp1-sha1.steamchina.com", "sha1", 443), + ("cmp2-sha1.steamchina.com", "sha1", 443), ]; FALLBACK .iter() - .map(|(host, dc)| CmServer { - endpoint: format!("{host}:443"), + .map(|(host, dc, port)| CmServer { + endpoint: format!("{host}:{port}"), host: (*host).to_string(), - port: 443, + port: *port, transport: CmTransport::WebSocket, realm: "steamglobal".to_string(), datacenter: (*dc).to_string(), diff --git a/app/src/main/cpp/wn-steam-client/rust/src/depot_downloader.rs b/app/src/main/cpp/wn-steam-client/rust/src/depot_downloader.rs index 3bd132a0c..b201b15bd 100644 --- a/app/src/main/cpp/wn-steam-client/rust/src/depot_downloader.rs +++ b/app/src/main/cpp/wn-steam-client/rust/src/depot_downloader.rs @@ -9,7 +9,14 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::thread; use std::time::Duration; -pub const MAX_MANIFEST_FETCH_ATTEMPTS: usize = 5; +/// Sentinel worker count that switches the writer into adaptive mode: hosts +/// are scored by measured throughput (Valve-client / GameHub style) instead of +/// being round-robined. Mirrors the "Adaptive" entry in the app's download +/// speed setting. +pub const ADAPTIVE_WORKERS_SENTINEL: u32 = 100; + +/// Worker count used when adaptive mode is selected. +pub const ADAPTIVE_DEFAULT_WORKERS: u32 = 64; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct DepotSpec { @@ -137,22 +144,88 @@ pub fn validate_resolved_download_inputs( Ok(()) } +/// Keeps every CDN server that has a reachable host (or vhost) while ordering +/// the list so that globally-reachable servers are tried first and China-only +/// servers act as a last-resort fallback. +/// +/// Previously `steam_china_only` servers were discarded outright. On networks +/// where the international `*.steamcontent.com` CDN is unreachable or +/// unstable (e.g. mainland China), that left the downloader with no usable +/// server at all, so manifest/chunk fetches failed before a single byte could +/// be downloaded. Keeping them — but deprioritising them — preserves the fast +/// path for the rest of the world while giving those networks a working path. pub fn filter_usable_cdn_servers( servers: impl IntoIterator, ) -> Vec { - servers - .into_iter() - .filter(|server| !server.steam_china_only && !server.host.is_empty()) + let mut global = Vec::new(); + let mut china = Vec::new(); + for server in servers { + // A server is usable if either `host` or `vhost` is present; the CDN + // client prefers `vhost` for the URL/SNI and falls back to `host`. + let has_host = !server.host.is_empty() || !server.vhost.is_empty(); + if !has_host { + continue; + } + if server.steam_china_only { + china.push(server); + } else { + global.push(server); + } + } + global.extend(china); + // The CM picks CDN nodes by the device's network location. On a device + // inside mainland China it hands out only the Perfect World partner CDNs + // (dl.steam.clngaa.com / st.dl.eccdnx.com / xz.pphimalayanrt.com), which + // reject manifests for non-PerfectWorld (international) accounts with + // HTTP 403 — leaving the downloader with zero working nodes. Append the + // global Valve CDN pool as a last-resort fallback so international + // accounts on Chinese networks can still fetch manifests and chunks. + global.extend(global_cdn_fallback_servers()); + global +} + +/// Global Valve CDN hosts that serve any depot regardless of account region. +/// +/// These are Steam's Asia-Pacific edge nodes (`dist*-hkg1` / `dist*-sgp1`). +/// Unlike the `cache*.steamcontent.com` pool — which is behind DNS pollution +/// and TLS SNI blocking on mainland-Chinese networks — the Hong Kong and +/// Singapore edges are served from Akamai addresses that are directly +/// reachable from China while still honouring international Steam accounts +/// (a 401 "needs a valid manifest request code" instead of the 403 that the +/// Perfect World partner CDNs return for international accounts). +fn global_cdn_fallback_servers() -> Vec { + const GLOBAL_CDN_HOSTS: &[&str] = &[ + "dist1-hkg1.steamcontent.com", + "dist2-hkg1.steamcontent.com", + "dist3-hkg1.steamcontent.com", + "dist1-sgp1.steamcontent.com", + "dist2-sgp1.steamcontent.com", + "dist3-sgp1.steamcontent.com", + // Last-resort global edges in case the Asia-Pacific pool is unreachable. + "cache1.steamcontent.com", + "cache2.steamcontent.com", + ]; + GLOBAL_CDN_HOSTS + .iter() + .map(|host| CContentServerDirectoryServerInfo { + server_type: "CDN".to_string(), + host: (*host).to_string(), + vhost: (*host).to_string(), + https_support: "mandatory".to_string(), + ..Default::default() + }) .collect() } -pub fn manifest_retry_server_indices(server_count: usize, attempts: usize) -> Vec { +pub fn manifest_retry_server_indices(server_count: usize) -> Vec { if server_count == 0 { return Vec::new(); } - (0..attempts) - .map(|attempt| attempt % server_count) - .collect() + // Try each server in order exactly once. When the list mixes global and + // China-only fallback servers, a fixed round-robin window (e.g. 5 attempts) + // could exhaust itself on the global servers and never reach the fallback + // that actually works on a restricted network. + (0..server_count).collect() } pub fn retry_backoff_millis(attempt: u32) -> u64 { @@ -163,6 +236,10 @@ pub fn retry_backoff_millis(attempt: u32) -> u64 { } } +/// Returns the CDN auth token for a specific depot + host. Region-restricted +/// CDN pools require a per-host token to serve manifests/chunks. +pub type CdnAuthTokenRefresher<'a> = &'a (dyn Fn(u32, &str) -> Option + Sync); + pub fn fetch_manifest_with_retry( cdn: &CdnClient, servers: &[CContentServerDirectoryServerInfo], @@ -171,6 +248,28 @@ pub fn fetch_manifest_with_retry( request_code: u64, cdn_auth_token: &str, timeout: Duration, +) -> CdnManifestResult { + fetch_manifest_with_retry_and_token( + cdn, + servers, + depot_id, + manifest_id, + request_code, + cdn_auth_token, + None, + timeout, + ) +} + +pub fn fetch_manifest_with_retry_and_token( + cdn: &CdnClient, + servers: &[CContentServerDirectoryServerInfo], + depot_id: u32, + manifest_id: u64, + request_code: u64, + fallback_token: &str, + token_refresher: Option>, + timeout: Duration, ) -> CdnManifestResult { if servers.is_empty() { return CdnManifestResult { @@ -178,30 +277,95 @@ pub fn fetch_manifest_with_retry( ..Default::default() }; } + // Sweep every server once (global first, then China-only fallback) so a + // restricted network can still reach a working CDN node. A fixed retry + // window would otherwise burn itself on the first few global servers and + // never touch the fallback list. let mut last = CdnManifestResult::default(); + let mut seen_errors: Vec = Vec::new(); for (attempt, server_idx) in - manifest_retry_server_indices(servers.len(), MAX_MANIFEST_FETCH_ATTEMPTS) + manifest_retry_server_indices(servers.len()) .into_iter() .enumerate() { if attempt > 0 { thread::sleep(Duration::from_millis(retry_backoff_millis(attempt as u32))); } + let server = &servers[server_idx]; + // A region-restricted host rejects tokenless requests with 403; fetch a + // per-host token on demand when the caller provided a provider. + let token = token_refresher + .and_then(|refresh| refresh(depot_id, preferred_cdn_host(server))) + .unwrap_or_else(|| fallback_token.to_string()); last = cdn.fetch_manifest( - &servers[server_idx], + server, depot_id, manifest_id, request_code, - cdn_auth_token, + &token, timeout, ); if last.ok() { return last; } + // Record one line per distinct failure so a remote-diagnosis log shows + // which CDN hosts were tried and how each rejected the request. + let summary = format!( + "manifest depot {depot_id} via {}:{} -> {}", + preferred_cdn_host(server), + last.http_status, + last.error + ); + if !seen_errors.contains(&summary) { + seen_errors.push(summary.clone()); + log_line(&summary); + } + } + if !seen_errors.is_empty() { + log_line(&format!( + "manifest depot {depot_id} failed on all {} servers; last: {}", + servers.len(), + last.error + )); } last } +fn preferred_cdn_host(server: &CContentServerDirectoryServerInfo) -> &str { + if !server.vhost.is_empty() { + &server.vhost + } else { + &server.host + } +} + +// One-line Android log emit; kept dependency-free so the downloader stays +// testable off-device (no android linkage required for unit tests). +pub fn log_line(message: &str) { + #[cfg(target_os = "android")] + { + use std::ffi::CString; + let tag = CString::new("WnDepotDownloader").unwrap_or_default(); + let msg = CString::new(message).unwrap_or_default(); + unsafe { + // __android_log_print(ANDROID_LOG_INFO, tag, fmt, ...) + extern "C" { + fn __android_log_print( + prio: i32, + tag: *const std::os::raw::c_char, + fmt: *const std::os::raw::c_char, + ... + ) -> i32; + } + let _ = __android_log_print(4, tag.as_ptr(), b"%s\0".as_ptr() as *const _, msg.as_ptr()); + } + } + #[cfg(not(target_os = "android"))] + { + eprintln!("{message}"); + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DepotResumeDecision { SkipInstalled, @@ -268,6 +432,9 @@ pub fn download_resolved_depots( None, None, None, + "", + None, + false, ) } @@ -290,6 +457,9 @@ pub fn download_resolved_depots_with_cancel( cancel, None, None, + "", + None, + false, ) } @@ -303,6 +473,9 @@ pub fn download_resolved_depots_with_cancel_progress( cancel: Option<&AtomicBool>, on_progress: Option>, code_refresher: Option>, + cdn_auth_token: &str, + token_refresher: Option>, + adaptive: bool, ) -> DepotDownloadResult { if let Err(error) = validate_resolved_download_inputs(install_dir, depots, servers) { return error; @@ -375,13 +548,14 @@ pub fn download_resolved_depots_with_cancel_progress( let refreshed_code = code_refresher .and_then(|refresh| refresh(depot.depot_id, depot.manifest_id)); let request_code = refreshed_code.unwrap_or(depot.manifest_request_code); - let mut manifest = fetch_manifest_with_retry( + let mut manifest = fetch_manifest_with_retry_and_token( &cdn, &usable_servers, depot.depot_id, depot.manifest_id, request_code, - "", + cdn_auth_token, + token_refresher, CdnClient::default_timeout(), ); if !manifest.ok() { @@ -390,13 +564,14 @@ pub fn download_resolved_depots_with_cancel_progress( .and_then(|refresh| refresh(depot.depot_id, depot.manifest_id)) .filter(|fresh| *fresh != request_code) { - manifest = fetch_manifest_with_retry( + manifest = fetch_manifest_with_retry_and_token( &cdn, &usable_servers, depot.depot_id, depot.manifest_id, fresh, - "", + cdn_auth_token, + token_refresher, CdnClient::default_timeout(), ); } @@ -452,6 +627,8 @@ pub fn download_resolved_depots_with_cancel_progress( max_workers, cancel, on_progress: Some(chunk_progress), + cdn_auth_token, + adaptive, ..Default::default() }, ); @@ -523,24 +700,75 @@ mod tests { } #[test] - fn filters_non_china_servers_with_hosts() { + fn keeps_china_servers_as_fallback_and_drops_hostless() { let servers = filter_usable_cdn_servers([ CContentServerDirectoryServerInfo { - host: "ok".into(), + host: "global1".into(), ..Default::default() }, CContentServerDirectoryServerInfo { - host: "china".into(), + host: "china1".into(), steam_china_only: true, ..Default::default() }, + CContentServerDirectoryServerInfo { + host: "global2".into(), + ..Default::default() + }, + // vhost-only servers are still reachable (CDN client falls back to vhost). + CContentServerDirectoryServerInfo { + host: String::new(), + vhost: "cdn-vhost.example".into(), + ..Default::default() + }, + // No host and no vhost: unusable, must be dropped. CContentServerDirectoryServerInfo { host: String::new(), ..Default::default() }, + CContentServerDirectoryServerInfo { + host: "china2".into(), + steam_china_only: true, + ..Default::default() + }, + ]); + // Global servers first, then China-only, then the global Valve CDN + // fallback pool; hostless entries dropped. + assert_eq!(servers.len(), 5 + 8); + assert_eq!(servers[0].host, "global1"); + assert_eq!(servers[1].host, "global2"); + assert_eq!(servers[2].vhost, "cdn-vhost.example"); + assert_eq!(servers[3].host, "china1"); + assert!(servers[3].steam_china_only); + assert_eq!(servers[4].host, "china2"); + assert!(servers[4].steam_china_only); + // The tail is the Valve global CDN fallback pool (Asia-Pacific edges first). + assert_eq!(servers[5].host, "dist1-hkg1.steamcontent.com"); + assert!(servers.last().unwrap().host.ends_with("cache2.steamcontent.com")); + } + + #[test] + fn appends_global_fallback_when_only_partner_cdns_offered() { + // Reproduces the field failure: a CM on a mainland-Chinese network + // hands out only Perfect World partner CDNs (china_only=false!), which + // 403 international accounts. The global pool must be appended. + let servers = filter_usable_cdn_servers([ + CContentServerDirectoryServerInfo { + host: "st.dl.eccdnx.com".into(), + vhost: "st.dl.eccdnx.com".into(), + ..Default::default() + }, + CContentServerDirectoryServerInfo { + host: "dl.steam.clngaa.com".into(), + vhost: "dl.steam.clngaa.com".into(), + ..Default::default() + }, ]); - assert_eq!(servers.len(), 1); - assert_eq!(servers[0].host, "ok"); + assert_eq!(servers.len(), 2 + 8); + assert_eq!(servers[0].host, "st.dl.eccdnx.com"); + assert_eq!(servers[1].host, "dl.steam.clngaa.com"); + assert_eq!(servers[2].host, "dist1-hkg1.steamcontent.com"); + assert_eq!(servers[2].port(), 443); } #[test] @@ -580,9 +808,12 @@ mod tests { } #[test] - fn retry_rotation_and_progress_mapping_match_cpp() { - assert_eq!(manifest_retry_server_indices(3, 5), [0, 1, 2, 0, 1]); - assert_eq!(manifest_retry_server_indices(0, 5), Vec::::new()); + fn retry_sweep_and_progress_mapping() { + // The manifest retry sweeps each server once (in order), so fallback + // servers at the tail are still reached. + assert_eq!(manifest_retry_server_indices(3), [0, 1, 2]); + assert_eq!(manifest_retry_server_indices(1), [0]); + assert_eq!(manifest_retry_server_indices(0), Vec::::new()); assert_eq!(retry_backoff_millis(1), 300); assert_eq!(retry_backoff_millis(5), 4000); assert_eq!( diff --git a/app/src/main/cpp/wn-steam-client/rust/src/depot_writer.rs b/app/src/main/cpp/wn-steam-client/rust/src/depot_writer.rs index 16e9b490d..6e2035114 100644 --- a/app/src/main/cpp/wn-steam-client/rust/src/depot_writer.rs +++ b/app/src/main/cpp/wn-steam-client/rust/src/depot_writer.rs @@ -1,9 +1,11 @@ use crate::cdn_client::{CdnClient, CdnConnection}; use crate::content_manifest::{ChunkData, ContentManifest}; use crate::depot_chunk::process_depot_chunk; +use crate::host_scheduler::HostScheduler; use crate::pb::ccontentserverdirectory::CContentServerDirectoryServerInfo; use std::fs::{self, File, OpenOptions}; use std::io::{Read, Seek, SeekFrom, Write}; +use std::net::{TcpStream, ToSocketAddrs}; use std::path::{Component, Path}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -13,7 +15,6 @@ use std::time::{Duration, Instant}; pub const DEPOT_FILE_FLAG_EXECUTABLE: u32 = 32; pub const DEPOT_FILE_FLAG_DIRECTORY: u32 = 64; pub const DEPOT_FILE_FLAG_SYMLINK: u32 = 512; -pub const MAX_CHUNK_ATTEMPTS: u32 = 5; const RESIZE_BUFFER_BYTES: usize = 256 * 1024; const ERRNO_EPERM: i32 = 1; const ERRNO_EACCES: i32 = 13; @@ -61,6 +62,9 @@ pub struct DepotWriteOptions<'a> { pub max_workers: u32, pub cancel: Option<&'a AtomicBool>, pub on_progress: Option>, + /// Score hosts by measured throughput and steer chunks at the fast ones + /// instead of round-robining over the whole list. + pub adaptive: bool, } impl Default for DepotWriteOptions<'_> { @@ -71,6 +75,7 @@ impl Default for DepotWriteOptions<'_> { max_workers: 8, cancel: None, on_progress: None, + adaptive: false, } } } @@ -108,18 +113,18 @@ pub fn retry_backoff_millis(attempt: u32) -> u64 { pub fn chunk_attempt_server_indices( start_server_index: usize, server_count: usize, - attempts: u32, ) -> Vec { - if server_count == 0 || attempts == 0 { + if server_count == 0 { return Vec::new(); } - let mut out = Vec::with_capacity(attempts as usize); - let mut server_index = start_server_index % server_count; - for attempt in 0..attempts { - if attempt > 0 && server_count > 1 { - server_index = (server_index + 1) % server_count; - } - out.push(server_index); + // Sweep every server exactly once, starting from `start_server_index` and + // wrapping around. This guarantees the China-only fallback servers (kept at + // the tail of the usable-server list) are actually reached when the global + // CDN nodes fail fast (connection reset / timeout on restricted networks). + let mut out = Vec::with_capacity(server_count); + let start = start_server_index % server_count; + for offset in 0..server_count { + out.push((start + offset) % server_count); } out } @@ -309,6 +314,7 @@ pub fn write_depot_sequential( options.cdn_auth_token, job_index % servers.len(), options.timeout, + None, ) { Ok(bytes) => { bytes_written += bytes; @@ -344,6 +350,71 @@ pub fn write_depot_sequential( } } +/// How long a pre-flight probe waits for a TCP connection. +const PROBE_CONNECT_TIMEOUT: Duration = Duration::from_secs(3); +/// How long an unreachable host stays parked after the pre-flight probe. +const PROBE_QUARANTINE: Duration = Duration::from_secs(180); +/// Parallel probe threads (one per group of hosts). +const PROBE_THREADS: usize = 8; + +/// Cheap pre-flight reachability probe over the usable content servers. +/// +/// A mainland-China CM hands out a list that mixes reachable Asia-Pacific edges +/// with hosts that are DNS-poisoned or TCP-blackholed. Without this probe the +/// first wave of workers spends its connect timeout on those hosts before the +/// scheduler can learn they are dead, which cost roughly half of the attainable +/// throughput during the first 30 s of a download. Probe them once up front and +/// park the unreachable ones. +fn probe_content_servers(scheduler: &HostScheduler, servers: &[CContentServerDirectoryServerInfo]) { + if servers.is_empty() { + return; + } + thread::scope(|scope| { + for (group_index, group) in servers.chunks(PROBE_THREADS.max(1)).enumerate() { + let base = group_index * PROBE_THREADS.max(1); + let scheduler = scheduler; + scope.spawn(move || { + for (offset, server) in group.iter().enumerate() { + if !content_server_reachable(server) { + scheduler.quarantine(base + offset, PROBE_QUARANTINE); + crate::depot_downloader::log_line(&format!( + "cdn host probe: host#{}({}) unreachable, parked for {}s", + base + offset, + content_server_label(server), + PROBE_QUARANTINE.as_secs() + )); + } + } + }); + } + }); +} + +fn content_server_label(server: &CContentServerDirectoryServerInfo) -> &str { + if !server.host.is_empty() { + &server.host + } else { + &server.vhost + } +} + +fn content_server_reachable(server: &CContentServerDirectoryServerInfo) -> bool { + let host = content_server_label(server); + if host.is_empty() { + return false; + } + let endpoint = format!("{}:{}", host, server.port()); + let Ok(addrs) = endpoint.to_socket_addrs() else { + return false; + }; + for addr in addrs { + if TcpStream::connect_timeout(&addr, PROBE_CONNECT_TIMEOUT).is_ok() { + return true; + } + } + false +} + fn write_depot_parallel( manifest: &ContentManifest, depot_key: &[u8], @@ -365,6 +436,15 @@ fn write_depot_parallel( let raw = c as *const AtomicBool; unsafe { raw.as_ref() }.unwrap() }); + // Adaptive scheduling (Valve-client/GameHub style): measure each content + // server's throughput and steer chunks at the fast ones, cooling down the + // hosts that fail instead of round-robining into blackholed edges. + let scheduler: Option> = options + .adaptive + .then(|| Arc::new(HostScheduler::new(servers.len()))); + if let Some(scheduler) = scheduler.as_ref() { + probe_content_servers(scheduler, servers); + } // SAFETY: we only spawn scoped threads so all `'a` references outlive joins. let scope_result = thread::scope(|scope| -> DepotWriteResult { let mut handles = Vec::with_capacity(worker_count); @@ -379,6 +459,7 @@ fn write_depot_parallel( let target_dir_ref = target_dir; let cdn_ref = cdn; let servers_ref = servers; + let scheduler_ref = scheduler.clone(); let progress = options.on_progress; handles.push(scope.spawn(move || { let mut conn = cdn_ref.open_connection(); @@ -427,7 +508,13 @@ fn write_depot_parallel( conn = cdn_ref.open_connection(); slow_chunks = 0; } - let start_server = (idx + worker_server_bias) % servers_ref.len(); + let start_server = match scheduler_ref + .as_ref() + .and_then(|scheduler| scheduler.pick()) + { + Some(host) => host, + None => (idx + worker_server_bias) % servers_ref.len(), + }; let started = Instant::now(); match fetch_process_write_chunk( cdn_ref, @@ -441,6 +528,7 @@ fn write_depot_parallel( &cdn_auth_token, start_server, timeout, + scheduler_ref.as_deref(), ) { Ok(bytes) => { if started.elapsed() @@ -484,6 +572,12 @@ fn write_depot_parallel( return scope_result; } + if let Some(scheduler) = scheduler.as_ref() { + for row in scheduler.summary() { + crate::depot_downloader::log_line(&format!("cdn host score {row}")); + } + } + for file in &manifest.files { if options .cancel @@ -564,6 +658,7 @@ pub fn fetch_process_write_chunk( cdn_auth_token: &str, start_server_index: usize, timeout: Duration, + scheduler: Option<&HostScheduler>, ) -> Result { if servers.is_empty() { return Err("write_depot: no CDN servers".to_string()); @@ -578,17 +673,26 @@ pub fn fetch_process_write_chunk( .ok_or_else(|| "write_depot: bad chunk index".to_string())?; let path = join_target_path(target_dir, &file.filename); let mut last_error = String::new(); - for (attempt, server_idx) in - chunk_attempt_server_indices(start_server_index, servers.len(), MAX_CHUNK_ATTEMPTS) - .into_iter() - .enumerate() - { + let attempt_indices = chunk_attempt_server_indices(start_server_index, servers.len()); + let mut scheduled_tried: Vec = Vec::new(); + for (attempt, fallback_server_idx) in attempt_indices.into_iter().enumerate() { if attempt > 0 { thread::sleep(Duration::from_millis(retry_backoff_millis(attempt as u32))); if let Some(connection) = conn.as_deref_mut() { *connection = cdn.open_connection(); } } + // With a scheduler the retry walks to the next best *measured* host + // instead of the next index in the list. + let server_idx = match scheduler { + Some(scheduler) => match scheduler.pick_excluding(&scheduled_tried) { + Some(host) => host, + None => break, + }, + None => fallback_server_idx, + }; + scheduled_tried.push(server_idx); + let started = Instant::now(); let fetched = match conn.as_deref_mut() { Some(connection) => cdn.fetch_chunk_with_connection( connection, @@ -606,18 +710,27 @@ pub fn fetch_process_write_chunk( timeout, ), }; + let elapsed = started.elapsed(); if !fetched.ok() { + if let Some(scheduler) = scheduler { + scheduler.record_failure(server_idx); + } last_error = fetched.error; continue; } + if let Some(scheduler) = scheduler { + scheduler.record_success(server_idx, fetched.data.len() as u64, elapsed); + } match process_and_write_chunk(&path, chunk, &fetched.data, depot_key) { Ok(bytes) => return Ok(bytes), Err(error) => last_error = error, } } Err(format!( - "write_depot: chunk for '{}' failed after {} attempts: {}", - file.filename, MAX_CHUNK_ATTEMPTS, last_error + "write_depot: chunk for '{}' failed after trying all {} servers: {}", + file.filename, + servers.len(), + last_error )) } @@ -925,16 +1038,16 @@ mod tests { assert_eq!(retry_backoff_millis(1), 300); assert_eq!(retry_backoff_millis(2), 600); assert_eq!(retry_backoff_millis(5), 4000); - assert_eq!(MAX_CHUNK_ATTEMPTS, 5); assert_eq!(SLOW_CHUNK_ROTATE_THRESHOLD_SECS, 8); } #[test] - fn chunk_retry_rotates_across_servers_like_cpp_worker() { - assert_eq!(chunk_attempt_server_indices(0, 3, 5), [0, 1, 2, 0, 1]); - assert_eq!(chunk_attempt_server_indices(2, 3, 5), [2, 0, 1, 2, 0]); - assert_eq!(chunk_attempt_server_indices(0, 1, 5), [0, 0, 0, 0, 0]); - assert!(chunk_attempt_server_indices(0, 0, 5).is_empty()); + fn chunk_retry_sweeps_every_server_starting_from_bias() { + assert_eq!(chunk_attempt_server_indices(0, 3), [0, 1, 2]); + assert_eq!(chunk_attempt_server_indices(2, 3), [2, 0, 1]); + assert_eq!(chunk_attempt_server_indices(1, 1), [0]); + assert_eq!(chunk_attempt_server_indices(5, 3), [2, 0, 1]); + assert!(chunk_attempt_server_indices(0, 0).is_empty()); assert!(!should_rotate_after_slow_chunks(2, 3)); assert!(should_rotate_after_slow_chunks(3, 3)); assert!(!should_rotate_after_slow_chunks(3, 1)); diff --git a/app/src/main/cpp/wn-steam-client/rust/src/host_scheduler.rs b/app/src/main/cpp/wn-steam-client/rust/src/host_scheduler.rs new file mode 100644 index 000000000..0fec624b4 --- /dev/null +++ b/app/src/main/cpp/wn-steam-client/rust/src/host_scheduler.rs @@ -0,0 +1,402 @@ +//! Adaptive CDN host scheduling. +//! +//! Valve's own client and other Steam downloaders do not spray chunks evenly +//! over every content server the CM handed out. They create one download +//! interface per source, measure each source's throughput, and keep steering +//! work at the sources that are actually fast while backing off from the ones +//! that fail. +//! +//! WinNative previously round-robined chunks across the whole usable list. On a +//! mainland-China network that list mixes reachable partner CDNs with +//! international edges that are blackholed, so a large share of workers sat in +//! connect timeouts and effective throughput collapsed. This scheduler keeps a +//! running throughput estimate per host, places failing hosts into a cooldown +//! that doubles on repeated failures, and occasionally samples the other hosts +//! so a recovering node is picked up again. + +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +/// Cooldown applied after the nth consecutive failure (clamped to the last). +const DEFAULT_FAILURE_COOLDOWNS: &[Duration] = &[ + Duration::from_secs(3), + Duration::from_secs(15), + Duration::from_secs(60), + Duration::from_secs(300), +]; + +/// EWMA weight for the newest throughput sample. +const SCORE_NEW_SAMPLE_WEIGHT: f64 = 0.35; + +/// Every Nth pick samples a host other than the current best. +const EXPLORATION_PERIOD: usize = 8; + +/// Throughput assumed for a host that has not produced a sample yet. Low +/// enough that measured hosts keep most of the workers, high enough that an +/// unmeasured host still gets probed. +const UNMEASURED_HOST_SCORE_BPS: f64 = 1_000_000.0; + +#[derive(Clone, Copy, Debug, Default)] +struct HostEntry { + score_bytes_per_sec: f64, + has_sample: bool, + successes: u32, + failures: u32, + consecutive_failures: u32, + cooldown_until: Option, + last_picked_at: Option, + in_flight: u32, +} + +#[derive(Debug)] +struct Inner { + entries: Vec, + pick_count: usize, + exploration_cursor: usize, +} + +pub struct HostScheduler { + inner: Mutex, + failure_cooldowns: Vec, +} + +impl HostScheduler { + pub fn new(host_count: usize) -> Self { + Self::with_failure_cooldowns(host_count, DEFAULT_FAILURE_COOLDOWNS.to_vec()) + } + + pub fn with_failure_cooldowns(host_count: usize, cooldowns: Vec) -> Self { + Self { + inner: Mutex::new(Inner { + entries: vec![HostEntry::default(); host_count], + pick_count: 0, + exploration_cursor: 0, + }), + failure_cooldowns: if cooldowns.is_empty() { + DEFAULT_FAILURE_COOLDOWNS.to_vec() + } else { + cooldowns + }, + } + } + + pub fn host_count(&self) -> usize { + self.inner.lock().expect("host scheduler poisoned").entries.len() + } + + /// Chooses the host a worker should fetch its next chunk from. + /// + /// Hosts in cooldown are skipped. Among the rest the highest estimated + /// throughput wins, except for a periodic exploration pick that samples the + /// least recently used host so a host that recovered is retried. + pub fn pick(&self) -> Option { + self.pick_excluding(&[]) + } + + /// Like [`Self::pick`] but never returns an index in `exclude`, which the + /// chunk retry path uses to walk through distinct hosts on repeated + /// failures. + pub fn pick_excluding(&self, exclude: &[usize]) -> Option { + let mut inner = self.inner.lock().expect("host scheduler poisoned"); + let now = Instant::now(); + let count = inner.entries.len(); + if count == 0 { + return None; + } + + inner.pick_count += 1; + let explore = inner.pick_count % EXPLORATION_PERIOD == 0; + + let usable = |entry: &HostEntry, idx: usize| -> bool { + !exclude.contains(&idx) + && !entry.cooldown_until.is_some_and(|until| until > now) + }; + + let chosen = if explore { + // Round-robin over usable hosts by last_picked_at, oldest first. + let mut candidates: Vec = (0..count) + .filter(|idx| usable(&inner.entries[*idx], *idx)) + .collect(); + if candidates.is_empty() { + return None; + } + candidates.sort_by_key(|idx| inner.entries[*idx].last_picked_at); + let idx = candidates[inner.exploration_cursor % candidates.len()]; + inner.exploration_cursor = inner.exploration_cursor.wrapping_add(1); + idx + } else { + // Allocate concurrency in proportion to measured throughput: divide + // each host's score by the number of transfers already in flight on + // it. A host that is twice as fast therefore holds roughly twice as + // many workers, which is what keeps a 4-host pool from starving when + // one host is far quicker than the others. + let mut best: Option<(usize, f64)> = None; + for idx in 0..count { + let entry = &inner.entries[idx]; + if !usable(entry, idx) { + continue; + } + let base = if entry.has_sample { + entry.score_bytes_per_sec + } else { + UNMEASURED_HOST_SCORE_BPS + }; + let marginal = base / (entry.in_flight as f64 + 1.0); + if best.is_none_or(|(_, best_marginal)| marginal > best_marginal) { + best = Some((idx, marginal)); + } + } + best.map(|(idx, _)| idx)? + }; + + let entry = &mut inner.entries[chosen]; + entry.last_picked_at = Some(now); + entry.in_flight = entry.in_flight.saturating_add(1); + Some(chosen) + } + + /// Records a successful transfer so the host's throughput estimate improves. + pub fn record_success(&self, host: usize, bytes: u64, elapsed: Duration) { + let mut inner = self.inner.lock().expect("host scheduler poisoned"); + let Some(entry) = inner.entries.get_mut(host) else { + return; + }; + entry.in_flight = entry.in_flight.saturating_sub(1); + entry.successes = entry.successes.saturating_add(1); + entry.consecutive_failures = 0; + entry.cooldown_until = None; + + let secs = elapsed.as_secs_f64(); + if secs <= 0.0 || bytes == 0 { + return; + } + let sample = bytes as f64 / secs; + entry.score_bytes_per_sec = if entry.has_sample { + entry.score_bytes_per_sec * (1.0 - SCORE_NEW_SAMPLE_WEIGHT) + + sample * SCORE_NEW_SAMPLE_WEIGHT + } else { + sample + }; + entry.has_sample = true; + } + + /// Records a failed transfer and places the host in an escalating cooldown. + pub fn record_failure(&self, host: usize) { + let mut inner = self.inner.lock().expect("host scheduler poisoned"); + let Some(entry) = inner.entries.get_mut(host) else { + return; + }; + entry.in_flight = entry.in_flight.saturating_sub(1); + entry.failures = entry.failures.saturating_add(1); + entry.consecutive_failures = entry.consecutive_failures.saturating_add(1); + + let step = (entry.consecutive_failures as usize - 1).min(self.failure_cooldowns.len() - 1); + entry.cooldown_until = Some(Instant::now() + self.failure_cooldowns[step]); + // Halve the estimate so a host that starts failing loses its lead even + // before the cooldown is consulted. + entry.score_bytes_per_sec *= 0.5; + } + + /// Immediately parks a host for `duration`. Used by the pre-flight reachability + /// probe so a blackholed host never receives a single chunk request during + /// the fragile start of a download. + pub fn quarantine(&self, host: usize, duration: Duration) { + let mut inner = self.inner.lock().expect("host scheduler poisoned"); + let Some(entry) = inner.entries.get_mut(host) else { + return; + }; + entry.failures = entry.failures.saturating_add(1); + entry.consecutive_failures = entry.consecutive_failures.saturating_add(1); + entry.score_bytes_per_sec = 0.0; + // has_sample stays false so the host is not treated as a measured zero: + // it is re-probed once the quarantine expires. + entry.has_sample = false; + entry.cooldown_until = Some(Instant::now() + duration); + } + + /// Estimated throughput in bytes/sec, or None when the host never succeeded. + pub fn score_of(&self, host: usize) -> Option { + let inner = self.inner.lock().expect("host scheduler poisoned"); + let entry = inner.entries.get(host)?; + entry.has_sample.then_some(entry.score_bytes_per_sec) + } + + /// One-line summary of the scheduler state, best host first. + pub fn summary(&self) -> Vec { + let inner = self.inner.lock().expect("host scheduler poisoned"); + let mut rows: Vec<(usize, f64, u32, u32)> = inner + .entries + .iter() + .enumerate() + .map(|(idx, entry)| { + ( + idx, + if entry.has_sample { + entry.score_bytes_per_sec + } else { + -1.0 + }, + entry.successes, + entry.failures, + ) + }) + .collect(); + rows.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + rows.into_iter() + .map(|(idx, score, successes, failures)| { + let mbps = if score >= 0.0 { score * 8.0 / 1_000_000.0 } else { 0.0 }; + format!( + "host#{idx}: {mbps:.1} Mbps (ok={successes} fail={failures})" + ) + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fast_seconds(ms: u64) -> Duration { + Duration::from_millis(ms) + } + + #[test] + fn prefers_the_host_with_the_highest_measured_throughput() { + let scheduler = HostScheduler::new(3); + // Host 0 is slow, host 1 is fast. + scheduler.record_success(0, 1_000_000, fast_seconds(100)); // 10 MB/s + scheduler.record_success(1, 1_000_000, fast_seconds(20)); // 50 MB/s + scheduler.record_success(2, 1_000_000, fast_seconds(80)); // 12.5 MB/s + + let mut picks = Vec::new(); + for _ in 0..6 { + picks.push(scheduler.pick().unwrap()); + } + // Most picks must target the fastest host. + let fast_picks = picks.iter().filter(|idx| **idx == 1).count(); + assert!(fast_picks >= 4, "picks={picks:?}"); + } + + #[test] + fn measured_host_leads_but_unmeasured_hosts_still_get_probed() { + let scheduler = HostScheduler::new(2); + scheduler.record_success(0, 1_000_000, fast_seconds(10)); // ~100 MB/s + + // The measured host wins the greedy picks. + assert_eq!(scheduler.pick(), Some(0)); + + // Exploration still reaches the unmeasured host within one period. + let mut saw_unmeasured = false; + for _ in 0..EXPLORATION_PERIOD { + if scheduler.pick() == Some(1) { + saw_unmeasured = true; + } + } + assert!(saw_unmeasured, "exploration must probe the unmeasured host"); + } + + #[test] + fn concurrency_is_allocated_in_proportion_to_measured_throughput() { + let scheduler = HostScheduler::new(2); + // Host 0 is 4x faster than host 1. + scheduler.record_success(0, 4_000_000, fast_seconds(100)); + scheduler.record_success(1, 1_000_000, fast_seconds(100)); + + // Do not record outcomes, so in_flight accumulates exactly like a wave + // of workers asking for work. + let mut picks = [0usize; 2]; + for _ in 0..24 { + if let Some(host) = scheduler.pick() { + picks[host] += 1; + } + } + assert!( + picks[0] > picks[1] * 2, + "fast host must hold more workers: {picks:?}" + ); + } + + #[test] + fn failing_host_is_cooled_down_and_others_take_over() { + let scheduler = HostScheduler::with_failure_cooldowns( + 2, + vec![Duration::from_millis(50), Duration::from_millis(80)], + ); + scheduler.record_success(0, 1_000_000, fast_seconds(10)); // fastest + scheduler.record_success(1, 1_000_000, fast_seconds(200)); + assert_eq!(scheduler.pick(), Some(0)); + + scheduler.record_failure(0); + assert_eq!(scheduler.pick(), Some(1), "failed host must be skipped"); + + std::thread::sleep(Duration::from_millis(70)); + // Cooldown elapsed: host 0 is candidates again (its score was halved, + // so host 1 may still win; just assert it is no longer excluded). + let picked = scheduler.pick().unwrap(); + assert!(picked < 2); + } + + #[test] + fn cooldown_escalates_with_consecutive_failures() { + let scheduler = HostScheduler::with_failure_cooldowns( + 1, + vec![Duration::from_millis(20), Duration::from_millis(10_000)], + ); + scheduler.record_failure(0); + assert_eq!(scheduler.pick(), None, "single host in cooldown"); + + std::thread::sleep(Duration::from_millis(30)); + assert_eq!(scheduler.pick(), Some(0)); + + // Second consecutive failure uses the longer cooldown. + scheduler.record_failure(0); + assert_eq!(scheduler.pick(), None); + } + + #[test] + fn exclude_walks_through_alternatives() { + let scheduler = HostScheduler::new(3); + scheduler.record_success(0, 1_000_000, fast_seconds(10)); + scheduler.record_success(1, 1_000_000, fast_seconds(100)); + scheduler.record_success(2, 1_000_000, fast_seconds(1000)); + + let first = scheduler.pick().unwrap(); + assert_eq!(first, 0); + let second = scheduler.pick_excluding(&[0]).unwrap(); + assert_ne!(second, 0); + let third = scheduler.pick_excluding(&[0, second]).unwrap(); + assert_ne!(third, 0); + assert_ne!(third, second); + } + + #[test] + fn summary_reports_measured_hosts_first() { + let scheduler = HostScheduler::new(2); + scheduler.record_success(0, 125_000, fast_seconds(1000)); // 1 Mbps + scheduler.record_success(1, 1_000_000, fast_seconds(1000)); // 8 Mbps + let rows = scheduler.summary(); + assert_eq!(rows.len(), 2); + assert!(rows[0].starts_with("host#1"), "{rows:?}"); + assert!(rows[0].contains("Mbps")); + } + + #[test] + fn quarantine_parks_a_host_until_it_expires() { + let scheduler = HostScheduler::new(2); + scheduler.quarantine(0, Duration::from_millis(40)); + // Host 0 must be skipped while parked. + for _ in 0..EXPLORATION_PERIOD { + assert_eq!(scheduler.pick(), Some(1)); + } + std::thread::sleep(Duration::from_millis(50)); + assert!(scheduler.pick().is_some()); + } + + #[test] + fn empty_scheduler_returns_none() { + let scheduler = HostScheduler::new(0); + assert_eq!(scheduler.pick(), None); + assert_eq!(scheduler.host_count(), 0); + } +} diff --git a/app/src/main/cpp/wn-steam-client/rust/src/jni.rs b/app/src/main/cpp/wn-steam-client/rust/src/jni.rs index e2e470140..4c6431917 100644 --- a/app/src/main/cpp/wn-steam-client/rust/src/jni.rs +++ b/app/src/main/cpp/wn-steam-client/rust/src/jni.rs @@ -1241,11 +1241,60 @@ fn request_cdn_servers( runtime: &Arc, timeout: Duration, ) -> Option> { + // Use the cell_id the CM handed back at logon (like the desktop client / + // SteamKit does) rather than 0. A concrete cell_id makes the directory + // service return the correct regional edge pool instead of a fallback + // China-only partner list. + let cell_id = runtime.core().cell_id(); let body = request_authed_service_body(runtime, timeout, |core, job_id| { - core.build_get_cdn_servers_call(0, job_id) + core.build_get_cdn_servers_call(cell_id, job_id) })?; - crate::pb::ccontentserverdirectory::CContentServerDirectoryGetServersForSteamPipeResponse::deserialize(&body) - .map(|response| response.servers) + let servers = crate::pb::ccontentserverdirectory::CContentServerDirectoryGetServersForSteamPipeResponse::deserialize(&body) + .map(|response| response.servers)?; + // Diagnose restricted networks: log every node the CM offered, including + // its china-only flag, so a 403 from the wrong CDN pool is explainable + // from logcat alone. + for server in &servers { + crate::depot_downloader::log_line(&format!( + "CM cdn server: host={} vhost={} type={} china_only={} https={} allowed_apps={}", + server.host, + server.vhost, + server.server_type, + server.steam_china_only, + server.https_support, + server.allowed_app_ids.len(), + )); + } + Some(servers) +} + +fn request_cdn_auth_token( + runtime: &Arc, + app_id: u32, + depot_id: u32, + host_name: &str, + timeout: Duration, +) -> Option { + crate::depot_downloader::log_line(&format!( + "requesting CDN auth token for app {app_id} depot {depot_id} host {host_name}" + )); + let body = request_authed_service_body(runtime, timeout, |core, job_id| { + core.build_get_cdn_auth_token(app_id, depot_id, host_name, job_id) + })?; + let response = + crate::pb::cmsg_client_get_cdn_auth_token::CMsgClientGetCdnAuthTokenResponse::deserialize(&body)?; + if !response.token.is_empty() { + crate::depot_downloader::log_line(&format!( + "CDN auth token acquired for host {host_name} ({} chars)", + response.token.len() + )); + Some(response.token) + } else { + crate::depot_downloader::log_line(&format!( + "CDN auth token empty for host {host_name}" + )); + None + } } const ERESULT_OK: i32 = 1; @@ -1779,29 +1828,83 @@ pub extern "system" fn Java_com_winlator_cmod_feature_stores_steam_wnsteam_WnSte let ca_bundle_path = jstring_to_string(&mut env, &ca_bundle_path).unwrap_or_default(); let directory = crate::steam_directory::SteamDirectoryClient::new(ca_bundle_path); let result = directory.fetch(0, crate::steam_directory::DEFAULT_TIMEOUT); - let directory_url = if result.ok() { - result - .servers - .into_iter() - .find_map(|server| { - let url = server.websocket_url(); - (!url.is_empty()).then_some(url) - }) - .unwrap_or_default() - } else { - String::new() - }; - let url = if !directory_url.is_empty() { - directory_url + + // Collect every advertised CM, least loaded first. Reconnect attempts then + // walk the list instead of retrying the same endpoint forever: on some + // networks several CMs are blackholed, so pinning the first entry made + // logon fail in a loop even though another entry in the same response was + // reachable. + let servers = if result.ok() { + crate::depot_downloader::log_line(&format!( + "CM directory: {} websocket CMs (http {})", + result.servers.len(), + result.http_status + )); + result.servers.clone() } else { + // Log why the directory was unusable: without this the client silently + // falls back to the built-in list and logon failures look inexplicable. + crate::depot_downloader::log_line(&format!( + "CM directory failed (http {}): {}; using {} built-in CMs", + result.http_status, + if result.error.is_empty() { + "no servers" + } else { + result.error.as_str() + }, + crate::cm_server_list::hardcoded_fallback_servers().len() + )); crate::cm_server_list::hardcoded_fallback_servers() - .into_iter() - .find_map(|server| { - let url = server.websocket_url(); - (!url.is_empty()).then_some(url) - }) - .unwrap_or_default() }; + let candidates = crate::cm_selector::candidates_for(&servers); + if candidates.is_empty() { + return new_string_or_null(&mut env, ""); + } + + // Probe every advertised endpoint in parallel and connect to the closest one, + // the same way the official client pings its CM candidates. Endpoints that + // recently failed are skipped so reconnects move on instead of hammering the + // datacenter that just dropped us. + let started = std::time::Instant::now(); + let pick = crate::cm_selector::pick(&candidates); + let elapsed_ms = started.elapsed().as_millis(); + let (index, url) = match pick { + Some((index, rtt)) => { + let url = candidates[index].url(); + crate::depot_downloader::log_line(&format!( + "CM pick: {} probes in {} ms -> {} (rtt {} ms, load {:.1})", + candidates.len(), + elapsed_ms, + url, + rtt.as_millis(), + candidates[index].weighted_load + )); + (index, url) + } + None => { + // Nothing answered: report the fallback the selector chose so the + // caller still sees the endpoint the client will attempt. + let fallback = candidates + .iter() + .enumerate() + .min_by(|a, b| { + a.1.weighted_load + .partial_cmp(&b.1.weighted_load) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|(index, _)| index) + .unwrap_or(0); + let url = candidates[fallback].url(); + crate::depot_downloader::log_line(&format!( + "CM pick: no CM answered among {} candidates in {} ms, falling back to {}", + candidates.len(), + elapsed_ms, + url + )); + (fallback, url) + } + }; + let _ = index; new_string_or_null(&mut env, &url) } @@ -3305,7 +3408,13 @@ pub extern "system" fn Java_com_winlator_cmod_feature_stores_steam_wnsteam_WnSte }; let app_id = app_id.max(0) as u32; let fresh = fresh != JNI_FALSE; - let max_workers = max_workers.max(1) as u32; + let requested_workers = max_workers.max(1) as u32; + let adaptive = requested_workers >= crate::depot_downloader::ADAPTIVE_WORKERS_SENTINEL; + let max_workers = if adaptive { + crate::depot_downloader::ADAPTIVE_DEFAULT_WORKERS + } else { + requested_workers + }; handle.download_cancel.store(false, Ordering::Relaxed); let download_cancel = Arc::clone(&handle.download_cancel); @@ -3327,6 +3436,17 @@ pub extern "system" fn Java_com_winlator_cmod_feature_stores_steam_wnsteam_WnSte ); return; }; + // Region-restricted CDN pools (e.g. the Perfect World partner CDNs the + // CM hands out on mainland-Chinese networks) reject tokenless + // manifest/chunk requests with HTTP 403. Provide a per-host token + // refresher that the downloader invokes lazily; on failure it falls + // back to an empty token, which the global Valve CDN pool still honours. + let cdn_auth_token = String::new(); + let token_refresher = |depot_id: u32, host_name: &str| -> Option { + request_cdn_auth_token(&runtime, app_id, depot_id, host_name, timeout) + }; + let token_refresher_cb: crate::depot_downloader::CdnAuthTokenRefresher = + &token_refresher; let config_dir = std::path::Path::new(&install_dir).join(".DepotDownloader"); let installed_cfg = crate::depot_config::DepotConfigStore::load(&config_dir); let mut resolved = Vec::with_capacity(specs.len()); @@ -3463,6 +3583,9 @@ pub extern "system" fn Java_com_winlator_cmod_feature_stores_steam_wnsteam_WnSte Some(download_cancel.as_ref()), Some(progress_cb), Some(code_refresher_cb), + &cdn_auth_token, + Some(token_refresher_cb), + adaptive, ); dispatch_download_complete(listener, result); }); diff --git a/app/src/main/cpp/wn-steam-client/rust/src/lib.rs b/app/src/main/cpp/wn-steam-client/rust/src/lib.rs index 04df26dc0..e054960b2 100644 --- a/app/src/main/cpp/wn-steam-client/rust/src/lib.rs +++ b/app/src/main/cpp/wn-steam-client/rust/src/lib.rs @@ -14,6 +14,7 @@ pub mod chat_image; pub mod cm_bridge; pub mod cm_client; pub mod cm_runtime; +pub mod cm_selector; pub mod cm_server; pub mod cm_server_list; pub mod cmsg_protobuf_header; @@ -27,6 +28,7 @@ pub mod emsg; pub mod encrypted_channel; pub mod handshake_messages; pub mod heartbeat; +pub mod host_scheduler; pub mod jni; pub mod job_manager; pub mod key_dictionary; diff --git a/app/src/main/cpp/wn-steam-client/rust/src/pb/cmsg_client_get_cdn_auth_token.rs b/app/src/main/cpp/wn-steam-client/rust/src/pb/cmsg_client_get_cdn_auth_token.rs new file mode 100644 index 000000000..0a491ffa1 --- /dev/null +++ b/app/src/main/cpp/wn-steam-client/rust/src/pb/cmsg_client_get_cdn_auth_token.rs @@ -0,0 +1,111 @@ +use crate::proto_wire::{Reader, Writer}; + +/// CContentServerDirectory.GetCDNAuthToken#1 request (SteamKit unified +/// service): app/depot/host triple. Region-restricted CDN pools (e.g. the +/// Perfect World partner CDNs inside mainland China) reject tokenless +/// manifest/chunk requests with HTTP 403, so the downloader must fetch this. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct CMsgClientGetCdnAuthToken { + pub app_id: u32, + pub depot_id: u32, + pub host_name: String, +} + +impl CMsgClientGetCdnAuthToken { + pub fn serialize(&self) -> Vec { + let mut out = Vec::new(); + let mut w = Writer::new(&mut out); + // Field numbers per SteamKit's CContentServerDirectory_GetCDNAuthToken_Request: + // 1 = depot_id, 2 = host_name, 3 = app_id. + w.uint32_field(1, self.depot_id); + w.string_field(2, &self.host_name); + w.uint32_field(3, self.app_id); + out + } +} + +/// GetCDNAuthToken#1 response: the CDN auth token plus its expiry. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct CMsgClientGetCdnAuthTokenResponse { + pub token: String, + pub expiration_time: u32, +} + +impl CMsgClientGetCdnAuthTokenResponse { + pub fn deserialize(body: &[u8]) -> Option { + let mut reader = Reader::new(body); + let mut msg = Self::default(); + while !reader.eof() { + let Some(tag) = reader.next_tag() else { + return reader.ok().then_some(msg); + }; + match tag.field_number { + 1 => msg.token = reader.string()?, + 2 => msg.expiration_time = reader.u32()?, + _ => { + if !reader.skip(tag.wire_type) { + return None; + } + } + } + } + Some(msg) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_serializes_all_fields() { + let req = CMsgClientGetCdnAuthToken { + app_id: 2289720, + depot_id: 2289721, + host_name: "st.dl.eccdnx.com".into(), + }; + let bytes = req.serialize(); + // Round-trip via manual parse: field1 = depot_id (varint), + // field2 = host_name (string), field3 = app_id (varint). + let mut r = Reader::new(&bytes); + let mut depot = None; + let mut host = None; + let mut app = None; + while let Some(tag) = r.next_tag() { + match tag.field_number { + 1 => depot = Some(r.u32().unwrap()), + 2 => host = Some(r.string().unwrap()), + 3 => app = Some(r.u32().unwrap()), + _ => panic!("unexpected field"), + } + } + assert_eq!(depot, Some(2289721)); + assert_eq!(host.as_deref(), Some("st.dl.eccdnx.com")); + assert_eq!(app, Some(2289720)); + } + + #[test] + fn response_parses_token_fields() { + let mut body = Vec::new(); + { + let mut w = Writer::new(&mut body); + w.string_field(1, "cdn-token-abc"); + w.uint32_field(2, 1769000000); + } + let parsed = CMsgClientGetCdnAuthTokenResponse::deserialize(&body).unwrap(); + assert_eq!(parsed.token, "cdn-token-abc"); + assert_eq!(parsed.expiration_time, 1769000000); + } + + #[test] + fn response_skips_unknown_fields() { + let mut body = Vec::new(); + { + let mut w = Writer::new(&mut body); + w.uint32_field(9, 3); + w.string_field(1, "t"); + } + let parsed = CMsgClientGetCdnAuthTokenResponse::deserialize(&body).unwrap(); + assert_eq!(parsed.token, "t"); + } +} diff --git a/app/src/main/cpp/wn-steam-client/rust/src/pb/mod.rs b/app/src/main/cpp/wn-steam-client/rust/src/pb/mod.rs index 27a82e1b4..68709e5a8 100644 --- a/app/src/main/cpp/wn-steam-client/rust/src/pb/mod.rs +++ b/app/src/main/cpp/wn-steam-client/rust/src/pb/mod.rs @@ -8,6 +8,7 @@ pub mod cmsg_client_change_status; pub mod cmsg_client_friends_list; pub mod cmsg_client_games_played; pub mod cmsg_client_get_app_ownership_ticket; +pub mod cmsg_client_get_cdn_auth_token; pub mod cmsg_client_get_depot_decryption_key; pub mod cmsg_client_get_user_stats; pub mod cmsg_client_kick_playing_session; diff --git a/app/src/main/feature/settings/stores/StoresScreen.kt b/app/src/main/feature/settings/stores/StoresScreen.kt index caac4b8dc..62af0dafd 100644 --- a/app/src/main/feature/settings/stores/StoresScreen.kt +++ b/app/src/main/feature/settings/stores/StoresScreen.kt @@ -188,6 +188,11 @@ fun StoresScreen( 16 to stringResource(R.string.stores_accounts_download_speed_balanced), 24 to stringResource(R.string.stores_accounts_download_speed_standard), 32 to stringResource(R.string.stores_accounts_download_speed_performance), + 48 to stringResource(R.string.stores_accounts_download_speed_turbo), + 64 to stringResource(R.string.stores_accounts_download_speed_maximum), + // Sentinel consumed by the native downloader: score CDN hosts by + // measured throughput and steer chunks at the fast ones. + 100 to stringResource(R.string.stores_accounts_download_speed_adaptive), ) CompositionLocalProvider(LocalPaneNav provides contentNav) { diff --git a/app/src/main/feature/stores/steam/utils/PrefManager.kt b/app/src/main/feature/stores/steam/utils/PrefManager.kt index d49e023c2..1c367d5b8 100644 --- a/app/src/main/feature/stores/steam/utils/PrefManager.kt +++ b/app/src/main/feature/stores/steam/utils/PrefManager.kt @@ -3,9 +3,17 @@ import android.content.Context import android.content.SharedPreferences import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey +import java.security.KeyStore import timber.log.Timber object PrefManager { + private const val ENCRYPTED_PREFS_NAME = "PluviaPreferences_enc" + private const val PLAIN_PREFS_NAME = "PluviaPreferences_enc_fallback" + + /** Alias androidx.security uses for [MasterKey.Builder]'s default AES256_GCM key. */ + private const val MASTER_KEY_ALIAS = "_androidx_security_master_key_" + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + @Volatile private var prefs: SharedPreferences? = null @@ -40,28 +48,62 @@ object PrefManager { this.appContext = appContext val legacyPrefs = appContext.getSharedPreferences("PluviaPreferences", Context.MODE_PRIVATE) - val encryptedPrefs = + // Creating the encrypted store can fail on a device where the AndroidKeyStore + // master key no longer matches the encrypted file: wiping the app's data removes + // PluviaPreferences_enc but leaves the key behind on some ROMs, and the key is then + // permanently invalidated (KeyPermanentlyInvalidatedException). Retrying once after + // deleting both the key and the stale file recovers those installs; if encryption is + // still unavailable we fall back to plain preferences instead of refusing to start. + var encryptedPrefs: SharedPreferences? = null + var lastError: Exception? = null + for (attempt in 1..2) { try { val masterKey = MasterKey .Builder(appContext) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() - EncryptedSharedPreferences.create( - appContext, - "PluviaPreferences_enc", - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) + encryptedPrefs = + EncryptedSharedPreferences.create( + appContext, + ENCRYPTED_PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + break } catch (e: Exception) { - Timber.e(e, "EncryptedSharedPreferences init failed") - throw RuntimeException("Failed to initialize secure storage", e) + lastError = e + Timber.e(e, "EncryptedSharedPreferences init failed (attempt %d)", attempt) + if (attempt == 1) deleteInvalidatedMasterKey(appContext) } + } + + val resolvedPrefs = + encryptedPrefs + ?: run { + Timber.e(lastError, "Falling back to plain SharedPreferences") + appContext.getSharedPreferences(PLAIN_PREFS_NAME, Context.MODE_PRIVATE) + } + + prefs = resolvedPrefs + migrateLegacyPrefsIfNeeded(legacyPrefs, appContext, resolvedPrefs) + return resolvedPrefs + } + } - prefs = encryptedPrefs - migrateLegacyPrefsIfNeeded(legacyPrefs, appContext, encryptedPrefs) - return encryptedPrefs + /** Removes the encrypted file and its AndroidKeyStore entry so a fresh key can be made. */ + private fun deleteInvalidatedMasterKey(context: Context) { + try { + context.deleteSharedPreferences(ENCRYPTED_PREFS_NAME) + } catch (e: Exception) { + Timber.w(e, "Could not delete the encrypted preferences file") + } + try { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + if (keyStore.containsAlias(MASTER_KEY_ALIAS)) keyStore.deleteEntry(MASTER_KEY_ALIAS) + } catch (e: Exception) { + Timber.w(e, "Could not delete the invalidated master key") } } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 7c521bee8..3d404c811 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -297,6 +297,9 @@ 平衡 标准 性能 + 极速 + 极限 + 自适应(模拟盖世) 自动检测:%1$s 用于游戏下载的 Steam CDN 区域 退出 %1$s 登录? diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9b5e7fa19..7a26320f4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -299,6 +299,9 @@ Balanced Standard Performance + Turbo + Maximum + Adaptive (GameHub-style) Auto-detected: %1$s Steam CDN region for game downloads Sign out of %1$s?