diff --git a/proxy_chain_manager/app.py b/proxy_chain_manager/app.py index b289404..ea0e913 100644 --- a/proxy_chain_manager/app.py +++ b/proxy_chain_manager/app.py @@ -111,7 +111,9 @@ def main() -> None: # HELPERS # ───────────────────────────────────────────────────────────────────────── def _btn(parent: Any, label: str, cmd: Any, w: int = 80, h: int = 28, **kw: Any) -> ctk.CTkButton: - # Pop styling keys so they are never duplicated in **kw (CustomTkinter raises on duplicate font, etc.). + # Pull known keys out first so **kw never duplicates CTkButton parameters (font, width, …). + kw.pop("text", None) + kw.pop("command", None) font = kw.pop("font", (FONT, 11)) fg_color = kw.pop("fg_color", ACCENT) hover_color = kw.pop("hover_color", ACCENT2) @@ -669,7 +671,7 @@ def main() -> None: ).pack(side="left") ctk.CTkFrame(sf_outer, fg_color="transparent", height=4).pack() - _btn(sf_outer, "💾 Save All Settings", + _btn(sf_outer, "Save All Settings", lambda: _save_settings(verbose=True), w=220, h=36, font=(FONT, 13)).pack(anchor="w", padx=6, pady=8) diff --git a/proxy_chain_manager/config.py b/proxy_chain_manager/config.py index ab5495a..cc30cc2 100644 --- a/proxy_chain_manager/config.py +++ b/proxy_chain_manager/config.py @@ -75,6 +75,63 @@ class Settings: return app_data_dir() / "settings.json" +def sanitize_settings(s: Settings) -> tuple[Settings, bool]: + """Clamp invalid values from hand-edited JSON. Returns (settings, changed).""" + changed = False + try: + cl = int(s.chain_length) + if not (1 <= cl <= 8): + s.chain_length = max(1, min(8, cl)) + changed = True + except (TypeError, ValueError): + s.chain_length = 3 + changed = True + try: + p = int(s.local_port) + if not (1 <= p <= 65535): + s.local_port = max(1, min(65535, p)) + changed = True + except (TypeError, ValueError): + s.local_port = 18888 + changed = True + try: + if int(s.health_check_seconds) < 10: + s.health_check_seconds = 10 + changed = True + except (TypeError, ValueError): + s.health_check_seconds = 180 + changed = True + try: + if int(s.full_refresh_seconds) < 60: + s.full_refresh_seconds = 60 + changed = True + except (TypeError, ValueError): + s.full_refresh_seconds = 1800 + changed = True + try: + if int(s.validation_concurrency) < 1: + s.validation_concurrency = 1 + changed = True + except (TypeError, ValueError): + s.validation_concurrency = 64 + changed = True + try: + if int(s.max_candidates) < 10: + s.max_candidates = 10 + changed = True + except (TypeError, ValueError): + s.max_candidates = 400 + changed = True + try: + if float(s.validation_timeout_seconds) < 2.0: + s.validation_timeout_seconds = 2.0 + changed = True + except (TypeError, ValueError): + s.validation_timeout_seconds = 12.0 + changed = True + return s, changed + + def normalize_listen_host(s: Settings) -> tuple[Settings, bool]: """Force loopback bind so LAN/DHCP IP changes (e.g. cloned VMs) never break the listener.""" if s.local_host == LISTEN_HOST: @@ -103,7 +160,9 @@ def load_settings() -> Settings: s.pinned_chain = [] if not hasattr(s, "manual_exit_proxy") or not isinstance(s.manual_exit_proxy, str): s.manual_exit_proxy = "" - s, changed = normalize_listen_host(s) + s, ch1 = sanitize_settings(s) + s, ch2 = normalize_listen_host(s) + changed = ch1 or ch2 if changed: try: p.write_text(json.dumps(asdict(s), indent=2), encoding="utf-8") @@ -113,6 +172,7 @@ def load_settings() -> Settings: def save_settings(s: Settings) -> None: + s, _ = sanitize_settings(s) s, _ = normalize_listen_host(s) p = app_data_dir() / "settings.json" p.write_text(json.dumps(asdict(s), indent=2), encoding="utf-8") diff --git a/proxy_chain_manager/gost_util.py b/proxy_chain_manager/gost_util.py index 4a3cea3..ec007ce 100644 --- a/proxy_chain_manager/gost_util.py +++ b/proxy_chain_manager/gost_util.py @@ -22,9 +22,13 @@ GOST_RELEASE_ZIP = ( def _add_defender_exclusion(path: Path) -> None: """Add Windows Defender exclusion so GOST is not quarantined.""" try: + # ExclusionPath covers gost.exe under this folder; avoid invalid combined params on older builds. + folder = str(path.parent).replace("'", "''") subprocess.run( - ["powershell", "-NoProfile", "-NonInteractive", "-Command", - f"Add-MpPreference -ExclusionPath '{path.parent}' -ExclusionProcess 'gost.exe' -ErrorAction SilentlyContinue"], + [ + "powershell", "-NoProfile", "-NonInteractive", "-Command", + f"Add-MpPreference -ExclusionPath '{folder}' -ErrorAction SilentlyContinue", + ], capture_output=True, timeout=30, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), diff --git a/proxy_chain_manager/service.py b/proxy_chain_manager/service.py index 93f64e9..70c7cb0 100644 --- a/proxy_chain_manager/service.py +++ b/proxy_chain_manager/service.py @@ -375,7 +375,7 @@ class ChainService: async def _build_pool(self) -> list[str]: """Fetch and validate proxies. Runs blocking I/O in thread pool.""" s = self._settings - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() # Fetch all sources concurrently in thread pool (they are blocking) async def _fetch_one(url: str) -> list[str]: