docs: add Phase 4 Final Mile implementation plan

This commit is contained in:
katR 2026-10-01 22:39:28 +05:30
parent 67ba1bfe4e
commit c64c9635fa

View file

@ -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/<uid>/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(&current_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<String>,
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<CleanReport, MolotovError> {
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<UnixStream>,
}
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<bool, String> {
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<u64>,
pub safe_mode: bool,
pub region: ServerRegion,
pub instance_id: String,
}
pub struct LaunchPlan {
pub place_id: Option<u64>,
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<LaunchPlan, MolotovError> {
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 <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"
```
---