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}