mod cli;
mod download;
mod errors;
use clap::Parser;
use cli::*;
use errors::CliError;
use prost::Message;
use std::path::PathBuf;
use steamroom::apps::AccessToken;
use steamroom::cdn::CdnClient;
use steamroom::client::LoggedIn;
use steamroom::client::PROTOCOL_VERSION;
use steamroom::client::SteamClient;
use steamroom::client::msg::ClientMsg;
use steamroom::connection;
use steamroom::depot::manifest::DepotManifest;
use steamroom::depot::*;
use steamroom::messages::EMsg;
use steamroom::transport::websocket::WebSocketTransport;
use steamroom::types::key_value;
use steamroom::types::key_value::KeyValue;
use steamroom::types::key_value::KvValue;
use tabled::builder::Builder as TableBuilder;
use tabled::settings::Style;
use tracing::debug;
use tracing::info;
use tracing::warn;
fn main() {
let cli = if std::env::var("DD_COMPAT").as_deref() == Ok("1") {
let args: Vec<String> = std::env::args()
.map(|a| {
if let Some(rest) = a.strip_prefix('-') {
if !rest.starts_with('-')
&& rest.contains(|c: char| c.is_ascii_alphabetic())
&& rest.len() > 1
{
format!("--{rest}")
} else {
a
}
} else {
a
}
})
.collect();
cli::CompatCli::parse_from(args).into_cli()
} else {
Cli::parse()
};
let default_filter = if cli.quiet {
"off"
} else if cli.debug {
"debug"
} else if cfg!(debug_assertions) {
"warn,steamroom=debug,steamroom_client=debug,steamroom_ffi=debug,steamroom_cli=debug"
} else {
"warn"
};
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| default_filter.into()),
)
.init();
let cpus = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4);
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.max_blocking_threads(cpus)
.build()
.expect("failed to build tokio runtime");
let raw_errors = cli.raw_errors;
if let Err(err) = rt.block_on(async_main(cli)) {
if raw_errors {
let report: rootcause::Report<CliError> = rootcause::report!(err);
eprintln!("Error: {report:?}");
} else {
eprintln!("Error: {err}");
}
std::process::exit(1);
}
}
async fn async_main(cli: Cli) -> Result<(), CliError> {
let show_progress = !cli.no_progress;
match cli.command {
Command::Info(args) => run_info(args, &cli.auth).await,
Command::Manifests(args) => run_manifests(args, &cli.auth).await,
Command::Files(args) => run_files(args, &cli.auth).await,
Command::Download(args) => run_download(args, &cli.auth, show_progress).await,
Command::Workshop(args) => run_workshop(args, &cli.auth, show_progress).await,
}
}
async fn connect_and_login(auth: &AuthOptions) -> Result<SteamClient<LoggedIn>, CliError> {
info!("discovering CM servers...");
let servers = connection::CmServer::fetch().await.unwrap_or_else(|_| {
warn!("failed to fetch CM servers, using defaults");
connection::CmServer::defaults()
});
let tcp_server = servers
.iter()
.find(|s| s.protocol == connection::Protocol::Tcp);
let client = if let Some(server) = tcp_server {
info!("connecting via TCP to {:?}", server.addr);
let transport = steamroom::transport::tcp::TcpTransport::connect(server).await?;
let (client, _rx) = SteamClient::connect(transport).await?;
info!("encrypting...");
client.encrypt().await?
} else {
let ws_server = servers
.iter()
.find(|s| s.protocol == connection::Protocol::WebSocket)
.ok_or(CliError::NoCmServers)?;
info!("connecting via WebSocket to {:?}", ws_server.addr);
let transport = WebSocketTransport::connect(ws_server).await?;
let (client, _rx) = SteamClient::connect_ws(transport).await?;
client
};
let (logon, steam_id) = if let Some(ref username) = auth.username {
if let Some(token) = load_saved_token(username) {
info!("using saved refresh token for {username}");
build_token_logon(username, &token)
} else if auth.qr {
let tokens = authenticate_qr(&client).await?;
save_token(
tokens.account_name.as_deref().unwrap_or(username),
&tokens.refresh_token,
);
build_token_logon(
tokens.account_name.as_deref().unwrap_or(username),
&tokens.access_token,
)
} else {
let mut last_err = None;
let mut tokens = None;
for attempt in 0..3 {
let password = if attempt == 0 {
auth.password.clone().unwrap_or_else(|| {
rpassword::prompt_password(format!("Password for {username}: "))
.unwrap_or_default()
})
} else {
eprintln!("Invalid password, try again ({}/3)", attempt + 1);
rpassword::prompt_password(format!("Password for {username}: "))
.unwrap_or_default()
};
match authenticate_credentials(
&client,
username,
&password,
auth.device_name.as_deref(),
)
.await
{
Ok(t) => {
tokens = Some(t);
break;
}
Err(CliError::Steam(steamroom::error::Error::Connection(
steamroom::error::ConnectionError::LogonFailed(
steamroom::enums::EResultError::InvalidPassword,
),
))) => {
last_err = Some(CliError::Steam(steamroom::error::Error::Connection(
steamroom::error::ConnectionError::LogonFailed(
steamroom::enums::EResultError::InvalidPassword,
),
)));
continue;
}
Err(e) => return Err(e),
}
}
let tokens = tokens.ok_or_else(|| last_err.unwrap())?;
save_token(
tokens.account_name.as_deref().unwrap_or(username),
&tokens.refresh_token,
);
build_token_logon(
tokens.account_name.as_deref().unwrap_or(username),
&tokens.access_token,
)
}
} else {
build_anon_logon()
};
let logon_bytes = logon.encode_to_vec();
let mut msg = ClientMsg::with_body(EMsg::CLIENT_LOGON, &logon_bytes);
msg.header.steamid = Some(steam_id);
msg.header.client_sessionid = Some(0);
info!("logging in...");
let (client, _resp) = client.login(msg).await?;
info!("logged in successfully");
Ok(client)
}
fn tokens_path() -> Option<std::path::PathBuf> {
Some(
dirs_next::home_dir()?
.join(".depotdownloader")
.join("tokens.json"),
)
}
fn load_saved_token(username: &str) -> Option<String> {
let data = std::fs::read_to_string(tokens_path()?).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&data).ok()?;
parsed["tokens"][username].as_str().map(|s| s.to_string())
}
fn save_token(username: &str, refresh_token: &str) {
let Some(path) = tokens_path() else { return };
let mut root = match std::fs::read_to_string(&path) {
Ok(data) => serde_json::from_str::<serde_json::Value>(&data).unwrap_or_default(),
Err(_) => serde_json::json!({}),
};
root["tokens"][username] = serde_json::Value::String(refresh_token.to_string());
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(
&path,
serde_json::to_string_pretty(&root).unwrap_or_default(),
);
info!("saved refresh token for {username}");
}
async fn authenticate_credentials(
client: &SteamClient<steamroom::client::Encrypted>,
username: &str,
password: &str,
device_name: Option<&str>,
) -> Result<steamroom::auth::AuthTokens, CliError> {
info!("getting RSA public key for {username}...");
let rsa = client.get_password_rsa_public_key(username).await?;
let modulus = rsa
.publickey_mod
.ok_or(CliError::Steam(steamroom::error::Error::Connection(
steamroom::error::ConnectionError::EncryptionFailed,
)))?;
let exponent =
rsa.publickey_exp
.ok_or(CliError::Steam(steamroom::error::Error::Connection(
steamroom::error::ConnectionError::EncryptionFailed,
)))?;
let timestamp = rsa.timestamp.unwrap_or(0);
let encrypted_password = steamroom::crypto::rsa::encrypt_with_rsa_public_key(
password.as_bytes(),
&modulus,
&exponent,
)?;
let encoded_password = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
&encrypted_password,
);
info!("beginning auth session...");
let req = steamroom::generated::CAuthenticationBeginAuthSessionViaCredentialsRequest {
account_name: Some(username.to_string()),
encrypted_password: Some(encoded_password),
encryption_timestamp: Some(timestamp),
remember_login: Some(true),
persistence: Some(1),
device_friendly_name: device_name.map(|s| s.to_string()),
..Default::default()
};
let session = client.begin_auth_session_via_credentials(req).await?;
for guard in &session.allowed_confirmations {
match guard {
steamroom::auth::GuardType::DeviceCode | steamroom::auth::GuardType::EmailCode => {
let prompt = match guard {
steamroom::auth::GuardType::DeviceCode => {
"Steam Guard code (from authenticator app): "
}
steamroom::auth::GuardType::EmailCode => "Steam Guard code (from email): ",
_ => unreachable!(),
};
let code = rpassword::prompt_password(prompt).unwrap_or_default();
if let (Some(client_id), Some(steam_id)) = (session.client_id, session.steam_id) {
client
.submit_steam_guard_code(client_id, steam_id, &code, *guard)
.await?;
}
break;
}
steamroom::auth::GuardType::DeviceConfirmation => {
info!("confirm login on your Steam mobile app...");
break;
}
_ => {}
}
}
let client_id = session.client_id.unwrap_or(0);
let request_id = session.request_id.unwrap_or_default();
let interval = session.poll_interval.unwrap_or(5.0);
loop {
tokio::time::sleep(std::time::Duration::from_secs_f32(interval)).await;
if let Some(tokens) = client.poll_auth_session(client_id, &request_id).await? {
return Ok(tokens);
}
}
}
async fn authenticate_qr(
client: &SteamClient<steamroom::client::Encrypted>,
) -> Result<steamroom::auth::AuthTokens, CliError> {
info!("generating QR code...");
let req = steamroom::generated::CAuthenticationBeginAuthSessionViaQrRequest {
device_friendly_name: Some("steamroom".to_string()),
..Default::default()
};
let session = client.begin_auth_session_via_qr(req).await?;
if let Some(ref url) = session.challenge_url {
let qr = qrcode::QrCode::new(url.as_bytes())
.map_err(|e| CliError::Io(std::io::Error::other(e)))?;
let rendered = qr.render::<qrcode::render::unicode::Dense1x2>().build();
eprintln!("{rendered}");
eprintln!("Scan this QR code with the Steam mobile app");
eprintln!("Or open: {url}");
}
let client_id = session.client_id.unwrap_or(0);
let request_id = session.request_id.unwrap_or_default();
let interval = session.poll_interval.unwrap_or(5.0);
loop {
tokio::time::sleep(std::time::Duration::from_secs_f32(interval)).await;
if let Some(tokens) = client.poll_auth_session(client_id, &request_id).await? {
return Ok(tokens);
}
}
}
fn build_token_logon(username: &str, token: &str) -> (steamroom::generated::CMsgClientLogon, u64) {
let logon = steamroom::generated::CMsgClientLogon {
protocol_version: Some(PROTOCOL_VERSION),
cell_id: Some(0),
client_os_type: Some(20),
account_name: Some(username.to_string()),
access_token: Some(token.to_string()),
..Default::default()
};
let steam_id = steamroom::types::SteamId::from_parts(1, 1, 1, 0);
(logon, steam_id.raw())
}
fn build_anon_logon() -> (steamroom::generated::CMsgClientLogon, u64) {
let logon = steamroom::generated::CMsgClientLogon {
protocol_version: Some(PROTOCOL_VERSION),
cell_id: Some(0),
client_os_type: Some(20),
..Default::default()
};
let steam_id = steamroom::types::SteamId::from_parts(1, 10, 0, 0);
(logon, steam_id.raw())
}
async fn run_download(
args: DownloadArgs,
auth: &AuthOptions,
show_progress: bool,
) -> Result<(), CliError> {
let client = connect_and_login(auth).await?;
let app_id = AppId(args.app);
info!("getting PICS access tokens for app {}", app_id);
let tokens = client.pics_get_access_tokens(&[app_id]).await?;
let token = tokens
.into_iter()
.next()
.unwrap_or(AccessToken { app_id, token: 0 });
info!("getting product info...");
let infos = client.pics_get_product_info(&[token]).await?;
let app_info = infos
.into_iter()
.next()
.ok_or_else(|| CliError::NoProductInfo(app_id.0))?;
let kv_data = app_info
.kv_data
.ok_or_else(|| CliError::NoKvData(app_id.0))?;
let kv = parse_app_kv(&kv_data)?;
let depot_id = if let Some(d) = args.depot {
DepotId(d)
} else {
let depots_kv = kv.get("depots").ok_or_else(|| CliError::NoDepots)?;
find_first_depot(depots_kv)?
};
let branch = args.branch.as_deref().unwrap_or("public");
let manifest_id = if let Some(m) = args.manifest {
ManifestId(m)
} else {
let depots_kv = kv.get("depots").ok_or_else(|| CliError::NoDepots)?;
find_manifest_for_depot(depots_kv, depot_id, branch)?
};
info!(
"depot={}, manifest={}, branch={}",
depot_id, manifest_id, branch
);
info!("getting depot key for depot {depot_id} app {app_id}...");
let depot_key = client.get_depot_decryption_key(depot_id, app_id).await?;
debug!("depot key: {:02x?}", &depot_key.0);
info!("getting CDN servers...");
let cdn_servers = client.get_cdn_servers(CellId(0), Some(20)).await?;
if cdn_servers.is_empty() {
return Err(CliError::NoCdnServers);
}
info!("got {} CDN servers", cdn_servers.len());
let cdn_server = &cdn_servers[0];
let cdn_pool = steamroom::cdn::CdnServerPool::new(cdn_servers.clone());
info!("getting manifest request code...");
let request_code = client
.get_manifest_request_code(app_id, depot_id, manifest_id, Some(branch), None)
.await?
.unwrap_or(0);
let cdn = CdnClient::new().map_err(CliError::Steam)?;
let manifest_cache = steamroom_client::manifest::ManifestCache::new(
steamroom_client::manifest::ManifestCache::default_path(),
);
let (manifest_bytes, cdn_raw) = if let Some(cached) = manifest_cache.load(depot_id, manifest_id)
{
debug!("using cached manifest for {depot_id}_{manifest_id}");
(cached, None)
} else {
info!("downloading manifest...");
let manifest_data = cdn
.download_manifest(cdn_server, depot_id, manifest_id, request_code, None)
.await?;
let decompressed = decompress_manifest(&manifest_data)?;
let _ = manifest_cache.save(depot_id, manifest_id, &decompressed);
(decompressed, Some(manifest_data))
};
{
let mut off = 0;
while off + 8 <= manifest_bytes.len() {
let magic = u32::from_le_bytes(manifest_bytes[off..off + 4].try_into().unwrap());
let size = u32::from_le_bytes(manifest_bytes[off + 4..off + 8].try_into().unwrap());
debug!(" section at {off}: magic=0x{magic:08x} size={size}");
if magic == 0xD64BF064 {
break;
}
off += 8 + size as usize;
}
}
let mut manifest = DepotManifest::parse(&manifest_bytes)?;
info!(
"manifest parsed: {} files, encrypted={}",
manifest.files.len(),
manifest.filenames_encrypted
);
if manifest.filenames_encrypted {
match manifest.decrypt_filenames(&depot_key) {
Ok(()) => info!("decrypted filenames"),
Err(e) => warn!("filename decryption failed ({e}), using raw names"),
}
}
let output_dir = args
.output
.unwrap_or_else(|| PathBuf::from("depots").join(depot_id.0.to_string()));
std::fs::create_dir_all(&output_dir)?;
let depot_config = steamroom_client::depot_config::DepotConfig::load(&output_dir);
let old_manifest_files = match depot_config.get_installed(depot_id) {
Some((old_id, old_key)) if old_id != manifest_id => {
debug!("previous manifest: {old_id}, loading for delta");
steamroom_client::depot_config::DepotConfig::load_manifest_decompressed(
&output_dir,
depot_id,
old_id,
)
.and_then(|bytes| {
let mut old = DepotManifest::parse(&bytes).ok()?;
if old.filenames_encrypted {
let _ = old.decrypt_filenames(&old_key);
}
Some(
old.files
.iter()
.map(|f| f.filename.clone())
.collect::<Vec<_>>(),
)
})
}
_ => None,
};
let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel();
let fetcher = steamroom_client::download::CdnChunkFetcher::new(cdn, cdn_pool, None);
let mut builder = steamroom_client::download::DepotJob::builder()
.depot_id(depot_id)
.depot_key(depot_key.clone())
.install_dir(output_dir.clone())
.verify(args.verify)
.event_sender(event_tx);
if let Some(old_files) = old_manifest_files {
info!("delta update: will remove files not in new manifest");
builder = builder.old_manifest_files(old_files);
}
if let Some(max) = args.max_downloads {
builder = builder.max_downloads(max);
}
if let Some(ref filelist_path) = args.filelist {
let content = std::fs::read_to_string(filelist_path)?;
let lines: Vec<String> = content
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect();
builder = builder.file_filter(steamroom_client::download::FileFilter::from_filelist(
&lines,
)?);
} else if let Some(ref pattern) = args.file_regex {
builder = builder.file_filter(steamroom_client::download::FileFilter::Regex(
regex::Regex::new(pattern)?,
));
}
let job = builder.build().map_err(|e| {
CliError::Steam(steamroom::error::Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
e,
)))
})?;
info!("downloading to {}", output_dir.display());
let progress_handle = download::spawn_progress_renderer(event_rx, show_progress);
let stats = job
.download(&manifest, std::sync::Arc::new(fetcher))
.await
.map_err(|e| CliError::Io(std::io::Error::other(e)))?;
drop(job);
let _ = progress_handle.await;
if let Some(raw) = cdn_raw {
let _ = steamroom_client::depot_config::DepotConfig::save_manifest_raw(
&output_dir,
depot_id,
manifest_id,
&raw,
);
}
let _ = steamroom_client::depot_config::DepotConfig::save_manifest_decompressed(
&output_dir,
depot_id,
manifest_id,
&manifest_bytes,
);
let mut depot_config = steamroom_client::depot_config::DepotConfig::load(&output_dir);
depot_config.set_installed(depot_id, manifest_id, &depot_key);
let _ = depot_config.save(&output_dir);
let mut summary = format!(
"download complete: {} files, {}",
stats.files_completed,
fmt_size(stats.bytes_downloaded),
);
if stats.files_removed > 0 {
summary.push_str(&format!(", {} removed", stats.files_removed));
}
info!("{summary}");
Ok(())
}
fn parse_app_kv(data: &[u8]) -> Result<KeyValue, CliError> {
if data.first() == Some(&0x00) {
key_value::parse_binary_kv(data).map_err(CliError::Io)
} else {
let text = String::from_utf8_lossy(data);
Ok(key_value::parse_text_kv(&text)?)
}
}
fn find_first_depot(depots_kv: &KeyValue) -> Result<DepotId, CliError> {
if let KvValue::Children(ref map) = depots_kv.value {
for key in map.keys() {
if let Ok(id) = key.parse::<u32>()
&& id > 0
{
return Ok(DepotId(id));
}
}
}
Err(CliError::NoDepots)
}
fn find_manifest_for_depot(
depots_kv: &KeyValue,
depot_id: DepotId,
branch: &str,
) -> Result<ManifestId, CliError> {
let depot_key = depot_id.0.to_string();
let depot = depots_kv
.get(&depot_key)
.ok_or(CliError::DepotNotFound(depot_id.0))?;
if let Some(manifests) = depot.get("manifests")
&& let Some(branch_kv) = manifests.get(branch)
{
if let Some(gid) = branch_kv.get("gid")
&& let Some(gid_str) = gid.as_str()
{
let id: u64 = gid_str.parse().map_err(|_| CliError::InvalidManifestId)?;
return Ok(ManifestId(id));
}
if let Some(gid_str) = branch_kv.as_str() {
let id: u64 = gid_str.parse().map_err(|_| CliError::InvalidManifestId)?;
return Ok(ManifestId(id));
}
}
Err(CliError::ManifestNotFound {
depot: depot_id.0,
branch: branch.to_string(),
})
}
fn decompress_manifest(data: &[u8]) -> Result<Vec<u8>, CliError> {
if data.len() > 2 && data[0] == 0x50 && data[1] == 0x4B {
let cursor = std::io::Cursor::new(data);
let mut archive = zip::ZipArchive::new(cursor)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
if archive.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"empty manifest archive",
)
.into());
}
let mut file = archive
.by_index(0)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut file, &mut buf)?;
Ok(buf)
} else {
Ok(data.to_vec())
}
}
fn fmt_size(bytes: u64) -> String {
if bytes < 1024 {
format!("{} B", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.2} KiB", bytes as f64 / 1024.0)
} else if bytes < 1024 * 1024 * 1024 {
format!("{:.2} MiB", bytes as f64 / (1024.0 * 1024.0))
} else {
format!("{:.2} GiB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
}
}
fn fmt_timestamp(epoch: u64) -> String {
jiff::Timestamp::from_second(epoch as i64)
.map(|ts| ts.strftime("%Y-%m-%d %H:%M:%S UTC").to_string())
.unwrap_or_else(|_| epoch.to_string())
}
fn fmt_relative(epoch: u64) -> String {
let Ok(ts) = jiff::Timestamp::from_second(epoch as i64) else {
return epoch.to_string();
};
let now = jiff::Timestamp::now();
let span = now.duration_since(ts);
let hours = span.as_hours();
if hours < 1 {
"just now".to_string()
} else if hours < 24 {
format!("{hours}h ago")
} else {
let days = hours / 24;
if days >= 365 {
let years = days / 365;
let rem_months = (days % 365) / 30;
if rem_months > 0 {
format!("{years}y {rem_months}mo ago")
} else {
format!("{years}y ago")
}
} else if days >= 30 {
let months = days / 30;
let rem_days = days % 30;
if rem_days > 0 {
format!("{months}mo {rem_days}d ago")
} else {
format!("{months}mo ago")
}
} else {
format!("{days}d ago")
}
}
}
async fn fetch_app_kv(
auth: &AuthOptions,
app_id: AppId,
) -> Result<(SteamClient<LoggedIn>, KeyValue), CliError> {
let client = connect_and_login(auth).await?;
let tokens = client.pics_get_access_tokens(&[app_id]).await?;
let token = tokens
.into_iter()
.next()
.unwrap_or(AccessToken { app_id, token: 0 });
let infos = client.pics_get_product_info(&[token]).await?;
let app_info = infos
.into_iter()
.next()
.ok_or(CliError::NoProductInfo(app_id.0))?;
let kv_data = app_info.kv_data.ok_or(CliError::NoKvData(app_id.0))?;
let kv = parse_app_kv(&kv_data)?;
Ok((client, kv))
}
async fn run_info(args: InfoArgs, auth: &AuthOptions) -> Result<(), CliError> {
let app_id = AppId(args.app);
let (_client, kv) = fetch_app_kv(auth, app_id).await?;
if args.format == Some(OutputFormat::Json) {
println!("{}", serde_json::to_string_pretty(&kv_to_json(&kv))?);
return Ok(());
}
let name = kv
.get("common")
.and_then(|c| c.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("(unknown)");
let app_type = kv
.get("common")
.and_then(|c| c.get("type"))
.and_then(|t| t.as_str())
.unwrap_or("(unknown)");
println!("App ID: {}", app_id);
println!("Name: {name}");
println!("Type: {app_type}");
let branch_name = "public";
if let Some(depots) = kv.get("depots")
&& let KvValue::Children(ref map) = depots.value
{
let mut depot_rows: Vec<[String; 4]> = Vec::new();
let mut redist_rows: Vec<[String; 4]> = Vec::new();
for key in map.keys() {
let Ok(_) = key.parse::<u32>() else {
continue;
};
let depot = &map[key];
let dname = depot.get("name").and_then(|n| n.as_str()).unwrap_or("");
let is_redist =
depot.get("depotfromapp").is_some() || depot.get("sharedinstall").is_some();
let os = depot
.get("config")
.and_then(|c| c.get("oslist"))
.and_then(|o| o.as_str())
.unwrap_or("");
if let Some(ref filter_os) = args.os
&& !os.is_empty()
&& !os
.split(',')
.any(|o| o.trim().eq_ignore_ascii_case(filter_os))
{
continue;
}
let mut config_parts = Vec::new();
if !os.is_empty() {
config_parts.push(
os.split(',')
.map(|o| match o.trim() {
"windows" => "Windows",
"macos" => "macOS",
"linux" => "Linux",
other => other,
})
.collect::<Vec<_>>()
.join(", "),
);
}
if let Some(arch) = depot
.get("config")
.and_then(|c| c.get("osarch"))
.and_then(|a| a.as_str())
{
config_parts.push(format!("{arch}-bit"));
}
if let Some(lang) = depot
.get("config")
.and_then(|c| c.get("language"))
.and_then(|l| l.as_str())
{
let cap = lang.get(..1).map(|c| c.to_uppercase()).unwrap_or_default()
+ lang.get(1..).unwrap_or("");
config_parts.push(cap);
}
if depot
.get("config")
.and_then(|c| c.get("lowviolence"))
.and_then(|l| l.as_str())
== Some("1")
{
config_parts.push("Low Violence".to_string());
}
if depot.get("sharedinstall").is_some() {
config_parts.push("Shared Install".to_string());
}
if let Some(from_app) = depot.get("depotfromapp").and_then(|d| d.as_str()) {
config_parts.push(format!("from app {from_app}"));
}
if !dname.is_empty() {
config_parts.push(dname.to_string());
}
let config_str = config_parts.join(", ");
let size_str = depot
.get("manifests")
.and_then(|m| m.get(branch_name))
.and_then(|b| b.get("size"))
.and_then(|s| s.as_str())
.and_then(|s| s.parse::<u64>().ok())
.map(fmt_size)
.unwrap_or_default();
let dl_str = depot
.get("manifests")
.and_then(|m| m.get(branch_name))
.and_then(|b| b.get("download"))
.and_then(|s| s.as_str())
.and_then(|s| s.parse::<u64>().ok())
.map(fmt_size)
.unwrap_or_default();
let row = [key.clone(), config_str, size_str, dl_str];
if is_redist {
redist_rows.push(row);
} else {
depot_rows.push(row);
}
}
let print_depot_table = |label: &str, rows: &[[String; 4]]| {
if rows.is_empty() {
return;
}
println!("\n{label}:");
let mut builder = TableBuilder::new();
builder.push_record(["ID", "CONFIGURATION", "SIZE", "DL."]);
for r in rows {
builder.push_record(r);
}
let table = builder
.build()
.with(Style::blank())
.with(tabled::settings::Padding::new(0, 2, 0, 0))
.with(
tabled::settings::Modify::new(tabled::settings::object::Columns::new(2..4))
.with(tabled::settings::Alignment::right()),
)
.to_string();
for line in table.lines() {
println!(" {line}");
}
};
print_depot_table("Depots", &depot_rows);
if args.show_all {
print_depot_table("Redistributables", &redist_rows);
}
if let Some(branches) = depots.get("branches")
&& let KvValue::Children(ref bmap) = branches.value
{
let mut branch_entries: Vec<(u64, [String; 5])> = Vec::new();
for (bname, branch) in bmap {
let build_id = branch
.get("buildid")
.and_then(|b| b.as_str())
.unwrap_or("-")
.to_string();
let desc = branch
.get("description")
.and_then(|d| d.as_str())
.unwrap_or("")
.trim();
let trimmed_desc = if desc.len() > 40 {
format!("{}...", &desc[..37])
} else {
desc.to_string()
};
let time_built_epoch = branch
.get("timebuildupdated")
.or_else(|| branch.get("timeupdated"))
.and_then(|t| t.as_str())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let time_updated_epoch = branch
.get("timeupdated")
.and_then(|t| t.as_str())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let time_built = if time_built_epoch > 0 {
fmt_relative(time_built_epoch)
} else {
String::new()
};
let time_updated = if time_updated_epoch > 0 {
fmt_relative(time_updated_epoch)
} else {
String::new()
};
let pwd = branch.get("pwdrequired").and_then(|p| p.as_str()) == Some("1");
let mut name_str = bname.clone();
if pwd {
name_str.push_str(" [password]");
}
branch_entries.push((
time_updated_epoch,
[name_str, trimmed_desc, build_id, time_built, time_updated],
));
}
branch_entries.sort_by(|a, b| b.0.cmp(&a.0));
let branch_rows: Vec<[String; 5]> =
branch_entries.into_iter().map(|(_, r)| r).collect();
println!("\nBranches:");
let mut builder = TableBuilder::new();
builder.push_record(["NAME", "DESCRIPTION", "BUILD", "TIME BUILT", "TIME UPDATED"]);
for r in &branch_rows {
builder.push_record(r);
}
let branch_table = builder
.build()
.with(Style::blank())
.with(tabled::settings::Padding::new(0, 2, 0, 0))
.to_string();
for line in branch_table.lines() {
println!(" {line}");
}
}
}
Ok(())
}
async fn run_manifests(args: ManifestsArgs, auth: &AuthOptions) -> Result<(), CliError> {
let app_id = AppId(args.app);
let (_client, kv) = fetch_app_kv(auth, app_id).await?;
let branch = args.branch.as_deref().unwrap_or("public");
let depots = kv.get("depots").ok_or(CliError::NoDepots)?;
if args.format == Some(OutputFormat::Json) {
let mut entries = Vec::new();
if let KvValue::Children(ref map) = depots.value {
for (key, depot) in map {
let Ok(depot_id) = key.parse::<u32>() else {
continue;
};
if let Some(manifests) = depot.get("manifests")
&& let Some(branch_kv) = manifests.get(branch)
{
let gid = branch_kv
.get("gid")
.and_then(|g| g.as_str())
.or_else(|| branch_kv.as_str());
if let Some(manifest_id) = gid {
entries.push(serde_json::json!({
"depot": depot_id,
"manifest": manifest_id,
}));
}
}
}
}
println!("{}", serde_json::to_string_pretty(&entries)?);
return Ok(());
}
println!("Manifests for branch '{branch}':\n");
if let KvValue::Children(ref map) = depots.value {
let mut rows: Vec<[String; 3]> = Vec::new();
for (key, depot) in map {
let Ok(depot_id) = key.parse::<u32>() else {
continue;
};
let dname = depot.get("name").and_then(|n| n.as_str()).unwrap_or("");
if let Some(manifests) = depot.get("manifests") {
if let Some(branch_kv) = manifests.get(branch) {
let gid = branch_kv
.get("gid")
.and_then(|g| g.as_str())
.or_else(|| branch_kv.as_str())
.unwrap_or("--");
rows.push([
format!("depot {depot_id}"),
format!("-> {gid}"),
dname.to_string(),
]);
} else {
rows.push([
format!("depot {depot_id}"),
"-> --".to_string(),
dname.to_string(),
]);
}
} else {
rows.push([
format!("depot {depot_id}"),
"-> --".to_string(),
dname.to_string(),
]);
}
}
let mut builder = TableBuilder::new();
for r in &rows {
builder.push_record(r);
}
let table = builder
.build()
.with(Style::blank())
.with(tabled::settings::Padding::new(0, 2, 0, 0))
.to_string();
for line in table.lines() {
println!(" {line}");
}
}
Ok(())
}
async fn run_files(args: FilesArgs, auth: &AuthOptions) -> Result<(), CliError> {
let raw_bytes = args.bytes;
let app_id = AppId(args.app);
let (client, kv) = fetch_app_kv(auth, app_id).await?;
let branch = args.branch.as_deref().unwrap_or("public");
let depot_id = args
.depot
.map(DepotId)
.or_else(|| kv.get("depots").and_then(|d| find_first_depot(d).ok()))
.ok_or(CliError::NoDepots)?;
let manifest_id = args
.manifest
.map(ManifestId)
.or_else(|| {
kv.get("depots")
.and_then(|d| find_manifest_for_depot(d, depot_id, branch).ok())
})
.ok_or(CliError::ManifestNotFound {
depot: depot_id.0,
branch: branch.to_string(),
})?;
let depot_key = client.get_depot_decryption_key(depot_id, app_id).await?;
let request_code = client
.get_manifest_request_code(app_id, depot_id, manifest_id, Some(branch), None)
.await?
.unwrap_or(0);
let cdn_servers = client.get_cdn_servers(CellId(0), Some(5)).await?;
let cdn_server = cdn_servers.first().ok_or(CliError::NoCdnServers)?;
let cdn = CdnClient::new().map_err(CliError::Steam)?;
let manifest_data = cdn
.download_manifest(cdn_server, depot_id, manifest_id, request_code, None)
.await?;
let manifest_bytes = decompress_manifest(&manifest_data)?;
let mut manifest = DepotManifest::parse(&manifest_bytes)?;
if manifest.filenames_encrypted && !args.raw {
manifest.decrypt_filenames(&depot_key)?;
}
if args.format == Some(OutputFormat::Json) {
let entries: Vec<serde_json::Value> = manifest
.files
.iter()
.map(|f| {
serde_json::json!({
"filename": &f.filename,
"size": f.size,
"flags": f.flags,
"chunks": f.chunks.len(),
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&entries)?);
return Ok(());
}
if args.format == Some(OutputFormat::Plain) {
for file in &manifest.files {
println!("{}", file.filename);
}
} else {
let total_size: u64 = manifest.files.iter().map(|f| f.size).sum();
let file_count = manifest.files.len();
let created = manifest
.creation_time
.map(|t| fmt_timestamp(t as u64))
.unwrap_or_else(|| "-".into());
println!("Depot: {}", depot_id);
println!("Manifest: {}", manifest_id);
println!("Created: {}", created);
println!("Size: {}", fmt_size(total_size));
println!("Files: {}", file_count);
println!();
let file_rows: Vec<[String; 3]> = manifest
.files
.iter()
.map(|f| {
let is_dir = steamroom::enums::DepotFileFlags(f.flags).is_directory();
let name = if is_dir {
format!("{}/", f.filename)
} else {
f.filename.clone()
};
let size_str = if raw_bytes {
f.size.to_string()
} else {
fmt_size(f.size)
};
[name, size_str, f.chunks.len().to_string()]
})
.collect();
let mut builder = TableBuilder::new();
builder.push_record(["FILENAME", "SIZE", "CHUNKS"]);
for r in &file_rows {
builder.push_record(r);
}
let table = builder
.build()
.with(Style::blank())
.with(tabled::settings::Padding::new(0, 2, 0, 0))
.with(
tabled::settings::Modify::new(tabled::settings::object::Columns::new(1..))
.with(tabled::settings::Alignment::right()),
)
.to_string();
println!("{table}");
}
Ok(())
}
fn kv_to_json(kv: &KeyValue) -> serde_json::Value {
match &kv.value {
KvValue::Children(map) => {
let obj: serde_json::Map<String, serde_json::Value> = map
.iter()
.map(|(k, v)| (k.clone(), kv_to_json(v)))
.collect();
serde_json::Value::Object(obj)
}
KvValue::String(s) => serde_json::Value::String(s.clone()),
KvValue::Int32(v) => serde_json::Value::Number((*v).into()),
KvValue::UInt64(v) => serde_json::Value::Number((*v).into()),
KvValue::Int64(v) => serde_json::Value::Number((*v).into()),
KvValue::Float32(v) => serde_json::Number::from_f64(*v as f64)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null),
_ => serde_json::Value::Null,
}
}
async fn run_workshop(
args: WorkshopArgs,
auth: &AuthOptions,
show_progress: bool,
) -> Result<(), CliError> {
let client = connect_and_login(auth).await?;
info!("fetching workshop item {} details...", args.item);
let req = steamroom::generated::CPublishedFileGetDetailsRequest {
publishedfileids: vec![args.item],
includechildren: Some(true),
..Default::default()
};
let resp = client
.call_service_method(
"PublishedFile.GetDetails#1",
&prost::Message::encode_to_vec(&req),
)
.await?;
let details: steamroom::generated::CPublishedFileGetDetailsResponse = resp.decode()?;
let item = details
.publishedfiledetails
.first()
.ok_or(CliError::NoProductInfo(args.app))?;
let title = item.title.as_deref().unwrap_or("(untitled)");
let hcontent = item.hcontent_file.unwrap_or(0);
let file_size = item.file_size.unwrap_or(0);
let consumer_app = item.consumer_appid.unwrap_or(args.app);
let filename = item.filename.as_deref().unwrap_or("workshop_content");
info!("workshop item: {title}");
info!(" content manifest: {hcontent}");
info!(" file: {filename} ({} bytes)", file_size);
if hcontent == 0 {
info!("no downloadable content for this workshop item");
return Ok(());
}
let app_id = AppId(consumer_app);
let depot_id = DepotId(consumer_app);
let manifest_id = ManifestId(hcontent);
let depot_key = client.get_depot_decryption_key(depot_id, app_id).await?;
let cdn_servers = client.get_cdn_servers(CellId(0), Some(5)).await?;
if cdn_servers.is_empty() {
return Err(CliError::NoCdnServers);
}
let cdn_server = &cdn_servers[0];
let cdn_pool = steamroom::cdn::CdnServerPool::new(cdn_servers.clone());
let cdn = CdnClient::new().map_err(CliError::Steam)?;
let request_code = client
.get_manifest_request_code(app_id, depot_id, manifest_id, None, None)
.await?
.unwrap_or(0);
let manifest_data = cdn
.download_manifest(cdn_server, depot_id, manifest_id, request_code, None)
.await?;
let manifest_bytes = decompress_manifest(&manifest_data)?;
let mut manifest = DepotManifest::parse(&manifest_bytes)?;
if manifest.filenames_encrypted {
manifest.decrypt_filenames(&depot_key)?;
}
let output_dir = args
.output
.unwrap_or_else(|| PathBuf::from("workshop").join(args.item.to_string()));
std::fs::create_dir_all(&output_dir)?;
let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel();
let fetcher = steamroom_client::download::CdnChunkFetcher::new(cdn, cdn_pool, None);
let job = steamroom_client::download::DepotJob::builder()
.depot_id(depot_id)
.depot_key(depot_key)
.install_dir(output_dir.clone())
.event_sender(event_tx)
.build()
.map_err(|e| CliError::Io(std::io::Error::other(e)))?;
info!("downloading to {}", output_dir.display());
let progress_handle = download::spawn_progress_renderer(event_rx, show_progress);
let stats = job
.download(&manifest, std::sync::Arc::new(fetcher))
.await
.map_err(|e| CliError::Io(std::io::Error::other(e)))?;
drop(job);
let _ = progress_handle.await;
info!(
"workshop download complete: {} files, {}",
stats.files_completed,
fmt_size(stats.bytes_downloaded)
);
Ok(())
}