zapret-kvn/xray_fluent/ui/singbox/guide.py
loop-uh f9971d5a1f
Some checks failed
Windows project source guards / test (push) Failing after 31s
Zapret: модуль и страница переписаны — проще, понятнее, строже
Движок разложен на части с одной задачей каждая:
- endpoint: вид сервера (TCP / QUIC / WireGuard), хосты, порты, DNS;
- strategies: каталоги winws2 и правило для выбранного сервера;
- presets: файлы пресетов;
- command: сборка командной строки winws2 (чистая функция);
- manager: процесс winws2 с одним входом apply(план) и ревизиями —
  готовность только по started процесса нужной ревизии, таймаут 5 с.
Склейка с подключением (~450 строк в контроллере, два наслоенных
механизма «UDP-защиты» и «профиля сервера», кэш и прогрев DNS) заменена
шлюзом application/server_bypass.py. Сохранены: DNS только в воркере,
остановка туннеля перед сменой правила (кроме восстановления Hysteria),
забор перед стартом ядра, fail-closed при потере winws2 (теперь и по
таймауту перезапуска), маскировка IP сервера в логах до старта ядра;
адреса серверов пула изучаются в фоне после подключения.

Поведение:
- у QUIC и WireGuard теперь свои стратегии; ключ zapret_target совместим,
  старые сборки после отката читают udp_* как раньше;
- в каталогах вернулись 82 составные стратегии с ветками --payload
  (валидатор принимал только --lua-desync и молча их выбрасывал);
- смена пресета под защищённой сессией идёт через переход: туннель
  останавливается, winws2 перезапускается, VPN подключается снова;
- пропавший файл выбранного пресета — откат к Default, а не отказ;
- правка работающего пресета перезапускает его; переименование больше
  не оставляет копию, занятое имя отклоняется;
- настройки Zapret не откатываются при сохранении страницы «Настройки».

Интерфейс: карточка состояния (работает/нет, пресет, одна кнопка,
автозапуск), выбранный VPN-сервер с живым правилом, три строки видов
серверов с подстраницей стратегии, пресеты — отдельной подстраницей.
Объяснение — в окне «Как это б#&^ь работает?» с иллюстрацией.
2026-09-28 20:53:25 +03:00

