Skip to main content

launcher/
launcher_config.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::io;
4use std::path::{Path, PathBuf};
5
6/// Configuration persisted in `.supreme/launcher_config.json`.
7///
8/// Uses `#[serde(flatten)]` to preserve any unknown keys (like settings written
9/// by other tools) when saving.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct LauncherConfig {
12    /// Absolute path to `launcher_config.json`. Skipped during serialization.
13    #[serde(skip)]
14    path: PathBuf,
15
16    /// Preserves all unknown keys in the JSON file so they are not lost on save.
17    #[serde(flatten)]
18    extra: HashMap<String, serde_json::Value>,
19
20    /// Renderer setting (e.g., "wgpu" or "glow").
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub renderer: Option<String>,
23
24    /// News-related configuration.
25    #[serde(default)]
26    pub news: NewsConfig,
27
28    /// The logged-in tournament account (the logged-in player mode).
29    /// `None` = guest mode. The record is the launcher's own (it outlives
30    /// any single mod), while the identity's WIRE form (user id + device
31    /// key) is additionally written into the tournament mod's hidden
32    /// parameters so the managed push URLs can interpolate it.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub account: Option<AccountConfig>,
35}
36
37/// One logged-in tournament account, mirrored from the server's
38/// device-link flow. `avatar_sha256` is the avatar cache key (the
39/// server serves the same sha as the same bytes, immutable);
40/// `guest_player` remembers the player name used before the login so
41/// logging out returns to exactly that guest name.
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43pub struct AccountConfig {
44    /// The server-side permanent account id.
45    pub user_id: i64,
46    /// The permanent log-in name (letters and digits, case-insensitively
47    /// unique server-side).
48    pub account_name: String,
49    /// The mutable in-game display name. While logged in this IS the
50    /// tournament player name (the server only accepts verified actions
51    /// whose player matches the account's nickname).
52    pub nickname: String,
53    /// This device's per-device key; attached to room actions and push
54    /// URLs, revoked server-side on logout.
55    pub auth_key: String,
56    /// sha256 of the account's current avatar, `None` when the account
57    /// has none.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub avatar_sha256: Option<String>,
60    /// The guest player name the launcher used before this login;
61    /// restored on logout (empty when the login happened without one).
62    #[serde(default)]
63    pub guest_player: String,
64}
65
66#[derive(Debug, Clone, Default, Serialize, Deserialize)]
67pub struct NewsConfig {
68    /// RFC 3339 timestamp of the last successful news check.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub last_check: Option<String>,
71
72    /// `YYYY-MM-DD_slug` of the most recently read article.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub last_read: Option<String>,
75}
76
77impl Default for LauncherConfig {
78    fn default() -> Self {
79        Self {
80            path: PathBuf::new(),
81            extra: HashMap::new(),
82            renderer: None,
83            news: NewsConfig::default(),
84            account: None,
85        }
86    }
87}
88
89impl LauncherConfig {
90    /// Load from `.supreme/launcher_config.json`.
91    ///
92    /// If that file doesn't exist, it will attempt to migrate from the legacy
93    /// location `display_config.json`.
94    ///
95    /// Missing file or missing keys are silently treated as default.
96    /// Also handles migration of legacy root-level news keys within the file content.
97    #[must_use]
98    pub fn load(game_path: impl AsRef<Path>) -> Self {
99        let launcher_config_path = game_path
100            .as_ref()
101            .join(".supreme")
102            .join("launcher_config.json");
103
104        // First, try to load from the expected location.
105        if let Ok(content) = std::fs::read_to_string(&launcher_config_path) {
106            return Self::from_content(content, launcher_config_path);
107        }
108
109        // If that fails, attempt to migrate from the legacy location.
110        // Still the shipped behavior; the allow comes off with the deprecated fn.
111        #[allow(deprecated)]
112        if let Some(legacy_content) = migrate_from_display_config_json(game_path.as_ref()) {
113            let config = Self::from_content(legacy_content, launcher_config_path.clone());
114            // Save the migrated config to its new permanent home.
115            if let Err(e) = config.save() {
116                eprintln!("Failed to save migrated launcher config: {e}");
117            }
118            return config;
119        }
120
121        // If all else fails, return a default config pointing to the expected path.
122        LauncherConfig {
123            path: launcher_config_path,
124            ..Default::default()
125        }
126    }
127
128    /// Internal helper to parse a string into a `LauncherConfig` and set its path.
129    fn from_content(content: String, path: PathBuf) -> Self {
130        if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
131            // 1. Use the deprecated migration function to find legacy values
132            // Still the shipped behavior; the allow comes off with the deprecated fn.
133            #[allow(deprecated)]
134            let (legacy_check, legacy_read) = migrate_legacy_news_config(&json);
135
136            // 2. Remove legacy keys from the root so they are not preserved by `#[serde(flatten)]`
137            if let Some(obj) = json.as_object_mut() {
138                obj.remove("last_news_check");
139                obj.remove("last_read_news");
140            }
141
142            // 3. Ensure the `news` object exists
143            if json.get("news").is_none() {
144                json["news"] = serde_json::json!({});
145            }
146
147            // 4. Insert legacy values into the `news` object if they aren't already there
148            if let Some(news_obj) = json.get_mut("news").and_then(|v| v.as_object_mut()) {
149                if !news_obj.contains_key("last_check")
150                    && let Some(check) = legacy_check
151                {
152                    news_obj.insert("last_check".to_string(), serde_json::Value::String(check));
153                }
154                if !news_obj.contains_key("last_read")
155                    && let Some(read) = legacy_read
156                {
157                    news_obj.insert("last_read".to_string(), serde_json::Value::String(read));
158                }
159            }
160
161            // 5. Deserialize the migrated JSON into the struct
162            let mut config = serde_json::from_value::<Self>(json).unwrap_or_default();
163            config.path = path;
164            config
165        } else {
166            // If parsing fails, return a default config with the correct path.
167            LauncherConfig {
168                path,
169                ..Default::default()
170            }
171        }
172    }
173
174    /// Persist the configuration back to `.supreme/launcher_config.json`.
175    ///
176    /// Because of `#[serde(flatten)]`, all other keys in the file are preserved.
177    pub fn save(&self) -> io::Result<()> {
178        // Ensure the parent directory exists
179        if let Some(parent) = self.path.parent() {
180            std::fs::create_dir_all(parent)?;
181        }
182        let content = serde_json::to_string_pretty(self)?;
183        std::fs::write(&self.path, content)
184    }
185
186    pub fn path(&self) -> &Path {
187        &self.path
188    }
189
190    pub fn set_path(&mut self, path: impl Into<PathBuf>) {
191        self.path = path.into();
192    }
193}
194
195/// Migrates the legacy config format keys (`last_news_check` and `last_read_news`)
196/// from the root of the config file to the nested `news` sub-object structure.
197#[must_use]
198#[deprecated(
199    since = "1.7.0",
200    note = "Legacy config format will be removed anytime after 2026-08"
201)]
202fn migrate_legacy_news_config(json: &serde_json::Value) -> (Option<String>, Option<String>) {
203    let news = &json["news"];
204    let last_check = news
205        .get("last_check")
206        .and_then(|v| v.as_str())
207        .map(ToString::to_string)
208        .or_else(|| {
209            json.get("last_news_check")
210                .and_then(|v| v.as_str())
211                .map(ToString::to_string)
212        });
213    let last_read = news
214        .get("last_read")
215        .and_then(|v| v.as_str())
216        .map(ToString::to_string)
217        .or_else(|| {
218            json.get("last_read_news")
219                .and_then(|v| v.as_str())
220                .map(ToString::to_string)
221        });
222    (last_check, last_read)
223}
224
225/// Attempts to load the config from the legacy path `{game_path}/display_config.json`.
226///
227/// If successful, the legacy file is **deleted** to prevent future confusion.
228/// Returns `None` if the file doesn't exist or cannot be read.
229#[must_use]
230#[deprecated(
231    since = "1.7.0",
232    note = "File location migration will be removed anytime after 2026-08"
233)]
234fn migrate_from_display_config_json(game_path: &Path) -> Option<String> {
235    let legacy_path = game_path.join("display_config.json");
236    match std::fs::read_to_string(&legacy_path) {
237        Ok(content) => {
238            // On successful read, delete the old file.
239            if let Err(e) = std::fs::remove_file(&legacy_path) {
240                eprintln!("Failed to remove legacy config file: {e}");
241            }
242            Some(content)
243        }
244        Err(_) => None,
245    }
246}