diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c740d93..b1ef59d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,30 +10,41 @@ env: CARGO_TERM_COLOR: always jobs: - check: - runs-on: ${{ matrix.os }} + validation: strategy: matrix: - os: [windows-latest, macos-latest, ubuntu-latest] + os: [ubuntu-latest, macos-latest, windows-latest] + toolchain: [stable, beta] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 - - name: Clippy + - name: Install ${{ matrix.toolchain }} toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.toolchain }} + components: clippy, rustfmt + - name: Setup cache + uses: Swatinem/rust-cache@v2 + - name: Commune with clippy run: cargo clippy --all -- -D warnings - - fmt: - needs: check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - name: Check formatting run: cargo fmt --all -- --check + - name: Run test suite + run: cargo test + - name: Check docs + env: + RUSTDOCFLAGS: -Dwarnings + run: cargo doc --all --no-deps - docs: - needs: fmt + wasm: runs-on: ubuntu-latest - env: - RUSTDOCFLAGS: -Dwarnings steps: - uses: actions/checkout@v3 - - name: Check docs - run: cargo doc --all --no-deps + - name: Install stable toolchain + uses: dtolnay/rust-toolchain@stable + - name: Install wasm-pack + uses: taiki-e/install-action@wasm-pack + - name: Run wasm tests (`--no-default-features`) + run: wasm-pack test --node --no-default-features + - name: Run wasm tests (`--all-features`) + run: wasm-pack test --node --all-features diff --git a/Cargo.toml b/Cargo.toml index 43350cc..bf0b79a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "steamlocate" -version = "1.2.1" +version = "2.0.0-beta.2" authors = ["William Venner "] edition = "2018" repository = "https://github.com/WilliamVenner/steamlocate-rs" @@ -10,22 +10,38 @@ readme = "README.md" keywords = ["steam", "vdf", "appmanifest", "directory", "steamapps"] categories = ["os", "hardware-support", "filesystem", "accessibility"] +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + [features] -default = [] -shortcuts_extras = ["crc"] -steamid_ng = ["steamid-ng"] +default = ["locate"] +locate = ["locate_backend"] [dependencies] -steamy-vdf = "0.2" -keyvalues-parser = "0.1" +crc = "3.0" +keyvalues-parser = "0.2" +keyvalues-serde = "0.2" serde = { version = "1.0.0", features = ["derive"] } -keyvalues-serde = "0.1" -crc = { version = "3.0", optional = true } +# Platform-specific dependencies used for locating the steam dir +[target."cfg(target_os=\"windows\")".dependencies] +locate_backend = { package = "winreg", version = "0.51", optional = true } +[target."cfg(not(target_os=\"windows\"))".dependencies] +locate_backend = { package = "dirs", version = "5", optional = true } + +[dev-dependencies] +insta = { version = "1.34.0", features = ["ron"] } +wasm-bindgen-test = "0.3.39" + +[[example]] +name = "appmanifest" +required-features = ["locate"] -steamid-ng = { version = "1", optional = true } +[[example]] +name = "overview" +required-features = ["locate"] -[target.'cfg(target_os="windows")'.dependencies] -winreg = "0.11" -[target.'cfg(not(target_os="windows"))'.dependencies] -dirs = "5" +[[example]] +name = "shortcuts" +required-features = ["locate"] diff --git a/README.md b/README.md index 528a2f9..d9dd57b 100644 --- a/README.md +++ b/README.md @@ -5,127 +5,97 @@ # steamlocate -A crate which efficiently locates any Steam application on the filesystem, and/or the Steam installation itself. +A crate which efficiently locates any Steam application on the filesystem, +and/or the Steam installation itself. -This crate is best used when you do not want to depend on the Steamworks API for your program. In some cases the Steamworks API may be more appropriate to use, in which case I recommend the fantastic [steamworks](https://github.com/Thinkofname/steamworks-rs) crate. You don't need to be a Steamworks partner to get installation directory locations from the Steamworks API. +This crate is best used when you do not want to depend on the Steamworks API +for your program. In some cases the Steamworks API may be more appropriate to +use, in which case I recommend the fantastic +[steamworks](https://github.com/Thinkofname/steamworks-rs) crate. You don't +need to be a Steamworks partner to get installation directory locations from +the Steamworks API. -**This crate supports Windows, macOS and Linux.** +# Using steamlocate -## Using steamlocate -Simply add to your [Cargo.toml](https://doc.rust-lang.org/cargo/reference/manifest.html) file: -```toml -[dependencies] -steamlocate = "0.*" -``` - -To use [steamid-ng](#steamid-ng-support) with steamlocate, add this to your [Cargo.toml](https://doc.rust-lang.org/cargo/reference/manifest.html) file: -```toml -[dependencies] -steamid-ng = "1.*" +Simply add `steamlocate` using +[`cargo`](https://doc.rust-lang.org/cargo/getting-started/installation.html). -[dependencies.steamlocate] -version = "0.*" -features = ["steamid_ng"] +```console +$ cargo add steamlocate ``` -## Caching -All functions in this crate cache their results, meaning you can call them as many times as you like and they will always return the same reference. +## Feature flags -If you need to get uncached results, simply instantiate a new [SteamDir](https://docs.rs/steamlocate/*/steamlocate/struct.SteamDir.html). +Default: `locate` -## steamid-ng Support -This crate supports [steamid-ng](https://docs.rs/steamid-ng) and can automatically convert [SteamApp::last_user](struct.SteamApp.html#structfield.last_user) to a [SteamID](https://docs.rs/steamid-ng/*/steamid_ng/struct.SteamID.html) for you. +| Feature flag | Description | +| :---: | :--- | +| `locate` | Enables automatically detecting the Steam installation on supported platforms (currently Windows, MacOS, and Linux). Unsupported platforms will return a runtime error. | -To enable this support, [use the `steamid_ng` Cargo.toml feature](#using-steamlocate). +# Examples -## Examples +## Locate the Steam installation and a specific game -#### Locate the installed Steam directory -```rust -extern crate steamlocate; -use steamlocate::SteamDir; +The `SteamDir` is going to be your entrypoint into _most_ parts of the API. +After you locate it you can access related information. -match SteamDir::locate() { - Some(steamdir) => println!("{:#?}", steamdir), - None => panic!("Couldn't locate Steam on this computer!") -} +```rust,ignore +let steam_dir = steamlocate::SteamDir::locate()?; +println!("Steam installation - {}", steam_dir.path().display()); +// ^^ prints something like `Steam installation - C:\Program Files (x86)\Steam` + +const GMOD_APP_ID: u32 = 4_000; +let (garrys_mod, _lib) = steam_dir + .find_app(GMOD_APP_ID)? + .expect("Of course we have G Mod"); +assert_eq!(garrys_mod.name.as_ref().unwrap(), "Garry's Mod"); +println!("{garrys_mod:#?}"); +// ^^ prints something like vv ``` -```rust -SteamDir ( - path: PathBuf: "C:\\Program Files (x86)\\Steam" -) +```rust,ignore +App { + app_id: 4_000, + install_dir: "GarrysMod", + name: Some("Garry's Mod"), + universe: Some(Public), + // much much more data +} ``` -#### Locate an installed Steam app by its app ID -This will locate Garry's Mod anywhere on the filesystem. -```rust -extern crate steamlocate; -use steamlocate::SteamDir; +## Get an overview of all libraries and apps on the system -let mut steamdir = SteamDir::locate().unwrap(); -match steamdir.app(&4000) { - Some(app) => println!("{:#?}", app), - None => panic!("Couldn't locate Garry's Mod on this computer!") -} -``` -```rust -SteamApp ( - appid: u32: 4000, - path: PathBuf: "C:\\Program Files (x86)\\steamapps\\common\\GarrysMod", - vdf: , - name: Some(String: "Garry's Mod"), - last_user: Some(u64: 76561198040894045) -) -``` +You can iterate over all of Steam's libraries from the steam dir. Then from each library you +can iterate over all of its apps. -#### Locate all Steam apps on this filesystem -```rust -extern crate steamlocate; -use steamlocate::{SteamDir, SteamApp}; -use std::collections::HashMap; +```rust,ignore +let steam_dir = steamlocate::SteamDir::locate()?; -let mut steamdir = SteamDir::locate().unwrap(); -let apps: &HashMap> = steamdir.apps(); +for library in steam_dir.libraries()? { + let library = library?; + println!("Library - {}", library.path().display()); -println!("{:#?}", apps); -``` -```rust -{ - 4000: SteamApp ( - appid: u32: 4000, - path: PathBuf: "C:\\Program Files (x86)\\steamapps\\common\\GarrysMod", - vdf: , - name: Some(String: "Garry's Mod"), - last_user: Some(u64: 76561198040894045) - ) - ... + for app in library.apps() { + let app = app?; + println!(" App {} - {:?}", app.app_id, app.name); + } } ``` -#### Locate all Steam library folders -```rust -extern crate steamlocate; -use steamlocate::{SteamDir, LibraryFolders}; -use std::{vec, path::PathBuf}; - -let mut steamdir: SteamDir = SteamDir::locate().unwrap(); -let libraryfolders: &LibraryFolders = steamdir.libraryfolders(); -let paths: &Vec = &libraryfolders.paths; - -println!("{:#?}", paths); -``` -```rust -{ - "C:\\Program Files (x86)\\Steam\\steamapps", - "D:\\Steam\\steamapps", - "E:\\Steam\\steamapps", - "F:\\Steam\\steamapps", - ... -} +On my laptop this prints + +```text +Library - /home/wintermute/.local/share/Steam + App 1628350 - Steam Linux Runtime 3.0 (sniper) + App 1493710 - Proton Experimental + App 4000 - Garry's Mod +Library - /home/wintermute/temp steam lib + App 391540 - Undertale + App 1714040 - Super Auto Pets + App 2348590 - Proton 8.0 ``` ## Contribution Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the MIT license, -shall be dual licensed as above, without any additional terms or conditions. +shall be licensed as above, without any additional terms or conditions. diff --git a/examples/appmanifest.rs b/examples/appmanifest.rs new file mode 100644 index 0000000..79dc53d --- /dev/null +++ b/examples/appmanifest.rs @@ -0,0 +1,19 @@ +use std::{env, process::exit}; + +use steamlocate::SteamDir; + +fn main() { + let args: Vec<_> = env::args().collect(); + if args.len() != 2 || args[1].parse::().is_err() { + eprintln!("Usage: cargo run --example appmanifest -- "); + exit(1); + } + let app_id: u32 = args[1].parse().expect(" should be a u32"); + + let steam_dir = SteamDir::locate().unwrap(); + match steam_dir.find_app(app_id) { + Ok(Some((app, _library))) => println!("Found app - {:#?}", app), + Ok(None) => println!("No app found for {}", app_id), + Err(err) => println!("Failed reading app: {err}"), + } +} diff --git a/examples/overview.rs b/examples/overview.rs new file mode 100644 index 0000000..7b6f4ae --- /dev/null +++ b/examples/overview.rs @@ -0,0 +1,26 @@ +use steamlocate::SteamDir; + +fn main() { + let steamdir = SteamDir::locate().unwrap(); + println!("Steam Dir - {:?}", steamdir.path()); + + // TODO: use `anyhow` to make error handling here simpler + for maybe_library in steamdir.libraries().unwrap() { + match maybe_library { + Err(err) => eprintln!("Failed reading library: {err}"), + Ok(library) => { + println!(" Library - {:?}", library.path()); + for app in library.apps() { + match app { + Ok(app) => println!( + " App {} - {}", + app.app_id, + app.name.as_deref().unwrap_or("") + ), + Err(err) => println!(" Failed reading app: {err}"), + } + } + } + } + } +} diff --git a/examples/shortcuts.rs b/examples/shortcuts.rs index 2984b26..74fe0b4 100644 --- a/examples/shortcuts.rs +++ b/examples/shortcuts.rs @@ -2,6 +2,11 @@ fn main() { let mut steamdir = steamlocate::SteamDir::locate().unwrap(); - let shortcuts = steamdir.shortcuts(); - println!("Shortcuts - {:#?}", shortcuts); + println!("Shortcuts:"); + for maybe_shortcut in steamdir.shortcuts().unwrap() { + match maybe_shortcut { + Ok(shortcut) => println!(" - {} {}", shortcut.app_id, shortcut.app_name), + Err(err) => println!("Failed reading potential shortcut: {err}"), + } + } } diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..e557b2a --- /dev/null +++ b/src/app.rs @@ -0,0 +1,462 @@ +use std::{ + collections::BTreeMap, + fs, + path::{Path, PathBuf}, + slice, time, +}; + +use crate::{ + error::{ParseError, ParseErrorKind}, + Error, Library, Result, +}; + +use serde::{Deserialize, Deserializer}; + +pub struct Iter<'library> { + library: &'library Library, + app_ids: slice::Iter<'library, u32>, +} + +impl<'library> Iter<'library> { + pub(crate) fn new(library: &'library Library) -> Self { + Self { + library, + app_ids: library.app_ids().iter(), + } + } +} + +impl<'library> Iterator for Iter<'library> { + type Item = Result; + + fn next(&mut self) -> Option { + let app_id = *self.app_ids.next()?; + if let some_res @ Some(_) = self.library.app(app_id) { + some_res + } else { + // We use the listing from libraryfolders, so all apps should be accounted for + Some(Err(Error::MissingExpectedApp { app_id })) + } + } +} + +/// Metadata for an installed Steam app +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[cfg_attr(test, derive(serde::Serialize))] +#[non_exhaustive] +#[serde(rename_all = "PascalCase")] +pub struct App { + /// The app ID of this Steam app + #[serde(rename = "appid")] + pub app_id: u32, + /// The name of the installation directory of this Steam app e.g. `"GarrysMod"` + /// + /// If you're trying to get the app's installation directory then take a look at + /// [`Library::resolve_app_dir()`][crate::Library::resolve_app_dir] + #[serde(rename = "installdir")] + pub install_dir: String, + /// The store name of the Steam app + #[serde(rename = "name")] + pub name: Option, + /// The SteamID64 of the last Steam user that played this game on the filesystem + #[serde(rename = "LastOwner")] + pub last_user: Option, + + pub universe: Option, + pub launcher_path: Option, + pub state_flags: Option, + // TODO: Need to handle this for serializing too before `App` can `impl Serialize` + #[serde( + alias = "lastupdated", + default, + deserialize_with = "de_time_as_secs_from_unix_epoch" + )] + pub last_updated: Option, + // Can't find anything on what these values mean. I've seen 0, 2, 4, 6, and 7 + pub update_result: Option, + pub size_on_disk: Option, + #[serde(rename = "buildid")] + pub build_id: Option, + pub bytes_to_download: Option, + pub bytes_downloaded: Option, + pub bytes_to_stage: Option, + pub bytes_staged: Option, + pub staging_size: Option, + #[serde(rename = "TargetBuildID")] + pub target_build_id: Option, + pub auto_update_behavior: Option, + pub allow_other_downloads_while_running: Option, + pub scheduled_auto_update: Option, + pub full_validate_before_next_update: Option, + pub full_validate_after_next_update: Option, + #[serde(default)] + pub installed_depots: BTreeMap, + #[serde(default)] + pub staged_depots: BTreeMap, + #[serde(default)] + pub user_config: BTreeMap, + #[serde(default)] + pub mounted_config: BTreeMap, + #[serde(default)] + pub install_scripts: BTreeMap, + #[serde(default)] + pub shared_depots: BTreeMap, +} + +impl App { + pub(crate) fn new(manifest: &Path) -> Result { + let contents = fs::read_to_string(manifest).map_err(|io| Error::io(io, manifest))?; + keyvalues_serde::from_str(&contents) + .map_err(|err| Error::parse(ParseErrorKind::App, ParseError::from_serde(err), manifest)) + } +} + +macro_rules! impl_deserialize_from_u64 { + ( $ty_name:ty ) => { + impl<'de> Deserialize<'de> for $ty_name { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let value = u64::deserialize(deserializer)?; + Ok(Self::from(value)) + } + } + }; +} + +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(test, derive(serde::Serialize))] +pub enum Universe { + Invalid, + Public, + Beta, + Internal, + Dev, + Unknown(u64), +} + +// More info: +// https://developer.valvesoftware.com/wiki/SteamID#Universes_Available_for_Steam_Accounts +impl From for Universe { + fn from(value: u64) -> Self { + match value { + 0 => Self::Invalid, + 1 => Self::Public, + 2 => Self::Beta, + 3 => Self::Internal, + 4 => Self::Dev, + unknown => Self::Unknown(unknown), + } + } +} + +impl_deserialize_from_u64!(Universe); + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq)] +#[cfg_attr(test, derive(serde::Serialize))] +pub struct StateFlags(pub u64); + +impl StateFlags { + pub fn flags(self) -> StateFlagIter { + self.into() + } +} + +#[derive(Clone, Debug, Default)] +pub struct StateFlagIter(Option); + +impl StateFlagIter { + fn from_valid(valid: ValidIter) -> Self { + Self(Some(StateFlagIterInner::Valid(valid))) + } +} + +impl From for StateFlagIter { + fn from(state: StateFlags) -> Self { + Self(Some(state.into())) + } +} + +impl Iterator for StateFlagIter { + type Item = StateFlag; + + fn next(&mut self) -> Option { + // Tiny little state machine: + // - None indicates the iterator is done (trap state) + // - Invalid will emit invalid once and finish + // - Valid will pull on the inner iterator till it's finished + let current = std::mem::take(self); + let (next, ret) = match current.0? { + StateFlagIterInner::Invalid => (Self::default(), StateFlag::Invalid), + StateFlagIterInner::Valid(mut valid) => { + let ret = valid.next()?; + (Self::from_valid(valid), ret) + } + }; + *self = next; + Some(ret) + } +} + +#[derive(Clone, Debug, Default)] +enum StateFlagIterInner { + #[default] + Invalid, + Valid(ValidIter), +} + +impl From for StateFlagIterInner { + fn from(state: StateFlags) -> Self { + if state.0 == 0 { + Self::Invalid + } else { + Self::Valid(state.into()) + } + } +} + +#[derive(Clone, Debug)] +struct ValidIter { + state: StateFlags, + offset: u8, +} + +impl From for ValidIter { + fn from(state: StateFlags) -> Self { + Self { state, offset: 0 } + } +} + +impl Iterator for ValidIter { + type Item = StateFlag; + + fn next(&mut self) -> Option { + // Rotate over each bit and emit each one that is set + loop { + let flag = 1u64.checked_shl(self.offset.into())?; + self.offset = self.offset.checked_add(1)?; + if self.state.0 & flag != 0 { + break Some(StateFlag::from_bit_offset(self.offset - 1)); + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(test, derive(serde::Serialize))] +pub enum StateFlag { + Invalid, + Uninstalled, + UpdateRequired, + FullyInstalled, + Encrypted, + Locked, + FilesMissing, + AppRunning, + FilesCorrupt, + UpdateRunning, + UpdatePaused, + UpdateStarted, + Uninstalling, + BackupRunning, + Reconfiguring, + Validating, + AddingFiles, + Preallocating, + Downloading, + Staging, + Committing, + UpdateStopping, + Unknown(u8), +} + +// More info: https://github.com/lutris/lutris/blob/master/docs/steam.rst +impl StateFlag { + fn from_bit_offset(offset: u8) -> Self { + match offset { + 0 => Self::Uninstalled, + 1 => Self::UpdateRequired, + 2 => Self::FullyInstalled, + 3 => Self::Encrypted, + 4 => Self::Locked, + 5 => Self::FilesMissing, + 6 => Self::AppRunning, + 7 => Self::FilesCorrupt, + 8 => Self::UpdateRunning, + 9 => Self::UpdatePaused, + 10 => Self::UpdateStarted, + 11 => Self::Uninstalling, + 12 => Self::BackupRunning, + 16 => Self::Reconfiguring, + 17 => Self::Validating, + 18 => Self::AddingFiles, + 19 => Self::Preallocating, + 20 => Self::Downloading, + 21 => Self::Staging, + 22 => Self::Committing, + 23 => Self::UpdateStopping, + unknown @ (13..=15 | 24..) => Self::Unknown(unknown), + } + } +} + +fn de_time_as_secs_from_unix_epoch<'de, D>( + deserializer: D, +) -> std::result::Result, D::Error> +where + D: Deserializer<'de>, +{ + let maybe_time = + >::deserialize(deserializer)?.and_then(time_as_secs_from_unix_epoch); + Ok(maybe_time) +} + +fn time_as_secs_from_unix_epoch(secs: u64) -> Option { + let offset = time::Duration::from_secs(secs); + time::SystemTime::UNIX_EPOCH.checked_add(offset) +} + +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(test, derive(serde::Serialize))] +pub enum AllowOtherDownloadsWhileRunning { + UseGlobalSetting, + Allow, + Never, + Unknown(u64), +} + +impl From for AllowOtherDownloadsWhileRunning { + fn from(value: u64) -> Self { + match value { + 0 => Self::UseGlobalSetting, + 1 => Self::Allow, + 2 => Self::Never, + unknown => Self::Unknown(unknown), + } + } +} + +impl Default for AllowOtherDownloadsWhileRunning { + fn default() -> Self { + Self::UseGlobalSetting + } +} + +impl_deserialize_from_u64!(AllowOtherDownloadsWhileRunning); + +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(test, derive(serde::Serialize))] +pub enum AutoUpdateBehavior { + KeepUpToDate, + OnlyUpdateOnLaunch, + UpdateWithHighPriority, + Unknown(u64), +} + +impl From for AutoUpdateBehavior { + fn from(value: u64) -> Self { + match value { + 0 => Self::KeepUpToDate, + 1 => Self::OnlyUpdateOnLaunch, + 2 => Self::UpdateWithHighPriority, + unknown => Self::Unknown(unknown), + } + } +} + +// TODO: Maybe don't have these defaults? +impl Default for AutoUpdateBehavior { + fn default() -> Self { + Self::KeepUpToDate + } +} + +impl_deserialize_from_u64!(AutoUpdateBehavior); + +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(test, derive(serde::Serialize))] +pub enum ScheduledAutoUpdate { + Zero, + Time(time::SystemTime), +} + +impl<'de> Deserialize<'de> for ScheduledAutoUpdate { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let sched_auto_upd = match u64::deserialize(deserializer)? { + 0 => Self::Zero, + secs => { + let time = time_as_secs_from_unix_epoch(secs) + .ok_or_else(|| serde::de::Error::custom("Exceeded max time"))?; + Self::Time(time) + } + }; + Ok(sched_auto_upd) + } +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq)] +#[cfg_attr(test, derive(serde::Serialize))] +#[non_exhaustive] +pub struct Depot { + pub manifest: u64, + pub size: u64, + #[serde(rename = "dlcappid")] + pub dlc_app_id: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app_from_manifest_str(s: &str) -> App { + keyvalues_serde::from_str(s).unwrap() + } + + #[test] + fn minimal() { + let minimal = r#" +"AppState" +{ + "appid" "2519830" + "installdir" "Resonite" +} +"#; + + let app = app_from_manifest_str(minimal); + insta::assert_ron_snapshot!(app); + } + + #[test] + fn sanity() { + let manifest = include_str!("../tests/assets/appmanifest_230410.acf"); + let app = app_from_manifest_str(manifest); + insta::assert_ron_snapshot!(app); + } + + #[test] + fn more_sanity() { + let manifest = include_str!("../tests/assets/appmanifest_599140.acf"); + let app = app_from_manifest_str(manifest); + insta::assert_ron_snapshot!(app); + } + + #[test] + fn state_flags() { + let mut it = StateFlags(0).flags(); + assert_eq!(it.next(), Some(StateFlag::Invalid)); + assert_eq!(it.next(), None); + + let mut it = StateFlags(4).flags(); + assert_eq!(it.next(), Some(StateFlag::FullyInstalled)); + assert_eq!(it.next(), None); + + let mut it = StateFlags(6).flags(); + assert_eq!(it.next(), Some(StateFlag::UpdateRequired)); + assert_eq!(it.next(), Some(StateFlag::FullyInstalled)); + assert_eq!(it.next(), None); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..2228c6f --- /dev/null +++ b/src/config.rs @@ -0,0 +1,41 @@ +use serde::Deserialize; +use std::collections::HashMap; + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +pub(crate) struct Store { + pub(crate) software: Software, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +pub(crate) struct Software { + pub(crate) valve: Valve, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "PascalCase")] +pub(crate) struct Valve { + pub(crate) steam: Steam, +} + +#[derive(Deserialize, Debug)] +pub(crate) struct Steam { + #[serde(rename = "CompatToolMapping")] + pub(crate) mapping: HashMap, +} + +/// An instance of a compatibility tool. +#[derive(Deserialize, Debug, Clone)] +pub struct CompatTool { + /// The name of the tool. + /// + /// Example: `proton_411` + pub name: Option, + + // Unknown option, may be used in the future + pub config: Option, + + // Unknown option, may be used in the future + pub priority: Option, +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..6176a36 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,245 @@ +use std::{ + fmt, io, + path::{Path, PathBuf}, +}; + +pub type Result = std::result::Result; + +#[derive(Debug)] +#[non_exhaustive] +pub enum Error { + FailedLocate(LocateError), + InvalidSteamDir(ValidationError), + Io { + inner: io::Error, + path: PathBuf, + }, + Parse { + kind: ParseErrorKind, + error: ParseError, + path: PathBuf, + }, + MissingExpectedApp { + app_id: u32, + }, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::FailedLocate(error) => { + write!(f, "Failed locating the steam dir. Error: {error}") + } + Self::InvalidSteamDir(error) => { + write!(f, "Failed validating steam dir. Error: {error}") + } + Self::Io { inner: err, path } => { + write!(f, "Encountered an I/O error: {} at {}", err, path.display()) + } + Self::Parse { kind, error, path } => write!( + f, + "Failed parsing VDF file. File kind: {:?}, Error: {} at {}", + kind, + error, + path.display(), + ), + Self::MissingExpectedApp { app_id } => { + write!(f, "Missing expected app with id: {}", app_id) + } + } + } +} + +impl std::error::Error for Error {} + +impl Error { + #[cfg(feature = "locate")] + pub(crate) fn locate(locate: LocateError) -> Self { + Self::FailedLocate(locate) + } + + pub(crate) fn validation(validation: ValidationError) -> Self { + Self::InvalidSteamDir(validation) + } + + pub(crate) fn io(io: io::Error, path: &Path) -> Self { + Self::Io { + inner: io, + path: path.to_owned(), + } + } + + pub(crate) fn parse(kind: ParseErrorKind, error: ParseError, path: &Path) -> Self { + Self::Parse { + kind, + error, + path: path.to_owned(), + } + } +} + +#[derive(Clone, Debug)] +pub enum LocateError { + Backend(BackendError), + Unsupported, +} + +impl LocateError { + #[cfg(all(feature = "locate", target_os = "windows"))] + pub(crate) fn winreg(io: io::Error) -> Self { + Self::Backend(BackendError { + inner: BackendErrorInner(std::sync::Arc::new(io)), + }) + } + + #[cfg(all(feature = "locate", not(target_os = "windows")))] + pub(crate) fn no_home() -> Self { + Self::Backend(BackendError { + inner: BackendErrorInner::NoHome, + }) + } +} + +impl fmt::Display for LocateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Backend(error) => error.fmt(f), + Self::Unsupported => f.write_str("Unsupported platform"), + } + } +} + +#[derive(Clone, Debug)] +pub struct BackendError { + #[cfg(feature = "locate")] + #[allow(dead_code)] // Only used for displaying currently + inner: BackendErrorInner, +} + +impl fmt::Display for BackendError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + #[cfg(all(feature = "locate", target_os = "windows"))] + { + write!(f, "{}", self.inner.0) + } + #[cfg(all(feature = "locate", not(target_os = "windows")))] + { + match self.inner { + BackendErrorInner::NoHome => f.write_str("Unable to locate the user's $HOME"), + } + } + #[cfg(not(feature = "locate"))] + { + // "Use" the unused value + let _ = f; + unreachable!("This should never be constructed!"); + } + } +} + +// TODO: move all this conditional junk into different modules, so that I don't have to keep +// repeating it everywhere +// TODO: ^^ +#[derive(Clone, Debug)] +#[cfg(all(feature = "locate", target_os = "windows"))] +struct BackendErrorInner(std::sync::Arc); +#[derive(Clone, Debug)] +#[cfg(all(feature = "locate", not(target_os = "windows")))] +enum BackendErrorInner { + NoHome, +} + +#[derive(Clone, Debug)] +pub struct ValidationError { + #[allow(dead_code)] // Only used for displaying currently + inner: ValidationErrorInner, +} + +impl ValidationError { + pub(crate) fn missing_dir() -> Self { + Self { + inner: ValidationErrorInner::MissingDirectory, + } + } +} + +impl fmt::Display for ValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.inner { + ValidationErrorInner::MissingDirectory => f.write_str( + "The Steam installation directory either isn't a directory or doesn't exist", + ), + } + } +} + +#[derive(Clone, Debug)] +enum ValidationErrorInner { + MissingDirectory, +} + +#[derive(Copy, Clone, Debug)] +#[non_exhaustive] +pub enum ParseErrorKind { + Config, + LibraryFolders, + App, + Shortcut, +} + +#[derive(Debug)] +pub struct ParseError { + // Keep `keyvalues_parser` and `keyvalues_serde` types out of the public API (this includes + // from traits, so no using `thiserror` with `#[from]`) + #[allow(dead_code)] // Only used for displaying currently + inner: Box, +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.inner) + } +} + +#[derive(Debug)] +pub(crate) enum ParseErrorInner { + Parse(keyvalues_parser::error::Error), + Serde(keyvalues_serde::error::Error), + UnexpectedStructure, + Missing, +} + +impl fmt::Display for ParseErrorInner { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Parse(err) => write!(f, "{}", err), + Self::Serde(err) => write!(f, "{}", err), + Self::UnexpectedStructure => f.write_str("File did not match expected structure"), + Self::Missing => f.write_str("Expected file was missing"), + } + } +} + +impl ParseError { + pub(crate) fn new(inner: ParseErrorInner) -> Self { + Self { + inner: Box::new(inner), + } + } + + pub(crate) fn from_parser(err: keyvalues_parser::error::Error) -> Self { + Self::new(ParseErrorInner::Parse(err)) + } + + pub(crate) fn from_serde(err: keyvalues_serde::error::Error) -> Self { + Self::new(ParseErrorInner::Serde(err)) + } + + pub(crate) fn unexpected_structure() -> Self { + Self::new(ParseErrorInner::UnexpectedStructure) + } + + pub(crate) fn missing() -> Self { + Self::new(ParseErrorInner::Missing) + } +} diff --git a/src/lib.rs b/src/lib.rs index c5a6aeb..77cbe6f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,386 +1,248 @@ //! A crate which efficiently locates any Steam application on the filesystem, and/or the Steam installation itself. //! -//! **This crate supports Windows, macOS and Linux.** -//! //! # Using steamlocate -//! Simply add to your [Cargo.toml](https://doc.rust-lang.org/cargo/reference/manifest.html) file: -//! ```toml -//! [dependencies] -//! steamlocate = "0.*" -//! ``` //! -//! To use [steamid-ng](#steamid-ng-support) with steamlocate, add this to your [Cargo.toml](https://doc.rust-lang.org/cargo/reference/manifest.html) file: -//! ```toml -//! [dependencies] -//! steamid-ng = "1.*" +//! Simply add `steamlocate` using +//! [`cargo`](https://doc.rust-lang.org/cargo/getting-started/installation.html). //! -//! [dependencies.steamlocate] -//! version = "0.*" -//! features = ["steamid_ng"] +//! ```console +//! $ cargo add steamlocate //! ``` //! -//! # Caching -//! All functions in this crate cache their results, meaning you can call them as many times as you like and they will always return the same reference. +//! ## Feature flags //! -//! If you need to get uncached results, simply instantiate a new [SteamDir](https://docs.rs/steamlocate/*/steamlocate/struct.SteamDir.html). +//! Default: `locate` //! -//! # steamid-ng Support -//! This crate supports [steamid-ng](https://docs.rs/steamid-ng) and can automatically convert [SteamApp::last_user](struct.SteamApp.html#structfield.last_user) to a [SteamID](https://docs.rs/steamid-ng/*/steamid_ng/struct.SteamID.html) for you. -//! -//! To enable this support, [use the `steamid_ng` Cargo.toml feature](#using-steamlocate). +//! | Feature flag | Description | +//! | :---: | :--- | +//! | `locate` | Enables automatically detecting the Steam installation on supported platforms (currently Windows, MacOS, and Linux). Unsupported platforms will return a runtime error. | //! //! # Examples //! -//! ### Locate the installed Steam directory -//! ```rust -//! extern crate steamlocate; -//! use steamlocate::SteamDir; -//! -//! match SteamDir::locate() { -//! Some(steamdir) => println!("{:#?}", steamdir), -//! None => panic!("Couldn't locate Steam on this computer!") -//! } -//! ``` -//! ```ignore -//! SteamDir ( -//! path: PathBuf: "C:\\Program Files (x86)\\Steam" -//! ) -//! ``` +//! ## Locate the Steam installation and a specific game //! -//! ### Locate an installed Steam app by its app ID -//! This will locate Garry's Mod anywhere on the filesystem. -//! ```rust -//! extern crate steamlocate; -//! use steamlocate::SteamDir; +//! The [`SteamDir`] is going to be your entrypoint into _most_ parts of the API. After you locate +//! it you can access related information. //! -//! let mut steamdir = SteamDir::locate().unwrap(); -//! match steamdir.app(&4000) { -//! Some(app) => println!("{:#?}", app), -//! None => panic!("Couldn't locate Garry's Mod on this computer!") -//! } -//! ``` -//! ```ignore -//! SteamApp ( -//! appid: u32: 4000, -//! path: PathBuf: "C:\\Program Files (x86)\\steamapps\\common\\GarrysMod", -//! vdf: , -//! name: Some(String: "Garry's Mod"), -//! last_user: Some(u64: 76561198040894045) -//! ) //! ``` -//! -//! ### Locate all Steam apps on this filesystem -//! ```rust -//! extern crate steamlocate; -//! use steamlocate::{SteamDir, SteamApp}; -//! use std::collections::HashMap; -//! -//! let mut steamdir = SteamDir::locate().unwrap(); -//! let apps: &HashMap> = steamdir.apps(); -//! -//! println!("{:#?}", apps); +//! # /* +//! let steam_dir = steamlocate::SteamDir::locate()?; +//! # */ +//! # let temp_steam_dir = steamlocate::tests::helpers::expect_test_env(); +//! # let steam_dir = temp_steam_dir.steam_dir(); +//! println!("Steam installation - {}", steam_dir.path().display()); +//! // ^^ prints something like `Steam installation - C:\Program Files (x86)\Steam` +//! +//! const GMOD_APP_ID: u32 = 4_000; +//! let (garrys_mod, _lib) = steam_dir +//! .find_app(GMOD_APP_ID)? +//! .expect("Of course we have G Mod"); +//! assert_eq!(garrys_mod.name.as_ref().unwrap(), "Garry's Mod"); +//! println!("{garrys_mod:#?}"); +//! // ^^ prints something like vv +//! # Ok::<_, steamlocate::tests::TestError>(()) //! ``` //! ```ignore -//! { -//! 4000: SteamApp ( -//! appid: u32: 4000, -//! path: PathBuf: "C:\\Program Files (x86)\\steamapps\\common\\GarrysMod", -//! vdf: , -//! name: Some(String: "Garry's Mod"), -//! last_user: Some(u64: 76561198040894045) -//! ) -//! ... +//! App { +//! app_id: 4_000, +//! install_dir: "GarrysMod", +//! name: Some("Garry's Mod"), +//! universe: Some(Public), +//! // much much more data //! } //! ``` //! -//! ### Locate all Steam library folders -//! ```rust -//! extern crate steamlocate; -//! use steamlocate::{SteamDir, LibraryFolders}; -//! use std::{vec, path::PathBuf}; +//! ## Get an overview of all libraries and apps on the system //! -//! let mut steamdir: SteamDir = SteamDir::locate().unwrap(); -//! let libraryfolders: &LibraryFolders = steamdir.libraryfolders(); -//! let paths: &Vec = &libraryfolders.paths; +//! You can iterate over all of Steam's libraries from the steam dir. Then from each library you +//! can iterate over all of its apps. //! -//! println!("{:#?}", paths); //! ``` -//! ```ignore -//! { -//! "C:\\Program Files (x86)\\Steam\\steamapps", -//! "D:\\Steam\\steamapps", -//! "E:\\Steam\\steamapps", -//! "F:\\Steam\\steamapps", -//! ... +//! # /* +//! let steam_dir = steamlocate::SteamDir::locate()?; +//! # */ +//! # let temp_steam_dir = steamlocate::tests::helpers::expect_test_env(); +//! # let steam_dir = temp_steam_dir.steam_dir(); +//! +//! for library in steam_dir.libraries()? { +//! let library = library?; +//! println!("Library - {}", library.path().display()); +//! +//! for app in library.apps() { +//! let app = app?; +//! println!(" App {} - {:?}", app.app_id, app.name); +//! } //! } +//! # Ok::<_, steamlocate::tests::TestError>(()) +//! ``` +//! +//! On my laptop this prints +//! +//! ```text +//! Library - /home/wintermute/.local/share/Steam +//! App 1628350 - Steam Linux Runtime 3.0 (sniper) +//! App 1493710 - Proton Experimental +//! App 4000 - Garry's Mod +//! Library - /home/wintermute/temp steam lib +//! App 391540 - Undertale +//! App 1714040 - Super Auto Pets +//! App 2348590 - Proton 8.0 //! ``` -#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] -compile_error!("Unsupported operating system!"); - -use std::{collections::HashMap, path::PathBuf}; - -#[cfg(target_os = "windows")] -use winreg::{ - enums::{HKEY_LOCAL_MACHINE, KEY_READ}, - RegKey, -}; -#[cfg(not(target_os = "windows"))] -extern crate dirs; - +#![warn( + // We're a library after all + clippy::print_stderr, clippy::print_stdout, + // Honestly just good in general + clippy::todo, +)] + +pub mod app; +pub mod config; +pub mod error; +pub mod library; +#[cfg(feature = "locate")] +mod locate; +pub mod shortcut; +// NOTE: exposed publicly, so that we can use them in doctests +/// Not part of the public API >:V #[doc(hidden)] -pub mod steamapp; -pub use steamapp::SteamApp; +pub mod tests; // TODO: rename this since it may leak out in compiler error messages -#[doc(hidden)] -pub mod steamcompat; -pub use steamcompat::SteamCompat; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; -#[doc(hidden)] -pub mod libraryfolders; -pub use libraryfolders::LibraryFolders; +use error::ValidationError; -mod steamapps; -use steamapps::SteamApps; +use crate::error::{ParseError, ParseErrorKind}; -mod steamcompats; -use steamcompats::SteamCompats; +pub use crate::app::App; +pub use crate::config::CompatTool; +pub use crate::error::{Error, Result}; +pub use crate::library::Library; +pub use crate::shortcut::Shortcut; -mod shortcut; -pub use shortcut::Shortcut; +// Run doctests on the README too +#[doc = include_str!("../README.md")] +#[cfg(doctest)] +pub struct ReadmeDoctests; -/// An instance of a Steam installation. +/// The entrypoint into most of the rest of the API /// -/// All functions of this struct will cache their results. +/// Use either [`SteamDir::locate()`] or [`SteamDir::from_dir()`] to create a new instance. +/// From there you have access to: /// -/// If you'd like to dispose of the cache or get uncached results, just instantiate a new `SteamDir`. +/// - The Steam installation directory +/// - [`steam_dir.path()`][SteamDir::path] +/// - Library info +/// - [`steam_dir.library_paths()`][SteamDir::library_paths] +/// - [`steam_dir.libraries()`][SteamDir::libraries] +/// - Convenient access to find a specific app by id +/// - [`steam_dir.find_app(app_id)`][SteamDir::find_app] +/// - Compatibility tool mapping (aka Proton to game mapping) +/// - [`steam_dir.compat_tool_mapping()`][SteamDir::compat_tool_mapping] +/// - Shortcuts info (aka the listing of non-Steam games) +/// - [`steam_dir.shortcuts()`][SteamDir::shortcuts] /// /// # Example -/// ```rust -/// # use steamlocate::SteamDir; -/// let steamdir = SteamDir::locate(); -/// println!("{:#?}", steamdir.unwrap()); /// ``` -/// ```ignore -/// SteamDir ( -/// path: "C:\\Program Files (x86)\\Steam" -/// ) +/// # /* +/// let steam_dir = SteamDir::locate()?; +/// # */ +/// # let temp_steam_dir = steamlocate::tests::helpers::expect_test_env(); +/// # let steam_dir = temp_steam_dir.steam_dir(); +/// assert!(steam_dir.path().ends_with("Steam")); /// ``` -#[derive(Default, Clone, Debug)] +#[derive(Clone, Debug)] pub struct SteamDir { - /// The path to the Steam installation directory on this computer. - /// - /// Example: `C:\Program Files (x86)\Steam` - pub path: PathBuf, - pub(crate) steam_apps: SteamApps, - pub(crate) steam_compat: SteamCompats, - pub(crate) libraryfolders: LibraryFolders, - pub(crate) shortcuts: Option>, + path: PathBuf, } impl SteamDir { - /// Returns a reference to a `LibraryFolders` instance. - /// - /// You can then index `LibraryFolders.paths` to get a reference to a `Vec` of every library folder installed on the file system. + /// The path to the Steam installation directory on this computer. /// - /// This function will cache its result. - pub fn libraryfolders(&mut self) -> &LibraryFolders { - let libraryfolders = &mut self.libraryfolders; - if !libraryfolders.discovered { - libraryfolders.discover(&self.path); - } - &*libraryfolders + /// Example: `C:\Program Files (x86)\Steam` + pub fn path(&self) -> &Path { + &self.path } - /// Returns a reference to `HashMap>` of all `SteamApp`s located on this computer. - /// - /// All `Option`s in this context will be `Some`, so you can safely `unwrap()` them without panicking. - /// - /// This function will cache its results and will always return a reference to the same `HashMap`. - /// # Example - /// ```rust - /// # use steamlocate::{SteamDir, SteamApp}; - /// # use std::collections::HashMap; - /// let mut steamdir = SteamDir::locate().unwrap(); - /// let apps: &HashMap> = steamdir.apps(); - /// println!("{:#?}", apps); - /// ``` - /// ```ignore - /// { - /// 4000: SteamApp ( - /// appid: u32: 4000, - /// path: PathBuf: "C:\\Program Files (x86)\\steamapps\\common\\GarrysMod", - /// vdf: , - /// name: Some(String: "Garry's Mod"), - /// last_user: Some(u64: 76561198040894045) // This will be a steamid_ng::SteamID if the "steamid_ng" feature is enabled - /// ) - /// ... - /// } - /// ``` - pub fn apps(&mut self) -> &HashMap> { - let steam_apps = &mut self.steam_apps; - if !steam_apps.discovered { - let libraryfolders = &mut self.libraryfolders; - if !libraryfolders.discovered { - libraryfolders.discover(&self.path); - } - steam_apps.discover_apps(libraryfolders); - } - &steam_apps.apps + pub fn library_paths(&self) -> Result> { + let libraryfolders_vdf = self.path.join("steamapps").join("libraryfolders.vdf"); + library::parse_library_paths(&libraryfolders_vdf) } - /// Returns a `Some` reference to a `SteamApp` via its app ID. - /// - /// If the Steam app is not installed on the system, this will return `None`. - /// - /// This function will cache its (either `Some` and `None`) result and will always return a reference to the same `SteamApp`. + pub fn libraries(&self) -> Result { + let paths = self.library_paths()?; + Ok(library::Iter::new(paths)) + } + + /// Convenient helper to look through all the libraries for a specific app /// /// # Example - /// ```rust - /// # use steamlocate::SteamDir; - /// let mut steamdir = SteamDir::locate().unwrap(); - /// let gmod = steamdir.app(&4000); - /// println!("{:#?}", gmod.unwrap()); /// ``` - /// ```ignore - /// SteamApp ( - /// appid: u32: 4000, - /// path: PathBuf: "C:\\Program Files (x86)\\steamapps\\common\\GarrysMod", - /// vdf: , - /// name: Some(String: "Garry's Mod"), - /// last_user: Some(u64: 76561198040894045) // This will be a steamid_ng::SteamID if the "steamid_ng" feature is enabled - /// ) + /// # let temp_steam_dir = steamlocate::tests::helpers::expect_test_env(); + /// # let steam_dir = temp_steam_dir.steam_dir(); + /// # /* + /// let steam_dir = SteamDir::locate()?; + /// # */ + /// const WARFRAME: u32 = 230_410; + /// let (warframe, library) = steam_dir.find_app(WARFRAME)?.unwrap(); + /// assert_eq!(warframe.app_id, WARFRAME); + /// assert!(library.app_ids().contains(&warframe.app_id)); + /// # Ok::<_, steamlocate::tests::TestError>(()) /// ``` - pub fn app(&mut self, app_id: &u32) -> Option<&SteamApp> { - let steam_apps = &mut self.steam_apps; - - if !steam_apps.apps.contains_key(app_id) { - let libraryfolders = &mut self.libraryfolders; - if !libraryfolders.discovered { - libraryfolders.discover(&self.path); - } - steam_apps.discover_app(libraryfolders, app_id); + pub fn find_app(&self, app_id: u32) -> Result> { + // Search for the `app_id` in each library + match self.libraries() { + Err(e) => Err(e), + Ok(libraries) => libraries + .filter_map(|library| library.ok()) + .find_map(|lib| { + lib.app(app_id) + .map(|maybe_app| maybe_app.map(|app| (app, lib))) + }) + .transpose(), } - - steam_apps.apps.get(app_id).and_then(|app| app.as_ref()) } - /// Returns a `Some` reference to a `SteamCompat` via its app ID. - /// - /// If no compatibility tool is configured for the app, this will return `None`. - /// - /// This function will cache its (either `Some` and `None`) result and will always return a reference to the same `SteamCompat`. - pub fn compat_tool(&mut self, app_id: &u32) -> Option<&SteamCompat> { - let steam_compat = &mut self.steam_compat; - - if !steam_compat.tools.contains_key(app_id) { - steam_compat.discover_tool(&self.path, app_id) - } - - steam_compat - .tools - .get(app_id) - .and_then(|compat_tool| compat_tool.as_ref()) + pub fn compat_tool_mapping(&self) -> Result> { + let config_path = self.path.join("config").join("config.vdf"); + let vdf_text = + fs::read_to_string(&config_path).map_err(|io| Error::io(io, &config_path))?; + let store: config::Store = keyvalues_serde::from_str(&vdf_text).map_err(|de| { + Error::parse( + ParseErrorKind::Config, + ParseError::from_serde(de), + &config_path, + ) + })?; + + Ok(store.software.valve.steam.mapping) } - /// Returns a listing of all added non-Steam games - pub fn shortcuts(&mut self) -> &[Shortcut] { - if self.shortcuts.is_none() { - let shortcuts = shortcut::discover_shortcuts(&self.path); - self.shortcuts = Some(shortcuts); - } - - self.shortcuts.as_ref().unwrap() + /// Returns an iterator of all non-Steam games that were added to steam + pub fn shortcuts(&mut self) -> Result { + shortcut::Iter::new(&self.path) } - /// Locates the Steam installation directory on the filesystem and initializes a `SteamDir` (Windows) - /// - /// Returns `None` if no Steam installation can be located. - #[cfg(target_os = "windows")] - pub fn locate() -> Option { - // Locating the Steam installation location is a bit more complicated on Windows - - // Steam's installation location can be found in the registry - let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); - let installation_regkey = match - hklm.open_subkey_with_flags("SOFTWARE\\Wow6432Node\\Valve\\Steam", KEY_READ).or_else(|_| // 32-bit - hklm.open_subkey_with_flags("SOFTWARE\\Valve\\Steam", KEY_READ)) // 64-bit - { - Ok(installation_regkey) => installation_regkey, - Err(_) => return None - }; - - // The InstallPath key will contain the full path to the Steam directory - let install_path_str: String = match installation_regkey.get_value("InstallPath") { - Ok(install_path_str) => install_path_str, - Err(_) => return None, - }; - - let install_path = PathBuf::from(install_path_str); + // TODO: rename to `from_dir()` and make consitent with similar constructors on other structs + pub fn from_dir(path: &Path) -> Result { + if !path.is_dir() { + return Err(Error::validation(ValidationError::missing_dir())); + } - Some(SteamDir { - path: install_path, - ..Default::default() + // TODO(cosmic): should we do some kind of extra validation here? Could also use validation + // to determine if a steam dir has been uninstalled. Should fix all the flatpack/snap issues + Ok(Self { + path: path.to_owned(), }) } - /// Locates the Steam installation directory on the filesystem and initializes a `SteamDir` (macOS) - /// - /// Returns `None` if no Steam installation can be located. - #[cfg(target_os = "macos")] - pub fn locate() -> Option { - // Steam's installation location is pretty easy to find on macOS, as it's always in $USER/Library/Application Support - let home_dir = match dirs::home_dir() { - Some(home_dir) => home_dir, - None => return None, - }; - - // Find Library/Application Support/Steam - let install_path = home_dir.join("Library/Application Support/Steam"); - match install_path.is_dir() { - false => None, - true => Some(SteamDir { - path: install_path, - ..Default::default() - }), - } - } - - /// Locates the Steam installation directory on the filesystem and initializes a `SteamDir` (Linux) - /// - /// Returns `None` if no Steam installation can be located. - #[cfg(target_os = "linux")] - pub fn locate() -> Option { - // Steam's installation location is pretty easy to find on Linux, too, thanks to the symlink in $USER - let home_dir = match dirs::home_dir() { - Some(home_dir) => home_dir, - None => return None, - }; - - // Check for Flatpak steam install - let steam_flatpak_path = home_dir.join(".var/app/com.valvesoftware.Steam"); - if steam_flatpak_path.is_dir() { - let steam_flatpak_install_path = steam_flatpak_path.join(".steam/steam"); - if steam_flatpak_install_path.is_dir() { - return Some(SteamDir { - path: steam_flatpak_install_path, - ..Default::default() - }); - } - } - - // Check for Standard steam install - let standard_path = home_dir.join(".steam/steam"); - if standard_path.is_dir() { - return Some(SteamDir { - path: standard_path, - ..Default::default() - }); - } + /// Attempts to locate the Steam installation directory on the system + #[cfg(feature = "locate")] + pub fn locate() -> Result { + let path = locate::locate_steam_dir()?; - None + Self::from_dir(&path) } } - -#[cfg(test)] -mod tests; diff --git a/src/library.rs b/src/library.rs new file mode 100644 index 0000000..8db1f1a --- /dev/null +++ b/src/library.rs @@ -0,0 +1,181 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use crate::{ + app, + error::{ParseError, ParseErrorKind}, + App, Error, Result, +}; + +use keyvalues_parser::Vdf; + +/// Discovers all the steam libraries from `libraryfolders.vdf` +/// +/// We want all the library paths from `libraryfolders.vdf` which has the following structure +/// +/// ```vdf +/// "libraryfolders" +/// { +/// ... +/// "0" +/// { +/// "path" "/path/to/first/library" +/// ... +/// "apps" +/// { +/// +/// ... // for all apps in the library +/// } +/// } +/// "1" +/// { +/// "path" "/path/to/second/library" +/// ... +/// "apps" +/// { +/// +/// ... // for all apps in the library +/// } +/// } +/// ... +/// } +/// ``` +pub(crate) fn parse_library_paths(path: &Path) -> Result> { + let parse_error = |err| Error::parse(ParseErrorKind::LibraryFolders, err, path); + + if !path.is_file() { + return Err(parse_error(ParseError::missing())); + } + + let contents = fs::read_to_string(path).map_err(|io| Error::io(io, path))?; + let value = Vdf::parse(&contents) + .map_err(|err| parse_error(ParseError::from_parser(err)))? + .value; + let obj = value + .get_obj() + .ok_or_else(|| parse_error(ParseError::unexpected_structure()))?; + let paths: Vec<_> = obj + .iter() + .filter(|(key, _)| key.parse::().is_ok()) + .map(|(_, values)| { + values + .first() + .and_then(|value| value.get_obj()) + .and_then(|obj| obj.get("path")) + .and_then(|values| values.first()) + .and_then(|value| value.get_str()) + .ok_or_else(|| parse_error(ParseError::unexpected_structure())) + .map(PathBuf::from) + }) + .collect::>()?; + + Ok(paths) +} + +pub struct Iter { + paths: std::vec::IntoIter, +} + +impl Iter { + pub(crate) fn new(paths: Vec) -> Self { + Self { + paths: paths.into_iter(), + } + } +} + +impl Iterator for Iter { + type Item = Result; + + fn next(&mut self) -> Option { + self.paths.next().map(|path| Library::from_dir(&path)) + } +} + +impl ExactSizeIterator for Iter { + fn len(&self) -> usize { + self.paths.len() + } +} + +#[derive(Clone, Debug)] +pub struct Library { + path: PathBuf, + apps: Vec, +} + +impl Library { + pub fn from_dir(path: &Path) -> Result { + // Read the manifest files at the library to get an up-to-date list of apps since the + // values in `libraryfolders.vdf` may be stale + let mut apps = Vec::new(); + let steamapps = path.join("steamapps"); + for entry in fs::read_dir(&steamapps).map_err(|io| Error::io(io, &steamapps))? { + let entry = entry.map_err(|io| Error::io(io, &steamapps))?; + if let Some(id) = entry + .file_name() + .to_str() + .and_then(|name| name.strip_prefix("appmanifest_")) + .and_then(|prefixless_name| prefixless_name.strip_suffix(".acf")) + .and_then(|app_id_str| app_id_str.parse().ok()) + { + apps.push(id); + } + } + + Ok(Self { + path: path.to_owned(), + apps, + }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + // TODO: if this was sorted then we could locate single apps faster + pub fn app_ids(&self) -> &[u32] { + &self.apps + } + + pub fn app(&self, app_id: u32) -> Option> { + self.app_ids().iter().find(|&&id| id == app_id).map(|&id| { + let manifest_path = self + .path() + .join("steamapps") + .join(format!("appmanifest_{}.acf", id)); + App::new(&manifest_path) + }) + } + + pub fn apps(&self) -> app::Iter { + app::Iter::new(self) + } + + /// Resolves the theoretical installation directory for the given `app` + /// + /// This is an unvalidated path, so it's up to you to call this with an `app` that's in this + /// library + /// + /// # Example + /// + /// ``` + /// # use std::path::Path; + /// # let temp_steam_dir = steamlocate::tests::helpers::expect_test_env(); + /// # let steam_dir = temp_steam_dir.steam_dir(); + /// const GRAVEYARD_KEEPER: u32 = 599_140; + /// let (graveyard_keeper, library) = steam_dir.find_app(GRAVEYARD_KEEPER)?.unwrap(); + /// let app_dir = library.resolve_app_dir(&graveyard_keeper); + /// let expected_rel_path = Path::new("steamapps").join("common").join("Graveyard Keeper"); + /// assert!(app_dir.ends_with(expected_rel_path)); + /// # Ok::<_, steamlocate::tests::TestError>(()) + /// ``` + pub fn resolve_app_dir(&self, app: &App) -> PathBuf { + self.path + .join("steamapps") + .join("common") + .join(&app.install_dir) + } +} diff --git a/src/libraryfolders.rs b/src/libraryfolders.rs deleted file mode 100644 index c5ae2ba..0000000 --- a/src/libraryfolders.rs +++ /dev/null @@ -1,95 +0,0 @@ -use std::{ - fs, - path::{Path, PathBuf}, -}; - -use keyvalues_parser::Vdf; - -/// An instance which contains all the Steam library folders installed on the file system. -/// Example: -/// ```rust -/// # use std::{vec, path::PathBuf}; -/// # use steamlocate::{SteamDir, LibraryFolders}; -/// let mut steamdir: SteamDir = SteamDir::locate().unwrap(); -/// let libraryfolders: &LibraryFolders = steamdir.libraryfolders(); -/// let paths: &Vec = &libraryfolders.paths; -/// println!("{:#?}", paths); -/// ``` -/// ```ignore -/// { -/// "C:\\Program Files (x86)\\Steam\\steamapps", -/// "D:\\Steam\\steamapps", -/// "E:\\Steam\\steamapps", -/// "F:\\Steam\\steamapps", -/// ... -/// } -/// ``` -#[derive(Default, Clone, Debug)] -pub struct LibraryFolders { - /// A `Vec` of Steam library folder paths. - /// - /// This will always include the Steam installation directory's `SteamApps` folder. - pub paths: Vec, - pub(crate) discovered: bool, -} - -impl LibraryFolders { - /// Discovers all the steam libraries from `libraryfolders.vdf` - /// - /// We want all the library paths from `libraryfolders.vdf` which has the following structure - /// - /// ```vdf - /// "libraryfolders" - /// { - /// ... - /// "0" - /// { - /// "path" "/path/to/first/library" - /// ... - /// } - /// "1" - /// { - /// "path" "/path/to/second/library" - /// ... - /// } - /// ... - /// } - /// ``` - pub(crate) fn discover(&mut self, path: &Path) { - let _ = self._discover(path); - } - - fn _discover(&mut self, path: &Path) -> Option<()> { - let steamapps = path.join("steamapps"); - self.paths.push(steamapps.clone()); - - let libraryfolders_vdf_path = steamapps.join("libraryfolders.vdf"); - if libraryfolders_vdf_path.is_file() { - let vdf_text = fs::read_to_string(&libraryfolders_vdf_path).ok()?; - let value = Vdf::parse(&vdf_text).ok()?.value; - let obj = value.get_obj()?; - - let library_folders: Vec<_> = obj - .iter() - .filter(|(key, values)| key.parse::().is_ok() && values.len() == 1) - .filter_map(|(_, values)| { - let library_folder_string = values - .get(0)? - .get_obj()? - .get("path")? - .get(0)? - .get_str()? - .to_string(); - let library_folder = PathBuf::from(library_folder_string).join("steamapps"); - Some(library_folder) - }) - .collect(); - - self.paths = library_folders; - } - - self.discovered = true; - - Some(()) - } -} diff --git a/src/locate.rs b/src/locate.rs new file mode 100644 index 0000000..318fad1 --- /dev/null +++ b/src/locate.rs @@ -0,0 +1,97 @@ +use std::path::PathBuf; + +use crate::Result; + +pub fn locate_steam_dir() -> Result { + locate_steam_dir_helper() +} + +#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] +fn locate_steam_dir_helper() -> Result { + use crate::error::{Error, LocateError}; + Err(Error::locate(LocateError::Unsupported)) +} + +#[cfg(target_os = "windows")] +fn locate_steam_dir_helper() -> Result { + use crate::error::{Error, LocateError}; + + use locate_backend as winreg; + use winreg::{ + enums::{HKEY_LOCAL_MACHINE, KEY_READ}, + RegKey, + }; + + let io_to_locate_err = |io_err| Error::locate(LocateError::winreg(io_err)); + + // Locating the Steam installation location is a bit more complicated on Windows + + // Steam's installation location can be found in the registry + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let installation_regkey = hklm + // 32-bit + .open_subkey_with_flags("SOFTWARE\\Wow6432Node\\Valve\\Steam", KEY_READ) + .or_else(|_| { + // 64-bit + hklm.open_subkey_with_flags("SOFTWARE\\Valve\\Steam", KEY_READ) + }) + .map_err(io_to_locate_err)?; + + // The InstallPath key will contain the full path to the Steam directory + let install_path_str: String = installation_regkey + .get_value("InstallPath") + .map_err(io_to_locate_err)?; + + let install_path = PathBuf::from(install_path_str); + Ok(install_path) +} + +#[cfg(target_os = "macos")] +fn locate_steam_dir_helper() -> Result { + use crate::{error::LocateError, Error}; + use locate_backend as dirs; + // Steam's installation location is pretty easy to find on macOS, as it's always in + // $USER/Library/Application Support + let home_dir = dirs::home_dir().ok_or_else(|| Error::locate(LocateError::no_home()))?; + + // Find Library/Application Support/Steam + let install_path = home_dir.join("Library/Application Support/Steam"); + Ok(install_path) +} + +#[cfg(target_os = "linux")] +fn locate_steam_dir_helper() -> Result { + use std::env; + + use crate::error::{Error, LocateError, ValidationError}; + + use locate_backend as dirs; + + // Steam's installation location is pretty easy to find on Linux, too, thanks to the symlink in $USER + let home_dir = dirs::home_dir().ok_or_else(|| Error::locate(LocateError::no_home()))?; + let snap_dir = match env::var("SNAP_USER_DATA") { + Ok(snap_dir) => PathBuf::from(snap_dir), + Err(_) => home_dir.join("snap"), + }; + + let steam_paths = vec![ + // Flatpak steam install directories + home_dir.join(".var/app/com.valvesoftware.Steam/.local/share/Steam"), + home_dir.join(".var/app/com.valvesoftware.Steam/.steam/steam"), + home_dir.join(".var/app/com.valvesoftware.Steam/.steam/root"), + // Standard install directories + home_dir.join(".local/share/Steam"), + home_dir.join(".steam/steam"), + home_dir.join(".steam/root"), + home_dir.join(".steam"), + // Snap steam install directories + snap_dir.join("steam/common/.local/share/Steam"), + snap_dir.join("steam/common/.steam/steam"), + snap_dir.join("steam/common/.steam/root"), + ]; + + steam_paths + .into_iter() + .find(|x| x.is_dir()) + .ok_or_else(|| Error::validation(ValidationError::missing_dir())) +} diff --git a/src/shortcut.rs b/src/shortcut.rs index 78073be..53d2f3b 100644 --- a/src/shortcut.rs +++ b/src/shortcut.rs @@ -1,6 +1,16 @@ -//! **WARN:** This is all hacky and should be replaced with proper binary VDF parsing +// HACK: This is all hacky and should be replaced with proper binary VDF parsing -use std::{fs, iter::Peekable, path::Path, slice::Iter}; +use std::{ + fs, io, + iter::Peekable, + path::{Path, PathBuf}, + slice, +}; + +use crate::{ + error::{ParseError, ParseErrorKind}, + Error, Result, +}; /// A added non-Steam game /// @@ -9,7 +19,7 @@ use std::{fs, iter::Peekable, path::Path, slice::Iter}; #[non_exhaustive] pub struct Shortcut { /// Steam's provided app id - pub appid: u32, + pub app_id: u32, /// The name of the application pub app_name: String, /// The executable used to launch the app @@ -18,47 +28,105 @@ pub struct Shortcut { pub executable: String, /// The directory that the application should be run in pub start_dir: String, + /// The shortcut's Steam ID calculated from the executable path and app name + pub steam_id: u64, } -#[cfg(feature = "shortcuts_extras")] impl Shortcut { /// Calculates the shortcut's Steam ID from the executable and app name - pub fn steam_id(&self) -> u64 { - let algorithm = crc::Crc::::new(&crc::CRC_32_ISO_HDLC); + pub fn new(app_id: u32, app_name: String, executable: String, start_dir: String) -> Self { + fn calculate_steam_id(executable: &[u8], app_name: &[u8]) -> u64 { + let algorithm = crc::Crc::::new(&crc::CRC_32_ISO_HDLC); + + let mut digest = algorithm.digest(); + digest.update(executable); + digest.update(app_name); - let mut digest = algorithm.digest(); - digest.update(self.executable.as_bytes()); - digest.update(self.app_name.as_bytes()); + let top = digest.finalize() | 0x80000000; + ((top as u64) << 32) | 0x02000000 + } - let top = digest.finalize() | 0x80000000; - ((top as u64) << 32) | 0x02000000 + let steam_id = calculate_steam_id(executable.as_bytes(), app_name.as_bytes()); + + Self { + app_id, + app_name, + executable, + start_dir, + steam_id, + } } } -/// Discovers any shorcuts stored within `userdata` -pub fn discover_shortcuts(steam_dir: &Path) -> Vec { - fn inner(steam_dir: &Path) -> Option> { - let mut shortcuts = Vec::new(); +pub struct Iter { + dir: PathBuf, + read_dir: fs::ReadDir, + pending: std::vec::IntoIter, +} - // Find and parse each `userdata//config/shortcuts.vdf` file +impl Iter { + pub(crate) fn new(steam_dir: &Path) -> Result { let user_data = steam_dir.join("userdata"); - for entry in fs::read_dir(user_data).ok()?.filter_map(|e| e.ok()) { - let shortcuts_path = entry.path().join("config").join("shortcuts.vdf"); - if !shortcuts_path.is_file() { - continue; + if !user_data.is_dir() { + return Err(Error::parse( + ParseErrorKind::Shortcut, + ParseError::missing(), + &user_data, + )); + } + + let read_dir = fs::read_dir(&user_data).map_err(|io| Error::io(io, &user_data))?; + Ok(Self { + dir: user_data, + read_dir, + pending: Vec::new().into_iter(), + }) + } +} + +impl Iterator for Iter { + type Item = Result; + + fn next(&mut self) -> Option { + let item = loop { + if let Some(shortcut) = self.pending.next() { + break Ok(shortcut); } - if let Ok(contents) = fs::read(&shortcuts_path) { - if let Some(parsed) = parse_shortcuts(&contents) { - shortcuts.extend(parsed); + // Need to parse the next set of pending shortcuts + let maybe_entry = self.read_dir.next()?; + match maybe_entry { + Ok(entry) => { + let shortcuts_path = entry.path().join("config").join("shortcuts.vdf"); + match fs::read(&shortcuts_path) { + Ok(contents) => { + if let Some(shortcuts) = parse_shortcuts(&contents) { + self.pending = shortcuts.into_iter(); + continue; + } else { + break Err(Error::parse( + ParseErrorKind::Shortcut, + ParseError::unexpected_structure(), + &shortcuts_path, + )); + } + } + Err(err) => { + // Not every directory in here has a shortcuts file + if err.kind() == io::ErrorKind::NotFound { + continue; + } else { + break Err(Error::io(err, &shortcuts_path)); + } + } + } } + Err(err) => break Err(Error::io(err, &self.dir)), } - } + }; - Some(shortcuts) + Some(item) } - - inner(steam_dir).unwrap_or_default() } /// Advances `it` until right after the matching `needle` @@ -66,29 +134,27 @@ pub fn discover_shortcuts(steam_dir: &Path) -> Vec { /// Only works if the starting byte is not used anywhere else in the needle. This works well when /// finding keys since the starting byte indicates the type and wouldn't be used in the key #[must_use] -fn after_many_case_insensitive(it: &mut Peekable>, needle: &[u8]) -> bool { +fn after_many_case_insensitive(it: &mut Peekable>, needle: &[u8]) -> bool { loop { - loop { - let mut needle_it = needle.iter(); - let b = match it.next() { - Some(b) => b, - None => return false, - }; - - let maybe_needle_b = needle_it.next(); - if maybe_u8_eq_ignore_ascii_case(maybe_needle_b, Some(b)) { - loop { - if needle_it.len() == 0 { - return true; - } + let mut needle_it = needle.iter(); + let b = match it.next() { + Some(b) => b, + None => return false, + }; - let maybe_b = it.peek(); - let maybe_needle_b = needle_it.next(); - if maybe_u8_eq_ignore_ascii_case(maybe_needle_b, maybe_b.copied()) { - let _ = it.next(); - } else { - break; - } + let maybe_needle_b = needle_it.next(); + if maybe_u8_eq_ignore_ascii_case(maybe_needle_b, Some(b)) { + loop { + if needle_it.len() == 0 { + return true; + } + + let maybe_b = it.peek(); + let maybe_needle_b = needle_it.next(); + if maybe_u8_eq_ignore_ascii_case(maybe_needle_b, maybe_b.copied()) { + let _ = it.next(); + } else { + break; } } } @@ -102,7 +168,7 @@ fn maybe_u8_eq_ignore_ascii_case(maybe_b1: Option<&u8>, maybe_b2: Option<&u8>) - .unwrap_or_default() } -fn parse_value_str(it: &mut Peekable>) -> Option { +fn parse_value_str(it: &mut Peekable>) -> Option { let mut buff = Vec::new(); loop { let b = it.next()?; @@ -114,13 +180,11 @@ fn parse_value_str(it: &mut Peekable>) -> Option { } } -fn parse_value_u32(it: &mut Peekable>) -> Option { +fn parse_value_u32(it: &mut Peekable>) -> Option { let bytes = [*it.next()?, *it.next()?, *it.next()?, *it.next()?]; Some(u32::from_le_bytes(bytes)) } -// The performance of this is likely terrible, but also the files we're parsing are tiny so it -// won't matter fn parse_shortcuts(contents: &[u8]) -> Option> { let mut it = contents.iter().peekable(); let mut shortcuts = Vec::new(); @@ -129,7 +193,7 @@ fn parse_shortcuts(contents: &[u8]) -> Option> { if !after_many_case_insensitive(&mut it, b"\x02appid\x00") { return Some(shortcuts); } - let appid = parse_value_u32(&mut it)?; + let app_id = parse_value_u32(&mut it)?; if !after_many_case_insensitive(&mut it, b"\x01AppName\x00") { return None; @@ -146,12 +210,7 @@ fn parse_shortcuts(contents: &[u8]) -> Option> { } let start_dir = parse_value_str(&mut it)?; - let shortcut = Shortcut { - appid, - app_name, - executable, - start_dir, - }; + let shortcut = Shortcut::new(app_id, app_name, executable, start_dir); shortcuts.push(shortcut); } } @@ -168,22 +227,25 @@ mod tests { shortcuts, vec![ Shortcut { - appid: 2786274309, + app_id: 2786274309, app_name: "Anki".into(), executable: "\"anki\"".into(), start_dir: "\"./\"".into(), + steam_id: 0xe89614fe02000000, }, Shortcut { - appid: 2492174738, + app_id: 2492174738, app_name: "LibreOffice Calc".into(), executable: "\"libreoffice\"".into(), start_dir: "\"./\"".into(), + steam_id: 0xdb01c79902000000, }, Shortcut { - appid: 3703025501, + app_id: 3703025501, app_name: "foo.sh".into(), executable: "\"/usr/local/bin/foo.sh\"".into(), start_dir: "\"/usr/local/bin/\"".into(), + steam_id: 0x9d55017302000000, } ], ); @@ -193,22 +255,12 @@ mod tests { assert_eq!( shortcuts, vec![Shortcut { - appid: 2931025216, + app_id: 2931025216, app_name: "Second Life".into(), executable: "\"/Applications/Second Life Viewer.app\"".into(), start_dir: "\"/Applications/\"".into(), + steam_id: 0xfdd972df02000000, }] ); } - - #[cfg(feature = "shortcuts_extras")] - #[test] - fn shortcuts_extras() { - let contents = include_bytes!("../tests/sample_data/shortcuts.vdf"); - let shortcuts = parse_shortcuts(contents).unwrap(); - let ideal_ids = vec![0xe89614fe02000000, 0xdb01c79902000000, 0x9d55017302000000]; - for (id, shortcut) in ideal_ids.into_iter().zip(shortcuts.iter()) { - assert_eq!(id, shortcut.steam_id()); - } - } } diff --git a/src/snapshots/steamlocate__app__tests__minimal.snap b/src/snapshots/steamlocate__app__tests__minimal.snap new file mode 100644 index 0000000..1829a54 --- /dev/null +++ b/src/snapshots/steamlocate__app__tests__minimal.snap @@ -0,0 +1,34 @@ +--- +source: src/app.rs +expression: app +--- +App( + appid: 2519830, + installdir: "Resonite", + name: None, + LastOwner: None, + Universe: None, + LauncherPath: None, + StateFlags: None, + LastUpdated: None, + UpdateResult: None, + SizeOnDisk: None, + buildid: None, + BytesToDownload: None, + BytesDownloaded: None, + BytesToStage: None, + BytesStaged: None, + StagingSize: None, + TargetBuildID: None, + AutoUpdateBehavior: None, + AllowOtherDownloadsWhileRunning: None, + ScheduledAutoUpdate: None, + FullValidateBeforeNextUpdate: None, + FullValidateAfterNextUpdate: None, + InstalledDepots: {}, + StagedDepots: {}, + UserConfig: {}, + MountedConfig: {}, + InstallScripts: {}, + SharedDepots: {}, +) diff --git a/src/snapshots/steamlocate__app__tests__more_sanity.snap b/src/snapshots/steamlocate__app__tests__more_sanity.snap new file mode 100644 index 0000000..bfc0a2b --- /dev/null +++ b/src/snapshots/steamlocate__app__tests__more_sanity.snap @@ -0,0 +1,50 @@ +--- +source: src/app.rs +expression: app +--- +App( + appid: 599140, + installdir: "Graveyard Keeper", + name: Some("Graveyard Keeper"), + LastOwner: Some(12312312312312312), + Universe: Some(Public), + LauncherPath: None, + StateFlags: Some(StateFlags(6)), + LastUpdated: Some(SystemTime( + secs_since_epoch: 1672176869, + nanos_since_epoch: 0, + )), + UpdateResult: Some(0), + SizeOnDisk: Some(1805798572), + buildid: Some(8559806), + BytesToDownload: Some(24348080), + BytesDownloaded: Some(0), + BytesToStage: Some(1284862702), + BytesStaged: Some(0), + StagingSize: Some(0), + TargetBuildID: Some(8559806), + AutoUpdateBehavior: Some(OnlyUpdateOnLaunch), + AllowOtherDownloadsWhileRunning: Some(Allow), + ScheduledAutoUpdate: Some(Time(SystemTime( + secs_since_epoch: 1678457806, + nanos_since_epoch: 0, + ))), + FullValidateBeforeNextUpdate: None, + FullValidateAfterNextUpdate: None, + InstalledDepots: { + 599143: Depot( + manifest: 8776335556818666951, + size: 1805798572, + dlcappid: None, + ), + }, + StagedDepots: {}, + UserConfig: { + "language": "english", + }, + MountedConfig: { + "language": "english", + }, + InstallScripts: {}, + SharedDepots: {}, +) diff --git a/src/snapshots/steamlocate__app__tests__sanity.snap b/src/snapshots/steamlocate__app__tests__sanity.snap new file mode 100644 index 0000000..5b925fd --- /dev/null +++ b/src/snapshots/steamlocate__app__tests__sanity.snap @@ -0,0 +1,47 @@ +--- +source: src/app.rs +expression: app +--- +App( + appid: 230410, + installdir: "Warframe", + name: Some("Warframe"), + LastOwner: Some(12312312312312312), + Universe: Some(Public), + LauncherPath: Some("C:\\Program Files (x86)\\Steam\\steam.exe"), + StateFlags: Some(StateFlags(4)), + LastUpdated: Some(SystemTime( + secs_since_epoch: 1630871495, + nanos_since_epoch: 0, + )), + UpdateResult: Some(2), + SizeOnDisk: Some(29070834580), + buildid: Some(6988007), + BytesToDownload: Some(28490671360), + BytesDownloaded: Some(28490671360), + BytesToStage: Some(29070834580), + BytesStaged: Some(29070834580), + StagingSize: None, + TargetBuildID: None, + AutoUpdateBehavior: Some(KeepUpToDate), + AllowOtherDownloadsWhileRunning: Some(UseGlobalSetting), + ScheduledAutoUpdate: Some(Zero), + FullValidateBeforeNextUpdate: None, + FullValidateAfterNextUpdate: None, + InstalledDepots: { + 230411: Depot( + manifest: 1659398175797234554, + size: 29070834580, + dlcappid: None, + ), + }, + StagedDepots: {}, + UserConfig: { + "language": "english", + }, + MountedConfig: {}, + InstallScripts: { + 230411: "installscript.vdf", + }, + SharedDepots: {}, +) diff --git a/src/steamapp.rs b/src/steamapp.rs deleted file mode 100644 index 5b35990..0000000 --- a/src/steamapp.rs +++ /dev/null @@ -1,85 +0,0 @@ -use std::path::{Path, PathBuf}; - -/// An instance of an installed Steam app. -/// # Example -/// ```rust -/// # use steamlocate::SteamDir; -/// let mut steamdir = SteamDir::locate().unwrap(); -/// let gmod = steamdir.app(&4000); -/// println!("{:#?}", gmod.unwrap()); -/// ``` -/// ```ignore -/// SteamApp ( -/// appid: u32: 4000, -/// path: PathBuf: "C:\\Program Files (x86)\\steamapps\\common\\GarrysMod", -/// vdf: , -/// name: Some(String: "Garry's Mod"), -/// last_user: Some(u64: 76561198040894045) // This will be a steamid_ng::SteamID if the "steamid_ng" feature is enabled -/// ) -/// ``` -#[derive(Debug, Clone)] -pub struct SteamApp { - /// The app ID of this Steam app. - pub appid: u32, - - /// The path to the installation directory of this Steam app. - /// - /// Example: `C:\Program Files (x86)\Steam\steamapps\common\GarrysMod` - pub path: PathBuf, - - /// A [steamy_vdf::Table](https://docs.rs/steamy-vdf/*/steamy_vdf/struct.Table.html) - pub vdf: steamy_vdf::Table, - - /// The store name of the Steam app. - pub name: Option, - - #[cfg(not(feature = "steamid_ng"))] - /// The SteamID64 of the last Steam user that played this game on the filesystem. - /// - /// This crate supports [steamid-ng](https://docs.rs/steamid-ng) and can automatically convert this to a [SteamID](https://docs.rs/steamid-ng/*/steamid_ng/struct.SteamID.html) for you. - /// - /// To enable this support, [use the `steamid_ng` Cargo.toml feature](https://docs.rs/steamlocate/*/steamlocate#using-steamlocate). - pub last_user: Option, - - #[cfg(feature = "steamid_ng")] - /// The [SteamID](https://docs.rs/steamid-ng/*/steamid_ng/struct.SteamID.html) of the last Steam user that played this game on the filesystem. - pub last_user: Option, -} - -impl SteamApp { - pub(crate) fn new(steamapps: &Path, vdf: &steamy_vdf::Table) -> Option { - // First check if the installation path exists and is a valid directory - let install_dir = steamapps.join(vdf.get("installdir")?.as_str()?); - if !install_dir.is_dir() { - return None; - } - - Some(SteamApp { - vdf: vdf.clone(), - path: install_dir, - - // Get the appid key, try and parse it as an unsigned 32-bit integer, if we fail, return None - appid: vdf.get("appid")?.as_value()?.parse::().ok()?, - - // Get the name key, try and convert it into a String, if we fail, name = None - name: vdf - .get("name") - .and_then(|entry| entry.as_str().map(|str| str.to_string())), - - // Get the LastOwner key, try and convert it into a SteamID64, if we fail, last_user = None - #[cfg(not(feature = "steamid_ng"))] - last_user: vdf - .get("LastOwner") - .and_then(|entry| entry.as_value().and_then(|val| val.parse::().ok())), - - #[cfg(feature = "steamid_ng")] - last_user: vdf.get("LastOwner").and_then(|entry| { - entry.as_value().and_then(|val| { - val.parse::() - .ok() - .and_then(|steamid64| Some(steamid_ng::SteamID::from(steamid64))) - }) - }), - }) - } -} diff --git a/src/steamapps.rs b/src/steamapps.rs deleted file mode 100644 index 2dcc6ea..0000000 --- a/src/steamapps.rs +++ /dev/null @@ -1,88 +0,0 @@ -use crate::libraryfolders::LibraryFolders; -use crate::steamapp::SteamApp; -use std::collections::HashMap; - -#[derive(Default, Clone, Debug)] -pub(crate) struct SteamApps { - pub(crate) apps: HashMap>, - pub(crate) discovered: bool, -} - -impl SteamApps { - pub(crate) fn discover_apps(&mut self, libraryfolders: &LibraryFolders) { - self.apps.drain(); - - for libraryfolder in &libraryfolders.paths { - let read_dir = libraryfolder.read_dir(); - if read_dir.is_err() { - continue; - } - for result in read_dir.unwrap() { - let file = match result { - Err(_) => continue, - Ok(file) => file, - }; - - let mut path = file.path(); - if !path.is_file() { - continue; - } - - let app_id: u32 = match file - .file_name() - .to_str() - .and_then(|name| name.strip_prefix("appmanifest_")) - .and_then(|prefixless_name| prefixless_name.strip_suffix(".acf")) - .and_then(|app_id_str| app_id_str.parse().ok()) - { - Some(app_id) => app_id, - None => continue, - }; - - let vdf = match steamy_vdf::load(&path) { - Err(_) => continue, - Ok(vdf) => match vdf.get("AppState") { - None => continue, - Some(app_state) => match app_state.as_table() { - None => continue, - Some(table) => table.to_owned(), - }, - }, - }; - - path.pop(); - path.push("common"); - - self.apps.insert(app_id, SteamApp::new(&path, &vdf)); - } - } - } - - pub(crate) fn discover_app( - &mut self, - libraryfolders: &LibraryFolders, - app_id: &u32, - ) -> Option<()> { - for libraryfolder in &libraryfolders.paths { - let mut appmanifest_path = libraryfolder.join(format!("appmanifest_{}.acf", app_id)); - if appmanifest_path.is_file() { - let appmanifest_vdf = steamy_vdf::load(&appmanifest_path).ok()?; - - appmanifest_path.pop(); - appmanifest_path.push("common"); - - self.apps.insert( - *app_id, - SteamApp::new( - &appmanifest_path, - appmanifest_vdf.get("AppState")?.as_table()?, - ), - ); - - return Some(()); - } - } - - None - } -} diff --git a/src/tests.rs b/src/tests.rs deleted file mode 100644 index 97a445d..0000000 --- a/src/tests.rs +++ /dev/null @@ -1,100 +0,0 @@ -// Prerequisites: -// * Steam must be installed -// * At least two library folders must be setup -// * At least two Steam apps must be installed -// * An installed Steam game's app ID must be specified below -static APP_ID: u32 = 4000; - -use super::*; - -#[test] -fn find_steam() { - let steamdir_found = SteamDir::locate(); - assert!(steamdir_found.is_some()); - println!("{:#?}", steamdir_found.unwrap()); -} - -#[test] -fn find_library_folders() { - let steamdir_found = SteamDir::locate(); - assert!(steamdir_found.is_some()); - - let mut steamdir = steamdir_found.unwrap(); - - steamdir.libraryfolders.discover(&steamdir.path); - assert!(steamdir.libraryfolders().paths.len() > 1); - - println!("{:#?}", steamdir.libraryfolders.paths); -} - -#[test] -fn find_app() { - let steamdir_found = SteamDir::locate(); - assert!(steamdir_found.is_some()); - - let mut steamdir = steamdir_found.unwrap(); - - let steamapp = steamdir.app(&APP_ID); - assert!(steamapp.is_some()); - - assert!(steamdir.app(&u32::MAX).is_none()); -} - -#[test] -fn app_details() { - let steamdir_found = SteamDir::locate(); - assert!(steamdir_found.is_some()); - - let mut steamdir = steamdir_found.unwrap(); - - let steamapp = steamdir.app(&APP_ID); - assert!(steamapp.is_some()); - - assert!(steamapp.unwrap().name.is_some()); - assert!(steamapp.unwrap().last_user.is_some()); -} - -#[test] -fn all_apps() { - let steamdir_found = SteamDir::locate(); - assert!(steamdir_found.is_some()); - - let mut steamdir = steamdir_found.unwrap(); - - let steamapps = steamdir.apps(); - assert!(!steamapps.is_empty()); - assert!(steamapps.keys().len() > 1); - - // println!("{:#?}", steamapps); -} - -#[test] -fn all_apps_get_one() { - let steamdir_found = SteamDir::locate(); - assert!(steamdir_found.is_some()); - - let mut steamdir = steamdir_found.unwrap(); - - let steamapps = steamdir.apps(); - assert!(!steamapps.is_empty()); - assert!(steamapps.keys().len() > 1); - - let steamapp = steamdir.app(&APP_ID); - assert!(steamapp.is_some()); - - assert!(steamapp.unwrap().name.is_some()); - assert!(steamapp.unwrap().last_user.is_some()); -} - -#[test] -fn find_compatibility_tool() { - let steamdir_found = SteamDir::locate(); - assert!(steamdir_found.is_some()); - - let mut steamdir = steamdir_found.unwrap(); - - let tool = steamdir.compat_tool(&APP_ID); - assert!(tool.is_some()); - - println!("{:#?}", tool.unwrap()); -} diff --git a/src/tests/helpers.rs b/src/tests/helpers.rs new file mode 100644 index 0000000..ce175c5 --- /dev/null +++ b/src/tests/helpers.rs @@ -0,0 +1,321 @@ +//! Some test helpers for setting up isolated dummy steam installations. + +// TODO: add a test with an env var flag that runs against your real local steam installation? + +use std::{ + collections::BTreeMap, + convert::{TryFrom, TryInto}, + fs, iter, + path::{Path, PathBuf}, +}; + +use crate::{ + tests::{temp::TempDir, TestError}, + SteamDir, +}; + +use serde::Serialize; + +pub fn expect_test_env() -> TempSteamDir { + TempSteamDir::builder() + .app(SampleApp::GarrysMod.into()) + .app(SampleApp::Warframe.into()) + .library(SampleApp::GraveyardKeeper.try_into().unwrap()) + .finish() + .unwrap() +} + +// TODO(cosmic): Add in functionality for providing shortcuts too +pub struct TempSteamDir { + steam_dir: crate::SteamDir, + _tmps: Vec, +} + +impl TryFrom for TempSteamDir { + type Error = TestError; + + fn try_from(app: AppFile) -> Result { + Self::builder().app(app).finish() + } +} + +impl TryFrom for TempSteamDir { + type Error = TestError; + + fn try_from(sample_app: SampleApp) -> Result { + Self::try_from(AppFile::from(sample_app)) + } +} + +impl TempSteamDir { + pub fn builder() -> TempSteamDirBuilder { + TempSteamDirBuilder::default() + } + + pub fn steam_dir(&self) -> &SteamDir { + &self.steam_dir + } +} + +#[derive(Default)] +#[must_use] +pub struct TempSteamDirBuilder { + libraries: Vec, + apps: Vec, +} + +impl TempSteamDirBuilder { + pub fn app(mut self, app: AppFile) -> Self { + self.apps.push(app); + self + } + + pub fn library(mut self, library: TempLibrary) -> Self { + self.libraries.push(library); + self + } + + // Steam dir is also a library, but is laid out slightly differently than a regular library + pub fn finish(self) -> Result { + let tmp = TempDir::new()?; + let root_dir = tmp.path().join("test-steam-dir"); + let steam_dir = root_dir.join("Steam"); + let apps_dir = steam_dir.join("steamapps"); + fs::create_dir_all(&apps_dir)?; + + setup_steamapps_dir(&apps_dir, &self.apps)?; + + let steam_dir_content_id = i32::MIN; + let apps = self.apps.iter().map(|app| (app.id, 0)).collect(); + let root_library = + LibraryFolder::mostly_default(steam_dir.clone(), steam_dir_content_id, apps); + setup_libraryfolders_file(&apps_dir, root_library, &self.libraries)?; + + let tmps = iter::once(tmp) + .chain(self.libraries.into_iter().map(|library| library._tmp)) + .collect(); + + Ok(TempSteamDir { + steam_dir: SteamDir::from_dir(&steam_dir)?, + _tmps: tmps, + }) + } +} + +fn setup_steamapps_dir(apps_dir: &Path, apps: &[AppFile]) -> Result<(), TestError> { + let apps_common_dir = apps_dir.join("common"); + fs::create_dir_all(&apps_common_dir)?; + + for app in apps { + let manifest_path = apps_dir.join(app.file_name()); + fs::write(&manifest_path, &app.contents)?; + let app_install_dir = apps_common_dir.join(&app.install_dir); + fs::create_dir_all(&app_install_dir)?; + } + + Ok(()) +} + +fn setup_libraryfolders_file( + apps_dir: &Path, + root_library: LibraryFolder, + aux_libraries: &[TempLibrary], +) -> Result<(), TestError> { + let library_folders = + iter::once(root_library).chain(aux_libraries.iter().map(|temp_library| { + LibraryFolder::mostly_default( + temp_library.path.clone(), + temp_library.content_id, + temp_library.apps.clone(), + ) + })); + let inner: BTreeMap = library_folders + .into_iter() + .enumerate() + .map(|(i, f)| (i.try_into().unwrap(), f)) + .collect(); + let library_folders_contents = + keyvalues_serde::to_string_with_key(&inner, "libraryfolders").unwrap(); + let library_folders_path = apps_dir.join("libraryfolders.vdf"); + fs::write(library_folders_path, library_folders_contents)?; + + Ok(()) +} + +#[derive(Serialize)] +struct LibraryFolder { + path: PathBuf, + label: String, + contentid: i32, + totalsize: u64, + update_clean_bytes_tally: u64, + time_last_update_corruption: u64, + apps: BTreeMap, +} + +impl LibraryFolder { + fn mostly_default(path: PathBuf, contentid: i32, apps: BTreeMap) -> Self { + let totalsize = apps.values().sum(); + Self { + path, + contentid, + apps, + totalsize, + label: String::default(), + update_clean_bytes_tally: 79_799_828_443, + time_last_update_corruption: 0, + } + } +} + +pub struct TempLibrary { + content_id: i32, + path: PathBuf, + apps: BTreeMap, + _tmp: TempDir, +} + +impl TryFrom for TempLibrary { + type Error = TestError; + + fn try_from(app: AppFile) -> Result { + Self::builder().app(app).finish() + } +} + +impl TryFrom for TempLibrary { + type Error = TestError; + + fn try_from(sample_app: SampleApp) -> Result { + Self::try_from(AppFile::from(sample_app)) + } +} + +impl TempLibrary { + pub fn builder() -> TempLibraryBuilder { + TempLibraryBuilder::default() + } +} + +#[derive(Default)] +#[must_use] +pub struct TempLibraryBuilder { + apps: Vec, +} + +impl TempLibraryBuilder { + fn app(mut self, app: AppFile) -> Self { + self.apps.push(app); + self + } + + fn finish(self) -> Result { + let tmp = TempDir::new()?; + let root_dir = tmp.path().join("test-library"); + let apps_dir = root_dir.join("steamapps"); + fs::create_dir_all(&apps_dir)?; + + let meta_path = apps_dir.join("libraryfolder.vdf"); + fs::write( + meta_path, + include_str!("../../tests/assets/libraryfolder.vdf"), + )?; + + setup_steamapps_dir(&apps_dir, &self.apps)?; + let apps = self.apps.iter().map(|app| (app.id, 0)).collect(); + + Ok(TempLibrary { + content_id: 1234, + path: root_dir, + apps, + _tmp: tmp, + }) + } +} + +pub struct AppFile { + id: u32, + install_dir: String, + contents: String, +} + +impl From for AppFile { + fn from(sample: SampleApp) -> Self { + Self { + id: sample.id(), + install_dir: sample.install_dir().to_owned(), + contents: sample.contents().to_owned(), + } + } +} + +impl AppFile { + fn file_name(&self) -> String { + format!("appmanifest_{}.acf", self.id) + } +} + +#[derive(Clone, Copy)] +pub enum SampleApp { + GarrysMod, + GraveyardKeeper, + Resonite, + Warframe, +} + +impl SampleApp { + pub const fn id(&self) -> u32 { + self.data().0 + } + + pub const fn install_dir(&self) -> &'static str { + self.data().1 + } + + pub const fn contents(&self) -> &'static str { + self.data().2 + } + + pub const fn data(&self) -> (u32, &'static str, &'static str) { + match self { + Self::GarrysMod => ( + 4_000, + "GarrysMod", + include_str!("../../tests/assets/appmanifest_4000.acf"), + ), + Self::GraveyardKeeper => ( + 599_140, + "Graveyard Keeper", + include_str!("../../tests/assets/appmanifest_599140.acf"), + ), + Self::Resonite => ( + 2_519_830, + "Resonite", + include_str!("../../tests/assets/appmanifest_2519830.acf"), + ), + Self::Warframe => ( + 230_410, + "Warframe", + include_str!("../../tests/assets/appmanifest_230410.acf"), + ), + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::tests::TestResult; + + #[test] + fn sanity() -> TestResult { + let tmp_steam_dir = TempSteamDir::try_from(SampleApp::GarrysMod)?; + let steam_dir = tmp_steam_dir.steam_dir(); + assert!(steam_dir + .find_app(SampleApp::GarrysMod.id()) + .unwrap() + .is_some()); + + Ok(()) + } +} diff --git a/src/tests/legacy.rs b/src/tests/legacy.rs new file mode 100644 index 0000000..9271609 --- /dev/null +++ b/src/tests/legacy.rs @@ -0,0 +1,99 @@ +use crate::{ + tests::{ + helpers::{expect_test_env, SampleApp}, + TestResult, + }, + Error, +}; + +static GMOD_ID: u32 = SampleApp::GarrysMod.id(); + +#[test] +fn find_library_folders() -> TestResult { + let tmp_steam_dir = expect_test_env(); + let steam_dir = tmp_steam_dir.steam_dir(); + assert!(steam_dir.libraries().unwrap().len() > 1); + Ok(()) +} + +#[test] +fn find_app() -> TestResult { + let tmp_steam_dir = expect_test_env(); + let steam_dir = tmp_steam_dir.steam_dir(); + let steam_app = steam_dir.find_app(GMOD_ID).unwrap(); + assert_eq!(steam_app.unwrap().0.app_id, GMOD_ID); + Ok(()) +} + +#[test] +fn app_details() -> TestResult { + let tmp_steam_dir = expect_test_env(); + let steam_dir = tmp_steam_dir.steam_dir(); + let steam_app = steam_dir.find_app(GMOD_ID)?.unwrap(); + assert_eq!(steam_app.0.name.unwrap(), "Garry's Mod"); + Ok(()) +} + +#[test] +fn all_apps() -> TestResult { + let tmp_steam_dir = expect_test_env(); + let steam_dir = tmp_steam_dir.steam_dir(); + let mut libraries = steam_dir.libraries().unwrap(); + let all_apps: Vec<_> = libraries + .try_fold(Vec::new(), |mut acc, maybe_library| { + let library = maybe_library?; + for maybe_app in library.apps() { + let app = maybe_app?; + acc.push(app); + } + Ok::<_, Error>(acc) + }) + .unwrap(); + assert!(all_apps.len() > 1); + Ok(()) +} + +#[test] +fn all_apps_get_one() -> TestResult { + let tmp_steam_dir = expect_test_env(); + let steam_dir = tmp_steam_dir.steam_dir(); + + let mut libraries = steam_dir.libraries().unwrap(); + let all_apps: Vec<_> = libraries + .try_fold(Vec::new(), |mut acc, maybe_library| { + let library = maybe_library?; + for maybe_app in library.apps() { + let app = maybe_app?; + acc.push(app); + } + Ok::<_, Error>(acc) + }) + .unwrap(); + assert!(!all_apps.is_empty()); + assert!(all_apps.len() > 1); + + let steam_app = steam_dir.find_app(GMOD_ID).unwrap().unwrap(); + assert_eq!( + all_apps + .into_iter() + .find(|app| app.app_id == GMOD_ID) + .unwrap(), + steam_app.0, + ); + + Ok(()) +} + +// FIXME: This should fake the steam installation now +// #[test] +// fn find_compatibility_tool() { +// let steamdir_found = SteamDir::locate(); +// assert!(steamdir_found.is_some()); + +// let mut steamdir = steamdir_found.unwrap(); + +// let tool = steamdir.compat_tool(&APP_ID); +// assert!(tool.is_some()); + +// println!("{:#?}", tool.unwrap()); +// } diff --git a/src/tests/mod.rs b/src/tests/mod.rs new file mode 100644 index 0000000..9df106d --- /dev/null +++ b/src/tests/mod.rs @@ -0,0 +1,11 @@ +pub mod helpers; +#[cfg(test)] +mod legacy; +mod temp; +#[cfg(test)] +mod tests; +#[cfg(test)] +mod wasm; + +pub type TestError = Box; +pub type TestResult = Result<(), TestError>; diff --git a/src/tests/temp.rs b/src/tests/temp.rs new file mode 100644 index 0000000..bb6197b --- /dev/null +++ b/src/tests/temp.rs @@ -0,0 +1,40 @@ +//! `TempDir` at home +//! +//! I want to use temporary directories in doctests, but that works against your public API. +//! Luckily all the functionality we need is very easy to replicate + +use std::{collections, env, fs, hash, path}; + +use crate::tests::TestError; + +#[derive(Debug)] +pub struct TempDir(Option); + +impl TempDir { + pub fn new() -> Result { + let mut dir = env::temp_dir(); + let random_name = format!("steamlocate-test-{:x}", random_seed()); + dir.push(random_name); + // TODO: could retry on failure + fs::create_dir_all(&dir)?; + Ok(Self(Some(dir))) + } + + pub fn path(&self) -> &path::Path { + self.0.as_deref().unwrap() + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + if let Some(path) = self.0.take() { + let _ = fs::remove_dir_all(path); + } + } +} + +fn random_seed() -> u64 { + hash::Hasher::finish(&hash::BuildHasher::build_hasher( + &collections::hash_map::RandomState::new(), + )) +} diff --git a/src/tests/tests.rs b/src/tests/tests.rs new file mode 100644 index 0000000..f98454e --- /dev/null +++ b/src/tests/tests.rs @@ -0,0 +1,18 @@ +use crate::tests::{ + helpers::{SampleApp, TempSteamDir}, + TestResult, +}; + +// Context: https://github.com/WilliamVenner/steamlocate-rs/issues/58 +#[test] +fn app_lastupdated_casing() -> TestResult { + let sample_app = SampleApp::Resonite; + let temp_steam_dir = TempSteamDir::builder().app(sample_app.into()).finish()?; + let steam_dir = temp_steam_dir.steam_dir(); + + let (app, _library) = steam_dir.find_app(sample_app.id())?.unwrap(); + // Last updated _should_ be `Some(_)` for this app even though it uses lowercase casing + let _ = app.last_updated.unwrap(); + + Ok(()) +} diff --git a/src/tests/wasm.rs b/src/tests/wasm.rs new file mode 100644 index 0000000..1cdef6d --- /dev/null +++ b/src/tests/wasm.rs @@ -0,0 +1,10 @@ +use wasm_bindgen_test::wasm_bindgen_test; + +#[wasm_bindgen_test] +#[cfg_attr(not(feature = "locate"), ignore = "Needs `locate` feature")] +fn locate() { + #[cfg(not(feature = "locate"))] + unreachable!("Don't run ignored tests silly"); + #[cfg(feature = "locate")] + let _ = crate::SteamDir::locate().unwrap_err(); +} diff --git a/tests/assets/appmanifest_230410.acf b/tests/assets/appmanifest_230410.acf new file mode 100644 index 0000000..58886d0 --- /dev/null +++ b/tests/assets/appmanifest_230410.acf @@ -0,0 +1,37 @@ +"AppState" +{ + "appid" "230410" + "Universe" "1" + "LauncherPath" "C:\\Program Files (x86)\\Steam\\steam.exe" + "name" "Warframe" + "StateFlags" "4" + "installdir" "Warframe" + "LastUpdated" "1630871495" + "UpdateResult" "2" + "SizeOnDisk" "29070834580" + "buildid" "6988007" + "LastOwner" "12312312312312312" + "BytesToDownload" "28490671360" + "BytesDownloaded" "28490671360" + "BytesToStage" "29070834580" + "BytesStaged" "29070834580" + "AutoUpdateBehavior" "0" + "AllowOtherDownloadsWhileRunning" "0" + "ScheduledAutoUpdate" "0" + "InstalledDepots" + { + "230411" + { + "manifest" "1659398175797234554" + "size" "29070834580" + } + } + "InstallScripts" + { + "230411" "installscript.vdf" + } + "UserConfig" + { + "language" "english" + } +} diff --git a/tests/assets/appmanifest_2519830.acf b/tests/assets/appmanifest_2519830.acf new file mode 100644 index 0000000..5610c7f --- /dev/null +++ b/tests/assets/appmanifest_2519830.acf @@ -0,0 +1,50 @@ +"AppState" +{ + "appid" "2519830" + "Universe" "1" + "name" "Resonite" + "StateFlags" "4" + "installdir" "Resonite" + "lastupdated" "1702688752" + "SizeOnDisk" "1102323116" + "StagingSize" "0" + "buildid" "12967476" + "LastOwner" "76561198022773299" + "UpdateResult" "0" + "BytesToDownload" "2332576" + "BytesDownloaded" "2332576" + "BytesToStage" "54625540" + "BytesStaged" "54625540" + "TargetBuildID" "12967476" + "AutoUpdateBehavior" "0" + "AllowOtherDownloadsWhileRunning" "0" + "ScheduledAutoUpdate" "0" + "InstalledDepots" + { + "2519832" + { + "manifest" "1396658363472368690" + "size" "514493538" + } + "2519831" + { + "manifest" "5082223756179978205" + "size" "587829578" + } + } + "SharedDepots" + { + "228984" "228980" + "228985" "228980" + "228988" "228980" + "228989" "228980" + } + "UserConfig" + { + "language" "english" + } + "MountedConfig" + { + "language" "english" + } +} diff --git a/tests/assets/appmanifest_4000.acf b/tests/assets/appmanifest_4000.acf new file mode 100644 index 0000000..8de8726 --- /dev/null +++ b/tests/assets/appmanifest_4000.acf @@ -0,0 +1,43 @@ +"AppState" +{ + "appid" "4000" + "Universe" "1" + "name" "Garry's Mod" + "StateFlags" "4" + "installdir" "GarrysMod" + "LastUpdated" "1699500640" + "SizeOnDisk" "4152333499" + "StagingSize" "0" + "buildid" "12123796" + "LastOwner" "12312312312312312" + "UpdateResult" "0" + "BytesToDownload" "2313758368" + "BytesDownloaded" "2313758368" + "BytesToStage" "4152290626" + "BytesStaged" "4152290626" + "TargetBuildID" "12123796" + "AutoUpdateBehavior" "0" + "AllowOtherDownloadsWhileRunning" "0" + "ScheduledAutoUpdate" "0" + "InstalledDepots" + { + "4001" + { + "manifest" "8033896166589191357" + "size" "3875126726" + } + "4003" + { + "manifest" "6271527943975114763" + "size" "281149259" + } + } + "UserConfig" + { + "language" "english" + } + "MountedConfig" + { + "language" "english" + } +} diff --git a/tests/assets/appmanifest_599140.acf b/tests/assets/appmanifest_599140.acf new file mode 100644 index 0000000..def025f --- /dev/null +++ b/tests/assets/appmanifest_599140.acf @@ -0,0 +1,38 @@ +"AppState" +{ + "appid" "599140" + "Universe" "1" + "name" "Graveyard Keeper" + "StateFlags" "6" + "installdir" "Graveyard Keeper" + "LastUpdated" "1672176869" + "SizeOnDisk" "1805798572" + "StagingSize" "0" + "buildid" "8559806" + "LastOwner" "12312312312312312" + "UpdateResult" "0" + "BytesToDownload" "24348080" + "BytesDownloaded" "0" + "BytesToStage" "1284862702" + "BytesStaged" "0" + "TargetBuildID" "8559806" + "AutoUpdateBehavior" "1" + "AllowOtherDownloadsWhileRunning" "1" + "ScheduledAutoUpdate" "1678457806" + "InstalledDepots" + { + "599143" + { + "manifest" "8776335556818666951" + "size" "1805798572" + } + } + "UserConfig" + { + "language" "english" + } + "MountedConfig" + { + "language" "english" + } +} diff --git a/tests/assets/libraryfolder.vdf b/tests/assets/libraryfolder.vdf new file mode 100644 index 0000000..a39147a --- /dev/null +++ b/tests/assets/libraryfolder.vdf @@ -0,0 +1,5 @@ +"libraryfolder" +{ + "contentid" "1298765432109876543" + "label" "" +}