diff --git a/docs/superpowers/plans/2026-09-29-molotov-phase4-final-mile.md b/docs/superpowers/plans/2026-09-29-molotov-phase4-final-mile.md new file mode 100644 index 0000000..cee42bd --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-molotov-phase4-final-mile.md @@ -0,0 +1,617 @@ +# Molotov Phase 4: The Final Mile — End-to-End Orchestration, Cleaner, Region Routing & Interactive UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Complete the remaining user-facing and integration systems for Molotov: the end-to-end launch pipeline, cache/version cleaner tool, server region router, Discord Rich Presence (RPC) daemon, and interactive GUI/CLI wire-up. + +**Architecture:** Connects `molotov-bootstrap`, `molotov-core`, `molotov-cli`, and `molotov-gui`. Scans and cleans stale versions from disk, passes region-routing parameters into engine flags, broadcasts playtime over Discord IPC socket (`/run/user//discord-ipc-0`), and wires up the `egui` dashboard buttons to real operations. + +**Tech Stack:** Rust 1.75+, `tokio`, `serde`, `serde_json`, `eframe` / `egui`, `clap`, `tracing`. + +**Spec:** `docs/superpowers/specs/2026-09-29-molotov-architecture-design.md` (Sections 4, 5, 6, 8) + +## Global Constraints + +- **Language & Edition**: Rust 1.75+ (2021 edition), strictly zero Wine or Proton. +- **License**: GNU General Public License v3.0 or later (GPL-3.0-or-later) with headers in all source files. +- **Atomic & Clean Operation**: Never leave partial files on disk; cleaning must never delete the active/current Roblox version. +- **Safe Mode Fallback**: Automatically activates minimal flags if consecutive startup failures are detected. + +## Review Focus + +1. **Active Version Deletion Prevention**: Cleaner must never delete the currently active version directory. +2. **Discord Socket Non-Blocking Failure**: If Discord is not running or socket is absent, RPC connection attempts must gracefully no-op without lagging the game or throwing panics. +3. **Region Route Parameter Validation**: Invalid or malformed region codes must default to `Auto` without crashing. +4. **GUI Background Threading**: Launching games or downloading updates from the GUI must not freeze the `egui` UI render loop. +5. **Partial Download Purging**: Cleaner must cleanly purge `.part` files in `~/.cache/molotov/downloads/`. + +--- + +### Task 1: Real Disk & Cache Cleaner Engine + +**Files:** +- Create: `crates/molotov-bootstrap/src/cleaner.rs` +- Modify: `crates/molotov-bootstrap/src/lib.rs` +- Test: `crates/molotov-bootstrap/tests/cleaner_test.rs` + +**Interfaces:** +- Consumes: None +- Produces: + - `molotov_bootstrap::cleaner::Cleaner` + - `molotov_bootstrap::cleaner::CleanReport` + +- [ ] **Step 1: Write the failing test for version and cache cleanup** + +```rust +// crates/molotov-bootstrap/tests/cleaner_test.rs +use molotov_bootstrap::cleaner::Cleaner; +use tempfile::tempdir; + +#[test] +fn test_cleaner_purges_stale_versions_and_part_files() { + 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(); + std::fs::create_dir_all(&downloads_dir).unwrap(); + + // Create current version and stale version + let current_ver = versions_dir.join("version-current"); + let stale_ver = versions_dir.join("version-stale-1"); + std::fs::create_dir_all(¤t_ver).unwrap(); + std::fs::create_dir_all(&stale_ver).unwrap(); + std::fs::write(current_ver.join("libroblox.so"), b"ACTIVE_LIB").unwrap(); + std::fs::write(stale_ver.join("libroblox.so"), b"STALE_LIB").unwrap(); + + // Create leftover .part file + let part_file = downloads_dir.join("download.apk.part"); + std::fs::write(&part_file, b"DANGLING_PART").unwrap(); + + let cleaner = Cleaner::new(versions_dir, downloads_dir); + let report = cleaner.clean_stale_data("version-current").unwrap(); + + assert_eq!(report.purged_versions.len(), 1); + assert_eq!(report.purged_versions[0], "version-stale-1"); + assert_eq!(report.purged_parts_count, 1); + assert!(report.reclaimed_bytes > 0); + assert!(!stale_ver.exists(), "Stale version must be deleted"); + assert!(!part_file.exists(), "Dangling .part file must be deleted"); + assert!(current_ver.exists(), "Active version must NOT be deleted"); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p molotov-bootstrap --test cleaner_test` +Expected: FAIL with unexported module `cleaner`. + +- [ ] **Step 3: Implement Cleaner** + +```rust +// crates/molotov-bootstrap/src/cleaner.rs +// SPDX-License-Identifier: GPL-3.0-or-later +use crate::error::MolotovError; +use std::path::PathBuf; + +#[derive(Debug, Clone, Default)] +pub struct CleanReport { + pub purged_versions: Vec, + pub purged_parts_count: usize, + pub reclaimed_bytes: u64, +} + +pub struct Cleaner { + versions_root: PathBuf, + downloads_root: PathBuf, +} + +impl Cleaner { + pub fn new(versions_root: PathBuf, downloads_root: PathBuf) -> Self { + Self { + versions_root, + downloads_root, + } + } + + pub fn clean_stale_data(&self, active_version_guid: &str) -> Result { + let mut report = CleanReport::default(); + + // 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; + } + } + } + + // 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; + } + } + } + + Ok(report) + } +} + +fn calculate_dir_size(path: &std::path::Path) -> u64 { + let mut total = 0; + if let Ok(entries) = std::fs::read_dir(path) { + for entry in entries.flatten() { + if let Ok(meta) = entry.metadata() { + if meta.is_dir() { + total += calculate_dir_size(&entry.path()); + } else { + total += meta.len(); + } + } + } + } + total +} +``` + +Update `crates/molotov-bootstrap/src/lib.rs` to expose `pub mod cleaner;`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p molotov-bootstrap --test cleaner_test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/molotov-bootstrap/ +git commit -m "feat(bootstrap): implement disk cache and version cleaner" +``` + +--- + +### Task 2: Preferred Server Region Selector & Network Configuration + +**Files:** +- Create: `crates/molotov-bootstrap/src/region.rs` +- Modify: `crates/molotov-bootstrap/src/lib.rs` +- Test: `crates/molotov-bootstrap/tests/region_test.rs` + +**Interfaces:** +- Consumes: None +- Produces: + - `molotov_bootstrap::region::ServerRegion` + - `molotov_bootstrap::region::ServerRegion::from_str()` + - `molotov_bootstrap::region::ServerRegion::apply_to_fflags()` + +- [ ] **Step 1: Write the failing test for region parsing and FastFlag routing** + +```rust +// crates/molotov-bootstrap/tests/region_test.rs +use molotov_bootstrap::fflags::FastFlagManager; +use molotov_bootstrap::region::ServerRegion; + +#[test] +fn test_server_region_parsing_and_flags() { + assert_eq!(ServerRegion::from_code("eu"), ServerRegion::Europe); + assert_eq!(ServerRegion::from_code("us-east"), ServerRegion::USEast); + assert_eq!(ServerRegion::from_code("invalid_garbage"), ServerRegion::Auto); + + let mut manager = FastFlagManager::new(); + ServerRegion::Europe.apply_to_fflags(&mut manager); + + // Verify flag was applied + assert_eq!( + manager.active_profile().flags.get("FStringPreferredGameRegion").map(|v| format!("{:?}", v)), + Some("Text(\"eu-central-1\")".to_string()) + ); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p molotov-bootstrap --test region_test` +Expected: FAIL with unexported module `region`. + +- [ ] **Step 3: Implement ServerRegion** + +```rust +// crates/molotov-bootstrap/src/region.rs +// SPDX-License-Identifier: GPL-3.0-or-later +use crate::fflags::{FastFlagManager, FlagValue}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ServerRegion { + #[default] + Auto, + USEast, + USWest, + Europe, + Asia, + Australia, + SouthAmerica, +} + +impl ServerRegion { + pub fn from_code(code: &str) -> Self { + let clean = code.trim().to_lowercase(); + match clean.as_str() { + "us-east" | "useast" | "virginia" => ServerRegion::USEast, + "us-west" | "uswest" | "california" => ServerRegion::USWest, + "eu" | "europe" | "frankfurt" | "london" => ServerRegion::Europe, + "asia" | "tokyo" | "singapore" => ServerRegion::Asia, + "aus" | "australia" | "sydney" => ServerRegion::Australia, + "sa" | "south-america" | "brazil" => ServerRegion::SouthAmerica, + _ => ServerRegion::Auto, + } + } + + pub fn display_name(&self) -> &'static str { + match self { + ServerRegion::Auto => "Auto / Default", + ServerRegion::USEast => "US East (Virginia)", + ServerRegion::USWest => "US West (California)", + ServerRegion::Europe => "Europe (Frankfurt / London)", + ServerRegion::Asia => "Asia (Tokyo / Singapore)", + ServerRegion::Australia => "Australia (Sydney)", + ServerRegion::SouthAmerica => "South America (São Paulo)", + } + } + + pub fn region_identifier(&self) -> Option<&'static str> { + match self { + ServerRegion::Auto => None, + ServerRegion::USEast => Some("us-east-1"), + ServerRegion::USWest => Some("us-west-1"), + ServerRegion::Europe => Some("eu-central-1"), + ServerRegion::Asia => Some("ap-northeast-1"), + ServerRegion::Australia => Some("ap-southeast-2"), + ServerRegion::SouthAmerica => Some("sa-east-1"), + } + } + + pub fn apply_to_fflags(&self, manager: &mut FastFlagManager) { + if let Some(id) = self.region_identifier() { + manager.set_flag("FStringPreferredGameRegion", FlagValue::Text(id.to_string())); + } + } +} +``` + +Update `crates/molotov-bootstrap/src/lib.rs` to expose `pub mod region;`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p molotov-bootstrap --test region_test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/molotov-bootstrap/ +git commit -m "feat(bootstrap): implement server region selector and FastFlag network routing" +``` + +--- + +### Task 3: Zero-Dependency Discord Rich Presence Client + +**Files:** +- Create: `crates/molotov-core/src/discord.rs` +- Modify: `crates/molotov-core/src/lib.rs` +- Test: `crates/molotov-core/tests/discord_rpc_test.rs` + +**Interfaces:** +- Consumes: None +- Produces: + - `molotov_core::discord::DiscordRpc` + - `molotov_core::discord::DiscordActivity` + +- [ ] **Step 1: Write the failing test for Discord IPC frame creation and non-blocking failure** + +```rust +// crates/molotov-core/tests/discord_rpc_test.rs +use molotov_core::discord::{DiscordActivity, DiscordRpc}; + +#[test] +fn test_discord_rpc_payload_construction() { + let activity = DiscordActivity { + details: "Playing Brookhaven RP".to_string(), + state: "In Game".to_string(), + start_timestamp: 1690000000, + }; + + let payload = activity.to_json_payload(); + assert!(payload.contains("Brookhaven RP")); + assert!(payload.contains("In Game")); +} + +#[test] +fn test_discord_rpc_connect_non_blocking_when_socket_missing() { + let mut rpc = DiscordRpc::new("123456789012345678"); + // Connect attempt when Discord socket is absent must return Ok(false) or Err cleanly, never panic + let connected = rpc.try_connect().unwrap_or(false); + assert!(!connected); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p molotov-core --test discord_rpc_test` +Expected: FAIL with unexported module `discord`. + +- [ ] **Step 3: Implement DiscordRpc** + +```rust +// crates/molotov-core/src/discord.rs +// SPDX-License-Identifier: GPL-3.0-or-later +use std::os::unix::net::UnixStream; +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct DiscordActivity { + pub details: String, + pub state: String, + pub start_timestamp: u64, +} + +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 + ) + } +} + +pub struct DiscordRpc { + client_id: String, + stream: Option, +} + +impl DiscordRpc { + pub fn new(client_id: &str) -> Self { + Self { + client_id: client_id.to_string(), + stream: None, + } + } + + pub fn try_connect(&mut self) -> Result { + let uid = unsafe { libc::getuid() }; + let socket_path = PathBuf::from(format!("/run/user/{}/discord-ipc-0", uid)); + + if !socket_path.exists() { + return Ok(false); + } + + 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) + } + } + } + + pub fn is_connected(&self) -> bool { + self.stream.is_some() + } +} +``` + +Update `crates/molotov-core/src/lib.rs` to expose `pub mod discord;`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p molotov-core --test discord_rpc_test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/molotov-core/ +git commit -m "feat(core): implement zero-dependency Discord Rich Presence IPC client" +``` + +--- + +### Task 4: Unified Launch Orchestrator + +**Files:** +- Create: `crates/molotov-bootstrap/src/launcher.rs` +- Modify: `crates/molotov-bootstrap/src/lib.rs` +- Test: `crates/molotov-bootstrap/tests/launcher_test.rs` + +**Interfaces:** +- Consumes: All `molotov-bootstrap` modules (`cdn`, `apk`, `fflags`, `mods`, `sandbox`, `watchdog`) +- Produces: + - `molotov_bootstrap::launcher::Launcher` + - `molotov_bootstrap::launcher::LaunchOptions` + - `molotov_bootstrap::launcher::Launcher::prepare_launch()` + +- [ ] **Step 1: Write the failing test for launcher orchestration flow** + +```rust +// crates/molotov-bootstrap/tests/launcher_test.rs +use molotov_bootstrap::launcher::{LaunchOptions, Launcher}; +use molotov_bootstrap::region::ServerRegion; +use tempfile::tempdir; + +#[test] +fn test_launcher_prepare_flow() { + let temp = tempdir().unwrap(); + let root = temp.path().to_path_buf(); + + let launcher = Launcher::new(root); + let options = LaunchOptions { + place_id: Some(1818), + safe_mode: false, + region: ServerRegion::Europe, + instance_id: "default".to_string(), + }; + + let plan = launcher.prepare_launch(options).expect("Should prepare launch plan"); + assert!(plan.fflags_path.exists()); + assert_eq!(plan.place_id, Some(1818)); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p molotov-bootstrap --test launcher_test` +Expected: FAIL with unexported module `launcher`. + +- [ ] **Step 3: Implement Launcher** + +```rust +// crates/molotov-bootstrap/src/launcher.rs +// SPDX-License-Identifier: GPL-3.0-or-later +use crate::error::MolotovError; +use crate::fflags::{FastFlagManager, PresetProfile}; +use crate::region::ServerRegion; +use crate::sandbox::{SandboxConfig, SandboxRunner}; +use crate::watchdog::{LaunchStatus, LaunchWatchdog}; +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct LaunchOptions { + pub place_id: Option, + pub safe_mode: bool, + pub region: ServerRegion, + pub instance_id: String, +} + +pub struct LaunchPlan { + pub place_id: Option, + pub fflags_path: PathBuf, + pub sandbox_config: SandboxConfig, + pub is_safe_mode: bool, +} + +pub struct Launcher { + base_dir: PathBuf, +} + +impl Launcher { + pub fn new(base_dir: PathBuf) -> Self { + Self { base_dir } + } + + 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); + + // Check if Safe Mode is required + let activate_safe_mode = options.safe_mode || watchdog.status() == LaunchStatus::RequiresSafeMode; + watchdog.record_start()?; + + let mut fflag_mgr = FastFlagManager::new(); + if activate_safe_mode { + tracing::warn!("Launcher: launching in SAFE MODE with minimal default flags"); + fflag_mgr.apply_preset(PresetProfile::Default); + } else { + fflag_mgr.apply_preset(PresetProfile::LowVram); + options.region.apply_to_fflags(&mut fflag_mgr); + } + + let fflags_path = self.base_dir.join("ClientAppSettings.json"); + fflag_mgr.export_client_app_settings(&fflags_path)?; + + let sandbox_config = SandboxConfig { + instance_id: options.instance_id.clone(), + data_dir: self.base_dir.join("instances").join(&options.instance_id).join("data"), + config_dir: self.base_dir.join("instances").join(&options.instance_id).join("config"), + nice_priority: -5, + }; + + Ok(LaunchPlan { + place_id: options.place_id, + fflags_path, + sandbox_config, + is_safe_mode: activate_safe_mode, + }) + } +} +``` + +Update `crates/molotov-bootstrap/src/lib.rs` to expose `pub mod launcher;`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p molotov-bootstrap --test launcher_test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/molotov-bootstrap/ +git commit -m "feat(bootstrap): implement unified Launch orchestrator" +``` + +--- + +### Task 5: Interactive GUI & CLI Wire-Up + +**Files:** +- Modify: `crates/molotov-cli/src/lib.rs` +- Modify: `crates/molotov-cli/src/commands.rs` +- Modify: `crates/molotov-gui/src/app.rs` +- Test: `crates/molotov-cli/tests/cli_test.rs` +- Test: `crates/molotov-gui/tests/gui_test.rs` + +**Interfaces:** +- Consumes: `molotov_bootstrap::launcher::Launcher`, `molotov_bootstrap::cleaner::Cleaner`, `molotov_core::discord::DiscordRpc` +- Produces: Live executable commands and GUI interactions + +- [ ] **Step 1: Write test verifying CLI clean and launch execution** + +```rust +// In crates/molotov-cli/tests/cli_test.rs +use clap::Parser; +use molotov_cli::commands::Cli; + +#[test] +fn test_cli_parse_region_flag() { + let parsed = Cli::try_parse_from(["molotov", "launch", "--region", "eu"]); + assert!(parsed.is_ok()); +} +``` + +- [ ] **Step 2: Implement CLI and GUI connections** + +Update `crates/molotov-cli/src/commands.rs` to add `--region ` option to `Commands::Launch`. +Update `crates/molotov-cli/src/lib.rs` to execute `Launcher::prepare_launch` and `Cleaner::clean_stale_data`. +Update `crates/molotov-gui/src/app.rs` to invoke `Launcher`, `Cleaner`, and display region dropdown and Discord RPC status. + +- [ ] **Step 3: Run tests to verify they pass** + +Run: `cargo test --workspace` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add crates/molotov-cli/ crates/molotov-gui/ +git commit -m "feat: wire up interactive GUI and CLI subcommands to launcher, cleaner, and region router" +``` + +---