Skip to main content

supreme_fs/game_storage/
mod.rs

1//! Stateful API for game file management.
2//!
3//! This module is split by concern:
4//! - [`mod@discovery`]: walking the game directory tree.
5//! - [`mod@local_ops`]: local hashing/profile-listing/report helpers.
6//! - [`mod@hooked_ops`]: hook-aware read/write/exists/remove/list, routes
7//!   through active mod redirection rules when the storage was built with
8//!   [`GameStorage::with_hooks`] or [`GameStorage::with_hook_resolver`].
9//! - `online` (feature = "online"): everything that talks to the remote API
10//!   (profiles, content, snapshot upload/download, chunked uploads).
11
12mod discovery;
13mod hooked_ops;
14mod local_ops;
15
16#[cfg(feature = "online")]
17mod online;
18
19// Type ownership: the tournament wire DTOs live in supreme_fs (module
20// `game_storage::online::rooms`, surfaced publicly as `supreme_fs::rooms`;
21// the account DTOs in `game_storage::online::auth`, surfaced as
22// `supreme_fs::auth`), so consumers never need supreme_tournament to talk
23// to the rooms API.
24#[cfg(feature = "online")]
25pub use online::auth::{AccountDto, AuthInfo, AuthPoll};
26#[cfg(feature = "online")]
27pub use online::fetch::{http_get_bytes, http_get_bytes_opt, http_post_bytes};
28#[cfg(feature = "online")]
29pub use online::rooms::{
30    RoomDto, RoomIdentity, RoomRecordDto, RoomSummaryDto, RoomUploadResult,
31    high_times_entries_from_records, room_remove_payload,
32};
33
34pub use discovery::DiscoverMode;
35
36use std::path::{Path, PathBuf};
37use std::sync::Arc;
38use supreme_hook_config::{HookConfig, HookConfigError, HookResolver};
39
40#[cfg(feature = "online")]
41use ureq::Agent;
42
43/// Provides a stateful API for game file management.
44#[derive(Clone)]
45pub struct GameStorage {
46    root_path: PathBuf,
47    /// When present, `read_*`/`write_*`/`exists`/`remove_file` route
48    /// game-relative paths through this resolver first, so a page reading
49    /// or writing a game asset sees exactly what the game itself would see
50    /// at runtime, including any active mod redirection, without the
51    /// launcher ever injecting into the game process. `None` means no
52    /// hook-awareness: every operation goes straight to `root_path`,
53    /// unchanged from before this existed.
54    hook_resolver: Option<Arc<HookResolver>>,
55    #[cfg(feature = "online")]
56    api_url: Option<String>,
57    #[cfg(feature = "online")]
58    client: Option<Agent>,
59    #[cfg(feature = "online")]
60    token: Option<String>,
61}
62
63impl GameStorage {
64    /// Create a new `GameStorage` instance for local-only storage, with no
65    /// hook-awareness: `read_text`/`write_text`/etc. always operate on the
66    /// literal path under `game_path`, exactly as before mod redirection
67    /// existed. Use [`GameStorage::with_hooks`] for pages that need to see
68    /// the game the way a mod-redirected game process would.
69    #[must_use]
70    pub fn new(game_path: &Path) -> Self {
71        Self {
72            root_path: game_path.to_path_buf(),
73            hook_resolver: None,
74            #[cfg(feature = "online")]
75            api_url: None,
76            #[cfg(feature = "online")]
77            client: None,
78            #[cfg(feature = "online")]
79            token: None,
80        }
81    }
82
83    /// Create a `GameStorage` instance whose `read_*`/`write_*`/`exists`/
84    /// `remove_file`/`list_files` calls are routed through the same
85    /// redirection rules the game process would apply at runtime, per
86    /// `mods.json` and each enabled mod's `mod.json` under `mods_dir`.
87    ///
88    /// `temp_dir` is only used for staging *patched* replacement files
89    /// (see [`HookResolver::resolve_path`]), most redirection rules never
90    /// touch it. Writes always land on the original file or a mod's own
91    /// `target`, never in `temp_dir`.
92    ///
93    /// Mods listed in `mods.json` whose `mod.json` couldn't be read or
94    /// parsed are skipped rather than failing the whole call; they're
95    /// returned alongside the instance so the caller can surface them
96    /// (e.g. in the mod manager page) rather than have them silently
97    /// dropped.
98    #[must_use]
99    pub fn with_hooks(
100        game_path: &Path,
101        mods_dir: &Path,
102        temp_dir: PathBuf,
103    ) -> (Self, Vec<(String, HookConfigError)>) {
104        let (resolver, skipped) = HookResolver::load(mods_dir, game_path.to_path_buf(), temp_dir);
105        let storage = Self {
106            root_path: game_path.to_path_buf(),
107            hook_resolver: Some(Arc::new(resolver)),
108            #[cfg(feature = "online")]
109            api_url: None,
110            #[cfg(feature = "online")]
111            client: None,
112            #[cfg(feature = "online")]
113            token: None,
114        };
115        (storage, skipped)
116    }
117
118    /// True if this instance routes reads/writes through mod redirection
119    /// rules (i.e. was built with [`GameStorage::with_hooks`] or
120    /// [`GameStorage::with_hook_resolver`]).
121    #[must_use]
122    pub fn is_hook_aware(&self) -> bool {
123        self.hook_resolver.is_some()
124    }
125
126    /// The loaded hook resolver's rule config, when hook-aware (see
127    /// [`GameStorage::with_hook_resolver`]).
128    ///
129    /// The launcher uses this to resolve a game-relative path to its
130    /// redirect target with the exact same rule machinery the DLL applies
131    /// at runtime (e.g. the tournament instance `high_times` file the
132    /// selected track's `Saved_Data/…_high_times.txt` rule points at),
133    /// the launcher and the hook can then never disagree about where a
134    /// redirected file lives.
135    #[must_use]
136    pub fn hook_config(&self) -> Option<&HookConfig> {
137        self.hook_resolver.as_ref().map(|r| r.config())
138    }
139
140    /// Attaches mod-redirection hook-awareness to an already-built
141    /// `GameStorage`, e.g. one built with [`GameStorage::with_online`],
142    /// which (unlike [`GameStorage::new`]) has no hook-aware counterpart
143    /// constructor of its own. Consumes and returns `self` so it composes
144    /// with any other constructor rather than needing a dedicated
145    /// combined one for every combination.
146    ///
147    /// Same loading contract as [`GameStorage::with_hooks`]: mods listed
148    /// in `mods.json` whose `mod.json` couldn't be read or parsed are
149    /// skipped rather than failing the whole call, and returned alongside
150    /// the instance.
151    #[must_use]
152    pub fn with_hook_resolver(
153        mut self,
154        mods_dir: &Path,
155        temp_dir: PathBuf,
156    ) -> (Self, Vec<(String, HookConfigError)>) {
157        let (resolver, skipped) = HookResolver::load(mods_dir, self.root_path.clone(), temp_dir);
158        self.hook_resolver = Some(Arc::new(resolver));
159        (self, skipped)
160    }
161
162    /// Create a new `GameStorage` instance with online fallback support at `api_url`.
163    #[cfg(feature = "online")]
164    #[must_use]
165    pub fn with_online(game_path: &Path, api_url: &str) -> Self {
166        Self::with_online_timeouts(
167            game_path,
168            api_url,
169            std::time::Duration::from_secs(10),
170            std::time::Duration::from_secs(300),
171        )
172    }
173
174    /// [`GameStorage::with_online`] with explicit HTTP timeouts.
175    ///
176    /// The launcher-facing [`with_online`] defaults (10 s connect / 300 s
177    /// global) are right for interactive pages, but an embedded consumer,
178    /// e.g. the tournament provider inside the hook DLL, whose cold-miss
179    /// fetch runs on a game thread that must never stall for minutes, needs
180    /// tighter bounds (bounded timeouts, blocking HTTP
181    /// only on the cold path). Platform-neutral: only the ureq agent config
182    /// differs.
183    #[cfg(feature = "online")]
184    #[must_use]
185    pub fn with_online_timeouts(
186        game_path: &Path,
187        api_url: &str,
188        timeout_connect: std::time::Duration,
189        timeout_global: std::time::Duration,
190    ) -> Self {
191        let token = std::env::var("SSBZ_API_TOKEN").ok();
192        let trimmed_url = online::normalize_api_url(api_url);
193
194        Self {
195            root_path: game_path.to_path_buf(),
196            hook_resolver: None,
197            api_url: Some(trimmed_url),
198            client: Some(ureq::Agent::new_with_config(
199                ureq::Agent::config_builder()
200                    .user_agent("SupremeSnowboardingLauncher")
201                    .timeout_connect(Some(timeout_connect))
202                    .timeout_global(Some(timeout_global))
203                    // Treat 4xx/5xx as ordinary responses so the explicit
204                    // `status().is_success()` checks below can read the
205                    // server's JSON error body and produce useful messages
206                    // instead of an opaque transport error.
207                    .http_status_as_error(false)
208                    .build(),
209            )),
210            token,
211        }
212    }
213
214    /// Check if online features are configured.
215    #[must_use]
216    pub fn is_online(&self) -> bool {
217        #[cfg(feature = "online")]
218        {
219            self.api_url.is_some()
220        }
221        #[cfg(not(feature = "online"))]
222        {
223            false
224        }
225    }
226
227    /// Clear the online token for this instance. Useful for testing unauthorized access.
228    #[cfg(feature = "online")]
229    pub fn clear_token(&mut self) {
230        self.token = None;
231    }
232
233    /// Get the root game path
234    #[must_use]
235    pub fn game_path(&self) -> &PathBuf {
236        &self.root_path
237    }
238
239    /// Get the profiles directory path
240    #[must_use]
241    pub fn profiles_path(&self) -> PathBuf {
242        crate::get_profiles_path(&self.root_path)
243    }
244
245    /// Get the contents directory path
246    #[must_use]
247    pub fn contents_path(&self) -> PathBuf {
248        crate::get_contents_path(&self.root_path)
249    }
250}
251
252#[derive(Debug, serde::Deserialize, Clone)]
253pub struct OnlineProfileInfo {
254    pub filename: String,
255    pub name: Option<String>,
256    pub description: Option<String>,
257    pub version: Option<String>,
258    pub size: u64,
259    pub mtime: u64,
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn new_stores_root_path_and_defaults_to_offline() {
268        let storage = GameStorage::new(Path::new("/tmp/my_game"));
269        assert_eq!(storage.game_path(), Path::new("/tmp/my_game"));
270        assert!(!storage.is_online());
271    }
272
273    #[test]
274    fn profiles_and_contents_paths_are_derived_from_root() {
275        let storage = GameStorage::new(Path::new("/tmp/my_game"));
276        assert_eq!(
277            storage.profiles_path(),
278            Path::new("/tmp/my_game/.supreme/profiles")
279        );
280        assert_eq!(
281            storage.contents_path(),
282            Path::new("/tmp/my_game/.supreme/contents")
283        );
284    }
285
286    #[test]
287    fn with_hook_resolver_makes_an_existing_instance_hook_aware() {
288        // No mods.json at this (nonexistent) mods_dir -- HookResolver::load
289        // gracefully falls back to "no mods enabled" rather than erroring,
290        // so this doesn't need a real tempdir to stay filesystem-free like
291        // the other tests here.
292        let storage = GameStorage::new(Path::new("/tmp/my_game"));
293        assert!(!storage.is_hook_aware());
294
295        let (storage, skipped) = storage.with_hook_resolver(
296            Path::new("/tmp/my_game/.supreme/mods"),
297            PathBuf::from("/tmp/my_game/.supreme/tmp"),
298        );
299        assert!(skipped.is_empty());
300        assert!(storage.is_hook_aware());
301        assert_eq!(storage.game_path(), Path::new("/tmp/my_game"));
302    }
303
304    #[cfg(feature = "online")]
305    #[test]
306    fn with_hook_resolver_composes_with_with_online() {
307        let storage = GameStorage::with_online(Path::new("/tmp/my_game"), "https://example.com");
308        let (storage, skipped) = storage.with_hook_resolver(
309            Path::new("/tmp/my_game/.supreme/mods"),
310            PathBuf::from("/tmp/my_game/.supreme/tmp"),
311        );
312        assert!(skipped.is_empty());
313        assert!(storage.is_hook_aware());
314        assert!(storage.is_online());
315    }
316
317    #[cfg(feature = "online")]
318    #[test]
319    fn with_online_reports_is_online_true() {
320        let storage = GameStorage::with_online(Path::new("/tmp/my_game"), "https://example.com");
321        assert!(storage.is_online());
322    }
323
324    #[cfg(feature = "online")]
325    #[test]
326    fn with_online_timeouts_matches_with_online_shape() {
327        let storage = GameStorage::with_online_timeouts(
328            Path::new("/tmp/my_game"),
329            "https://example.com",
330            std::time::Duration::from_secs(5),
331            std::time::Duration::from_secs(15),
332        );
333        assert!(storage.is_online());
334        assert_eq!(storage.game_path(), Path::new("/tmp/my_game"));
335    }
336
337    #[cfg(feature = "online")]
338    #[test]
339    fn clear_token_removes_configured_token() {
340        // SAFETY: tests run single-threaded per-process for env var mutation concerns here,
341        // and this test does not rely on any other test's environment state.
342        unsafe {
343            std::env::set_var("SSBZ_API_TOKEN", "secret-token");
344        }
345        let mut storage =
346            GameStorage::with_online(Path::new("/tmp/my_game"), "https://example.com");
347        storage.clear_token();
348        // clear_token has no public getter, but at minimum this should not panic and
349        // the instance should remain online-configured.
350        assert!(storage.is_online());
351        unsafe {
352            std::env::remove_var("SSBZ_API_TOKEN");
353        }
354    }
355}