diff --git a/Cargo.lock b/Cargo.lock index 75b61be..4afd084 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1317,7 +1317,7 @@ dependencies = [ [[package]] name = "mhrv-rs" -version = "0.8.3" +version = "0.8.4" dependencies = [ "base64 0.22.1", "bytes", diff --git a/Cargo.toml b/Cargo.toml index e3ffda6..8839303 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mhrv-rs" -version = "0.8.3" +version = "0.8.4" edition = "2021" description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting" license = "MIT" diff --git a/src/bin/ui.rs b/src/bin/ui.rs index 5305996..2b23baa 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -46,7 +46,8 @@ fn main() -> eframe::Result<()> { .spawn(move || background_thread(shared_bg, cmd_rx)) .expect("failed to spawn background thread"); - let form = load_form(); + let (form, load_err) = load_form(); + let initial_toast = load_err.map(|e| (e, Instant::now())); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() @@ -66,7 +67,7 @@ fn main() -> eframe::Result<()> { cmd_tx, form, last_poll: Instant::now(), - toast: None, + toast: initial_toast, })) }), ) @@ -161,17 +162,42 @@ struct SniRow { enabled: bool, } -fn load_form() -> FormState { +fn load_form() -> (FormState, Option) { + // Try the user-data config first, then the cwd fallback. Report WHY load + // fails so the user isn't silently shown a blank form (issue: user reports + // 'settings saved to file but not loaded back'). Without this signal the + // failure is invisible — `.ok()` swallows it and the form looks fresh. let path = data_dir::config_path(); let cwd = PathBuf::from("config.json"); - let existing = if path.exists() { - Config::load(&path).ok() + + let (existing, load_err): (Option, Option) = if path.exists() { + tracing::info!("config: attempting load from {}", path.display()); + match Config::load(&path) { + Ok(c) => { + tracing::info!("config: loaded OK from {}", path.display()); + (Some(c), None) + } + Err(e) => { + let msg = format!("Config at {} failed to load: {}", path.display(), e); + tracing::warn!("{}", msg); + (None, Some(msg)) + } + } } else if cwd.exists() { - Config::load(&cwd).ok() + tracing::info!("config: attempting fallback load from {}", cwd.display()); + match Config::load(&cwd) { + Ok(c) => (Some(c), None), + Err(e) => { + let msg = format!("Config at {} failed to load: {}", cwd.display(), e); + tracing::warn!("{}", msg); + (None, Some(msg)) + } + } } else { - None + tracing::info!("config: no config found at {} — starting with defaults", path.display()); + (None, None) }; - if let Some(c) = existing { + let form = if let Some(c) = existing { let sid = match &c.script_id { Some(ScriptId::One(s)) => s.clone(), Some(ScriptId::Many(v)) => v.join("\n"), @@ -225,7 +251,8 @@ fn load_form() -> FormState { google_ip_validation:true, scan_batch_size:500 } - } + }; + (form, load_err) } /// Build the initial `sni_pool` list shown in the editor. @@ -762,9 +789,15 @@ impl eframe::App for App { } }); - // Transient toast at the bottom. + // Transient toast at the bottom. Config-load failures stick for + // 30s instead of 5 because they explain why the form looks empty. if let Some((msg, t)) = &self.toast { - if t.elapsed() < Duration::from_secs(5) { + let ttl = if msg.contains("failed to load") { + Duration::from_secs(30) + } else { + Duration::from_secs(5) + }; + if t.elapsed() < ttl { ui.add_space(4.0); ui.colored_label(egui::Color32::from_rgb(200, 170, 80), msg); } else { diff --git a/src/config.rs b/src/config.rs index bf8ed4c..4d528ea 100644 --- a/src/config.rs +++ b/src/config.rs @@ -214,3 +214,69 @@ mod tests { assert!(cfg.validate().is_err()); } } + +#[cfg(test)] +mod rt_tests { + use super::*; + + #[test] + fn round_trip_all_current_fields() { + // Regression guard: make sure a config written by the UI (all current + // optional fields present and populated) loads back cleanly. + let json = r#"{ + "mode": "apps_script", + "google_ip": "216.239.38.120", + "front_domain": "www.google.com", + "script_id": "AKfyc_TEST", + "auth_key": "testtesttest", + "listen_host": "127.0.0.1", + "listen_port": 8085, + "socks5_port": 8086, + "log_level": "info", + "verify_ssl": true, + "upstream_socks5": "127.0.0.1:50529", + "parallel_relay": 2, + "sni_hosts": ["www.google.com", "drive.google.com"], + "fetch_ips_from_api": true, + "max_ips_to_scan": 50, + "scan_batch_size": 100, + "google_ip_validation": true +}"#; + let tmp = std::env::temp_dir().join("mhrv-rt-test.json"); + std::fs::write(&tmp, json).unwrap(); + let cfg = Config::load(&tmp).expect("config should load"); + assert_eq!(cfg.mode, "apps_script"); + assert_eq!(cfg.auth_key, "testtesttest"); + assert_eq!(cfg.listen_port, 8085); + assert_eq!(cfg.upstream_socks5.as_deref(), Some("127.0.0.1:50529")); + assert_eq!(cfg.parallel_relay, 2); + assert_eq!( + cfg.sni_hosts.as_ref().unwrap(), + &vec!["www.google.com".to_string(), "drive.google.com".to_string()] + ); + assert_eq!(cfg.fetch_ips_from_api, true); + let _ = std::fs::remove_file(&tmp); + } + + #[test] + fn round_trip_minimal_fields_only() { + // User saves with defaults for everything optional. This is what the + // UI's save button actually writes for a first-run user. + let json = r#"{ + "mode": "apps_script", + "google_ip": "216.239.38.120", + "front_domain": "www.google.com", + "script_id": "A", + "auth_key": "secretkey123", + "listen_host": "127.0.0.1", + "listen_port": 8085, + "log_level": "info", + "verify_ssl": true +}"#; + let tmp = std::env::temp_dir().join("mhrv-rt-min.json"); + std::fs::write(&tmp, json).unwrap(); + let cfg = Config::load(&tmp).expect("minimal config should load"); + assert_eq!(cfg.mode, "apps_script"); + let _ = std::fs::remove_file(&tmp); + } +}