diff --git a/Cargo.lock b/Cargo.lock index cfdfd22..a456d84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -412,6 +412,19 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -614,6 +627,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1240,6 +1259,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1550,6 +1582,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "oid-registry" version = "0.8.1" @@ -2509,6 +2547,7 @@ dependencies = [ "http", "http-body-util", "hudsucker", + "indicatif", "log", "md-5", "mockito", @@ -2673,6 +2712,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -2879,6 +2924,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -2941,6 +2996,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index 315a481..264c0f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ thiserror = "2" dashmap = "6" log = "0.4" env_logger = { version = "0.11", optional = true } +indicatif = "0.17" [target.'cfg(windows)'.dependencies] hudsucker = "0.25" diff --git a/src/capture/windows.rs b/src/capture/windows.rs index 748f095..78ae50e 100644 --- a/src/capture/windows.rs +++ b/src/capture/windows.rs @@ -5,14 +5,14 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use base64::Engine; +use http::request::Request; +use http_body_util::BodyExt; use hudsucker::certificate_authority::RcgenAuthority; use hudsucker::rcgen::{ BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, PKCS_ECDSA_P256_SHA256, }; use hudsucker::{Body, HttpContext, HttpHandler, Proxy, RequestOrResponse}; -use http::request::Request; -use http_body_util::BodyExt; use md5::{Digest, Md5}; use serde::{Deserialize, Serialize}; @@ -74,11 +74,7 @@ impl Default for CaptureConfig { impl Default for DeviceConfig { fn default() -> Self { - Self { - serial: "127.0.0.1:5555".to_string(), - wait_secs: 180, - shutdown_bluestacks: true, - } + Self { serial: "127.0.0.1:5555".to_string(), wait_secs: 180, shutdown_bluestacks: true } } } @@ -100,17 +96,13 @@ impl Default for ProxyConfig { impl Default for CaptureSettings { fn default() -> Self { - Self { - timeout_secs: 300, - replay_timeout_secs: 30, - } + Self { timeout_secs: 300, replay_timeout_secs: 30 } } } fn detect_adb() -> String { if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") { - let candidate = - format!("{local_app_data}\\Android\\Sdk\\platform-tools\\adb.exe"); + let candidate = format!("{local_app_data}\\Android\\Sdk\\platform-tools\\adb.exe"); if Path::new(&candidate).exists() { return candidate; } @@ -206,13 +198,11 @@ impl HttpHandler for CaptureHandler { && host.contains("topdrives") && path_and_query.contains("/sync") { - let body_bytes = BodyExt::collect(std::mem::replace( - request.body_mut(), - Body::from(Vec::new()), - )) - .await - .map(|collected| collected.to_bytes().to_vec()) - .unwrap_or_default(); + let body_bytes = + BodyExt::collect(std::mem::replace(request.body_mut(), Body::from(Vec::new()))) + .await + .map(|collected| collected.to_bytes().to_vec()) + .unwrap_or_default(); let template = CapturedRequest { method: request.method().to_string(), @@ -259,15 +249,13 @@ impl HttpHandler for CaptureHandler { return response; } - let bytes = match BodyExt::collect(std::mem::replace( - response.body_mut(), - Body::from(Vec::new()), - )) - .await - { - Ok(collected) => collected.to_bytes().to_vec(), - Err(_) => return response, - }; + let bytes = + match BodyExt::collect(std::mem::replace(response.body_mut(), Body::from(Vec::new()))) + .await + { + Ok(collected) => collected.to_bytes().to_vec(), + Err(_) => return response, + }; if contains_marker(&bytes, GARAGE_MARKER) { if let Ok(json) = serde_json::from_slice::(&bytes) { @@ -321,7 +309,12 @@ async fn fetch_response( let lower = name.as_str().to_lowercase(); if matches!( lower.as_str(), - "host" | "content-length" | "accept-encoding" | "connection" | "proxy-connection" | "keep-alive" + "host" + | "content-length" + | "accept-encoding" + | "connection" + | "proxy-connection" + | "keep-alive" ) { continue; } @@ -347,11 +340,10 @@ fn persist_capture( json: &serde_json::Value, template: Option<&CapturedRequest>, ) -> Result<()> { - let timestamp = time::OffsetDateTime::now_utc().format( - &time::format_description::parse_borrowed::<2>( + let timestamp = + time::OffsetDateTime::now_utc().format(&time::format_description::parse_borrowed::<2>( "[year]-[month]-[day]T[hour]-[minute]-[second]", - )?, - )?; + )?)?; let run_dir = project_root.join("captures").join(×tamp); std::fs::create_dir_all(&run_dir)?; @@ -561,19 +553,14 @@ fn inject_ca(config: &CaptureConfig, cert_pem: &str, project_root: &Path) -> Res let local_path = project_root.join("ca").join("topdrives-ca.pem"); std::fs::write(&local_path, cert_pem)?; - let presence = adb_shell( - config, - &format!("test -f {store_path} && echo present || echo absent"), - )?; + let presence = + adb_shell(config, &format!("test -f {store_path} && echo present || echo absent"))?; if presence.trim() == "present" { println!("CA already present in the system trust store"); return Ok(()); } - adb( - config, - &["push", local_path.to_string_lossy().as_ref(), staging_path], - )?; + adb(config, &["push", local_path.to_string_lossy().as_ref(), staging_path])?; adb_shell_root( config, "mkdir -p /data/local/tmp/cacerts-backup && cp /system/etc/security/cacerts/* /data/local/tmp/cacerts-backup/", @@ -619,13 +606,15 @@ fn setup_adb_reverse(config: &CaptureConfig, port: u16) -> Result<()> { Err(_) => { let route = adb_shell(config, "ip route")?; let gateway = route.lines().find_map(|line| { - line.strip_prefix("default via ") - .and_then(|rest| rest.split(' ').next()) + line.strip_prefix("default via ").and_then(|rest| rest.split(' ').next()) }); match gateway { Some(gateway) => { - println!("adb reverse failed, falling back to gateway address {gateway}:{port}"); - adb_shell(config, &format!("settings put global http_proxy {gateway}:{port}")).map(|_| ()) + println!( + "adb reverse failed, falling back to gateway address {gateway}:{port}" + ); + adb_shell(config, &format!("settings put global http_proxy {gateway}:{port}")) + .map(|_| ()) } None => Err("could not set up adb reverse and no default gateway found".into()), } @@ -638,15 +627,7 @@ fn launch_game(config: &CaptureConfig) -> Result<()> { std::thread::sleep(Duration::from_secs(2)); adb( config, - &[ - "shell", - "monkey", - "-p", - GAME_PACKAGE, - "-c", - "android.intent.category.LAUNCHER", - "1", - ], + &["shell", "monkey", "-p", GAME_PACKAGE, "-c", "android.intent.category.LAUNCHER", "1"], )?; Ok(()) } @@ -671,8 +652,9 @@ fn build_proxy( Ok(Box::pin(proxy.start())) } -type ProxyFuture = - std::pin::Pin> + Send>>; +type ProxyFuture = std::pin::Pin< + Box> + Send>, +>; async fn command_run(config: &CaptureConfig) -> Result<()> { let project_root = std::env::current_dir()?; @@ -704,15 +686,13 @@ async fn command_run(config: &CaptureConfig) -> Result<()> { output: output_path(config, &project_root), pending: std::sync::Mutex::new(None), }); - let proxy = build_proxy(listener, &cert_pem, &key_pem, CaptureHandler { state: state.clone() })?; + let proxy = + build_proxy(listener, &cert_pem, &key_pem, CaptureHandler { state: state.clone() })?; tokio::spawn(proxy); set_device_proxy(config, config.proxy.port)?; setup_adb_reverse(config, config.proxy.port)?; - println!( - "proxy active on 127.0.0.1:{}, launching {GAME_PACKAGE} ...", - config.proxy.port - ); + println!("proxy active on 127.0.0.1:{}, launching {GAME_PACKAGE} ...", config.proxy.port); launch_game(config)?; println!( @@ -770,7 +750,12 @@ async fn command_replay(config: &CaptureConfig) -> Result<()> { let lower = name.to_lowercase(); if matches!( lower.as_str(), - "host" | "content-length" | "accept-encoding" | "connection" | "proxy-connection" | "keep-alive" + "host" + | "content-length" + | "accept-encoding" + | "connection" + | "proxy-connection" + | "keep-alive" ) { continue; } @@ -799,12 +784,7 @@ async fn command_replay(config: &CaptureConfig) -> Result<()> { ); } let json: serde_json::Value = serde_json::from_slice(&bytes)?; - persist_capture( - &project_root, - &output_path(config, &project_root), - &json, - Some(&template), - )?; + persist_capture(&project_root, &output_path(config, &project_root), &json, Some(&template))?; println!("replay finished successfully"); Ok(()) } @@ -899,9 +879,7 @@ mod tests { let filename = android_ca_filename(CA_COMMON_NAME); assert_eq!(filename.len(), 10); assert!(filename.ends_with(".0")); - assert!(filename[..8] - .chars() - .all(|character| character.is_ascii_hexdigit())); + assert!(filename[..8].chars().all(|character| character.is_ascii_hexdigit())); } #[test] diff --git a/src/parsing/mod.rs b/src/parsing/mod.rs index e92dcb5..8ceecc9 100644 --- a/src/parsing/mod.rs +++ b/src/parsing/mod.rs @@ -11,6 +11,7 @@ use std::sync::Arc; use dashmap::DashMap; use futures::future::join_all; +use indicatif::{ProgressBar, ProgressStyle}; use serde_json::Value; use tokio::sync::Semaphore; use tokio::task; @@ -363,25 +364,43 @@ const CAR_TIMING_API_BASE: &str = "https://api.topdrivesrecords.com/car/"; /// Fetches timing data files for all cars using controlled concurrency. /// +/// Displays a single-line progress counter (completed/total cars) that stays +/// on the bottom line while downloads run. The counter tracks completions, +/// not cars seen, since downloads run concurrently under the semaphore. +/// /// Returns the first error encountered, or `Ok(())` if all downloads succeeded. pub async fn fetch_times(cars_db_path: &str, times_dir: &str) -> Result<(), AppError> { + let car_list = get_car_list(cars_db_path)?; + + let progress = ProgressBar::new(car_list.len() as u64); + progress.set_style( + ProgressStyle::with_template("{msg} {pos}/{len}") + .expect("static progress template is valid"), + ); + progress.set_message("Downloading track times for car"); + let semaphore = Arc::new(Semaphore::new(CONCURRENCY_LIMIT)); let mut handles = vec![]; - for car in get_car_list(cars_db_path)? { + for car in car_list { let permit = semaphore .clone() .acquire_owned() .await .map_err(|e| AppError::Network(format!("Semaphore closed: {e}")))?; let times_dir = times_dir.to_string(); + let progress = progress.clone(); handles.push(tokio::spawn(async move { let _permit = permit; // held until end of block - fetch_car(&car.rid, ×_dir).await + let result = fetch_car(&car.rid, ×_dir).await; + progress.inc(1); + result })); } let results = join_all(handles).await; + // Clear the counter before error logging so warnings don't garble the line. + progress.finish_and_clear(); let mut first_error: Option = None; for result in results { match result {