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}