From 85b5e7f22a237e82420f95e75a0346d939ab97f1 Mon Sep 17 00:00:00 2001 From: Kirill <43275267+rozkomnadzor@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:41:49 +0300 Subject: [PATCH] feature: i18n (#1025) --- linux.py | 77 +++++--- ui/ctk_tray_ui.py | 400 +++++++++++++++++++++------------------- ui/i18n/__init__.py | 164 ++++++++++++++++ ui/i18n/en.json | 148 +++++++++++++++ ui/i18n/ru.json | 148 +++++++++++++++ utils/default_config.py | 5 +- utils/diagnostics.py | 31 +--- utils/tray_common.py | 37 ++-- windows.py | 109 ++++++----- 9 files changed, 810 insertions(+), 309 deletions(-) create mode 100644 ui/i18n/__init__.py create mode 100644 ui/i18n/en.json create mode 100644 ui/i18n/ru.json diff --git a/linux.py b/linux.py index 8c2843e..46b4d83 100644 --- a/linux.py +++ b/linux.py @@ -30,6 +30,7 @@ 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 _tray_icon: Optional[object] = None _config: dict = {} @@ -53,16 +54,16 @@ def _msgbox(kind: str, text: str, title: str, **kw): return result -def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None: - _msgbox("showerror", text, title) +def _show_error(text: str, title: Optional[str] = None) -> None: + _msgbox("showerror", text, title or t("app.error_title")) -def _show_info(text: str, title: str = "TG WS Proxy") -> None: - _msgbox("showinfo", text, title) +def _show_info(text: str, title: Optional[str] = None) -> None: + _msgbox("showinfo", text, title or t("app.name")) -def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: - return bool(_msgbox("askyesno", text, title)) +def _ask_yes_no(text: str, title: Optional[str] = None) -> bool: + return bool(_msgbox("askyesno", text, title or t("app.name"))) def _apply_window_icon(root) -> None: @@ -80,12 +81,10 @@ def _on_open_in_telegram(icon=None, item=None) -> None: log.info("Copying %s", url) try: pyperclip.copy(url) - _show_info( - f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}" - ) + _show_info(t("dialog.copy_ok", url=url)) except Exception as exc: log.error("Clipboard copy failed: %s", exc) - _show_error(f"Не удалось скопировать ссылку:\n{exc}") + _show_error(t("dialog.copy_fail", error=exc)) def _on_copy_link(icon=None, item=None) -> None: @@ -95,7 +94,7 @@ def _on_copy_link(icon=None, item=None) -> None: pyperclip.copy(url) except Exception as exc: log.error("Clipboard copy failed: %s", exc) - _show_error(f"Не удалось скопировать ссылку:\n{exc}") + _show_error(t("dialog.copy_fail", error=exc)) def _on_restart(icon=None, item=None) -> None: @@ -118,7 +117,7 @@ def _on_open_logs(icon=None, item=None) -> None: stdin=subprocess.DEVNULL, start_new_session=True, ) else: - _show_info("Файл логов ещё не создан.") + _show_info(t("dialog.log_not_found")) def _on_exit(icon=None, item=None) -> None: @@ -139,7 +138,7 @@ def _on_exit(icon=None, item=None) -> None: def _edit_config_dialog() -> None: if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")): - _show_error("customtkinter не установлен.") + _show_error(t("dialog.ctk_missing")) return cfg = dict(_config) @@ -148,41 +147,59 @@ def _edit_config_dialog() -> None: theme = ctk_theme_for_platform() w, h = CONFIG_DIALOG_SIZE root = create_ctk_toplevel( - ctk, title="TG WS Proxy — Настройки", width=w, height=h, theme=theme, + ctk, title=t("app.settings_title"), width=w, height=h, theme=theme, after_create=_apply_window_icon, ) fpx, fpy = CONFIG_DIALOG_FRAME_PAD frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme) - widgets = install_tray_config_form(ctk, scroll, theme, cfg, DEFAULT_CONFIG, show_autostart=False) + + def _refresh_tray_menu() -> 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() def on_save() -> None: from tkinter import messagebox merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=False) if isinstance(merged, str): - messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root) + messagebox.showerror(t("app.error_title"), merged, parent=root) return - _ui_only_keys = {"appearance", "check_updates"} - config_changed = any(merged.get(k) != cfg.get(k) for k in merged) - proxy_changed = any(merged.get(k) != cfg.get(k) for k in merged if k not in _ui_only_keys) + _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() @@ -191,8 +208,8 @@ def _edit_config_dialog() -> None: return do_restart = messagebox.askyesno( - "Перезапустить?", - "Настройки сохранены.\n\nПерезапустить прокси сейчас?", + t("dialog.restart_title"), + t("dialog.restart_body"), parent=root, ) _finish() @@ -224,7 +241,7 @@ def _show_first_run() -> None: theme = ctk_theme_for_platform() w, h = FIRST_RUN_SIZE root = create_ctk_toplevel( - ctk, title="TG WS Proxy", width=w, height=h, theme=theme, + ctk, title=t("app.name"), width=w, height=h, theme=theme, after_create=_apply_window_icon, ) @@ -248,14 +265,14 @@ def _build_menu(): port = _config.get("port", DEFAULT_CONFIG["port"]) link_host = get_link_host(host) return pystray.Menu( - pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True), - pystray.MenuItem("Скопировать ссылку", _on_copy_link), + pystray.MenuItem(t("tray.open_telegram", host=link_host, port=port), _on_open_in_telegram, default=True), + pystray.MenuItem(t("tray.copy_link"), _on_copy_link), pystray.Menu.SEPARATOR, - pystray.MenuItem("Перезапустить прокси", _on_restart), - pystray.MenuItem("Настройки...", _on_edit_config), - pystray.MenuItem("Открыть логи", _on_open_logs), + pystray.MenuItem(t("tray.restart"), _on_restart), + pystray.MenuItem(t("tray.settings"), _on_edit_config), + pystray.MenuItem(t("tray.logs"), _on_open_logs), pystray.Menu.SEPARATOR, - pystray.MenuItem("Выход", _on_exit), + pystray.MenuItem(t("tray.exit"), _on_exit), ) @@ -283,7 +300,7 @@ def run_tray() -> None: _show_first_run() check_ipv6_warning(_show_info) - _tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu()) + _tray_icon = pystray.Icon(APP_NAME, load_icon(), t("app.name"), menu=_build_menu()) log.info("Tray icon running") _tray_icon.run() @@ -293,7 +310,7 @@ def run_tray() -> None: def main() -> None: if not acquire_lock(): - _show_info("Приложение уже запущено.", os.path.basename(sys.argv[0])) + _show_info(t("dialog.already_running"), os.path.basename(sys.argv[0])) return try: run_tray() diff --git a/ui/ctk_tray_ui.py b/ui/ctk_tray_ui.py index 4df2e8e..21a887c 100644 --- a/ui/ctk_tray_ui.py +++ b/ui/ctk_tray_ui.py @@ -17,63 +17,16 @@ from ui.ctk_theme import ( main_content_frame, ) 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') -_TIP_HOST = ( - "Адрес, на котором прокси принимает подключения.\n" - "Обычно 127.0.0.1 — локальная сеть, 0.0.0.0 - все интерфейсы" -) -_TIP_PORT = ( - "Порт прокси. В Telegram Desktop в настройках прокси должен быть " - "указан тот же порт" -) -_TIP_SECRET = "Секретный ключ для авторизации клиентов" -_TIP_DC = ( - "Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\n" - "Каждая строка: «номер:IP», например 4:149.154.167.220. " - "Прокси по этим правилам направляет трафик к нужным серверам Telegram\n\n" - "Если у вас не работают медиа и работает CF-прокси, то попробуйте убрать строку 2:149.154.167.220" -) -_TIP_VERBOSE = ( - "Если включено, в файл логов пишется больше подробностей — " - "необходимо при поиске неполадок" -) -_TIP_BUF_KB = ( - "Размер буфера приёма/передачи в килобайтах.\n" - "Больше значение — больше выделение памяти на сокет" -) -_TIP_POOL = ( - "Сколько параллельных WebSocket-сессий к одному датацентру можно держать.\n" - "Увеличение может помочь при высокой нагрузке" -) -_TIP_LOG_MB = ( - "Максимальный размер файла лога; при достижении лимита файл перезаписывается" -) -_TIP_AUTOSTART = ( - "Запускать TG WS Proxy при входе в Windows. " - "Если вы переместите программу в другую папку, автозапуск сбросится" -) -_TIP_CHECK_UPDATES = "При запуске проверять наличие обновлений" -_TIP_CFPROXY = ( - "Использовать Cloudflare прокси для недоступных датацентров" -) -_TIP_CFPROXY_DOMAIN = ( - "Ваши собственные домены, проксируемые через Cloudflare, для WS-подключения.\n" - "Несколько доменов указывайте через запятую.\n" - "Если не указаны — выбираются автоматически из поддерживаемых доменов" -) -_TIP_CFPROXY_USER_DOMAIN_CB = ( - "Указать свои домены вместо автоматического выбора" -) -_TIP_CFWORKER_DOMAIN = ( - "Домены Cloudflare Worker (например, name.account.workers.dev).\n" - "Несколько доменов указывайте через запятую.\n" - "Прокси передает через них подключение к Telegram DC по IP" -) -_TIP_SAVE = "Сохранить настройки" -_TIP_CANCEL = "Закрыть окно без сохранения изменений" - _CFPROXY_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfProxy.md" _CFWORKER_HELP_URL = "https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/CfWorker.md" _CFPROXY_TEST_DCS = [1, 2, 3, 4, 5, 203] @@ -123,11 +76,11 @@ def _run_connectivity_test(cases: list) -> dict: if "101" in first: results[dc] = True else: - results[dc] = first or "нет ответа" + results[dc] = first or t("connectivity.no_response") ssock.close() raw.close() except _socket.timeout: - results[dc] = "таймаут" + results[dc] = t("connectivity.timeout") except OSError as exc: msg = str(exc) results[dc] = msg[:60] if len(msg) > 60 else msg @@ -183,30 +136,34 @@ def _show_connectivity_results(title_base: str, results: dict, 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 = f"{title_base}: доступен" - msg = f"\u2713 {title_base} работает. {len(ok)} из {len(_CFPROXY_TEST_DCS)} серверов доступны." + title = t("connectivity.available", title=title_base) + msg = t("connectivity.auto_ok", title=title_base, ok=len(ok), total=total) else: - title = f"{title_base}: недоступен" + 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) == len(_CFPROXY_TEST_DCS): - title = f"{title_base}: всё работает" - msg = f"\u2713 Все {len(_CFPROXY_TEST_DCS)} серверов доступны через {domain}." + 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 = f"{title_base}: недоступен" - msg = f"\u2717 Ни один сервер не отвечает через {domain}.\n\nОшибки:\n" - msg += "\n".join(f" {label_prefix}{dc}: {v}" for dc, v in fail) - else: - title = f"{title_base}: частично работает" - msg = ( - f"Домен: {domain}\n\n" - f"\u2713 Работают: {', '.join(f'{label_prefix}{dc}' for dc in ok)}\n\n" - f"\u2717 Недоступны:\n" - + "\n".join(f" {label_prefix}{dc}: {v}" for dc, v in fail) + 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() @@ -232,26 +189,25 @@ def _show_multi_connectivity_results(title_base: str, per_domain: dict, fail = [(dc, v) for dc, v in results.items() if v is not True] if len(ok) == total: any_ok = True - blocks.append(f"\u2713 {domain}: все {total} серверов доступны") + blocks.append(t("connectivity.multi_all_ok", domain=domain, total=total)) elif not ok: all_ok = False - blocks.append(f"\u2717 {domain}: недоступен") + 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( - f"~ {domain}: работают " - f"{', '.join(f'{label_prefix}{dc}' for dc in ok)}; " - f"недоступны " - f"{', '.join(f'{label_prefix}{dc}' for dc, _ in fail)}" + t("connectivity.multi_partial", domain=domain, ok_list=ok_list, fail_list=fail_list) ) if all_ok: - title = f"{title_base}: всё работает" + title = t("connectivity.all_ok", title=title_base) elif any_ok: - title = f"{title_base}: частично работает" + title = t("connectivity.partial", title=title_base) else: - title = f"{title_base}: недоступен" + title = t("connectivity.unavailable", title=title_base) msg = "\n\n".join(blocks) root = _tk.Tk() @@ -265,12 +221,32 @@ def _show_multi_connectivity_results(title_base: str, per_domain: dict, _INNER_W = 396 -_APPEARANCE_OPTIONS = ["Авто", "Светлая", "Тёмная"] -_APPEARANCE_FROM_CFG = {"auto": "Авто", "light": "Светлая", "dark": "Тёмная"} -_APPEARANCE_TO_CFG = {"Авто": "auto", "Светлая": "light", "Тёмная": "dark"} +_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 = dict( font=(theme.ui_font_family, 13), corner_radius=radius, @@ -374,6 +350,7 @@ class TrayConfigFormWidgets: cfproxy_user_domain_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( @@ -385,11 +362,15 @@ def install_tray_config_form( *, show_autostart: bool = False, autostart_value: bool = False, + on_language_change: 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="Настройки", + header, text=t("settings.title"), font=(theme.ui_font_family, 17, "bold"), text_color=theme.text_primary, anchor="w", ).pack(side="left") @@ -398,35 +379,16 @@ def install_tray_config_form( 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.get(cfg.get("appearance", "auto"), "Авто") + value=_appearance_from_cfg(cfg.get("appearance", "auto")) ) def _on_appearance_change(choice: str) -> None: - cfg_val = _APPEARANCE_TO_CFG.get(choice, "auto") + cfg_val = _appearance_to_cfg(choice) ctk.set_appearance_mode(_APPEARANCE_TO_CTK[cfg_val]) cfg["appearance"] = cfg_val - ctk.CTkComboBox( - header, - values=_APPEARANCE_OPTIONS, - variable=appearance_var, - width=102, - height=28, - font=(theme.ui_font_family, 12), - text_color=theme.text_secondary, - fg_color=theme.field_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, - ).pack(side="right") - ctk.CTkButton( header, text="Donate ♥", width=90, height=28, font=(theme.ui_font_family, 13, "bold"), corner_radius=8, @@ -438,22 +400,88 @@ def install_tray_config_form( ), ).pack(side="right", padx=(0, 6)) - conn = _config_section(ctk, frame, theme, "Подключение MTProto") + 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) + + def _on_language_change(choice: str) -> None: + lang = language_from_label(choice) + set_language(lang) + _sync_language_combobox(language_combo, language_var, lang) + if on_language_change is not None: + on_language_change() + + language_combo.configure(command=_on_language_change) + + _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, "IP-адрес", + ctk, host_row, theme, t("label.host"), cfg.get("host", default_config["host"]), - tip=_TIP_HOST, width=160, pack_fill=True, + 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, "Порт", + ctk, host_row, theme, t("label.port"), cfg.get("port", default_config["port"]), - tip=_TIP_PORT, width=100, + tip=t("tip.port"), width=100, ) port_col.pack(side="left") @@ -461,9 +489,9 @@ def install_tray_config_form( secret_row.pack(fill="x") secret_col, secret_var = _labeled_entry( - ctk, secret_row, theme, "Secret", + ctk, secret_row, theme, t("label.secret"), cfg.get("secret", default_config["secret"]), - tip=_TIP_SECRET, width=160, pack_fill=True, + tip=t("tip.secret"), width=160, pack_fill=True, ) secret_col.pack(side="left", fill="x", expand=True, padx=(0, 10)) @@ -478,8 +506,8 @@ def install_tray_config_form( command=lambda: secret_var.set(os.urandom(16).hex()), ).pack() - dc_inner = _config_section(ctk, frame, theme, "Датацентры Telegram (DC → IP)") - dc_lbl = _label(ctk, dc_inner, theme, "По одному правилу на строку, формат: номер:IP", size=11) + 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, @@ -489,9 +517,9 @@ def install_tray_config_form( ) 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], _TIP_DC) + attach_tooltip_to_widgets([dc_lbl, dc_textbox], t("tip.dc")) - cf_inner = _config_section(ctk, frame, theme, "Cloudflare Proxy") + 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)) @@ -499,9 +527,9 @@ def install_tray_config_form( cfproxy_var = ctk.BooleanVar( value=cfg.get("cfproxy", default_config.get("cfproxy", True)) ) - cf_cb = _checkbox(ctk, cf_row, theme, "Включить CF-прокси", cfproxy_var) + 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, _TIP_CFPROXY) + attach_ctk_tooltip(cf_cb, t("tip.cfproxy")) _cf_test_btn = [None] @@ -512,7 +540,7 @@ def install_tray_config_form( ) btn = _cf_test_btn[0] if btn: - btn.configure(text="...", state="disabled") + btn.configure(text=t("button.test_loading"), state="disabled") import threading as _threading if user_domains: def _worker(): @@ -522,14 +550,14 @@ def install_tray_config_form( btn.after( 0, lambda: _show_multi_connectivity_results( - "CF-прокси", per, label_prefix='kws', + 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="Тест", state="normal")) + btn.after(0, lambda: btn.configure(text=t("button.test"), state="normal")) _threading.Thread(target=_worker, daemon=True).start() else: def _worker_auto(): @@ -539,23 +567,21 @@ def install_tray_config_form( btn.after( 0, lambda: _show_connectivity_results( - "CF-прокси", res, + t("connectivity.cfproxy_title"), res, domain=ok_domain or '', auto_mode=True, - unavailable_message=( - "\u2717 Ни один из автоматических CF-доменов не отвечает." - ), + 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="Тест", state="normal")) + 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="Тест", width=56, height=28, + 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, @@ -571,9 +597,9 @@ def install_tray_config_form( cfg.get("cfproxy_user_domain", default_config.get("cfproxy_user_domain", "")) ) cf_custom_cb_var = ctk.BooleanVar(value=bool(saved_user_domains)) - cf_custom_cb = _checkbox(ctk, cf_custom_row, theme, "Свой домен", cf_custom_cb_var) + 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, _TIP_CFPROXY_USER_DOMAIN_CB) + attach_ctk_tooltip(cf_custom_cb, t("tip.cfproxy_user_domain_cb")) ctk.CTkButton( cf_custom_row, text="?", width=28, height=32, @@ -589,7 +615,7 @@ def install_tray_config_form( height=32, radius=8, ) cf_domain_entry.pack(side="left", fill="x", expand=True, padx=(0, 6)) - attach_ctk_tooltip(cf_domain_entry, _TIP_CFPROXY_DOMAIN) + attach_ctk_tooltip(cf_domain_entry, t("tip.cfproxy_domain")) def _sync_domain_entry(*_): state = "normal" if cf_custom_cb_var.get() else "disabled" @@ -600,11 +626,11 @@ def install_tray_config_form( cf_custom_cb_var.trace_add("write", _sync_domain_entry) _sync_domain_entry() - cf_worker_inner = _config_section(ctk, frame, theme, "Cloudflare Worker") + 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, "Cloudflare Worker домены (через запятую)", size=11) + cf_worker_lbl = _label(ctk, cf_worker_row, theme, t("label.cfworker_domains"), size=11) cf_worker_lbl.pack(anchor="w", pady=(0, 2)) cf_worker_input = ctk.CTkFrame(cf_worker_inner, fg_color="transparent") @@ -620,7 +646,7 @@ def install_tray_config_form( 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], _TIP_CFWORKER_DOMAIN) + attach_tooltip_to_widgets([cf_worker_lbl, cf_worker_entry], t("tip.cfworker_domain")) _cfworker_test_btn = [None] @@ -636,7 +662,7 @@ def install_tray_config_form( btn = _cfworker_test_btn[0] if not domains or btn is None: return - btn.configure(text="...", state="disabled") + btn.configure(text=t("button.test_loading"), state="disabled") import threading as _threading def _worker(): @@ -645,13 +671,13 @@ def install_tray_config_form( btn.after( 0, lambda: _show_multi_connectivity_results( - "CF Worker", per, label_prefix='DC', + 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="Тест")) + btn.after(0, lambda: btn.configure(text=t("button.test"))) btn.after(0, _sync_cfworker_test_button) _threading.Thread(target=_worker, daemon=True).start() @@ -665,7 +691,7 @@ def install_tray_config_form( ).pack(side="right") _cfworker_test_widget = ctk.CTkButton( - cf_worker_input, text="Тест", width=56, height=32, + cf_worker_input, text=t("button.test"), width=56, height=32, 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, @@ -676,20 +702,20 @@ def install_tray_config_form( cfproxy_worker_domain_var.trace_add("write", _sync_cfworker_test_button) _sync_cfworker_test_button() - log_inner = _config_section(ctk, frame, theme, "Логи и производительность") + 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, "Подробное логирование (verbose)", verbose_var) + 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, _TIP_VERBOSE) + attach_ctk_tooltip(verbose_cb, t("tip.verbose")) adv_frame = ctk.CTkFrame(log_inner, fg_color="transparent") adv_frame.pack(fill="x") adv_rows = [ - ("Буфер, КБ (по умолчанию 256)", "buf_kb", _TIP_BUF_KB), - ("Пул WebSocket-сессий (по умолчанию 4)", "pool_size", _TIP_POOL), - ("Макс. размер лога, МБ (по умолчанию 5)", "log_max_mb", _TIP_LOG_MB), + (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") @@ -706,38 +732,32 @@ def install_tray_config_form( adv_entries = list(adv_frame.winfo_children()) adv_keys = ("buf_kb", "pool_size", "log_max_mb") - upd_inner = _config_section(ctk, frame, theme, "Обновления") + 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, "Проверять обновления при запуске", check_updates_var) + 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, _TIP_CHECK_UPDATES) + attach_ctk_tooltip(upd_cb, t("tip.check_updates")) if st.get("error"): - upd_status = "Не удалось связаться с GitHub. Проверьте сеть." + upd_status = t("updates.status_error") elif not st.get("checked"): - upd_status = "Статус появится после фоновой проверки при запуске." + upd_status = t("updates.status_pending") elif st.get("has_update") and st.get("latest"): - upd_status = ( - f"На GitHub доступна версия {st['latest']} " - f"(у вас {__version__})." - ) + upd_status = t("updates.status_available", latest=st["latest"], current=__version__) elif st.get("ahead_of_release") and st.get("latest"): - upd_status = ( - f"У вас {__version__} — новее последнего релиза на GitHub " - f"({st['latest']})." - ) + upd_status = t("updates.status_ahead", current=__version__, latest=st["latest"]) else: - upd_status = "Установлена последняя известная версия с GitHub." + 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 ctk.CTkButton( - upd_inner, text="Открыть страницу релиза", height=32, + 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, @@ -747,17 +767,17 @@ def install_tray_config_form( autostart_var = None if show_autostart: - sys_inner = _config_section(ctk, frame, theme, "Запуск Windows", bottom_spacer=4) + 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, "Автозапуск при включении компьютера", autostart_var) + 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], _TIP_AUTOSTART) + 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, @@ -768,6 +788,7 @@ def install_tray_config_form( cfproxy_user_domain_var=cfproxy_user_domain_var, cfproxy_worker_domain_var=cfproxy_worker_domain_var, appearance_var=appearance_var, + language_var=language_var, ) @@ -800,14 +821,14 @@ def validate_config_form( try: _sock.inet_aton(host_val) except OSError: - return "Некорректный IP-адрес." + 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 "Порт должен быть числом 1-65535" + return t("validation.bad_port") lines = [ line.strip() @@ -817,15 +838,22 @@ def validate_config_form( try: parse_dc_ip_list(lines) except ValueError as e: - return str(e) + msg = str(e) + if "expected DC:IP" in msg: + entry = msg.split("format ", 1)[-1].rstrip(")") + return t("validation.dc_format", entry=entry.strip("'")) + if msg.startswith("Invalid --dc-ip "): + entry = msg.split(" ", 2)[-1] + return t("validation.dc_invalid", entry=entry) + return msg secret_val = widgets.secret_var.get().strip() if len(secret_val) != 32: - return "Secret должен содержать ровно 32 hex-символа (16 байт)." + return t("validation.bad_secret_len") try: bytes.fromhex(secret_val) except ValueError: - return "Secret должен состоять только из hex-символов (0-9, a-f)." + return t("validation.bad_secret_hex") new_cfg: Dict[str, Any] = { "host": host_val, @@ -851,7 +879,9 @@ def validate_config_form( 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.get(widgets.appearance_var.get(), "auto") + 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 return new_cfg @@ -872,22 +902,22 @@ def install_tray_config_buttons( btn_frame = ctk.CTkFrame(frame, fg_color="transparent") btn_frame.pack(fill="x", pady=(0, 0)) save_btn = ctk.CTkButton( - btn_frame, text="Сохранить", height=38, + btn_frame, text=t("button.save"), height=38, font=(theme.ui_font_family, 14, "bold"), corner_radius=10, fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, text_color="#ffffff", command=on_save) save_btn.pack(side="left", fill="x", expand=True, padx=(0, 8)) - attach_ctk_tooltip(save_btn, _TIP_SAVE) + attach_ctk_tooltip(save_btn, t("tip.save")) cancel_btn = ctk.CTkButton( - btn_frame, text="Отмена", height=38, + btn_frame, text=t("button.cancel"), height=38, font=(theme.ui_font_family, 14), corner_radius=10, fg_color=theme.field_bg, hover_color=theme.field_border, text_color=theme.text_primary, border_width=1, border_color=theme.field_border, command=on_cancel) cancel_btn.pack(side="right", fill="x", expand=True) - attach_ctk_tooltip(cancel_btn, _TIP_CANCEL) + attach_ctk_tooltip(cancel_btn, t("tip.cancel")) def populate_first_run_window( @@ -912,19 +942,19 @@ def populate_first_run_window( width=4, height=32, corner_radius=2) accent_bar.pack(side="left", padx=(0, 12)) - ctk.CTkLabel(title_frame, text="Прокси запущен и работает в системном трее", + ctk.CTkLabel(title_frame, text=t("first_run.title"), font=(theme.ui_font_family, 17, "bold"), text_color=theme.text_primary).pack(side="left") sections = [ - ("Как подключить Telegram Desktop:", True), - (" Автоматически:", True), - (" ПКМ по иконке в трее → «Открыть в Telegram»", False), - (f" Или скопировать ссылку, отправить её себе в TG и нажать по ней: {tg_url}", False), - ("\n Вручную:", True), - (" Настройки → Продвинутые → Тип подключения → Прокси", False), - (f" MTProto → {link_host} : {port}", False), - (f" Secret: dd{secret}", False), + (t("first_run.how_to"), True), + (t("first_run.auto"), True), + (t("first_run.auto_hint"), False), + (t("first_run.auto_link", url=tg_url), False), + ("\n" + t("first_run.manual"), True), + (t("first_run.manual_path"), False), + (t("first_run.manual_mtproto", host=link_host, port=port), False), + (t("first_run.manual_secret", secret=secret), False), ] textbox = ctk.CTkTextbox( @@ -956,13 +986,13 @@ def populate_first_run_window( corner_radius=0).pack(fill="x", pady=(0, 12)) auto_var = ctk.BooleanVar(value=True) - _checkbox(ctk, frame, theme, "Открыть прокси в Telegram сейчас", + _checkbox(ctk, frame, theme, t("first_run.open_now"), auto_var).pack(anchor="w", pady=(0, 16)) def on_ok(): on_done(auto_var.get()) - ctk.CTkButton(frame, text="Начать", width=180, height=42, + ctk.CTkButton(frame, text=t("button.start"), width=180, height=42, font=(theme.ui_font_family, 15, "bold"), corner_radius=10, fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover, text_color="#ffffff", diff --git a/ui/i18n/__init__.py b/ui/i18n/__init__.py new file mode 100644 index 0000000..c8ce11f --- /dev/null +++ b/ui/i18n/__init__.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import json +import locale +import os +from enum import Enum +from pathlib import Path +from typing import Any, Dict, List, Tuple, Union + +LocaleInput = Union[str, "LocaleEnum"] + + +class LocaleEnum(str, Enum): + russian = "ru" + english = "en" + + @classmethod + def parse(cls, value: LocaleInput) -> LocaleEnum: + if isinstance(value, cls): + return value + + try: + return cls(value) + except ValueError: + return _DEFAULT_LOCALE + + +_LOCALES_DIR = Path(__file__).resolve().parent +_DEFAULT_LOCALE = LocaleEnum.russian + +_translations: Dict[str, str] = {} +_current_lang: LocaleEnum = _DEFAULT_LOCALE +_config_value: LocaleEnum = _DEFAULT_LOCALE + +_LANGUAGE_TO_LABEL: Dict[LocaleEnum, str] = {} +_LABEL_TO_LANGUAGE: Dict[str, LocaleEnum] = {} + + +def _locale_json_files() -> Tuple[str, ...]: + return tuple( + p.stem for p in sorted(_LOCALES_DIR.glob("*.json")) if p.stem != "manifest" + ) + + +def supported_languages() -> Tuple[str, ...]: + """Locale codes that have a JSON catalog on disk (e.g. ru, en).""" + return _locale_json_files() + + +def content_locales() -> Tuple[LocaleEnum, ...]: + return tuple( + LocaleEnum(stem) + for stem in _locale_json_files() + if stem in LocaleEnum._value2member_map_ + ) + + +def detect_system_language() -> LocaleEnum: + """Pick the best locale from available catalogs, else Russian.""" + available = content_locales() + if not available: + return _DEFAULT_LOCALE + + for getter in (locale.getlocale, locale.getdefaultlocale): + try: + loc = getter() + if loc and loc[0]: + code = loc[0].split("_")[0].lower() + try: + candidate = LocaleEnum(code) + if candidate in available: + return candidate + except ValueError: + pass + except Exception: + pass + for env_key in ("LC_ALL", "LC_MESSAGES", "LANG"): + val = os.environ.get(env_key, "") + if val: + code = val.split(".")[0].split("_")[0].lower() + try: + candidate = LocaleEnum(code) + if candidate in available: + return candidate + except ValueError: + pass + return _DEFAULT_LOCALE + + +def resolve_language(config_value: LocaleInput) -> LocaleEnum: + loc = LocaleEnum.parse(config_value) + if loc.value in supported_languages(): + return loc + return _DEFAULT_LOCALE + + +def _load_locale(lang: LocaleEnum) -> Dict[str, str]: + path = _LOCALES_DIR / f"{lang.value}.json" + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def set_language(config_value: LocaleInput) -> LocaleEnum: + global _translations, _current_lang, _config_value + _config_value = LocaleEnum.parse(config_value) + _current_lang = resolve_language(_config_value) + _translations = _load_locale(_current_lang) + refresh_language_option_maps() + return _current_lang + + +def get_language() -> LocaleEnum: + return _current_lang + + +def get_config_language() -> LocaleEnum: + return _config_value + + +def t(key: str, **kwargs: Any) -> str: + text = _translations.get(key, key) + if kwargs: + try: + return text.format(**kwargs) + except (KeyError, IndexError, ValueError): + return text + return text + + +def language_option_labels() -> List[Tuple[LocaleEnum, str]]: + """Config values and display labels for the language combobox.""" + return [ + (loc, t(f"language.{loc.value}")) + for loc in content_locales() + ] + + +def language_label_for_config(value: LocaleInput) -> str: + loc = LocaleEnum.parse(value) + for cfg_val, label in language_option_labels(): + if cfg_val == loc: + return label + return language_option_labels()[0][1] + + +def refresh_language_option_maps() -> None: + global _LANGUAGE_TO_LABEL, _LABEL_TO_LANGUAGE + _LANGUAGE_TO_LABEL = dict(language_option_labels()) + _LABEL_TO_LANGUAGE = {label: val for val, label in _LANGUAGE_TO_LABEL.items()} + + +def language_from_label(label: str) -> LocaleEnum: + return _LABEL_TO_LANGUAGE.get(label, _DEFAULT_LOCALE) + + +def label_from_language(value: LocaleInput) -> str: + loc = LocaleEnum.parse(value) + return _LANGUAGE_TO_LABEL.get( + loc, + _LANGUAGE_TO_LABEL.get(_DEFAULT_LOCALE, _DEFAULT_LOCALE.value), + ) + + +set_language(detect_system_language()) diff --git a/ui/i18n/en.json b/ui/i18n/en.json new file mode 100644 index 0000000..fb05669 --- /dev/null +++ b/ui/i18n/en.json @@ -0,0 +1,148 @@ +{ + "app.name": "TG WS Proxy", + "app.error_title": "TG WS Proxy — Error", + "app.settings_title": "TG WS Proxy — Settings", + "app.update_title": "TG WS Proxy — Update", + + "language.ru": "Русский", + "language.en": "English", + + "appearance.auto": "Auto", + "appearance.light": "Light", + "appearance.dark": "Dark", + + "settings.title": "Settings", + "settings.language": "Language", + "settings.theme": "Theme", + + "section.interface": "Interface", + "section.mtproto": "MTProto Connection", + "section.dc": "Telegram Data Centers (DC → IP)", + "section.cfproxy": "Cloudflare Proxy", + "section.cfworker": "Cloudflare Worker", + "section.logs": "Logs & Performance", + "section.updates": "Updates", + "section.windows_startup": "Windows Startup", + + "label.host": "IP address", + "label.port": "Port", + "label.secret": "Secret", + "label.dc_hint": "One rule per line, format: number:IP", + "label.cf_enable": "Enable CF proxy", + "label.cf_custom_domain": "Custom domain", + "label.cfworker_domains": "Cloudflare Worker domains (comma-separated)", + "label.verbose": "Verbose logging", + "label.buf_kb": "Buffer, KB (default 256)", + "label.pool_size": "WebSocket session pool (default 4)", + "label.log_max_mb": "Max log size, MB (default 5)", + "label.check_updates": "Check for updates on startup", + "label.autostart": "Start on system boot", + "label.autostart_hint": "If you move the app to another folder, the autostart entry may reset.", + + "tip.host": "Address the proxy listens on.\nUsually 127.0.0.1 for localhost, 0.0.0.0 for all interfaces", + "tip.port": "Proxy port. Telegram Desktop proxy settings must use the same port", + "tip.secret": "Secret key for client authorization", + "tip.dc": "Mapping of Telegram data center (DC) number to server IP.\nEach line: «number:IP», e.g. 4:149.154.167.220. The proxy routes traffic to Telegram servers using these rules\n\nIf media does not work with CF proxy enabled, try removing the line 2:149.154.167.220", + "tip.verbose": "When enabled, more details are written to the log file — useful for troubleshooting", + "tip.buf_kb": "Receive/send buffer size in kilobytes.\nA larger value allocates more memory per socket", + "tip.pool": "How many parallel WebSocket sessions per data center can be kept open.\nIncreasing may help under high load", + "tip.log_mb": "Maximum log file size; the file is overwritten when the limit is reached", + "tip.autostart": "Launch TG WS Proxy on Windows login. If you move the app to another folder, autostart will reset", + "tip.check_updates": "Check for updates on startup", + "tip.cfproxy": "Use Cloudflare proxy for unreachable data centers", + "tip.cfproxy_domain": "Your own domains proxied through Cloudflare for WS connections.\nSeparate multiple domains with commas.\nIf empty — chosen automatically from supported domains", + "tip.cfproxy_user_domain_cb": "Specify your own domains instead of automatic selection", + "tip.cfworker_domain": "Cloudflare Worker domains (e.g. name.account.workers.dev).\nSeparate multiple domains with commas.\nThe proxy routes connections to Telegram DCs by IP through them", + "tip.save": "Save settings", + "tip.cancel": "Close without saving changes", + + "button.save": "Save", + "button.cancel": "Cancel", + "button.test": "Test", + "button.test_loading": "...", + "button.open_release": "Open release page", + "button.start": "Get started", + "button.update": "Update", + "button.page": "Page", + "button.close": "Close", + + "validation.bad_host": "Invalid IP address.", + "validation.bad_port": "Port must be a number between 1 and 65535", + "validation.bad_secret_len": "Secret must be exactly 32 hex characters (16 bytes).", + "validation.bad_secret_hex": "Secret must contain only hex characters (0-9, a-f).", + "validation.dc_format": "Invalid DC:IP format: {entry}", + "validation.dc_invalid": "Invalid DC:IP entry: {entry}", + + "connectivity.cfproxy_title": "CF Proxy", + "connectivity.cfworker_title": "CF Worker", + "connectivity.timeout": "timeout", + "connectivity.available": "{title}: available", + "connectivity.unavailable": "{title}: unavailable", + "connectivity.all_ok": "{title}: all working", + "connectivity.partial": "{title}: partially working", + "connectivity.auto_ok": "✓ {title} works. {ok} of {total} servers reachable.", + "connectivity.all_ok_domain": "✓ All {total} servers reachable via {domain}.", + "connectivity.none_ok": "✗ No servers respond via {domain}.\n\nErrors:\n{errors}", + "connectivity.partial_detail": "Domain: {domain}\n\n✓ Working: {ok_list}\n\n✗ Unreachable:\n{fail_list}", + "connectivity.error_line": " {prefix}{dc}: {error}", + "connectivity.cf_auto_fail": "✗ None of the automatic CF domains respond.", + "connectivity.multi_all_ok": "✓ {domain}: all {total} servers reachable", + "connectivity.multi_fail": "✗ {domain}: unavailable", + "connectivity.multi_partial": "~ {domain}: working {ok_list}; unreachable {fail_list}", + + "updates.status_error": "Could not reach GitHub. Check your network.", + "updates.status_pending": "Status will appear after the background check on startup.", + "updates.status_available": "Version {latest} is available on GitHub (you have {current}).", + "updates.status_ahead": "You have {current} — newer than the latest GitHub release ({latest}).", + "updates.status_latest": "Latest known version from GitHub is installed.", + + "first_run.title": "Proxy is running in the system tray", + "first_run.how_to": "How to connect Telegram Desktop:", + "first_run.auto": " Automatically:", + "first_run.auto_hint": " Right-click tray icon → «Open in Telegram»", + "first_run.auto_link": " Or copy the link, send it to yourself in TG and click it: {url}", + "first_run.manual": " Manually:", + "first_run.manual_path": " Settings → Advanced → Connection type → Proxy", + "first_run.manual_mtproto": " MTProto → {host} : {port}", + "first_run.manual_secret": " Secret: dd{secret}", + "first_run.open_now": "Open proxy in Telegram now", + + "tray.open_telegram": "Open in Telegram ({host}:{port})", + "tray.copy_link": "Copy link", + "tray.restart": "Restart proxy", + "tray.settings": "Settings...", + "tray.logs": "Open logs", + "tray.exit": "Exit", + + "dialog.restart_title": "Restart?", + "dialog.restart_body": "Settings saved.\n\nRestart the proxy now?", + "dialog.already_running": "Application is already running.", + "dialog.log_not_found": "Log file has not been created yet.", + "dialog.ctk_missing": "customtkinter is not installed.", + "dialog.copy_ok": "Link copied to clipboard, send it in Telegram and click it:\n{url}", + "dialog.copy_fail": "Failed to copy link:\n{error}", + "dialog.open_tg_fail": "Could not open Telegram automatically.\n\n{detail}", + "dialog.open_tg_fail_clipboard": "Link copied to clipboard, send it in Telegram and click it:\n{url}", + "dialog.open_tg_fail_manual": "Install pyperclip to copy to clipboard, or open manually:\n{url}", + "dialog.pyperclip_missing": "Install pyperclip to copy to clipboard.", + "dialog.log_open_fail": "Failed to open log file:\n{error}", + "dialog.autostart_fail": "Failed to change autostart.\n\nTry running the app as a user with registry permissions.\n\nError: {error}", + + "update.available": "New version available: {version}", + "update.ask_open": "New version available: {version}\n\nOpen the release page in the browser?", + "update.downloading": "Downloading...", + "update.replacing": "Replacing file...", + "update.restarting": "Restarting...", + "update.error": "Error: {msg}", + "update.download_fail": "Download failed:\n{error}", + "update.rename_fail": "Failed to rename file:\n{error}", + "update.move_fail": "Failed to move file:\n{error}", + + "error.dc_config": "DC → IP configuration error.", + + "diagnostics.port_busy": "Failed to start proxy:\nPort is already in use by another application.\n\nClose the app using this port, or change the port in proxy settings and restart.", + "diagnostics.permission": "Failed to start proxy:\nAccess to address/port denied (firewall, antivirus, or permissions).\n\nChange the port to a random value in 10000–50000 in settings, check firewall/antivirus, and restart.", + "diagnostics.bad_address": "Failed to start proxy:\nInvalid or unavailable listen address.\n\nCheck the solution at the link opened in your browser.\nVerify host and port in proxy settings and restart.", + + "ipv6.warning": "IPv6 connectivity is enabled on your computer.\n\nTelegram may try to connect over IPv6, which is not supported and may cause errors.\n\nIf the proxy does not work or logs show IPv6 connection attempts, try disabling IPv6 connection attempts in Telegram proxy settings. If that does not help, try disabling IPv6 system-wide.\n\nThis warning is shown only once." +} diff --git a/ui/i18n/ru.json b/ui/i18n/ru.json new file mode 100644 index 0000000..3cb7a0d --- /dev/null +++ b/ui/i18n/ru.json @@ -0,0 +1,148 @@ +{ + "app.name": "TG WS Proxy", + "app.error_title": "TG WS Proxy — Ошибка", + "app.settings_title": "TG WS Proxy — Настройки", + "app.update_title": "TG WS Proxy — обновление", + + "language.ru": "Русский", + "language.en": "English", + + "appearance.auto": "Авто", + "appearance.light": "Светлая", + "appearance.dark": "Тёмная", + + "settings.title": "Настройки", + "settings.language": "Язык", + "settings.theme": "Тема", + + "section.interface": "Интерфейс", + "section.mtproto": "Подключение MTProto", + "section.dc": "Датацентры Telegram (DC → IP)", + "section.cfproxy": "Cloudflare Proxy", + "section.cfworker": "Cloudflare Worker", + "section.logs": "Логи и производительность", + "section.updates": "Обновления", + "section.windows_startup": "Запуск Windows", + + "label.host": "IP-адрес", + "label.port": "Порт", + "label.secret": "Secret", + "label.dc_hint": "По одному правилу на строку, формат: номер:IP", + "label.cf_enable": "Включить CF-прокси", + "label.cf_custom_domain": "Свой домен", + "label.cfworker_domains": "Cloudflare Worker домены (через запятую)", + "label.verbose": "Подробное логирование (verbose)", + "label.buf_kb": "Буфер, КБ (по умолчанию 256)", + "label.pool_size": "Пул WebSocket-сессий (по умолчанию 4)", + "label.log_max_mb": "Макс. размер лога, МБ (по умолчанию 5)", + "label.check_updates": "Проверять обновления при запуске", + "label.autostart": "Автозапуск при включении компьютера", + "label.autostart_hint": "Если переместить программу в другую папку, запись автозапуска может сброситься.", + + "tip.host": "Адрес, на котором прокси принимает подключения.\nОбычно 127.0.0.1 — локальная сеть, 0.0.0.0 - все интерфейсы", + "tip.port": "Порт прокси. В Telegram Desktop в настройках прокси должен быть указан тот же порт", + "tip.secret": "Секретный ключ для авторизации клиентов", + "tip.dc": "Соответствие номера датацентра Telegram (DC) и IP-адреса сервера.\nКаждая строка: «номер:IP», например 4:149.154.167.220. Прокси по этим правилам направляет трафик к нужным серверам Telegram\n\nЕсли у вас не работают медиа и работает CF-прокси, то попробуйте убрать строку 2:149.154.167.220", + "tip.verbose": "Если включено, в файл логов пишется больше подробностей — необходимо при поиске неполадок", + "tip.buf_kb": "Размер буфера приёма/передачи в килобайтах.\nБольше значение — больше выделение памяти на сокет", + "tip.pool": "Сколько параллельных WebSocket-сессий к одному датацентру можно держать.\nУвеличение может помочь при высокой нагрузке", + "tip.log_mb": "Максимальный размер файла лога; при достижении лимита файл перезаписывается", + "tip.autostart": "Запускать TG WS Proxy при входе в Windows. Если вы переместите программу в другую папку, автозапуск сбросится", + "tip.check_updates": "При запуске проверять наличие обновлений", + "tip.cfproxy": "Использовать Cloudflare прокси для недоступных датацентров", + "tip.cfproxy_domain": "Ваши собственные домены, проксируемые через Cloudflare, для WS-подключения.\nНесколько доменов указывайте через запятую.\nЕсли не указаны — выбираются автоматически из поддерживаемых доменов", + "tip.cfproxy_user_domain_cb": "Указать свои домены вместо автоматического выбора", + "tip.cfworker_domain": "Домены Cloudflare Worker (например, name.account.workers.dev).\nНесколько доменов указывайте через запятую.\nПрокси передает через них подключение к Telegram DC по IP", + "tip.save": "Сохранить настройки", + "tip.cancel": "Закрыть окно без сохранения изменений", + + "button.save": "Сохранить", + "button.cancel": "Отмена", + "button.test": "Тест", + "button.test_loading": "...", + "button.open_release": "Открыть страницу релиза", + "button.start": "Начать", + "button.update": "Обновить", + "button.page": "Страница", + "button.close": "Закрыть", + + "validation.bad_host": "Некорректный IP-адрес.", + "validation.bad_port": "Порт должен быть числом 1-65535", + "validation.bad_secret_len": "Secret должен содержать ровно 32 hex-символа (16 байт).", + "validation.bad_secret_hex": "Secret должен состоять только из hex-символов (0-9, a-f).", + "validation.dc_format": "Неверный формат DC:IP: {entry}", + "validation.dc_invalid": "Неверная запись DC:IP: {entry}", + + "connectivity.cfproxy_title": "CF-прокси", + "connectivity.cfworker_title": "CF Worker", + "connectivity.timeout": "таймаут", + "connectivity.available": "{title}: доступен", + "connectivity.unavailable": "{title}: недоступен", + "connectivity.all_ok": "{title}: всё работает", + "connectivity.partial": "{title}: частично работает", + "connectivity.auto_ok": "✓ {title} работает. {ok} из {total} серверов доступны.", + "connectivity.all_ok_domain": "✓ Все {total} серверов доступны через {domain}.", + "connectivity.none_ok": "✗ Ни один сервер не отвечает через {domain}.\n\nОшибки:\n{errors}", + "connectivity.partial_detail": "Домен: {domain}\n\n✓ Работают: {ok_list}\n\n✗ Недоступны:\n{fail_list}", + "connectivity.error_line": " {prefix}{dc}: {error}", + "connectivity.cf_auto_fail": "✗ Ни один из автоматических CF-доменов не отвечает.", + "connectivity.multi_all_ok": "✓ {domain}: все {total} серверов доступны", + "connectivity.multi_fail": "✗ {domain}: недоступен", + "connectivity.multi_partial": "~ {domain}: работают {ok_list}; недоступны {fail_list}", + + "updates.status_error": "Не удалось связаться с GitHub. Проверьте сеть.", + "updates.status_pending": "Статус появится после фоновой проверки при запуске.", + "updates.status_available": "На GitHub доступна версия {latest} (у вас {current}).", + "updates.status_ahead": "У вас {current} — новее последнего релиза на GitHub ({latest}).", + "updates.status_latest": "Установлена последняя известная версия с GitHub.", + + "first_run.title": "Прокси запущен и работает в системном трее", + "first_run.how_to": "Как подключить Telegram Desktop:", + "first_run.auto": " Автоматически:", + "first_run.auto_hint": " ПКМ по иконке в трее → «Открыть в Telegram»", + "first_run.auto_link": " Или скопировать ссылку, отправить её себе в TG и нажать по ней: {url}", + "first_run.manual": " Вручную:", + "first_run.manual_path": " Настройки → Продвинутые → Тип подключения → Прокси", + "first_run.manual_mtproto": " MTProto → {host} : {port}", + "first_run.manual_secret": " Secret: dd{secret}", + "first_run.open_now": "Открыть прокси в Telegram сейчас", + + "tray.open_telegram": "Открыть в Telegram ({host}:{port})", + "tray.copy_link": "Скопировать ссылку", + "tray.restart": "Перезапустить прокси", + "tray.settings": "Настройки...", + "tray.logs": "Открыть логи", + "tray.exit": "Выход", + + "dialog.restart_title": "Перезапустить?", + "dialog.restart_body": "Настройки сохранены.\n\nПерезапустить прокси сейчас?", + "dialog.already_running": "Приложение уже запущено.", + "dialog.log_not_found": "Файл логов ещё не создан.", + "dialog.ctk_missing": "customtkinter не установлен.", + "dialog.copy_ok": "Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}", + "dialog.copy_fail": "Не удалось скопировать ссылку:\n{error}", + "dialog.open_tg_fail": "Не удалось открыть Telegram автоматически.\n\n{detail}", + "dialog.open_tg_fail_clipboard": "Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}", + "dialog.open_tg_fail_manual": "Установите пакет pyperclip для копирования в буфер или откройте вручную:\n{url}", + "dialog.pyperclip_missing": "Установите пакет pyperclip для копирования в буфер обмена.", + "dialog.log_open_fail": "Не удалось открыть файл логов:\n{error}", + "dialog.autostart_fail": "Не удалось изменить автозапуск.\n\nПопробуйте запустить приложение от имени пользователя с правами на реестр.\n\nОшибка: {error}", + + "update.available": "Доступна новая версия: {version}", + "update.ask_open": "Доступна новая версия: {version}\n\nОткрыть страницу релиза в браузере?", + "update.downloading": "Скачивание...", + "update.replacing": "Замена файла...", + "update.restarting": "Перезапуск...", + "update.error": "Ошибка: {msg}", + "update.download_fail": "Не удалось скачать:\n{error}", + "update.rename_fail": "Не удалось переименовать файл:\n{error}", + "update.move_fail": "Не удалось переместить файл:\n{error}", + + "error.dc_config": "Ошибка конфигурации DC → IP.", + + "diagnostics.port_busy": "Не удалось запустить прокси:\nПорт уже используется другим приложением.\n\nЗакройте приложение, использующее этот порт, или измените порт в настройках прокси и перезапустите.", + "diagnostics.permission": "Не удалось запустить прокси:\nДоступ к адресу/порту запрещён (брандмауэр, антивирус или права доступа).\n\nИзмените порт на случайный в диапазоне 10000–50000 в настройках, проверьте брандмауэр/антивирус и перезапустите.", + "diagnostics.bad_address": "Не удалось запустить прокси:\nНекорректный или недоступный адрес для прослушивания.\n\nПроверьте решение по открывшейся в браузере ссылке.\nПроверьте host и порт в настройках прокси и перезапустите.", + + "ipv6.warning": "На вашем компьютере включена поддержка подключения по IPv6.\n\nTelegram может пытаться подключаться через IPv6, что не поддерживается и может привести к ошибкам.\n\nЕсли прокси не работает или в логах присутствуют ошибки, связанные с попытками подключения по IPv6 - попробуйте отключить в настройках прокси Telegram попытку соединения по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 в системе.\n\nЭто предупреждение будет показано только один раз." +} diff --git a/utils/default_config.py b/utils/default_config.py index 1c04cae..3d244b9 100644 --- a/utils/default_config.py +++ b/utils/default_config.py @@ -8,6 +8,8 @@ import sys import os from typing import Any, Dict +from ui.i18n import detect_system_language + _TRAY_DEFAULTS_COMMON: Dict[str, Any] = { "port": 1443, "host": "127.0.0.1", @@ -20,13 +22,14 @@ _TRAY_DEFAULTS_COMMON: Dict[str, Any] = { "cfproxy": True, "cfproxy_user_domain": [], "cfproxy_worker_domain": [], - "ws_keepalive_interval": 30 + "ws_keepalive_interval": 30, } def default_tray_config() -> Dict[str, Any]: cfg = dict(_TRAY_DEFAULTS_COMMON) cfg["secret"] = os.urandom(16).hex() + cfg["language"] = detect_system_language().value if sys.platform == "win32": cfg["autostart"] = False diff --git a/utils/diagnostics.py b/utils/diagnostics.py index 76caeba..8a0a199 100644 --- a/utils/diagnostics.py +++ b/utils/diagnostics.py @@ -5,29 +5,6 @@ import webbrowser from typing import Optional, Tuple, Callable - -MSG_PORT_BUSY = ( - "Не удалось запустить прокси:\n" - "Порт уже используется другим приложением.\n\n" - "Закройте приложение, использующее этот порт, " - "или измените порт в настройках прокси и перезапустите." -) - -MSG_PERMISSION = ( - "Не удалось запустить прокси:\n" - "Доступ к адресу/порту запрещён " - "(брандмауэр, антивирус или права доступа).\n\n" - "Измените порт на случайный в диапазоне 10000–50000 в настройках, " - "проверьте брандмауэр/антивирус и перезапустите." -) - -MSG_BAD_ADDRESS = ( - "Не удалось запустить прокси:\n" - "Некорректный или недоступный адрес для прослушивания.\n\n" - "Проверьте решение по открывшейся в браузере ссылке.\n" - "Проверьте host и порт в настройках прокси и перезапустите." -) - # Windows WinSock error codes (exc.winerror); errno may differ from POSIX. _WSA_EACCES = 10013 _WSA_EFAULT = 10014 @@ -41,6 +18,8 @@ def diagnose_listen_error(exc: BaseException) -> Tuple[Optional[str], Optional[C Returns None when the exception is not a recognizable bind failure, so callers can fall back to generic handling. """ + from ui.i18n import t + if not isinstance(exc, OSError): return None @@ -48,10 +27,10 @@ def diagnose_listen_error(exc: BaseException) -> Tuple[Optional[str], Optional[C winerror = getattr(exc, "winerror", None) if err == errno.EADDRINUSE or winerror == _WSA_EADDRINUSE: - return MSG_PORT_BUSY, None + return t("diagnostics.port_busy"), None if err == errno.EACCES or winerror == _WSA_EACCES: - return MSG_PERMISSION, None + return t("diagnostics.permission"), None if (winerror in (_WSA_EFAULT, _WSA_EADDRNOTAVAIL) or err in (errno.EADDRNOTAVAIL, errno.EFAULT)): - return MSG_BAD_ADDRESS, lambda : webbrowser.open("https://github.com/Flowseal/tg-ws-proxy/issues/903#issuecomment-4726752103") + return t("diagnostics.bad_address"), lambda : webbrowser.open("https://github.com/Flowseal/tg-ws-proxy/issues/903#issuecomment-4726752103") return None, None diff --git a/utils/tray_common.py b/utils/tray_common.py index 40acdd1..5de5b76 100644 --- a/utils/tray_common.py +++ b/utils/tray_common.py @@ -180,6 +180,12 @@ def release_lock() -> None: # config +def _apply_ui_language(cfg: dict) -> None: + from ui.i18n import set_language + + set_language(cfg.get("language", DEFAULT_CONFIG["language"])) + + def load_config() -> dict: ensure_dirs() if CONFIG_FILE.exists(): @@ -188,10 +194,13 @@ def load_config() -> dict: data = json.load(f) for k, v in DEFAULT_CONFIG.items(): data.setdefault(k, v) + _apply_ui_language(data) return data except Exception as exc: log.warning("Failed to load config: %s", repr(exc)) - return dict(DEFAULT_CONFIG) + cfg = dict(DEFAULT_CONFIG) + _apply_ui_language(cfg) + return cfg def save_config(cfg: dict) -> None: @@ -335,7 +344,8 @@ def start_proxy(cfg: dict, on_error: Callable[[str], None]) -> None: return if not apply_proxy_config(cfg): - on_error("Ошибка конфигурации DC → IP.") + from ui.i18n import t + on_error(t("error.dc_config")) return pc = proxy_config @@ -372,19 +382,6 @@ def tg_proxy_url(cfg: dict) -> str: return f"tg://proxy?server={link_host}&port={port}&secret=dd{secret}" -_IPV6_WARNING = ( - "На вашем компьютере включена поддержка подключения по IPv6.\n\n" - "Telegram может пытаться подключаться через IPv6, " - "что не поддерживается и может привести к ошибкам.\n\n" - "Если прокси не работает или в логах присутствуют ошибки, " - "связанные с попытками подключения по IPv6 - " - "попробуйте отключить в настройках прокси Telegram попытку соединения " - "по IPv6. Если данная мера не помогает, попробуйте отключить IPv6 " - "в системе.\n\n" - "Это предупреждение будет показано только один раз." -) - - def _has_ipv6() -> bool: try: for addr in _socket.getaddrinfo(_socket.gethostname(), None, _socket.AF_INET6): @@ -407,8 +404,10 @@ def check_ipv6_warning(show_info: Callable[[str, str], None]) -> None: if IPV6_WARN_MARKER.exists() or not _has_ipv6(): return IPV6_WARN_MARKER.touch() + from ui.i18n import t + threading.Thread( - target=lambda: show_info(_IPV6_WARNING, "TG WS Proxy"), + target=lambda: show_info(t("ipv6.warning"), t("app.name")), daemon=True, ).start() @@ -437,9 +436,11 @@ def maybe_notify_update( return url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL ver = st.get("latest") or "?" + from ui.i18n import t + if ask_open( - f"Доступна новая версия: {ver}\n\nОткрыть страницу релиза в браузере?", - "TG WS Proxy — обновление", + t("update.ask_open", version=ver), + t("app.update_title"), ): webbrowser.open(url) except Exception as exc: diff --git a/windows.py b/windows.py index 8e6b3b3..35495b9 100644 --- a/windows.py +++ b/windows.py @@ -56,6 +56,7 @@ 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 _tray_icon: Optional[object] = None _config: dict = {} @@ -110,22 +111,23 @@ _IDYES = 6 _IDNO = 7 -def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None: - _u32.MessageBoxW(None, text, title, _MB_OK_ERR) +def _show_error(text: str, title: Optional[str] = None) -> None: + _u32.MessageBoxW(None, text, title or t("app.error_title"), _MB_OK_ERR) -def _show_info(text: str, title: str = "TG WS Proxy") -> None: - _u32.MessageBoxW(None, text, title, _MB_OK_INFO) +def _show_info(text: str, title: Optional[str] = None) -> None: + _u32.MessageBoxW(None, text, title or t("app.name"), _MB_OK_INFO) -def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: - return _u32.MessageBoxW(None, text, title, _MB_YESNO_Q) == _IDYES +def _ask_yes_no(text: str, title: Optional[str] = None) -> bool: + return _u32.MessageBoxW(None, text, title or t("app.name"), _MB_YESNO_Q) == _IDYES def update_ctk_form( - text: str, title: str = "TG WS Proxy", download_url: Optional[str] = None, + text: str, title: Optional[str] = None, download_url: Optional[str] = None, release_url: Optional[str] = None, ) -> str: + title = title or t("app.name") if ctk is None or not ensure_ctk_thread(ctk, _config.get("appearance", "auto")): result = _u32.MessageBoxW(None, text, title, _MB_YESNOCANCEL_Q) if result == _IDYES: @@ -194,19 +196,19 @@ def update_ctk_form( if IS_FROZEN: btn_upd = ctk.CTkButton( - row, text="Обновить", width=88, height=34, + row, text=t("button.update"), width=88, height=34, font=(theme.ui_font_family, 13), command=_on_update, ) btn_upd.pack(side="left", padx=(0, 6)) btns.append(btn_upd) btn_pg = ctk.CTkButton( - row, text="Страница", width=88, height=34, + row, text=t("button.page"), width=88, height=34, font=(theme.ui_font_family, 13), command=lambda: _close_with("open"), ) btn_pg.pack(side="left", padx=(0, 6)) btns.append(btn_pg) btn_cl = ctk.CTkButton( - row, text="Закрыть", width=88, height=34, + row, text=t("button.close"), width=88, height=34, font=(theme.ui_font_family, 13), fg_color=theme.field_bg, hover_color=theme.field_border, text_color=theme.text_primary, border_width=1, border_color=theme.field_border, @@ -231,11 +233,11 @@ def _perform_update(download_url: str, set_status=None) -> None: def _err(msg: str) -> None: log.error("Update error: %s", msg) if set_status: - set_status(f"Ошибка: {msg}") + set_status(f"{t('update.error', msg=msg)}") else: _show_error(msg) - _step("Скачивание...") + _step(t("update.downloading")) cur_exe = Path(sys.executable) old_exe = cur_exe.with_name(cur_exe.stem + "_oldtgws.exe") tmp_path = None @@ -253,7 +255,7 @@ def _perform_update(download_url: str, set_status=None) -> None: break _fout.write(_chunk) except Exception as exc: - _err(f"Не удалось скачать:\n{exc}") + _err(t("update.download_fail", error=exc)) if tmp_path: try: tmp_path.unlink(missing_ok=True) @@ -261,13 +263,13 @@ def _perform_update(download_url: str, set_status=None) -> None: pass return - _step("Замена файла...") + _step(t("update.replacing")) try: if old_exe.exists(): old_exe.unlink() cur_exe.rename(old_exe) except Exception as exc: - _err(f"Не удалось переименовать файл:\n{exc}") + _err(t("update.rename_fail", error=exc)) try: tmp_path.unlink(missing_ok=True) except OSError: @@ -277,7 +279,7 @@ def _perform_update(download_url: str, set_status=None) -> None: try: tmp_path.rename(cur_exe) except Exception as exc: - _err(f"Не удалось переместить файл:\n{exc}") + _err(t("update.move_fail", error=exc)) try: old_exe.rename(cur_exe) except OSError: @@ -288,7 +290,7 @@ def _perform_update(download_url: str, set_status=None) -> None: pass return - _step("Перезапуск...") + _step(t("update.restarting")) _release_win_mutex() stop_proxy() @@ -335,7 +337,7 @@ def _maybe_do_update(cfg: dict, is_exiting) -> None: ver = st.get("latest") or "?" asset = get_update_asset(Path(sys.executable), __version__) if IS_FROZEN else None choice = update_ctk_form( - f"Доступна новая версия: {ver}", + t("update.available", version=ver), download_url=asset[0] if asset else None, release_url=url, ) @@ -382,9 +384,7 @@ def set_autostart_enabled(enabled: bool) -> None: except OSError as exc: log.error("Failed to update autostart: %s", exc) _show_error( - "Не удалось изменить автозапуск.\n\n" - "Попробуйте запустить приложение от имени пользователя " - f"с правами на реестр.\n\nОшибка: {exc}" + t("dialog.autostart_fail", error=exc) ) @@ -400,34 +400,30 @@ def _on_open_in_telegram(icon=None, item=None) -> None: log.info("Browser open failed, copying to clipboard") if pyperclip is None: _show_error( - "Не удалось открыть Telegram автоматически.\n\n" - f"Установите пакет pyperclip для копирования в буфер или откройте вручную:\n{url}" + t("dialog.open_tg_fail_manual", url=url) ) return try: pyperclip.copy(url) _show_info( - "Не удалось открыть Telegram автоматически.\n\n" - f"Ссылка скопирована в буфер обмена, отправьте её в Telegram и нажмите по ней ЛКМ:\n{url}" + t("dialog.open_tg_fail_clipboard", url=url) ) except Exception as exc: log.error("Clipboard copy failed: %s", exc) - _show_error(f"Не удалось скопировать ссылку:\n{exc}") + _show_error(t("dialog.copy_fail", error=exc)) def _on_copy_link(icon=None, item=None) -> None: url = tg_proxy_url(_config) log.info("Copying link: %s", url) if pyperclip is None: - _show_error( - "Установите пакет pyperclip для копирования в буфер обмена." - ) + _show_error(t("dialog.pyperclip_missing")) return try: pyperclip.copy(url) except Exception as exc: log.error("Clipboard copy failed: %s", exc) - _show_error(f"Не удалось скопировать ссылку:\n{exc}") + _show_error(t("dialog.copy_fail", error=exc)) def _on_restart(icon=None, item=None) -> None: @@ -447,9 +443,9 @@ def _on_open_logs(icon=None, item=None) -> None: os.startfile(str(LOG_FILE)) except Exception as exc: log.error("Failed to open log file: %s", exc) - _show_error(f"Не удалось открыть файл логов:\n{exc}") + _show_error(t("dialog.log_open_fail", error=exc)) else: - _show_info("Файл логов ещё не создан.") + _show_info(t("dialog.log_not_found")) def _on_exit(icon=None, item=None) -> None: @@ -469,7 +465,7 @@ def _on_exit(icon=None, item=None) -> None: def _edit_config_dialog() -> None: if not ensure_ctk_thread(ctk, _config.get("appearance", "auto")): - _show_error("customtkinter не установлен.") + _show_error(t("dialog.ctk_missing")) return cfg = dict(_config) @@ -484,45 +480,60 @@ def _edit_config_dialog() -> None: h += 100 root = create_ctk_toplevel( - ctk, title="TG WS Proxy — Настройки", width=w, height=h, theme=theme, + ctk, title=t("app.settings_title"), width=w, height=h, theme=theme, after_create=lambda r: r.iconbitmap(ICON_PATH), ) fpx, fpy = CONFIG_DIALOG_FRAME_PAD frame = main_content_frame(ctk, root, theme, padx=fpx, pady=fpy) scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme) + + def _refresh_tray_menu() -> 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, ) _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() 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("TG WS Proxy — Ошибка", merged, parent=root) + messagebox.showerror(t("app.error_title"), merged, parent=root) return - _ui_only_keys = {"appearance", "autostart", "check_updates"} - config_changed = any(merged.get(k) != cfg.get(k) for k in merged) - proxy_changed = any(merged.get(k) != cfg.get(k) for k in merged if k not in _ui_only_keys) + _ui_only_keys = {"appearance", "autostart", "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) if _supports_autostart(): set_autostart_enabled(bool(merged.get("autostart", False))) @@ -533,8 +544,8 @@ def _edit_config_dialog() -> None: return do_restart = messagebox.askyesno( - "Перезапустить?", - "Настройки сохранены.\n\nПерезапустить прокси сейчас?", + t("dialog.restart_title"), + t("dialog.restart_body"), parent=root, ) _finish() @@ -565,7 +576,7 @@ def _show_first_run() -> None: theme = ctk_theme_for_platform() w, h = FIRST_RUN_SIZE root = create_ctk_toplevel( - ctk, title="TG WS Proxy", width=w, height=h, theme=theme, + ctk, title=t("app.name"), width=w, height=h, theme=theme, after_create=lambda r: r.iconbitmap(ICON_PATH), ) @@ -590,14 +601,14 @@ def _build_menu(): port = _config.get("port", DEFAULT_CONFIG["port"]) link_host = get_link_host(host) return pystray.Menu( - pystray.MenuItem(f"Открыть в Telegram ({link_host}:{port})", _on_open_in_telegram, default=True), - pystray.MenuItem("Скопировать ссылку", _on_copy_link), + pystray.MenuItem(t("tray.open_telegram", host=link_host, port=port), _on_open_in_telegram, default=True), + pystray.MenuItem(t("tray.copy_link"), _on_copy_link), pystray.Menu.SEPARATOR, - pystray.MenuItem("Перезапустить прокси", _on_restart), - pystray.MenuItem("Настройки...", _on_edit_config), - pystray.MenuItem("Открыть логи", _on_open_logs), + pystray.MenuItem(t("tray.restart"), _on_restart), + pystray.MenuItem(t("tray.settings"), _on_edit_config), + pystray.MenuItem(t("tray.logs"), _on_open_logs), pystray.Menu.SEPARATOR, - pystray.MenuItem("Выход", _on_exit), + pystray.MenuItem(t("tray.exit"), _on_exit), ) @@ -628,7 +639,7 @@ def run_tray() -> None: _show_first_run() check_ipv6_warning(_show_info) - _tray_icon = pystray.Icon(APP_NAME, load_icon(), "TG WS Proxy", menu=_build_menu()) + _tray_icon = pystray.Icon(APP_NAME, load_icon(), t("app.name"), menu=_build_menu()) log.info("Tray icon running") _tray_icon.run() @@ -638,7 +649,7 @@ def run_tray() -> None: def main() -> None: if (mutex_result := _acquire_win_mutex()) is False or mutex_result is None and not acquire_lock(): - _show_info("Приложение уже запущено.", os.path.basename(sys.argv[0])) + _show_info(t("dialog.already_running"), os.path.basename(sys.argv[0])) return if IS_FROZEN: