Skip to main content

loadsmith_platform/
lib.rs

1//! Platform and game detection for the loadsmith mod-manager library.
2//!
3//! This is an internal crate of the [`loadsmith`] workspace. Most consumers
4//! should depend on the `loadsmith` facade crate instead of using this
5//! crate directly.
6
7mod error;
8mod launch;
9mod locate;
10
11use std::{
12    borrow::Cow,
13    path::{Path, PathBuf},
14    process::Command,
15};
16
17use camino::{Utf8Path, Utf8PathBuf};
18pub use error::{Error, Result};
19use loadsmith_loader::LaunchContext;
20use tracing::warn;
21
22/// A game distribution platform.
23///
24/// Each variant identifies a specific storefront or launcher. The
25/// [`Platform`] type is used to locate game installations and create launch
26/// commands on the host system.
27///
28/// This enum is `#[non_exhaustive]`; new platforms may be added without a
29/// breaking change.
30///
31/// ```
32/// use loadsmith_platform::Platform;
33///
34/// let steam = Platform::Steam { id: 230230 };
35/// assert_eq!(steam.name(), "Steam");
36/// ```
37#[derive(Debug, Clone, PartialEq, Eq)]
38#[non_exhaustive]
39pub enum Platform {
40    /// A Steam game, identified by its Steam App ID.
41    Steam { id: u32 },
42    /// An Epic Games Store game, identified by its launcher identifier.
43    EpicGames { identifier: String },
44    /// An Oculus / Meta Store game.
45    Oculus,
46    /// An Origin (EA App) game.
47    Origin,
48    /// An Xbox / Microsoft Store game, identified by its package name.
49    XboxStore { identifier: String },
50    /// Any other platform that is not explicitly handled.
51    Other,
52}
53
54impl Platform {
55    /// Return the human-readable display name for this platform.
56    ///
57    /// ```
58    /// use loadsmith_platform::Platform;
59    ///
60    /// assert_eq!(Platform::Steam { id: 1 }.name(), "Steam");
61    /// assert_eq!(Platform::EpicGames { identifier: "x".into() }.name(), "Epic Games");
62    /// assert_eq!(Platform::Oculus.name(), "Oculus");
63    /// assert_eq!(Platform::Origin.name(), "Origin");
64    /// assert_eq!(Platform::XboxStore { identifier: "x".into() }.name(), "Xbox Store");
65    /// assert_eq!(Platform::Other.name(), "Other");
66    /// ```
67    pub fn name(&self) -> &'static str {
68        match self {
69            Platform::Steam { .. } => "Steam",
70            Platform::EpicGames { .. } => "Epic Games",
71            Platform::Oculus => "Oculus",
72            Platform::Origin => "Origin",
73            Platform::XboxStore { .. } => "Xbox Store",
74            Platform::Other => "Other",
75        }
76    }
77
78    /// Locate the game directory for this platform's game, if possible.
79    ///
80    /// Returns `Ok(None)` when the platform does not support automatic
81    /// detection or when the game cannot be found.
82    ///
83    /// ```no_run
84    /// use loadsmith_platform::Platform;
85    ///
86    /// let platform = Platform::Steam { id: 730 }; // CS:GO / CS2
87    /// match platform.locate_game() {
88    ///     Ok(Some(path)) => println!("Game found at: {path}"),
89    ///     _ => println!("Game not found"),
90    /// }
91    /// ```
92    pub fn locate_game(&self) -> Result<Option<Utf8PathBuf>> {
93        locate::locate_game(self)
94    }
95
96    /// Create a platform-specific [`Command`] to launch the game, if
97    /// supported.
98    ///
99    /// Returns `Ok(None)` for platforms where launch-command generation is
100    /// not implemented.
101    ///
102    /// ```no_run
103    /// use loadsmith_platform::Platform;
104    ///
105    /// let platform = Platform::Steam { id: 730 };
106    /// if let Ok(Some(cmd)) = platform.create_launch_command() {
107    ///     println!("Launch command: {cmd:?}");
108    /// }
109    /// ```
110    pub fn create_launch_command(&self) -> Result<Option<Command>> {
111        launch::create_launch_command(self)
112    }
113
114    /// Build a [`LaunchContext`] for this platform, resolving the game path
115    /// and guessing whether Proton is being used.
116    ///
117    /// If `override_game_path` is `Some`, it is used directly; otherwise the
118    /// game is located via `locate_game`.
119    ///
120    /// ```no_run
121    /// use loadsmith_platform::Platform;
122    /// use camino::Utf8PathBuf;
123    ///
124    /// let platform = Platform::Steam { id: 730 };
125    /// let ctx = platform.create_launch_context(Utf8PathBuf::from("./profiles"), None);
126    /// ```
127    pub fn create_launch_context<'a>(
128        &'a self,
129        profile_path: impl Into<Cow<'a, Utf8Path>>,
130        override_game_path: Option<Utf8PathBuf>,
131    ) -> Result<LaunchContext<'a>> {
132        let game_path = match override_game_path {
133            Some(path) => path,
134            None => match self.locate_game()? {
135                Some(path) => path,
136                None => return Err(Error::NoGamePathOverride),
137            },
138        };
139
140        let is_proton = crate::try_guess_proton(&*game_path)?;
141
142        Ok(LaunchContext::new(profile_path, game_path, is_proton))
143    }
144}
145
146/// Walk a game directory tree and yield paths to executable files.
147///
148/// Filters by platform-appropriate extensions (`.exe` on Windows; `.exe`,
149/// `.sh`, `.x86_64`, `.x86` on Linux) and skips known crash-handler binaries.
150///
151/// ```no_run
152/// use loadsmith_platform::find_executables;
153///
154/// for exe in find_executables("/path/to/game").unwrap() {
155///     println!("Found executable: {}", exe.display());
156/// }
157/// ```
158pub fn find_executables(game_path: impl AsRef<Path>) -> Result<impl Iterator<Item = PathBuf>> {
159    Ok(locate::find_executables(game_path.as_ref()))
160}
161
162/// Guess whether a game is running under Proton (Linux Steam Play).
163///
164/// On Windows this always returns `false`. On Linux, it checks for a
165/// `.forceproton` marker file or the presence of `.exe` files in the game
166/// directory. Errors are silently swallowed and return `false`.
167///
168/// ```no_run
169/// use loadsmith_platform::guess_proton;
170///
171/// let is_proton = guess_proton("/path/to/game");
172/// println!("Proton: {is_proton}");
173/// ```
174pub fn guess_proton(game_path: impl AsRef<Path>) -> bool {
175    try_guess_proton(game_path).unwrap_or_else(|err| {
176        warn!(%err, "failed to guess if game is running under Proton");
177        false
178    })
179}
180
181/// Guess whether a game is running under Proton (Linux Steam Play),
182/// returning errors.
183///
184/// This is the fallible version of [`guess_proton`]. On Windows it always
185/// returns `Ok(false)`.
186///
187/// ```no_run
188/// use loadsmith_platform::try_guess_proton;
189///
190/// match try_guess_proton("/path/to/game") {
191///     Ok(true) => println!("Likely running under Proton"),
192///     Ok(false) => println!("Native Linux or Windows"),
193///     Err(e) => eprintln!("Error checking Proton: {e}"),
194/// }
195/// ```
196pub fn try_guess_proton(#[allow(unused)] game_path: impl AsRef<Path>) -> Result<bool> {
197    #[cfg(target_os = "windows")]
198    {
199        Ok(false)
200    }
201
202    #[cfg(target_os = "linux")]
203    {
204        use tracing::{debug, trace};
205
206        let game_path = game_path.as_ref();
207
208        trace!("checking for .forceproton file in game directory");
209
210        if game_path.join(".forceproton").exists() {
211            debug!(".forceproton file found");
212            return Ok(true);
213        }
214
215        let exe = find_executables(game_path).map(|mut executable| {
216            executable.find(|path| {
217                path.extension()
218                    .and_then(|ext| ext.to_str())
219                    .is_some_and(|ext| ext == "exe")
220            })
221        })?;
222
223        if let Some(exe) = exe {
224            debug!(exe = %exe.display(), "found .exe file in game directory, assuming proton");
225            Ok(true)
226        } else {
227            debug!("no .exe file found in game directory");
228            Ok(false)
229        }
230    }
231}