zapret-kvn/xray_fluent/engines/zapret/presets.py
loop-uh 672e41fdd1
Some checks failed
Windows project source guards / test (push) Failing after 28s
Zapret: макет с нуля — список ↔ карточка вместо выпадающих списков
- Пресет выбирается на странице пресетов: поиск, в строке — что пресет
  обходит (цели из --hostlist/--ipset/--hostlist-domains/--filter-l7 и
  «остальной трафик»), в карточке — цели, порты перехвата, профили и
  кнопки «Выбрать», «Изменить», «Удалить». Выпадающий список на 33
  пункта, не влезавший в окно, убран.
- Стратегия вида сервера: поиск, фильтр по метке с количеством, строки
  «название + описание» с галочкой у используемой; карточка с описанием,
  автором и аргументами по параметрам, кнопка «Использовать».
  Подсветка ничего не меняет, кнопка и переключатель обхода применяют
  сразу — отдельного «Применить» больше нет.
- На узком окне карточка уходит под список, ничего не обрезается.
- Сводка пресета считается при чтении списка (presets.summarize_arguments).
2026-09-28 21:28:46 +03:00

282 lines
9.1 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.

"""Preset files: the user's own winws2 command lines under ``zapret/presets``.
A preset is a text file of winws2 arguments, one per line, with a ``# Key:``
comment header (name, description, dates). It configures site bypass and runs
on its own; the selected-server rule is added on top at launch time and never
written into the file.
"""
from __future__ import annotations
import logging
import shutil
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from ...constants import BASE_DIR
log = logging.getLogger(__name__)
ZAPRET_DIR = BASE_DIR / "zapret"
PRESETS_DIR = ZAPRET_DIR / "presets"
#: Preset used when the user never picked one; falls back to any preset on disk.
DEFAULT_PRESET_NAME = "Default"
_HEADER_KEYS = ("Preset", "Description", "Created", "Modified", "BuiltinVersion")
_REWRITTEN_KEYS = ("Preset", "Description", "Created", "Modified")
_TARGET_PREFIXES = ("--hostlist=", "--ipset=", "--hostlist-domains=", "--ipset-ip=", "--filter-l7=")
#: Generic list names read better in words.
_TARGET_WORDS = {
"other": "прочие сайты",
"blacklist": "реестр блокировок",
"all": "общий IP-список",
"base": "",
"exclude": "",
}
@dataclass(frozen=True, slots=True)
class PresetSummary:
"""What a preset works on, read from its own arguments."""
profiles: int = 0
tcp_ports: str = ""
udp_ports: str = ""
#: Named targets (lists, domains, L7 protocols) in order of appearance.
targets: tuple[str, ...] = ()
#: Some profile has a filter but no target: it handles all other traffic.
catch_all: bool = False
def short(self) -> str:
"""One line for a list row: targets first, then the rest."""
parts = list(self.targets[:4])
if len(self.targets) > 4:
parts.append(f"ещё {len(self.targets) - 4}")
if self.catch_all:
parts.append("остальной трафик")
return ", ".join(parts) or "без целей"
def _target_name(raw: str) -> str:
"""``lists/ipset-discord.txt`` → ``discord``; ``googlevideo.com`` → ``googlevideo``."""
value = raw.strip().replace("\\", "/").lower()
if "/" in value or value.endswith(".txt"):
name = value.rsplit("/", 1)[-1].removesuffix(".txt")
else:
# A domain names its site: updates.discord.com → discord.
labels = [label for label in value.split(".") if label]
name = labels[-2] if len(labels) >= 2 else (labels[0] if labels else "")
for prefix in ("ipset-", "russia-"):
name = name.removeprefix(prefix)
for suffix in ("-ipset_v6", "-ipset", "_v6", "-rtmps"):
name = name.removesuffix(suffix)
name = name.rstrip("0123456789")
return _TARGET_WORDS.get(name, name)
def summarize_arguments(args: list[str]) -> PresetSummary:
profiles: list[list[str]] = [[]]
tcp: list[str] = []
udp: list[str] = []
for arg in args:
if arg == "--new" or arg.startswith("--new="):
profiles.append([])
elif arg.startswith("--wf-tcp-out="):
tcp.append(arg.removeprefix("--wf-tcp-out="))
elif arg.startswith("--wf-udp-out="):
udp.append(arg.removeprefix("--wf-udp-out="))
else:
profiles[-1].append(arg)
targets: dict[str, None] = {}
catch_all = False
counted = 0
for profile in profiles:
if not any(arg.startswith("--filter-") for arg in profile):
continue
counted += 1
named = False
for arg in profile:
prefix = next((item for item in _TARGET_PREFIXES if arg.startswith(item)), "")
if not prefix:
continue
named = True
if prefix == "--ipset-ip=":
continue
for value in arg[len(prefix):].split(","):
target = _target_name(value)
if len(target) > 1: # x.com / t.me read as noise
targets.setdefault(target, None)
catch_all = catch_all or not named
return PresetSummary(
profiles=counted,
tcp_ports=",".join(tcp),
udp_ports=",".join(udp),
targets=tuple(targets),
catch_all=catch_all,
)
@dataclass(frozen=True, slots=True)
class PresetInfo:
name: str
description: str
created: str
modified: str
arg_count: int
file_path: Path
summary: PresetSummary = PresetSummary()
def preset_path(name: str) -> Path:
return PRESETS_DIR / f"{name}.txt"
def _is_preset_file(path: Path) -> bool:
return path.suffix == ".txt" and not path.name.startswith("_")
def _argument_lines(text: str) -> list[str]:
return [
stripped for stripped in (line.strip() for line in text.splitlines())
if stripped and not stripped.startswith("#")
]
def _header(text: str) -> dict[str, str]:
"""``# Key: value`` lines before the first argument."""
meta: dict[str, str] = {}
for line in text.splitlines()[:15]:
stripped = line.strip()
if not stripped:
continue
if not stripped.startswith("#"):
break
for key in _HEADER_KEYS:
prefix = f"# {key}:"
if stripped.startswith(prefix):
meta[key] = stripped[len(prefix):].strip()
break
return meta
def _info(path: Path, text: str) -> PresetInfo:
meta = _header(text)
args = _argument_lines(text)
return PresetInfo(
name=path.stem,
description=meta.get("Description", ""),
created=meta.get("Created", ""),
modified=meta.get("Modified", ""),
arg_count=len(args),
file_path=path,
summary=summarize_arguments(args),
)
def list_presets() -> list[str]:
if not PRESETS_DIR.is_dir():
return []
return sorted(path.stem for path in PRESETS_DIR.iterdir() if _is_preset_file(path))
def list_preset_infos() -> list[PresetInfo]:
if not PRESETS_DIR.is_dir():
return []
result: list[PresetInfo] = []
for path in sorted(PRESETS_DIR.iterdir()):
if not _is_preset_file(path):
continue
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
# This runs while the window is built; one unreadable file must not
# take the whole page down with it.
log.warning("Не удалось прочитать пресет %s: %s", path.name, exc)
continue
result.append(_info(path, text))
return result
def default_preset() -> str:
"""Preset a fresh install can connect with (``Default``, else any)."""
if preset_path(DEFAULT_PRESET_NAME).is_file():
return DEFAULT_PRESET_NAME
available = list_presets()
return available[0] if available else ""
def read_preset(name: str) -> str:
path = preset_path(name)
if not path.is_file():
return ""
return path.read_text(encoding="utf-8", errors="replace")
def preset_arguments(name: str) -> list[str]:
"""winws2 arguments of a preset (``@file`` can't take spaces in the path)."""
return _argument_lines(preset_path(name).read_text(encoding="utf-8", errors="replace"))
def save_preset(name: str, content: str, description: str = "") -> PresetInfo:
"""Write a preset, refreshing its header and keeping the creation date."""
path = preset_path(name)
PRESETS_DIR.mkdir(parents=True, exist_ok=True)
created = _header(path.read_text(encoding="utf-8", errors="replace")).get("Created", "") \
if path.is_file() else ""
now = datetime.now().isoformat(timespec="seconds")
body = [
line for line in content.splitlines()
if not any(line.strip().startswith(f"# {key}:") for key in _REWRITTEN_KEYS)
]
while body and not body[0].strip():
body.pop(0)
text = (
f"# Preset: {name}\n# Description: {description}\n"
f"# Created: {created or now}\n# Modified: {now}\n\n"
+ "\n".join(body) + "\n"
)
path.write_text(text, encoding="utf-8")
return _info(path, text)
def rename_preset(old_name: str, new_name: str) -> PresetInfo | None:
old_path, new_path = preset_path(old_name), preset_path(new_name)
if not old_path.is_file() or new_path.exists():
return None
text = old_path.read_text(encoding="utf-8", errors="replace")
text = text.replace(f"# Preset: {old_name}", f"# Preset: {new_name}", 1)
new_path.write_text(text, encoding="utf-8")
old_path.unlink()
return _info(new_path, text)
def delete_preset(name: str) -> bool:
path = preset_path(name)
if not path.is_file():
return False
path.unlink()
return True
def import_preset(source_path: Path) -> PresetInfo | None:
"""Copy a preset file in, numbering the name on conflicts."""
if not source_path.is_file():
return None
PRESETS_DIR.mkdir(parents=True, exist_ok=True)
target = preset_path(source_path.stem)
counter = 1
while target.exists():
target = preset_path(f"{source_path.stem} ({counter})")
counter += 1
shutil.copy2(source_path, target)
return _info(target, target.read_text(encoding="utf-8", errors="replace"))