Skip to main content

launcher/app/
mod.rs

1use eframe::egui;
2use egui_extras::install_image_loaders;
3
4use crate::config_manager::ConfigManager;
5use crate::launcher_config::LauncherConfig;
6use crate::news::{NewsManager, NewsStatus};
7use crate::pages::{
8    ModsPageState, MusicPageState, ReplaysPageState, RidersPageState, SnowboardsPageState,
9    TrainerPageState, TroubleshootPageState, trainer, troubleshoot,
10};
11use crate::state::{Page, UnifiedOperation};
12use crate::tasks::TaskHandle;
13use crate::updater::{UpdateStatus, Updater};
14
15mod logic;
16mod ui;
17
18pub use crate::utils::get_game_path;
19
20pub struct LauncherApp {
21    pub current_page: Page,
22    pub storage: supreme_fs::GameStorage,
23
24    // Config management
25    pub config_manager: ConfigManager,
26
27    // UI state
28    pub error_message: Option<String>,
29
30    // Unified operation state
31    pub unified_operation: UnifiedOperation,
32    pub dependency_receiver: Option<TaskHandle<()>>,
33    pub troubleshoot_receiver: Option<TaskHandle<troubleshoot::TroubleshootData>>,
34    pub trainer_receiver: Option<TaskHandle<trainer::TrainerData>>,
35
36    // Page states
37    pub troubleshoot_state: TroubleshootPageState,
38    pub trainer_state: TrainerPageState,
39    pub replays_state: ReplaysPageState,
40    pub music_state: MusicPageState,
41    pub snowboards_state: SnowboardsPageState,
42    pub riders_state: RidersPageState,
43    pub mods_state: ModsPageState,
44
45    // Updater state
46    pub updater: Updater,
47    pub update_logs: UpdateLogs,
48    pub last_status: UpdateStatus,
49
50    // Focus state
51    pub has_focused_play: bool,
52
53    // Launch state
54    pub is_supreme_initiator: bool,
55
56    // News state
57    pub launcher_config: LauncherConfig,
58    pub news_manager: NewsManager,
59    pub markdown_cache: egui_commonmark::CommonMarkCache,
60    pub show_news: bool,
61    pub current_article_index: usize,
62}
63
64#[derive(Default, serde::Serialize)]
65pub struct UpdateLogs {
66    pub check: Option<String>,
67    pub download: Option<String>,
68    pub patch: Option<String>,
69}
70
71/// Checks whether the parent process's directory and the current process's directory are the same.
72///
73/// Returns:
74/// - `Some(true)`  => directories match
75/// - `Some(false)` => directories differ
76/// - `None`        => an error occurred while gathering information
77///
78/// # Safety and Unsafe Usage
79///
80/// This function calls multiple Win32 APIs through the `windows` crate. These APIs are
81/// inherently unsafe for the following reasons:
82/// - They operate on raw system handles and pointers (e.g., snapshot and process handles).
83/// - They require manual memory management and careful initialization of C-compatible structs
84///   (e.g., `PROCESSENTRY32W`).
85/// - They write into caller-provided buffers (e.g., `QueryFullProcessImageNameW`), requiring
86///   correct buffer sizing and handling of returned lengths.
87/// - They can fail for reasons outside Rust's type system (permissions, races, process exit).
88#[cfg(windows)]
89#[must_use]
90fn parent_and_current_dirs_equal() -> Option<bool> {
91    use std::ffi::OsString;
92    use std::mem;
93    use std::os::windows::ffi::OsStringExt;
94    use std::path::Path;
95    use windows::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE};
96    use windows::Win32::System::Diagnostics::ToolHelp::{
97        CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
98        TH32CS_SNAPPROCESS,
99    };
100    use windows::Win32::System::Threading::{
101        GetCurrentProcessId, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
102    };
103    use windows::core::PWSTR;
104
105    // SAFETY: GetCurrentProcessId is a simple Win32 call that returns the calling process id.
106    let my_pid = unsafe { GetCurrentProcessId() };
107
108    // SAFETY: CreateToolhelp32Snapshot returns a handle that must be closed with CloseHandle.
109    let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }.ok()?;
110    if snapshot == INVALID_HANDLE_VALUE {
111        return None;
112    }
113
114    // SAFETY: PROCESSENTRY32W is a C-compatible struct. Zero-initializing it and setting
115    // dwSize is the documented initialization pattern for ToolHelp APIs.
116    let mut entry: PROCESSENTRY32W = unsafe { mem::zeroed() };
117    entry.dwSize = mem::size_of::<PROCESSENTRY32W>() as u32;
118
119    // First pass: find parent PID by iterating processes in the snapshot.
120    // SAFETY: Process32FirstW/Process32NextW require a valid snapshot handle and a properly
121    // initialized PROCESSENTRY32W. We check return values and break on errors.
122    let mut parent_pid: Option<u32> = None;
123    if unsafe { Process32FirstW(snapshot, &mut entry) }.is_ok() {
124        loop {
125            if entry.th32ProcessID == my_pid {
126                parent_pid = Some(entry.th32ParentProcessID);
127                break;
128            }
129            if unsafe { Process32NextW(snapshot, &mut entry) }.is_err() {
130                break;
131            }
132        }
133    }
134
135    // Get current process directory.
136    let current_exe = std::env::current_exe().ok();
137    let current_dir = current_exe
138        .as_ref()
139        .and_then(|p| p.parent().map(|d| d.to_path_buf()));
140
141    // If we couldn't get current exe or parent pid, clean up and return None.
142    if current_dir.is_none() || parent_pid.is_none() {
143        // SAFETY: Close the snapshot handle before returning.
144        unsafe {
145            if let Err(e) = CloseHandle(snapshot) {
146                eprintln!("Failed to close process snapshot handle: {e}");
147            }
148        }
149        return None;
150    }
151
152    // If we have a parent PID, open the parent process and query its image path.
153
154    if let Some(ppid) = parent_pid {
155        // SAFETY: OpenProcess returns a Result<HANDLE, Error> in windows 0.58.
156        // We must extract the HANDLE from the Result.
157        let proc_handle: HANDLE =
158            match unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, ppid) } {
159                Ok(h) => h,
160                Err(_) => {
161                    // Clean up snapshot and return
162                    unsafe {
163                        if let Err(e) = CloseHandle(snapshot) {
164                            eprintln!("Failed to close process snapshot handle: {e}");
165                        }
166                    }
167                    return None;
168                }
169            };
170
171        // Prepare a wide buffer for QueryFullProcessImageNameW.
172        // Use a large buffer to accommodate long paths.
173        let mut buf: Vec<u16> = vec![0u16; 32768];
174        let mut size: u32 = buf.len() as u32;
175
176        // SAFETY: QueryFullProcessImageNameW expects a HANDLE, a PROCESS_NAME_FORMAT,
177        // a PWSTR and a pointer to a u32 size. It returns Result<()> in windows 0.58.
178        let qres = unsafe {
179            use windows::Win32::System::Threading::{
180                PROCESS_NAME_FORMAT, QueryFullProcessImageNameW,
181            };
182
183            QueryFullProcessImageNameW(
184                proc_handle,
185                PROCESS_NAME_FORMAT(0), // Win32 path format
186                PWSTR(buf.as_mut_ptr()),
187                &mut size,
188            )
189        };
190
191        let parent_dir_opt = if qres.is_ok() {
192            // Truncate to the returned length and convert to OsString.
193            // `size` is the number of WCHARs written (not including a terminating null in some docs),
194            // but QueryFullProcessImageNameW writes the path into the buffer and updates size.
195            buf.truncate(size as usize);
196            let os = OsString::from_wide(&buf);
197            // Get the parent directory of the executable path.
198            Path::new(&os).parent().map(|p| p.to_path_buf())
199        } else {
200            None
201        };
202
203        // SAFETY: Close the handles returned by OpenProcess.
204        unsafe {
205            if let Err(e) = CloseHandle(proc_handle) {
206                eprintln!("Failed to close process handle: {e}");
207            }
208            if let Err(e) = CloseHandle(snapshot) {
209                eprintln!("Failed to close process snapshot handle: {e}");
210            }
211        }
212
213        // If we couldn't get the parent's directory, return None.
214        let parent_dir = parent_dir_opt?;
215        let current_dir = current_dir?;
216
217        // Attempt to canonicalize both directories to resolve symlinks/junctions.
218        // Canonicalize may fail (permissions, removed paths), so fall back to string compare.
219        let canonical_parent = std::fs::canonicalize(&parent_dir).ok();
220        let canonical_current = std::fs::canonicalize(&current_dir).ok();
221
222        // On Windows, compare case-insensitively.
223        let equal = match (canonical_parent, canonical_current) {
224            (Some(cp), Some(cc)) => {
225                let a = cp.to_string_lossy().to_lowercase();
226                let b = cc.to_string_lossy().to_lowercase();
227                a == b
228            }
229            _ => {
230                let a = parent_dir.to_string_lossy().to_lowercase();
231                let b = current_dir.to_string_lossy().to_lowercase();
232                a == b
233            }
234        };
235
236        Some(equal)
237    } else {
238        // No parent PID found; ensure snapshot closed.
239        unsafe {
240            if let Err(e) = CloseHandle(snapshot) {
241                eprintln!("Failed to close process snapshot handle: {e}");
242            }
243        }
244        None
245    }
246}
247
248#[cfg(not(windows))]
249fn parent_and_current_dirs_equal() -> Option<bool> {
250    None
251}
252
253impl LauncherApp {
254    #[must_use]
255    pub fn new(cc: &eframe::CreationContext<'_>, launcher_config: LauncherConfig) -> Self {
256        // Fonts, spacing and the web pages' design system (the launcher
257        // and the leaderboard wear the same palette and shapes).
258        crate::theme::install(&cc.egui_ctx);
259        for theme in [egui::Theme::Dark, egui::Theme::Light] {
260            let mut style = (*cc.egui_ctx.style_of(theme)).clone();
261            style.spacing.item_spacing = egui::vec2(8.0, 8.0);
262            cc.egui_ctx.set_style_of(theme, style);
263        }
264
265        let storage = crate::utils::create_storage();
266        let game_path = storage.game_path().to_path_buf();
267
268        // Check if launched by "supreme" process
269        let is_supreme_initiator = parent_and_current_dirs_equal().unwrap_or(false);
270
271        // Check for Supreme.exe (case-insensitive)
272        let game_exe_path = std::fs::read_dir(&game_path)
273            .ok()
274            .into_iter()
275            .flatten()
276            .flatten()
277            .find(|entry| {
278                let filename = entry.file_name().to_string_lossy().to_lowercase();
279                filename.contains("supreme") && filename.contains("exe")
280            })
281            .map(|entry| entry.path());
282
283        let mut spawn_error = None;
284        // If user started launcher directly (not from Supreme) and Supreme.exe exists,
285        // immediately launch Supreme.exe and exit to avoid showing launcher twice
286        if !is_supreme_initiator && let Some(game_exe) = &game_exe_path {
287            match std::process::Command::new(game_exe)
288                .current_dir(&game_path)
289                .spawn()
290            {
291                Ok(mut child) => {
292                    // Give it a moment to see if it fails immediately (e.g. wrong architecture, missing DLLs)
293                    std::thread::sleep(std::time::Duration::from_millis(200));
294                    match child.try_wait() {
295                        Ok(None) => {
296                            // Process is still running, assume it started correctly and exit launcher
297                            std::process::exit(0);
298                        }
299                        Ok(Some(status)) => {
300                            if status.success() {
301                                // If it finished successfully in 200ms, it's probably fine to exit
302                                std::process::exit(0);
303                            } else {
304                                spawn_error =
305                                    Some(format!("Supreme.exe failed to start: {status}"));
306                            }
307                        }
308                        Err(e) => {
309                            spawn_error = Some(format!("Failed to verify Supreme.exe status: {e}"));
310                        }
311                    }
312                }
313                Err(e) => {
314                    spawn_error = Some(format!("Failed to auto-launch Supreme.exe: {e}"));
315                }
316            }
317        }
318
319        let config_manager = ConfigManager::load(&storage);
320
321        // Start update check in background
322        let mut updater = Updater::new();
323        if !std::env::var("SSCL_DISABLE_UPDATES").is_ok_and(|v| &v == "1") {
324            updater.check_for_updates();
325        }
326
327        let mut news_manager = NewsManager::new(&game_path, &launcher_config);
328        if news_manager.is_check_due(&launcher_config) {
329            news_manager.start_check(&launcher_config.news.last_read);
330        }
331
332        Self {
333            current_page: Page::Display,
334            storage,
335            config_manager,
336            error_message: spawn_error,
337            unified_operation: UnifiedOperation::default(),
338            dependency_receiver: None,
339            troubleshoot_receiver: None,
340            trainer_receiver: None,
341            troubleshoot_state: TroubleshootPageState::default(),
342            trainer_state: TrainerPageState::default(),
343            replays_state: ReplaysPageState::default(),
344            music_state: MusicPageState::default(),
345            snowboards_state: SnowboardsPageState::default(),
346            riders_state: RidersPageState::default(),
347            mods_state: ModsPageState::default(),
348            updater,
349            update_logs: UpdateLogs::default(),
350            last_status: UpdateStatus::Idle,
351            has_focused_play: false,
352            is_supreme_initiator,
353            launcher_config,
354            news_manager,
355            markdown_cache: egui_commonmark::CommonMarkCache::default(),
356            show_news: false,
357            current_article_index: 0,
358        }
359    }
360}
361
362impl eframe::App for LauncherApp {
363    fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
364        // Always save configs when the window is closed
365        self.save_all_configs();
366    }
367
368    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
369        let update_status = self.updater.poll().unwrap_or(self.last_status.clone());
370
371        if let Some(status) = self.news_manager.poll() {
372            if let NewsStatus::Success {
373                last_news_check, ..
374            } = status
375            {
376                self.launcher_config.news.last_check = Some(last_news_check);
377                if let Err(e) = self.launcher_config.save() {
378                    eprintln!("Failed to save launcher config: {e}");
379                }
380            }
381            ui.request_repaint();
382        }
383
384        install_image_loaders(ui);
385
386        if self.show_news {
387            self.show_news_window(ui);
388        }
389
390        // Handle update logic and logging
391        if std::mem::discriminant(&update_status) != std::mem::discriminant(&self.last_status) {
392            match &update_status {
393                UpdateStatus::UpdateAvailable(info) => {
394                    self.update_logs.check = Some(format!("Update found: {}", info.tag_name));
395
396                    // Spawn detached background self-update process
397                    let current_exe = std::env::current_exe().ok();
398                    if let Some(exe) = current_exe
399                        && let Err(e) = std::process::Command::new(exe)
400                            .arg("--self-update")
401                            .stdin(std::process::Stdio::null())
402                            .stdout(std::process::Stdio::null())
403                            .stderr(std::process::Stdio::null())
404                            .spawn()
405                    {
406                        eprintln!("Failed to spawn self-update process: {e}");
407                    }
408                }
409                UpdateStatus::NoUpdateAvailable => {
410                    self.update_logs.check = Some("No update available.".to_string());
411                }
412                UpdateStatus::ReadyToApply(path) => {
413                    self.update_logs.download =
414                        Some(format!("Update downloaded and ready at {}", path.display()));
415
416                    // Apply update immediately
417                    match Updater::apply_update(path) {
418                        Ok(()) => {
419                            self.update_logs.patch =
420                                Some(format!("SUCCESS: Update applied from {}", path.display()));
421                        }
422                        Err(e) => {
423                            self.update_logs.patch = Some(format!(
424                                "ERROR: Failed to apply update from {}: {e}",
425                                path.display()
426                            ));
427                        }
428                    }
429
430                    if let Err(e) = self.storage.write_report("update_log", &self.update_logs) {
431                        eprintln!("Failed to write update_log report: {e}");
432                    }
433                }
434                UpdateStatus::Error(e) => {
435                    if self.update_logs.check.is_none() {
436                        self.update_logs.check = Some(format!("Error: {e}"));
437                    } else {
438                        self.update_logs.download = Some(format!("Error: {e}"));
439                    }
440                }
441                _ => {}
442            }
443            self.last_status = update_status.clone();
444        }
445
446        self.update_unified_operations();
447
448        // The pages paint the content as white cards on the gray page and
449        // the launcher splits the two the same way, one tone per column:
450        // in the light theme the rail wears the page gray and the content
451        // column is the card; in the dark theme the rail is the raised
452        // card over the page-dark content.
453        let sheet = crate::theme::palette(ui.ctx());
454        let visuals = ui.visuals().clone();
455        let light = ui.ctx().theme() != egui::Theme::Dark;
456        let (rail_fill, content_fill) = if light {
457            (sheet.bg, sheet.card)
458        } else {
459            (sheet.card, sheet.bg)
460        };
461        egui::Panel::left("sidebar")
462            .resizable(false)
463            .min_size(180.0)
464            .frame(
465                egui::Frame::new()
466                    .fill(rail_fill)
467                    .stroke(visuals.widgets.inactive.bg_stroke)
468                    .inner_margin(egui::Margin::same(10)),
469            )
470            .show(ui, |ui| {
471                self.show_sidebar(ui, &update_status);
472                self.show_footer(ui);
473            });
474
475        // Bottom-anchored operation progress panel
476        self.render_operation_progress(ui);
477
478        // Main content area with scrolling
479        egui::CentralPanel::default()
480            .frame(egui::Frame::new().inner_margin(8).fill(content_fill))
481            .show(ui, |ui| {
482            egui::ScrollArea::vertical()
483                .auto_shrink([false, false])
484                .stick_to_bottom(true)
485                .show(ui, |ui| {
486                    self.render_page_content(ui);
487                });
488        });
489    }
490}