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}