From ad7f3cae9eab6e868e56d3e50ea77825886dfe2a Mon Sep 17 00:00:00 2001 From: Flowseal Date: Fri, 25 Sep 2026 16:30:14 +0300 Subject: [PATCH] UI refactoring --- linux.py | 64 +-- macos.py | 79 +--- ui/background_task.py | 56 +++ ui/connectivity.py | 178 ++++++++ ui/ctk_controls.py | 90 ++++ ui/ctk_tray_ui.py | 936 +--------------------------------------- ui/settings.py | 84 ++++ ui/settings_dialog.py | 81 ++++ ui/settings_form.py | 578 +++++++++++++++++++++++++ utils/default_config.py | 4 +- utils/tray_common.py | 3 +- windows.py | 70 +-- 12 files changed, 1116 insertions(+), 1107 deletions(-) create mode 100644 ui/background_task.py create mode 100644 ui/connectivity.py create mode 100644 ui/ctk_controls.py create mode 100644 ui/settings.py create mode 100644 ui/settings_dialog.py create mode 100644 ui/settings_form.py diff --git a/linux.py b/linux.py index d5149f7..846214d 100644 --- a/linux.py +++ b/linux.py @@ -24,13 +24,13 @@ from utils.tray_common import ( from ui.ctk_tray_ui import ( install_tray_config_buttons, install_tray_config_form, populate_first_run_window, tray_settings_scroll_and_footer, - validate_config_form, ) from ui.ctk_theme import ( CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE, create_ctk_toplevel, ctk_theme_for_platform, main_content_frame, ) -from ui.i18n import set_language, t +from ui.i18n import t +from ui.settings_dialog import SettingsDialog _tray_icon: Optional[object] = None _config: dict = {} @@ -158,68 +158,24 @@ def _edit_config_dialog() -> None: if _tray_icon is not None: _tray_icon.menu = _build_menu() - _original_language = _config.get("language", DEFAULT_CONFIG["language"]) - widgets = install_tray_config_form( ctk, scroll, theme, cfg, DEFAULT_CONFIG, show_autostart=False, - on_language_change=_refresh_tray_menu, ) - _original_appearance = ctk.get_appearance_mode() - - def _restore_ui_locale() -> None: - set_language(_original_language) - _refresh_tray_menu() - def _finish() -> None: root.destroy() done.set() - def _cancel() -> None: - ctk.set_appearance_mode(_original_appearance) - _restore_ui_locale() - _finish() + dialog = SettingsDialog( + ctk=ctk, root=root, widgets=widgets, config=_config, + defaults=DEFAULT_CONFIG, persist=save_config, + refresh_menu=_refresh_tray_menu, finish=_finish, + restart=lambda config: restart_proxy(config, _show_error), + ) - def on_save() -> None: - from tkinter import messagebox - merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=False) - if isinstance(merged, str): - messagebox.showerror(t("app.error_title"), merged, parent=root) - return - - merged["force_test_dc"] = _config.get("force_test_dc", DEFAULT_CONFIG["force_test_dc"]) - - _ui_only_keys = {"appearance", "check_updates", "language"} - config_changed = any(merged.get(k) != _config.get(k) for k in merged) - proxy_changed = any(merged.get(k) != _config.get(k) for k in merged if k not in _ui_only_keys) - - if not config_changed: - _restore_ui_locale() - _finish() - return - - save_config(merged) - _config.update(merged) - set_language(merged.get("language", DEFAULT_CONFIG["language"])) - log.info("Config saved: %s", merged) - _tray_icon.menu = _build_menu() - - if not proxy_changed: - _finish() - return - - do_restart = messagebox.askyesno( - t("dialog.restart_title"), - t("dialog.restart_body"), - parent=root, - ) - _finish() - if do_restart: - threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start() - - root.protocol("WM_DELETE_WINDOW", _cancel) - install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_cancel) + root.protocol("WM_DELETE_WINDOW", dialog.cancel) + install_tray_config_buttons(ctk, footer, theme, on_save=dialog.save, on_cancel=dialog.cancel) ctk_run_dialog(_build) diff --git a/macos.py b/macos.py index 3481821..57a935b 100644 --- a/macos.py +++ b/macos.py @@ -87,9 +87,9 @@ from ui.ctk_tray_ui import ( install_tray_config_form, populate_first_run_window, tray_settings_scroll_and_footer, - validate_config_form, ) -from ui.i18n import set_language, t +from ui.i18n import t +from ui.settings_dialog import SettingsDialog from utils.tray_common import ( APP_DIR, APP_NAME, @@ -362,7 +362,6 @@ def _edit_config_dialog() -> None: pady=frame_pad_y, ) scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme) - original_language = _config.get("language", DEFAULT_CONFIG["language"]) log.info("Building settings form") widgets = install_tray_config_form( ctk, @@ -371,14 +370,8 @@ def _edit_config_dialog() -> None: cfg, DEFAULT_CONFIG, show_autostart=False, - on_language_change=_refresh_tray_menu, ) log.info("Settings form built") - original_appearance = ctk.get_appearance_mode() - - def restore_ui_locale() -> None: - set_language(original_language) - _refresh_tray_menu() def finish() -> None: global _settings_window @@ -386,70 +379,20 @@ def _edit_config_dialog() -> None: _settings_window = None _hide_ctk_root() - def cancel() -> None: - ctk.set_appearance_mode(original_appearance) - restore_ui_locale() - finish() + dialog = SettingsDialog( + ctk=ctk, root=root, widgets=widgets, config=_config, + defaults=DEFAULT_CONFIG, persist=save_config, + refresh_menu=_refresh_tray_menu, finish=finish, + restart=lambda config: restart_proxy(config, _show_error), + ) - def save() -> None: - from tkinter import messagebox - - merged = validate_config_form( - widgets, - DEFAULT_CONFIG, - include_autostart=False, - ) - if isinstance(merged, str): - messagebox.showerror(t("app.error_title"), merged, parent=root) - return - - merged["force_test_dc"] = _config.get( - "force_test_dc", - DEFAULT_CONFIG["force_test_dc"], - ) - ui_only_keys = {"appearance", "check_updates", "language"} - config_changed = any(merged.get(key) != _config.get(key) for key in merged) - proxy_changed = any( - merged.get(key) != _config.get(key) - for key in merged - if key not in ui_only_keys - ) - - if not config_changed: - restore_ui_locale() - finish() - return - - save_config(merged) - _config.update(merged) - set_language(merged.get("language", DEFAULT_CONFIG["language"])) - log.info("Config saved: %s", merged) - _refresh_tray_menu() - - if not proxy_changed: - finish() - return - - do_restart = messagebox.askyesno( - t("dialog.restart_title"), - t("dialog.restart_body"), - parent=root, - ) - finish() - if do_restart: - threading.Thread( - target=lambda: restart_proxy(_config, _show_error), - daemon=True, - name="proxy-restart", - ).start() - - root.protocol("WM_DELETE_WINDOW", cancel) + root.protocol("WM_DELETE_WINDOW", dialog.cancel) install_tray_config_buttons( ctk, footer, theme, - on_save=save, - on_cancel=cancel, + on_save=dialog.save, + on_cancel=dialog.cancel, ) _activate_app() log.info("Settings window ready") diff --git a/ui/background_task.py b/ui/background_task.py new file mode 100644 index 0000000..af30b92 --- /dev/null +++ b/ui/background_task.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import logging +import queue +import threading + +log = logging.getLogger("tg-ws-tray") + + +class BackgroundTask: + def __init__(self, owner): + self.owner = owner + self.running = False + self.closed = False + self.pending = None + owner.bind("", self._on_destroy, add="+") + + def _on_destroy(self, event): + if event.widget is self.owner: + self.closed = True + if self.pending is not None: + self.owner.after_cancel(self.pending) + self.pending = None + + def start(self, work, complete, finish): + if self.running or self.closed: + return + self.running = True + results = queue.Queue() + + def run(): + try: + results.put((True, work())) + except Exception: + log.exception("Connectivity test failed") + results.put((False, None)) + + def poll(): + self.pending = None + if self.closed: + return + try: + success, result = results.get_nowait() + except queue.Empty: + self.pending = self.owner.after(50, poll) + return + self.running = False + try: + if success: + complete(result) + finally: + if not self.closed: + finish() + + threading.Thread(target=run, daemon=True, name="connectivity-test").start() + self.pending = self.owner.after(50, poll) diff --git a/ui/connectivity.py b/ui/connectivity.py new file mode 100644 index 0000000..6c3063a --- /dev/null +++ b/ui/connectivity.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import base64 +import os +import socket as _socket +from contextlib import nullcontext +from tkinter import messagebox +from typing import Any + +from proxy.utils import create_ssl_context +from ui.i18n import t + +_CFPROXY_TEST_DCS = [1, 2, 3, 4, 5, 203] +_CFWORKER_TEST_DST = { + 1: '149.154.175.50', + 2: '149.154.167.51', + 3: '149.154.175.100', + 4: '149.154.167.91', + 5: '149.154.171.5', + 203: '91.105.192.100', +} + + +def _run_connectivity_test(cases: list, *, secure: bool = True) -> dict: + ctx = create_ssl_context() if secure else None + port = 443 if secure else 80 + results = {} + for dc, connect_host, sni_host, req_host, path in cases: + try: + with _socket.create_connection((connect_host, port), timeout=5) as raw: + connection = (ctx.wrap_socket(raw, server_hostname=sni_host) + if secure else nullcontext(raw)) + with connection as ssock: + ws_key = base64.b64encode(os.urandom(16)).decode() + req = ( + f"GET {path} HTTP/1.1\r\n" + f"Host: {req_host}\r\n" + f"Upgrade: websocket\r\n" + f"Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {ws_key}\r\n" + f"Sec-WebSocket-Version: 13\r\n" + f"Sec-WebSocket-Protocol: binary\r\n" + f"\r\n" + ).encode() + ssock.sendall(req) + ssock.settimeout(5) + buf = b"" + while b"\r\n\r\n" not in buf: + chunk = ssock.recv(512) + if not chunk: + break + buf += chunk + first = buf.decode("utf-8", errors="replace").split("\r\n")[0] + if "101" in first: + results[dc] = True + else: + results[dc] = first or t("connectivity.no_response") + ssock.close() + raw.close() + except _socket.timeout: + results[dc] = t("connectivity.timeout") + except OSError as exc: + msg = str(exc) + results[dc] = msg[:60] if len(msg) > 60 else msg + return results + + +def _run_cfproxy_connectivity_test(domain: str, *, secure: bool = True) -> dict: + cases = [] + for dc in _CFPROXY_TEST_DCS: + host = f"kws{dc}.{domain}" + cases.append((dc, host, host, host, "/apiws")) + return _run_connectivity_test(cases, secure=secure) + + +def _run_cfworker_connectivity_test(domain: str, *, secure: bool = True) -> dict: + cases = [] + for dc in _CFPROXY_TEST_DCS: + dst = _CFWORKER_TEST_DST[dc] + path = f"/apiws?dst={dst}&dc={dc}&media=0" + cases.append((dc, domain, domain, domain, path)) + return _run_connectivity_test(cases, secure=secure) + + +def run_cfproxy_multi_test(domains: list, *, secure: bool = True) -> dict: + return {domain: _run_cfproxy_connectivity_test(domain, secure=secure) for domain in domains} + + +def run_cfworker_multi_test(domains: list, *, secure: bool = True) -> dict: + return {domain: _run_cfworker_connectivity_test(domain, secure=secure) for domain in domains} + + +def run_cfproxy_auto_test(domains: list, *, secure: bool = True) -> tuple: + merged: dict = {} + best_domain = None + for domain in reversed(domains): + res = _run_cfproxy_connectivity_test(domain, secure=secure) + if all(v is True for v in res.values()): + return domain, res + for dc, v in res.items(): + if v is True: + merged[dc] = True + best_domain = domain + elif dc not in merged: + merged[dc] = v + return best_domain, merged + + +def show_connectivity_results(title_base: str, results: dict, + domain: str = '', label_prefix: str = 'DC', + auto_mode: bool = False, + unavailable_message: str = '', *, parent: Any) -> None: + ok = [dc for dc, v in results.items() if v is True] + total = len(_CFPROXY_TEST_DCS) + if auto_mode: + if domain: + title = t("connectivity.available", title=title_base) + msg = t("connectivity.auto_ok", title=title_base, ok=len(ok), total=total) + else: + title = t("connectivity.unavailable", title=title_base) + msg = unavailable_message + else: + fail = [(dc, v) for dc, v in results.items() if v is not True] + if len(ok) == total: + title = t("connectivity.all_ok", title=title_base) + msg = t("connectivity.all_ok_domain", total=total, domain=domain) + elif not ok: + title = t("connectivity.unavailable", title=title_base) + errors = "\n".join( + t("connectivity.error_line", prefix=label_prefix, dc=dc, error=v) + for dc, v in fail + ) + msg = t("connectivity.none_ok", domain=domain, errors=errors) + else: + title = t("connectivity.partial", title=title_base) + ok_list = ", ".join(f"{label_prefix}{dc}" for dc in ok) + fail_list = "\n".join( + t("connectivity.error_line", prefix=label_prefix, dc=dc, error=v) + for dc, v in fail + ) + msg = t("connectivity.partial_detail", domain=domain, ok_list=ok_list, fail_list=fail_list) + + messagebox.showinfo(title, msg, parent=parent) + + +def show_multi_connectivity_results(title_base: str, per_domain: dict, + label_prefix: str = 'DC', *, parent: Any) -> None: + total = len(_CFPROXY_TEST_DCS) + all_ok = True + any_ok = False + blocks = [] + for domain, results in per_domain.items(): + ok = [dc for dc, v in results.items() if v is True] + fail = [(dc, v) for dc, v in results.items() if v is not True] + if len(ok) == total: + any_ok = True + blocks.append(t("connectivity.multi_all_ok", domain=domain, total=total)) + elif not ok: + all_ok = False + blocks.append(t("connectivity.multi_fail", domain=domain)) + else: + all_ok = False + any_ok = True + ok_list = ", ".join(f"{label_prefix}{dc}" for dc in ok) + fail_list = ", ".join(f"{label_prefix}{dc}" for dc, _ in fail) + blocks.append( + t("connectivity.multi_partial", domain=domain, ok_list=ok_list, fail_list=fail_list) + ) + + if all_ok: + title = t("connectivity.all_ok", title=title_base) + elif any_ok: + title = t("connectivity.partial", title=title_base) + else: + title = t("connectivity.unavailable", title=title_base) + msg = "\n\n".join(blocks) + + messagebox.showinfo(title, msg, parent=parent) diff --git a/ui/ctk_controls.py b/ui/ctk_controls.py new file mode 100644 index 0000000..0639699 --- /dev/null +++ b/ui/ctk_controls.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from typing import Any + +from ui.ctk_theme import CtkTheme +from ui.ctk_tooltip import attach_tooltip_to_widgets + + +def create_entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw): + opts = { + "font": (theme.ui_font_family, 13), "corner_radius": radius, + "fg_color": theme.bg, "border_color": theme.field_border, + "border_width": 1, "text_color": theme.text_primary, + } + if var is not None: + opts["textvariable"] = var + if width: + opts["width"] = width + opts["height"] = height + opts.update(kw) + return ctk.CTkEntry(parent, **opts) + + +def create_checkbox(ctk, parent, theme, text, variable): + return ctk.CTkCheckBox( + parent, text=text, variable=variable, + font=(theme.ui_font_family, 13), text_color=theme.text_primary, + fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, + corner_radius=6, border_width=2, border_color=theme.field_border, + ) + + +def create_label(ctk, parent, theme, text, *, size=12, bold=False, secondary=True, **kw): + weight = "bold" if bold else "normal" + return ctk.CTkLabel( + parent, text=text, + font=(theme.ui_font_family, size, weight), + text_color=theme.text_secondary if secondary else theme.text_primary, + anchor="w", **kw, + ) + + +def create_labeled_entry(ctk, parent, theme, label_text, value, *, tip="", width=0, pack_fill=False): + col = ctk.CTkFrame(parent, fg_color="transparent") + lbl = create_label(ctk, col, theme, label_text) + lbl.pack(anchor="w", pady=(0, 2)) + var = ctk.StringVar(master=parent, value=str(value)) + ent = create_entry(ctk, col, theme, var=var, width=width) + if pack_fill: + ent.pack(fill="x") + else: + ent.pack(anchor="w") + if tip: + attach_tooltip_to_widgets([lbl, ent, col], tip) + return col, var + + +def create_config_section( + ctk: Any, + parent: Any, + theme: CtkTheme, + title: str, + *, + bottom_spacer: int = 6, +) -> Any: + wrap = ctk.CTkFrame(parent, fg_color="transparent") + wrap.pack(fill="x", pady=(0, bottom_spacer)) + create_label(ctk, wrap, theme, title, secondary=False, bold=True).pack(anchor="w", pady=(0, 2)) + card = ctk.CTkFrame( + wrap, fg_color=theme.field_bg, corner_radius=10, + border_width=1, border_color=theme.field_border, + ) + card.pack(fill="x") + inner = ctk.CTkFrame(card, fg_color="transparent") + inner.pack(fill="x", padx=10, pady=8) + return inner + + +def create_combobox(ctk, parent, theme, *, variable, values, command=None): + return ctk.CTkComboBox( + parent, values=values, variable=variable, command=command, + height=32, font=(theme.ui_font_family, 12), + text_color=theme.text_primary, fg_color=theme.bg, + border_color=theme.field_border, button_color=theme.field_border, + button_hover_color=theme.text_secondary, + dropdown_fg_color=theme.field_bg, + dropdown_text_color=theme.text_primary, + dropdown_hover_color=theme.field_border, + corner_radius=8, state="readonly", + ) diff --git a/ui/ctk_tray_ui.py b/ui/ctk_tray_ui.py index 4adf271..9f9f581 100644 --- a/ui/ctk_tray_ui.py +++ b/ui/ctk_tray_ui.py @@ -1,305 +1,17 @@ from __future__ import annotations -import logging -import os -import webbrowser -from dataclasses import dataclass -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from typing import Any, Callable, Tuple -from proxy import __version__, get_link_host, parse_dc_ip_list, coerce_domain_list -from proxy.balancer import balancer -from utils.update_check import RELEASES_PAGE_URL, get_status - - -from ui.ctk_theme import ( - FIRST_RUN_FRAME_PAD, - CtkTheme, - main_content_frame, +from proxy import get_link_host +from ui.ctk_controls import create_checkbox +from ui.ctk_theme import FIRST_RUN_FRAME_PAD, CtkTheme, main_content_frame +from ui.ctk_tooltip import attach_ctk_tooltip +from ui.i18n import t +from ui.settings_form import ( + TrayConfigFormWidgets as TrayConfigFormWidgets, + install_tray_config_form as install_tray_config_form, + validate_config_form as validate_config_form, ) -from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets -from ui.i18n import ( - label_from_language, - language_from_label, - language_option_labels, - set_language, - t, -) - -log = logging.getLogger('tg-mtproto-proxy') - - -def _get_doc_url(doc_name: str) -> str: - from ui.i18n import get_language - lang = get_language().value - lang_folder = "EN" if lang == "en" else "RU" - return f"https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/{lang_folder}/{doc_name}.md" -_CFPROXY_TEST_DCS = [1, 2, 3, 4, 5, 203] -_CFWORKER_TEST_DST = { - 1: '149.154.175.50', - 2: '149.154.167.51', - 3: '149.154.175.100', - 4: '149.154.167.91', - 5: '149.154.171.5', - 203: '91.105.192.100', -} - - -def _run_connectivity_test(cases: list, *, secure: bool = True) -> dict: - import base64 - from contextlib import nullcontext - import socket as _socket - from proxy.utils import create_ssl_context - - ctx = create_ssl_context() if secure else None - port = 443 if secure else 80 - results = {} - for dc, connect_host, sni_host, req_host, path in cases: - try: - with _socket.create_connection((connect_host, port), timeout=5) as raw: - connection = (ctx.wrap_socket(raw, server_hostname=sni_host) - if secure else nullcontext(raw)) - with connection as ssock: - ws_key = base64.b64encode(os.urandom(16)).decode() - req = ( - f"GET {path} HTTP/1.1\r\n" - f"Host: {req_host}\r\n" - f"Upgrade: websocket\r\n" - f"Connection: Upgrade\r\n" - f"Sec-WebSocket-Key: {ws_key}\r\n" - f"Sec-WebSocket-Version: 13\r\n" - f"Sec-WebSocket-Protocol: binary\r\n" - f"\r\n" - ).encode() - ssock.sendall(req) - ssock.settimeout(5) - buf = b"" - while b"\r\n\r\n" not in buf: - chunk = ssock.recv(512) - if not chunk: - break - buf += chunk - first = buf.decode("utf-8", errors="replace").split("\r\n")[0] - if "101" in first: - results[dc] = True - else: - results[dc] = first or t("connectivity.no_response") - ssock.close() - raw.close() - except _socket.timeout: - results[dc] = t("connectivity.timeout") - except OSError as exc: - msg = str(exc) - results[dc] = msg[:60] if len(msg) > 60 else msg - return results - - -def _run_cfproxy_connectivity_test(domain: str, *, secure: bool = True) -> dict: - cases = [] - for dc in _CFPROXY_TEST_DCS: - host = f"kws{dc}.{domain}" - cases.append((dc, host, host, host, "/apiws")) - return _run_connectivity_test(cases, secure=secure) - - -def _run_cfworker_connectivity_test(domain: str, *, secure: bool = True) -> dict: - cases = [] - for dc in _CFPROXY_TEST_DCS: - dst = _CFWORKER_TEST_DST[dc] - path = f"/apiws?dst={dst}&dc={dc}&media=0" - cases.append((dc, domain, domain, domain, path)) - return _run_connectivity_test(cases, secure=secure) - - -def _run_cfproxy_multi_test(domains: list, *, secure: bool = True) -> dict: - return {domain: _run_cfproxy_connectivity_test(domain, secure=secure) for domain in domains} - - -def _run_cfworker_multi_test(domains: list, *, secure: bool = True) -> dict: - return {domain: _run_cfworker_connectivity_test(domain, secure=secure) for domain in domains} - - -def _run_cfproxy_auto_test(domains: list, *, secure: bool = True) -> tuple: - merged: dict = {} - best_domain = None - for domain in reversed(domains): - res = _run_cfproxy_connectivity_test(domain, secure=secure) - if all(v is True for v in res.values()): - return domain, res - for dc, v in res.items(): - if v is True: - merged[dc] = True - best_domain = domain - elif dc not in merged: - merged[dc] = v - return best_domain, merged - - -def _show_connectivity_results(title_base: str, results: dict, - domain: str = '', label_prefix: str = 'DC', - auto_mode: bool = False, - unavailable_message: str = '') -> None: - import tkinter as _tk - from tkinter import messagebox as _mb - - ok = [dc for dc, v in results.items() if v is True] - total = len(_CFPROXY_TEST_DCS) - if auto_mode: - if domain: - title = t("connectivity.available", title=title_base) - msg = t("connectivity.auto_ok", title=title_base, ok=len(ok), total=total) - else: - title = t("connectivity.unavailable", title=title_base) - msg = unavailable_message - else: - fail = [(dc, v) for dc, v in results.items() if v is not True] - if len(ok) == total: - title = t("connectivity.all_ok", title=title_base) - msg = t("connectivity.all_ok_domain", total=total, domain=domain) - elif not ok: - title = t("connectivity.unavailable", title=title_base) - errors = "\n".join( - t("connectivity.error_line", prefix=label_prefix, dc=dc, error=v) - for dc, v in fail - ) - msg = t("connectivity.none_ok", domain=domain, errors=errors) - else: - title = t("connectivity.partial", title=title_base) - ok_list = ", ".join(f"{label_prefix}{dc}" for dc in ok) - fail_list = "\n".join( - t("connectivity.error_line", prefix=label_prefix, dc=dc, error=v) - for dc, v in fail - ) - msg = t("connectivity.partial_detail", domain=domain, ok_list=ok_list, fail_list=fail_list) - - root = _tk.Tk() - root.withdraw() - try: - root.attributes("-topmost", True) - except Exception: - pass - _mb.showinfo(title, msg, parent=root) - root.destroy() - - -def _show_multi_connectivity_results(title_base: str, per_domain: dict, - label_prefix: str = 'DC') -> None: - import tkinter as _tk - from tkinter import messagebox as _mb - - total = len(_CFPROXY_TEST_DCS) - all_ok = True - any_ok = False - blocks = [] - for domain, results in per_domain.items(): - ok = [dc for dc, v in results.items() if v is True] - fail = [(dc, v) for dc, v in results.items() if v is not True] - if len(ok) == total: - any_ok = True - blocks.append(t("connectivity.multi_all_ok", domain=domain, total=total)) - elif not ok: - all_ok = False - blocks.append(t("connectivity.multi_fail", domain=domain)) - else: - all_ok = False - any_ok = True - ok_list = ", ".join(f"{label_prefix}{dc}" for dc in ok) - fail_list = ", ".join(f"{label_prefix}{dc}" for dc, _ in fail) - blocks.append( - t("connectivity.multi_partial", domain=domain, ok_list=ok_list, fail_list=fail_list) - ) - - if all_ok: - title = t("connectivity.all_ok", title=title_base) - elif any_ok: - title = t("connectivity.partial", title=title_base) - else: - title = t("connectivity.unavailable", title=title_base) - msg = "\n\n".join(blocks) - - root = _tk.Tk() - root.withdraw() - try: - root.attributes("-topmost", True) - except Exception: - pass - _mb.showinfo(title, msg, parent=root) - root.destroy() - -_INNER_W = 396 - -_APPEARANCE_KEYS = ("auto", "light", "dark") -_APPEARANCE_TO_CTK = {"auto": "system", "light": "Light", "dark": "Dark"} - - -def _appearance_options() -> List[str]: - return [t(f"appearance.{key}") for key in _APPEARANCE_KEYS] - - -def _appearance_from_cfg(value: str) -> str: - if value in _APPEARANCE_KEYS: - return t(f"appearance.{value}") - return t("appearance.auto") - - -def _appearance_to_cfg(label: str) -> str: - for key in _APPEARANCE_KEYS: - if t(f"appearance.{key}") == label: - return key - return "auto" - - -def _sync_language_combobox(combo: Any, var: Any, cfg_value: str) -> None: - combo.configure(values=[label for _, label in language_option_labels()]) - var.set(label_from_language(cfg_value)) - - -def _entry(ctk, parent, theme, *, var=None, width=0, height=36, radius=10, **kw): - opts = { - "font": (theme.ui_font_family, 13), "corner_radius": radius, - "fg_color": theme.bg, "border_color": theme.field_border, - "border_width": 1, "text_color": theme.text_primary, - } - if var is not None: - opts["textvariable"] = var - if width: - opts["width"] = width - opts["height"] = height - opts.update(kw) - return ctk.CTkEntry(parent, **opts) - - -def _checkbox(ctk, parent, theme, text, variable): - return ctk.CTkCheckBox( - parent, text=text, variable=variable, - font=(theme.ui_font_family, 13), text_color=theme.text_primary, - fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, - corner_radius=6, border_width=2, border_color=theme.field_border, - ) - - -def _label(ctk, parent, theme, text, *, size=12, bold=False, secondary=True, **kw): - weight = "bold" if bold else "normal" - return ctk.CTkLabel( - parent, text=text, - font=(theme.ui_font_family, size, weight), - text_color=theme.text_secondary if secondary else theme.text_primary, - anchor="w", **kw, - ) - - -def _labeled_entry(ctk, parent, theme, label_text, value, *, tip="", width=0, pack_fill=False): - col = ctk.CTkFrame(parent, fg_color="transparent") - lbl = _label(ctk, col, theme, label_text) - lbl.pack(anchor="w", pady=(0, 2)) - var = ctk.StringVar(value=str(value)) - ent = _entry(ctk, col, theme, var=var, width=width) - if pack_fill: - ent.pack(fill="x") - else: - ent.pack(anchor="w") - if tip: - attach_tooltip_to_widgets([lbl, ent, col], tip) - return col, var def tray_settings_scroll_and_footer( @@ -324,632 +36,6 @@ def tray_settings_scroll_and_footer( return scroll, footer -def _config_section( - ctk: Any, - parent: Any, - theme: CtkTheme, - title: str, - *, - bottom_spacer: int = 6, -) -> Any: - wrap = ctk.CTkFrame(parent, fg_color="transparent") - wrap.pack(fill="x", pady=(0, bottom_spacer)) - _label(ctk, wrap, theme, title, secondary=False, bold=True).pack(anchor="w", pady=(0, 2)) - card = ctk.CTkFrame( - wrap, fg_color=theme.field_bg, corner_radius=10, - border_width=1, border_color=theme.field_border, - ) - card.pack(fill="x") - inner = ctk.CTkFrame(card, fg_color="transparent") - inner.pack(fill="x", padx=10, pady=8) - return inner - - -@dataclass -class TrayConfigFormWidgets: - host_var: Any - port_var: Any - secret_var: Any - dc_textbox: Any - verbose_var: Any - no_secure_var: Any - adv_entries: List[Any] - adv_keys: Tuple[str, ...] - autostart_var: Optional[Any] - check_updates_var: Optional[Any] - cfproxy_var: Optional[Any] = None - cfproxy_user_domain_enabled_var: Optional[Any] = None - cfproxy_user_domain_var: Optional[Any] = None - cfproxy_worker_enabled_var: Optional[Any] = None - cfproxy_worker_domain_var: Optional[Any] = None - appearance_var: Optional[Any] = None - language_var: Optional[Any] = None - - -def install_tray_config_form( - ctk: Any, - frame: Any, - theme: CtkTheme, - cfg: dict, - default_config: dict, - *, - show_autostart: bool = False, - autostart_value: bool = False, - on_language_change: Optional[Callable[[], None]] = None, - on_update_click: Optional[Callable[[], None]] = None, -) -> TrayConfigFormWidgets: - lang_cfg = cfg.get("language", default_config["language"]) - set_language(lang_cfg) - - header = ctk.CTkFrame(frame, fg_color="transparent") - header.pack(fill="x", pady=(0, 2)) - ctk.CTkLabel( - header, text=t("settings.title"), - font=(theme.ui_font_family, 17, "bold"), - text_color=theme.text_primary, anchor="w", - ).pack(side="left") - ctk.CTkLabel( - header, text=f"v{__version__}", - font=(theme.ui_font_family, 12), - text_color=theme.text_secondary, anchor="e", - ).pack(side="right", padx=(4, 0)) - - appearance_var = ctk.StringVar( - value=_appearance_from_cfg(cfg.get("appearance", "auto")) - ) - - def _on_appearance_change(choice: str) -> None: - cfg_val = _appearance_to_cfg(choice) - ctk.set_appearance_mode(_APPEARANCE_TO_CTK[cfg_val]) - cfg["appearance"] = cfg_val - - ctk.CTkButton( - header, text="Donate ♥", width=90, height=28, - font=(theme.ui_font_family, 13, "bold"), corner_radius=8, - fg_color="#22c55e", hover_color="#16a34a", - text_color="#ffffff", border_width=0, - command=lambda: ( - header.winfo_toplevel().iconify(), - webbrowser.open(_get_doc_url("Funding")), - ), - ).pack(side="right", padx=(0, 6)) - - ui_inner = _config_section(ctk, frame, theme, t("section.interface")) - ui_row = ctk.CTkFrame(ui_inner, fg_color="transparent") - ui_row.pack(fill="x") - - lang_col = ctk.CTkFrame(ui_row, fg_color="transparent") - lang_col.pack(side="left", fill="x", expand=True, padx=(0, 8)) - - theme_col = ctk.CTkFrame(ui_row, fg_color="transparent") - theme_col.pack(side="left", fill="x", expand=True, padx=(8, 0)) - - language_var = ctk.StringVar(value=label_from_language(lang_cfg)) - _label(ctk, lang_col, theme, t("settings.language"), size=11).pack( - anchor="w", pady=(0, 2) - ) - language_combo = ctk.CTkComboBox( - lang_col, - values=[label for _, label in language_option_labels()], - variable=language_var, - height=32, - font=(theme.ui_font_family, 12), - text_color=theme.text_primary, - fg_color=theme.bg, - border_color=theme.field_border, - button_color=theme.field_border, - button_hover_color=theme.text_secondary, - dropdown_fg_color=theme.field_bg, - dropdown_text_color=theme.text_primary, - dropdown_hover_color=theme.field_border, - corner_radius=8, - state="readonly", - ) - language_combo.pack(fill="x") - _sync_language_combobox(language_combo, language_var, lang_cfg) - - _label(ctk, theme_col, theme, t("settings.theme"), size=11).pack( - anchor="w", pady=(0, 2) - ) - theme_combo = ctk.CTkComboBox( - theme_col, - values=_appearance_options(), - variable=appearance_var, - height=32, - font=(theme.ui_font_family, 12), - text_color=theme.text_primary, - fg_color=theme.bg, - border_color=theme.field_border, - button_color=theme.field_border, - button_hover_color=theme.text_secondary, - dropdown_fg_color=theme.field_bg, - dropdown_text_color=theme.text_primary, - dropdown_hover_color=theme.field_border, - corner_radius=8, - state="readonly", - command=_on_appearance_change, - ) - theme_combo.pack(fill="x") - - conn = _config_section(ctk, frame, theme, t("section.mtproto")) - - host_row = ctk.CTkFrame(conn, fg_color="transparent") - host_row.pack(fill="x") - - host_col, host_var = _labeled_entry( - ctk, host_row, theme, t("label.host"), - cfg.get("host", default_config["host"]), - tip=t("tip.host"), width=160, pack_fill=True, - ) - host_col.pack(side="left", fill="x", expand=True, padx=(0, 10)) - - port_col, port_var = _labeled_entry( - ctk, host_row, theme, t("label.port"), - cfg.get("port", default_config["port"]), - tip=t("tip.port"), width=100, - ) - port_col.pack(side="left") - - secret_row = ctk.CTkFrame(conn, fg_color="transparent") - secret_row.pack(fill="x") - - secret_col, secret_var = _labeled_entry( - ctk, secret_row, theme, t("label.secret"), - cfg.get("secret", default_config["secret"]), - tip=t("tip.secret"), width=160, pack_fill=True, - ) - secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10)) - - regen_col = ctk.CTkFrame(secret_row, fg_color="transparent") - regen_col.pack(side="left", anchor="s") - ctk.CTkLabel(regen_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2)) - ctk.CTkButton( - regen_col, text="↺", width=36, height=36, - font=(theme.ui_font_family, 18), corner_radius=10, - fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, - text_color="#ffffff", border_width=1, border_color=theme.field_border, - command=lambda: secret_var.set(os.urandom(16).hex()), - ).pack() - - dc_inner = _config_section(ctk, frame, theme, t("section.dc")) - dc_lbl = _label(ctk, dc_inner, theme, t("label.dc_hint"), size=11) - dc_lbl.pack(anchor="w", pady=(0, 4)) - dc_textbox = ctk.CTkTextbox( - dc_inner, width=_INNER_W, height=88, - font=(theme.mono_font_family, 12), corner_radius=10, - fg_color=theme.bg, border_color=theme.field_border, - border_width=1, text_color=theme.text_primary, - ) - dc_textbox.pack(fill="x") - dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"]))) - attach_tooltip_to_widgets([dc_lbl, dc_textbox], t("tip.dc")) - - cf_inner = _config_section(ctk, frame, theme, t("section.cfproxy")) - - cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent") - cf_row.pack(fill="x", pady=(0, 4)) - - cfproxy_var = ctk.BooleanVar( - value=cfg.get("cfproxy", default_config.get("cfproxy", True)) - ) - cf_cb = _checkbox(ctk, cf_row, theme, t("label.cf_enable"), cfproxy_var) - cf_cb.pack(side="left", padx=(0, 16)) - attach_ctk_tooltip(cf_cb, t("tip.cfproxy")) - - _cf_test_btn = [None] - - def _on_cf_test(): - secure = not no_secure_var.get() - user_domains = ( - coerce_domain_list(cfproxy_user_domain_var.get()) - if cf_custom_cb_var.get() else [] - ) - btn = _cf_test_btn[0] - if btn: - btn.configure(text=t("button.test_loading"), state="disabled") - import threading as _threading - if user_domains: - def _worker(): - try: - per = _run_cfproxy_multi_test(user_domains, secure=secure) - if btn: - btn.after( - 0, - lambda: _show_multi_connectivity_results( - t("connectivity.cfproxy_title"), per, label_prefix='kws', - ), - ) - except Exception as exc: - log.error("CF proxy test failed: %s", exc) - finally: - if btn: - btn.after(0, lambda: btn.configure(text=t("button.test"), state="normal")) - _threading.Thread(target=_worker, daemon=True).start() - else: - def _worker_auto(): - try: - ok_domain, res = _run_cfproxy_auto_test(balancer.domains, secure=secure) - if btn: - btn.after( - 0, - lambda: _show_connectivity_results( - t("connectivity.cfproxy_title"), res, - domain=ok_domain or '', - auto_mode=True, - unavailable_message=t("connectivity.cf_auto_fail"), - ), - ) - except Exception as exc: - log.error("CF proxy auto-test failed: %s", exc) - finally: - if btn: - btn.after(0, lambda: btn.configure(text=t("button.test"), state="normal")) - _threading.Thread(target=_worker_auto, daemon=True).start() - - _cf_test_widget = ctk.CTkButton( - cf_row, text=t("button.test"), width=56, height=28, - font=(theme.ui_font_family, 13), corner_radius=8, - fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, - text_color="#ffffff", border_width=1, border_color=theme.field_border, - command=_on_cf_test, - ) - _cf_test_widget.pack(side="right") - _cf_test_btn[0] = _cf_test_widget - - cf_custom_row = ctk.CTkFrame(cf_inner, fg_color="transparent") - cf_custom_row.pack(fill="x") - - saved_user_domains = coerce_domain_list( - cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", "")) - ) - cf_custom_cb_var = ctk.BooleanVar( - value=cfg.get("cfproxy_user_domain_enabled", bool(saved_user_domains)) - ) - cf_custom_cb = _checkbox(ctk, cf_custom_row, theme, t("label.cf_custom_domain"), cf_custom_cb_var) - cf_custom_cb.pack(side="left", padx=(0, 10)) - attach_ctk_tooltip(cf_custom_cb, t("tip.cfproxy_user_domain_cb")) - - ctk.CTkButton( - cf_custom_row, text="?", width=28, height=32, - font=(theme.ui_font_family, 14), corner_radius=8, - fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, - text_color="#ffffff", border_width=1, border_color=theme.field_border, - command=lambda: webbrowser.open(_get_doc_url("CfProxy")), - ).pack(side="right") - - cfproxy_user_domain_var = ctk.StringVar(value=", ".join(saved_user_domains)) - cf_domain_entry = _entry( - ctk, cf_custom_row, theme, var=cfproxy_user_domain_var, - height=32, radius=8, - ) - cf_domain_entry.pack(side="left", fill="x", expand=True, padx=(0, 6)) - attach_ctk_tooltip(cf_domain_entry, t("tip.cfproxy_domain")) - - def _sync_domain_entry(*_): - state = "normal" if cf_custom_cb_var.get() else "disabled" - cf_domain_entry.configure(state=state) - - cf_custom_cb_var.trace_add("write", _sync_domain_entry) - _sync_domain_entry() - - cf_worker_inner = _config_section(ctk, frame, theme, t("section.cfworker")) - - cf_worker_row = ctk.CTkFrame(cf_worker_inner, fg_color="transparent") - cf_worker_row.pack(fill="x", pady=(0, 4)) - cf_worker_lbl = _label(ctk, cf_worker_row, theme, t("label.cfworker_domains"), size=11) - cf_worker_lbl.pack(side="left", anchor="w", pady=(0, 2)) - - cf_worker_input = ctk.CTkFrame(cf_worker_inner, fg_color="transparent") - cf_worker_input.pack(fill="x") - - saved_worker_domains = coerce_domain_list( - cfg.get("cfproxy_worker_domain", default_config.get("cfproxy_worker_domain", "")) - ) - cfproxy_worker_enabled_var = ctk.BooleanVar( - value=cfg.get("cfproxy_worker_enabled", bool(saved_worker_domains)) - ) - cf_worker_cb = _checkbox( - ctk, cf_worker_input, theme, t("label.cf_custom_domain"), - cfproxy_worker_enabled_var, - ) - cf_worker_cb.pack(side="left", padx=(0, 10)) - attach_ctk_tooltip(cf_worker_cb, t("tip.cfworker_domain")) - - cfproxy_worker_domain_var = ctk.StringVar(value=", ".join(saved_worker_domains)) - cf_worker_entry = _entry( - ctk, cf_worker_input, theme, var=cfproxy_worker_domain_var, - height=32, radius=8, - ) - cf_worker_entry.pack(side="left", fill="x", expand=True, padx=(0, 6)) - attach_tooltip_to_widgets([cf_worker_lbl, cf_worker_entry], t("tip.cfworker_domain")) - - _cfworker_test_btn = [None] - - def _sync_cfworker_test_button(*_): - btn = _cfworker_test_btn[0] - if btn is None: - return - enabled = ( - cfproxy_worker_enabled_var.get() - and bool(coerce_domain_list(cfproxy_worker_domain_var.get())) - ) - btn.configure(state="normal" if enabled else "disabled") - - def _on_cfworker_test(): - secure = not no_secure_var.get() - domains = coerce_domain_list(cfproxy_worker_domain_var.get()) - btn = _cfworker_test_btn[0] - if not cfproxy_worker_enabled_var.get() or not domains or btn is None: - return - btn.configure(text=t("button.test_loading"), state="disabled") - import threading as _threading - - def _worker(): - try: - per = _run_cfworker_multi_test(domains, secure=secure) - btn.after( - 0, - lambda: _show_multi_connectivity_results( - t("connectivity.cfworker_title"), per, label_prefix='DC', - ), - ) - except Exception as exc: - log.error("CF worker test failed: %s", exc) - finally: - btn.after(0, lambda: btn.configure(text=t("button.test"))) - btn.after(0, _sync_cfworker_test_button) - - _threading.Thread(target=_worker, daemon=True).start() - - ctk.CTkButton( - cf_worker_input, text="?", width=28, height=32, - font=(theme.ui_font_family, 14), corner_radius=8, - fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, - text_color="#ffffff", border_width=1, border_color=theme.field_border, - command=lambda: webbrowser.open(_get_doc_url("CfWorker")), - ).pack(side="right") - - _cfworker_test_widget = ctk.CTkButton( - cf_worker_row, text=t("button.test"), width=56, height=28, - font=(theme.ui_font_family, 13), corner_radius=8, - fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, - text_color="#ffffff", border_width=1, border_color=theme.field_border, - command=_on_cfworker_test, - ) - _cfworker_test_widget.pack(side="right") - _cfworker_test_btn[0] = _cfworker_test_widget - - def _sync_cfworker_entry(*_): - state = "normal" if cfproxy_worker_enabled_var.get() else "disabled" - cf_worker_entry.configure(state=state) - _sync_cfworker_test_button() - - cfproxy_worker_enabled_var.trace_add("write", _sync_cfworker_entry) - cfproxy_worker_domain_var.trace_add("write", _sync_cfworker_test_button) - _sync_cfworker_entry() - - log_inner = _config_section(ctk, frame, theme, t("section.logs")) - - verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False)) - verbose_cb = _checkbox(ctk, log_inner, theme, t("label.verbose"), verbose_var) - verbose_cb.pack(anchor="w", pady=(0, 6)) - attach_ctk_tooltip(verbose_cb, t("tip.verbose")) - - no_secure_var = ctk.BooleanVar(value=cfg.get("no_secure", False)) - no_secure_cb = _checkbox(ctk, log_inner, theme, t("label.no_secure"), no_secure_var) - no_secure_cb.pack(anchor="w", pady=(0, 6)) - attach_ctk_tooltip(no_secure_cb, t("tip.no_secure")) - - adv_frame = ctk.CTkFrame(log_inner, fg_color="transparent") - adv_frame.pack(fill="x") - - adv_rows = [ - (t("label.buf_kb"), "buf_kb", t("tip.buf_kb")), - (t("label.pool_size"), "pool_size", t("tip.pool")), - (t("label.log_max_mb"), "log_max_mb", t("tip.log_mb")), - ] - for label_text, key, tip in adv_rows: - col = ctk.CTkFrame(adv_frame, fg_color="transparent") - col.pack(fill="x", pady=(0, 0 if key == "log_max_mb" else 5)) - adv_l = _label(ctk, col, theme, label_text, size=11) - adv_l.pack(anchor="w", pady=(0, 2)) - adv_e = _entry( - ctk, col, theme, width=_INNER_W, height=32, radius=8, - textvariable=ctk.StringVar(value=str(cfg.get(key, default_config[key]))), - ) - adv_e.pack(fill="x") - attach_tooltip_to_widgets([adv_l, adv_e, col], tip) - - adv_entries = list(adv_frame.winfo_children()) - adv_keys = ("buf_kb", "pool_size", "log_max_mb") - - upd_inner = _config_section(ctk, frame, theme, t("section.updates")) - st = get_status() - check_updates_var = ctk.BooleanVar( - value=bool(cfg.get("check_updates", default_config.get("check_updates", True))) - ) - upd_cb = _checkbox(ctk, upd_inner, theme, t("label.check_updates"), check_updates_var) - upd_cb.pack(anchor="w", pady=(0, 6)) - attach_ctk_tooltip(upd_cb, t("tip.check_updates")) - - if st.get("error"): - upd_status = t("updates.status_error") - elif not st.get("checked"): - upd_status = t("updates.status_pending") - elif st.get("has_update") and st.get("latest"): - upd_status = t("updates.status_available", latest=st["latest"], current=__version__) - elif st.get("ahead_of_release") and st.get("latest"): - upd_status = t("updates.status_ahead", current=__version__, latest=st["latest"]) - else: - upd_status = t("updates.status_latest") - - _label(ctk, upd_inner, theme, upd_status, size=11, - justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8)) - - rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL - if st.get("has_update") and on_update_click is not None: - upd_btn_row = ctk.CTkFrame(upd_inner, fg_color="transparent") - upd_btn_row.pack(fill="x") - upd_btn_row.grid_columnconfigure(0, weight=1) - upd_btn_row.grid_columnconfigure(1, weight=1) - ctk.CTkButton( - upd_btn_row, text=t("button.open_release"), height=32, - font=(theme.ui_font_family, 13), corner_radius=8, - fg_color=theme.field_bg, hover_color=theme.field_border, - text_color=theme.text_primary, border_width=1, - border_color=theme.field_border, - command=lambda u=rel_url: webbrowser.open(u), - ).grid(row=0, column=0, sticky="ew", padx=(0, 4)) - ctk.CTkButton( - upd_btn_row, text=t("button.update"), height=32, - font=(theme.ui_font_family, 13, "bold"), corner_radius=8, - fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, - text_color="#ffffff", - command=on_update_click, - ).grid(row=0, column=1, sticky="ew", padx=(4, 0)) - else: - ctk.CTkButton( - upd_inner, text=t("button.open_release"), height=32, - font=(theme.ui_font_family, 13), corner_radius=8, - fg_color=theme.field_bg, hover_color=theme.field_border, - text_color=theme.text_primary, border_width=1, - border_color=theme.field_border, - command=lambda u=rel_url: webbrowser.open(u), - ).pack(anchor="w") - - autostart_var = None - if show_autostart: - sys_inner = _config_section(ctk, frame, theme, t("section.windows_startup"), bottom_spacer=4) - autostart_var = ctk.BooleanVar(value=autostart_value) - as_cb = _checkbox(ctk, sys_inner, theme, t("label.autostart"), autostart_var) - as_cb.pack(anchor="w", pady=(0, 4)) - as_hint = _label( - ctk, sys_inner, theme, - t("label.autostart_hint"), - size=11, justify="left", wraplength=_INNER_W, - ) - as_hint.pack(anchor="w") - attach_tooltip_to_widgets([as_cb, as_hint], t("tip.autostart")) - - return TrayConfigFormWidgets( - host_var=host_var, port_var=port_var, secret_var=secret_var, - dc_textbox=dc_textbox, verbose_var=verbose_var, no_secure_var=no_secure_var, - adv_entries=adv_entries, adv_keys=adv_keys, - autostart_var=autostart_var, check_updates_var=check_updates_var, - cfproxy_var=cfproxy_var, - cfproxy_user_domain_enabled_var=cf_custom_cb_var, - cfproxy_user_domain_var=cfproxy_user_domain_var, - cfproxy_worker_enabled_var=cfproxy_worker_enabled_var, - cfproxy_worker_domain_var=cfproxy_worker_domain_var, - appearance_var=appearance_var, - language_var=language_var, - ) - - -def merge_adv_from_form( - widgets: TrayConfigFormWidgets, - base: Dict[str, Any], - default_config: dict, -) -> None: - for i, key in enumerate(widgets.adv_keys): - col_frame = widgets.adv_entries[i] - entry = col_frame.winfo_children()[1] - try: - val = float(entry.get().strip()) - if key in ("buf_kb", "pool_size"): - val = int(val) - base[key] = val - except ValueError: - base[key] = default_config[key] - - -def _dc_validation_message(error: ValueError) -> str: - exc_entry = getattr(error, "entry", None) - if exc_entry is None: - return str(error) - kind = getattr(error, "kind", "invalid") - if kind == "format": - return t("validation.dc_format", entry=exc_entry) - return t("validation.dc_invalid", entry=exc_entry) - - -def validate_config_form( - widgets: TrayConfigFormWidgets, - default_config: dict, - *, - include_autostart: bool, -) -> Union[dict, str]: - import socket as _sock - - host_val = widgets.host_var.get().strip() - try: - _sock.inet_aton(host_val) - except OSError: - return t("validation.bad_host") - - try: - port_val = int(widgets.port_var.get().strip()) - if not (1 <= port_val <= 65535): - raise ValueError - except ValueError: - return t("validation.bad_port") - - lines = [ - line.strip() - for line in widgets.dc_textbox.get("1.0", "end").strip().splitlines() - if line.strip() - ] - try: - parse_dc_ip_list(lines) - except ValueError as e: - return _dc_validation_message(e) - - secret_val = widgets.secret_var.get().strip() - if len(secret_val) != 32: - return t("validation.bad_secret_len") - try: - bytes.fromhex(secret_val) - except ValueError: - return t("validation.bad_secret_hex") - - new_cfg: Dict[str, Any] = { - "host": host_val, - "port": port_val, - "secret": secret_val, - "dc_ip": lines, - "verbose": widgets.verbose_var.get(), - } - if include_autostart: - new_cfg["autostart"] = ( - widgets.autostart_var.get() - if widgets.autostart_var is not None - else False - ) - - merge_adv_from_form(widgets, new_cfg, default_config) - if widgets.check_updates_var is not None: - new_cfg["check_updates"] = bool(widgets.check_updates_var.get()) - if widgets.cfproxy_var is not None: - new_cfg["cfproxy"] = bool(widgets.cfproxy_var.get()) - if widgets.cfproxy_user_domain_enabled_var is not None: - new_cfg["cfproxy_user_domain_enabled"] = bool( - widgets.cfproxy_user_domain_enabled_var.get() - ) - if widgets.cfproxy_user_domain_var is not None: - new_cfg["cfproxy_user_domain"] = coerce_domain_list(widgets.cfproxy_user_domain_var.get()) - if widgets.cfproxy_worker_enabled_var is not None: - new_cfg["cfproxy_worker_enabled"] = bool(widgets.cfproxy_worker_enabled_var.get()) - if widgets.cfproxy_worker_domain_var is not None: - new_cfg["cfproxy_worker_domain"] = coerce_domain_list(widgets.cfproxy_worker_domain_var.get()) - if widgets.appearance_var is not None: - new_cfg["appearance"] = _appearance_to_cfg(widgets.appearance_var.get()) - if widgets.language_var is not None: - new_cfg["language"] = language_from_label(widgets.language_var.get()).value - if widgets.no_secure_var is not None: - new_cfg["no_secure"] = bool(widgets.no_secure_var.get()) - return new_cfg - - def install_tray_config_buttons( ctk: Any, frame: Any, @@ -1051,7 +137,7 @@ def populate_first_run_window( corner_radius=0).pack(fill="x", pady=(0, 12)) auto_var = ctk.BooleanVar(value=True) - _checkbox(ctk, frame, theme, t("first_run.open_now"), + create_checkbox(ctk, frame, theme, t("first_run.open_now"), auto_var).pack(anchor="w", pady=(0, 16)) def on_ok(): diff --git a/ui/settings.py b/ui/settings.py new file mode 100644 index 0000000..e325453 --- /dev/null +++ b/ui/settings.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import math +import socket +from copy import deepcopy +from dataclasses import dataclass +from typing import Union + +from proxy import coerce_domain_list, parse_dc_ip_list +from ui.i18n import t + +UI_ONLY_KEYS = frozenset({ + "appearance", "autostart", "check_updates", "language" +}) + + +@dataclass(frozen=True) +class SettingsChange: + config: dict + changed_keys: frozenset + + @property + def requires_restart(self) -> bool: + return bool(self.changed_keys - UI_ONLY_KEYS) + + +def prepare_settings(current: dict, values: dict, defaults: dict) -> SettingsChange: + config = deepcopy(defaults) + config.update(deepcopy(current)) + changed_keys = frozenset( + key for key, value in values.items() if value != config.get(key) + ) + config.update(deepcopy(values)) + return SettingsChange(config, changed_keys) + + +def validate_settings(values: dict, defaults: dict) -> Union[dict, str]: + config = dict(values) + + host = values["host"].strip() + try: + socket.inet_aton(host) + except OSError: + return t("validation.bad_host") + + try: + port = int(values["port"].strip()) + if not 1 <= port <= 65535: + raise ValueError + except ValueError: + return t("validation.bad_port") + + lines = [line.strip() for line in values["dc_ip"].splitlines() if line.strip()] + try: + parse_dc_ip_list(lines) + except ValueError as exc: + entry = getattr(exc, "entry", None) + if entry is None: + return str(exc) + key = "dc_format" if getattr(exc, "kind", "invalid") == "format" else "dc_invalid" + return t(f"validation.{key}", entry=entry) + + secret = values["secret"].strip() + if len(secret) != 32: + return t("validation.bad_secret_len") + if any(char not in "0123456789abcdefABCDEF" for char in secret): + return t("validation.bad_secret_hex") + + config.update(host=host, port=port, secret=secret, dc_ip=lines) + + for key in ("buf_kb", "pool_size", "log_max_mb"): + try: + value = float(values[key].strip()) + if not math.isfinite(value): + raise ValueError + config[key] = int(value) if key != "log_max_mb" else value + except (ValueError, OverflowError): + config[key] = defaults[key] + + for key in ("cfproxy_user_domain", "cfproxy_worker_domain"): + if key in config: + config[key] = coerce_domain_list(config[key]) + + return config diff --git a/ui/settings_dialog.py b/ui/settings_dialog.py new file mode 100644 index 0000000..9865ee9 --- /dev/null +++ b/ui/settings_dialog.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import logging +import threading +from copy import deepcopy + +from tkinter import messagebox + +from ui.settings_form import validate_config_form +from ui.i18n import set_language, t +from ui.settings import prepare_settings + +log = logging.getLogger("tg-ws-tray") + + +class SettingsDialog: + def __init__( + self, *, ctk, root, widgets, config, defaults, persist, + refresh_menu, finish, restart, include_autostart=False, + apply_autostart=None, + ): + self.ctk = ctk + self.root = root + self.widgets = widgets + self.config = config + self.defaults = defaults + self.persist = persist + self.refresh_menu = refresh_menu + self.finish = finish + self.restart = restart + self.include_autostart = include_autostart + self.apply_autostart = apply_autostart + self.closed = False + + def cancel(self): + if self.closed: + return + mode = self.config.get("appearance", "auto") + self.ctk.set_appearance_mode("system" if mode == "auto" else mode) + set_language(self.config.get("language", self.defaults["language"])) + self.refresh_menu() + self._finish() + + def _finish(self): + self.closed = True + self.finish() + + def save(self): + if self.closed: + return + values = validate_config_form( + self.widgets, self.defaults, include_autostart=self.include_autostart, + ) + if isinstance(values, str): + messagebox.showerror(t("app.error_title"), values, parent=self.root) + return + change = prepare_settings(self.config, values, self.defaults) + if not change.changed_keys: + self._finish() + return + try: + self.persist(change.config) + except (OSError, ValueError, TypeError) as exc: + log.exception("Failed to save settings") + messagebox.showerror(t("app.error_title"), str(exc), parent=self.root) + return + self.config.update(deepcopy(change.config)) + set_language(change.config["language"]) + log.info("Settings saved: %s", ", ".join(sorted(change.changed_keys))) + if self.apply_autostart is not None: + self.apply_autostart(bool(change.config.get("autostart", False))) + self.refresh_menu() + do_restart = change.requires_restart and messagebox.askyesno( + t("dialog.restart_title"), t("dialog.restart_body"), parent=self.root, + ) + self._finish() + if do_restart: + threading.Thread( + target=self.restart, args=(deepcopy(change.config),), + daemon=True, name="proxy-restart", + ).start() diff --git a/ui/settings_form.py b/ui/settings_form.py new file mode 100644 index 0000000..94331c9 --- /dev/null +++ b/ui/settings_form.py @@ -0,0 +1,578 @@ +from __future__ import annotations + +import os +import webbrowser +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Optional, Union + +from proxy import __version__, coerce_domain_list +from proxy.balancer import balancer +from utils.update_check import RELEASES_PAGE_URL, get_status +from ui.background_task import BackgroundTask +from ui.connectivity import ( + run_cfproxy_multi_test, run_cfproxy_auto_test, run_cfworker_multi_test, + show_connectivity_results, show_multi_connectivity_results, +) +from ui.ctk_controls import ( + create_entry, create_checkbox, create_label, create_labeled_entry, + create_config_section, create_combobox, +) +from ui.ctk_theme import CtkTheme +from ui.ctk_tooltip import attach_ctk_tooltip, attach_tooltip_to_widgets +from ui.settings import validate_settings +from ui.i18n import ( + label_from_language, language_from_label, language_option_labels, + set_language, get_language, t, +) + +_INNER_W = 396 +_APPEARANCE_KEYS = ("auto", "light", "dark") +_APPEARANCE_TO_CTK = {"auto": "system", "light": "Light", "dark": "Dark"} + + +def _get_doc_url(doc_name: str) -> str: + lang = get_language().value + lang_folder = "EN" if lang == "en" else "RU" + return f"https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/{lang_folder}/{doc_name}.md" + + +def _appearance_options() -> List[str]: + return [t(f"appearance.{key}") for key in _APPEARANCE_KEYS] + + +def _appearance_from_cfg(value: str) -> str: + if value in _APPEARANCE_KEYS: + return t(f"appearance.{value}") + return t("appearance.auto") + + +def _appearance_to_cfg(label: str) -> str: + for key in _APPEARANCE_KEYS: + if t(f"appearance.{key}") == label: + return key + return "auto" + + +@dataclass +class TrayConfigFormWidgets: + host_var: Any + port_var: Any + secret_var: Any + dc_textbox: Any + verbose_var: Any + no_secure_var: Any + advanced_vars: Dict[str, Any] + autostart_var: Optional[Any] + check_updates_var: Optional[Any] + cfproxy_var: Optional[Any] = None + cfproxy_user_domain_enabled_var: Optional[Any] = None + cfproxy_user_domain_var: Optional[Any] = None + cfproxy_worker_enabled_var: Optional[Any] = None + cfproxy_worker_domain_var: Optional[Any] = None + appearance_var: Optional[Any] = None + language_var: Optional[Any] = None + + +def install_tray_config_form( + ctk: Any, + frame: Any, + theme: CtkTheme, + cfg: dict, + default_config: dict, + *, + show_autostart: bool = False, + autostart_value: bool = False, + on_update_click: Optional[Callable[[], None]] = None, +) -> TrayConfigFormWidgets: + set_language(cfg.get("language", default_config["language"])) + no_secure_var = ctk.BooleanVar(master=frame, value=cfg.get("no_secure", False)) + + _create_header(ctk, frame, theme) + appearance_var, language_var = _create_interface(ctk, frame, theme, cfg, default_config) + host_var, port_var, secret_var = _create_connection(ctk, frame, theme, cfg, default_config) + dc_textbox = _create_dc(ctk, frame, theme, cfg, default_config) + cfproxy_var, cf_custom_cb_var, cfproxy_user_domain_var = _create_cfproxy( + ctk, frame, theme, cfg, default_config, no_secure_var, + ) + cfproxy_worker_enabled_var, cfproxy_worker_domain_var = _create_cfworker( + ctk, frame, theme, cfg, default_config, no_secure_var, + ) + verbose_var, advanced_vars = _create_logging( + ctk, frame, theme, cfg, default_config, no_secure_var, + ) + check_updates_var = _create_updates(ctk, frame, theme, cfg, default_config, on_update_click) + autostart_var = _create_autostart(ctk, frame, theme, show_autostart, autostart_value) + + return TrayConfigFormWidgets( + host_var=host_var, port_var=port_var, secret_var=secret_var, + dc_textbox=dc_textbox, verbose_var=verbose_var, no_secure_var=no_secure_var, + advanced_vars=advanced_vars, + autostart_var=autostart_var, check_updates_var=check_updates_var, + cfproxy_var=cfproxy_var, + cfproxy_user_domain_enabled_var=cf_custom_cb_var, + cfproxy_user_domain_var=cfproxy_user_domain_var, + cfproxy_worker_enabled_var=cfproxy_worker_enabled_var, + cfproxy_worker_domain_var=cfproxy_worker_domain_var, + appearance_var=appearance_var, + language_var=language_var, + ) + + +def _create_header(ctk, frame, theme): + header = ctk.CTkFrame(frame, fg_color="transparent") + header.pack(fill="x", pady=(0, 2)) + ctk.CTkLabel( + header, text=t("settings.title"), + font=(theme.ui_font_family, 17, "bold"), + text_color=theme.text_primary, anchor="w", + ).pack(side="left") + ctk.CTkLabel( + header, text=f"v{__version__}", + font=(theme.ui_font_family, 12), + text_color=theme.text_secondary, anchor="e", + ).pack(side="right", padx=(4, 0)) + + ctk.CTkButton( + header, text="Donate ♥", width=90, height=28, + font=(theme.ui_font_family, 13, "bold"), corner_radius=8, + fg_color="#22c55e", hover_color="#16a34a", + text_color="#ffffff", border_width=0, + command=lambda: ( + header.winfo_toplevel().iconify(), + webbrowser.open(_get_doc_url("Funding")), + ), + ).pack(side="right", padx=(0, 6)) + + +def _create_interface(ctk, frame, theme, cfg, default_config): + lang_cfg = cfg.get("language", default_config["language"]) + appearance_var = ctk.StringVar( + master=frame, value=_appearance_from_cfg(cfg.get("appearance", "auto")) + ) + + def _on_appearance_change(choice: str) -> None: + cfg_val = _appearance_to_cfg(choice) + ctk.set_appearance_mode(_APPEARANCE_TO_CTK[cfg_val]) + + ui_inner = create_config_section(ctk, frame, theme, t("section.interface")) + ui_row = ctk.CTkFrame(ui_inner, fg_color="transparent") + ui_row.pack(fill="x") + + lang_col = ctk.CTkFrame(ui_row, fg_color="transparent") + lang_col.pack(side="left", fill="x", expand=True, padx=(0, 8)) + + theme_col = ctk.CTkFrame(ui_row, fg_color="transparent") + theme_col.pack(side="left", fill="x", expand=True, padx=(8, 0)) + + language_var = ctk.StringVar(master=frame, value=label_from_language(lang_cfg)) + create_label(ctk, lang_col, theme, t("settings.language"), size=11).pack( + anchor="w", pady=(0, 2) + ) + create_combobox( + ctk, lang_col, theme, variable=language_var, + values=[label for _, label in language_option_labels()], + ).pack(fill="x") + create_label(ctk, theme_col, theme, t("settings.theme"), size=11).pack( + anchor="w", pady=(0, 2) + ) + create_combobox( + ctk, theme_col, theme, variable=appearance_var, + values=_appearance_options(), command=_on_appearance_change, + ).pack(fill="x") + return appearance_var, language_var + + +def _create_connection(ctk, frame, theme, cfg, default_config): + conn = create_config_section(ctk, frame, theme, t("section.mtproto")) + + host_row = ctk.CTkFrame(conn, fg_color="transparent") + host_row.pack(fill="x") + + host_col, host_var = create_labeled_entry( + ctk, host_row, theme, t("label.host"), + cfg.get("host", default_config["host"]), + tip=t("tip.host"), width=160, pack_fill=True, + ) + host_col.pack(side="left", fill="x", expand=True, padx=(0, 10)) + + port_col, port_var = create_labeled_entry( + ctk, host_row, theme, t("label.port"), + cfg.get("port", default_config["port"]), + tip=t("tip.port"), width=100, + ) + port_col.pack(side="left") + + secret_row = ctk.CTkFrame(conn, fg_color="transparent") + secret_row.pack(fill="x") + + secret_col, secret_var = create_labeled_entry( + ctk, secret_row, theme, t("label.secret"), + cfg.get("secret", default_config["secret"]), + tip=t("tip.secret"), width=160, pack_fill=True, + ) + secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10)) + + regen_col = ctk.CTkFrame(secret_row, fg_color="transparent") + regen_col.pack(side="left", anchor="s") + ctk.CTkLabel(regen_col, text="", font=(theme.ui_font_family, 12)).pack(pady=(0, 2)) + ctk.CTkButton( + regen_col, text="↺", width=36, height=36, + font=(theme.ui_font_family, 18), corner_radius=10, + fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, + text_color="#ffffff", border_width=1, border_color=theme.field_border, + command=lambda: secret_var.set(os.urandom(16).hex()), + ).pack() + return host_var, port_var, secret_var + + +def _create_dc(ctk, frame, theme, cfg, default_config): + dc_inner = create_config_section(ctk, frame, theme, t("section.dc")) + dc_lbl = create_label(ctk, dc_inner, theme, t("label.dc_hint"), size=11) + dc_lbl.pack(anchor="w", pady=(0, 4)) + dc_textbox = ctk.CTkTextbox( + dc_inner, width=_INNER_W, height=88, + font=(theme.mono_font_family, 12), corner_radius=10, + fg_color=theme.bg, border_color=theme.field_border, + border_width=1, text_color=theme.text_primary, + ) + dc_textbox.pack(fill="x") + dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", default_config["dc_ip"]))) + attach_tooltip_to_widgets([dc_lbl, dc_textbox], t("tip.dc")) + return dc_textbox + + +def _create_cfproxy(ctk, frame, theme, cfg, default_config, no_secure_var): + cf_inner = create_config_section(ctk, frame, theme, t("section.cfproxy")) + + cf_row = ctk.CTkFrame(cf_inner, fg_color="transparent") + cf_row.pack(fill="x", pady=(0, 4)) + + cfproxy_var = ctk.BooleanVar( + master=frame, value=cfg.get("cfproxy", default_config.get("cfproxy", True)) + ) + cf_cb = create_checkbox(ctk, cf_row, theme, t("label.cf_enable"), cfproxy_var) + cf_cb.pack(side="left", padx=(0, 16)) + attach_ctk_tooltip(cf_cb, t("tip.cfproxy")) + + cf_test = BackgroundTask(frame.winfo_toplevel()) + + def _on_cf_test(): + secure = not no_secure_var.get() + user_domains = ( + coerce_domain_list(cfproxy_user_domain_var.get()) + if cf_custom_cb_var.get() else [] + ) + if cf_test.running: + return + _cf_test_widget.configure(text=t("button.test_loading"), state="disabled") + + def finish(): + _cf_test_widget.configure(text=t("button.test"), state="normal") + + if user_domains: + cf_test.start( + lambda: run_cfproxy_multi_test(user_domains, secure=secure), + lambda per: show_multi_connectivity_results( + t("connectivity.cfproxy_title"), per, label_prefix="kws", + parent=frame.winfo_toplevel(), + ), + finish, + ) + else: + domains = list(balancer.domains) + cf_test.start( + lambda: run_cfproxy_auto_test(domains, secure=secure), + lambda result: show_connectivity_results( + t("connectivity.cfproxy_title"), result[1], + domain=result[0] or "", auto_mode=True, + unavailable_message=t("connectivity.cf_auto_fail"), + parent=frame.winfo_toplevel(), + ), + finish, + ) + + _cf_test_widget = ctk.CTkButton( + cf_row, text=t("button.test"), width=56, height=28, + font=(theme.ui_font_family, 13), corner_radius=8, + fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, + text_color="#ffffff", border_width=1, border_color=theme.field_border, + command=_on_cf_test, + ) + _cf_test_widget.pack(side="right") + + cf_custom_row = ctk.CTkFrame(cf_inner, fg_color="transparent") + cf_custom_row.pack(fill="x") + + saved_user_domains = coerce_domain_list( + cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", "")) + ) + cf_custom_cb_var = ctk.BooleanVar( + master=frame, value=cfg.get("cfproxy_user_domain_enabled", bool(saved_user_domains)) + ) + cf_custom_cb = create_checkbox(ctk, cf_custom_row, theme, t("label.cf_custom_domain"), cf_custom_cb_var) + cf_custom_cb.pack(side="left", padx=(0, 10)) + attach_ctk_tooltip(cf_custom_cb, t("tip.cfproxy_user_domain_cb")) + + ctk.CTkButton( + cf_custom_row, text="?", width=28, height=32, + font=(theme.ui_font_family, 14), corner_radius=8, + fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, + text_color="#ffffff", border_width=1, border_color=theme.field_border, + command=lambda: webbrowser.open(_get_doc_url("CfProxy")), + ).pack(side="right") + + cfproxy_user_domain_var = ctk.StringVar(master=frame, value=", ".join(saved_user_domains)) + cf_domain_entry = create_entry( + ctk, cf_custom_row, theme, var=cfproxy_user_domain_var, + height=32, radius=8, + ) + cf_domain_entry.pack(side="left", fill="x", expand=True, padx=(0, 6)) + attach_ctk_tooltip(cf_domain_entry, t("tip.cfproxy_domain")) + + def _sync_domain_entry(*_): + state = "normal" if cf_custom_cb_var.get() else "disabled" + cf_domain_entry.configure(state=state) + + cf_custom_cb_var.trace_add("write", _sync_domain_entry) + _sync_domain_entry() + return cfproxy_var, cf_custom_cb_var, cfproxy_user_domain_var + + +def _create_cfworker(ctk, frame, theme, cfg, default_config, no_secure_var): + cf_worker_inner = create_config_section(ctk, frame, theme, t("section.cfworker")) + + cf_worker_row = ctk.CTkFrame(cf_worker_inner, fg_color="transparent") + cf_worker_row.pack(fill="x", pady=(0, 4)) + cf_worker_lbl = create_label(ctk, cf_worker_row, theme, t("label.cfworker_domains"), size=11) + cf_worker_lbl.pack(side="left", anchor="w", pady=(0, 2)) + + cf_worker_input = ctk.CTkFrame(cf_worker_inner, fg_color="transparent") + cf_worker_input.pack(fill="x") + + saved_worker_domains = coerce_domain_list( + cfg.get("cfproxy_worker_domain", default_config.get("cfproxy_worker_domain", "")) + ) + cfproxy_worker_enabled_var = ctk.BooleanVar( + master=frame, value=cfg.get("cfproxy_worker_enabled", bool(saved_worker_domains)) + ) + cf_worker_cb = create_checkbox( + ctk, cf_worker_input, theme, t("label.cf_custom_domain"), + cfproxy_worker_enabled_var, + ) + cf_worker_cb.pack(side="left", padx=(0, 10)) + attach_ctk_tooltip(cf_worker_cb, t("tip.cfworker_domain")) + + cfproxy_worker_domain_var = ctk.StringVar(master=frame, value=", ".join(saved_worker_domains)) + cf_worker_entry = create_entry( + ctk, cf_worker_input, theme, var=cfproxy_worker_domain_var, + height=32, radius=8, + ) + cf_worker_entry.pack(side="left", fill="x", expand=True, padx=(0, 6)) + attach_tooltip_to_widgets([cf_worker_lbl, cf_worker_entry], t("tip.cfworker_domain")) + + worker_test = BackgroundTask(frame.winfo_toplevel()) + + def _sync_cfworker_test_button(*_): + btn = _cfworker_test_widget + enabled = ( + not worker_test.running and cfproxy_worker_enabled_var.get() + and bool(coerce_domain_list(cfproxy_worker_domain_var.get())) + ) + btn.configure(state="normal" if enabled else "disabled") + + def _on_cfworker_test(): + secure = not no_secure_var.get() + domains = coerce_domain_list(cfproxy_worker_domain_var.get()) + if worker_test.running or not cfproxy_worker_enabled_var.get() or not domains: + return + _cfworker_test_widget.configure(text=t("button.test_loading"), state="disabled") + + def finish(): + _cfworker_test_widget.configure(text=t("button.test")) + _sync_cfworker_test_button() + + worker_test.start( + lambda: run_cfworker_multi_test(domains, secure=secure), + lambda per: show_multi_connectivity_results( + t("connectivity.cfworker_title"), per, label_prefix="DC", + parent=frame.winfo_toplevel(), + ), + finish, + ) + + ctk.CTkButton( + cf_worker_input, text="?", width=28, height=32, + font=(theme.ui_font_family, 14), corner_radius=8, + fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, + text_color="#ffffff", border_width=1, border_color=theme.field_border, + command=lambda: webbrowser.open(_get_doc_url("CfWorker")), + ).pack(side="right") + + _cfworker_test_widget = ctk.CTkButton( + cf_worker_row, text=t("button.test"), width=56, height=28, + font=(theme.ui_font_family, 13), corner_radius=8, + fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, + text_color="#ffffff", border_width=1, border_color=theme.field_border, + command=_on_cfworker_test, + ) + _cfworker_test_widget.pack(side="right") + + def _sync_cfworker_entry(*_): + state = "normal" if cfproxy_worker_enabled_var.get() else "disabled" + cf_worker_entry.configure(state=state) + _sync_cfworker_test_button() + + cfproxy_worker_enabled_var.trace_add("write", _sync_cfworker_entry) + cfproxy_worker_domain_var.trace_add("write", _sync_cfworker_test_button) + _sync_cfworker_entry() + return cfproxy_worker_enabled_var, cfproxy_worker_domain_var + + +def _create_logging(ctk, frame, theme, cfg, default_config, no_secure_var): + log_inner = create_config_section(ctk, frame, theme, t("section.logs")) + + verbose_var = ctk.BooleanVar(master=frame, value=cfg.get("verbose", False)) + verbose_cb = create_checkbox(ctk, log_inner, theme, t("label.verbose"), verbose_var) + verbose_cb.pack(anchor="w", pady=(0, 6)) + attach_ctk_tooltip(verbose_cb, t("tip.verbose")) + + no_secure_cb = create_checkbox(ctk, log_inner, theme, t("label.no_secure"), no_secure_var) + no_secure_cb.pack(anchor="w", pady=(0, 6)) + attach_ctk_tooltip(no_secure_cb, t("tip.no_secure")) + + adv_frame = ctk.CTkFrame(log_inner, fg_color="transparent") + adv_frame.pack(fill="x") + + adv_rows = [ + (t("label.buf_kb"), "buf_kb", t("tip.buf_kb")), + (t("label.pool_size"), "pool_size", t("tip.pool")), + (t("label.log_max_mb"), "log_max_mb", t("tip.log_mb")), + ] + advanced_vars = {} + for label_text, key, tip in adv_rows: + col = ctk.CTkFrame(adv_frame, fg_color="transparent") + col.pack(fill="x", pady=(0, 0 if key == "log_max_mb" else 5)) + adv_l = create_label(ctk, col, theme, label_text, size=11) + adv_l.pack(anchor="w", pady=(0, 2)) + advanced_vars[key] = ctk.StringVar(master=frame, value=str(cfg.get(key, default_config[key]))) + adv_e = create_entry( + ctk, col, theme, width=_INNER_W, height=32, radius=8, + var=advanced_vars[key], + ) + adv_e.pack(fill="x") + attach_tooltip_to_widgets([adv_l, adv_e, col], tip) + return verbose_var, advanced_vars + + +def _create_updates(ctk, frame, theme, cfg, default_config, on_update_click): + upd_inner = create_config_section(ctk, frame, theme, t("section.updates")) + st = get_status() + check_updates_var = ctk.BooleanVar( + master=frame, value=bool(cfg.get("check_updates", default_config.get("check_updates", True))) + ) + upd_cb = create_checkbox(ctk, upd_inner, theme, t("label.check_updates"), check_updates_var) + upd_cb.pack(anchor="w", pady=(0, 6)) + attach_ctk_tooltip(upd_cb, t("tip.check_updates")) + + if st.get("error"): + upd_status = t("updates.status_error") + elif not st.get("checked"): + upd_status = t("updates.status_pending") + elif st.get("has_update") and st.get("latest"): + upd_status = t("updates.status_available", latest=st["latest"], current=__version__) + elif st.get("ahead_of_release") and st.get("latest"): + upd_status = t("updates.status_ahead", current=__version__, latest=st["latest"]) + else: + upd_status = t("updates.status_latest") + + create_label(ctk, upd_inner, theme, upd_status, size=11, + justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8)) + + rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL + if st.get("has_update") and on_update_click is not None: + upd_btn_row = ctk.CTkFrame(upd_inner, fg_color="transparent") + upd_btn_row.pack(fill="x") + upd_btn_row.grid_columnconfigure(0, weight=1) + upd_btn_row.grid_columnconfigure(1, weight=1) + ctk.CTkButton( + upd_btn_row, text=t("button.open_release"), height=32, + font=(theme.ui_font_family, 13), corner_radius=8, + fg_color=theme.field_bg, hover_color=theme.field_border, + text_color=theme.text_primary, border_width=1, + border_color=theme.field_border, + command=lambda u=rel_url: webbrowser.open(u), + ).grid(row=0, column=0, sticky="ew", padx=(0, 4)) + ctk.CTkButton( + upd_btn_row, text=t("button.update"), height=32, + font=(theme.ui_font_family, 13, "bold"), corner_radius=8, + fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, + text_color="#ffffff", + command=on_update_click, + ).grid(row=0, column=1, sticky="ew", padx=(4, 0)) + else: + ctk.CTkButton( + upd_inner, text=t("button.open_release"), height=32, + font=(theme.ui_font_family, 13), corner_radius=8, + fg_color=theme.field_bg, hover_color=theme.field_border, + text_color=theme.text_primary, border_width=1, + border_color=theme.field_border, + command=lambda u=rel_url: webbrowser.open(u), + ).pack(anchor="w") + return check_updates_var + + +def _create_autostart(ctk, frame, theme, show_autostart, autostart_value): + autostart_var = None + if show_autostart: + sys_inner = create_config_section(ctk, frame, theme, t("section.windows_startup"), bottom_spacer=4) + autostart_var = ctk.BooleanVar(master=frame, value=autostart_value) + as_cb = create_checkbox(ctk, sys_inner, theme, t("label.autostart"), autostart_var) + as_cb.pack(anchor="w", pady=(0, 4)) + as_hint = create_label( + ctk, sys_inner, theme, + t("label.autostart_hint"), + size=11, justify="left", wraplength=_INNER_W, + ) + as_hint.pack(anchor="w") + attach_tooltip_to_widgets([as_cb, as_hint], t("tip.autostart")) + return autostart_var + + +def validate_config_form( + widgets: TrayConfigFormWidgets, + default_config: dict, + *, + include_autostart: bool, +) -> Union[dict, str]: + values = { + "host": widgets.host_var.get(), + "port": widgets.port_var.get(), + "secret": widgets.secret_var.get(), + "dc_ip": widgets.dc_textbox.get("1.0", "end"), + "verbose": bool(widgets.verbose_var.get()), + } + for key, var in widgets.advanced_vars.items(): + values[key] = var.get() + for key in ( + "check_updates", "cfproxy", "cfproxy_user_domain_enabled", + "cfproxy_worker_enabled", "no_secure", + ): + var = getattr(widgets, f"{key}_var") + if var is not None: + values[key] = bool(var.get()) + for key in ("cfproxy_user_domain", "cfproxy_worker_domain"): + var = getattr(widgets, f"{key}_var") + if var is not None: + values[key] = var.get() + if include_autostart: + values["autostart"] = ( + bool(widgets.autostart_var.get()) + if widgets.autostart_var is not None else False + ) + if widgets.appearance_var is not None: + values["appearance"] = _appearance_to_cfg(widgets.appearance_var.get()) + if widgets.language_var is not None: + values["language"] = language_from_label(widgets.language_var.get()).value + return validate_settings(values, default_config) + + diff --git a/utils/default_config.py b/utils/default_config.py index ab50496..73526f1 100644 --- a/utils/default_config.py +++ b/utils/default_config.py @@ -7,6 +7,7 @@ from __future__ import annotations import sys import os from typing import Any, Dict +from copy import deepcopy from ui.i18n import detect_system_language @@ -26,11 +27,12 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = { "cfproxy_worker_domain": [], "force_test_dc": False, "no_secure": False, + "appearance": "auto", } def default_tray_config() -> Dict[str, Any]: - cfg = dict(_TRAY_DEFAULTS_COMMON) + cfg = deepcopy(_TRAY_DEFAULTS_COMMON) cfg["secret"] = os.urandom(16).hex() cfg["language"] = detect_system_language().value diff --git a/utils/tray_common.py b/utils/tray_common.py index 2970d3d..fdb50aa 100644 --- a/utils/tray_common.py +++ b/utils/tray_common.py @@ -11,6 +11,7 @@ import threading import time from pathlib import Path from typing import Any, Callable, Dict, Optional, Tuple +from copy import deepcopy import psutil @@ -211,7 +212,7 @@ def load_config() -> dict: except Exception as exc: log.warning("Failed to load config: %s", repr(exc)) if cfg is None: - cfg = dict(DEFAULT_CONFIG) + cfg = deepcopy(DEFAULT_CONFIG) _apply_ui_language(cfg) return cfg diff --git a/windows.py b/windows.py index a0a5a7e..f70efb4 100644 --- a/windows.py +++ b/windows.py @@ -53,13 +53,13 @@ from utils.update_check import ( from ui.ctk_tray_ui import ( install_tray_config_buttons, install_tray_config_form, populate_first_run_window, tray_settings_scroll_and_footer, - validate_config_form, ) from ui.ctk_theme import ( CONFIG_DIALOG_FRAME_PAD, CONFIG_DIALOG_SIZE, FIRST_RUN_SIZE, create_ctk_toplevel, ctk_theme_for_platform, main_content_frame, ) -from ui.i18n import set_language, t +from ui.i18n import t +from ui.settings_dialog import SettingsDialog _tray_icon: Optional[object] = None _config: dict = {} @@ -500,8 +500,6 @@ def _edit_config_dialog() -> None: cfg = dict(_config) cfg["autostart"] = is_autostart_enabled() - if _supports_autostart() and not cfg["autostart"]: - set_autostart_enabled(False) def _build(done: threading.Event) -> None: theme = ctk_theme_for_platform() @@ -521,72 +519,28 @@ def _edit_config_dialog() -> None: if _tray_icon is not None: _tray_icon.menu = _build_menu() - _original_language = _config.get("language", DEFAULT_CONFIG["language"]) - widgets = install_tray_config_form( ctk, scroll, theme, cfg, DEFAULT_CONFIG, show_autostart=_supports_autostart(), autostart_value=cfg.get("autostart", False), - on_language_change=_refresh_tray_menu, on_update_click=_trigger_update_flow, ) - _original_appearance = ctk.get_appearance_mode() - - def _restore_ui_locale() -> None: - set_language(_original_language) - _refresh_tray_menu() - def _finish() -> None: root.destroy() done.set() - def _cancel() -> None: - ctk.set_appearance_mode(_original_appearance) - _restore_ui_locale() - _finish() + dialog = SettingsDialog( + ctk=ctk, root=root, widgets=widgets, config=_config, + defaults=DEFAULT_CONFIG, persist=save_config, + refresh_menu=_refresh_tray_menu, finish=_finish, + restart=lambda config: restart_proxy(config, _show_error), + include_autostart=_supports_autostart(), + apply_autostart=set_autostart_enabled if _supports_autostart() else None, + ) - def on_save() -> None: - from tkinter import messagebox - merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=_supports_autostart()) - if isinstance(merged, str): - messagebox.showerror(t("app.error_title"), merged, parent=root) - return - - merged["force_test_dc"] = _config.get("force_test_dc", DEFAULT_CONFIG["force_test_dc"]) - - _ui_only_keys = {"appearance", "autostart", "check_updates", "language"} - config_changed = any(merged.get(k) != cfg.get(k) for k in merged) - proxy_changed = any(merged.get(k) != _config.get(k) for k in merged if k not in _ui_only_keys) - - if not config_changed: - _restore_ui_locale() - _finish() - return - - save_config(merged) - _config.update(merged) - set_language(merged.get("language", DEFAULT_CONFIG["language"])) - log.info("Config saved: %s", merged) - if _supports_autostart(): - set_autostart_enabled(bool(merged.get("autostart", False))) - _tray_icon.menu = _build_menu() - - if not proxy_changed: - _finish() - return - - do_restart = messagebox.askyesno( - t("dialog.restart_title"), - t("dialog.restart_body"), - parent=root, - ) - _finish() - if do_restart: - threading.Thread(target=lambda: restart_proxy(_config, _show_error), daemon=True).start() - - root.protocol("WM_DELETE_WINDOW", _cancel) - install_tray_config_buttons(ctk, footer, theme, on_save=on_save, on_cancel=_cancel) + root.protocol("WM_DELETE_WINDOW", dialog.cancel) + install_tray_config_buttons(ctk, footer, theme, on_save=dialog.save, on_cancel=dialog.cancel) ctk_run_dialog(_build)