diff --git a/docs/README.md b/docs/README.md index dfc4089..c25ed4f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -121,6 +121,14 @@ chmod +x TgWsProxy_linux_amd64 При первом запуске откроется окно с инструкцией. Приложение работает в системном трее (требуется AppIndicator). +В Linux-настройках доступен обычный tray-автозапуск: + +- **Автозапуск tray при входе в сессию** - создаёт `~/.config/autostart/tg-ws-proxy.desktop` и подходит для GUI-режима + +> [!NOTE] +> +> XDG-автозапуск tray поддерживается только для упакованной Linux-сборки (`.deb` / `TgWsProxy_linux_amd64`). + ## Установка из исходников ### Консольный proxy @@ -272,11 +280,12 @@ Tray-приложение хранит данные в: "cfproxy": true, "cfproxy_priority": true, "cfproxy_user_domain": "", - "appearance": "auto" + "appearance": "auto", + "linux_gui_autostart": false } ``` -Ключ **`check_updates`** — при `true` при запросе к GitHub сравнивается версия с последним релизом (только уведомление и ссылка на страницу загрузки). На Windows в конфиге может быть **`autostart`** (автозапуск при входе в систему). +Ключ **`check_updates`** - при `true` при запросе к GitHub сравнивается версия с последним релизом (только уведомление и ссылка на страницу загрузки). На Windows в конфиге может быть **`autostart`** (автозапуск при входе в систему). На Linux используется **`linux_gui_autostart`**, а фактическое состояние дополнительно синхронизируется с XDG autostart. ## Автоматическая сборка diff --git a/linux.py b/linux.py index 45b6eda..80995f7 100644 --- a/linux.py +++ b/linux.py @@ -14,6 +14,11 @@ from PIL import Image, ImageTk from proxy import get_link_host +from utils.linux_integration import ( + linux_autostart_capabilities, + read_linux_autostart_state, + sync_linux_autostart, +) from utils.tray_common import ( APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, LOG_FILE, acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog, @@ -22,6 +27,7 @@ from utils.tray_common import ( save_config, start_proxy, stop_proxy, tg_proxy_url, ) from ui.ctk_tray_ui import ( + AutostartSectionConfig, install_tray_config_buttons, install_tray_config_form, populate_first_run_window, tray_settings_scroll_and_footer, validate_config_form, @@ -53,7 +59,7 @@ def _msgbox(kind: str, text: str, title: str, **kw): return result -def _show_error(text: str, title: str = "TG WS Proxy — Ошибка") -> None: +def _show_error(text: str, title: str = "TG WS Proxy - Ошибка") -> None: _msgbox("showerror", text, title) @@ -142,19 +148,36 @@ def _edit_config_dialog() -> None: _show_error("customtkinter не установлен.") return - cfg = dict(_config) + capabilities = linux_autostart_capabilities() + cfg = read_linux_autostart_state(_config) def _build(done: threading.Event) -> 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="TG WS Proxy - Настройки", 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) + widgets = install_tray_config_form( + ctk, + scroll, + theme, + cfg, + DEFAULT_CONFIG, + autostart_section=AutostartSectionConfig( + title="Автозапуск", + linux_gui_autostart=capabilities.gui_autostart_supported, + linux_gui_reason=( + "Создаёт XDG desktop entry в ~/.config/autostart." + if capabilities.gui_autostart_supported + else capabilities.gui_autostart_reason + ), + ), + linux_gui_autostart_value=cfg.get("linux_gui_autostart", False), + ) def _finish() -> None: root.destroy() @@ -162,15 +185,35 @@ def _edit_config_dialog() -> None: def on_save() -> None: from tkinter import messagebox - merged = validate_config_form(widgets, DEFAULT_CONFIG, include_autostart=False) + merged = validate_config_form( + widgets, + DEFAULT_CONFIG, + include_autostart=False, + include_linux_gui_autostart=capabilities.gui_autostart_supported, + ) if isinstance(merged, str): - messagebox.showerror("TG WS Proxy — Ошибка", merged, parent=root) + messagebox.showerror("TG WS Proxy - Ошибка", merged, parent=root) return save_config(merged) _config.update(merged) + sync_errors = sync_linux_autostart( + gui_enabled=bool(merged.get("linux_gui_autostart", False)), + ) log.info("Config saved: %s", merged) _tray_icon.menu = _build_menu() + if sync_errors: + parts = [] + for scope, text in sync_errors: + label = "XDG autostart" if scope == "gui" else scope + parts.append(f"{label}: {text}") + messagebox.showwarning( + "TG WS Proxy - предупреждение", + "Настройки сохранены, но автозапуск обновился не полностью:\n\n" + + "\n".join(parts), + parent=root, + ) + do_restart = messagebox.askyesno( "Перезапустить?", "Настройки сохранены.\n\nПерезапустить прокси сейчас?", diff --git a/packaging/linux/tg-ws-proxy.desktop b/packaging/linux/tg-ws-proxy.desktop new file mode 100644 index 0000000..cde324c --- /dev/null +++ b/packaging/linux/tg-ws-proxy.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Type=Application +Name=TG WS Proxy +GenericName=Telegram Proxy +Comment=Telegram Desktop WebSocket Bridge Proxy +Exec=tg-ws-proxy +Icon=tg-ws-proxy +Terminal=false +Categories=Network; +StartupNotify=true +Keywords=telegram;proxy;websocket; diff --git a/ui/ctk_tray_ui.py b/ui/ctk_tray_ui.py index 3d3e6e1..b6dc4d3 100644 --- a/ui/ctk_tray_ui.py +++ b/ui/ctk_tray_ui.py @@ -299,6 +299,16 @@ class TrayConfigFormWidgets: cfproxy_priority_var: Optional[Any] = None cfproxy_user_domain_var: Optional[Any] = None appearance_var: Optional[Any] = None + linux_gui_autostart_var: Optional[Any] = None + + +@dataclass(frozen=True) +class AutostartSectionConfig: + title: str + hint: Optional[str] = None + windows_autostart: bool = False + linux_gui_autostart: bool = False + linux_gui_reason: Optional[str] = None def install_tray_config_form( @@ -308,8 +318,9 @@ def install_tray_config_form( cfg: dict, default_config: dict, *, - show_autostart: bool = False, + autostart_section: Optional[AutostartSectionConfig] = None, autostart_value: bool = False, + linux_gui_autostart_value: bool = False, ) -> TrayConfigFormWidgets: header = ctk.CTkFrame(frame, fg_color="transparent") header.pack(fill="x", pady=(0, 2)) @@ -571,18 +582,57 @@ def install_tray_config_form( ).pack(anchor="w") autostart_var = None - if show_autostart: - sys_inner = _config_section(ctk, frame, theme, "Запуск Windows", bottom_spacer=4) - autostart_var = ctk.BooleanVar(value=autostart_value) - as_cb = _checkbox(ctk, sys_inner, theme, "Автозапуск при включении компьютера", autostart_var) - as_cb.pack(anchor="w", pady=(0, 4)) - as_hint = _label( - ctk, sys_inner, theme, - "Если переместить программу в другую папку, запись автозапуска может сброситься.", - size=11, justify="left", wraplength=_INNER_W, - ) - as_hint.pack(anchor="w") - attach_tooltip_to_widgets([as_cb, as_hint], _TIP_AUTOSTART) + linux_gui_autostart_var = None + if autostart_section is not None: + sys_inner = _config_section(ctk, frame, theme, autostart_section.title, bottom_spacer=4) + added_rows = False + + if autostart_section.hint: + _label( + ctk, sys_inner, theme, autostart_section.hint, + size=11, justify="left", wraplength=_INNER_W, + ).pack(anchor="w", pady=(0, 6)) + + if autostart_section.windows_autostart: + autostart_var = ctk.BooleanVar(value=autostart_value) + as_cb = _checkbox(ctk, sys_inner, theme, "Автозапуск при включении компьютера", autostart_var) + as_cb.pack(anchor="w", pady=(0, 4)) + as_hint = _label( + ctk, sys_inner, theme, + "Если переместить программу в другую папку, запись автозапуска может сброситься.", + size=11, justify="left", wraplength=_INNER_W, + ) + as_hint.pack(anchor="w", pady=(0, 6)) + attach_tooltip_to_widgets([as_cb, as_hint], _TIP_AUTOSTART) + added_rows = True + + if autostart_section.linux_gui_autostart: + linux_gui_autostart_var = ctk.BooleanVar(value=linux_gui_autostart_value) + gui_cb = _checkbox(ctk, sys_inner, theme, "Запускать tray при входе в графическую сессию", linux_gui_autostart_var) + gui_cb.pack(anchor="w", pady=(0, 4)) + gui_hint = autostart_section.linux_gui_reason or "Создаёт XDG desktop entry в ~/.config/autostart." + gui_lbl = _label( + ctk, sys_inner, theme, gui_hint, + size=11, justify="left", wraplength=_INNER_W, + ) + gui_lbl.pack(anchor="w", pady=(0, 6)) + attach_tooltip_to_widgets([gui_cb, gui_lbl], gui_hint) + added_rows = True + + if not added_rows: + unavailable = [] + if autostart_section.linux_gui_reason: + unavailable.append(f"Tray autostart: {autostart_section.linux_gui_reason}") + if unavailable: + _label( + ctk, + sys_inner, + theme, + "\n".join(unavailable), + size=11, + justify="left", + wraplength=_INNER_W, + ).pack(anchor="w") return TrayConfigFormWidgets( host_var=host_var, port_var=port_var, secret_var=secret_var, @@ -593,6 +643,7 @@ def install_tray_config_form( cfproxy_priority_var=cfproxy_priority_var, cfproxy_user_domain_var=cfproxy_user_domain_var, appearance_var=appearance_var, + linux_gui_autostart_var=linux_gui_autostart_var, ) @@ -618,6 +669,7 @@ def validate_config_form( default_config: dict, *, include_autostart: bool, + include_linux_gui_autostart: bool = False, ) -> Union[dict, str]: import socket as _sock @@ -665,6 +717,12 @@ def validate_config_form( if widgets.autostart_var is not None else False ) + if include_linux_gui_autostart: + new_cfg["linux_gui_autostart"] = ( + widgets.linux_gui_autostart_var.get() + if widgets.linux_gui_autostart_var is not None + else False + ) merge_adv_from_form(widgets, new_cfg, default_config) if widgets.check_updates_var is not None: diff --git a/utils/default_config.py b/utils/default_config.py index 8e0dc5b..45c8a45 100644 --- a/utils/default_config.py +++ b/utils/default_config.py @@ -1,7 +1,4 @@ -""" -Общие значения по умолчанию для tray-приложений (Windows / Linux / macOS). -Единственное отличие по платформе — ключ autostart только на Windows. -""" +"""Общие значения по умолчанию для tray-приложений.""" from __future__ import annotations import sys @@ -29,5 +26,7 @@ def default_tray_config() -> Dict[str, Any]: if sys.platform == "win32": cfg["autostart"] = False + elif sys.platform.startswith("linux"): + cfg["linux_gui_autostart"] = False return cfg diff --git a/utils/linux_integration.py b/utils/linux_integration.py new file mode 100644 index 0000000..6397f9c --- /dev/null +++ b/utils/linux_integration.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import logging +import os +import sys +import tempfile +from configparser import ConfigParser, Error as ConfigError +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +from utils.tray_common import APP_NAME + +log = logging.getLogger("tg-ws-tray") + + +@dataclass(frozen=True) +class LinuxAutostartCapabilities: + gui_autostart_supported: bool + gui_autostart_reason: Optional[str] + + +def _xdg_config_home() -> Path: + return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + + +def _xdg_autostart_dir() -> Path: + return _xdg_config_home() / "autostart" + + +def _xdg_autostart_file() -> Path: + return _xdg_autostart_dir() / "tg-ws-proxy.desktop" + + +def _shell_quote(value: str) -> str: + if not value: + return '""' + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + +def _is_frozen_build() -> bool: + return bool(getattr(sys, "frozen", False)) + + +def _desktop_exec() -> Optional[str]: + if not _is_frozen_build(): + return None + return _shell_quote(sys.executable) + + +def _desktop_entry_text(exec_cmd: str) -> str: + return "\n".join( + [ + "[Desktop Entry]", + "Type=Application", + f"Name={APP_NAME}", + "GenericName=Telegram Proxy", + "Comment=Telegram Desktop WebSocket Bridge Proxy", + f"Exec={exec_cmd}", + "Icon=tg-ws-proxy", + "Terminal=false", + "Categories=Network;", + "StartupNotify=true", + "Hidden=false", + "X-GNOME-Autostart-enabled=true", + "", + ] + ) + + +def _read_desktop_entry(path: Path) -> Optional[ConfigParser]: + if not path.exists(): + return None + + parser = ConfigParser(interpolation=None) + parser.optionxform = str + try: + with path.open("r", encoding="utf-8") as handle: + parser.read_file(handle) + except (OSError, ConfigError) as exc: + log.warning("Failed to read Linux autostart entry %s: %s", path, exc) + return None + + if not parser.has_section("Desktop Entry"): + log.warning("Linux autostart entry has no [Desktop Entry] section: %s", path) + return None + + return parser + + +def _atomic_write_text(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temp_path: Optional[Path] = None + try: + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + delete=False, + ) as handle: + handle.write(content) + temp_path = Path(handle.name) + os.chmod(temp_path, 0o644) + os.replace(temp_path, path) + finally: + if temp_path is not None and temp_path.exists(): + temp_path.unlink(missing_ok=True) + + +def linux_autostart_capabilities() -> LinuxAutostartCapabilities: + gui_exec = _desktop_exec() + gui_reason = None if gui_exec else "XDG autostart доступен только в упакованной Linux-сборке." + if gui_exec: + log.debug("Linux GUI autostart supported, exec=%s", gui_exec) + else: + log.info("Linux GUI autostart unavailable: %s", gui_reason) + return LinuxAutostartCapabilities( + gui_autostart_supported=gui_exec is not None, + gui_autostart_reason=gui_reason, + ) + + +def is_linux_gui_autostart_enabled() -> bool: + path = _xdg_autostart_file() + exec_cmd = _desktop_exec() + if exec_cmd is None: + return False + + parser = _read_desktop_entry(path) + if parser is None: + return False + + section = parser["Desktop Entry"] + file_exec = section.get("Exec", "").strip() + file_type = section.get("Type", "").strip() + hidden = section.get("Hidden", "false").strip().lower() + enabled = section.get("X-GNOME-Autostart-enabled", "true").strip().lower() + + if file_type != "Application": + log.warning("Linux autostart entry has unexpected Type=%s in %s", file_type, path) + return False + if hidden in {"1", "true", "yes", "on"}: + log.info("Linux autostart entry is marked Hidden=true: %s", path) + return False + if enabled in {"0", "false", "no", "off"}: + log.info("Linux autostart entry is disabled by X-GNOME-Autostart-enabled=false: %s", path) + return False + if file_exec != exec_cmd: + log.warning( + "Linux autostart exec mismatch in %s: current=%s expected=%s", + path, + file_exec, + exec_cmd, + ) + return False + + return True + + +def set_linux_gui_autostart_enabled(enabled: bool) -> None: + path = _xdg_autostart_file() + exec_cmd = _desktop_exec() + if exec_cmd is None: + raise RuntimeError("XDG autostart поддерживается только для упакованной Linux-сборки.") + + if enabled: + content = _desktop_entry_text(exec_cmd) + current = None + if path.exists(): + try: + current = path.read_text(encoding="utf-8") + except OSError as exc: + log.warning("Failed to read existing Linux autostart entry %s: %s", path, exc) + if current == content: + log.info("Linux autostart already up to date: %s", path) + return + _atomic_write_text(path, content) + log.info("Linux autostart enabled: %s", path) + return + + if path.exists(): + path.unlink(missing_ok=True) + log.info("Linux autostart disabled: %s", path) + else: + log.info("Linux autostart already disabled: %s", path) + + +def sync_linux_autostart( + *, + gui_enabled: bool, +) -> List[Tuple[str, str]]: + errors: List[Tuple[str, str]] = [] + log.info("Syncing Linux autostart: gui_enabled=%s", gui_enabled) + + try: + set_linux_gui_autostart_enabled(gui_enabled) + except Exception as exc: + log.error("Failed to sync Linux autostart: %s", exc) + errors.append(("gui", str(exc))) + + return errors + + +def read_linux_autostart_state(cfg: Dict[str, object]) -> Dict[str, object]: + merged = dict(cfg) + merged["linux_gui_autostart"] = is_linux_gui_autostart_enabled() + log.info("Detected Linux autostart state: linux_gui_autostart=%s", merged["linux_gui_autostart"]) + return merged diff --git a/windows.py b/windows.py index e3b5ecb..887042b 100644 --- a/windows.py +++ b/windows.py @@ -44,6 +44,7 @@ from utils.tray_common import ( save_config, start_proxy, stop_proxy, tg_proxy_url, ) from ui.ctk_tray_ui import ( + AutostartSectionConfig, install_tray_config_buttons, install_tray_config_form, populate_first_run_window, tray_settings_scroll_and_footer, validate_config_form, @@ -257,7 +258,10 @@ def _edit_config_dialog() -> None: scroll, footer = tray_settings_scroll_and_footer(ctk, frame, theme) widgets = install_tray_config_form( ctk, scroll, theme, cfg, DEFAULT_CONFIG, - show_autostart=_supports_autostart(), + autostart_section=AutostartSectionConfig( + title="Автозапуск", + windows_autostart=_supports_autostart(), + ), autostart_value=cfg.get("autostart", False), )