1use 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
27pub static INTERACTIVE: OnceLock<bool> = OnceLock::new();
30
31pub 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
40const FIRST_PARTY_CRATES: [&str; 4] = [
43 "steamroom",
44 "steamroom_client",
45 "steamroom_ffi",
46 "steamroom_cli",
47];
48
49pub 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 if data.first() == Some(&0x00) {
78 key_value::parse_binary_kv(data).map_err(CliError::Io)
79 } else {
80 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 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 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 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 if let Some(ref manifest_path) = args.manifest_file
186 && let Some(parent) = manifest_path.parent()
187 {
188 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 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 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 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
294pub 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 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 if let Some(ref username) = auth.username {
390 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 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 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 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
445pub 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; 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 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}