336 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""«Как это б#&^ь работает?» — объяснение раздела «Маршрутизации» для новичков.
На основном экране раздела остаются только заголовок и сами данные; длинные
пояснения и живая иллюстрация раздела показываются по ссылке в стоковом
окне qfluentwidgets поверх приложения (``MessageBoxBase`` с затемнением).
Окно создаётся при открытии и удаляется через ``dispose_later`` после
закрытия: в нём есть ScrollArea со SmoothScrollDelegate (см. qt_lifecycle).
"""
from __future__ import annotations
from typing import Callable
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QSizePolicy, QVBoxLayout, QWidget
from qfluentwidgets import (
BodyLabel,
FluentIcon as FIF,
HyperlinkButton,
MessageBoxBase,
ScrollArea,
StrongBodyLabel,
SubtitleLabel,
)
from ..qt_lifecycle import dispose_later
from .art import (
ArtCanvas,
DnsArt,
JsonArt,
OutboundHubArt,
RouteMapArt,
RuleScanArt,
RuleSetArt,
TunnelArt,
ZapretArt,
)
LINK_TEXT = "Как это б#&^ь работает?"
#: «&» у кнопок Qt — метка горячей клавиши; «&&» рисует сам символ.
LINK_LABEL = LINK_TEXT.replace("&", "&&")
#: Абзац-строка — обычный текст; ``("h", …)`` — подзаголовок.
Paragraph = str | tuple[str, str]
GUIDES: dict[str, tuple[str, tuple[Paragraph, ...]]] = {
"overview": (
"Маршрутизация: что это вообще такое?",
(
"Когда VPN включён, трафик компьютера проходит через ядро sing-box. Маршрутизация — это "
"инструкция для ядра, что делать с каждым соединением: пустить через выбранный сервер "
"(прокси), отправить напрямую, как будто VPN выключен, или заблокировать.",
"Зачем это нужно: российские сайты и банки часто не открываются через заграничный сервер "
"или работают медленнее, а заблокированные сайты, наоборот, открываются только через него. "
"Правила разводят трафик так, чтобы работало всё сразу.",
"Картинка сверху — ваш конфиг: пакеты от устройства разбегаются по трём дорожкам, и чем "
"больше правил ведёт в прокси, напрямую или в блок, тем гуще соответствующий поток.",
("h", "Что где лежит"),
"• Правила — главное: какой сайт или программа куда идёт.\n"
"• Наборы правил — большие готовые списки сайтов и адресов.\n"
"• DNS — как узнаются адреса сайтов.\n"
"• Исходящие — куда вообще можно отправить трафик.\n"
"• Система — как трафик попадает в ядро (TUN) и служебные настройки.\n"
"• JSON — всё то же самое одним текстом, для опытных.",
("h", "Конфиг и шаблон"),
"Конфиг — ваш рабочий файл, шаблон — стоковый вариант от разработчиков. При обновлении "
"приложения разделы, которые вы не трогали, сами подтягиваются из нового шаблона, а "
"изменённые вами остаются как есть.",
"Если что-то сломалось, «Сбросить раздел к стоку…» или «Сбросить всё к стоку» вернут "
"заводское состояние. Сброс всего конфига записывается на диск сразу.",
("h", "Кнопки сверху"),
"• Проверить — ядро ищет ошибки в конфиге, ничего не сохраняя.\n"
"• Сохранить — записать правки на диск.\n"
"• Применить — сохранить и, если VPN подключён, сразу переподключиться с новыми правилами.\n"
"• Отменить изменения — вернуть последнее сохранённое состояние.\n"
"Пульсирующая точка слева значит, что есть несохранённые правки.",
"Совет новичку: если всё открывается — ничего здесь не трогайте.",
),
),
"rules": (
"Правила: как это работает?",
(
"Правило — это «если …, то …». Слева условие: домен, IP-адрес, программа, порт или набор "
"правил. Справа действие: куда отправить такое соединение.",
"Порядок важен. Ядро проверяет правила сверху вниз и останавливается на первом "
"подошедшем — анимация сверху показывает именно это: «зонд» бежит по списку и замирает на "
"первом совпадении. Поэтому точечные правила ставьте выше общих.",
("h", "Куда можно отправить"),
"• Прокси (выбранный сервер) — через VPN.\n"
"• Напрямую — мимо VPN, как без него.\n"
"• Блокировать — соединение сбрасывается.\n"
"• Определить протокол (sniff) и Перехватить DNS — служебные правила в начале списка: "
"первое узнаёт, на какой сайт идёт соединение (без него правила по доменам не сработают), "
"второе отдаёт DNS-запросы самому ядру. Их лучше не трогать.",
("h", "Условия"),
"Если в правиле несколько доменов — подходит любой из них. Если указаны разные виды "
"условий, например домен и порт, — должны совпасть оба. «Логическое правило (И / ИЛИ)» "
"собирает несколько правил в одно.",
"«Если ничего не подошло» внизу страницы решает, куда уходит весь остальной трафик.",
("h", "Как добавить правило"),
"«Добавить правило» → «Сайты, IP, программы (просто)». Выберите, куда отправить — через VPN, "
"напрямую или заблокировать, — и перечислите сайты, IP-адреса или программы, по одному в "
"строке. Правило сработает, если совпадёт что-нибудь из списков, и само встанет наверх — выше "
"стоковых, но после служебных и защитных (блок DoT и сервисов проверки IP). «Добавить и "
"применить» сразу включит его, если VPN подключён; просто «Добавить» — после «Применить».",
"Записи как в v2rayN: «2ip.ru» — сайт со всеми поддоменами, «full:» — точно этот адрес, "
"«keyword:» или слово без точки — по слову, «geosite:» и «geoip:» — готовые наборы, "
"«geoip:private» — локальные адреса. Программа — имя файла (telegram.exe) или полный путь, "
"регистр не важен.",
("h", "Почему сайт идёт не туда"),
"Впишите сайт в строку «Проверить сайт» над списком: приложение пройдёт правила так же, как "
"ядро, и покажет, какое сработает и куда уйдёт трафик. Сайты часто подгружают данные с "
"других адресов — например, свой IP сервис может узнавать через другой домен. Проверьте и "
"его и добавьте в то же правило.",
("h", "Как править"),
"Нажмите на строку, чтобы открыть правило со всеми параметрами ядра. Стрелки справа двигают "
"его выше или ниже, рядом — копия и удаление.",
),
),
"rule_sets": (
"Наборы правил: как это работает?",
(
"Набор правил — большой готовый список сайтов или IP-адресов: например, все российские "
"сайты или всё, что заблокировано в РФ. Вместо тысяч доменов вручную правило ссылается на "
"набор одним словом.",
"Обычно geosite-… — списки доменов, geoip-… — списки IP-адресов.",
("h", "Виды наборов"),
"• Локальный файл — .srs или .json на диске; относительные пути считаются от папки core.\n"
"• По URL — скачивается из интернета и обновляется сам.\n"
"• Встроенный — правила записаны прямо в конфиге.",
"Картинка сверху показывает, сколько наборов каждого вида в вашем конфиге.",
("h", "Важно"),
"Сам по себе набор ничего не делает: он работает, только когда на него ссылается правило "
"(в «Правилах» — условие «наборы»). Под каждым набором написано, используется ли он.",
"Если набор нужен какому-то правилу, приложение предупредит перед удалением: без него ядро "
"не запустится.",
),
),
"dns": (
"DNS: как это работает?",
(
"DNS — телефонная книга интернета: превращает имя сайта (youtube.com) в IP-адрес. Прежде "
"чем открыть сайт, компьютер спрашивает адрес у DNS-сервера.",
"Почему это важно: провайдер может подменять ответы для заблокированных сайтов, а по "
"запросам видно, куда вы ходите. Поэтому на DNS-запросы отвечает само ядро и само решает, "
"у какого сервера спросить.",
("h", "Серверы"),
"Список тех, у кого можно спросить: обычный UDP, зашифрованные DoH (https) и DoT (tls), "
"системный и другие. Порядок не важен — сервер выбирают правила.",
"bootstrap-dns нужен самому приложению (через него узнаются адреса других серверов), "
"поэтому он помечен и удалять его не стоит.",
("h", "Правила DNS"),
"Работают как обычные правила: сверху вниз, первое совпадение выбирает сервер. Например, "
"российские домены — к российскому DNS, всё остальное — через защищённый. Если ничего не "
"подошло, запрос уходит на сервер из «final».",
"Обновления приложения больше не перезаписывают ваш DNS. Вернуть стоковый вариант можно "
"на странице «Обзор».",
),
),
"outbounds": (
"Исходящие: как это работает?",
(
"Исходящие (outbounds) — выходы, через которые трафик покидает ядро. Правила лишь "
"выбирают один из выходов по его имени-тегу.",
"Картинка сверху — ваши выходы вокруг ядра.",
("h", "Главные выходы"),
"• proxy — особый выход: при подключении приложение подставляет сюда сервер, выбранный на "
"странице «Серверы». Поэтому здесь его можно только посмотреть.\n"
"• direct — напрямую, мимо VPN. Он нужен самому приложению.\n"
"• block — «чёрная дыра»: всё, что туда отправлено, пропадает.",
("h", "Для опытных"),
"Можно добавить свой сервер (vless, trojan, shadowsocks и другие) и отправлять в него часть "
"трафика правилом. selector и urltest — группы: выбирают один из нескольких серверов "
"вручную или автоматически по пингу.",
"Endpoints — WireGuard, WARP и похожие. Приложение допускает один WireGuard endpoint без "
"outbound proxy (AmneziaWG).",
"Если на выход ссылаются правила, приложение предупредит перед удалением: иначе ядро не "
"запустится.",
),
),
"system": (
"Система: как это работает?",
(
"Служебная часть конфига. Новичку здесь обычно менять нечего.",
("h", "Входящие"),
"Так трафик попадает в ядро. TUN — виртуальная сетевая карта: Windows отправляет в неё "
"трафик всех программ, это и показывает картинка сверху. Для режима TUN такой вход "
"обязателен. Входящие socks/http/mixed — для режима системного прокси, их порты "
"приложение при запуске подставляет само.",
("h", "Остальное"),
"• Маршрутизация: общие параметры — редкие настройки route.*, кроме правил и наборов.\n"
"• Журнал ядра — сколько подробностей писать в лог. Приложение читает журнал из вывода "
"ядра: если направить его в файл или отключить, ошибки пропадут из «Логов».\n"
"• Experimental — cache_file хранит скачанные наборы и DNS между запусками. Через Clash API "
"приложение узнаёт скорость и переключает серверы; его адрес назначается само, а секрет "
"не поддерживается — иначе перестанут работать метрики и переключение.\n"
"• Прочие секции — всё остальное, что знает ядро.",
),
),
"json": (
"JSON: как это работает?",
(
"Это тот же самый конфиг целиком, текстом в формате JSON — ровно то, что читает ядро "
"sing-box. Остальные разделы — удобные формы поверх этого текста.",
"Правки здесь и в разделах — одно и то же. Разделы обновятся, когда вы уйдёте с этой "
"страницы.",
"Если ошибиться в синтаксисе (лишняя запятая, незакрытая скобка), разделы станут "
"недоступны, пока текст не будет исправлен. Кнопка «Проверить» покажет, что не так.",
"Если в outbounds есть тег proxy, при запуске туда подставляется выбранный сервер.",
"Сюда удобно вставить готовый кусок конфига из инструкции или скопировать конфиг целиком, "
"чтобы поделиться им.",
),
),
"zapret": (
"Zapret: как пробить блокировку",
(
"Провайдер следит за трафиком системой DPI: по первым пакетам соединения она узнаёт сайт "
"или VPN-протокол и обрывает его. Zapret (программа winws2) работает на вашем компьютере, "
"до провайдера, и слегка портит эти первые пакеты: режет их на части, пускает перед ними "
"обманку, меняет служебные поля. DPI сбивается и пропускает соединение, а сервер собирает "
"данные как обычно.",
("h", "Две задачи"),
"• Пресет — обход для сайтов и программ (YouTube, Discord и т. д.). Это готовый набор "
"правил winws2. Он запускается кнопкой «Запустить» и работает независимо от VPN.\n"
"• Обход до VPN-сервера — отдельное правило только для адреса выбранного сервера. Оно нужно, "
"когда провайдер режет само подключение к VPN. Правило добавляется поверх пресета.",
("h", "Виды серверов"),
"У TCP-серверов, QUIC-серверов и WireGuard — своя стратегия, потому что обманывать DPI для "
"них нужно по-разному.",
"• TCP (VLESS, Trojan, Shadowsocks…): «выключено» значит «по пресету» — к серверу применяется "
"то же, что и ко всем сайтам.\n"
"• QUIC (Hysteria2, TUIC) и WireGuard: «выключено» значит «не трогать» — Zapret нарочно "
"пропускает пакеты сервера мимо пресета, потому что его обманки ломают такие туннели.",
("h", "Что происходит при подключении"),
"Если для вида сервера обход включён, приложение само узнаёт свежий IP сервера, запускает "
"Zapret (с выбранным пресетом, а если он не выбран — с «Default») и пускает VPN только после "
"того, как Zapret заработал с нужным правилом. Если Zapret остановится посреди такой сессии, "
"VPN тоже остановится — чтобы трафик не пошёл без обхода.",
"Смена пресета или стратегии во время такой сессии коротко переподключает VPN: туннель "
"останавливается, Zapret перезапускается с новыми настройками, и VPN подключается снова.",
("h", "Как выбрать стратегию"),
"Стратегия — это рецепт порчи пакетов. Метка подсказывает, насколько она проверена: начните "
"с «Рекомендуется» и «Стабильная». Не помогло — пробуйте следующую: у разных провайдеров "
"разные DPI. «Своя стратегия» — ваши строки --lua-desync=… из инструкций к zapret.",
("h", "Права администратора"),
"winws2 перехватывает пакеты через драйвер WinDivert, поэтому Zapret работает, только "
"когда приложение запущено от имени администратора.",
),
),
}
ART_CLASSES: dict[str, type[ArtCanvas]] = {
"overview": RouteMapArt,
"rules": RuleScanArt,
"rule_sets": RuleSetArt,
"dns": DnsArt,
"outbounds": OutboundHubArt,
"system": TunnelArt,
"json": JsonArt,
"zapret": ZapretArt,
}
def help_link(parent: QWidget) -> HyperlinkButton:
"""Ссылка без рамки: акцентный текст с иконкой вопроса."""
link = HyperlinkButton(parent)
link.setText(LINK_LABEL)
link.setIcon(FIF.HELP)
link.setToolTip(LINK_TEXT)
link.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
return link
class GuideDialog(MessageBoxBase):
"""Стоковое окно поверх приложения: иллюстрация раздела и текст для новичка."""
#: Ширина окна и отступ от краёв главного окна.
WIDTH = 680
MARGIN = 48
def __init__(self, key: str, parent: QWidget, feed: Callable[[ArtCanvas], None] | None = None):
super().__init__(parent)
title, paragraphs = GUIDES[key]
self.key = key
self.title_label = SubtitleLabel(title, self.widget)
self.viewLayout.addWidget(self.title_label)
self.art = ART_CLASSES[key](self.widget)
if feed is not None:
feed(self.art)
self.viewLayout.addWidget(self.art)
self.scroll = ScrollArea(self.widget)
self.scroll.setWidgetResizable(True)
self.scroll.enableTransparentBackground()
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
body = QWidget()
body.setStyleSheet("QWidget { background: transparent; }")
column = QVBoxLayout(body)
column.setContentsMargins(0, 0, 12, 0)
column.setSpacing(10)
self.labels: list[BodyLabel] = []
for paragraph in paragraphs:
if isinstance(paragraph, tuple):
label = StrongBodyLabel(paragraph[1], body)
else:
label = BodyLabel(paragraph, body)
label.setWordWrap(True)
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
column.addWidget(label)
self.labels.append(label)
column.addStretch(1)
self.scroll.setWidget(body)
self.viewLayout.addWidget(self.scroll, 1)
self.yesButton.setText("Понятно")
self.cancelButton.hide()
self.setClosableOnMaskClicked(True)
self._fit(parent.window())
def _fit(self, window: QWidget) -> None:
"""Окно не шире и не выше главного: текст прокручивается внутри."""
width = max(360, min(self.WIDTH, window.width() - 2 * self.MARGIN))
self.widget.setFixedWidth(width)
# Заголовок, иллюстрация, кнопка и поля занимают около 330 px.
self.scroll.setFixedHeight(max(160, min(520, window.height() - 2 * self.MARGIN - 330)))
def text(self) -> str:
return "\n".join(label.text() for label in self.labels)
def open_guide(key: str, parent: QWidget, feed: Callable[[ArtCanvas], None] | None = None) -> GuideDialog:
"""Показать объяснение без вложенного цикла событий; удалить после закрытия."""
dialog = GuideDialog(key, parent.window(), feed)
dialog.finished.connect(lambda _code: dispose_later(dialog))
dialog.open()
return dialog