@@ -294,6 +294,7 @@ struct FormState {
294294 auto_blacklist_window_secs : u64 ,
295295 auto_blacklist_cooldown_secs : u64 ,
296296 request_timeout_secs : u64 ,
297+ stream_timeout_secs : u64 ,
297298 /// Optional second-hop exit node for CF-anti-bot bypass (chatgpt.com /
298299 /// claude.ai / grok.com / x.com). Config-only — no UI editor yet.
299300 /// See `assets/exit_node/` for the generic exit-node handler.
@@ -391,6 +392,7 @@ fn load_form() -> (FormState, Option<String>) {
391392 auto_blacklist_window_secs : c. auto_blacklist_window_secs ,
392393 auto_blacklist_cooldown_secs : c. auto_blacklist_cooldown_secs ,
393394 request_timeout_secs : c. request_timeout_secs ,
395+ stream_timeout_secs : c. stream_timeout_secs ,
394396 exit_node : c. exit_node . clone ( ) ,
395397 }
396398 } else {
@@ -433,6 +435,7 @@ fn load_form() -> (FormState, Option<String>) {
433435 auto_blacklist_window_secs : 30 ,
434436 auto_blacklist_cooldown_secs : 120 ,
435437 request_timeout_secs : 30 ,
438+ stream_timeout_secs : 300 ,
436439 exit_node : mhrv_rs:: config:: ExitNodeConfig :: default ( ) ,
437440 }
438441 } ;
@@ -618,6 +621,7 @@ impl FormState {
618621 auto_blacklist_window_secs : self . auto_blacklist_window_secs ,
619622 auto_blacklist_cooldown_secs : self . auto_blacklist_cooldown_secs ,
620623 request_timeout_secs : self . request_timeout_secs ,
624+ stream_timeout_secs : self . stream_timeout_secs ,
621625 // Exit-node config (CF-anti-bot bypass for chatgpt.com / claude.ai
622626 // / grok.com / x.com). Round-trip through FormState — config-only
623627 // editing for now, UI editor planned for v1.9.x desktop UI batch.
@@ -637,6 +641,166 @@ fn save_config(cfg: &Config) -> Result<PathBuf, String> {
637641 Ok ( path)
638642}
639643
644+ #[ derive( serde:: Serialize ) ]
645+ struct ConfigWire < ' a > {
646+ mode : & ' a str ,
647+ google_ip : & ' a str ,
648+ front_domain : & ' a str ,
649+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
650+ script_id : Option < ScriptIdWire < ' a > > ,
651+ auth_key : & ' a str ,
652+ listen_host : & ' a str ,
653+ listen_port : u16 ,
654+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
655+ socks5_port : Option < u16 > ,
656+ log_level : & ' a str ,
657+ verify_ssl : bool ,
658+ #[ serde( skip_serializing_if = "std::collections::HashMap::is_empty" ) ]
659+ hosts : & ' a std:: collections:: HashMap < String , String > ,
660+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
661+ upstream_socks5 : Option < & ' a str > ,
662+ #[ serde( skip_serializing_if = "is_zero_u8" ) ]
663+ parallel_relay : u8 ,
664+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
665+ sni_hosts : Option < Vec < & ' a str > > ,
666+ #[ serde( skip_serializing_if = "is_false" ) ]
667+ normalize_x_graphql : bool ,
668+ #[ serde( skip_serializing_if = "is_false" ) ]
669+ youtube_via_relay : bool ,
670+ #[ serde( skip_serializing_if = "Vec::is_empty" ) ]
671+ passthrough_hosts : & ' a Vec < String > ,
672+ // IP-scan knobs. These used to be missing from the wire struct, so
673+ // every Save-config silently dropped them — the user would toggle
674+ // "fetch from API" on, save, reopen, and find it off again. Add
675+ // them here and keep them in sync if Config ever grows more.
676+ #[ serde( skip_serializing_if = "is_false" ) ]
677+ fetch_ips_from_api : bool ,
678+ max_ips_to_scan : usize ,
679+ scan_batch_size : usize ,
680+ google_ip_validation : bool ,
681+ /// Default false (= bypass DoH). Only emitted when explicitly true
682+ /// so unchanged configs stay clean.
683+ #[ serde( skip_serializing_if = "is_false" ) ]
684+ tunnel_doh : bool ,
685+ #[ serde( skip_serializing_if = "Vec::is_empty" ) ]
686+ bypass_doh_hosts : & ' a Vec < String > ,
687+ /// PR #763: default true (= browser DoH rejected, system DNS used).
688+ /// Skip when matching default to keep unchanged configs clean —
689+ /// emit only when the user has explicitly disabled the block.
690+ #[ serde( skip_serializing_if = "is_true" ) ]
691+ block_doh : bool ,
692+ /// Default false. Emit only when the user enables STUN/TURN blocking.
693+ #[ serde( skip_serializing_if = "is_false" ) ]
694+ block_stun : bool ,
695+ #[ serde( skip_serializing_if = "Vec::is_empty" ) ]
696+ fronting_groups : & ' a Vec < FrontingGroup > ,
697+ /// Auto-blacklist tuning + batch timeout (#391, #444, #430). Skip
698+ /// serialization when matching the historical defaults so unchanged
699+ /// configs stay clean — only emitted when the user has explicitly
700+ /// tuned them.
701+ #[ serde( skip_serializing_if = "is_default_strikes" ) ]
702+ auto_blacklist_strikes : u32 ,
703+ #[ serde( skip_serializing_if = "is_default_window_secs" ) ]
704+ auto_blacklist_window_secs : u64 ,
705+ #[ serde( skip_serializing_if = "is_default_cooldown_secs" ) ]
706+ auto_blacklist_cooldown_secs : u64 ,
707+ #[ serde( skip_serializing_if = "is_default_timeout_secs" ) ]
708+ request_timeout_secs : u64 ,
709+ #[ serde( skip_serializing_if = "is_default_stream_timeout_secs" ) ]
710+ stream_timeout_secs : u64 ,
711+ /// HTTP/2 multiplexing kill switch. Default false (h2 active); only
712+ /// emitted on save when the user has explicitly disabled h2, so
713+ /// unchanged configs stay clean.
714+ #[ serde( skip_serializing_if = "is_false" ) ]
715+ force_http1 : bool ,
716+ /// Exit-node config (CF-anti-bot bypass for chatgpt.com / claude.ai /
717+ /// grok.com / x.com via exit-node second-hop relay). Skip when fully
718+ /// default (disabled with no URL/PSK/hosts) so configs without
719+ /// exit-node setup stay clean. Round-tripped through FormState so
720+ /// Save preserves user-edited values.
721+ #[ serde( skip_serializing_if = "is_default_exit_node" ) ]
722+ exit_node : & ' a mhrv_rs:: config:: ExitNodeConfig ,
723+ }
724+
725+ fn is_default_strikes ( v : & u32 ) -> bool { * v == 3 }
726+ fn is_default_window_secs ( v : & u64 ) -> bool { * v == 30 }
727+ fn is_default_cooldown_secs ( v : & u64 ) -> bool { * v == 120 }
728+ fn is_default_timeout_secs ( v : & u64 ) -> bool { * v == 30 }
729+ fn is_default_stream_timeout_secs ( v : & u64 ) -> bool { * v == 300 }
730+ fn is_default_exit_node ( en : & & mhrv_rs:: config:: ExitNodeConfig ) -> bool {
731+ !en. enabled
732+ && en. relay_url . is_empty ( )
733+ && en. psk . is_empty ( )
734+ && en. hosts . is_empty ( )
735+ && ( en. mode . is_empty ( ) || en. mode == "selective" )
736+ }
737+
738+ fn is_false ( b : & bool ) -> bool {
739+ !* b
740+ }
741+
742+ fn is_true ( b : & bool ) -> bool {
743+ * b
744+ }
745+
746+ fn is_zero_u8 ( v : & u8 ) -> bool {
747+ * v == 0
748+ }
749+
750+ #[ derive( serde:: Serialize ) ]
751+ #[ serde( untagged) ]
752+ enum ScriptIdWire < ' a > {
753+ One ( & ' a str ) ,
754+ Many ( Vec < & ' a str > ) ,
755+ }
756+
757+ impl < ' a > From < & ' a Config > for ConfigWire < ' a > {
758+ fn from ( c : & ' a Config ) -> Self {
759+ let script_id = c. script_id . as_ref ( ) . map ( |s| match s {
760+ ScriptId :: One ( v) => ScriptIdWire :: One ( v. as_str ( ) ) ,
761+ ScriptId :: Many ( v) => ScriptIdWire :: Many ( v. iter ( ) . map ( String :: as_str) . collect ( ) ) ,
762+ } ) ;
763+ ConfigWire {
764+ mode : c. mode . as_str ( ) ,
765+ google_ip : c. google_ip . as_str ( ) ,
766+ front_domain : c. front_domain . as_str ( ) ,
767+ script_id,
768+ auth_key : c. auth_key . as_str ( ) ,
769+ listen_host : c. listen_host . as_str ( ) ,
770+ listen_port : c. listen_port ,
771+ socks5_port : c. socks5_port ,
772+ log_level : c. log_level . as_str ( ) ,
773+ verify_ssl : c. verify_ssl ,
774+ hosts : & c. hosts ,
775+ upstream_socks5 : c. upstream_socks5 . as_deref ( ) ,
776+ parallel_relay : c. parallel_relay ,
777+ sni_hosts : c
778+ . sni_hosts
779+ . as_ref ( )
780+ . map ( |v| v. iter ( ) . map ( String :: as_str) . collect ( ) ) ,
781+ normalize_x_graphql : c. normalize_x_graphql ,
782+ youtube_via_relay : c. youtube_via_relay ,
783+ passthrough_hosts : & c. passthrough_hosts ,
784+ fetch_ips_from_api : c. fetch_ips_from_api ,
785+ max_ips_to_scan : c. max_ips_to_scan ,
786+ scan_batch_size : c. scan_batch_size ,
787+ google_ip_validation : c. google_ip_validation ,
788+ tunnel_doh : c. tunnel_doh ,
789+ bypass_doh_hosts : & c. bypass_doh_hosts ,
790+ block_doh : c. block_doh ,
791+ block_stun : c. block_stun ,
792+ fronting_groups : & c. fronting_groups ,
793+ auto_blacklist_strikes : c. auto_blacklist_strikes ,
794+ auto_blacklist_window_secs : c. auto_blacklist_window_secs ,
795+ auto_blacklist_cooldown_secs : c. auto_blacklist_cooldown_secs ,
796+ request_timeout_secs : c. request_timeout_secs ,
797+ stream_timeout_secs : c. stream_timeout_secs ,
798+ force_http1 : c. force_http1 ,
799+ exit_node : & c. exit_node ,
800+ }
801+ }
802+ }
803+
640804/// Accent color — same blue used throughout the UI for primary actions.
641805const ACCENT : egui:: Color32 = egui:: Color32 :: from_rgb ( 70 , 120 , 180 ) ;
642806const ACCENT_HOVER : egui:: Color32 = egui:: Color32 :: from_rgb ( 90 , 145 , 205 ) ;
@@ -2480,7 +2644,8 @@ fn install_ui_tracing(shared: Arc<Shared>, config_level: &str) {
24802644 let fmt_layer = tracing_subscriber:: fmt:: layer ( )
24812645 . with_target ( false )
24822646 . with_ansi ( false )
2483- . with_writer ( writer) ;
2647+ . with_writer ( writer)
2648+ . with_timer ( mhrv_rs:: logging:: CompactUtcTime ) ;
24842649
24852650 let _ = tracing_subscriber:: registry ( )
24862651 . with ( filter_layer)
0 commit comments