zapret-kvn/xray_fluent/models.py
loop-uh 05b03165a6
Some checks failed
Windows project source guards / test (push) Has been cancelled
fix: preserve Hysteria2 links and improve diagnostics
2026-08-31 15:16:09 +03:00

704 lines
31 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.

from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
import re
import uuid
from .constants import DEFAULT_ACCENT_COLOR, ROUTING_RULE, STATE_SCHEMA_VERSION
# Model-level accent validation (no Qt imports here, D2): the color picker
# only ever produces "#RRGGBB" strings; anything else falls back to the
# default. Full QColor-based normalization lives in ui/theme.py.
_ACCENT_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
_SUBSCRIPTION_URI_RE = re.compile(
r"(?i)\b(?:https?|happ|vless|vmess|trojan|ss|hysteria|hysteria2|hy2|tuic|"
r"socks5?|wireguard|awg)://\S+"
)
_SUBSCRIPTION_SECRET_RE = re.compile(
r"(?i)\b(auth|password|obfs-password|privatekey|presharedkey|token|uuid)"
r"\s*[:=]\s*[^\s;,]+"
)
_SUBSCRIPTION_WARNING_LIMIT = 20
_SUBSCRIPTION_WARNING_LENGTH = 500
def normalize_accent_color(value: Any) -> str:
"""Return *value* if it looks like "#RRGGBB", else the default accent."""
text = str(value or "")
return text if _ACCENT_COLOR_RE.fullmatch(text) else DEFAULT_ACCENT_COLOR
def normalize_subscription_warnings(value: Any) -> list[str]:
"""Bound and redact parser diagnostics before they reach persistent state."""
if not isinstance(value, (list, tuple)):
return []
normalized: list[str] = []
for item in value:
if not isinstance(item, str):
continue
text = " ".join(item.split())
text = _SUBSCRIPTION_URI_RE.sub("<ссылка скрыта>", text)
text = _SUBSCRIPTION_SECRET_RE.sub(lambda match: f"{match.group(1)}=<скрыто>", text)
text = text[:_SUBSCRIPTION_WARNING_LENGTH].strip()
if text:
normalized.append(text)
if len(normalized) >= _SUBSCRIPTION_WARNING_LIMIT:
break
return normalized
def utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _int_mapping(value: Any) -> dict[str, int]:
if not isinstance(value, dict):
return {}
result: dict[str, int] = {}
for key, item in value.items():
if isinstance(item, bool) or not isinstance(item, (int, float)):
continue
result[str(key)] = int(item)
return result
@dataclass(slots=True)
class Node:
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = ""
scheme: str = ""
server: str = ""
port: int = 0
link: str = ""
outbound: dict[str, Any] = field(default_factory=dict)
group: str = "Default"
tags: list[str] = field(default_factory=list)
ping_ms: int | None = None
last_used_at: str | None = None
created_at: str = field(default_factory=utc_now_iso)
country_code: str = ""
speed_mbps: float | None = None
is_alive: bool | None = None
ping_history: list[tuple[str, int | None]] = field(default_factory=list)
speed_history: list[tuple[str, float | None]] = field(default_factory=list)
sort_order: int = 0
subscription_id: str | None = None
source_key: str = ""
source_fingerprint: str = ""
provider_name: str = ""
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"scheme": self.scheme,
"server": self.server,
"port": self.port,
"link": self.link,
"outbound": self.outbound,
"group": self.group,
"tags": list(self.tags),
"ping_ms": self.ping_ms,
"last_used_at": self.last_used_at,
"created_at": self.created_at,
"country_code": self.country_code,
"speed_mbps": self.speed_mbps,
"is_alive": self.is_alive,
"ping_history": self.ping_history,
"speed_history": self.speed_history,
"sort_order": self.sort_order,
"subscription_id": self.subscription_id,
"source_key": self.source_key,
"source_fingerprint": self.source_fingerprint,
"provider_name": self.provider_name,
}
@staticmethod
def from_dict(data: dict[str, Any]) -> "Node":
return Node(
id=str(data.get("id") or uuid.uuid4()),
name=str(data.get("name") or ""),
scheme=str(data.get("scheme") or ""),
server=str(data.get("server") or ""),
port=int(data.get("port") or 0),
link=str(data.get("link") or ""),
outbound=dict(data.get("outbound") or {}),
group=str(data.get("group") or "Default"),
tags=list(data.get("tags") or []),
ping_ms=data.get("ping_ms"),
last_used_at=data.get("last_used_at"),
created_at=str(data.get("created_at") or utc_now_iso()),
country_code=str(data.get("country_code") or ""),
speed_mbps=data.get("speed_mbps"),
is_alive=data.get("is_alive"),
ping_history=data.get("ping_history", []),
speed_history=data.get("speed_history", []),
sort_order=int(data.get("sort_order", 0)),
subscription_id=(str(data.get("subscription_id")) if data.get("subscription_id") else None),
source_key=str(data.get("source_key") or ""),
source_fingerprint=str(data.get("source_fingerprint") or ""),
provider_name=str(data.get("provider_name") or data.get("name") or ""),
)
@dataclass(slots=True)
class SubscriptionInfo:
upload: int = 0
download: int = 0
total: int = 0
expire: int = 0
web_page_url: str = ""
support_url: str = ""
@property
def used(self) -> int:
return max(0, self.upload) + max(0, self.download)
def to_dict(self) -> dict[str, Any]:
return {
"upload": self.upload,
"download": self.download,
"total": self.total,
"expire": self.expire,
"web_page_url": self.web_page_url,
"support_url": self.support_url,
}
@staticmethod
def from_dict(data: dict[str, Any]) -> "SubscriptionInfo":
def _number(key: str) -> int:
try:
return max(0, int(data.get(key) or 0))
except (TypeError, ValueError):
return 0
return SubscriptionInfo(
upload=_number("upload"),
download=_number("download"),
total=_number("total"),
expire=_number("expire"),
web_page_url=str(data.get("web_page_url") or ""),
support_url=str(data.get("support_url") or ""),
)
@dataclass(slots=True)
class Subscription:
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = ""
url: str = ""
user_agent: str = ""
client_profile: str = "zapret"
send_hwid: bool = False
hwid: str = ""
auto_update: bool = True
update_interval_hours: int | None = None
provider_interval_hours: int | None = None
include_pattern: str = ""
exclude_pattern: str = ""
etag: str = ""
last_modified: str = ""
# Revision describes the parser that produced the stored node snapshot.
# A new/legacy subscription has no applied snapshot until first reconcile.
parser_revision: int = 0
skipped_count: int = 0
warnings: list[str] = field(default_factory=list)
last_checked_at: str | None = None
last_success_at: str | None = None
last_error: str = ""
failure_count: int = 0
backoff_until: str | None = None
info: SubscriptionInfo = field(default_factory=SubscriptionInfo)
pending_url: str = ""
hidden_source_keys: list[str] = field(default_factory=list)
created_at: str = field(default_factory=utc_now_iso)
sort_order: int = 0
@property
def effective_interval_hours(self) -> int:
value = self.update_interval_hours or self.provider_interval_hours or 24
return max(1, int(value))
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"url": self.url,
"user_agent": self.user_agent,
"client_profile": self.client_profile,
"send_hwid": self.send_hwid,
"hwid": self.hwid,
"auto_update": self.auto_update,
"update_interval_hours": self.update_interval_hours,
"provider_interval_hours": self.provider_interval_hours,
"include_pattern": self.include_pattern,
"exclude_pattern": self.exclude_pattern,
"etag": self.etag,
"last_modified": self.last_modified,
"parser_revision": self.parser_revision,
"skipped_count": self.skipped_count,
"warnings": normalize_subscription_warnings(self.warnings),
"last_checked_at": self.last_checked_at,
"last_success_at": self.last_success_at,
"last_error": self.last_error,
"failure_count": self.failure_count,
"backoff_until": self.backoff_until,
"info": self.info.to_dict(),
"pending_url": self.pending_url,
"hidden_source_keys": list(self.hidden_source_keys),
"created_at": self.created_at,
"sort_order": self.sort_order,
}
@staticmethod
def from_dict(data: dict[str, Any]) -> "Subscription":
def _optional_positive_int(key: str) -> int | None:
try:
value = int(data.get(key) or 0)
except (TypeError, ValueError):
return None
return value if value > 0 else None
def _non_negative_int(key: str) -> int:
try:
return max(0, int(data.get(key) or 0))
except (TypeError, ValueError):
return 0
return Subscription(
id=str(data.get("id") or uuid.uuid4()),
name=str(data.get("name") or ""),
url=str(data.get("url") or ""),
user_agent=str(data.get("user_agent") or ""),
client_profile=str(data.get("client_profile") or "zapret"),
send_hwid=bool(data.get("send_hwid", False)),
hwid=str(data.get("hwid") or ""),
auto_update=bool(data.get("auto_update", True)),
update_interval_hours=_optional_positive_int("update_interval_hours"),
provider_interval_hours=_optional_positive_int("provider_interval_hours"),
include_pattern=str(data.get("include_pattern") or ""),
exclude_pattern=str(data.get("exclude_pattern") or ""),
etag=str(data.get("etag") or ""),
last_modified=str(data.get("last_modified") or ""),
parser_revision=_non_negative_int("parser_revision"),
skipped_count=_non_negative_int("skipped_count"),
warnings=normalize_subscription_warnings(data.get("warnings")),
last_checked_at=data.get("last_checked_at"),
last_success_at=data.get("last_success_at"),
last_error=str(data.get("last_error") or ""),
failure_count=_non_negative_int("failure_count"),
backoff_until=data.get("backoff_until"),
info=SubscriptionInfo.from_dict(dict(data.get("info") or {})),
pending_url=str(data.get("pending_url") or ""),
hidden_source_keys=[str(item) for item in (data.get("hidden_source_keys") or [])],
created_at=str(data.get("created_at") or utc_now_iso()),
sort_order=_non_negative_int("sort_order"),
)
@dataclass(slots=True)
class SubscriptionUpdateResult:
subscription_id: str
success: bool
message: str = ""
added: int = 0
updated: int = 0
removed: int = 0
skipped: int = 0
warnings: list[str] = field(default_factory=list)
not_modified: bool = False
reconnect_required: bool = False
#: Проверка только читает подписку и ничего не сохраняет, поэтому счётчики
#: изменений у неё всегда нулевые и показывать их нельзя.
check_only: bool = False
#: Сколько серверов отдала подписка и сколько уже сохранено локально.
source_count: int = 0
stored_count: int = 0
@dataclass(slots=True)
class RoutingSettings:
mode: str = ROUTING_RULE
bypass_lan: bool = True
direct_domains: list[str] = field(default_factory=list)
proxy_domains: list[str] = field(default_factory=list)
block_domains: list[str] = field(default_factory=list)
dns_mode: str = "system" # system | builtin
dns_bootstrap_server: str = "1.1.1.1" # DNS for direct traffic
dns_bootstrap_type: str = "udp" # udp | tcp | tls | https
dns_proxy_server: str = "8.8.8.8" # DNS for proxy traffic
dns_proxy_type: str = "tcp" # tcp | tls | https
process_rules: list[dict[str, str]] = field(default_factory=list) # [{"process": "chrome.exe", "action": "direct|proxy|block"}]
process_preset_routes: dict[str, str] = field(default_factory=dict) # {"telegram": "proxy", "windows_system": "direct"}
service_routes: dict[str, str] = field(default_factory=dict) # {"youtube": "proxy", "steam": "direct", ...}
tun_default_outbound: str = "direct" # "proxy" | "direct"
def to_dict(self) -> dict[str, Any]:
return {
"mode": self.mode,
"bypass_lan": self.bypass_lan,
"direct_domains": list(self.direct_domains),
"proxy_domains": list(self.proxy_domains),
"block_domains": list(self.block_domains),
"dns_mode": self.dns_mode,
"dns_bootstrap_server": self.dns_bootstrap_server,
"dns_bootstrap_type": self.dns_bootstrap_type,
"dns_proxy_server": self.dns_proxy_server,
"dns_proxy_type": self.dns_proxy_type,
"process_rules": list(self.process_rules),
"process_preset_routes": dict(self.process_preset_routes),
"service_routes": dict(self.service_routes),
"tun_default_outbound": self.tun_default_outbound,
}
@staticmethod
def from_dict(data: dict[str, Any]) -> "RoutingSettings":
return RoutingSettings(
mode=str(data.get("mode") or ROUTING_RULE),
bypass_lan=bool(data.get("bypass_lan", True)),
direct_domains=list(data.get("direct_domains") or []),
proxy_domains=list(data.get("proxy_domains") or []),
block_domains=list(data.get("block_domains") or []),
dns_mode=str(data.get("dns_mode") or "system"),
dns_bootstrap_server=str(data.get("dns_bootstrap_server") or "1.1.1.1"),
dns_bootstrap_type=str(data.get("dns_bootstrap_type") or "udp"),
dns_proxy_server=str(data.get("dns_proxy_server") or "8.8.8.8"),
dns_proxy_type=str(data.get("dns_proxy_type") or "tcp"),
process_rules=list(data.get("process_rules") or []),
process_preset_routes=dict(data.get("process_preset_routes") or {}),
service_routes=dict(data.get("service_routes") or {}),
tun_default_outbound=str(data.get("tun_default_outbound") or "direct"),
)
@dataclass(slots=True)
class SecuritySettings:
enabled: bool = False
password_hash: str = ""
salt: str = ""
auto_lock_minutes: int = 15
def to_dict(self) -> dict[str, Any]:
return {
"enabled": self.enabled,
"password_hash": self.password_hash,
"salt": self.salt,
"auto_lock_minutes": self.auto_lock_minutes,
}
@staticmethod
def from_dict(data: dict[str, Any]) -> "SecuritySettings":
return SecuritySettings(
enabled=bool(data.get("enabled", False)),
password_hash=str(data.get("password_hash") or ""),
salt=str(data.get("salt") or ""),
auto_lock_minutes=int(data.get("auto_lock_minutes") or 15),
)
# --- Subscription auto-update globals (startup-subscription-settings) ---
SUBSCRIPTIONS_CHECK_INTERVAL_MIN = 5
SUBSCRIPTIONS_CHECK_INTERVAL_MAX = 1440
SUBSCRIPTIONS_CHECK_INTERVAL_DEFAULT = 15
STARTUP_CONNECT_ORDERS = ("immediate", "after_subscriptions")
def clamp_subscriptions_check_interval(value: Any) -> int:
"""Clamp the global subscription check interval (minutes) to [5, 1440].
Garbage values (None, non-numeric strings) fall back to the default
without raising, so corrupted state files never break loading.
"""
try:
minutes = int(value)
except (TypeError, ValueError):
return SUBSCRIPTIONS_CHECK_INTERVAL_DEFAULT
return max(
SUBSCRIPTIONS_CHECK_INTERVAL_MIN,
min(SUBSCRIPTIONS_CHECK_INTERVAL_MAX, minutes),
)
def normalize_startup_connect_order(value: Any) -> str:
"""Unknown/legacy values read as \"immediate\" (current behaviour)."""
text = str(value or "immediate")
return text if text in STARTUP_CONNECT_ORDERS else "immediate"
@dataclass(slots=True)
class ZapretTargetSettings:
"""Policy for the synthetic first winws2 profile.
The policy follows the currently selected node. It deliberately stores
strategy choices, not resolved addresses: endpoint DNS is refreshed for
every protected connection attempt.
"""
tcp_proxy_enabled: bool = True
quic_proxy_enabled: bool = False
wireguard_enabled: bool = False
tcp_strategy_id: str = "alt9"
udp_strategy_id: str = ""
tcp_custom_args: str = ""
udp_custom_args: str = ""
def to_dict(self) -> dict[str, Any]:
return {
"tcp_proxy_enabled": self.tcp_proxy_enabled,
"quic_proxy_enabled": self.quic_proxy_enabled,
"wireguard_enabled": self.wireguard_enabled,
"tcp_strategy_id": self.tcp_strategy_id,
"udp_strategy_id": self.udp_strategy_id,
"tcp_custom_args": self.tcp_custom_args,
"udp_custom_args": self.udp_custom_args,
}
@staticmethod
def from_dict(data: Any) -> "ZapretTargetSettings":
payload = data if isinstance(data, dict) else {}
return ZapretTargetSettings(
tcp_proxy_enabled=bool(payload.get("tcp_proxy_enabled", True)),
quic_proxy_enabled=bool(payload.get("quic_proxy_enabled", False)),
wireguard_enabled=bool(payload.get("wireguard_enabled", False)),
tcp_strategy_id=str(payload.get("tcp_strategy_id") or "alt9"),
udp_strategy_id=str(payload.get("udp_strategy_id") or ""),
tcp_custom_args=str(payload.get("tcp_custom_args") or ""),
udp_custom_args=str(payload.get("udp_custom_args") or ""),
)
@dataclass(slots=True)
class AppSettings:
theme: str = "system" # system | light | dark
accent_color: str = DEFAULT_ACCENT_COLOR
auto_connect_last: bool = True
start_minimized: bool = False
enable_system_proxy: bool = True
system_proxy_bypass_lan: bool = True
launch_on_startup: bool = False
reconnect_on_network_change: bool = True
xray_path: str = ""
log_level: str = "warning"
check_updates: bool = True
allow_updates: bool = True
release_channel: str = "stable" # stable | beta | nightly
update_feed_url: str = ""
xray_release_channel: str = "stable" # stable | beta | nightly
xray_update_feed_url: str = ""
xray_auto_update: bool = False
tun_mode: bool = False
proxy_engine: str = "singbox" # "singbox" | "xray"
tun_engine: str = "singbox" # "singbox" | "xray" | "tun2socks"
xray_config_file: str = ""
xray_template_file: str = ""
singbox_path: str = ""
singbox_config_file: str = ""
singbox_template_file: str = ""
window_width: int = 1000
window_height: int = 720
window_x: int = -1
window_y: int = -1
zapret_preset: str = ""
zapret_autostart: bool = False
zapret_target: ZapretTargetSettings = field(default_factory=ZapretTargetSettings)
auto_switch_enabled: bool = True
auto_switch_threshold_kbps: int = 50
auto_switch_delay_sec: int = 30
auto_switch_cooldown_sec: int = 60
# Rotation: periodic exit-node switching inside a pool (xray balancer override)
rotation_enabled: bool = False
rotation_mode: str = "random" # random | sequential
rotation_interval_sec: int = 600
rotation_jitter_pct: int = 20
rotation_pool: str = "all" # all | group | tag | subscription
rotation_pool_value: str = ""
rotation_only_alive: bool = True
rotation_max_nodes: int = 20
# Server list view preferences (stable english keys; empty = defaults)
nodes_sort_key: str = "manual" # manual | name | group | type | ping | speed | last_used
nodes_sort_desc: bool = False
nodes_group_filter: str = ""
nodes_tag_filter: str = ""
nodes_source_filter: str = ""
nodes_visible_columns: list[str] = field(default_factory=list) # empty = default set
nodes_column_widths: dict[str, int] = field(default_factory=dict)
nodes_column_order: list[str] = field(default_factory=list)
nodes_column_layout_version: int = 1
# Subscription auto-update globals (additive, defaults keep current behaviour)
subscriptions_auto_update: bool = True
subscriptions_check_on_startup: bool = True
subscriptions_check_interval_min: int = SUBSCRIPTIONS_CHECK_INTERVAL_DEFAULT # clamp 5..1440
startup_connect_order: str = "immediate" # immediate | after_subscriptions
def to_dict(self) -> dict[str, Any]:
return {
"theme": self.theme,
"accent_color": self.accent_color,
"auto_connect_last": self.auto_connect_last,
"start_minimized": self.start_minimized,
"enable_system_proxy": self.enable_system_proxy,
"system_proxy_bypass_lan": self.system_proxy_bypass_lan,
"launch_on_startup": self.launch_on_startup,
"reconnect_on_network_change": self.reconnect_on_network_change,
"xray_path": self.xray_path,
"log_level": self.log_level,
"check_updates": self.check_updates,
"allow_updates": self.allow_updates,
"release_channel": self.release_channel,
"update_feed_url": self.update_feed_url,
"xray_release_channel": self.xray_release_channel,
"xray_update_feed_url": self.xray_update_feed_url,
"xray_auto_update": self.xray_auto_update,
"tun_mode": self.tun_mode,
"proxy_engine": self.proxy_engine,
"tun_engine": self.tun_engine,
"xray_config_file": self.xray_config_file,
"xray_template_file": self.xray_template_file,
"singbox_path": self.singbox_path,
"singbox_config_file": self.singbox_config_file,
"singbox_template_file": self.singbox_template_file,
"window_width": self.window_width,
"window_height": self.window_height,
"window_x": self.window_x,
"window_y": self.window_y,
"zapret_preset": self.zapret_preset,
"zapret_autostart": self.zapret_autostart,
"zapret_target": self.zapret_target.to_dict(),
"auto_switch_enabled": self.auto_switch_enabled,
"auto_switch_threshold_kbps": self.auto_switch_threshold_kbps,
"auto_switch_delay_sec": self.auto_switch_delay_sec,
"auto_switch_cooldown_sec": self.auto_switch_cooldown_sec,
"rotation_enabled": self.rotation_enabled,
"rotation_mode": self.rotation_mode,
"rotation_interval_sec": self.rotation_interval_sec,
"rotation_jitter_pct": self.rotation_jitter_pct,
"rotation_pool": self.rotation_pool,
"rotation_pool_value": self.rotation_pool_value,
"rotation_only_alive": self.rotation_only_alive,
"rotation_max_nodes": self.rotation_max_nodes,
"nodes_sort_key": self.nodes_sort_key,
"nodes_sort_desc": self.nodes_sort_desc,
"nodes_group_filter": self.nodes_group_filter,
"nodes_tag_filter": self.nodes_tag_filter,
"nodes_source_filter": self.nodes_source_filter,
"nodes_visible_columns": list(self.nodes_visible_columns),
"nodes_column_widths": dict(self.nodes_column_widths),
"nodes_column_order": list(self.nodes_column_order),
"nodes_column_layout_version": self.nodes_column_layout_version,
"subscriptions_auto_update": self.subscriptions_auto_update,
"subscriptions_check_on_startup": self.subscriptions_check_on_startup,
"subscriptions_check_interval_min": self.subscriptions_check_interval_min,
"startup_connect_order": self.startup_connect_order,
}
@staticmethod
def from_dict(data: dict[str, Any]) -> "AppSettings":
return AppSettings(
theme=str(data.get("theme") or "system"),
accent_color=normalize_accent_color(data.get("accent_color")),
auto_connect_last=bool(data.get("auto_connect_last", True)),
start_minimized=bool(data.get("start_minimized", False)),
enable_system_proxy=bool(data.get("enable_system_proxy", True)),
system_proxy_bypass_lan=bool(data.get("system_proxy_bypass_lan", True)),
launch_on_startup=bool(data.get("launch_on_startup", False)),
reconnect_on_network_change=bool(data.get("reconnect_on_network_change", True)),
xray_path=str(data.get("xray_path") or ""),
log_level=str(data.get("log_level") or "warning"),
check_updates=bool(data.get("check_updates", True)),
allow_updates=bool(data.get("allow_updates", True)),
release_channel=str(data.get("release_channel") or "stable"),
update_feed_url=str(data.get("update_feed_url") or ""),
xray_release_channel=str(data.get("xray_release_channel") or "stable"),
xray_update_feed_url=str(data.get("xray_update_feed_url") or ""),
xray_auto_update=bool(data.get("xray_auto_update", False)),
tun_mode=bool(data.get("tun_mode", False)),
proxy_engine=str(data.get("proxy_engine") or "singbox"),
tun_engine=str(data.get("tun_engine") or "singbox"),
xray_config_file=str(data.get("xray_config_file") or ""),
xray_template_file=str(data.get("xray_template_file") or ""),
singbox_path=str(data.get("singbox_path") or ""),
singbox_config_file=str(data.get("singbox_config_file") or ""),
singbox_template_file=str(data.get("singbox_template_file") or ""),
window_width=int(data.get("window_width") or 1000),
window_height=int(data.get("window_height") or 720),
window_x=int(data.get("window_x", -1)),
window_y=int(data.get("window_y", -1)),
zapret_preset=str(data.get("zapret_preset") or ""),
zapret_autostart=bool(data.get("zapret_autostart", False)),
zapret_target=ZapretTargetSettings.from_dict(data.get("zapret_target")),
auto_switch_enabled=bool(data.get("auto_switch_enabled", True)),
auto_switch_threshold_kbps=int(data.get("auto_switch_threshold_kbps") or 50),
auto_switch_delay_sec=int(data.get("auto_switch_delay_sec") or 30),
auto_switch_cooldown_sec=int(data.get("auto_switch_cooldown_sec") or 60),
rotation_enabled=bool(data.get("rotation_enabled", False)),
rotation_mode=str(data.get("rotation_mode") or "random"),
rotation_interval_sec=int(data.get("rotation_interval_sec") or 600),
rotation_jitter_pct=int(data.get("rotation_jitter_pct", 20)),
rotation_pool=str(data.get("rotation_pool") or "all"),
rotation_pool_value=str(data.get("rotation_pool_value") or ""),
rotation_only_alive=bool(data.get("rotation_only_alive", True)),
rotation_max_nodes=int(data.get("rotation_max_nodes") or 20),
nodes_sort_key=str(data.get("nodes_sort_key") or "manual"),
nodes_sort_desc=bool(data.get("nodes_sort_desc", False)),
nodes_group_filter=str(data.get("nodes_group_filter") or ""),
nodes_tag_filter=str(data.get("nodes_tag_filter") or ""),
nodes_source_filter=str(data.get("nodes_source_filter") or ""),
nodes_visible_columns=[str(item) for item in (data.get("nodes_visible_columns") or [])],
nodes_column_widths=_int_mapping(data.get("nodes_column_widths")),
nodes_column_order=[str(item) for item in (data.get("nodes_column_order") or [])],
nodes_column_layout_version=int(data.get("nodes_column_layout_version", 0) or 0),
subscriptions_auto_update=bool(data.get("subscriptions_auto_update", True)),
subscriptions_check_on_startup=bool(data.get("subscriptions_check_on_startup", True)),
subscriptions_check_interval_min=clamp_subscriptions_check_interval(
data.get("subscriptions_check_interval_min", SUBSCRIPTIONS_CHECK_INTERVAL_DEFAULT)
),
startup_connect_order=normalize_startup_connect_order(data.get("startup_connect_order")),
)
@dataclass(slots=True)
class AppState:
schema_version: int = STATE_SCHEMA_VERSION
selected_node_id: str | None = None
subscription_device_id: str = field(default_factory=lambda: str(uuid.uuid4()).upper())
nodes: list[Node] = field(default_factory=list)
subscriptions: list[Subscription] = field(default_factory=list)
routing: RoutingSettings = field(default_factory=RoutingSettings)
settings: AppSettings = field(default_factory=AppSettings)
security: SecuritySettings = field(default_factory=SecuritySettings)
def to_dict(self) -> dict[str, Any]:
return {
"schema_version": self.schema_version,
"selected_node_id": self.selected_node_id,
"subscription_device_id": self.subscription_device_id,
"nodes": [node.to_dict() for node in self.nodes],
"subscriptions": [subscription.to_dict() for subscription in self.subscriptions],
"routing": self.routing.to_dict(),
"settings": self.settings.to_dict(),
"security": self.security.to_dict(),
}
@staticmethod
def from_dict(data: dict[str, Any]) -> "AppState":
nodes_raw = data.get("nodes") or []
nodes = [Node.from_dict(item) for item in nodes_raw if isinstance(item, dict)]
subscriptions_raw = data.get("subscriptions") or []
subscriptions = [
Subscription.from_dict(item) for item in subscriptions_raw if isinstance(item, dict)
]
return AppState(
schema_version=int(data.get("schema_version") or STATE_SCHEMA_VERSION),
selected_node_id=data.get("selected_node_id"),
subscription_device_id=str(
data.get("subscription_device_id") or uuid.uuid4()
).upper(),
nodes=nodes,
subscriptions=subscriptions,
routing=RoutingSettings.from_dict(dict(data.get("routing") or {})),
settings=AppSettings.from_dict(dict(data.get("settings") or {})),
security=SecuritySettings.from_dict(dict(data.get("security") or {})),
)