Skip to content

Commit a34fd57

Browse files
author
XrayFluent Dev
committed
вапвап
1 parent 788c1bc commit a34fd57

5 files changed

Lines changed: 365 additions & 86 deletions

File tree

.github/workflows/build.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,8 @@ jobs:
132132
run: |
133133
cd dist/ZapretKVN
134134
135-
7z a -tzip -mx=9 -mfb=258 -mpass=15 "../ZapretKVN-v$env:VERSION-windows-x64.zip" *
136-
7z a -t7z -mx=9 -mfb=273 -ms=on -md=64m -mmt=on "../ZapretKVN-v$env:VERSION-windows-x64.7z" *
135+
7z a -tzip -mx=5 "../ZapretKVN-v$env:VERSION-windows-x64.zip" *
136+
7z a -t7z -mx=5 "../ZapretKVN-v$env:VERSION-windows-x64.7z" *
137137
138138
cd ..
139139
Write-Host "Release archives:"

xray_fluent/app_updater.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Self-update: check GitHub releases, download, extract, restart."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import os
7+
import shutil
8+
import subprocess
9+
import sys
10+
import tempfile
11+
import zipfile
12+
from dataclasses import dataclass
13+
from pathlib import Path
14+
from urllib.request import Request, urlopen
15+
16+
from PyQt6.QtCore import QThread, pyqtSignal
17+
18+
from .constants import APP_VERSION, BASE_DIR
19+
20+
GITHUB_REPO = "youtubediscord/zapret-kvn"
21+
GITHUB_API = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
22+
USER_AGENT = f"ZapretKVN/{APP_VERSION}"
23+
24+
25+
@dataclass(slots=True)
26+
class AppUpdate:
27+
version: str
28+
tag: str
29+
download_url: str
30+
size: int
31+
notes: str
32+
33+
34+
def _parse_version(v: str) -> tuple[int, ...]:
35+
clean = v.lstrip("v").split("-")[0]
36+
return tuple(int(x) for x in clean.split(".") if x.isdigit())
37+
38+
39+
class UpdateChecker(QThread):
40+
"""Check GitHub for a newer release."""
41+
42+
result = pyqtSignal(object) # AppUpdate | None
43+
44+
def run(self) -> None:
45+
try:
46+
req = Request(GITHUB_API, headers={"User-Agent": USER_AGENT})
47+
with urlopen(req, timeout=10) as resp:
48+
data = json.loads(resp.read())
49+
50+
tag = data.get("tag_name", "")
51+
remote = _parse_version(tag)
52+
local = _parse_version(APP_VERSION)
53+
54+
if remote <= local:
55+
self.result.emit(None)
56+
return
57+
58+
asset = None
59+
for a in data.get("assets", []):
60+
name = a.get("name", "").lower()
61+
if name.endswith(".zip") and "windows" in name and "x64" in name:
62+
asset = a
63+
break
64+
65+
if not asset:
66+
self.result.emit(None)
67+
return
68+
69+
self.result.emit(AppUpdate(
70+
version=tag.lstrip("v"),
71+
tag=tag,
72+
download_url=asset["browser_download_url"],
73+
size=asset.get("size", 0),
74+
notes=data.get("body", ""),
75+
))
76+
except Exception:
77+
self.result.emit(None)
78+
79+
80+
class UpdateDownloader(QThread):
81+
"""Download and extract update, then launch restart script."""
82+
83+
progress = pyqtSignal(int) # percent 0-100
84+
finished_ok = pyqtSignal()
85+
error = pyqtSignal(str)
86+
87+
def __init__(self, update: AppUpdate, parent=None):
88+
super().__init__(parent)
89+
self._update = update
90+
91+
def run(self) -> None:
92+
try:
93+
tmp_dir = Path(tempfile.mkdtemp(prefix="zapretkvn_update_"))
94+
zip_path = tmp_dir / "update.zip"
95+
96+
# Download
97+
req = Request(self._update.download_url, headers={"User-Agent": USER_AGENT})
98+
with urlopen(req, timeout=120) as resp:
99+
total = int(resp.headers.get("Content-Length", 0))
100+
downloaded = 0
101+
with open(zip_path, "wb") as f:
102+
while True:
103+
chunk = resp.read(256 * 1024)
104+
if not chunk:
105+
break
106+
f.write(chunk)
107+
downloaded += len(chunk)
108+
if total > 0:
109+
self.progress.emit(int(downloaded * 100 / total))
110+
111+
self.progress.emit(100)
112+
113+
# Extract
114+
extract_dir = tmp_dir / "extracted"
115+
with zipfile.ZipFile(zip_path, "r") as zf:
116+
zf.extractall(extract_dir)
117+
118+
# Write restart script
119+
exe_name = "ZapretKVN.exe"
120+
app_dir = str(BASE_DIR)
121+
src_dir = str(extract_dir)
122+
script = tmp_dir / "_update.bat"
123+
script.write_text(
124+
"@echo off\r\n"
125+
"echo Updating zapret kvn...\r\n"
126+
"timeout /t 2 /nobreak >nul\r\n"
127+
f'taskkill /F /IM {exe_name} 2>nul\r\n'
128+
"timeout /t 1 /nobreak >nul\r\n"
129+
f'xcopy /E /Y /Q "{src_dir}\\*" "{app_dir}\\"\r\n'
130+
"echo Update complete. Restarting...\r\n"
131+
f'start "" "{app_dir}\\{exe_name}"\r\n'
132+
f'rmdir /S /Q "{str(tmp_dir)}"\r\n',
133+
encoding="ascii",
134+
)
135+
136+
# Launch script and exit
137+
subprocess.Popen(
138+
["cmd", "/c", str(script)],
139+
creationflags=0x08000000,
140+
close_fds=True,
141+
)
142+
143+
self.finished_ok.emit()
144+
145+
except Exception as exc:
146+
self.error.emit(str(exc))

