diff --git a/crates/molotov-bootstrap/src/cleaner.rs b/crates/molotov-bootstrap/src/cleaner.rs index f3d4c65..df28c67 100644 --- a/crates/molotov-bootstrap/src/cleaner.rs +++ b/crates/molotov-bootstrap/src/cleaner.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later use crate::error::MolotovError; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; #[derive(Debug, Clone, Default)] pub struct CleanReport { @@ -22,33 +22,79 @@ impl Cleaner { } } - pub fn clean_stale_data(&self, active_version_guid: &str) -> Result { - let mut report = CleanReport::default(); + pub fn resolve_active_version(base_dir: &Path) -> Option { + let active_file = base_dir.join("active_version.txt"); + if let Ok(text) = std::fs::read_to_string(active_file) { + let trimmed = text.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } - // 1. Scan versions directory - if self.versions_root.exists() { - for entry in std::fs::read_dir(&self.versions_root)? { - let entry = entry?; - let file_name = entry.file_name().to_string_lossy().to_string(); - if entry.file_type()?.is_dir() && file_name != active_version_guid { - let dir_size = calculate_dir_size(&entry.path()); - std::fs::remove_dir_all(entry.path())?; - report.purged_versions.push(file_name); - report.reclaimed_bytes += dir_size; + // Fallback: pick the latest modified version directory in versions/ + let versions_dir = base_dir.join("versions"); + if let Ok(entries) = std::fs::read_dir(versions_dir) { + let mut latest_name = None; + let mut latest_time = std::time::SystemTime::UNIX_EPOCH; + + for entry in entries.flatten() { + if let Ok(meta) = entry.metadata() { + if meta.is_dir() { + if let Ok(mtime) = meta.modified() { + if mtime > latest_time { + latest_time = mtime; + latest_name = Some(entry.file_name().to_string_lossy().to_string()); + } + } + } } } + return latest_name; + } + + None + } + + pub fn clean_stale_data(&self, active_version_guid: &str) -> Result { + let mut report = CleanReport::default(); + let active_guid = active_version_guid.trim(); + + // 1. Scan versions directory only if active version is specified to prevent data loss + if !active_guid.is_empty() && self.versions_root.exists() { + if let Ok(entries) = std::fs::read_dir(&self.versions_root) { + for entry in entries.flatten() { + let file_name = entry.file_name().to_string_lossy().to_string(); + if let Ok(ft) = entry.file_type() { + if ft.is_dir() && file_name != active_guid { + let dir_size = calculate_dir_size(&entry.path()); + if std::fs::remove_dir_all(entry.path()).is_ok() { + report.purged_versions.push(file_name); + report.reclaimed_bytes += dir_size; + } + } + } + } + } + } else if active_guid.is_empty() { + tracing::warn!("Cleaner: active version unspecified; skipping version directory purge for safety"); } // 2. Scan downloads directory for leftover .part files if self.downloads_root.exists() { - for entry in std::fs::read_dir(&self.downloads_root)? { - let entry = entry?; - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) == Some("part") { - let file_size = entry.metadata()?.len(); - std::fs::remove_file(path)?; - report.purged_parts_count += 1; - report.reclaimed_bytes += file_size; + if let Ok(entries) = std::fs::read_dir(&self.downloads_root) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("part") { + if let Ok(meta) = entry.metadata() { + if meta.is_file() { + let file_size = meta.len(); + if std::fs::remove_file(path).is_ok() { + report.purged_parts_count += 1; + report.reclaimed_bytes += file_size; + } + } + } + } } } } diff --git a/crates/molotov-bootstrap/src/launcher.rs b/crates/molotov-bootstrap/src/launcher.rs index 234cf41..0afb27d 100644 --- a/crates/molotov-bootstrap/src/launcher.rs +++ b/crates/molotov-bootstrap/src/launcher.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later use crate::error::MolotovError; -use crate::fflags::{FastFlagManager, PresetProfile}; +use crate::fflags::{FastFlagManager, FlagValue, PresetProfile}; use crate::region::ServerRegion; use crate::sandbox::SandboxConfig; use crate::watchdog::{LaunchStatus, LaunchWatchdog}; @@ -12,6 +12,8 @@ pub struct LaunchOptions { pub safe_mode: bool, pub region: ServerRegion, pub instance_id: String, + pub fps_cap: Option, + pub vram_skip_mips: bool, } pub struct LaunchPlan { @@ -32,11 +34,10 @@ impl Launcher { pub fn prepare_launch(&self, options: LaunchOptions) -> Result { let state_file = self.base_dir.join("launch_state.json"); - let mut watchdog = LaunchWatchdog::new(state_file); + let watchdog = LaunchWatchdog::new(state_file); - // Check if Safe Mode is required + // Check if Safe Mode is required without modifying state let activate_safe_mode = options.safe_mode || watchdog.status() == LaunchStatus::RequiresSafeMode; - watchdog.record_start()?; let mut fflag_mgr = FastFlagManager::new(); if activate_safe_mode { @@ -45,6 +46,14 @@ impl Launcher { } else { fflag_mgr.apply_preset(PresetProfile::LowVram); options.region.apply_to_fflags(&mut fflag_mgr); + + // Apply custom user settings + if let Some(fps) = options.fps_cap { + fflag_mgr.set_flag("DFIntTaskSchedulerTargetFps", FlagValue::Integer(fps)); + } + if options.vram_skip_mips { + fflag_mgr.set_flag("FIntDebugTextureManagerSkipMips", FlagValue::Integer(1)); + } } let fflags_path = self.base_dir.join("ClientAppSettings.json"); @@ -64,4 +73,16 @@ impl Launcher { is_safe_mode: activate_safe_mode, }) } + + pub fn record_launch_start(&self) -> Result<(), MolotovError> { + let state_file = self.base_dir.join("launch_state.json"); + let mut watchdog = LaunchWatchdog::new(state_file); + watchdog.record_start() + } + + pub fn record_launch_success(&self) -> Result<(), MolotovError> { + let state_file = self.base_dir.join("launch_state.json"); + let mut watchdog = LaunchWatchdog::new(state_file); + watchdog.record_success() + } } diff --git a/crates/molotov-bootstrap/tests/final_mile_fixes_test.rs b/crates/molotov-bootstrap/tests/final_mile_fixes_test.rs new file mode 100644 index 0000000..1408da2 --- /dev/null +++ b/crates/molotov-bootstrap/tests/final_mile_fixes_test.rs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +use molotov_bootstrap::cleaner::Cleaner; +use molotov_bootstrap::launcher::{LaunchOptions, Launcher}; +use molotov_bootstrap::region::ServerRegion; +use tempfile::tempdir; + +#[test] +fn test_cleaner_fail_safe_when_active_version_unspecified() { + let temp = tempdir().unwrap(); + let versions_dir = temp.path().join("versions"); + let downloads_dir = temp.path().join("downloads"); + std::fs::create_dir_all(&versions_dir).unwrap(); + + let ver_dir = versions_dir.join("version-real-12345"); + std::fs::create_dir_all(&ver_dir).unwrap(); + + let cleaner = Cleaner::new(versions_dir, downloads_dir); + // If active_version_guid is empty or unknown, cleaner must NOT delete installed versions + let report = cleaner.clean_stale_data("").unwrap(); + assert_eq!(report.purged_versions.len(), 0); + assert!(ver_dir.exists(), "Version directory must not be deleted when active version is unspecified"); +} + +#[test] +fn test_launcher_prepare_does_not_poison_watchdog_state() { + let temp = tempdir().unwrap(); + let root = temp.path().to_path_buf(); + + let launcher = Launcher::new(root); + let options = LaunchOptions { + place_id: None, + safe_mode: false, + region: ServerRegion::Auto, + instance_id: "default".to_string(), + fps_cap: Some(144), + vram_skip_mips: true, + }; + + // Calling prepare_launch 3 times must NOT trigger Safe Mode + let plan1 = launcher.prepare_launch(options.clone()).unwrap(); + assert!(!plan1.is_safe_mode); + + let plan2 = launcher.prepare_launch(options.clone()).unwrap(); + assert!(!plan2.is_safe_mode); + + let plan3 = launcher.prepare_launch(options).unwrap(); + assert!(!plan3.is_safe_mode, "Static preparation must not poison watchdog into Safe Mode"); +} diff --git a/crates/molotov-bootstrap/tests/launcher_test.rs b/crates/molotov-bootstrap/tests/launcher_test.rs index 6f07026..2386e23 100644 --- a/crates/molotov-bootstrap/tests/launcher_test.rs +++ b/crates/molotov-bootstrap/tests/launcher_test.rs @@ -14,6 +14,8 @@ fn test_launcher_prepare_flow() { safe_mode: false, region: ServerRegion::Europe, instance_id: "default".to_string(), + fps_cap: Some(144), + vram_skip_mips: true, }; let plan = launcher.prepare_launch(options).expect("Should prepare launch plan"); diff --git a/crates/molotov-cli/src/lib.rs b/crates/molotov-cli/src/lib.rs index f72d4ec..90db207 100644 --- a/crates/molotov-cli/src/lib.rs +++ b/crates/molotov-cli/src/lib.rs @@ -49,6 +49,8 @@ pub fn run(args: &[String]) { safe_mode, region: s_region, instance_id: "default".to_string(), + fps_cap: None, + vram_skip_mips: false, }; match launcher.prepare_launch(options) { @@ -99,8 +101,9 @@ pub fn run(args: &[String]) { } Some(Commands::Clean) => { println!("🧹 Cleaning old versions and temporary cache..."); + let active_guid = Cleaner::resolve_active_version(&base_dir).unwrap_or_default(); let cleaner = Cleaner::new(versions_dir, downloads_dir); - match cleaner.clean_stale_data("active") { + match cleaner.clean_stale_data(&active_guid) { Ok(report) => { println!("✅ Cleaning complete:"); println!(" • Purged old version directories: {}", report.purged_versions.len()); diff --git a/crates/molotov-core/Cargo.toml b/crates/molotov-core/Cargo.toml index d69842a..257732a 100644 --- a/crates/molotov-core/Cargo.toml +++ b/crates/molotov-core/Cargo.toml @@ -9,6 +9,7 @@ tracing = { workspace = true } thiserror = { workspace = true } libc = { workspace = true } lazy_static = "1.5" +serde_json = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/molotov-core/src/discord.rs b/crates/molotov-core/src/discord.rs index 99099a6..5a74cb5 100644 --- a/crates/molotov-core/src/discord.rs +++ b/crates/molotov-core/src/discord.rs @@ -1,7 +1,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later +use std::io::Write; use std::os::unix::net::UnixStream; use std::path::PathBuf; +pub const OPCODE_HANDSHAKE: u32 = 0; +pub const OPCODE_FRAME: u32 = 1; + #[derive(Debug, Clone)] pub struct DiscordActivity { pub details: String, @@ -11,13 +15,25 @@ pub struct DiscordActivity { impl DiscordActivity { pub fn to_json_payload(&self) -> String { - format!( - r#"{{"cmd":"SET_ACTIVITY","args":{{"pid":{},"activity":{{"details":"{}","state":"{}","timestamps":{{"start":{}}},"assets":{{"large_image":"roblox_logo","large_text":"Roblox on Linux (Molotov)"}}}}}},"nonce":"1"}}"#, - std::process::id(), - self.details, - self.state, - self.start_timestamp - ) + serde_json::json!({ + "cmd": "SET_ACTIVITY", + "args": { + "pid": std::process::id(), + "activity": { + "details": self.details, + "state": self.state, + "timestamps": { + "start": self.start_timestamp + }, + "assets": { + "large_image": "roblox_logo", + "large_text": "Roblox on Linux (Molotov)" + } + } + }, + "nonce": "1" + }) + .to_string() } } @@ -40,23 +56,50 @@ impl DiscordRpc { pub fn try_connect(&mut self) -> Result { let uid = unsafe { libc::getuid() }; - let socket_path = PathBuf::from(format!("/run/user/{}/discord-ipc-0", uid)); + let base_dir = std::env::var("XDG_RUNTIME_DIR") + .unwrap_or_else(|_| format!("/run/user/{}", uid)); - if !socket_path.exists() { - return Ok(false); - } + // Scan discord-ipc-0 through discord-ipc-9 + for i in 0..=9 { + let socket_path = PathBuf::from(&base_dir).join(format!("discord-ipc-{}", i)); + if socket_path.exists() { + if let Ok(mut s) = UnixStream::connect(&socket_path) { + // Send Handshake: OPCODE_HANDSHAKE (0) + let handshake_payload = serde_json::json!({ + "v": 1, + "client_id": self.client_id + }).to_string(); - match UnixStream::connect(&socket_path) { - Ok(s) => { - self.stream = Some(s); - tracing::info!("DiscordRpc: successfully connected to Discord IPC socket"); - Ok(true) - } - Err(e) => { - tracing::debug!("DiscordRpc: failed to connect to socket: {}", e); - Ok(false) + if Self::send_ipc_frame(&mut s, OPCODE_HANDSHAKE, handshake_payload.as_bytes()).is_ok() { + self.stream = Some(s); + tracing::info!("DiscordRpc: connected to socket {}", socket_path.display()); + return Ok(true); + } + } } } + + Ok(false) + } + + pub fn update_activity(&mut self, activity: &DiscordActivity) -> Result<(), String> { + if let Some(stream) = &mut self.stream { + let payload = activity.to_json_payload(); + Self::send_ipc_frame(stream, OPCODE_FRAME, payload.as_bytes()) + .map_err(|e| e.to_string()) + } else { + Err("Discord IPC stream not connected".to_string()) + } + } + + fn send_ipc_frame(stream: &mut UnixStream, opcode: u32, payload: &[u8]) -> std::io::Result<()> { + let mut header = [0u8; 8]; + header[0..4].copy_from_slice(&opcode.to_le_bytes()); + header[4..8].copy_from_slice(&(payload.len() as u32).to_le_bytes()); + + stream.write_all(&header)?; + stream.write_all(payload)?; + stream.flush() } pub fn is_connected(&self) -> bool { diff --git a/crates/molotov-gui/src/app.rs b/crates/molotov-gui/src/app.rs index 0c0e0a0..4e6a1d7 100644 --- a/crates/molotov-gui/src/app.rs +++ b/crates/molotov-gui/src/app.rs @@ -4,6 +4,7 @@ use molotov_bootstrap::cleaner::Cleaner; use molotov_bootstrap::launcher::{LaunchOptions, Launcher}; use molotov_bootstrap::region::ServerRegion; use std::path::PathBuf; +use std::sync::mpsc::{channel, Receiver, Sender}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Tab { @@ -23,10 +24,13 @@ pub struct MolotovApp { pub region: ServerRegion, pub discord_rpc: bool, pub status_message: String, + tx: Sender, + rx: Receiver, } impl Default for MolotovApp { fn default() -> Self { + let (tx, rx) = channel(); Self { current_tab: Tab::Play, safe_mode: false, @@ -35,6 +39,8 @@ impl Default for MolotovApp { region: ServerRegion::Auto, discord_rpc: true, status_message: "Ready to launch".to_string(), + tx, + rx, } } } @@ -62,6 +68,11 @@ impl MolotovApp { impl eframe::App for MolotovApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Poll asynchronous worker responses without blocking UI thread + while let Ok(msg) = self.rx.try_recv() { + self.status_message = msg; + } + egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { ui.horizontal(|ui| { ui.selectable_value(&mut self.current_tab, Tab::Play, "🎮 Play"); @@ -92,28 +103,41 @@ impl eframe::App for MolotovApp { ui.selectable_value(&mut self.region, ServerRegion::Europe, "Europe (Frankfurt / London)"); ui.selectable_value(&mut self.region, ServerRegion::Asia, "Asia (Tokyo / Singapore)"); ui.selectable_value(&mut self.region, ServerRegion::Australia, "Australia (Sydney)"); + ui.selectable_value(&mut self.region, ServerRegion::SouthAmerica, "South America (São Paulo)"); }); }); ui.add_space(10.0); if ui.button("🚀 Launch Roblox").clicked() { let (base_dir, _, _) = Self::get_paths(); - let launcher = Launcher::new(base_dir); - let options = LaunchOptions { - place_id: None, - safe_mode: self.safe_mode, - region: self.region, - instance_id: "default".to_string(), - }; + let tx = self.tx.clone(); + let safe_mode = self.safe_mode; + let region = self.region; + let fps_cap = self.fps_cap; + let vram_skip = self.vram_skip_mips; - match launcher.prepare_launch(options) { - Ok(plan) => { - self.status_message = format!("Launch plan prepared! Safe Mode: {}", plan.is_safe_mode); + self.status_message = "Preparing launch in background...".to_string(); + + std::thread::spawn(move || { + let launcher = Launcher::new(base_dir); + let options = LaunchOptions { + place_id: None, + safe_mode, + region, + instance_id: "default".to_string(), + fps_cap: Some(fps_cap), + vram_skip_mips: vram_skip, + }; + + match launcher.prepare_launch(options) { + Ok(plan) => { + let _ = tx.send(format!("Launch plan ready! Safe Mode: {}, Region: {:?}", plan.is_safe_mode, region)); + } + Err(e) => { + let _ = tx.send(format!("Launch preparation failed: {}", e)); + } } - Err(e) => { - self.status_message = format!("Launch preparation failed: {}", e); - } - } + }); } } Tab::FastFlags => { @@ -139,17 +163,23 @@ impl eframe::App for MolotovApp { ui.add_space(10.0); if ui.button("Clean Stale Versions").clicked() { - let (_, downloads_dir, versions_dir) = Self::get_paths(); - let cleaner = Cleaner::new(versions_dir, downloads_dir); - match cleaner.clean_stale_data("active") { - Ok(report) => { - let mb = (report.reclaimed_bytes as f64) / (1024.0 * 1024.0); - self.status_message = format!("Cleaned {} old versions, reclaimed {:.2} MB", report.purged_versions.len(), mb); + let (base_dir, downloads_dir, versions_dir) = Self::get_paths(); + let tx = self.tx.clone(); + self.status_message = "Cleaning cache and stale versions in background...".to_string(); + + std::thread::spawn(move || { + let active_ver = Cleaner::resolve_active_version(&base_dir).unwrap_or_default(); + let cleaner = Cleaner::new(versions_dir, downloads_dir); + match cleaner.clean_stale_data(&active_ver) { + Ok(report) => { + let mb = (report.reclaimed_bytes as f64) / (1024.0 * 1024.0); + let _ = tx.send(format!("Cleaned {} old versions, reclaimed {:.2} MB", report.purged_versions.len(), mb)); + } + Err(e) => { + let _ = tx.send(format!("Clean failed: {}", e)); + } } - Err(e) => { - self.status_message = format!("Clean failed: {}", e); - } - } + }); } } });