Skip to content

Commit f9ea463

Browse files
committed
v0.8.4: config load failures are no longer silent (diagnoses the reset-on-reopen bug)
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<String>). 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 <path>' or 'Config at <path> failed to load: <reason>' 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.
1 parent bc43a64 commit f9ea463

4 files changed

Lines changed: 112 additions & 13 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mhrv-rs"
3-
version = "0.8.3"
3+
version = "0.8.4"
44
edition = "2021"
55
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
66
license = "MIT"

src/bin/ui.rs

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ fn main() -> eframe::Result<()> {
4646
.spawn(move || background_thread(shared_bg, cmd_rx))
4747
.expect("failed to spawn background thread");
4848

49-
let form = load_form();
49+
let (form, load_err) = load_form();
50+
let initial_toast = load_err.map(|e| (e, Instant::now()));
5051

5152
let options = eframe::NativeOptions {
5253
viewport: egui::ViewportBuilder::default()
@@ -66,7 +67,7 @@ fn main() -> eframe::Result<()> {
6667
cmd_tx,
6768
form,
6869
last_poll: Instant::now(),
69-
toast: None,
70+
toast: initial_toast,
7071
}))
7172
}),
7273
)
@@ -161,17 +162,42 @@ struct SniRow {
161162
enabled: bool,
162163
}
163164

164-
fn load_form() -> FormState {
165+
fn load_form() -> (FormState, Option<String>) {
166+
// Try the user-data config first, then the cwd fallback. Report WHY load
167+
// fails so the user isn't silently shown a blank form (issue: user reports
168+
// 'settings saved to file but not loaded back'). Without this signal the
169+
// failure is invisible — `.ok()` swallows it and the form looks fresh.
165170
let path = data_dir::config_path();
166171
let cwd = PathBuf::from("config.json");
167-
let existing = if path.exists() {
168-
Config::load(&path).ok()
172+
173+
let (existing, load_err): (Option<Config>, Option<String>) = if path.exists() {
174+
tracing::info!("config: attempting load from {}", path.display());
175+
match Config::load(&path) {
176+
Ok(c) => {
177+
tracing::info!("config: loaded OK from {}", path.display());
178+
(Some(c), None)
179+
}
180+
Err(e) => {
181+
let msg = format!("Config at {} failed to load: {}", path.display(), e);
182+
tracing::warn!("{}", msg);
183+
(None, Some(msg))
184+
}
185+
}
169186
} else if cwd.exists() {
170-
Config::load(&cwd).ok()
187+
tracing::info!("config: attempting fallback load from {}", cwd.display());
188+
match Config::load(&cwd) {
189+
Ok(c) => (Some(c), None),
190+
Err(e) => {
191+
let msg = format!("Config at {} failed to load: {}", cwd.display(), e);
192+
tracing::warn!("{}", msg);
193+
(None, Some(msg))
194+
}
195+
}
171196
} else {
172-
None
197+
tracing::info!("config: no config found at {} — starting with defaults", path.display());
198+
(None, None)
173199
};
174-
if let Some(c) = existing {
200+
let form = if let Some(c) = existing {
175201
let sid = match &c.script_id {
176202
Some(ScriptId::One(s)) => s.clone(),
177203
Some(ScriptId::Many(v)) => v.join("\n"),
@@ -225,7 +251,8 @@ fn load_form() -> FormState {
225251
google_ip_validation:true,
226252
scan_batch_size:500
227253
}
228-
}
254+
};
255+
(form, load_err)
229256
}
230257

231258
/// Build the initial `sni_pool` list shown in the editor.
@@ -762,9 +789,15 @@ impl eframe::App for App {
762789
}
763790
});
764791

765-
// Transient toast at the bottom.
792+
// Transient toast at the bottom. Config-load failures stick for
793+
// 30s instead of 5 because they explain why the form looks empty.
766794
if let Some((msg, t)) = &self.toast {
767-
if t.elapsed() < Duration::from_secs(5) {
795+
let ttl = if msg.contains("failed to load") {
796+
Duration::from_secs(30)
797+
} else {
798+
Duration::from_secs(5)
799+
};
800+
if t.elapsed() < ttl {
768801
ui.add_space(4.0);
769802
ui.colored_label(egui::Color32::from_rgb(200, 170, 80), msg);
770803
} else {

src/config.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,3 +214,69 @@ mod tests {
214214
assert!(cfg.validate().is_err());
215215
}
216216
}
217+
218+
#[cfg(test)]
219+
mod rt_tests {
220+
use super::*;
221+
222+
#[test]
223+
fn round_trip_all_current_fields() {
224+
// Regression guard: make sure a config written by the UI (all current
225+
// optional fields present and populated) loads back cleanly.
226+
let json = r#"{
227+
"mode": "apps_script",
228+
"google_ip": "216.239.38.120",
229+
"front_domain": "www.google.com",
230+
"script_id": "AKfyc_TEST",
231+
"auth_key": "testtesttest",
232+
"listen_host": "127.0.0.1",
233+
"listen_port": 8085,
234+
"socks5_port": 8086,
235+
"log_level": "info",
236+
"verify_ssl": true,
237+
"upstream_socks5": "127.0.0.1:50529",
238+
"parallel_relay": 2,
239+
"sni_hosts": ["www.google.com", "drive.google.com"],
240+
"fetch_ips_from_api": true,
241+
"max_ips_to_scan": 50,
242+
"scan_batch_size": 100,
243+
"google_ip_validation": true
244+
}"#;
245+
let tmp = std::env::temp_dir().join("mhrv-rt-test.json");
246+
std::fs::write(&tmp, json).unwrap();
247+
let cfg = Config::load(&tmp).expect("config should load");
248+
assert_eq!(cfg.mode, "apps_script");
249+
assert_eq!(cfg.auth_key, "testtesttest");
250+
assert_eq!(cfg.listen_port, 8085);
251+
assert_eq!(cfg.upstream_socks5.as_deref(), Some("127.0.0.1:50529"));
252+
assert_eq!(cfg.parallel_relay, 2);
253+
assert_eq!(
254+
cfg.sni_hosts.as_ref().unwrap(),
255+
&vec!["www.google.com".to_string(), "drive.google.com".to_string()]
256+
);
257+
assert_eq!(cfg.fetch_ips_from_api, true);
258+
let _ = std::fs::remove_file(&tmp);
259+
}
260+
261+
#[test]
262+
fn round_trip_minimal_fields_only() {
263+
// User saves with defaults for everything optional. This is what the
264+
// UI's save button actually writes for a first-run user.
265+
let json = r#"{
266+
"mode": "apps_script",
267+
"google_ip": "216.239.38.120",
268+
"front_domain": "www.google.com",
269+
"script_id": "A",
270+
"auth_key": "secretkey123",
271+
"listen_host": "127.0.0.1",
272+
"listen_port": 8085,
273+
"log_level": "info",
274+
"verify_ssl": true
275+
}"#;
276+
let tmp = std::env::temp_dir().join("mhrv-rt-min.json");
277+
std::fs::write(&tmp, json).unwrap();
278+
let cfg = Config::load(&tmp).expect("minimal config should load");
279+
assert_eq!(cfg.mode, "apps_script");
280+
let _ = std::fs::remove_file(&tmp);
281+
}
282+
}

0 commit comments

Comments
 (0)