xray_fluent/ui/main_window.py

Lines changed: 60 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@
1616

1717
from ..app_controller import AppController
1818
from ..storage import PassphraseRequired
19-
from ..constants import APP_NAME
19+
from ..constants import APP_NAME, APP_VERSION
2020
from ..models import AppSettings, Node, RoutingSettings
21-
from ..update_checker import check_update
21+
from ..app_updater import AppUpdate, UpdateChecker, UpdateDownloader
2222
from ..xray_core_updater import XrayCoreUpdateResult
2323
from .bulk_edit_dialog import BulkEditDialog
2424
from .dashboard_page import DashboardPage
@@ -28,6 +28,7 @@
2828
from .nodes_page import NodesPage
2929
from .routing_page import RoutingPage
3030
from .settings_page import SettingsPage
31+
from .updates_page import UpdatesPage
3132

3233

3334
class MainWindow(FluentWindow):
@@ -56,6 +57,7 @@ def initialize(self) -> None:
5657
self.routing_page = RoutingPage(self)
5758
self.logs_page = LogsPage(self)
5859
self.settings_page = SettingsPage(self)
60+
self.updates_page = UpdatesPage(self)
5961

6062
self._create_navigation()
6163
self._create_tray()
@@ -72,6 +74,11 @@ def initialize(self) -> None:
7274
if loaded and unlocked:
7375
self.controller.auto_connect_if_needed()
7476

77+
# Set Xray version on updates page
78+
from ..xray_manager import get_xray_version
79+
xv = get_xray_version(self.controller.state.settings.xray_path)
80+
self.updates_page.set_xray_version(xv or "")
81+
7582
if self.controller.state.settings.check_updates:
7683
QTimer.singleShot(2500, lambda: self._check_updates(silent=True))
7784

@@ -85,6 +92,7 @@ def _create_navigation(self) -> None:
8592
self.addSubInterface(self.nodes_page, FIF.LINK, "Nodes")
8693
self.addSubInterface(self.routing_page, FIF.GLOBE, "Routing")
8794
self.addSubInterface(self.logs_page, FIF.DOCUMENT, "Logs")
95+
self.addSubInterface(self.updates_page, FIF.UPDATE, "Updates", NavigationItemPosition.BOTTOM)
8896
self.addSubInterface(self.settings_page, FIF.SETTING, "Settings", NavigationItemPosition.BOTTOM)
8997

