From 790212cf519624a7a01e7b945c5fcb3057b91da8 Mon Sep 17 00:00:00 2001 From: therealaleph Date: Wed, 22 Apr 2026 17:19:31 +0300 Subject: [PATCH] v0.8.4: config load failures are no longer silent (diagnoses the reset-on-reopen bug) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user reported that after Save-config, closing the UI, and reopening, every form field was blank — but the config.json on disk still had all the right values. The culprit in the UI was load_form()'s swallow-errors pattern: let existing = if path.exists() { Config::load(&path).ok() // .ok() threw away the error } else { ... }; if let Some(c) = existing { /* populate form */ } else { /* defaults */ } When Config::load returned an Err, .ok() silently converted to None, the form went back to defaults, and the user had no signal at all that the load had failed or WHY. On every platform I could test (macOS / Linux) the round-trip works fine with a real round-trip test added in config.rs (config::rt_tests::round_trip_all_current_fields and round_trip_minimal_fields_only — both green). So whatever's failing for this specific reporter is environment-specific (weird filesystem encoding, partial write, different field shape from an older version, … TBD). Without visibility we can't diagnose it. Changes: 1. load_form() now returns (FormState, Option). The String is a user-facing error message (with the full path + the underlying parse/validate reason) when Config::load fails on an existing file. 2. main() plumbs that error into App's initial toast, which sticks for 30 seconds (vs the normal 5 for regular toasts) so users who only open the UI briefly still see it. 3. Added tracing::info! in load_form for the success path too — the Recent log panel now always shows either 'config: loaded OK from ' or 'Config at failed to load: ' on startup, regardless of toast timing. 4. Added two regression-guard tests in config.rs covering the full-fields and minimal-fields save shapes the UI emits. Next time a user reports this: they'll have the exact error in the toast + the Recent log panel, and we can fix the actual bug instead of shooting blind. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/bin/ui.rs | 55 +++++++++++++++++++++++++++++++++--------- src/config.rs | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 13 deletions(-) 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); + } +}