Обновил Linux-автозапуск для tray-версии.

Что изменено
  - Linux-настройки управляют автозапуском через XDG autostart.
  - Приложение читает фактическое состояние .desktop-файла при старте и при
  повторном открытии настроек.
  - Запись автозапуска сделана атомарной.
  - Если запись отсутствует, устарела или указывает не на текущий бинарник, при
  сохранении она пересоздается.
  - Для Linux добавлено логирование ключевых действий и проблем автозапуска.
This commit is contained in:
Kintara Vault 2026-04-16 12:38:27 +00:00 • committed by GitHub
parent 77723d875f
commit 06f5345ec7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 360 additions and 26 deletions

View file

@ -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.
## Автоматическая сборка

View file

@ -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Перезапустить прокси сейчас?",

View file

@ -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;

View file

@ -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:

View file

@ -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

210
utils/linux_integration.py Normal file
View file

@ -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

View file

@ -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),
)