9098
def _create_tray(self) -> None:
@@ -166,9 +174,9 @@ def _connect_signals(self) -> None:
166174
self.settings_page.set_password_requested.connect(self._set_password)
167175
self.settings_page.disable_password_requested.connect(self.controller.disable_master_password)
168176
self.settings_page.lock_now_requested.connect(self.controller.lock)
169-
self.settings_page.check_updates_requested.connect(self._check_updates)
170-
self.settings_page.check_xray_updates_requested.connect(lambda: self.controller.run_xray_core_update(False, silent=False))
171-
self.settings_page.update_xray_requested.connect(lambda: self.controller.run_xray_core_update(True, silent=False))
177+
self.updates_page.check_app_requested.connect(self._check_updates)
178+
self.updates_page.check_xray_requested.connect(lambda: self.controller.run_xray_core_update(False, silent=False))
179+
self.updates_page.update_xray_requested.connect(lambda: self.controller.run_xray_core_update(True, silent=False))
172180
self.settings_page.export_backup_requested.connect(self._export_backup)
173181
self.settings_page.import_backup_requested.connect(self._import_backup)
174182
self.settings_page.set_encryption_requested.connect(self._set_encryption)
@@ -372,29 +380,57 @@ def _export_diagnostics(self) -> None:
372380
self._show_status("success", f"Diagnostics exported: {path}")
373381

374382
def _check_updates(self, silent: bool = False) -> None:
375-
settings = self.controller.state.settings
376-
if not settings.allow_updates:
377-
if not silent:
378-
self._show_status("info", "Updates disabled in settings")
379-
return
380-
if not settings.update_feed_url:
383+
self._pending_update: AppUpdate | None = None
384+
self._update_checker = UpdateChecker(parent=self)
385+
self._update_checker.result.connect(lambda u: self._on_update_check_result(u, silent))
386+
self._update_checker.start()
387+
if not silent:
388+
self.updates_page.show_checking()
389+
390+
def _on_update_check_result(self, update: AppUpdate | None, silent: bool) -> None:
391+
if update is None:
392+
self.updates_page.show_up_to_date()
381393
if not silent:
382-
self._show_status("warning", "Update feed URL is empty")
394+
self._show_status("info", "You are on the latest version")
383395
return
384396

385-
try:
386-
info = check_update(settings.update_feed_url, channel=settings.release_channel)
387-
except Exception as exc:
388-
if not silent:
389-
self._show_status("error", f"Update check failed: {exc}")
390-
else:
391-
self.logs_page.append_line(f"[update] check failed: {exc}")
392-
return
397+
self._pending_update = update
398+
self.updates_page.show_update_available(update.version)
399+
self.updates_page.download_btn.clicked.connect(
400+
lambda: self._start_update_download(self._pending_update)
401+
)
393402

394-
if info:
395-
self._show_status("success", f"Latest {info.channel}: {info.version}")
396-
elif not silent:
397-
self._show_status("info", "No update info available")
403+
# Blocking dialog
404+
from qfluentwidgets import MessageBox
405+
box = MessageBox(
406+
"Update available",
407+
f"New version v{update.version} is available.\n"
408+
f"Current: v{APP_VERSION}\n\n"
409+
f"The app will download, close, and restart automatically.",
410+
self,
411+
)
412+
box.yesButton.setText("Download && Install")
413+
box.cancelButton.setText("Later")
414+
if box.exec():
415+
self._start_update_download(update)
416+
417+
def _start_update_download(self, update: AppUpdate) -> None:
418+
self.updates_page.show_download_progress(0)
419+
self._update_downloader = UpdateDownloader(update, parent=self)
420+
self._update_downloader.progress.connect(self.updates_page.show_download_progress)
421+
self._update_downloader.finished_ok.connect(self._on_update_ready)
422+
self._update_downloader.error.connect(self._on_update_error)
423+
self._update_downloader.start()
424+
425+
def _on_update_ready(self) -> None:
426+
self.updates_page.set_app_status("Update downloaded. Restarting...")
427+
self._show_status("success", "Update downloaded. Restarting...")
428+
QTimer.singleShot(1500, lambda: QApplication.quit())
429+
430+
def _on_update_error(self, err: str) -> None:
431+
self.updates_page.show_idle()
432+
self.updates_page.set_app_status(f"Update failed: {err}")
433+
self._show_status("error", f"Update failed: {err}")
398434

399435
def _apply_theme(self, theme_name: str, accent_color: str) -> None:
400436
normalized = theme_name.lower().strip()

0 commit comments

Comments
 (0)