Skip to main content

steamroom_cli/commands/
shared.rs

1//! Helpers shared by every `run_*` command handler.
2//!
3//! Most of these used to live in `main.rs`. They were lifted here so the
4//! per-command modules (and, eventually, the daemon worker) can share the
5//! same KV parsing, formatting, manifest decompression, and login glue.
6
7use crate::cli::AuthOptions;
8use crate::cli::FilesArgs;
9use crate::errors::CliError;
10use std::sync::OnceLock;
11use steamroom::apps::AccessToken;
12use steamroom::cdn::CdnClient;
13use steamroom::client::LoggedIn;
14use steamroom::client::SteamClient;
15use steamroom::depot::manifest::DepotManifest;
16use steamroom::depot::*;
17use steamroom::types::key_value;
18use steamroom::types::key_value::KeyValue;
19use steamroom::types::key_value::KvValue;
20use steamroom_client::login::CredentialsLoginFlow;
21use steamroom_client::login::GuardType;
22use steamroom_client::login::LoginBuilder;
23use steamroom_client::login::LoginError;
24use tracing::info;
25use tracing::warn;
26
27/// Set once in `main`: true iff the user did not pass `--non-interactive`
28/// and stdin is a TTY. Read via [`is_interactive`].
29pub static INTERACTIVE: OnceLock<bool> = OnceLock::new();
30
31/// Initialize the interactive flag. Safe to call once from `main`.
32pub fn init_interactive(v: bool) {
33    let _ = INTERACTIVE.set(v);
34}
35
36pub fn is_interactive() -> bool {
37    INTERACTIVE.get().copied().unwrap_or(false)
38}
39
40/// Crates whose tracing output is first-party. Everything else is
41/// silenced by [`log_filter`] unless `RUST_LOG` opts it back in.
42const FIRST_PARTY_CRATES: [&str; 4] = [
43    "steamroom",
44    "steamroom_client",
45    "steamroom_ffi",
46    "steamroom_cli",
47];
48
49/// Build the tracing filter layer. When `RUST_LOG` is set it is honored
50/// verbatim; otherwise logging is restricted to the first-party crates at
51/// `level` and every other crate (h2, hyper, reqwest, tokio, ...) is
52/// silenced. The default branch is a typed [`Targets`] filter rather than
53/// a parsed directive string. Boxed so both branches share one type at
54/// the call site.
55///
56/// [`Targets`]: tracing_subscriber::filter::Targets
57pub fn log_filter<S>(
58    level: tracing_subscriber::filter::LevelFilter,
59) -> Box<dyn tracing_subscriber::Layer<S> + Send + Sync>
60where
61    S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
62{
63    use tracing_subscriber::Layer;
64    if let Ok(env) = tracing_subscriber::EnvFilter::try_from_default_env() {
65        return env.boxed();
66    }
67    let mut targets = tracing_subscriber::filter::Targets::new();
68    for krate in FIRST_PARTY_CRATES {
69        targets = targets.with_target(krate, level);
70    }
71    targets.boxed()
72}
73
74pub fn parse_app_kv(data: &[u8]) -> Result<KeyValue, CliError> {
75    // PICS KV data can be binary KV or text
76    // Binary KV starts with 0x00 tag
77    if data.first() == Some(&0x00) {
78        key_value::parse_binary_kv(data).map_err(CliError::Io)
79    } else {
80        // Try text parse, skip any leading null bytes
81        let text = String::from_utf8_lossy(data);
82        Ok(key_value::parse_text_kv(&text)?)
83    }
84}
85
86pub fn parse_package_kv(data: &[u8]) -> Result<KeyValue, CliError> {
87    // Package PICS data has a 4-byte header before the binary KV blob.
88    let kv_data = if data.len() > 4 && data[0] != 0x00 {
89        &data[4..]
90    } else {
91        data
92    };
93    parse_app_kv(kv_data)
94}
95
96pub fn kv_to_json(kv: &KeyValue) -> serde_json::Value {
97    match &kv.value {
98        KvValue::Children(map) => {
99            let obj: serde_json::Map<String, serde_json::Value> = map
100                .iter()
101                .map(|(k, v)| (k.clone(), kv_to_json(v)))
102                .collect();
103            serde_json::Value::Object(obj)
104        }
105        KvValue::String(s) => serde_json::Value::String(s.clone()),
106        KvValue::Int32(v) => serde_json::Value::Number((*v).into()),
107        KvValue::UInt64(v) => serde_json::Value::Number((*v).into()),
108        KvValue::Int64(v) => serde_json::Value::Number((*v).into()),
109        KvValue::Float32(v) => serde_json::Number::from_f64(*v as f64)
110            .map(serde_json::Value::Number)
111            .unwrap_or(serde_json::Value::Null),
112        _ => serde_json::Value::Null,
113    }
114}
115
116pub fn find_first_depot(depots_kv: &KeyValue) -> Result<DepotId, CliError> {
117    if let KvValue::Children(ref map) = depots_kv.value {
118        for key in map.keys() {
119            if let Ok(id) = key.parse::<u32>()
120                && id > 0
121            {
122                return Ok(DepotId(id));
123            }
124        }
125    }
126    Err(CliError::NoDepots)
127}
128
129pub fn find_manifest_for_depot(
130    depots_kv: &KeyValue,
131    depot_id: DepotId,
132    branch: &str,
133) -> Result<ManifestId, CliError> {
134    let depot_key = depot_id.0.to_string();
135    let depot = depots_kv
136        .get(&depot_key)
137        .ok_or(CliError::DepotNotFound(depot_id.0))?;
138
139    // Look in depots -> {depot_id} -> manifests -> {branch} -> gid
140    if let Some(manifests) = depot.get("manifests")
141        && let Some(branch_kv) = manifests.get(branch)
142    {
143        if let Some(gid) = branch_kv.get("gid")
144            && let Some(gid_str) = gid.as_str()
145        {
146            let id: u64 = gid_str.parse().map_err(|_| CliError::InvalidManifestId)?;
147            return Ok(ManifestId(id));
148        }
149        // Maybe branch_kv itself is a string (manifest ID directly)
150        if let Some(gid_str) = branch_kv.as_str() {
151            let id: u64 = gid_str.parse().map_err(|_| CliError::InvalidManifestId)?;
152            return Ok(ManifestId(id));
153        }
154    }
155
156    Err(CliError::ManifestNotFound {
157        depot: depot_id.0,
158        branch: branch.to_string(),
159    })
160}
161
162pub fn resolve_depot_key(args: &FilesArgs) -> Result<DepotKey, CliError> {
163    if let Some(ref hex) = args.depot_key {
164        let bytes: Vec<u8> = (0..hex.len())
165            .step_by(2)
166            .map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
167            .collect::<Result<_, _>>()
168            .map_err(|_| {
169                CliError::Io(std::io::Error::new(
170                    std::io::ErrorKind::InvalidInput,
171                    "invalid hex in --depot-key",
172                ))
173            })?;
174        if bytes.len() != 32 {
175            return Err(CliError::Io(std::io::Error::new(
176                std::io::ErrorKind::InvalidInput,
177                format!("depot key must be 32 bytes, got {}", bytes.len()),
178            )));
179        }
180        let mut key = [0u8; 32];
181        key.copy_from_slice(&bytes);
182        return Ok(DepotKey(key));
183    }
184    // Try auto-detect from depot.json next to the manifest file
185    if let Some(ref manifest_path) = args.manifest_file
186        && let Some(parent) = manifest_path.parent()
187    {
188        // Check sibling depot.json (manifest might be in .depotdownloader/manifests/)
189        for dir in [parent, &parent.join("../.."), &parent.join("..")] {
190            let config = steamroom_client::depot_config::DepotConfig::load(dir);
191            if let Some(depot_id) = args.depot
192                && let Some((_, key)) = config.get_installed(DepotId(depot_id))
193            {
194                return Ok(key);
195            }
196            // Try any depot in the config
197            for info in config.depots.values() {
198                let bytes: Vec<u8> = (0..info.depot_key.len())
199                    .step_by(2)
200                    .filter_map(|i| u8::from_str_radix(&info.depot_key[i..i + 2], 16).ok())
201                    .collect();
202                if bytes.len() == 32 {
203                    let mut key = [0u8; 32];
204                    key.copy_from_slice(&bytes);
205                    return Ok(DepotKey(key));
206                }
207            }
208        }
209    }
210    Err(CliError::Io(std::io::Error::new(
211        std::io::ErrorKind::NotFound,
212        "no depot key available (pass --depot-key <hex> or --raw for encrypted names)",
213    )))
214}
215
216pub fn decompress_manifest(data: &[u8]) -> Result<Vec<u8>, CliError> {
217    // Manifest data from CDN is zip-compressed
218    if data.len() > 2 && data[0] == 0x50 && data[1] == 0x4B {
219        let cursor = std::io::Cursor::new(data);
220        let mut archive = zip::ZipArchive::new(cursor)
221            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
222        if archive.is_empty() {
223            return Err(std::io::Error::new(
224                std::io::ErrorKind::InvalidData,
225                "empty manifest archive",
226            )
227            .into());
228        }
229        let mut file = archive
230            .by_index(0)
231            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
232        let mut buf = Vec::new();
233        std::io::Read::read_to_end(&mut file, &mut buf)?;
234        Ok(buf)
235    } else {
236        // Not compressed, return as-is
237        Ok(data.to_vec())
238    }
239}
240
241pub fn fmt_size(bytes: u64) -> String {
242    if bytes < 1024 {
243        format!("{} B", bytes)
244    } else if bytes < 1024 * 1024 {
245        format!("{:.2} KiB", bytes as f64 / 1024.0)
246    } else if bytes < 1024 * 1024 * 1024 {
247        format!("{:.2} MiB", bytes as f64 / (1024.0 * 1024.0))
248    } else {
249        format!("{:.2} GiB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
250    }
251}
252
253pub fn fmt_timestamp(epoch: u64) -> String {
254    jiff::Timestamp::from_second(epoch as i64)
255        .map(|ts| ts.strftime("%Y-%m-%d %H:%M:%S UTC").to_string())
256        .unwrap_or_else(|_| epoch.to_string())
257}
258
259pub fn fmt_relative(epoch: u64) -> String {
260    let Ok(ts) = jiff::Timestamp::from_second(epoch as i64) else {
261        return epoch.to_string();
262    };
263    let now = jiff::Timestamp::now();
264    let span = now.duration_since(ts);
265    let hours = span.as_hours();
266    if hours < 1 {
267        "just now".to_string()
268    } else if hours < 24 {
269        format!("{hours}h ago")
270    } else {
271        let days = hours / 24;
272        if days >= 365 {
273            let years = days / 365;
274            let rem_months = (days % 365) / 30;
275            if rem_months > 0 {
276                format!("{years}y {rem_months}mo ago")
277            } else {
278                format!("{years}y ago")
279            }
280        } else if days >= 30 {
281            let months = days / 30;
282            let rem_days = days % 30;
283            if rem_days > 0 {
284                format!("{months}mo {rem_days}d ago")
285            } else {
286                format!("{months}mo ago")
287            }
288        } else {
289            format!("{days}d ago")
290        }
291    }
292}
293
294/// Look up an app's KV data via PICS using an already-authenticated client.
295///
296/// Previously this helper also built the client (took an `AuthOptions`);
297/// the refactor moved the connect/login step up to `async_main` so it can
298/// be reused across daemon requests, leaving this helper as a pure
299/// PICS-fetch + parse.
300pub async fn fetch_app_kv(
301    client: &SteamClient<LoggedIn>,
302    app_id: AppId,
303) -> Result<KeyValue, CliError> {
304    let tokens = client.pics_get_access_tokens(&[app_id]).await?;
305    let token = tokens
306        .into_iter()
307        .next()
308        .unwrap_or(AccessToken { app_id, token: 0 });
309    let infos = client.pics_get_product_info(&[token]).await?;
310    let app_info = infos
311        .into_iter()
312        .next()
313        .ok_or(CliError::NoProductInfo(app_id.0))?;
314    let kv_data = app_info.kv_data.ok_or(CliError::NoKvData(app_id.0))?;
315    let kv = parse_app_kv(&kv_data)?;
316    Ok(kv)
317}
318
319pub async fn fetch_manifest(
320    client: &SteamClient<LoggedIn>,
321    app_id: AppId,
322    depot_id: DepotId,
323    manifest_id: ManifestId,
324    branch: Option<&str>,
325) -> Result<DepotManifest, CliError> {
326    let depot_key = client.get_depot_decryption_key(depot_id, app_id).await?;
327    let request_code = client
328        .get_manifest_request_code(app_id, depot_id, manifest_id, branch, None)
329        .await?
330        .unwrap_or(0);
331
332    let cdn_servers = client.get_cdn_servers(CellId(0), Some(5)).await?;
333    let cdn_server = cdn_servers.first().ok_or(CliError::NoCdnServers)?;
334    let cdn = CdnClient::new().map_err(CliError::Steam)?;
335    let manifest_data = cdn
336        .download_manifest(cdn_server, depot_id, manifest_id, request_code, None)
337        .await?;
338    let manifest_bytes = decompress_manifest(&manifest_data)?;
339    let mut manifest = DepotManifest::parse(&manifest_bytes)?;
340    if manifest.filenames_encrypted {
341        let _ = manifest.decrypt_filenames(&depot_key);
342    }
343    Ok(manifest)
344}
345
346pub async fn connect_and_login(
347    auth: &AuthOptions,
348    recorder: Option<&steamroom::transport::recording::Recorder>,
349) -> Result<SteamClient<LoggedIn>, CliError> {
350    let make_builder = || {
351        let b = LoginBuilder::new().device_name(auth.device_name.as_deref().unwrap_or("steamroom"));
352        match recorder {
353            Some(r) => b.record(r.clone()),
354            None => b,
355        }
356    };
357    let builder = make_builder();
358
359    // --use-steam-token: prefer local Steam install's cached token.
360    if auth.use_steam_token {
361        let username = auth.username.clone().or_else(|| {
362            let dir = steamroom_client::steam_creds::steam_dir()?;
363            steamroom_client::steam_creds::detect_username(&dir)
364        });
365        let cached = username.as_deref().and_then(|u| {
366            info!("extracting cached Steam token for {u}...");
367            steamroom_client::steam_creds::extract_token(u)
368        });
369        if let Some(cred) = cached {
370            info!("using cached Steam token for {}", cred.account_name);
371            return Ok(builder
372                .with_refresh_token(cred.account_name, cred.refresh_token)
373                .login()
374                .await?);
375        }
376        warn!("failed to extract Steam token, falling back to normal auth");
377        if let Some(u) = username
378            && let Some(token) = load_saved_token(&u)
379        {
380            info!("using saved refresh token for {u}");
381            return Ok(builder.with_refresh_token(u, token).login().await?);
382        }
383        return Ok(builder.anonymous().login().await?);
384    }
385
386    // -u/--username given. --qr forces a fresh QR session; otherwise try a
387    // saved refresh token (with fallback to interactive auth if it's stale),
388    // then password.
389    if let Some(ref username) = auth.username {
390        // Try the saved refresh token first regardless of `--qr` /
391        // password. The flags describe how to RECOVER if the token is
392        // missing or stale; a valid token is always the cheap path.
393        if let Some(token) = load_saved_token(username) {
394            info!("using saved refresh token for {username}");
395            let attempt = make_builder()
396                .with_refresh_token(username, token)
397                .login()
398                .await;
399            match attempt {
400                Ok(client) => return Ok(client),
401                Err(LoginError::LogonFailed(
402                    steamroom::enums::EResultError::InvalidPassword
403                    | steamroom::enums::EResultError::AccessDenied
404                    | steamroom::enums::EResultError::Expired,
405                ))
406                | Err(LoginError::InvalidPassword) => {
407                    warn!("saved refresh token rejected; re-authenticating");
408                    forget_saved_token(username);
409                }
410                Err(e) => return Err(e.into()),
411            }
412        }
413        // No token (or it was rejected). Fall through to the requested
414        // interactive flow.
415        if auth.qr {
416            if !is_interactive() {
417                return Err(CliError::InteractiveAuthRequired);
418            }
419            return drive_qr_flow(builder, username).await;
420        }
421        if !is_interactive() && auth.password.is_none() {
422            return Err(CliError::InteractiveAuthRequired);
423        }
424        return drive_credentials_flow(builder, username, auth).await;
425    }
426
427    // Auto-detect Steam user with a saved token.
428    if let Some((username, token)) = detect_steam_user() {
429        info!("auto-detected Steam user: {username}");
430        return Ok(builder.with_refresh_token(username, token).login().await?);
431    }
432
433    // Last resort: anonymous.
434    Ok(builder.anonymous().login().await?)
435}
436
437pub fn tokens_path() -> Option<std::path::PathBuf> {
438    Some(
439        dirs_next::home_dir()?
440            .join(".depotdownloader")
441            .join("tokens.json"),
442    )
443}
444
445/// Try to detect the active Steam user and find a saved refresh token.
446pub fn detect_steam_user() -> Option<(String, String)> {
447    let dir = steamroom_client::steam_creds::steam_dir()?;
448    let username = steamroom_client::steam_creds::detect_username(&dir)?;
449    let token = load_saved_token(&username)?;
450    Some((username, token))
451}
452
453pub fn load_saved_token(username: &str) -> Option<String> {
454    let data = std::fs::read_to_string(tokens_path()?).ok()?;
455    let parsed: serde_json::Value = serde_json::from_str(&data).ok()?;
456    parsed["tokens"][username].as_str().map(|s| s.to_string())
457}
458
459pub fn save_token(username: &str, refresh_token: &str) {
460    let Some(path) = tokens_path() else { return };
461    let mut root = match std::fs::read_to_string(&path) {
462        Ok(data) => serde_json::from_str::<serde_json::Value>(&data).unwrap_or_default(),
463        Err(_) => serde_json::json!({}),
464    };
465    root["tokens"][username] = serde_json::Value::String(refresh_token.to_string());
466    if let Some(parent) = path.parent() {
467        let _ = std::fs::create_dir_all(parent);
468    }
469    let _ = std::fs::write(
470        &path,
471        serde_json::to_string_pretty(&root).unwrap_or_default(),
472    );
473    info!("saved refresh token for {username}");
474}
475
476pub fn forget_saved_token(username: &str) {
477    let Some(path) = tokens_path() else { return };
478    let Ok(data) = std::fs::read_to_string(&path) else {
479        return;
480    };
481    let Ok(mut root) = serde_json::from_str::<serde_json::Value>(&data) else {
482        return;
483    };
484    if let Some(tokens) = root.get_mut("tokens").and_then(|v| v.as_object_mut()) {
485        tokens.remove(username);
486    }
487    let _ = std::fs::write(
488        &path,
489        serde_json::to_string_pretty(&root).unwrap_or_default(),
490    );
491}
492
493pub async fn drive_credentials_flow(
494    builder: LoginBuilder,
495    username: &str,
496    auth: &AuthOptions,
497) -> Result<SteamClient<LoggedIn>, CliError> {
498    let _ = builder; // Dropped here, the loop creates a fresh LoginBuilder per attempt (each attempt reconnects).
499    for attempt in 0..3u32 {
500        let password = if attempt == 0 {
501            match (auth.password.clone(), is_interactive()) {
502                (Some(p), _) => p,
503                (None, true) => rpassword::prompt_password(format!("Password for {username}: "))
504                    .unwrap_or_default(),
505                (None, false) => return Err(CliError::InteractiveAuthRequired),
506            }
507        } else if !is_interactive() {
508            return Err(CliError::InteractiveAuthRequired);
509        } else {
510            eprintln!("Invalid password, try again ({}/3)", attempt + 1);
511            rpassword::prompt_password(format!("Password for {username}: ")).unwrap_or_default()
512        };
513
514        let credentials = LoginBuilder::new()
515            .device_name(auth.device_name.as_deref().unwrap_or("steamroom"))
516            .with_credentials(username, password);
517        let flow = match credentials.begin().await {
518            Ok(f) => f,
519            Err(LoginError::InvalidPassword) => continue,
520            Err(e) => return Err(e.into()),
521        };
522
523        let approved = match flow {
524            CredentialsLoginFlow::Approved(a) => a,
525            CredentialsLoginFlow::NeedsGuardCode(mut challenge) => {
526                if !is_interactive() {
527                    return Err(CliError::InteractiveAuthRequired);
528                }
529                loop {
530                    let prompt = guard_prompt(challenge.allowed_kinds());
531                    let kind = preferred_kind(challenge.allowed_kinds());
532                    let code = rpassword::prompt_password(prompt).unwrap_or_default();
533                    match challenge.submit_code(&code, kind).await {
534                        Ok(a) => break a,
535                        Err((c, LoginError::InvalidGuardCode)) => {
536                            eprintln!("Invalid Steam Guard code, try again.");
537                            challenge = c;
538                        }
539                        Err((_, e)) => return Err(e.into()),
540                    }
541                }
542            }
543            CredentialsLoginFlow::NeedsMobileConfirm(mobile) => {
544                if !is_interactive() {
545                    return Err(CliError::InteractiveAuthRequired);
546                }
547                info!("confirm login on your Steam mobile app...");
548                mobile.wait_for_confirmation().await?
549            }
550            _ => unreachable!("unexpected CredentialsLoginFlow variant"),
551        };
552
553        let tokens = approved.tokens();
554        save_token(
555            tokens.account_name.as_deref().unwrap_or(username),
556            &tokens.refresh_token,
557        );
558        return Ok(approved.finish().await?);
559    }
560    // Three attempts exhausted.
561    Err(CliError::Login(LoginError::InvalidPassword))
562}
563
564pub fn guard_prompt(kinds: &[GuardType]) -> &'static str {
565    if kinds.contains(&GuardType::DeviceCode) {
566        "Steam Guard code (from authenticator app): "
567    } else if kinds.contains(&GuardType::EmailCode) {
568        "Steam Guard code (from email): "
569    } else {
570        "Steam Guard code: "
571    }
572}
573
574pub fn preferred_kind(kinds: &[GuardType]) -> GuardType {
575    if kinds.contains(&GuardType::DeviceCode) {
576        GuardType::DeviceCode
577    } else if kinds.contains(&GuardType::EmailCode) {
578        GuardType::EmailCode
579    } else {
580        kinds.first().copied().unwrap_or(GuardType::DeviceCode)
581    }
582}
583
584pub async fn drive_qr_flow(
585    builder: LoginBuilder,
586    username: &str,
587) -> Result<SteamClient<LoggedIn>, CliError> {
588    info!("generating QR code...");
589    let flow = builder.with_qr().begin().await?;
590
591    let url = flow.challenge_url();
592    let qr =
593        qrcode::QrCode::new(url.as_bytes()).map_err(|e| CliError::Io(std::io::Error::other(e)))?;
594    let rendered = qr.render::<qrcode::render::unicode::Dense1x2>().build();
595    eprintln!("{rendered}");
596    eprintln!("Scan this QR code with the Steam mobile app");
597    eprintln!("Or open: {url}");
598
599    let approved = flow.wait_for_scan().await?;
600    let tokens = approved.tokens();
601    save_token(
602        tokens.account_name.as_deref().unwrap_or(username),
603        &tokens.refresh_token,
604    );
605    Ok(approved.finish().await?)
606}