├── .editorconfig ├── tests ├── assets │ ├── libraryfolder.vdf │ ├── appmanifest_599140.acf │ ├── appmanifest_230410.acf │ ├── appmanifest_4000.acf │ └── appmanifest_2519830.acf ├── sample_data │ ├── shortcuts.vdf │ ├── shortcuts_different_key_case.vdf │ └── shortcuts_just_gog_moonlighter.vdf ├── wasm.rs ├── tests.rs └── legacy.rs ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── examples ├── multiple-dirs.rs ├── shortcuts.rs ├── appmanifest.rs └── overview.rs ├── src ├── __private_tests │ ├── mod.rs │ ├── temp.rs │ └── helpers.rs ├── locate │ ├── macos.rs │ ├── mod.rs │ ├── windows.rs │ └── linux.rs ├── snapshots │ ├── steamlocate__app__tests__minimal.snap │ ├── steamlocate__app__tests__sanity.snap │ └── steamlocate__app__tests__more_sanity.snap ├── config.rs ├── error.rs ├── library.rs ├── shortcut.rs ├── lib.rs └── app.rs ├── RELEASE.md ├── LICENSE ├── Cargo.toml ├── CHANGELOG.md ├── README.md └── Cargo.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.yml] 4 | indent_style = space 5 | indent_size = 2 6 | -------------------------------------------------------------------------------- /tests/assets/libraryfolder.vdf: -------------------------------------------------------------------------------- 1 | "libraryfolder" 2 | { 3 | "contentid" "1298765432109876543" 4 | "label" "" 5 | } 6 | -------------------------------------------------------------------------------- /tests/sample_data/shortcuts.vdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WilliamVenner/steamlocate-rs/HEAD/tests/sample_data/shortcuts.vdf -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /tests/sample_data/shortcuts_different_key_case.vdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WilliamVenner/steamlocate-rs/HEAD/tests/sample_data/shortcuts_different_key_case.vdf -------------------------------------------------------------------------------- /tests/sample_data/shortcuts_just_gog_moonlighter.vdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WilliamVenner/steamlocate-rs/HEAD/tests/sample_data/shortcuts_just_gog_moonlighter.vdf -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vs 2 | .vscode 3 | .DS_Store 4 | 5 | # Generated by Cargo 6 | # will have compiled files and executables 7 | debug/ 8 | target/ 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | -------------------------------------------------------------------------------- /examples/multiple-dirs.rs: -------------------------------------------------------------------------------- 1 | //! Just prints all discovered shortcuts aka all non-Steam added games 2 | 3 | fn main() { 4 | let steamdir = steamlocate::SteamDir::locate_multiple().unwrap(); 5 | println!("Dirs:"); 6 | for dir in steamdir { 7 | println!("{}", dir.path().to_str().unwrap_or_default()) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/__private_tests/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod helpers; 2 | mod temp; 3 | 4 | pub type TestError = Box; 5 | pub type TestResult = Result<(), TestError>; 6 | 7 | pub mod prelude { 8 | pub use super::{ 9 | helpers::{ 10 | expect_test_env, AppFile, SampleApp, SampleShortcuts, TempLibrary, TempSteamDir, 11 | }, 12 | TestError, TestResult, 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /examples/shortcuts.rs: -------------------------------------------------------------------------------- 1 | //! Just prints all discovered shortcuts aka all non-Steam added games 2 | 3 | fn main() { 4 | let steamdir = steamlocate::SteamDir::locate().unwrap(); 5 | println!("Shortcuts:"); 6 | for maybe_shortcut in steamdir.shortcuts().unwrap() { 7 | match maybe_shortcut { 8 | Ok(shortcut) => println!(" - {} {}", shortcut.app_id, shortcut.app_name), 9 | Err(err) => println!("Failed reading potential shortcut: {err}"), 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/wasm.rs: -------------------------------------------------------------------------------- 1 | use wasm_bindgen_test::wasm_bindgen_test; 2 | 3 | #[wasm_bindgen_test] 4 | #[cfg_attr( 5 | not(any(target_os = "windows", target_os = "macos", target_os = "linux")), 6 | ignore = "Needs `locate` feature" 7 | )] 8 | fn locate() { 9 | #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] 10 | unreachable!("Don't run ignored tests silly"); 11 | #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] 12 | let _ = steamlocate::SteamDir::locate().unwrap_err(); 13 | } 14 | -------------------------------------------------------------------------------- /src/locate/macos.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::Result; 4 | 5 | pub fn locate_steam_dir_helper() -> Result { 6 | use crate::{error::LocateError, Error}; 7 | // Steam's installation location is pretty easy to find on macOS, as it's always in 8 | // $USER/Library/Application Support 9 | let home_dir = home::home_dir().ok_or_else(|| Error::locate(LocateError::no_home()))?; 10 | 11 | // Find Library/Application Support/Steam 12 | let install_path = home_dir.join("Library/Application Support/Steam"); 13 | Ok(install_path) 14 | } 15 | -------------------------------------------------------------------------------- /tests/tests.rs: -------------------------------------------------------------------------------- 1 | use steamlocate::__private_tests::prelude::*; 2 | 3 | // Context: https://github.com/WilliamVenner/steamlocate-rs/issues/58 4 | #[test] 5 | fn app_lastupdated_casing() -> TestResult { 6 | let sample_app = SampleApp::Resonite; 7 | let temp_steam_dir: TempSteamDir = sample_app.try_into()?; 8 | let steam_dir = temp_steam_dir.steam_dir(); 9 | 10 | let (app, _library) = steam_dir.find_app(sample_app.id())?.unwrap(); 11 | // Last updated _should_ be `Some(_)` for this app even though it uses lowercase casing 12 | let _ = app.last_updated.unwrap(); 13 | 14 | Ok(()) 15 | } 16 | -------------------------------------------------------------------------------- /examples/appmanifest.rs: -------------------------------------------------------------------------------- 1 | use std::{env, process::exit}; 2 | 3 | use steamlocate::SteamDir; 4 | 5 | fn main() { 6 | let args: Vec<_> = env::args().collect(); 7 | if args.len() != 2 || args[1].parse::().is_err() { 8 | eprintln!("Usage: cargo run --example appmanifest -- "); 9 | exit(1); 10 | } 11 | let app_id: u32 = args[1].parse().expect(" should be a u32"); 12 | 13 | let steam_dir = SteamDir::locate().unwrap(); 14 | match steam_dir.find_app(app_id) { 15 | Ok(Some((app, _library))) => println!("Found app - {:#?}", app), 16 | Ok(None) => println!("No app found for {}", app_id), 17 | Err(err) => println!("Failed reading app: {err}"), 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | The process for cutting a new release 2 | 3 | - [ ] Check for unused dependencies 4 | - `$ cargo +nightly udeps` 5 | - [ ] Bump the `version` in `Cargo.toml` 6 | - [ ] Propagate the change to `Cargo.lock` 7 | - `$ cargo check` 8 | - [ ] Update `rust-version` in `Cargo.toml` 9 | - Comment out the existing `rust-version` 10 | - `$ cargo msrv find [--ignore-lockfile]` 11 | - [ ] Update the `CHANGELOG.md` to reflect any of the changes 12 | - [ ] Merge changes through a PR or directly to make sure CI passes 13 | - [ ] Publish on crates.io 14 | - `$ cargo publish` 15 | - [ ] Publish on GitHub by pushing a version tag 16 | - `$ git tag v{VERSION}` (make sure the branch you are on is up to date) 17 | - `$ git push upstream/origin v{VERSION}` 18 | - [ ] Make a release announcement on GitHub after the release workflow finishes 19 | -------------------------------------------------------------------------------- /src/snapshots/steamlocate__app__tests__minimal.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/app.rs 3 | expression: app 4 | --- 5 | App( 6 | appid: 2519830, 7 | installdir: "Resonite", 8 | name: None, 9 | LastOwner: None, 10 | Universe: None, 11 | LauncherPath: None, 12 | StateFlags: None, 13 | LastUpdated: None, 14 | UpdateResult: None, 15 | SizeOnDisk: None, 16 | buildid: None, 17 | BytesToDownload: None, 18 | BytesDownloaded: None, 19 | BytesToStage: None, 20 | BytesStaged: None, 21 | StagingSize: None, 22 | TargetBuildID: None, 23 | AutoUpdateBehavior: None, 24 | AllowOtherDownloadsWhileRunning: None, 25 | ScheduledAutoUpdate: None, 26 | FullValidateBeforeNextUpdate: None, 27 | FullValidateAfterNextUpdate: None, 28 | InstalledDepots: {}, 29 | StagedDepots: {}, 30 | UserConfig: {}, 31 | MountedConfig: {}, 32 | InstallScripts: {}, 33 | SharedDepots: {}, 34 | ) 35 | -------------------------------------------------------------------------------- /tests/assets/appmanifest_599140.acf: -------------------------------------------------------------------------------- 1 | "AppState" 2 | { 3 | "appid" "599140" 4 | "Universe" "1" 5 | "name" "Graveyard Keeper" 6 | "StateFlags" "6" 7 | "installdir" "Graveyard Keeper" 8 | "LastUpdated" "1672176869" 9 | "SizeOnDisk" "1805798572" 10 | "StagingSize" "0" 11 | "buildid" "8559806" 12 | "LastOwner" "12312312312312312" 13 | "UpdateResult" "0" 14 | "BytesToDownload" "24348080" 15 | "BytesDownloaded" "0" 16 | "BytesToStage" "1284862702" 17 | "BytesStaged" "0" 18 | "TargetBuildID" "8559806" 19 | "AutoUpdateBehavior" "1" 20 | "AllowOtherDownloadsWhileRunning" "1" 21 | "ScheduledAutoUpdate" "1678457806" 22 | "InstalledDepots" 23 | { 24 | "599143" 25 | { 26 | "manifest" "8776335556818666951" 27 | "size" "1805798572" 28 | } 29 | } 30 | "UserConfig" 31 | { 32 | "language" "english" 33 | } 34 | "MountedConfig" 35 | { 36 | "language" "english" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/assets/appmanifest_230410.acf: -------------------------------------------------------------------------------- 1 | "AppState" 2 | { 3 | "appid" "230410" 4 | "Universe" "1" 5 | "LauncherPath" "C:\\Program Files (x86)\\Steam\\steam.exe" 6 | "name" "Warframe" 7 | "StateFlags" "4" 8 | "installdir" "Warframe" 9 | "LastUpdated" "1630871495" 10 | "UpdateResult" "2" 11 | "SizeOnDisk" "29070834580" 12 | "buildid" "6988007" 13 | "LastOwner" "12312312312312312" 14 | "BytesToDownload" "28490671360" 15 | "BytesDownloaded" "28490671360" 16 | "BytesToStage" "29070834580" 17 | "BytesStaged" "29070834580" 18 | "AutoUpdateBehavior" "0" 19 | "AllowOtherDownloadsWhileRunning" "0" 20 | "ScheduledAutoUpdate" "0" 21 | "InstalledDepots" 22 | { 23 | "230411" 24 | { 25 | "manifest" "1659398175797234554" 26 | "size" "29070834580" 27 | } 28 | } 29 | "InstallScripts" 30 | { 31 | "230411" "installscript.vdf" 32 | } 33 | "UserConfig" 34 | { 35 | "language" "english" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /examples/overview.rs: -------------------------------------------------------------------------------- 1 | use steamlocate::SteamDir; 2 | 3 | fn main() { 4 | let steamdir = SteamDir::locate().unwrap(); 5 | println!("Steam Dir - {:?}", steamdir.path()); 6 | 7 | for maybe_library in steamdir.libraries().unwrap() { 8 | match maybe_library { 9 | Err(err) => eprintln!("Failed reading library: {err}"), 10 | Ok(library) => { 11 | println!(" Library - {:?}", library.path()); 12 | for app in library.apps() { 13 | match app { 14 | Ok(app) => println!( 15 | " App {} - {}", 16 | app.app_id, 17 | app.name.as_deref().unwrap_or("") 18 | ), 19 | Err(err) => println!(" Failed reading app: {err}"), 20 | } 21 | } 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/assets/appmanifest_4000.acf: -------------------------------------------------------------------------------- 1 | "AppState" 2 | { 3 | "appid" "4000" 4 | "Universe" "1" 5 | "name" "Garry's Mod" 6 | "StateFlags" "4" 7 | "installdir" "GarrysMod" 8 | "LastUpdated" "1699500640" 9 | "SizeOnDisk" "4152333499" 10 | "StagingSize" "0" 11 | "buildid" "12123796" 12 | "LastOwner" "12312312312312312" 13 | "UpdateResult" "0" 14 | "BytesToDownload" "2313758368" 15 | "BytesDownloaded" "2313758368" 16 | "BytesToStage" "4152290626" 17 | "BytesStaged" "4152290626" 18 | "TargetBuildID" "12123796" 19 | "AutoUpdateBehavior" "0" 20 | "AllowOtherDownloadsWhileRunning" "0" 21 | "ScheduledAutoUpdate" "0" 22 | "InstalledDepots" 23 | { 24 | "4001" 25 | { 26 | "manifest" "8033896166589191357" 27 | "size" "3875126726" 28 | } 29 | "4003" 30 | { 31 | "manifest" "6271527943975114763" 32 | "size" "281149259" 33 | } 34 | } 35 | "UserConfig" 36 | { 37 | "language" "english" 38 | } 39 | "MountedConfig" 40 | { 41 | "language" "english" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/locate/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::Result; 2 | 3 | #[cfg(target_os = "linux")] 4 | mod linux; 5 | #[cfg(target_os = "linux")] 6 | use crate::locate::linux::locate_steam_dir_helper; 7 | 8 | #[cfg(target_os = "windows")] 9 | mod windows; 10 | #[cfg(target_os = "windows")] 11 | use crate::locate::windows::locate_steam_dir_helper; 12 | 13 | #[cfg(target_os = "macos")] 14 | mod macos; 15 | #[cfg(target_os = "macos")] 16 | use crate::locate::macos::locate_steam_dir_helper; 17 | 18 | #[cfg(target_os = "linux")] 19 | pub fn locate_steam_dir() -> Result> { 20 | locate_steam_dir_helper() 21 | } 22 | #[cfg(not(target_os = "linux"))] 23 | pub fn locate_steam_dir() -> Result> { 24 | locate_steam_dir_helper().map(|path| vec![path]) 25 | } 26 | 27 | #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] 28 | fn locate_steam_dir_helper() -> Result { 29 | use crate::error::{Error, LocateError}; 30 | Err(Error::locate(LocateError::Unsupported)) 31 | } 32 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use serde_derive::Deserialize; 2 | use std::collections::HashMap; 3 | 4 | #[derive(Deserialize, Debug)] 5 | #[serde(rename_all = "PascalCase")] 6 | pub(crate) struct Store { 7 | pub(crate) software: Software, 8 | } 9 | 10 | #[derive(Deserialize, Debug)] 11 | #[serde(rename_all = "PascalCase")] 12 | pub(crate) struct Software { 13 | pub(crate) valve: Valve, 14 | } 15 | 16 | #[derive(Deserialize, Debug)] 17 | #[serde(rename_all = "PascalCase")] 18 | pub(crate) struct Valve { 19 | pub(crate) steam: Steam, 20 | } 21 | 22 | #[derive(Deserialize, Debug)] 23 | pub(crate) struct Steam { 24 | #[serde(rename = "CompatToolMapping")] 25 | pub(crate) mapping: HashMap, 26 | } 27 | 28 | /// An instance of a compatibility tool. 29 | #[derive(Deserialize, Debug, Clone)] 30 | pub struct CompatTool { 31 | /// The name of the tool. 32 | /// 33 | /// Example: `proton_411` 34 | pub name: Option, 35 | 36 | // Unknown option, may be used in the future 37 | pub config: Option, 38 | 39 | // Unknown option, may be used in the future 40 | pub priority: Option, 41 | } 42 | -------------------------------------------------------------------------------- /tests/assets/appmanifest_2519830.acf: -------------------------------------------------------------------------------- 1 | "AppState" 2 | { 3 | "appid" "2519830" 4 | "Universe" "1" 5 | "name" "Resonite" 6 | "StateFlags" "4" 7 | "installdir" "Resonite" 8 | "lastupdated" "1702688752" 9 | "SizeOnDisk" "1102323116" 10 | "StagingSize" "0" 11 | "buildid" "12967476" 12 | "LastOwner" "76561198022773299" 13 | "UpdateResult" "0" 14 | "BytesToDownload" "2332576" 15 | "BytesDownloaded" "2332576" 16 | "BytesToStage" "54625540" 17 | "BytesStaged" "54625540" 18 | "TargetBuildID" "12967476" 19 | "AutoUpdateBehavior" "0" 20 | "AllowOtherDownloadsWhileRunning" "0" 21 | "ScheduledAutoUpdate" "0" 22 | "InstalledDepots" 23 | { 24 | "2519832" 25 | { 26 | "manifest" "1396658363472368690" 27 | "size" "514493538" 28 | } 29 | "2519831" 30 | { 31 | "manifest" "5082223756179978205" 32 | "size" "587829578" 33 | } 34 | } 35 | "SharedDepots" 36 | { 37 | "228984" "228980" 38 | "228985" "228980" 39 | "228988" "228980" 40 | "228989" "228980" 41 | } 42 | "UserConfig" 43 | { 44 | "language" "english" 45 | } 46 | "MountedConfig" 47 | { 48 | "language" "english" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 William Venner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/__private_tests/temp.rs: -------------------------------------------------------------------------------- 1 | //! `TempDir` at home 2 | //! 3 | //! I want to use temporary directories in doctests, but that works against your public API. 4 | //! Luckily all the functionality we need is very easy to replicate 5 | 6 | use std::{collections, env, fs, hash, path}; 7 | 8 | use super::TestError; 9 | 10 | #[derive(Debug)] 11 | pub struct TempDir(Option); 12 | 13 | impl TempDir { 14 | pub fn new() -> Result { 15 | let mut dir = env::temp_dir(); 16 | let random_name = format!("steamlocate-test-{:x}", random_seed()); 17 | dir.push(random_name); 18 | fs::create_dir_all(&dir)?; 19 | Ok(Self(Some(dir))) 20 | } 21 | 22 | pub fn path(&self) -> &path::Path { 23 | self.0.as_deref().unwrap() 24 | } 25 | } 26 | 27 | impl Drop for TempDir { 28 | fn drop(&mut self) { 29 | if let Some(path) = self.0.take() { 30 | let _ = fs::remove_dir_all(path); 31 | } 32 | } 33 | } 34 | 35 | fn random_seed() -> u64 { 36 | hash::Hasher::finish(&hash::BuildHasher::build_hasher( 37 | &collections::hash_map::RandomState::new(), 38 | )) 39 | } 40 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "steamlocate" 3 | version = "2.0.1" 4 | authors = [ 5 | "William Venner ", 6 | "Cosmic ", 7 | ] 8 | edition = "2021" 9 | repository = "https://github.com/WilliamVenner/steamlocate-rs" 10 | license = "MIT" 11 | description = "Crate for locating Steam game installation directories (and Steam itself!)" 12 | keywords = ["steam", "vdf", "appmanifest", "directory", "steamapps"] 13 | categories = ["config", "filesystem"] 14 | rust-version = "1.70.0" 15 | 16 | [package.metadata.docs.rs] 17 | rustdoc-args = ["--cfg", "docsrs"] 18 | 19 | [dependencies] 20 | crc = "3.0" 21 | keyvalues-parser = "0.2" 22 | keyvalues-serde = "0.2" 23 | serde = "1.0" 24 | serde_derive = "1.0" 25 | 26 | # Platform-specific dependencies used for locating the steam dir 27 | [target."cfg(target_os=\"windows\")".dependencies] 28 | winreg = "0.55.0" 29 | [target."cfg(any(target_os=\"macos\", target_os=\"linux\"))".dependencies] 30 | home = "0.5.9" 31 | 32 | [dev-dependencies] 33 | insta = { version = "1.34.0", features = ["ron"] } 34 | wasm-bindgen-test = "0.3.39" 35 | 36 | [[example]] 37 | name = "appmanifest" 38 | 39 | [[example]] 40 | name = "overview" 41 | 42 | [[example]] 43 | name = "shortcuts" 44 | -------------------------------------------------------------------------------- /src/locate/windows.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::Result; 4 | 5 | pub fn locate_steam_dir_helper() -> Result { 6 | use crate::error::{Error, LocateError}; 7 | 8 | use winreg::{ 9 | enums::{HKEY_LOCAL_MACHINE, KEY_READ}, 10 | RegKey, 11 | }; 12 | 13 | let io_to_locate_err = |io_err| Error::locate(LocateError::winreg(io_err)); 14 | 15 | // Locating the Steam installation location is a bit more complicated on Windows 16 | 17 | // Steam's installation location can be found in the registry 18 | let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); 19 | let installation_regkey = hklm 20 | // 32-bit 21 | .open_subkey_with_flags("SOFTWARE\\Wow6432Node\\Valve\\Steam", KEY_READ) 22 | .or_else(|_| { 23 | // 64-bit 24 | hklm.open_subkey_with_flags("SOFTWARE\\Valve\\Steam", KEY_READ) 25 | }) 26 | .map_err(io_to_locate_err)?; 27 | 28 | // The InstallPath key will contain the full path to the Steam directory 29 | let install_path_str: String = installation_regkey 30 | .get_value("InstallPath") 31 | .map_err(io_to_locate_err)?; 32 | 33 | let install_path = PathBuf::from(install_path_str); 34 | Ok(install_path) 35 | } 36 | -------------------------------------------------------------------------------- /src/snapshots/steamlocate__app__tests__sanity.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/app.rs 3 | expression: app 4 | --- 5 | App( 6 | appid: 230410, 7 | installdir: "Warframe", 8 | name: Some("Warframe"), 9 | LastOwner: Some(12312312312312312), 10 | Universe: Some(Public), 11 | LauncherPath: Some("C:\\Program Files (x86)\\Steam\\steam.exe"), 12 | StateFlags: Some(StateFlags(4)), 13 | LastUpdated: Some(SystemTime( 14 | secs_since_epoch: 1630871495, 15 | nanos_since_epoch: 0, 16 | )), 17 | UpdateResult: Some(2), 18 | SizeOnDisk: Some(29070834580), 19 | buildid: Some(6988007), 20 | BytesToDownload: Some(28490671360), 21 | BytesDownloaded: Some(28490671360), 22 | BytesToStage: Some(29070834580), 23 | BytesStaged: Some(29070834580), 24 | StagingSize: None, 25 | TargetBuildID: None, 26 | AutoUpdateBehavior: Some(KeepUpToDate), 27 | AllowOtherDownloadsWhileRunning: Some(UseGlobalSetting), 28 | ScheduledAutoUpdate: Some(Zero), 29 | FullValidateBeforeNextUpdate: None, 30 | FullValidateAfterNextUpdate: None, 31 | InstalledDepots: { 32 | 230411: Depot( 33 | manifest: 1659398175797234554, 34 | size: 29070834580, 35 | dlcappid: None, 36 | ), 37 | }, 38 | StagedDepots: {}, 39 | UserConfig: { 40 | "language": "english", 41 | }, 42 | MountedConfig: {}, 43 | InstallScripts: { 44 | 230411: "installscript.vdf", 45 | }, 46 | SharedDepots: {}, 47 | ) 48 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | validation: 14 | strategy: 15 | matrix: 16 | os: [ubuntu-latest, macos-latest, windows-latest] 17 | toolchain: [stable, beta] 18 | runs-on: ${{ matrix.os }} 19 | steps: 20 | - uses: actions/checkout@v6 21 | - name: Install ${{ matrix.toolchain }} toolchain 22 | uses: dtolnay/rust-toolchain@master 23 | with: 24 | toolchain: ${{ matrix.toolchain }} 25 | components: clippy, rustfmt 26 | - name: Setup cache 27 | uses: Swatinem/rust-cache@v2 28 | - name: Commune with clippy 29 | run: cargo clippy --all -- -D warnings 30 | - name: Check formatting 31 | run: cargo fmt --all -- --check 32 | - name: Run test suite 33 | run: cargo test 34 | - name: Check docs 35 | env: 36 | RUSTDOCFLAGS: -Dwarnings 37 | run: cargo doc --all --no-deps 38 | 39 | wasm: 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v6 43 | - name: Install stable toolchain 44 | uses: dtolnay/rust-toolchain@stable 45 | - name: Install wasm-pack 46 | uses: taiki-e/install-action@wasm-pack 47 | - name: Run wasm tests 48 | run: wasm-pack test --node 49 | -------------------------------------------------------------------------------- /src/snapshots/steamlocate__app__tests__more_sanity.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/app.rs 3 | expression: app 4 | --- 5 | App( 6 | appid: 599140, 7 | installdir: "Graveyard Keeper", 8 | name: Some("Graveyard Keeper"), 9 | LastOwner: Some(12312312312312312), 10 | Universe: Some(Public), 11 | LauncherPath: None, 12 | StateFlags: Some(StateFlags(6)), 13 | LastUpdated: Some(SystemTime( 14 | secs_since_epoch: 1672176869, 15 | nanos_since_epoch: 0, 16 | )), 17 | UpdateResult: Some(0), 18 | SizeOnDisk: Some(1805798572), 19 | buildid: Some(8559806), 20 | BytesToDownload: Some(24348080), 21 | BytesDownloaded: Some(0), 22 | BytesToStage: Some(1284862702), 23 | BytesStaged: Some(0), 24 | StagingSize: Some(0), 25 | TargetBuildID: Some(8559806), 26 | AutoUpdateBehavior: Some(OnlyUpdateOnLaunch), 27 | AllowOtherDownloadsWhileRunning: Some(Allow), 28 | ScheduledAutoUpdate: Some(Time(SystemTime( 29 | secs_since_epoch: 1678457806, 30 | nanos_since_epoch: 0, 31 | ))), 32 | FullValidateBeforeNextUpdate: None, 33 | FullValidateAfterNextUpdate: None, 34 | InstalledDepots: { 35 | 599143: Depot( 36 | manifest: 8776335556818666951, 37 | size: 1805798572, 38 | dlcappid: None, 39 | ), 40 | }, 41 | StagedDepots: {}, 42 | UserConfig: { 43 | "language": "english", 44 | }, 45 | MountedConfig: { 46 | "language": "english", 47 | }, 48 | InstallScripts: {}, 49 | SharedDepots: {}, 50 | ) 51 | -------------------------------------------------------------------------------- /src/locate/linux.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::Result; 4 | 5 | pub fn locate_steam_dir_helper() -> Result> { 6 | use std::{collections::BTreeSet, env}; 7 | 8 | use crate::error::{Error, LocateError}; 9 | 10 | // Steam's installation location is pretty easy to find on Linux, too, thanks to the symlink in $USER 11 | let home_dir = home::home_dir().ok_or_else(|| Error::locate(LocateError::no_home()))?; 12 | let snap_dir = match env::var("SNAP_USER_DATA") { 13 | Ok(snap_dir) => PathBuf::from(snap_dir), 14 | Err(_) => home_dir.join("snap"), 15 | }; 16 | 17 | let mut path_deduper = BTreeSet::new(); 18 | let unique_paths = [ 19 | // Flatpak steam install directories 20 | home_dir.join(".var/app/com.valvesoftware.Steam/.local/share/Steam"), 21 | home_dir.join(".var/app/com.valvesoftware.Steam/.steam/steam"), 22 | home_dir.join(".var/app/com.valvesoftware.Steam/.steam/root"), 23 | // Standard install directories 24 | home_dir.join(".local/share/Steam"), 25 | home_dir.join(".steam/steam"), 26 | home_dir.join(".steam/root"), 27 | home_dir.join(".steam/debian-installation"), 28 | // Snap steam install directories 29 | snap_dir.join("steam/common/.local/share/Steam"), 30 | snap_dir.join("steam/common/.steam/steam"), 31 | snap_dir.join("steam/common/.steam/root"), 32 | ] 33 | .into_iter() 34 | .filter(|path| path.is_dir()) 35 | .filter_map(|path| { 36 | let resolved_path = path.read_link().unwrap_or_else(|_| path.clone()); 37 | path_deduper.insert(resolved_path.clone()).then_some(path) 38 | }) 39 | .collect(); 40 | Ok(unique_paths) 41 | } 42 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 2.0.1 2 | 3 | Just a small release to keep things up to date 4 | 5 | ## Documentation 6 | 7 | - Add a changelog #89 8 | 9 | ## Dependencies 10 | 11 | - Update `winreg` from 0.52 -> 0.55 #86 12 | 13 | ## Internal 14 | 15 | - Make tests into into integration tests where possible #87 16 | - Remove publish workflow #88 17 | - Add a release checklist #90 18 | 19 | # 2.0.0 20 | 21 | Finally after a _very_ long development period we're release version 2.0.0. Living up to the major version bump this release does involve breaking changes to most parts of the API. The majority of these changes fit into three core 22 | themes: 23 | 24 | 1. `Iterator`ification of the _list all_ flavors of methods 25 | 2. Exhaustively parsing `App`s (previously `SteamApp`s) to drop the public dependency on `steamy-vdf` 26 | 3. Actually defining an `Error` type instead of returning ambiguous `None`s 27 | 28 | Let's dive right in 29 | 30 | ## `Iterator`ification of the _list all_ methods 31 | 32 | Methods that would previously exhaustively collect some set of information and cache it like `SteamDir::libraryfolders()` and `SteamDir::apps()` now return iterators that walk over the set of information and returns values on the fly akin to APIs like `std::fs::read_dir()`. This has a couple of distinct advantages where we can return precise errors for each item ergonomically, and we can be lazier with our computation 33 | 34 | ## Exhaustive `App`s 35 | 36 | We're trying to be a stable library since our major version is >0, but unfortunately there's not a stable [VDF](https://developer.valvesoftware.com/wiki/KeyValues) parser in sight. That's a bit problematic as we'll want to avoid relying on one in our public API, but that also means significant changes to how `App` would hold a `steamy_vdf::Table` representing the parsed appmanifest file. To mitigate this we attempt to exhaustively parse and provide as much data as we can from steam apps, and to top it off we also annotated it with `#[non_exhaustive]`, so that more fields can be added in the future without a breaking change 37 | 38 | ## An `Error` appears! 39 | 40 | This is a _significant_ improvement over the old API. Previously errors would be bubbled up as `None`s that would lead to ambiguity over whether a `None` is from something not existing or simply failing to be parsed. We now religiously return errors to represent failure cases leaving it up to the consumer to decide whether to ignore the error or fail loudly 41 | 42 | Where possible we try to include relevant information for the error, but several of the underlying types are intentionally opaque to avoid exposing unstable depdencies in our public API 43 | -------------------------------------------------------------------------------- /tests/legacy.rs: -------------------------------------------------------------------------------- 1 | use steamlocate::Error; 2 | use steamlocate::__private_tests::prelude::*; 3 | 4 | static GMOD_ID: u32 = SampleApp::GarrysMod.id(); 5 | 6 | #[test] 7 | fn find_library_folders() -> TestResult { 8 | let tmp_steam_dir = expect_test_env(); 9 | let steam_dir = tmp_steam_dir.steam_dir(); 10 | assert!(steam_dir.libraries()?.len() > 1); 11 | Ok(()) 12 | } 13 | 14 | #[test] 15 | fn find_app() -> TestResult { 16 | let tmp_steam_dir = expect_test_env(); 17 | let steam_dir = tmp_steam_dir.steam_dir(); 18 | let steam_app = steam_dir.find_app(GMOD_ID)?; 19 | assert_eq!(steam_app.unwrap().0.app_id, GMOD_ID); 20 | Ok(()) 21 | } 22 | 23 | #[test] 24 | fn app_details() -> TestResult { 25 | let tmp_steam_dir = expect_test_env(); 26 | let steam_dir = tmp_steam_dir.steam_dir(); 27 | let steam_app = steam_dir.find_app(GMOD_ID)?.unwrap(); 28 | assert_eq!(steam_app.0.name.unwrap(), "Garry's Mod"); 29 | Ok(()) 30 | } 31 | 32 | #[test] 33 | fn all_apps() -> TestResult { 34 | let tmp_steam_dir = expect_test_env(); 35 | let steam_dir = tmp_steam_dir.steam_dir(); 36 | let mut libraries = steam_dir.libraries()?; 37 | let all_apps: Vec<_> = libraries.try_fold(Vec::new(), |mut acc, maybe_library| { 38 | let library = maybe_library?; 39 | for maybe_app in library.apps() { 40 | let app = maybe_app?; 41 | acc.push(app); 42 | } 43 | Ok::<_, Error>(acc) 44 | })?; 45 | assert!(all_apps.len() > 1); 46 | Ok(()) 47 | } 48 | 49 | #[test] 50 | fn all_apps_get_one() -> TestResult { 51 | let tmp_steam_dir = expect_test_env(); 52 | let steam_dir = tmp_steam_dir.steam_dir(); 53 | 54 | let mut libraries = steam_dir.libraries()?; 55 | let all_apps: Vec<_> = libraries.try_fold(Vec::new(), |mut acc, maybe_library| { 56 | let library = maybe_library?; 57 | for maybe_app in library.apps() { 58 | let app = maybe_app?; 59 | acc.push(app); 60 | } 61 | Ok::<_, Error>(acc) 62 | })?; 63 | assert!(!all_apps.is_empty()); 64 | assert!(all_apps.len() > 1); 65 | 66 | let steam_app = steam_dir.find_app(GMOD_ID)?.unwrap(); 67 | assert_eq!( 68 | all_apps 69 | .into_iter() 70 | .find(|app| app.app_id == GMOD_ID) 71 | .unwrap(), 72 | steam_app.0, 73 | ); 74 | 75 | Ok(()) 76 | } 77 | 78 | // FIXME: This should fake the steam installation now 79 | // #[test] 80 | // fn find_compatibility_tool() { 81 | // let steamdir_found = SteamDir::locate(); 82 | // assert!(steamdir_found.is_some()); 83 | 84 | // let mut steamdir = steamdir_found.unwrap(); 85 | 86 | // let tool = steamdir.compat_tool(&APP_ID); 87 | // assert!(tool.is_some()); 88 | 89 | // println!("{:#?}", tool.unwrap()); 90 | // } 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![crates.io](https://img.shields.io/crates/v/steamlocate.svg)](https://crates.io/crates/steamlocate) 2 | [![docs.rs](https://docs.rs/steamlocate/badge.svg)](https://docs.rs/steamlocate/) 3 | [![license](https://img.shields.io/crates/l/steamlocate)](https://github.com/WilliamVenner/steamlocate/blob/master/LICENSE) 4 | [![Workflow Status](https://github.com/WilliamVenner/steamlocate-rs/workflows/ci/badge.svg)](https://github.com/WilliamVenner/steamlocate-rs/actions?query=workflow%3A%22ci%22) 5 | 6 | # steamlocate 7 | 8 | A crate which efficiently locates any Steam application on the filesystem, 9 | and/or the Steam installation itself. 10 | 11 | This crate is best used when you do not want to depend on the Steamworks API 12 | for your program. In some cases the Steamworks API may be more appropriate to 13 | use, in which case I recommend the fantastic 14 | [steamworks](https://github.com/Thinkofname/steamworks-rs) crate. You don't 15 | need to be a Steamworks partner to get installation directory locations from 16 | the Steamworks API. 17 | 18 | # Using steamlocate 19 | 20 | Simply add `steamlocate` using 21 | [`cargo`](https://doc.rust-lang.org/cargo/getting-started/installation.html). 22 | 23 | ```console 24 | $ cargo add steamlocate 25 | ``` 26 | 27 | # Examples 28 | 29 | ## Locate the Steam installation and a specific game 30 | 31 | The `SteamDir` is going to be your entrypoint into _most_ parts of the API. 32 | After you locate it you can access related information. 33 | 34 | ```rust,ignore 35 | let steam_dir = steamlocate::SteamDir::locate()?; 36 | println!("Steam installation - {}", steam_dir.path().display()); 37 | // ^^ prints something like `Steam installation - C:\Program Files (x86)\Steam` 38 | 39 | const GMOD_APP_ID: u32 = 4_000; 40 | let (garrys_mod, _lib) = steam_dir 41 | .find_app(GMOD_APP_ID)? 42 | .expect("Of course we have G Mod"); 43 | assert_eq!(garrys_mod.name.as_ref().unwrap(), "Garry's Mod"); 44 | println!("{garrys_mod:#?}"); 45 | // ^^ prints something like vv 46 | ``` 47 | ```rust,ignore 48 | App { 49 | app_id: 4_000, 50 | install_dir: "GarrysMod", 51 | name: Some("Garry's Mod"), 52 | universe: Some(Public), 53 | // much much more data 54 | } 55 | ``` 56 | 57 | ## Get an overview of all libraries and apps on the system 58 | 59 | You can iterate over all of Steam's libraries from the steam dir. Then from each library you 60 | can iterate over all of its apps. 61 | 62 | ```rust,ignore 63 | let steam_dir = steamlocate::SteamDir::locate()?; 64 | 65 | for library in steam_dir.libraries()? { 66 | let library = library?; 67 | println!("Library - {}", library.path().display()); 68 | 69 | for app in library.apps() { 70 | let app = app?; 71 | println!(" App {} - {:?}", app.app_id, app.name); 72 | } 73 | } 74 | ``` 75 | 76 | On my laptop this prints 77 | 78 | ```text 79 | Library - /home/wintermute/.local/share/Steam 80 | App 1628350 - Steam Linux Runtime 3.0 (sniper) 81 | App 1493710 - Proton Experimental 82 | App 4000 - Garry's Mod 83 | Library - /home/wintermute/temp steam lib 84 | App 391540 - Undertale 85 | App 1714040 - Super Auto Pets 86 | App 2348590 - Proton 8.0 87 | ``` 88 | 89 | ## Contribution 90 | 91 | Unless you explicitly state otherwise, any contribution intentionally 92 | submitted for inclusion in the work by you, as defined in the MIT license, 93 | shall be licensed as above, without any additional terms or conditions. 94 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt, io, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | pub type Result = std::result::Result; 7 | 8 | #[derive(Debug)] 9 | #[non_exhaustive] 10 | pub enum Error { 11 | FailedLocate(LocateError), 12 | InvalidSteamDir(ValidationError), 13 | Io { 14 | inner: io::Error, 15 | path: PathBuf, 16 | }, 17 | Parse { 18 | kind: ParseErrorKind, 19 | error: ParseError, 20 | path: PathBuf, 21 | }, 22 | MissingExpectedApp { 23 | app_id: u32, 24 | }, 25 | } 26 | 27 | impl fmt::Display for Error { 28 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 29 | match self { 30 | Self::FailedLocate(error) => { 31 | write!(f, "Failed locating the steam dir. Error: {error}") 32 | } 33 | Self::InvalidSteamDir(error) => { 34 | write!(f, "Failed validating steam dir. Error: {error}") 35 | } 36 | Self::Io { inner: err, path } => { 37 | write!(f, "Encountered an I/O error: {} at {}", err, path.display()) 38 | } 39 | Self::Parse { kind, error, path } => write!( 40 | f, 41 | "Failed parsing VDF file. File kind: {:?}, Error: {} at {}", 42 | kind, 43 | error, 44 | path.display(), 45 | ), 46 | Self::MissingExpectedApp { app_id } => { 47 | write!(f, "Missing expected app with id: {app_id}") 48 | } 49 | } 50 | } 51 | } 52 | 53 | impl std::error::Error for Error {} 54 | 55 | impl Error { 56 | pub(crate) fn locate(locate: LocateError) -> Self { 57 | Self::FailedLocate(locate) 58 | } 59 | 60 | pub(crate) fn validation(validation: ValidationError) -> Self { 61 | Self::InvalidSteamDir(validation) 62 | } 63 | 64 | pub(crate) fn io(io: io::Error, path: &Path) -> Self { 65 | Self::Io { 66 | inner: io, 67 | path: path.to_owned(), 68 | } 69 | } 70 | 71 | pub(crate) fn parse(kind: ParseErrorKind, error: ParseError, path: &Path) -> Self { 72 | Self::Parse { 73 | kind, 74 | error, 75 | path: path.to_owned(), 76 | } 77 | } 78 | } 79 | 80 | #[derive(Clone, Debug)] 81 | pub enum LocateError { 82 | Backend(BackendError), 83 | Unsupported, 84 | } 85 | 86 | impl LocateError { 87 | #[cfg(target_os = "windows")] 88 | pub(crate) fn winreg(io: io::Error) -> Self { 89 | Self::Backend(BackendError { 90 | inner: BackendErrorInner(std::sync::Arc::new(io)), 91 | }) 92 | } 93 | 94 | #[cfg(any(target_os = "macos", target_os = "linux"))] 95 | pub(crate) fn no_home() -> Self { 96 | Self::Backend(BackendError { 97 | inner: BackendErrorInner::NoHome, 98 | }) 99 | } 100 | } 101 | 102 | impl fmt::Display for LocateError { 103 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 104 | match self { 105 | Self::Backend(error) => error.fmt(f), 106 | Self::Unsupported => f.write_str("Unsupported platform"), 107 | } 108 | } 109 | } 110 | 111 | #[derive(Clone, Debug)] 112 | pub struct BackendError { 113 | #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] 114 | #[allow(dead_code)] // Only used for displaying currently 115 | inner: BackendErrorInner, 116 | } 117 | 118 | impl fmt::Display for BackendError { 119 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 120 | #[cfg(target_os = "windows")] 121 | { 122 | write!(f, "{}", self.inner.0) 123 | } 124 | #[cfg(any(target_os = "macos", target_os = "linux"))] 125 | { 126 | match self.inner { 127 | BackendErrorInner::NoHome => f.write_str("Unable to locate the user's $HOME"), 128 | } 129 | } 130 | #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] 131 | { 132 | // "Use" the unused value 133 | let _ = f; 134 | unreachable!("This should never be constructed!"); 135 | } 136 | } 137 | } 138 | 139 | // TODO: move all this conditional junk into different modules, so that I don't have to keep 140 | // repeating it everywhere 141 | #[derive(Clone, Debug)] 142 | #[cfg(target_os = "windows")] 143 | struct BackendErrorInner(std::sync::Arc); 144 | #[derive(Clone, Debug)] 145 | #[cfg(any(target_os = "macos", target_os = "linux"))] 146 | enum BackendErrorInner { 147 | NoHome, 148 | } 149 | 150 | #[derive(Clone, Debug)] 151 | pub struct ValidationError { 152 | #[allow(dead_code)] // Only used for displaying currently 153 | inner: ValidationErrorInner, 154 | } 155 | 156 | impl ValidationError { 157 | pub(crate) fn missing_dir() -> Self { 158 | Self { 159 | inner: ValidationErrorInner::MissingDirectory, 160 | } 161 | } 162 | } 163 | 164 | impl fmt::Display for ValidationError { 165 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 166 | match self.inner { 167 | ValidationErrorInner::MissingDirectory => f.write_str( 168 | "The Steam installation directory either isn't a directory or doesn't exist", 169 | ), 170 | } 171 | } 172 | } 173 | 174 | #[derive(Clone, Debug)] 175 | enum ValidationErrorInner { 176 | MissingDirectory, 177 | } 178 | 179 | #[derive(Copy, Clone, Debug)] 180 | #[non_exhaustive] 181 | pub enum ParseErrorKind { 182 | Config, 183 | LibraryFolders, 184 | App, 185 | Shortcut, 186 | } 187 | 188 | #[derive(Debug)] 189 | pub struct ParseError { 190 | // Keep `keyvalues_parser` and `keyvalues_serde` types out of the public API (this includes 191 | // from traits, so no using `thiserror` with `#[from]`) 192 | #[allow(dead_code)] // Only used for displaying currently 193 | inner: Box, 194 | } 195 | 196 | impl fmt::Display for ParseError { 197 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 198 | write!(f, "{}", self.inner) 199 | } 200 | } 201 | 202 | #[derive(Debug)] 203 | pub(crate) enum ParseErrorInner { 204 | Parse(keyvalues_parser::error::Error), 205 | Serde(keyvalues_serde::error::Error), 206 | UnexpectedStructure, 207 | Missing, 208 | } 209 | 210 | impl fmt::Display for ParseErrorInner { 211 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 212 | match self { 213 | Self::Parse(err) => write!(f, "{err}"), 214 | Self::Serde(err) => write!(f, "{err}"), 215 | Self::UnexpectedStructure => f.write_str("File did not match expected structure"), 216 | Self::Missing => f.write_str("Expected file was missing"), 217 | } 218 | } 219 | } 220 | 221 | impl ParseError { 222 | pub(crate) fn new(inner: ParseErrorInner) -> Self { 223 | Self { 224 | inner: Box::new(inner), 225 | } 226 | } 227 | 228 | pub(crate) fn from_parser(err: keyvalues_parser::error::Error) -> Self { 229 | Self::new(ParseErrorInner::Parse(err)) 230 | } 231 | 232 | pub(crate) fn from_serde(err: keyvalues_serde::error::Error) -> Self { 233 | Self::new(ParseErrorInner::Serde(err)) 234 | } 235 | 236 | pub(crate) fn unexpected_structure() -> Self { 237 | Self::new(ParseErrorInner::UnexpectedStructure) 238 | } 239 | 240 | pub(crate) fn missing() -> Self { 241 | Self::new(ParseErrorInner::Missing) 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /src/library.rs: -------------------------------------------------------------------------------- 1 | //! Functionality related to Steam [`Library`]s and related types 2 | //! 3 | //! [`Library`]s are obtained from either [`SteamDir::libraries()`][super::SteamDir::libraries], 4 | //! [`SteamDir::find_app()`][super::SteamDir::find_app], or located manually with 5 | //! [`Library::from_dir()`]. 6 | 7 | use std::{ 8 | fs, 9 | path::{Path, PathBuf}, 10 | }; 11 | 12 | use crate::{ 13 | app, 14 | error::{ParseError, ParseErrorKind}, 15 | App, Error, Result, 16 | }; 17 | 18 | use keyvalues_parser::Vdf; 19 | 20 | /// Discovers all the steam libraries from `libraryfolders.vdf` 21 | /// 22 | /// We want all the library paths from `libraryfolders.vdf` which has the following structure 23 | /// 24 | /// ```vdf 25 | /// "libraryfolders" 26 | /// { 27 | /// ... 28 | /// "0" 29 | /// { 30 | /// "path" "/path/to/first/library" 31 | /// ... 32 | /// "apps" 33 | /// { 34 | /// 35 | /// ... // for all apps in the library 36 | /// } 37 | /// } 38 | /// "1" 39 | /// { 40 | /// "path" "/path/to/second/library" 41 | /// ... 42 | /// "apps" 43 | /// { 44 | /// 45 | /// ... // for all apps in the library 46 | /// } 47 | /// } 48 | /// ... 49 | /// } 50 | /// ``` 51 | pub(crate) fn parse_library_paths(path: &Path) -> Result> { 52 | let parse_error = |err| Error::parse(ParseErrorKind::LibraryFolders, err, path); 53 | 54 | if !path.is_file() { 55 | return Err(parse_error(ParseError::missing())); 56 | } 57 | 58 | let contents = fs::read_to_string(path).map_err(|io| Error::io(io, path))?; 59 | let value = Vdf::parse(&contents) 60 | .map_err(|err| parse_error(ParseError::from_parser(err)))? 61 | .value; 62 | let obj = value 63 | .get_obj() 64 | .ok_or_else(|| parse_error(ParseError::unexpected_structure()))?; 65 | let paths: Vec<_> = obj 66 | .iter() 67 | .filter(|(key, _)| key.parse::().is_ok()) 68 | .map(|(_, values)| { 69 | values 70 | .first() 71 | .and_then(|value| value.get_obj()) 72 | .and_then(|obj| obj.get("path")) 73 | .and_then(|values| values.first()) 74 | .and_then(|value| value.get_str()) 75 | .ok_or_else(|| parse_error(ParseError::unexpected_structure())) 76 | .map(PathBuf::from) 77 | }) 78 | .collect::>()?; 79 | 80 | Ok(paths) 81 | } 82 | 83 | /// An [`Iterator`] over a Steam installation's [`Library`]s 84 | /// 85 | /// Returned from calling [`SteamDir::libraries()`][super::SteamDir::libraries] 86 | pub struct Iter { 87 | paths: std::vec::IntoIter, 88 | } 89 | 90 | impl Iter { 91 | pub(crate) fn new(paths: Vec) -> Self { 92 | Self { 93 | paths: paths.into_iter(), 94 | } 95 | } 96 | } 97 | 98 | impl Iterator for Iter { 99 | type Item = Result; 100 | 101 | fn next(&mut self) -> Option { 102 | self.paths.next().map(|path| Library::from_dir(&path)) 103 | } 104 | } 105 | 106 | impl ExactSizeIterator for Iter { 107 | fn len(&self) -> usize { 108 | self.paths.len() 109 | } 110 | } 111 | 112 | /// A steam library containing various installed [`App`]s 113 | #[derive(Clone, Debug)] 114 | pub struct Library { 115 | path: PathBuf, 116 | apps: Vec, 117 | } 118 | 119 | impl Library { 120 | /// Attempt to create a [`Library`] directly from its installation directory 121 | /// 122 | /// You'll typically want to use methods that handle locating the library for you like 123 | /// [`SteamDir::libraries()`][super::SteamDir::libraries] or 124 | /// [`SteamDir::find_app()`][super::SteamDir::find_app]. 125 | pub fn from_dir(path: &Path) -> Result { 126 | // Read the manifest files at the library to get an up-to-date list of apps since the 127 | // values in `libraryfolders.vdf` may be stale 128 | let mut apps = Vec::new(); 129 | let steamapps = path.join("steamapps"); 130 | for entry in fs::read_dir(&steamapps).map_err(|io| Error::io(io, &steamapps))? { 131 | let entry = entry.map_err(|io| Error::io(io, &steamapps))?; 132 | if let Some(id) = entry 133 | .file_name() 134 | .to_str() 135 | .and_then(|name| name.strip_prefix("appmanifest_")) 136 | .and_then(|prefixless_name| prefixless_name.strip_suffix(".acf")) 137 | .and_then(|app_id_str| app_id_str.parse().ok()) 138 | { 139 | apps.push(id); 140 | } 141 | } 142 | 143 | Ok(Self { 144 | path: path.to_owned(), 145 | apps, 146 | }) 147 | } 148 | 149 | /// Returns the path to the library's installation directory 150 | /// 151 | /// # Example 152 | /// 153 | /// ``` 154 | /// # use steamlocate::__private_tests::prelude::*; 155 | /// # let temp_steam_dir = expect_test_env(); 156 | /// # let steam_dir = temp_steam_dir.steam_dir(); 157 | /// # let library = steam_dir.libraries().unwrap().next().unwrap().unwrap(); 158 | /// # /* 159 | /// let library = /* Somehow get a library */; 160 | /// # */ 161 | /// let path = library.path(); 162 | /// assert!(path.join("steamapps").is_dir()); 163 | /// ``` 164 | pub fn path(&self) -> &Path { 165 | &self.path 166 | } 167 | 168 | /// Returns the full list of Application IDs located within this library 169 | pub fn app_ids(&self) -> &[u32] { 170 | &self.apps 171 | } 172 | 173 | /// Attempts to return the [`App`] identified by `app_id` 174 | /// 175 | /// Returns [`None`] if the app isn't located within this library. Otherwise it attempts to 176 | /// return metadata for the installed app 177 | /// 178 | /// # Example 179 | /// 180 | /// ``` 181 | /// # use steamlocate::__private_tests::prelude::*; 182 | /// # let temp_steam_dir = expect_test_env(); 183 | /// # let steam_dir = temp_steam_dir.steam_dir(); 184 | /// # let library = steam_dir.libraries()?.next().unwrap()?; 185 | /// const GMOD: u32 = 4_000; 186 | /// # /* 187 | /// let library = /* Somehow get a library */; 188 | /// # */ 189 | /// let gmod = library.app(GMOD).expect("Of course we have gmod")?; 190 | /// assert_eq!(gmod.app_id, GMOD); 191 | /// assert_eq!(gmod.name.unwrap(), "Garry's Mod"); 192 | /// # Ok::<_, TestError>(()) 193 | /// ``` 194 | pub fn app(&self, app_id: u32) -> Option> { 195 | self.app_ids().iter().find(|&&id| id == app_id).map(|&id| { 196 | let manifest_path = self 197 | .path() 198 | .join("steamapps") 199 | .join(format!("appmanifest_{id}.acf")); 200 | App::new(&manifest_path) 201 | }) 202 | } 203 | 204 | /// Returns an [`Iterator`] over all of the [`App`]s contained in this library 205 | /// 206 | /// # Example 207 | /// 208 | /// ``` 209 | /// # use steamlocate::__private_tests::prelude::*; 210 | /// # let temp_steam_dir = expect_test_env(); 211 | /// # let steam_dir = temp_steam_dir.steam_dir(); 212 | /// # let library = steam_dir.libraries()?.next().unwrap()?; 213 | /// # /* 214 | /// let library = /* Somehow get a library */; 215 | /// # */ 216 | /// let total_size: u64 = library 217 | /// .apps() 218 | /// .filter_map(Result::ok) 219 | /// .filter_map(|app| app.bytes_downloaded) 220 | /// .sum(); 221 | /// println!( 222 | /// "Library {} takes up {} bytes", 223 | /// library.path().display(), total_size, 224 | /// ); 225 | /// # assert_eq!(total_size, 30804429728); 226 | /// # Ok::<_, TestError>(()) 227 | /// ``` 228 | pub fn apps(&self) -> app::Iter<'_> { 229 | app::Iter::new(self) 230 | } 231 | 232 | /// Resolves the theoretical installation directory for the given `app` 233 | /// 234 | /// This is an unvalidated path, so it's up to you to call this with an `app` that's in this 235 | /// library 236 | /// 237 | /// # Example 238 | /// 239 | /// ``` 240 | /// # use std::path::Path; 241 | /// # use steamlocate::__private_tests::prelude::*; 242 | /// # let temp_steam_dir = expect_test_env(); 243 | /// # let steam_dir = temp_steam_dir.steam_dir(); 244 | /// const GRAVEYARD_KEEPER: u32 = 599_140; 245 | /// let (graveyard_keeper, library) = steam_dir.find_app(GRAVEYARD_KEEPER)?.unwrap(); 246 | /// let app_dir = library.resolve_app_dir(&graveyard_keeper); 247 | /// let expected_rel_path = Path::new("steamapps").join("common").join("Graveyard Keeper"); 248 | /// assert!(app_dir.ends_with(expected_rel_path)); 249 | /// # Ok::<_, TestError>(()) 250 | /// ``` 251 | pub fn resolve_app_dir(&self, app: &App) -> PathBuf { 252 | self.path 253 | .join("steamapps") 254 | .join("common") 255 | .join(&app.install_dir) 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /src/shortcut.rs: -------------------------------------------------------------------------------- 1 | // HACK: This is all hacky and should be replaced with proper binary VDF parsing 2 | 3 | use std::{ 4 | fs, io, 5 | iter::Peekable, 6 | path::{Path, PathBuf}, 7 | slice, 8 | }; 9 | 10 | use crate::{ 11 | error::{ParseError, ParseErrorKind}, 12 | Error, Result, 13 | }; 14 | 15 | // TODO: refactor this to remove storing the `steam_id` and instead make it a method that 16 | // calculates on demand. That fixes some API issues and more directly represents the underlying 17 | // data. This also means that `fn new()` can be removed 18 | /// A non-Steam game that has been added to Steam 19 | /// 20 | /// Information is parsed from your `userdata//config/shortcuts.vdf` files 21 | #[derive(Clone, Debug, PartialEq, Eq)] 22 | #[non_exhaustive] 23 | pub struct Shortcut { 24 | /// Steam's provided app id 25 | pub app_id: u32, 26 | /// The name of the application 27 | pub app_name: String, 28 | /// The executable used to launch the app 29 | /// 30 | /// This is either the name of the program or the full path to the program 31 | pub executable: String, 32 | /// The directory that the application should be run in 33 | pub start_dir: String, 34 | } 35 | 36 | impl Shortcut { 37 | /// Calculates the shortcut's Steam ID from the executable and app name 38 | pub fn new(app_id: u32, app_name: String, executable: String, start_dir: String) -> Self { 39 | Self { 40 | app_id, 41 | app_name, 42 | executable, 43 | start_dir, 44 | } 45 | } 46 | 47 | /// The shortcut's Steam ID calculated from the executable path and app name 48 | pub fn steam_id(&self) -> u64 { 49 | let executable = self.executable.as_bytes(); 50 | let app_name = self.app_name.as_bytes(); 51 | 52 | let algorithm = crc::Crc::::new(&crc::CRC_32_ISO_HDLC); 53 | 54 | let mut digest = algorithm.digest(); 55 | digest.update(executable); 56 | digest.update(app_name); 57 | 58 | let top = digest.finalize() | 0x80000000; 59 | ((top as u64) << 32) | 0x02000000 60 | } 61 | } 62 | 63 | /// An [`Iterator`] over a Steam installation's [`Shortcut`]s 64 | /// 65 | /// Returned from calling [`SteamDir::shortcuts()`][super::SteamDir::shortcuts] 66 | pub struct Iter { 67 | dir: PathBuf, 68 | read_dir: fs::ReadDir, 69 | pending: std::vec::IntoIter, 70 | } 71 | 72 | impl Iter { 73 | pub(crate) fn new(steam_dir: &Path) -> Result { 74 | let user_data = steam_dir.join("userdata"); 75 | if !user_data.is_dir() { 76 | return Err(Error::parse( 77 | ParseErrorKind::Shortcut, 78 | ParseError::missing(), 79 | &user_data, 80 | )); 81 | } 82 | 83 | let read_dir = fs::read_dir(&user_data).map_err(|io| Error::io(io, &user_data))?; 84 | Ok(Self { 85 | dir: user_data, 86 | read_dir, 87 | pending: Vec::new().into_iter(), 88 | }) 89 | } 90 | } 91 | 92 | impl Iterator for Iter { 93 | type Item = Result; 94 | 95 | fn next(&mut self) -> Option { 96 | let item = loop { 97 | if let Some(shortcut) = self.pending.next() { 98 | break Ok(shortcut); 99 | } 100 | 101 | // Need to parse the next set of pending shortcuts 102 | let maybe_entry = self.read_dir.next()?; 103 | match maybe_entry { 104 | Ok(entry) => { 105 | let shortcuts_path = entry.path().join("config").join("shortcuts.vdf"); 106 | match fs::read(&shortcuts_path) { 107 | Ok(contents) => { 108 | if let Some(shortcuts) = parse_shortcuts(&contents) { 109 | self.pending = shortcuts.into_iter(); 110 | continue; 111 | } else { 112 | break Err(Error::parse( 113 | ParseErrorKind::Shortcut, 114 | ParseError::unexpected_structure(), 115 | &shortcuts_path, 116 | )); 117 | } 118 | } 119 | Err(err) => { 120 | // Not every directory in here has a shortcuts file 121 | if err.kind() == io::ErrorKind::NotFound { 122 | continue; 123 | } else { 124 | break Err(Error::io(err, &shortcuts_path)); 125 | } 126 | } 127 | } 128 | } 129 | Err(err) => break Err(Error::io(err, &self.dir)), 130 | } 131 | }; 132 | 133 | Some(item) 134 | } 135 | } 136 | 137 | /// Advances `it` until right after the matching `needle` 138 | /// 139 | /// Only works if the starting byte is not used anywhere else in the needle. This works well when 140 | /// finding keys since the starting byte indicates the type and wouldn't be used in the key 141 | #[must_use] 142 | fn after_many_case_insensitive(it: &mut Peekable>, needle: &[u8]) -> bool { 143 | loop { 144 | let mut needle_it = needle.iter(); 145 | let b = match it.next() { 146 | Some(b) => b, 147 | None => return false, 148 | }; 149 | 150 | let maybe_needle_b = needle_it.next(); 151 | if maybe_u8_eq_ignore_ascii_case(maybe_needle_b, Some(b)) { 152 | loop { 153 | if needle_it.len() == 0 { 154 | return true; 155 | } 156 | 157 | let maybe_b = it.peek(); 158 | let maybe_needle_b = needle_it.next(); 159 | if maybe_u8_eq_ignore_ascii_case(maybe_needle_b, maybe_b.copied()) { 160 | let _ = it.next(); 161 | } else { 162 | break; 163 | } 164 | } 165 | } 166 | } 167 | } 168 | 169 | fn maybe_u8_eq_ignore_ascii_case(maybe_b1: Option<&u8>, maybe_b2: Option<&u8>) -> bool { 170 | maybe_b1 171 | .zip(maybe_b2) 172 | .map(|(b1, b2)| b1.eq_ignore_ascii_case(b2)) 173 | .unwrap_or_default() 174 | } 175 | 176 | fn parse_value_str(it: &mut Peekable>) -> Option { 177 | let mut buff = Vec::new(); 178 | loop { 179 | let b = it.next()?; 180 | if *b == 0x00 { 181 | break Some(String::from_utf8_lossy(&buff).into_owned()); 182 | } 183 | 184 | buff.push(*b); 185 | } 186 | } 187 | 188 | fn parse_value_u32(it: &mut Peekable>) -> Option { 189 | let bytes = [*it.next()?, *it.next()?, *it.next()?, *it.next()?]; 190 | Some(u32::from_le_bytes(bytes)) 191 | } 192 | 193 | fn parse_shortcuts(contents: &[u8]) -> Option> { 194 | let mut it = contents.iter().peekable(); 195 | let mut shortcuts = Vec::new(); 196 | 197 | loop { 198 | if !after_many_case_insensitive(&mut it, b"\x02appid\x00") { 199 | return Some(shortcuts); 200 | } 201 | let app_id = parse_value_u32(&mut it)?; 202 | 203 | if !after_many_case_insensitive(&mut it, b"\x01AppName\x00") { 204 | return None; 205 | } 206 | let app_name = parse_value_str(&mut it)?; 207 | 208 | if !after_many_case_insensitive(&mut it, b"\x01Exe\x00") { 209 | return None; 210 | } 211 | let executable = parse_value_str(&mut it)?; 212 | 213 | if !after_many_case_insensitive(&mut it, b"\x01StartDir\x00") { 214 | return None; 215 | } 216 | let start_dir = parse_value_str(&mut it)?; 217 | 218 | let shortcut = Shortcut::new(app_id, app_name, executable, start_dir); 219 | shortcuts.push(shortcut); 220 | } 221 | } 222 | 223 | #[cfg(test)] 224 | mod tests { 225 | use super::*; 226 | 227 | #[test] 228 | fn sanity() { 229 | let contents = include_bytes!("../tests/sample_data/shortcuts.vdf"); 230 | let shortcuts = parse_shortcuts(contents).unwrap(); 231 | assert_eq!( 232 | shortcuts, 233 | vec![ 234 | Shortcut { 235 | app_id: 2786274309, 236 | app_name: "Anki".into(), 237 | executable: "\"anki\"".into(), 238 | start_dir: "\"./\"".into(), 239 | }, 240 | Shortcut { 241 | app_id: 2492174738, 242 | app_name: "LibreOffice Calc".into(), 243 | executable: "\"libreoffice\"".into(), 244 | start_dir: "\"./\"".into(), 245 | }, 246 | Shortcut { 247 | app_id: 3703025501, 248 | app_name: "foo.sh".into(), 249 | executable: "\"/usr/local/bin/foo.sh\"".into(), 250 | start_dir: "\"/usr/local/bin/\"".into(), 251 | } 252 | ], 253 | ); 254 | let steam_ids: Vec<_> = shortcuts 255 | .iter() 256 | .map(|shortcut| shortcut.steam_id()) 257 | .collect(); 258 | assert_eq!( 259 | steam_ids, 260 | [0xe89614fe02000000, 0xdb01c79902000000, 0x9d55017302000000,] 261 | ); 262 | 263 | let contents = include_bytes!("../tests/sample_data/shortcuts_different_key_case.vdf"); 264 | let shortcuts = parse_shortcuts(contents).unwrap(); 265 | assert_eq!( 266 | shortcuts, 267 | vec![Shortcut { 268 | app_id: 2931025216, 269 | app_name: "Second Life".into(), 270 | executable: "\"/Applications/Second Life Viewer.app\"".into(), 271 | start_dir: "\"/Applications/\"".into(), 272 | }] 273 | ); 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /src/__private_tests/helpers.rs: -------------------------------------------------------------------------------- 1 | //! Some test helpers for setting up isolated dummy steam installations. 2 | 3 | use std::{ 4 | collections::BTreeMap, 5 | fs, iter, 6 | path::{Path, PathBuf}, 7 | }; 8 | 9 | use super::{temp::TempDir, TestError}; 10 | use crate::SteamDir; 11 | 12 | use serde_derive::Serialize; 13 | 14 | pub fn expect_test_env() -> TempSteamDir { 15 | TempSteamDir::builder() 16 | .app(SampleApp::GarrysMod.into()) 17 | .app(SampleApp::Warframe.into()) 18 | .library(SampleApp::GraveyardKeeper.try_into().unwrap()) 19 | .finish() 20 | .unwrap() 21 | } 22 | 23 | // TODO(cosmic): Add in functionality for providing shortcuts too 24 | pub struct TempSteamDir { 25 | steam_dir: crate::SteamDir, 26 | _tmps: Vec, 27 | } 28 | 29 | impl TryFrom for TempSteamDir { 30 | type Error = TestError; 31 | 32 | fn try_from(app: AppFile) -> Result { 33 | Self::builder().app(app).finish() 34 | } 35 | } 36 | 37 | impl TryFrom for TempSteamDir { 38 | type Error = TestError; 39 | 40 | fn try_from(sample_app: SampleApp) -> Result { 41 | Self::try_from(AppFile::from(sample_app)) 42 | } 43 | } 44 | 45 | impl TryFrom for TempSteamDir { 46 | type Error = TestError; 47 | 48 | fn try_from(sample_shortcuts: SampleShortcuts) -> Result { 49 | Self::builder().shortcuts(sample_shortcuts).finish() 50 | } 51 | } 52 | 53 | impl TempSteamDir { 54 | pub fn builder() -> TempSteamDirBuilder { 55 | TempSteamDirBuilder::default() 56 | } 57 | 58 | pub fn steam_dir(&self) -> &SteamDir { 59 | &self.steam_dir 60 | } 61 | } 62 | 63 | #[derive(Default)] 64 | #[must_use] 65 | pub struct TempSteamDirBuilder { 66 | shortcuts: Option, 67 | libraries: Vec, 68 | apps: Vec, 69 | } 70 | 71 | impl TempSteamDirBuilder { 72 | pub fn shortcuts(mut self, shortcuts: SampleShortcuts) -> Self { 73 | self.shortcuts = Some(shortcuts); 74 | self 75 | } 76 | 77 | pub fn app(mut self, app: AppFile) -> Self { 78 | self.apps.push(app); 79 | self 80 | } 81 | 82 | pub fn library(mut self, library: TempLibrary) -> Self { 83 | self.libraries.push(library); 84 | self 85 | } 86 | 87 | // Steam dir is also a library, but is laid out slightly differently than a regular library 88 | pub fn finish(self) -> Result { 89 | let Self { 90 | shortcuts, 91 | libraries, 92 | apps, 93 | } = self; 94 | 95 | let tmp = TempDir::new()?; 96 | let root_dir = tmp.path().join("test-steam-dir"); 97 | let steam_dir = root_dir.join("Steam"); 98 | let apps_dir = steam_dir.join("steamapps"); 99 | fs::create_dir_all(&apps_dir)?; 100 | let shortcuts_dir = steam_dir.join("userdata").join("123123123").join("config"); 101 | fs::create_dir_all(&shortcuts_dir)?; 102 | 103 | if let Some(shortcuts) = shortcuts { 104 | let data = shortcuts.data(); 105 | let shortcuts_file = shortcuts_dir.join("shortcuts.vdf"); 106 | fs::write(&shortcuts_file, data)?; 107 | } 108 | 109 | setup_steamapps_dir(&apps_dir, &apps)?; 110 | 111 | let steam_dir_content_id = i32::MIN; 112 | let apps = apps.iter().map(|app| (app.id, 0)).collect(); 113 | let root_library = 114 | LibraryFolder::mostly_default(steam_dir.clone(), steam_dir_content_id, apps); 115 | setup_libraryfolders_file(&apps_dir, root_library, &libraries)?; 116 | 117 | let tmps = iter::once(tmp) 118 | .chain(libraries.into_iter().map(|library| library._tmp)) 119 | .collect(); 120 | 121 | Ok(TempSteamDir { 122 | steam_dir: SteamDir::from_dir(&steam_dir)?, 123 | _tmps: tmps, 124 | }) 125 | } 126 | } 127 | 128 | fn setup_steamapps_dir(apps_dir: &Path, apps: &[AppFile]) -> Result<(), TestError> { 129 | let apps_common_dir = apps_dir.join("common"); 130 | fs::create_dir_all(&apps_common_dir)?; 131 | 132 | for app in apps { 133 | let manifest_path = apps_dir.join(app.file_name()); 134 | fs::write(&manifest_path, &app.contents)?; 135 | let app_install_dir = apps_common_dir.join(&app.install_dir); 136 | fs::create_dir_all(&app_install_dir)?; 137 | } 138 | 139 | Ok(()) 140 | } 141 | 142 | fn setup_libraryfolders_file( 143 | apps_dir: &Path, 144 | root_library: LibraryFolder, 145 | aux_libraries: &[TempLibrary], 146 | ) -> Result<(), TestError> { 147 | let library_folders = 148 | iter::once(root_library).chain(aux_libraries.iter().map(|temp_library| { 149 | LibraryFolder::mostly_default( 150 | temp_library.path.clone(), 151 | temp_library.content_id, 152 | temp_library.apps.clone(), 153 | ) 154 | })); 155 | let inner: BTreeMap = library_folders 156 | .into_iter() 157 | .enumerate() 158 | .map(|(i, f)| (i.try_into().unwrap(), f)) 159 | .collect(); 160 | let library_folders_contents = 161 | keyvalues_serde::to_string_with_key(&inner, "libraryfolders").unwrap(); 162 | let library_folders_path = apps_dir.join("libraryfolders.vdf"); 163 | fs::write(library_folders_path, library_folders_contents)?; 164 | 165 | Ok(()) 166 | } 167 | 168 | #[derive(Serialize)] 169 | struct LibraryFolder { 170 | path: PathBuf, 171 | label: String, 172 | contentid: i32, 173 | totalsize: u64, 174 | update_clean_bytes_tally: u64, 175 | time_last_update_corruption: u64, 176 | apps: BTreeMap, 177 | } 178 | 179 | impl LibraryFolder { 180 | fn mostly_default(path: PathBuf, contentid: i32, apps: BTreeMap) -> Self { 181 | let totalsize = apps.values().sum(); 182 | Self { 183 | path, 184 | contentid, 185 | apps, 186 | totalsize, 187 | label: String::default(), 188 | update_clean_bytes_tally: 79_799_828_443, 189 | time_last_update_corruption: 0, 190 | } 191 | } 192 | } 193 | 194 | pub struct TempLibrary { 195 | content_id: i32, 196 | path: PathBuf, 197 | apps: BTreeMap, 198 | _tmp: TempDir, 199 | } 200 | 201 | impl TryFrom for TempLibrary { 202 | type Error = TestError; 203 | 204 | fn try_from(app: AppFile) -> Result { 205 | Self::builder().app(app).finish() 206 | } 207 | } 208 | 209 | impl TryFrom for TempLibrary { 210 | type Error = TestError; 211 | 212 | fn try_from(sample_app: SampleApp) -> Result { 213 | Self::try_from(AppFile::from(sample_app)) 214 | } 215 | } 216 | 217 | impl TempLibrary { 218 | pub fn builder() -> TempLibraryBuilder { 219 | TempLibraryBuilder::default() 220 | } 221 | } 222 | 223 | #[derive(Default)] 224 | #[must_use] 225 | pub struct TempLibraryBuilder { 226 | apps: Vec, 227 | } 228 | 229 | impl TempLibraryBuilder { 230 | fn app(mut self, app: AppFile) -> Self { 231 | self.apps.push(app); 232 | self 233 | } 234 | 235 | fn finish(self) -> Result { 236 | let tmp = TempDir::new()?; 237 | let root_dir = tmp.path().join("test-library"); 238 | let apps_dir = root_dir.join("steamapps"); 239 | fs::create_dir_all(&apps_dir)?; 240 | 241 | let meta_path = apps_dir.join("libraryfolder.vdf"); 242 | fs::write( 243 | meta_path, 244 | include_str!("../../tests/assets/libraryfolder.vdf"), 245 | )?; 246 | 247 | setup_steamapps_dir(&apps_dir, &self.apps)?; 248 | let apps = self.apps.iter().map(|app| (app.id, 0)).collect(); 249 | 250 | Ok(TempLibrary { 251 | content_id: 1234, 252 | path: root_dir, 253 | apps, 254 | _tmp: tmp, 255 | }) 256 | } 257 | } 258 | 259 | pub struct AppFile { 260 | id: u32, 261 | install_dir: String, 262 | contents: String, 263 | } 264 | 265 | impl From for AppFile { 266 | fn from(sample: SampleApp) -> Self { 267 | Self { 268 | id: sample.id(), 269 | install_dir: sample.install_dir().to_owned(), 270 | contents: sample.contents().to_owned(), 271 | } 272 | } 273 | } 274 | 275 | impl AppFile { 276 | fn file_name(&self) -> String { 277 | format!("appmanifest_{}.acf", self.id) 278 | } 279 | } 280 | 281 | #[derive(Clone, Copy)] 282 | pub enum SampleApp { 283 | GarrysMod, 284 | GraveyardKeeper, 285 | Resonite, 286 | Warframe, 287 | } 288 | 289 | impl SampleApp { 290 | pub const fn id(&self) -> u32 { 291 | self.data().0 292 | } 293 | 294 | pub const fn install_dir(&self) -> &'static str { 295 | self.data().1 296 | } 297 | 298 | pub const fn contents(&self) -> &'static str { 299 | self.data().2 300 | } 301 | 302 | pub const fn data(&self) -> (u32, &'static str, &'static str) { 303 | match self { 304 | Self::GarrysMod => ( 305 | 4_000, 306 | "GarrysMod", 307 | include_str!("../../tests/assets/appmanifest_4000.acf"), 308 | ), 309 | Self::GraveyardKeeper => ( 310 | 599_140, 311 | "Graveyard Keeper", 312 | include_str!("../../tests/assets/appmanifest_599140.acf"), 313 | ), 314 | Self::Resonite => ( 315 | 2_519_830, 316 | "Resonite", 317 | include_str!("../../tests/assets/appmanifest_2519830.acf"), 318 | ), 319 | Self::Warframe => ( 320 | 230_410, 321 | "Warframe", 322 | include_str!("../../tests/assets/appmanifest_230410.acf"), 323 | ), 324 | } 325 | } 326 | } 327 | 328 | pub enum SampleShortcuts { 329 | JustGogMoonlighter, 330 | } 331 | 332 | impl SampleShortcuts { 333 | pub const fn data(&self) -> &'static [u8] { 334 | match self { 335 | Self::JustGogMoonlighter => { 336 | include_bytes!("../../tests/sample_data/shortcuts_just_gog_moonlighter.vdf") 337 | } 338 | } 339 | } 340 | } 341 | 342 | #[cfg(test)] 343 | mod test { 344 | use super::*; 345 | use crate::__private_tests::TestResult; 346 | 347 | #[test] 348 | fn sanity() -> TestResult { 349 | let tmp_steam_dir = TempSteamDir::try_from(SampleApp::GarrysMod)?; 350 | let steam_dir = tmp_steam_dir.steam_dir(); 351 | assert!(steam_dir 352 | .find_app(SampleApp::GarrysMod.id()) 353 | .unwrap() 354 | .is_some()); 355 | 356 | Ok(()) 357 | } 358 | } 359 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! A crate which efficiently locates any Steam application on the filesystem, and/or the Steam installation itself. 2 | //! 3 | //! # Using steamlocate 4 | //! 5 | //! Simply add `steamlocate` using 6 | //! [`cargo`](https://doc.rust-lang.org/cargo/getting-started/installation.html). 7 | //! 8 | //! ```console 9 | //! $ cargo add steamlocate 10 | //! ``` 11 | //! 12 | //! # Examples 13 | //! 14 | //! ## Locate the Steam installation and a specific game 15 | //! 16 | //! The [`SteamDir`] is going to be your entrypoint into _most_ parts of the API. After you locate 17 | //! it you can access related information. 18 | //! 19 | //! ``` 20 | //! # /* 21 | //! let steam_dir = steamlocate::SteamDir::locate()?; 22 | //! # */ 23 | //! # use steamlocate::__private_tests::prelude::*; 24 | //! # let temp_steam_dir = expect_test_env(); 25 | //! # let steam_dir = temp_steam_dir.steam_dir(); 26 | //! println!("Steam installation - {}", steam_dir.path().display()); 27 | //! // ^^ prints something like `Steam installation - C:\Program Files (x86)\Steam` 28 | //! 29 | //! const GMOD_APP_ID: u32 = 4_000; 30 | //! let (garrys_mod, _lib) = steam_dir 31 | //! .find_app(GMOD_APP_ID)? 32 | //! .expect("Of course we have G Mod"); 33 | //! assert_eq!(garrys_mod.name.as_ref().unwrap(), "Garry's Mod"); 34 | //! println!("{garrys_mod:#?}"); 35 | //! // ^^ prints something like vv 36 | //! # Ok::<_, TestError>(()) 37 | //! ``` 38 | //! ```ignore 39 | //! App { 40 | //! app_id: 4_000, 41 | //! install_dir: "GarrysMod", 42 | //! name: Some("Garry's Mod"), 43 | //! universe: Some(Public), 44 | //! // much much more data 45 | //! } 46 | //! ``` 47 | //! 48 | //! ## Get an overview of all libraries and apps on the system 49 | //! 50 | //! You can iterate over all of Steam's libraries from the steam dir. Then from each library you 51 | //! can iterate over all of its apps. 52 | //! 53 | //! ``` 54 | //! # /* 55 | //! let steam_dir = steamlocate::SteamDir::locate()?; 56 | //! # */ 57 | //! # use steamlocate::__private_tests::prelude::*; 58 | //! # let temp_steam_dir = expect_test_env(); 59 | //! # let steam_dir = temp_steam_dir.steam_dir(); 60 | //! 61 | //! for library in steam_dir.libraries()? { 62 | //! let library = library?; 63 | //! println!("Library - {}", library.path().display()); 64 | //! 65 | //! for app in library.apps() { 66 | //! let app = app?; 67 | //! println!(" App {} - {:?}", app.app_id, app.name); 68 | //! } 69 | //! } 70 | //! # Ok::<_, TestError>(()) 71 | //! ``` 72 | //! 73 | //! On my laptop this prints 74 | //! 75 | //! ```text 76 | //! Library - /home/wintermute/.local/share/Steam 77 | //! App 1628350 - Steam Linux Runtime 3.0 (sniper) 78 | //! App 1493710 - Proton Experimental 79 | //! App 4000 - Garry's Mod 80 | //! Library - /home/wintermute/temp steam lib 81 | //! App 391540 - Undertale 82 | //! App 1714040 - Super Auto Pets 83 | //! App 2348590 - Proton 8.0 84 | //! ``` 85 | 86 | #![warn( 87 | // We're a library after all 88 | clippy::print_stderr, clippy::print_stdout, 89 | // Honestly just good in general 90 | clippy::todo, 91 | )] 92 | 93 | pub mod app; 94 | pub mod config; 95 | pub mod error; 96 | pub mod library; 97 | mod locate; 98 | pub mod shortcut; 99 | // NOTE: exposed publicly, so that we can use them in doctests 100 | /// Not part of the public API >:V 101 | #[doc(hidden)] 102 | pub mod __private_tests; 103 | 104 | use std::collections::HashMap; 105 | use std::fs; 106 | use std::path::{Path, PathBuf}; 107 | 108 | use error::ValidationError; 109 | 110 | use crate::error::{ParseError, ParseErrorKind}; 111 | 112 | pub use crate::app::App; 113 | pub use crate::config::CompatTool; 114 | pub use crate::error::{Error, Result}; 115 | pub use crate::library::Library; 116 | pub use crate::shortcut::Shortcut; 117 | 118 | // Run doctests on the README too 119 | #[doc = include_str!("../README.md")] 120 | #[cfg(doctest)] 121 | pub struct ReadmeDoctests; 122 | 123 | /// The entrypoint into most of the rest of the API 124 | /// 125 | /// Use either [`SteamDir::locate()`] or [`SteamDir::from_dir()`] to create a new instance. 126 | /// From there you have access to: 127 | /// 128 | /// - The Steam installation directory 129 | /// - [`steam_dir.path()`][SteamDir::path] 130 | /// - Library info 131 | /// - [`steam_dir.library_paths()`][SteamDir::library_paths] 132 | /// - [`steam_dir.libraries()`][SteamDir::libraries] 133 | /// - Convenient access to find a specific app by id 134 | /// - [`steam_dir.find_app(app_id)`][SteamDir::find_app] 135 | /// - Compatibility tool mapping (aka Proton to game mapping) 136 | /// - [`steam_dir.compat_tool_mapping()`][SteamDir::compat_tool_mapping] 137 | /// - Shortcuts info (aka the listing of non-Steam games) 138 | /// - [`steam_dir.shortcuts()`][SteamDir::shortcuts] 139 | /// 140 | /// # Example 141 | /// ``` 142 | /// # /* 143 | /// let steam_dir = SteamDir::locate()?; 144 | /// # */ 145 | /// # use steamlocate::__private_tests::prelude::*; 146 | /// # let temp_steam_dir = expect_test_env(); 147 | /// # let steam_dir = temp_steam_dir.steam_dir(); 148 | /// assert!(steam_dir.path().ends_with("Steam")); 149 | /// ``` 150 | #[derive(Clone, Debug)] 151 | pub struct SteamDir { 152 | path: PathBuf, 153 | } 154 | 155 | impl SteamDir { 156 | /// Attempts to locate the Steam installation directory on the system 157 | /// 158 | /// 159 | /// Uses platform specific operations to locate the Steam directory. Currently the supported 160 | /// platforms are Windows, MacOS, and Linux while other platforms return an 161 | /// [`LocateError::Unsupported`][error::LocateError::Unsupported] 162 | /// 163 | /// [See the struct docs][Self#example] for an example 164 | pub fn locate() -> Result { 165 | let paths = locate::locate_steam_dir()?; 166 | let path = paths 167 | .first() 168 | .ok_or(error::Error::InvalidSteamDir(ValidationError::missing_dir()))?; 169 | Self::from_dir(path) 170 | } 171 | 172 | pub fn locate_multiple() -> Result> { 173 | let paths = locate::locate_steam_dir()?; 174 | let mapped_paths: Result> = 175 | paths.iter().map(|item| Self::from_dir(item)).collect(); 176 | mapped_paths 177 | } 178 | 179 | /// Attempt to create a [`SteamDir`] from its installation directory 180 | /// 181 | /// When possible you should prefer using [`SteamDir::locate()`] 182 | /// 183 | /// # Example 184 | /// 185 | /// ``` 186 | /// # use steamlocate::SteamDir; 187 | /// # use steamlocate::__private_tests::prelude::*; 188 | /// # let temp_steam_dir = expect_test_env(); 189 | /// # let steam_dir = temp_steam_dir.steam_dir(); 190 | /// # /* 191 | /// let steam_dir = SteamDir::locate()?; 192 | /// # */ 193 | /// let steam_path = steam_dir.path(); 194 | /// let still_steam_dir = SteamDir::from_dir(steam_path).expect("We just located it"); 195 | /// assert_eq!(still_steam_dir.path(), steam_path); 196 | /// ``` 197 | pub fn from_dir(path: &Path) -> Result { 198 | if !path.is_dir() { 199 | return Err(Error::validation(ValidationError::missing_dir())); 200 | } 201 | 202 | // TODO(cosmic): should we do some kind of extra validation here? Could also use validation 203 | // to determine if a steam dir has been uninstalled. Should fix all the flatpack/snap issues 204 | Ok(Self { 205 | path: path.to_owned(), 206 | }) 207 | } 208 | 209 | /// The path to the Steam installation directory on this computer. 210 | /// 211 | /// Example: `C:\Program Files (x86)\Steam` 212 | pub fn path(&self) -> &Path { 213 | &self.path 214 | } 215 | 216 | pub fn library_paths(&self) -> Result> { 217 | let libraryfolders_vdf = self.path.join("steamapps").join("libraryfolders.vdf"); 218 | library::parse_library_paths(&libraryfolders_vdf) 219 | } 220 | 221 | /// Returns an [`Iterator`] over all the [`Library`]s believed to be part of this installation 222 | /// 223 | /// For reasons akin to [`std::fs::read_dir()`] this method both returns a [`Result`] and 224 | /// returns [`Result`]s for the iterator's items. 225 | /// 226 | /// # Example 227 | /// 228 | /// ``` 229 | /// # /* 230 | /// let steam_dir = SteamDir::locate()?; 231 | /// # */ 232 | /// # use steamlocate::__private_tests::prelude::*; 233 | /// # let temp_steam_dir = expect_test_env(); 234 | /// # let steam_dir = temp_steam_dir.steam_dir(); 235 | /// let num_apps: usize = steam_dir 236 | /// .libraries()? 237 | /// .filter_map(Result::ok) 238 | /// .map(|lib| lib.app_ids().len()) 239 | /// .sum(); 240 | /// println!("Wow you have {num_apps} installed!"); 241 | /// # assert_eq!(num_apps, 3); 242 | /// # Ok::<_, TestError>(()) 243 | /// ``` 244 | pub fn libraries(&self) -> Result { 245 | let paths = self.library_paths()?; 246 | Ok(library::Iter::new(paths)) 247 | } 248 | 249 | /// Convenient helper to look through all the libraries for a specific app 250 | /// 251 | /// # Example 252 | /// 253 | /// ``` 254 | /// # use steamlocate::__private_tests::prelude::*; 255 | /// # let temp_steam_dir = expect_test_env(); 256 | /// # let steam_dir = temp_steam_dir.steam_dir(); 257 | /// # /* 258 | /// let steam_dir = SteamDir::locate()?; 259 | /// # */ 260 | /// const WARFRAME: u32 = 230_410; 261 | /// let (warframe, library) = steam_dir.find_app(WARFRAME)?.unwrap(); 262 | /// assert_eq!(warframe.app_id, WARFRAME); 263 | /// assert!(library.app_ids().contains(&warframe.app_id)); 264 | /// # Ok::<_, TestError>(()) 265 | /// ``` 266 | pub fn find_app(&self, app_id: u32) -> Result> { 267 | // Search for the `app_id` in each library 268 | self.libraries()? 269 | .filter_map(|library| library.ok()) 270 | .find_map(|lib| { 271 | lib.app(app_id) 272 | .map(|maybe_app| maybe_app.map(|app| (app, lib))) 273 | }) 274 | .transpose() 275 | } 276 | 277 | // TODO: `Iterator`ify this 278 | pub fn compat_tool_mapping(&self) -> Result> { 279 | let config_path = self.path.join("config").join("config.vdf"); 280 | let vdf_text = 281 | fs::read_to_string(&config_path).map_err(|io| Error::io(io, &config_path))?; 282 | let store: config::Store = keyvalues_serde::from_str(&vdf_text).map_err(|de| { 283 | Error::parse( 284 | ParseErrorKind::Config, 285 | ParseError::from_serde(de), 286 | &config_path, 287 | ) 288 | })?; 289 | 290 | Ok(store.software.valve.steam.mapping) 291 | } 292 | 293 | /// Returns an [`Iterator`] of all [`Shortcut`]s aka non-Steam games that were added to steam 294 | /// 295 | /// # Example 296 | /// 297 | /// ``` 298 | /// # use steamlocate::__private_tests::prelude::*; 299 | /// # let moonlighter = SampleShortcuts::JustGogMoonlighter; 300 | /// # let temp_steam_dir: TempSteamDir = moonlighter.try_into()?; 301 | /// # let steam_dir = temp_steam_dir.steam_dir(); 302 | /// # /* 303 | /// let steam_dir = SteamDir::locate()?; 304 | /// # */ 305 | /// let mut shortcuts_iter = steam_dir.shortcuts()?; 306 | /// let moonlighter = shortcuts_iter.next().unwrap()?; 307 | /// assert_eq!(moonlighter.app_name, "Moonlighter"); 308 | /// assert!(moonlighter.executable.ends_with("Moonlighter/start.sh\"")); 309 | /// # Ok::<_, TestError>(()) 310 | /// ``` 311 | pub fn shortcuts(&self) -> Result { 312 | shortcut::Iter::new(&self.path) 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | //! All of the data available from parsing [App] manifest files 2 | //! 3 | //! This contains the definition of [`App`] and all of the types used within it 4 | //! 5 | //! Fundamentally an [`App`] is contained within a [`Library`], but there are a variety of helpers 6 | //! that make locating an app easier. Namely: 7 | //! 8 | //! - [SteamDir::find_app()][crate::SteamDir::find_app] 9 | //! - Searches through all of the libraries to locate an app by ID 10 | //! - [Library::app()] 11 | //! - Searches this specific library for an app by ID 12 | //! - [Library::apps()] 13 | //! - Iterates over all of the apps contained in this library 14 | 15 | use std::{ 16 | collections::BTreeMap, 17 | fs, 18 | path::{Path, PathBuf}, 19 | slice, time, 20 | }; 21 | 22 | use crate::{ 23 | error::{ParseError, ParseErrorKind}, 24 | Error, Library, Result, 25 | }; 26 | 27 | use serde::{Deserialize, Deserializer}; 28 | 29 | /// An [`Iterator`] over a [`Library`]'s [`App`]s 30 | /// 31 | /// Returned from calling [`Library::apps()`] 32 | pub struct Iter<'library> { 33 | library: &'library Library, 34 | app_ids: slice::Iter<'library, u32>, 35 | } 36 | 37 | impl<'library> Iter<'library> { 38 | pub(crate) fn new(library: &'library Library) -> Self { 39 | Self { 40 | library, 41 | app_ids: library.app_ids().iter(), 42 | } 43 | } 44 | } 45 | 46 | impl Iterator for Iter<'_> { 47 | type Item = Result; 48 | 49 | fn next(&mut self) -> Option { 50 | let app_id = *self.app_ids.next()?; 51 | if let some_res @ Some(_) = self.library.app(app_id) { 52 | some_res 53 | } else { 54 | // We use the listing from libraryfolders, so all apps should be accounted for 55 | Some(Err(Error::MissingExpectedApp { app_id })) 56 | } 57 | } 58 | } 59 | 60 | /// Metadata for an installed Steam app 61 | /// 62 | /// _See the [module level docs][self] for different ways to get an [`App`]_ 63 | /// 64 | /// All of the information contained within the `appmanifest_.acf` file. For instance 65 | /// 66 | /// ```vdf 67 | /// "AppState" 68 | /// { 69 | /// "appid" "599140" 70 | /// "installdir" "Graveyard Keeper" 71 | /// "name" "Graveyard Keeper" 72 | /// "LastOwner" "12312312312312312" 73 | /// "Universe" "1" 74 | /// "StateFlags" "6" 75 | /// "LastUpdated" "1672176869" 76 | /// "UpdateResult" "0" 77 | /// "SizeOnDisk" "1805798572" 78 | /// "buildid" "8559806" 79 | /// "BytesToDownload" "24348080" 80 | /// "BytesDownloaded" "0" 81 | /// "TargetBuildID" "8559806" 82 | /// "AutoUpdateBehavior" "1" 83 | /// } 84 | /// ``` 85 | /// 86 | /// gets parsed as 87 | /// 88 | /// ```ignore 89 | /// App { 90 | /// app_id: 599140, 91 | /// install_dir: "Graveyard Keeper", 92 | /// name: Some("Graveyard Keeper"), 93 | /// last_user: Some(12312312312312312), 94 | /// universe: Some(Public), 95 | /// state_flags: Some(StateFlags(6)), 96 | /// last_updated: Some(SystemTime { 97 | /// tv_sec: 1672176869, 98 | /// tv_nsec: 0, 99 | /// }), 100 | /// update_result: Some(0), 101 | /// size_on_disk: Some(1805798572), 102 | /// build_id: Some(8559806), 103 | /// bytes_to_download: Some(24348080), 104 | /// bytes_downloaded: Some(0), 105 | /// target_build_id: Some(8559806), 106 | /// auto_update_behavior: Some(OnlyUpdateOnLaunch), 107 | /// // ... 108 | /// } 109 | /// ``` 110 | #[derive(Clone, Debug, serde_derive::Deserialize, PartialEq)] 111 | #[cfg_attr(test, derive(serde_derive::Serialize))] 112 | #[non_exhaustive] 113 | #[serde(rename_all = "PascalCase")] 114 | pub struct App { 115 | /// The app ID of this Steam app 116 | #[serde(rename = "appid")] 117 | pub app_id: u32, 118 | /// The name of the installation directory of this Steam app e.g. `"GarrysMod"` 119 | /// 120 | /// If you're trying to get the app's installation directory then take a look at 121 | /// [`Library::resolve_app_dir()`][crate::Library::resolve_app_dir] 122 | #[serde(rename = "installdir")] 123 | pub install_dir: String, 124 | /// The store name of the Steam app 125 | #[serde(rename = "name")] 126 | pub name: Option, 127 | /// The SteamID64 of the last Steam user that played this game on the filesystem 128 | #[serde(rename = "LastOwner")] 129 | pub last_user: Option, 130 | 131 | pub universe: Option, 132 | pub launcher_path: Option, 133 | pub state_flags: Option, 134 | // NOTE: Need to handle this for serializing too before `App` can `impl Serialize` 135 | #[serde( 136 | alias = "lastupdated", 137 | default, 138 | deserialize_with = "de_time_as_secs_from_unix_epoch" 139 | )] 140 | pub last_updated: Option, 141 | // Can't find anything on what these values mean. I've seen 0, 2, 4, 6, and 7 142 | pub update_result: Option, 143 | pub size_on_disk: Option, 144 | #[serde(rename = "buildid")] 145 | pub build_id: Option, 146 | pub bytes_to_download: Option, 147 | pub bytes_downloaded: Option, 148 | pub bytes_to_stage: Option, 149 | pub bytes_staged: Option, 150 | pub staging_size: Option, 151 | #[serde(rename = "TargetBuildID")] 152 | pub target_build_id: Option, 153 | pub auto_update_behavior: Option, 154 | pub allow_other_downloads_while_running: Option, 155 | pub scheduled_auto_update: Option, 156 | pub full_validate_before_next_update: Option, 157 | pub full_validate_after_next_update: Option, 158 | #[serde(default)] 159 | pub installed_depots: BTreeMap, 160 | #[serde(default)] 161 | pub staged_depots: BTreeMap, 162 | #[serde(default)] 163 | pub user_config: BTreeMap, 164 | #[serde(default)] 165 | pub mounted_config: BTreeMap, 166 | #[serde(default)] 167 | pub install_scripts: BTreeMap, 168 | #[serde(default)] 169 | pub shared_depots: BTreeMap, 170 | } 171 | 172 | impl App { 173 | pub(crate) fn new(manifest: &Path) -> Result { 174 | let contents = fs::read_to_string(manifest).map_err(|io| Error::io(io, manifest))?; 175 | keyvalues_serde::from_str(&contents) 176 | .map_err(|err| Error::parse(ParseErrorKind::App, ParseError::from_serde(err), manifest)) 177 | } 178 | } 179 | 180 | macro_rules! impl_deserialize_from_u64 { 181 | ( $ty_name:ty ) => { 182 | impl<'de> Deserialize<'de> for $ty_name { 183 | fn deserialize(deserializer: D) -> std::result::Result 184 | where 185 | D: Deserializer<'de>, 186 | { 187 | let value = u64::deserialize(deserializer)?; 188 | Ok(Self::from(value)) 189 | } 190 | } 191 | }; 192 | } 193 | 194 | #[derive(Debug, Clone, Copy, PartialEq)] 195 | #[cfg_attr(test, derive(serde_derive::Serialize))] 196 | pub enum Universe { 197 | Invalid, 198 | Public, 199 | Beta, 200 | Internal, 201 | Dev, 202 | Unknown(u64), 203 | } 204 | 205 | // More info: 206 | // https://developer.valvesoftware.com/wiki/SteamID#Universes_Available_for_Steam_Accounts 207 | impl From for Universe { 208 | fn from(value: u64) -> Self { 209 | match value { 210 | 0 => Self::Invalid, 211 | 1 => Self::Public, 212 | 2 => Self::Beta, 213 | 3 => Self::Internal, 214 | 4 => Self::Dev, 215 | unknown => Self::Unknown(unknown), 216 | } 217 | } 218 | } 219 | 220 | impl_deserialize_from_u64!(Universe); 221 | 222 | #[derive(Clone, Copy, Debug, serde_derive::Deserialize, PartialEq)] 223 | #[cfg_attr(test, derive(serde_derive::Serialize))] 224 | pub struct StateFlags(pub u64); 225 | 226 | impl StateFlags { 227 | pub fn flags(self) -> StateFlagIter { 228 | self.into() 229 | } 230 | } 231 | 232 | #[derive(Clone, Debug)] 233 | pub struct StateFlagIter(Option); 234 | 235 | impl From for StateFlagIter { 236 | fn from(state: StateFlags) -> Self { 237 | Self(Some(state.into())) 238 | } 239 | } 240 | 241 | impl Iterator for StateFlagIter { 242 | type Item = StateFlag; 243 | 244 | fn next(&mut self) -> Option { 245 | // Tiny little state machine: 246 | // - None indicates the iterator is done (trap state) 247 | // - Invalid will emit invalid once and finish 248 | // - Valid will pull on the inner iterator till it's finished 249 | let current = std::mem::take(&mut self.0); 250 | let (next, ret) = match current? { 251 | StateFlagIterInner::Invalid => (None, StateFlag::Invalid), 252 | StateFlagIterInner::Valid(mut valid) => { 253 | let ret = valid.next()?; 254 | (Some(StateFlagIterInner::Valid(valid)), ret) 255 | } 256 | }; 257 | self.0 = next; 258 | Some(ret) 259 | } 260 | } 261 | 262 | #[derive(Clone, Debug)] 263 | enum StateFlagIterInner { 264 | Invalid, 265 | Valid(ValidIter), 266 | } 267 | 268 | impl From for StateFlagIterInner { 269 | fn from(state: StateFlags) -> Self { 270 | if state.0 == 0 { 271 | Self::Invalid 272 | } else { 273 | Self::Valid(state.into()) 274 | } 275 | } 276 | } 277 | 278 | #[derive(Clone, Debug)] 279 | struct ValidIter { 280 | state: StateFlags, 281 | offset: u8, 282 | } 283 | 284 | impl From for ValidIter { 285 | fn from(state: StateFlags) -> Self { 286 | Self { state, offset: 0 } 287 | } 288 | } 289 | 290 | impl Iterator for ValidIter { 291 | type Item = StateFlag; 292 | 293 | fn next(&mut self) -> Option { 294 | // Rotate over each bit and emit each one that is set 295 | loop { 296 | let flag = 1u64.checked_shl(self.offset.into())?; 297 | self.offset = self.offset.checked_add(1)?; 298 | if self.state.0 & flag != 0 { 299 | break Some(StateFlag::from_bit_offset(self.offset - 1)); 300 | } 301 | } 302 | } 303 | } 304 | 305 | #[derive(Debug, Clone, Copy, PartialEq)] 306 | #[cfg_attr(test, derive(serde_derive::Serialize))] 307 | pub enum StateFlag { 308 | Invalid, 309 | Uninstalled, 310 | UpdateRequired, 311 | FullyInstalled, 312 | Encrypted, 313 | Locked, 314 | FilesMissing, 315 | AppRunning, 316 | FilesCorrupt, 317 | UpdateRunning, 318 | UpdatePaused, 319 | UpdateStarted, 320 | Uninstalling, 321 | BackupRunning, 322 | Reconfiguring, 323 | Validating, 324 | AddingFiles, 325 | Preallocating, 326 | Downloading, 327 | Staging, 328 | Committing, 329 | UpdateStopping, 330 | Unknown(u8), 331 | } 332 | 333 | // More info: https://github.com/lutris/lutris/blob/master/docs/steam.rst 334 | impl StateFlag { 335 | fn from_bit_offset(offset: u8) -> Self { 336 | match offset { 337 | 0 => Self::Uninstalled, 338 | 1 => Self::UpdateRequired, 339 | 2 => Self::FullyInstalled, 340 | 3 => Self::Encrypted, 341 | 4 => Self::Locked, 342 | 5 => Self::FilesMissing, 343 | 6 => Self::AppRunning, 344 | 7 => Self::FilesCorrupt, 345 | 8 => Self::UpdateRunning, 346 | 9 => Self::UpdatePaused, 347 | 10 => Self::UpdateStarted, 348 | 11 => Self::Uninstalling, 349 | 12 => Self::BackupRunning, 350 | 16 => Self::Reconfiguring, 351 | 17 => Self::Validating, 352 | 18 => Self::AddingFiles, 353 | 19 => Self::Preallocating, 354 | 20 => Self::Downloading, 355 | 21 => Self::Staging, 356 | 22 => Self::Committing, 357 | 23 => Self::UpdateStopping, 358 | unknown @ (13..=15 | 24..) => Self::Unknown(unknown), 359 | } 360 | } 361 | } 362 | 363 | fn de_time_as_secs_from_unix_epoch<'de, D>( 364 | deserializer: D, 365 | ) -> std::result::Result, D::Error> 366 | where 367 | D: Deserializer<'de>, 368 | { 369 | let maybe_time = 370 | >::deserialize(deserializer)?.and_then(time_as_secs_from_unix_epoch); 371 | Ok(maybe_time) 372 | } 373 | 374 | fn time_as_secs_from_unix_epoch(secs: u64) -> Option { 375 | let offset = time::Duration::from_secs(secs); 376 | time::SystemTime::UNIX_EPOCH.checked_add(offset) 377 | } 378 | 379 | #[derive(Debug, Clone, PartialEq)] 380 | #[cfg_attr(test, derive(serde_derive::Serialize))] 381 | pub enum AllowOtherDownloadsWhileRunning { 382 | UseGlobalSetting, 383 | Allow, 384 | Never, 385 | Unknown(u64), 386 | } 387 | 388 | impl From for AllowOtherDownloadsWhileRunning { 389 | fn from(value: u64) -> Self { 390 | match value { 391 | 0 => Self::UseGlobalSetting, 392 | 1 => Self::Allow, 393 | 2 => Self::Never, 394 | unknown => Self::Unknown(unknown), 395 | } 396 | } 397 | } 398 | 399 | impl_deserialize_from_u64!(AllowOtherDownloadsWhileRunning); 400 | 401 | #[derive(Debug, Clone, PartialEq)] 402 | #[cfg_attr(test, derive(serde_derive::Serialize))] 403 | pub enum AutoUpdateBehavior { 404 | KeepUpToDate, 405 | OnlyUpdateOnLaunch, 406 | UpdateWithHighPriority, 407 | Unknown(u64), 408 | } 409 | 410 | impl From for AutoUpdateBehavior { 411 | fn from(value: u64) -> Self { 412 | match value { 413 | 0 => Self::KeepUpToDate, 414 | 1 => Self::OnlyUpdateOnLaunch, 415 | 2 => Self::UpdateWithHighPriority, 416 | unknown => Self::Unknown(unknown), 417 | } 418 | } 419 | } 420 | 421 | impl_deserialize_from_u64!(AutoUpdateBehavior); 422 | 423 | #[derive(Debug, Clone, PartialEq)] 424 | #[cfg_attr(test, derive(serde_derive::Serialize))] 425 | pub enum ScheduledAutoUpdate { 426 | Zero, 427 | Time(time::SystemTime), 428 | } 429 | 430 | impl<'de> Deserialize<'de> for ScheduledAutoUpdate { 431 | fn deserialize(deserializer: D) -> std::result::Result 432 | where 433 | D: Deserializer<'de>, 434 | { 435 | let sched_auto_upd = match u64::deserialize(deserializer)? { 436 | 0 => Self::Zero, 437 | secs => { 438 | let time = time_as_secs_from_unix_epoch(secs) 439 | .ok_or_else(|| serde::de::Error::custom("Exceeded max time"))?; 440 | Self::Time(time) 441 | } 442 | }; 443 | Ok(sched_auto_upd) 444 | } 445 | } 446 | 447 | #[derive(Clone, Copy, Debug, serde_derive::Deserialize, PartialEq)] 448 | #[cfg_attr(test, derive(serde_derive::Serialize))] 449 | #[non_exhaustive] 450 | pub struct Depot { 451 | pub manifest: u64, 452 | pub size: u64, 453 | #[serde(rename = "dlcappid")] 454 | pub dlc_app_id: Option, 455 | } 456 | 457 | #[cfg(test)] 458 | mod tests { 459 | use super::*; 460 | 461 | fn app_from_manifest_str(s: &str) -> App { 462 | keyvalues_serde::from_str(s).unwrap() 463 | } 464 | 465 | #[test] 466 | fn minimal() { 467 | let minimal = r#" 468 | "AppState" 469 | { 470 | "appid" "2519830" 471 | "installdir" "Resonite" 472 | } 473 | "#; 474 | 475 | let app = app_from_manifest_str(minimal); 476 | insta::assert_ron_snapshot!(app); 477 | } 478 | 479 | #[test] 480 | fn sanity() { 481 | let manifest = include_str!("../tests/assets/appmanifest_230410.acf"); 482 | let app = app_from_manifest_str(manifest); 483 | insta::assert_ron_snapshot!(app); 484 | } 485 | 486 | #[test] 487 | fn more_sanity() { 488 | let manifest = include_str!("../tests/assets/appmanifest_599140.acf"); 489 | let app = app_from_manifest_str(manifest); 490 | insta::assert_ron_snapshot!(app); 491 | } 492 | 493 | #[test] 494 | fn state_flags() { 495 | let mut it = StateFlags(0).flags(); 496 | assert_eq!(it.next(), Some(StateFlag::Invalid)); 497 | assert_eq!(it.next(), None); 498 | 499 | let mut it = StateFlags(4).flags(); 500 | assert_eq!(it.next(), Some(StateFlag::FullyInstalled)); 501 | assert_eq!(it.next(), None); 502 | 503 | let mut it = StateFlags(6).flags(); 504 | assert_eq!(it.next(), Some(StateFlag::UpdateRequired)); 505 | assert_eq!(it.next(), Some(StateFlag::FullyInstalled)); 506 | assert_eq!(it.next(), None); 507 | } 508 | } 509 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "base64" 7 | version = "0.13.1" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" 10 | 11 | [[package]] 12 | name = "bitflags" 13 | version = "1.3.2" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 16 | 17 | [[package]] 18 | name = "block-buffer" 19 | version = "0.10.4" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" 22 | dependencies = [ 23 | "generic-array", 24 | ] 25 | 26 | [[package]] 27 | name = "bumpalo" 28 | version = "3.18.1" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee" 31 | 32 | [[package]] 33 | name = "cc" 34 | version = "1.2.27" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc" 37 | dependencies = [ 38 | "shlex", 39 | ] 40 | 41 | [[package]] 42 | name = "cfg-if" 43 | version = "1.0.0" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 46 | 47 | [[package]] 48 | name = "console" 49 | version = "0.15.11" 50 | source = "registry+https://github.com/rust-lang/crates.io-index" 51 | checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" 52 | dependencies = [ 53 | "encode_unicode", 54 | "libc", 55 | "once_cell", 56 | "windows-sys 0.59.0", 57 | ] 58 | 59 | [[package]] 60 | name = "cpufeatures" 61 | version = "0.2.11" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | checksum = "ce420fe07aecd3e67c5f910618fe65e94158f6dcc0adf44e00d69ce2bdfe0fd0" 64 | dependencies = [ 65 | "libc", 66 | ] 67 | 68 | [[package]] 69 | name = "crc" 70 | version = "3.2.1" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" 73 | dependencies = [ 74 | "crc-catalog", 75 | ] 76 | 77 | [[package]] 78 | name = "crc-catalog" 79 | version = "2.4.0" 80 | source = "registry+https://github.com/rust-lang/crates.io-index" 81 | checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" 82 | 83 | [[package]] 84 | name = "crypto-common" 85 | version = "0.1.6" 86 | source = "registry+https://github.com/rust-lang/crates.io-index" 87 | checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" 88 | dependencies = [ 89 | "generic-array", 90 | "typenum", 91 | ] 92 | 93 | [[package]] 94 | name = "digest" 95 | version = "0.10.7" 96 | source = "registry+https://github.com/rust-lang/crates.io-index" 97 | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 98 | dependencies = [ 99 | "block-buffer", 100 | "crypto-common", 101 | ] 102 | 103 | [[package]] 104 | name = "encode_unicode" 105 | version = "1.0.0" 106 | source = "registry+https://github.com/rust-lang/crates.io-index" 107 | checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" 108 | 109 | [[package]] 110 | name = "generic-array" 111 | version = "0.14.7" 112 | source = "registry+https://github.com/rust-lang/crates.io-index" 113 | checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" 114 | dependencies = [ 115 | "typenum", 116 | "version_check", 117 | ] 118 | 119 | [[package]] 120 | name = "home" 121 | version = "0.5.9" 122 | source = "registry+https://github.com/rust-lang/crates.io-index" 123 | checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" 124 | dependencies = [ 125 | "windows-sys 0.52.0", 126 | ] 127 | 128 | [[package]] 129 | name = "insta" 130 | version = "1.43.1" 131 | source = "registry+https://github.com/rust-lang/crates.io-index" 132 | checksum = "154934ea70c58054b556dd430b99a98c2a7ff5309ac9891597e339b5c28f4371" 133 | dependencies = [ 134 | "console", 135 | "once_cell", 136 | "ron", 137 | "serde", 138 | "similar", 139 | ] 140 | 141 | [[package]] 142 | name = "js-sys" 143 | version = "0.3.77" 144 | source = "registry+https://github.com/rust-lang/crates.io-index" 145 | checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" 146 | dependencies = [ 147 | "once_cell", 148 | "wasm-bindgen", 149 | ] 150 | 151 | [[package]] 152 | name = "keyvalues-parser" 153 | version = "0.2.0" 154 | source = "registry+https://github.com/rust-lang/crates.io-index" 155 | checksum = "7e4c8354918309196302015ac9cae43362f1a13d0d5c5539a33b4c2fd2cd6d25" 156 | dependencies = [ 157 | "pest", 158 | "pest_derive", 159 | "thiserror", 160 | ] 161 | 162 | [[package]] 163 | name = "keyvalues-serde" 164 | version = "0.2.1" 165 | source = "registry+https://github.com/rust-lang/crates.io-index" 166 | checksum = "0447866c47c00f8bd1949618e8f63017cf93e985b4684dc28d784527e2882390" 167 | dependencies = [ 168 | "keyvalues-parser", 169 | "serde", 170 | "thiserror", 171 | ] 172 | 173 | [[package]] 174 | name = "libc" 175 | version = "0.2.155" 176 | source = "registry+https://github.com/rust-lang/crates.io-index" 177 | checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" 178 | 179 | [[package]] 180 | name = "log" 181 | version = "0.4.27" 182 | source = "registry+https://github.com/rust-lang/crates.io-index" 183 | checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" 184 | 185 | [[package]] 186 | name = "memchr" 187 | version = "2.6.4" 188 | source = "registry+https://github.com/rust-lang/crates.io-index" 189 | checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" 190 | 191 | [[package]] 192 | name = "minicov" 193 | version = "0.3.7" 194 | source = "registry+https://github.com/rust-lang/crates.io-index" 195 | checksum = "f27fe9f1cc3c22e1687f9446c2083c4c5fc7f0bcf1c7a86bdbded14985895b4b" 196 | dependencies = [ 197 | "cc", 198 | "walkdir", 199 | ] 200 | 201 | [[package]] 202 | name = "once_cell" 203 | version = "1.21.3" 204 | source = "registry+https://github.com/rust-lang/crates.io-index" 205 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 206 | 207 | [[package]] 208 | name = "pest" 209 | version = "2.7.5" 210 | source = "registry+https://github.com/rust-lang/crates.io-index" 211 | checksum = "ae9cee2a55a544be8b89dc6848072af97a20f2422603c10865be2a42b580fff5" 212 | dependencies = [ 213 | "memchr", 214 | "thiserror", 215 | "ucd-trie", 216 | ] 217 | 218 | [[package]] 219 | name = "pest_derive" 220 | version = "2.7.5" 221 | source = "registry+https://github.com/rust-lang/crates.io-index" 222 | checksum = "81d78524685f5ef2a3b3bd1cafbc9fcabb036253d9b1463e726a91cd16e2dfc2" 223 | dependencies = [ 224 | "pest", 225 | "pest_generator", 226 | ] 227 | 228 | [[package]] 229 | name = "pest_generator" 230 | version = "2.7.5" 231 | source = "registry+https://github.com/rust-lang/crates.io-index" 232 | checksum = "68bd1206e71118b5356dae5ddc61c8b11e28b09ef6a31acbd15ea48a28e0c227" 233 | dependencies = [ 234 | "pest", 235 | "pest_meta", 236 | "proc-macro2", 237 | "quote", 238 | "syn", 239 | ] 240 | 241 | [[package]] 242 | name = "pest_meta" 243 | version = "2.7.5" 244 | source = "registry+https://github.com/rust-lang/crates.io-index" 245 | checksum = "7c747191d4ad9e4a4ab9c8798f1e82a39affe7ef9648390b7e5548d18e099de6" 246 | dependencies = [ 247 | "once_cell", 248 | "pest", 249 | "sha2", 250 | ] 251 | 252 | [[package]] 253 | name = "proc-macro2" 254 | version = "1.0.86" 255 | source = "registry+https://github.com/rust-lang/crates.io-index" 256 | checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" 257 | dependencies = [ 258 | "unicode-ident", 259 | ] 260 | 261 | [[package]] 262 | name = "quote" 263 | version = "1.0.36" 264 | source = "registry+https://github.com/rust-lang/crates.io-index" 265 | checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" 266 | dependencies = [ 267 | "proc-macro2", 268 | ] 269 | 270 | [[package]] 271 | name = "ron" 272 | version = "0.7.1" 273 | source = "registry+https://github.com/rust-lang/crates.io-index" 274 | checksum = "88073939a61e5b7680558e6be56b419e208420c2adb92be54921fa6b72283f1a" 275 | dependencies = [ 276 | "base64", 277 | "bitflags", 278 | "serde", 279 | ] 280 | 281 | [[package]] 282 | name = "same-file" 283 | version = "1.0.6" 284 | source = "registry+https://github.com/rust-lang/crates.io-index" 285 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 286 | dependencies = [ 287 | "winapi-util", 288 | ] 289 | 290 | [[package]] 291 | name = "serde" 292 | version = "1.0.219" 293 | source = "registry+https://github.com/rust-lang/crates.io-index" 294 | checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 295 | dependencies = [ 296 | "serde_derive", 297 | ] 298 | 299 | [[package]] 300 | name = "serde_derive" 301 | version = "1.0.219" 302 | source = "registry+https://github.com/rust-lang/crates.io-index" 303 | checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 304 | dependencies = [ 305 | "proc-macro2", 306 | "quote", 307 | "syn", 308 | ] 309 | 310 | [[package]] 311 | name = "sha2" 312 | version = "0.10.8" 313 | source = "registry+https://github.com/rust-lang/crates.io-index" 314 | checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" 315 | dependencies = [ 316 | "cfg-if", 317 | "cpufeatures", 318 | "digest", 319 | ] 320 | 321 | [[package]] 322 | name = "shlex" 323 | version = "1.3.0" 324 | source = "registry+https://github.com/rust-lang/crates.io-index" 325 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 326 | 327 | [[package]] 328 | name = "similar" 329 | version = "2.7.0" 330 | source = "registry+https://github.com/rust-lang/crates.io-index" 331 | checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" 332 | 333 | [[package]] 334 | name = "steamlocate" 335 | version = "2.0.1" 336 | dependencies = [ 337 | "crc", 338 | "home", 339 | "insta", 340 | "keyvalues-parser", 341 | "keyvalues-serde", 342 | "serde", 343 | "serde_derive", 344 | "wasm-bindgen-test", 345 | "winreg", 346 | ] 347 | 348 | [[package]] 349 | name = "syn" 350 | version = "2.0.87" 351 | source = "registry+https://github.com/rust-lang/crates.io-index" 352 | checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" 353 | dependencies = [ 354 | "proc-macro2", 355 | "quote", 356 | "unicode-ident", 357 | ] 358 | 359 | [[package]] 360 | name = "thiserror" 361 | version = "1.0.50" 362 | source = "registry+https://github.com/rust-lang/crates.io-index" 363 | checksum = "f9a7210f5c9a7156bb50aa36aed4c95afb51df0df00713949448cf9e97d382d2" 364 | dependencies = [ 365 | "thiserror-impl", 366 | ] 367 | 368 | [[package]] 369 | name = "thiserror-impl" 370 | version = "1.0.50" 371 | source = "registry+https://github.com/rust-lang/crates.io-index" 372 | checksum = "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8" 373 | dependencies = [ 374 | "proc-macro2", 375 | "quote", 376 | "syn", 377 | ] 378 | 379 | [[package]] 380 | name = "typenum" 381 | version = "1.17.0" 382 | source = "registry+https://github.com/rust-lang/crates.io-index" 383 | checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" 384 | 385 | [[package]] 386 | name = "ucd-trie" 387 | version = "0.1.6" 388 | source = "registry+https://github.com/rust-lang/crates.io-index" 389 | checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" 390 | 391 | [[package]] 392 | name = "unicode-ident" 393 | version = "1.0.12" 394 | source = "registry+https://github.com/rust-lang/crates.io-index" 395 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 396 | 397 | [[package]] 398 | name = "version_check" 399 | version = "0.9.4" 400 | source = "registry+https://github.com/rust-lang/crates.io-index" 401 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" 402 | 403 | [[package]] 404 | name = "walkdir" 405 | version = "2.5.0" 406 | source = "registry+https://github.com/rust-lang/crates.io-index" 407 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 408 | dependencies = [ 409 | "same-file", 410 | "winapi-util", 411 | ] 412 | 413 | [[package]] 414 | name = "wasm-bindgen" 415 | version = "0.2.100" 416 | source = "registry+https://github.com/rust-lang/crates.io-index" 417 | checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" 418 | dependencies = [ 419 | "cfg-if", 420 | "once_cell", 421 | "wasm-bindgen-macro", 422 | ] 423 | 424 | [[package]] 425 | name = "wasm-bindgen-backend" 426 | version = "0.2.100" 427 | source = "registry+https://github.com/rust-lang/crates.io-index" 428 | checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" 429 | dependencies = [ 430 | "bumpalo", 431 | "log", 432 | "proc-macro2", 433 | "quote", 434 | "syn", 435 | "wasm-bindgen-shared", 436 | ] 437 | 438 | [[package]] 439 | name = "wasm-bindgen-futures" 440 | version = "0.4.50" 441 | source = "registry+https://github.com/rust-lang/crates.io-index" 442 | checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" 443 | dependencies = [ 444 | "cfg-if", 445 | "js-sys", 446 | "once_cell", 447 | "wasm-bindgen", 448 | "web-sys", 449 | ] 450 | 451 | [[package]] 452 | name = "wasm-bindgen-macro" 453 | version = "0.2.100" 454 | source = "registry+https://github.com/rust-lang/crates.io-index" 455 | checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" 456 | dependencies = [ 457 | "quote", 458 | "wasm-bindgen-macro-support", 459 | ] 460 | 461 | [[package]] 462 | name = "wasm-bindgen-macro-support" 463 | version = "0.2.100" 464 | source = "registry+https://github.com/rust-lang/crates.io-index" 465 | checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" 466 | dependencies = [ 467 | "proc-macro2", 468 | "quote", 469 | "syn", 470 | "wasm-bindgen-backend", 471 | "wasm-bindgen-shared", 472 | ] 473 | 474 | [[package]] 475 | name = "wasm-bindgen-shared" 476 | version = "0.2.100" 477 | source = "registry+https://github.com/rust-lang/crates.io-index" 478 | checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" 479 | dependencies = [ 480 | "unicode-ident", 481 | ] 482 | 483 | [[package]] 484 | name = "wasm-bindgen-test" 485 | version = "0.3.50" 486 | source = "registry+https://github.com/rust-lang/crates.io-index" 487 | checksum = "66c8d5e33ca3b6d9fa3b4676d774c5778031d27a578c2b007f905acf816152c3" 488 | dependencies = [ 489 | "js-sys", 490 | "minicov", 491 | "wasm-bindgen", 492 | "wasm-bindgen-futures", 493 | "wasm-bindgen-test-macro", 494 | ] 495 | 496 | [[package]] 497 | name = "wasm-bindgen-test-macro" 498 | version = "0.3.50" 499 | source = "registry+https://github.com/rust-lang/crates.io-index" 500 | checksum = "17d5042cc5fa009658f9a7333ef24291b1291a25b6382dd68862a7f3b969f69b" 501 | dependencies = [ 502 | "proc-macro2", 503 | "quote", 504 | "syn", 505 | ] 506 | 507 | [[package]] 508 | name = "web-sys" 509 | version = "0.3.77" 510 | source = "registry+https://github.com/rust-lang/crates.io-index" 511 | checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" 512 | dependencies = [ 513 | "js-sys", 514 | "wasm-bindgen", 515 | ] 516 | 517 | [[package]] 518 | name = "winapi-util" 519 | version = "0.1.9" 520 | source = "registry+https://github.com/rust-lang/crates.io-index" 521 | checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" 522 | dependencies = [ 523 | "windows-sys 0.59.0", 524 | ] 525 | 526 | [[package]] 527 | name = "windows-sys" 528 | version = "0.52.0" 529 | source = "registry+https://github.com/rust-lang/crates.io-index" 530 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 531 | dependencies = [ 532 | "windows-targets", 533 | ] 534 | 535 | [[package]] 536 | name = "windows-sys" 537 | version = "0.59.0" 538 | source = "registry+https://github.com/rust-lang/crates.io-index" 539 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 540 | dependencies = [ 541 | "windows-targets", 542 | ] 543 | 544 | [[package]] 545 | name = "windows-targets" 546 | version = "0.52.6" 547 | source = "registry+https://github.com/rust-lang/crates.io-index" 548 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 549 | dependencies = [ 550 | "windows_aarch64_gnullvm", 551 | "windows_aarch64_msvc", 552 | "windows_i686_gnu", 553 | "windows_i686_gnullvm", 554 | "windows_i686_msvc", 555 | "windows_x86_64_gnu", 556 | "windows_x86_64_gnullvm", 557 | "windows_x86_64_msvc", 558 | ] 559 | 560 | [[package]] 561 | name = "windows_aarch64_gnullvm" 562 | version = "0.52.6" 563 | source = "registry+https://github.com/rust-lang/crates.io-index" 564 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 565 | 566 | [[package]] 567 | name = "windows_aarch64_msvc" 568 | version = "0.52.6" 569 | source = "registry+https://github.com/rust-lang/crates.io-index" 570 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 571 | 572 | [[package]] 573 | name = "windows_i686_gnu" 574 | version = "0.52.6" 575 | source = "registry+https://github.com/rust-lang/crates.io-index" 576 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 577 | 578 | [[package]] 579 | name = "windows_i686_gnullvm" 580 | version = "0.52.6" 581 | source = "registry+https://github.com/rust-lang/crates.io-index" 582 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 583 | 584 | [[package]] 585 | name = "windows_i686_msvc" 586 | version = "0.52.6" 587 | source = "registry+https://github.com/rust-lang/crates.io-index" 588 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 589 | 590 | [[package]] 591 | name = "windows_x86_64_gnu" 592 | version = "0.52.6" 593 | source = "registry+https://github.com/rust-lang/crates.io-index" 594 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 595 | 596 | [[package]] 597 | name = "windows_x86_64_gnullvm" 598 | version = "0.52.6" 599 | source = "registry+https://github.com/rust-lang/crates.io-index" 600 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 601 | 602 | [[package]] 603 | name = "windows_x86_64_msvc" 604 | version = "0.52.6" 605 | source = "registry+https://github.com/rust-lang/crates.io-index" 606 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 607 | 608 | [[package]] 609 | name = "winreg" 610 | version = "0.55.0" 611 | source = "registry+https://github.com/rust-lang/crates.io-index" 612 | checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" 613 | dependencies = [ 614 | "cfg-if", 615 | "windows-sys 0.59.0", 616 | ] 617 | --------------------------------------------------------------------------------