fix(integration): resolve active version detection, prevent watchdog safe mode lock, and add async GUI background channels

This commit is contained in:
katR 2026-10-01 22:53:41 +05:30
parent 66deae64d6
commit c1d67e3160
8 changed files with 264 additions and 70 deletions

View file

@ -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<CleanReport, MolotovError> {
let mut report = CleanReport::default();
pub fn resolve_active_version(base_dir: &Path) -> Option<String> {
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<CleanReport, MolotovError> {
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;
}
}
}
}
}
}
}

View file

@ -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<i64>,
pub vram_skip_mips: bool,
}
pub struct LaunchPlan {
@ -32,11 +34,10 @@ impl Launcher {
pub fn prepare_launch(&self, options: LaunchOptions) -> Result<LaunchPlan, MolotovError> {
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()
}
}

View file

@ -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");
}

View file

@ -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");

View file

@ -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());

View file

@ -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 }

View file

@ -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<bool, String> {
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 {

View file

@ -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<String>,
rx: Receiver<String>,
}
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);
}
}
});
}
}
});