v1.8.78: auto-updater — Gitea releases check, download, apply
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
72
core/i18n.py
72
core/i18n.py
@@ -453,6 +453,30 @@ _EN = {
|
|||||||
"ctx_check_status": "Check Status",
|
"ctx_check_status": "Check Status",
|
||||||
"ctx_copy_alias": "Copy Alias",
|
"ctx_copy_alias": "Copy Alias",
|
||||||
"ctx_alias_copied": "Alias copied",
|
"ctx_alias_copied": "Alias copied",
|
||||||
|
|
||||||
|
# Updates
|
||||||
|
"update_available": "ServerManager v{version} is available",
|
||||||
|
"update_available_title": "Update Available",
|
||||||
|
"update_check": "Check for Updates",
|
||||||
|
"update_checking": "Checking for updates...",
|
||||||
|
"update_download_install": "Download & Install",
|
||||||
|
"update_downloading": "Downloading...",
|
||||||
|
"update_downloaded": "Downloaded",
|
||||||
|
"update_install": "Update",
|
||||||
|
"update_restart": "Restart to Update",
|
||||||
|
"update_skip": "Skip",
|
||||||
|
"update_later": "Later",
|
||||||
|
"update_error": "Update failed",
|
||||||
|
"update_changelog": "What's New",
|
||||||
|
"update_no_changelog": "No changelog available.",
|
||||||
|
"update_current": "Current",
|
||||||
|
"update_ready": "Ready to install",
|
||||||
|
"update_mode": "Update Mode",
|
||||||
|
"update_mode_notify": "Notify Only",
|
||||||
|
"update_mode_download": "Auto-Download",
|
||||||
|
"update_mode_auto": "Full Auto",
|
||||||
|
"update_no_updates": "You're up to date!",
|
||||||
|
"update_not_frozen": "Updates only work in packaged (exe) mode",
|
||||||
}
|
}
|
||||||
|
|
||||||
_RU = {
|
_RU = {
|
||||||
@@ -883,6 +907,30 @@ _RU = {
|
|||||||
"ctx_check_status": "Проверить статус",
|
"ctx_check_status": "Проверить статус",
|
||||||
"ctx_copy_alias": "Копировать алиас",
|
"ctx_copy_alias": "Копировать алиас",
|
||||||
"ctx_alias_copied": "Алиас скопирован",
|
"ctx_alias_copied": "Алиас скопирован",
|
||||||
|
|
||||||
|
# Updates
|
||||||
|
"update_available": "Доступен ServerManager v{version}",
|
||||||
|
"update_available_title": "Доступно обновление",
|
||||||
|
"update_check": "Проверить обновления",
|
||||||
|
"update_checking": "Проверка обновлений...",
|
||||||
|
"update_download_install": "Скачать и установить",
|
||||||
|
"update_downloading": "Скачивание...",
|
||||||
|
"update_downloaded": "Скачано",
|
||||||
|
"update_install": "Обновить",
|
||||||
|
"update_restart": "Перезапустить",
|
||||||
|
"update_skip": "Пропустить",
|
||||||
|
"update_later": "Позже",
|
||||||
|
"update_error": "Ошибка обновления",
|
||||||
|
"update_changelog": "Что нового",
|
||||||
|
"update_no_changelog": "Описание изменений отсутствует.",
|
||||||
|
"update_current": "Текущая",
|
||||||
|
"update_ready": "Готово к установке",
|
||||||
|
"update_mode": "Режим обновлений",
|
||||||
|
"update_mode_notify": "Только уведомлять",
|
||||||
|
"update_mode_download": "Авто-скачивание",
|
||||||
|
"update_mode_auto": "Полный авто",
|
||||||
|
"update_no_updates": "У вас последняя версия!",
|
||||||
|
"update_not_frozen": "Обновления работают только в exe-режиме",
|
||||||
}
|
}
|
||||||
|
|
||||||
_ZH = {
|
_ZH = {
|
||||||
@@ -1313,6 +1361,30 @@ _ZH = {
|
|||||||
"ctx_check_status": "检查状态",
|
"ctx_check_status": "检查状态",
|
||||||
"ctx_copy_alias": "复制别名",
|
"ctx_copy_alias": "复制别名",
|
||||||
"ctx_alias_copied": "别名已复制",
|
"ctx_alias_copied": "别名已复制",
|
||||||
|
|
||||||
|
# Updates
|
||||||
|
"update_available": "ServerManager v{version} 可用",
|
||||||
|
"update_available_title": "有可用更新",
|
||||||
|
"update_check": "检查更新",
|
||||||
|
"update_checking": "正在检查更新...",
|
||||||
|
"update_download_install": "下载并安装",
|
||||||
|
"update_downloading": "下载中...",
|
||||||
|
"update_downloaded": "已下载",
|
||||||
|
"update_install": "更新",
|
||||||
|
"update_restart": "重启更新",
|
||||||
|
"update_skip": "跳过",
|
||||||
|
"update_later": "稍后",
|
||||||
|
"update_error": "更新失败",
|
||||||
|
"update_changelog": "更新内容",
|
||||||
|
"update_no_changelog": "无更新日志。",
|
||||||
|
"update_current": "当前版本",
|
||||||
|
"update_ready": "准备安装",
|
||||||
|
"update_mode": "更新模式",
|
||||||
|
"update_mode_notify": "仅通知",
|
||||||
|
"update_mode_download": "自动下载",
|
||||||
|
"update_mode_auto": "全自动",
|
||||||
|
"update_no_updates": "已是最新版本!",
|
||||||
|
"update_not_frozen": "更新仅在打包(exe)模式下有效",
|
||||||
}
|
}
|
||||||
|
|
||||||
_TRANSLATIONS = {
|
_TRANSLATIONS = {
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ class ServerStore:
|
|||||||
self._terminal_font_size: int = 11
|
self._terminal_font_size: int = 11
|
||||||
self._window_geometry: str = ""
|
self._window_geometry: str = ""
|
||||||
self._servers_file: str = DEFAULT_SERVERS_FILE
|
self._servers_file: str = DEFAULT_SERVERS_FILE
|
||||||
|
# Update settings
|
||||||
|
self._update_mode: str = "auto-download" # "notify-only" | "auto-download" | "full-auto"
|
||||||
|
self._last_update_check: float = 0
|
||||||
|
self._skip_version: str = ""
|
||||||
self._load_settings()
|
self._load_settings()
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
@@ -79,6 +83,9 @@ class ServerStore:
|
|||||||
self._check_interval = settings.get("check_interval", 60)
|
self._check_interval = settings.get("check_interval", 60)
|
||||||
self._terminal_font_size = settings.get("terminal_font_size", 11)
|
self._terminal_font_size = settings.get("terminal_font_size", 11)
|
||||||
self._window_geometry = settings.get("window_geometry", "")
|
self._window_geometry = settings.get("window_geometry", "")
|
||||||
|
self._update_mode = settings.get("update_mode", "auto-download")
|
||||||
|
self._last_update_check = settings.get("last_update_check", 0)
|
||||||
|
self._skip_version = settings.get("skip_version", "")
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
log.warning("Corrupted settings.json, using defaults")
|
log.warning("Corrupted settings.json, using defaults")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -93,6 +100,9 @@ class ServerStore:
|
|||||||
"check_interval": self._check_interval,
|
"check_interval": self._check_interval,
|
||||||
"terminal_font_size": self._terminal_font_size,
|
"terminal_font_size": self._terminal_font_size,
|
||||||
"window_geometry": self._window_geometry,
|
"window_geometry": self._window_geometry,
|
||||||
|
"update_mode": self._update_mode,
|
||||||
|
"last_update_check": self._last_update_check,
|
||||||
|
"skip_version": self._skip_version,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
tmp = SETTINGS_FILE + ".tmp"
|
tmp = SETTINGS_FILE + ".tmp"
|
||||||
@@ -413,3 +423,28 @@ class ServerStore:
|
|||||||
def set_terminal_font_size(self, size: int):
|
def set_terminal_font_size(self, size: int):
|
||||||
self._terminal_font_size = max(6, min(28, size))
|
self._terminal_font_size = max(6, min(28, size))
|
||||||
self._save_settings()
|
self._save_settings()
|
||||||
|
|
||||||
|
# ── Update settings ──────────────────────────────────
|
||||||
|
|
||||||
|
def get_update_mode(self) -> str:
|
||||||
|
return self._update_mode
|
||||||
|
|
||||||
|
def set_update_mode(self, mode: str):
|
||||||
|
if mode in ("notify-only", "auto-download", "full-auto"):
|
||||||
|
self._update_mode = mode
|
||||||
|
self._save_settings()
|
||||||
|
|
||||||
|
def get_last_update_check(self) -> float:
|
||||||
|
return self._last_update_check
|
||||||
|
|
||||||
|
def set_last_update_check(self):
|
||||||
|
import time
|
||||||
|
self._last_update_check = time.time()
|
||||||
|
self._save_settings()
|
||||||
|
|
||||||
|
def get_skip_version(self) -> str:
|
||||||
|
return self._skip_version
|
||||||
|
|
||||||
|
def set_skip_version(self, version: str):
|
||||||
|
self._skip_version = version
|
||||||
|
self._save_settings()
|
||||||
|
|||||||
396
core/updater.py
Normal file
396
core/updater.py
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
"""
|
||||||
|
Auto-updater — checks Gitea releases, downloads and applies updates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from core.server_store import ServerStore
|
||||||
|
|
||||||
|
from core.logger import log
|
||||||
|
from version import __version__
|
||||||
|
|
||||||
|
|
||||||
|
# Check interval: 1 hour
|
||||||
|
_CHECK_INTERVAL = 3600
|
||||||
|
|
||||||
|
# Hide console windows on Windows for subprocess calls
|
||||||
|
_SUBPROCESS_FLAGS = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
|
||||||
|
|
||||||
|
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
|
||||||
|
def _platform_tag() -> str:
|
||||||
|
"""Get platform tag matching build.py naming: win-x64, linux-x64, etc."""
|
||||||
|
s = platform.system().lower()
|
||||||
|
m = platform.machine().lower()
|
||||||
|
arch_map = {
|
||||||
|
"x86_64": "x64", "amd64": "x64",
|
||||||
|
"x86": "x32", "i686": "x32", "i386": "x32",
|
||||||
|
"aarch64": "arm64", "arm64": "arm64",
|
||||||
|
"armv7l": "arm",
|
||||||
|
}
|
||||||
|
arch = arch_map.get(m, m)
|
||||||
|
os_map = {"windows": "win", "linux": "linux", "darwin": "mac"}
|
||||||
|
os_tag = os_map.get(s, s)
|
||||||
|
return f"{os_tag}-{arch}"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_version(ver: str) -> tuple[int, int, int]:
|
||||||
|
"""Parse semver string into (major, minor, patch)."""
|
||||||
|
m = re.match(r"v?(\d+)\.(\d+)\.(\d+)", ver)
|
||||||
|
if not m:
|
||||||
|
return (0, 0, 0)
|
||||||
|
return int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||||
|
|
||||||
|
|
||||||
|
def _version_newer(remote: str, local: str) -> bool:
|
||||||
|
"""Return True if remote version is newer than local."""
|
||||||
|
return _parse_version(remote) > _parse_version(local)
|
||||||
|
|
||||||
|
|
||||||
|
def _gitea_api(endpoint: str) -> Optional[dict]:
|
||||||
|
"""Call Gitea API. Reads credentials from git remote 'sensey'."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "remote", "get-url", "sensey"],
|
||||||
|
capture_output=True, text=True, cwd=PROJECT_DIR,
|
||||||
|
creationflags=_SUBPROCESS_FLAGS,
|
||||||
|
)
|
||||||
|
remote_url = result.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
# Frozen exe: no git available, try reading from embedded config
|
||||||
|
remote_url = ""
|
||||||
|
|
||||||
|
if not remote_url:
|
||||||
|
# Fallback: try common remote names
|
||||||
|
for name in ["origin"]:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "remote", "get-url", name],
|
||||||
|
capture_output=True, text=True, cwd=PROJECT_DIR,
|
||||||
|
creationflags=_SUBPROCESS_FLAGS,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
remote_url = result.stdout.strip()
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not remote_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
m = re.match(r"https://([^:]+):([^@]+)@([^/]+)/(.+?)(?:\.git)?$", remote_url)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user, password, host, repo_path = m.groups()
|
||||||
|
url = f"https://{host}/api/v1/repos/{repo_path}/{endpoint}"
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": "Basic " + base64.b64encode(
|
||||||
|
f"{user}:{password}".encode()
|
||||||
|
).decode(),
|
||||||
|
}
|
||||||
|
|
||||||
|
req = urllib.request.Request(url, headers=headers, method="GET")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateChecker:
|
||||||
|
"""Background thread that checks for new releases on Gitea."""
|
||||||
|
|
||||||
|
def __init__(self, store: "ServerStore", gui_callback=None):
|
||||||
|
self.store = store
|
||||||
|
self._gui_callback = gui_callback
|
||||||
|
self._running = False
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
self._latest: Optional[dict] = None # cached latest release info
|
||||||
|
self._download_path: Optional[str] = None
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
self._running = True
|
||||||
|
self._thread = threading.Thread(target=self._loop, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._running = False
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=3.0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def latest(self) -> Optional[dict]:
|
||||||
|
"""Latest release info: {version, url, changelog, filename, sha256}."""
|
||||||
|
return self._latest
|
||||||
|
|
||||||
|
@property
|
||||||
|
def download_path(self) -> Optional[str]:
|
||||||
|
return self._download_path
|
||||||
|
|
||||||
|
def check_now(self) -> Optional[dict]:
|
||||||
|
"""Manual check — returns release info or None."""
|
||||||
|
info = self._fetch_latest_release()
|
||||||
|
if info:
|
||||||
|
self._latest = info
|
||||||
|
# Update timestamp
|
||||||
|
self.store.set_last_update_check()
|
||||||
|
return info
|
||||||
|
|
||||||
|
def _fetch_latest_release(self) -> Optional[dict]:
|
||||||
|
"""Fetch latest release from Gitea API."""
|
||||||
|
try:
|
||||||
|
data = _gitea_api("releases?limit=1")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not data or not isinstance(data, list) or len(data) == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
release = data[0]
|
||||||
|
tag = release.get("tag_name", "")
|
||||||
|
version = tag.lstrip("v")
|
||||||
|
|
||||||
|
if not _version_newer(version, __version__):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Skip if user chose to skip this version
|
||||||
|
skip = self.store.get_skip_version()
|
||||||
|
if skip and version == skip:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Find platform-specific asset
|
||||||
|
assets = release.get("assets", [])
|
||||||
|
asset = self._get_platform_asset(assets)
|
||||||
|
if not asset:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"version": version,
|
||||||
|
"tag": tag,
|
||||||
|
"url": asset["browser_download_url"],
|
||||||
|
"filename": asset["name"],
|
||||||
|
"size": asset.get("size", 0),
|
||||||
|
"changelog": release.get("body", ""),
|
||||||
|
"sha256": self._extract_sha256(release.get("body", ""), asset["name"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_platform_asset(self, assets: list[dict]) -> Optional[dict]:
|
||||||
|
"""Find the asset matching current platform."""
|
||||||
|
tag = _platform_tag()
|
||||||
|
for asset in assets:
|
||||||
|
name = asset.get("name", "")
|
||||||
|
if tag in name:
|
||||||
|
return asset
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _extract_sha256(self, body: str, filename: str) -> Optional[str]:
|
||||||
|
"""Try to extract SHA256 hash from release body."""
|
||||||
|
# Common formats: `sha256: abc123...` or `abc123... filename`
|
||||||
|
for line in body.splitlines():
|
||||||
|
if filename in line:
|
||||||
|
m = re.search(r"[a-f0-9]{64}", line, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
return m.group(0)
|
||||||
|
# Also check for generic sha256 line
|
||||||
|
m = re.search(r"sha256[:\s]+([a-f0-9]{64})", body, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def download_update(self, url: str, progress_cb=None) -> Optional[str]:
|
||||||
|
"""Download update binary to temp dir. Returns path or None."""
|
||||||
|
try:
|
||||||
|
# Get auth headers
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "remote", "get-url", "sensey"],
|
||||||
|
capture_output=True, text=True, cwd=PROJECT_DIR,
|
||||||
|
creationflags=_SUBPROCESS_FLAGS,
|
||||||
|
)
|
||||||
|
remote_url = result.stdout.strip()
|
||||||
|
headers = {}
|
||||||
|
m = re.match(r"https://([^:]+):([^@]+)@", remote_url)
|
||||||
|
if m:
|
||||||
|
user, password = m.groups()
|
||||||
|
headers["Authorization"] = "Basic " + base64.b64encode(
|
||||||
|
f"{user}:{password}".encode()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
req = urllib.request.Request(url, headers=headers, method="GET")
|
||||||
|
resp = urllib.request.urlopen(req, timeout=120)
|
||||||
|
|
||||||
|
total = int(resp.headers.get("Content-Length", 0))
|
||||||
|
downloaded = 0
|
||||||
|
|
||||||
|
# Save to temp file
|
||||||
|
suffix = ".exe" if sys.platform == "win32" else ""
|
||||||
|
fd, tmp_path = tempfile.mkstemp(suffix=suffix, prefix="sm_update_")
|
||||||
|
with os.fdopen(fd, "wb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = resp.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
f.write(chunk)
|
||||||
|
downloaded += len(chunk)
|
||||||
|
if progress_cb and total > 0:
|
||||||
|
progress_cb(downloaded, total)
|
||||||
|
|
||||||
|
resp.close()
|
||||||
|
|
||||||
|
# Verify SHA256 if available
|
||||||
|
if self._latest and self._latest.get("sha256"):
|
||||||
|
expected = self._latest["sha256"].lower()
|
||||||
|
sha = hashlib.sha256()
|
||||||
|
with open(tmp_path, "rb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = f.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
sha.update(chunk)
|
||||||
|
actual = sha.hexdigest()
|
||||||
|
if actual != expected:
|
||||||
|
log.error(f"SHA256 mismatch: expected {expected}, got {actual}")
|
||||||
|
os.remove(tmp_path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._download_path = tmp_path
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Download failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def apply_update(self, binary_path: str):
|
||||||
|
"""Replace current exe and restart."""
|
||||||
|
if not getattr(sys, "frozen", False):
|
||||||
|
log.info("Not a frozen exe, skipping apply")
|
||||||
|
return False
|
||||||
|
|
||||||
|
current_exe = sys.executable
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return self._apply_windows(binary_path, current_exe)
|
||||||
|
else:
|
||||||
|
return self._apply_linux(binary_path, current_exe)
|
||||||
|
|
||||||
|
def _apply_windows(self, new_exe: str, current_exe: str) -> bool:
|
||||||
|
"""Windows update: create hidden .vbs that runs .bat silently."""
|
||||||
|
try:
|
||||||
|
tmp_dir = tempfile.gettempdir()
|
||||||
|
bat_path = os.path.join(tmp_dir, "sm_update.bat")
|
||||||
|
vbs_path = os.path.join(tmp_dir, "sm_update.vbs")
|
||||||
|
pid = os.getpid()
|
||||||
|
|
||||||
|
bat_content = f"""@echo off
|
||||||
|
:wait
|
||||||
|
tasklist /fi "PID eq {pid}" 2>NUL | find "{pid}" >NUL
|
||||||
|
if %ERRORLEVEL% == 0 (
|
||||||
|
timeout /t 1 /nobreak >NUL
|
||||||
|
goto wait
|
||||||
|
)
|
||||||
|
copy /Y "{new_exe}" "{current_exe}" >NUL
|
||||||
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
start "" "{current_exe}"
|
||||||
|
del "{bat_path}"
|
||||||
|
del "%~f0"
|
||||||
|
"""
|
||||||
|
with open(bat_path, "w") as f:
|
||||||
|
f.write(bat_content)
|
||||||
|
|
||||||
|
# VBS wrapper runs .bat completely hidden (no CMD flash)
|
||||||
|
vbs_content = f'CreateObject("Wscript.Shell").Run """{bat_path}""", 0, False\n'
|
||||||
|
with open(vbs_path, "w") as f:
|
||||||
|
f.write(vbs_content)
|
||||||
|
|
||||||
|
# Launch VBS detached — no visible window at all
|
||||||
|
subprocess.Popen(
|
||||||
|
["wscript", vbs_path],
|
||||||
|
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Windows update failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _apply_linux(self, new_exe: str, current_exe: str) -> bool:
|
||||||
|
"""Linux update: replace in-place and exec."""
|
||||||
|
try:
|
||||||
|
os.replace(new_exe, current_exe)
|
||||||
|
os.chmod(current_exe, 0o755)
|
||||||
|
os.execv(current_exe, sys.argv)
|
||||||
|
return True # won't reach here
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Linux update failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _loop(self):
|
||||||
|
"""Background loop — check periodically."""
|
||||||
|
# Initial delay: 10 seconds after startup
|
||||||
|
for _ in range(100):
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
# Check if enough time passed since last check
|
||||||
|
last_check = self.store.get_last_update_check()
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
if not last_check or (now - last_check) >= _CHECK_INTERVAL:
|
||||||
|
info = self.check_now()
|
||||||
|
if info and self._gui_callback:
|
||||||
|
mode = self.store.get_update_mode()
|
||||||
|
if mode == "full-auto":
|
||||||
|
# Auto-download and apply
|
||||||
|
path = self.download_update(info["url"])
|
||||||
|
if path:
|
||||||
|
try:
|
||||||
|
self._gui_callback("auto_apply", info, path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif mode == "auto-download":
|
||||||
|
# Download in background, then notify
|
||||||
|
path = self.download_update(info["url"])
|
||||||
|
if path:
|
||||||
|
try:
|
||||||
|
self._gui_callback("downloaded", info, path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
self._gui_callback("available", info, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# notify-only
|
||||||
|
try:
|
||||||
|
self._gui_callback("available", info, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Sleep for 5 minutes, checking _running flag
|
||||||
|
for _ in range(3000):
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
time.sleep(0.1)
|
||||||
128
gui/app.py
128
gui/app.py
@@ -8,6 +8,7 @@ from tkinter import messagebox
|
|||||||
|
|
||||||
from core.server_store import ServerStore
|
from core.server_store import ServerStore
|
||||||
from core.status_checker import StatusChecker
|
from core.status_checker import StatusChecker
|
||||||
|
from core.updater import UpdateChecker
|
||||||
from core import i18n
|
from core import i18n
|
||||||
from core.i18n import t, LANGUAGES
|
from core.i18n import t, LANGUAGES
|
||||||
from core.icons import icon, TAB_ICONS
|
from core.icons import icon, TAB_ICONS
|
||||||
@@ -83,6 +84,7 @@ class App(ctk.CTk):
|
|||||||
self.store = ServerStore()
|
self.store = ServerStore()
|
||||||
self.checker = StatusChecker(self.store)
|
self.checker = StatusChecker(self.store)
|
||||||
self.session_pool = SessionPool(max_sessions=5) # Create session pool
|
self.session_pool = SessionPool(max_sessions=5) # Create session pool
|
||||||
|
self.updater = UpdateChecker(self.store, gui_callback=self._on_update_event)
|
||||||
|
|
||||||
# Restore saved window geometry or use default
|
# Restore saved window geometry or use default
|
||||||
saved_geo = self.store._window_geometry
|
saved_geo = self.store._window_geometry
|
||||||
@@ -99,6 +101,9 @@ class App(ctk.CTk):
|
|||||||
self.checker.start()
|
self.checker.start()
|
||||||
self.checker.check_all_now()
|
self.checker.check_all_now()
|
||||||
|
|
||||||
|
# Auto-updater
|
||||||
|
self.updater.start()
|
||||||
|
|
||||||
# Fix Ctrl+V/C/A/X for non-Latin keyboard layouts (e.g. Russian)
|
# Fix Ctrl+V/C/A/X for non-Latin keyboard layouts (e.g. Russian)
|
||||||
# Tkinter maps <<Paste>> to <Control-v> by keysym, which fails when
|
# Tkinter maps <<Paste>> to <Control-v> by keysym, which fails when
|
||||||
# the layout produces non-Latin characters. This fix uses keycodes instead.
|
# the layout produces non-Latin characters. This fix uses keycodes instead.
|
||||||
@@ -142,6 +147,14 @@ class App(ctk.CTk):
|
|||||||
)
|
)
|
||||||
self.lang_menu.pack(side="right", padx=(5, 0))
|
self.lang_menu.pack(side="right", padx=(5, 0))
|
||||||
|
|
||||||
|
# Check Updates button
|
||||||
|
self._update_check_btn = ctk.CTkButton(
|
||||||
|
header_bar, text="\u21bb", width=30, height=30,
|
||||||
|
corner_radius=15, fg_color="#6b7280", hover_color="#4b5563",
|
||||||
|
command=self._check_updates_manual,
|
||||||
|
)
|
||||||
|
self._update_check_btn.pack(side="right", padx=(5, 0))
|
||||||
|
|
||||||
# About button
|
# About button
|
||||||
self.about_btn = ctk.CTkButton(
|
self.about_btn = ctk.CTkButton(
|
||||||
header_bar, text="ⓘ", width=30, height=30,
|
header_bar, text="ⓘ", width=30, height=30,
|
||||||
@@ -150,6 +163,11 @@ class App(ctk.CTk):
|
|||||||
)
|
)
|
||||||
self.about_btn.pack(side="right", padx=(5, 5))
|
self.about_btn.pack(side="right", padx=(5, 5))
|
||||||
|
|
||||||
|
# Update banner (hidden by default)
|
||||||
|
self._update_banner = None
|
||||||
|
self._pending_update_info = None
|
||||||
|
self._pending_download_path = None
|
||||||
|
|
||||||
# Initialize tab tracking
|
# Initialize tab tracking
|
||||||
self.tabview = None
|
self.tabview = None
|
||||||
self._tab_keys = []
|
self._tab_keys = []
|
||||||
@@ -316,6 +334,115 @@ class App(ctk.CTk):
|
|||||||
def _show_about(self):
|
def _show_about(self):
|
||||||
AboutDialog(self)
|
AboutDialog(self)
|
||||||
|
|
||||||
|
# ── Update handling ─────────────────────────────────
|
||||||
|
|
||||||
|
def _on_update_event(self, event_type: str, info: dict, path: str = None):
|
||||||
|
"""Called from updater thread — schedule GUI work on main thread."""
|
||||||
|
self.after(0, lambda: self._handle_update_event(event_type, info, path))
|
||||||
|
|
||||||
|
def _handle_update_event(self, event_type: str, info: dict, path: str = None):
|
||||||
|
"""Handle update events on the main thread."""
|
||||||
|
self._pending_update_info = info
|
||||||
|
self._pending_download_path = path
|
||||||
|
|
||||||
|
if event_type == "auto_apply":
|
||||||
|
# Full-auto mode: apply immediately
|
||||||
|
if self.updater.apply_update(path):
|
||||||
|
self.destroy()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Show banner
|
||||||
|
self._show_update_banner(info, downloaded=(path is not None))
|
||||||
|
|
||||||
|
def _show_update_banner(self, info: dict, downloaded: bool = False):
|
||||||
|
"""Show/update the update banner."""
|
||||||
|
from gui.update_dialog import UpdateBanner
|
||||||
|
|
||||||
|
if self._update_banner is not None:
|
||||||
|
try:
|
||||||
|
self._update_banner.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._update_banner = UpdateBanner(
|
||||||
|
self._main_frame,
|
||||||
|
on_update=self._show_update_dialog,
|
||||||
|
on_skip=lambda: self._skip_update(info["version"]),
|
||||||
|
on_dismiss=self._dismiss_banner,
|
||||||
|
)
|
||||||
|
self._update_banner.set_info(info["version"], downloaded=downloaded)
|
||||||
|
# Pack banner between header and tabview
|
||||||
|
self._update_banner.pack(fill="x", padx=10, pady=(4, 0), before=self.tabview)
|
||||||
|
|
||||||
|
def _show_update_dialog(self):
|
||||||
|
"""Open the update dialog."""
|
||||||
|
from gui.update_dialog import UpdateDialog
|
||||||
|
|
||||||
|
if not self._pending_update_info:
|
||||||
|
return
|
||||||
|
|
||||||
|
import sys
|
||||||
|
if not getattr(sys, "frozen", False):
|
||||||
|
from tkinter import messagebox
|
||||||
|
messagebox.showinfo(
|
||||||
|
t("update_available_title"),
|
||||||
|
t("update_not_frozen"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
UpdateDialog(
|
||||||
|
self,
|
||||||
|
self._pending_update_info,
|
||||||
|
downloaded_path=self._pending_download_path,
|
||||||
|
on_install=self._apply_update,
|
||||||
|
on_skip=self._skip_update,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _apply_update(self, path: str):
|
||||||
|
"""Apply downloaded update."""
|
||||||
|
if self.updater.apply_update(path):
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
def _skip_update(self, version: str):
|
||||||
|
"""Skip this version."""
|
||||||
|
self.store.set_skip_version(version)
|
||||||
|
self._dismiss_banner()
|
||||||
|
|
||||||
|
def _dismiss_banner(self):
|
||||||
|
if self._update_banner:
|
||||||
|
try:
|
||||||
|
self._update_banner.pack_forget()
|
||||||
|
self._update_banner.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._update_banner = None
|
||||||
|
|
||||||
|
def _check_updates_manual(self):
|
||||||
|
"""Manual check for updates (button click)."""
|
||||||
|
import threading
|
||||||
|
from tkinter import messagebox
|
||||||
|
|
||||||
|
self._update_check_btn.configure(state="disabled")
|
||||||
|
|
||||||
|
def _check():
|
||||||
|
info = self.updater.check_now()
|
||||||
|
self.after(0, lambda: self._manual_check_done(info))
|
||||||
|
|
||||||
|
threading.Thread(target=_check, daemon=True).start()
|
||||||
|
|
||||||
|
def _manual_check_done(self, info):
|
||||||
|
self._update_check_btn.configure(state="normal")
|
||||||
|
if info:
|
||||||
|
self._pending_update_info = info
|
||||||
|
self._pending_download_path = None
|
||||||
|
self._show_update_banner(info)
|
||||||
|
else:
|
||||||
|
from tkinter import messagebox
|
||||||
|
messagebox.showinfo(
|
||||||
|
t("update_check"),
|
||||||
|
t("update_no_updates"),
|
||||||
|
)
|
||||||
|
|
||||||
def _get_current_tab_key(self) -> str:
|
def _get_current_tab_key(self) -> str:
|
||||||
"""Get the i18n key of the currently active tab."""
|
"""Get the i18n key of the currently active tab."""
|
||||||
try:
|
try:
|
||||||
@@ -514,4 +641,5 @@ class App(ctk.CTk):
|
|||||||
# Disconnect all sessions before closing
|
# Disconnect all sessions before closing
|
||||||
self.session_pool.disconnect_all()
|
self.session_pool.disconnect_all()
|
||||||
self.checker.stop()
|
self.checker.stop()
|
||||||
|
self.updater.stop()
|
||||||
self.destroy()
|
self.destroy()
|
||||||
|
|||||||
@@ -124,6 +124,45 @@ class SetupTab(ctk.CTkFrame):
|
|||||||
btn.pack(side="left", padx=2)
|
btn.pack(side="left", padx=2)
|
||||||
self._interval_buttons[seconds] = btn
|
self._interval_buttons[seconds] = btn
|
||||||
|
|
||||||
|
# ── Updates section ─────────────────────────
|
||||||
|
update_frame = ctk.CTkFrame(self._scroll)
|
||||||
|
update_frame.pack(fill="x", padx=20, pady=(5, 5))
|
||||||
|
|
||||||
|
self.update_title = ctk.CTkLabel(
|
||||||
|
update_frame, text=t("update_mode"),
|
||||||
|
font=ctk.CTkFont(size=14, weight="bold"), anchor="w"
|
||||||
|
)
|
||||||
|
self.update_title.pack(fill="x", padx=15, pady=(10, 5))
|
||||||
|
|
||||||
|
update_row = ctk.CTkFrame(update_frame, fg_color="transparent")
|
||||||
|
update_row.pack(fill="x", padx=15, pady=(0, 10))
|
||||||
|
|
||||||
|
self._update_mode_buttons: dict[str, ctk.CTkButton] = {}
|
||||||
|
current_mode = store.get_update_mode()
|
||||||
|
update_modes = [
|
||||||
|
("notify-only", "update_mode_notify"),
|
||||||
|
("auto-download", "update_mode_download"),
|
||||||
|
("full-auto", "update_mode_auto"),
|
||||||
|
]
|
||||||
|
for mode, key in update_modes:
|
||||||
|
is_active = (mode == current_mode)
|
||||||
|
btn = ctk.CTkButton(
|
||||||
|
update_row, text=t(key), width=120, height=28,
|
||||||
|
fg_color="#3b82f6" if is_active else "#6b7280",
|
||||||
|
hover_color="#2563eb" if is_active else "#4b5563",
|
||||||
|
command=lambda m=mode: self._set_update_mode(m)
|
||||||
|
)
|
||||||
|
btn.pack(side="left", padx=2)
|
||||||
|
self._update_mode_buttons[mode] = btn
|
||||||
|
|
||||||
|
# Check for updates button
|
||||||
|
self._check_updates_btn = ctk.CTkButton(
|
||||||
|
update_row, text=t("update_check"), width=140, height=28,
|
||||||
|
fg_color="#3b82f6", hover_color="#2563eb",
|
||||||
|
command=self._check_updates,
|
||||||
|
)
|
||||||
|
self._check_updates_btn.pack(side="right")
|
||||||
|
|
||||||
# ── Configuration section ─────────────────────
|
# ── Configuration section ─────────────────────
|
||||||
config_frame = ctk.CTkFrame(self._scroll)
|
config_frame = ctk.CTkFrame(self._scroll)
|
||||||
config_frame.pack(fill="x", padx=20, pady=(5, 5))
|
config_frame.pack(fill="x", padx=20, pady=(5, 5))
|
||||||
@@ -212,6 +251,38 @@ class SetupTab(ctk.CTkFrame):
|
|||||||
# Initial status check
|
# Initial status check
|
||||||
self._refresh_status()
|
self._refresh_status()
|
||||||
|
|
||||||
|
def _set_update_mode(self, mode: str):
|
||||||
|
self.store.set_update_mode(mode)
|
||||||
|
for m, btn in self._update_mode_buttons.items():
|
||||||
|
if m == mode:
|
||||||
|
btn.configure(fg_color="#3b82f6", hover_color="#2563eb")
|
||||||
|
else:
|
||||||
|
btn.configure(fg_color="#6b7280", hover_color="#4b5563")
|
||||||
|
self._log(f"{t('update_mode')}: {t('update_mode_notify') if mode == 'notify-only' else t('update_mode_download') if mode == 'auto-download' else t('update_mode_auto')}")
|
||||||
|
|
||||||
|
def _check_updates(self):
|
||||||
|
self._check_updates_btn.configure(state="disabled", text=t("update_checking"))
|
||||||
|
|
||||||
|
def _do():
|
||||||
|
try:
|
||||||
|
# Access updater via the app (grandparent of tab)
|
||||||
|
app = self.winfo_toplevel()
|
||||||
|
if hasattr(app, "updater"):
|
||||||
|
info = app.updater.check_now()
|
||||||
|
if info:
|
||||||
|
self.after(0, lambda: self._log(t("update_available").format(version=info["version"])))
|
||||||
|
self.after(0, lambda: app._handle_update_event("available", info, None))
|
||||||
|
else:
|
||||||
|
self.after(0, lambda: self._log(t("update_no_updates")))
|
||||||
|
else:
|
||||||
|
self.after(0, lambda: self._log("Updater not available"))
|
||||||
|
except Exception as e:
|
||||||
|
self.after(0, lambda: self._log(f"Error: {e}"))
|
||||||
|
finally:
|
||||||
|
self.after(0, lambda: self._check_updates_btn.configure(state="normal", text=t("update_check")))
|
||||||
|
|
||||||
|
threading.Thread(target=_do, daemon=True).start()
|
||||||
|
|
||||||
def _set_interval(self, seconds: int):
|
def _set_interval(self, seconds: int):
|
||||||
self.store.set_check_interval(seconds)
|
self.store.set_check_interval(seconds)
|
||||||
for s, btn in self._interval_buttons.items():
|
for s, btn in self._interval_buttons.items():
|
||||||
|
|||||||
271
gui/update_dialog.py
Normal file
271
gui/update_dialog.py
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
"""
|
||||||
|
Update dialog and banner — shows available updates, changelog, download progress.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
from core.i18n import t
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateBanner(ctk.CTkFrame):
|
||||||
|
"""Thin banner bar shown when an update is available."""
|
||||||
|
|
||||||
|
def __init__(self, parent, on_update=None, on_skip=None, on_dismiss=None):
|
||||||
|
super().__init__(parent, fg_color="#1e3a5f", corner_radius=8, height=40)
|
||||||
|
self.pack_propagate(False)
|
||||||
|
self._on_update = on_update
|
||||||
|
self._on_skip = on_skip
|
||||||
|
self._on_dismiss = on_dismiss
|
||||||
|
|
||||||
|
self._label = ctk.CTkLabel(
|
||||||
|
self, text="", font=ctk.CTkFont(size=13),
|
||||||
|
text_color="#e0e0e0"
|
||||||
|
)
|
||||||
|
self._label.pack(side="left", padx=12)
|
||||||
|
|
||||||
|
# Dismiss X
|
||||||
|
dismiss_btn = ctk.CTkButton(
|
||||||
|
self, text="\u2715", width=28, height=28,
|
||||||
|
corner_radius=14, fg_color="transparent", hover_color="#374151",
|
||||||
|
text_color="#9ca3af", font=ctk.CTkFont(size=12),
|
||||||
|
command=self._dismiss,
|
||||||
|
)
|
||||||
|
dismiss_btn.pack(side="right", padx=(0, 6))
|
||||||
|
|
||||||
|
# Skip button
|
||||||
|
self._skip_btn = ctk.CTkButton(
|
||||||
|
self, text=t("update_skip"), width=70, height=28,
|
||||||
|
corner_radius=6, fg_color="#4b5563", hover_color="#374151",
|
||||||
|
font=ctk.CTkFont(size=12),
|
||||||
|
command=self._skip,
|
||||||
|
)
|
||||||
|
self._skip_btn.pack(side="right", padx=(0, 4))
|
||||||
|
|
||||||
|
# Update button
|
||||||
|
self._update_btn = ctk.CTkButton(
|
||||||
|
self, text=t("update_install"), width=90, height=28,
|
||||||
|
corner_radius=6, fg_color="#2563eb", hover_color="#1d4ed8",
|
||||||
|
font=ctk.CTkFont(size=12, weight="bold"),
|
||||||
|
command=self._do_update,
|
||||||
|
)
|
||||||
|
self._update_btn.pack(side="right", padx=(0, 4))
|
||||||
|
|
||||||
|
def set_info(self, version: str, downloaded: bool = False):
|
||||||
|
"""Update the banner text."""
|
||||||
|
text = t("update_available").format(version=version)
|
||||||
|
if downloaded:
|
||||||
|
text += f" \u2714 {t('update_downloaded')}"
|
||||||
|
self._label.configure(text=text)
|
||||||
|
|
||||||
|
def _do_update(self):
|
||||||
|
if self._on_update:
|
||||||
|
self._on_update()
|
||||||
|
|
||||||
|
def _skip(self):
|
||||||
|
if self._on_skip:
|
||||||
|
self._on_skip()
|
||||||
|
self.pack_forget()
|
||||||
|
|
||||||
|
def _dismiss(self):
|
||||||
|
if self._on_dismiss:
|
||||||
|
self._on_dismiss()
|
||||||
|
self.pack_forget()
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateDialog(ctk.CTkToplevel):
|
||||||
|
"""Modal dialog showing update details, changelog, and download progress."""
|
||||||
|
|
||||||
|
def __init__(self, parent, info: dict, downloaded_path: str = None,
|
||||||
|
on_install=None, on_skip=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.title(t("update_available_title"))
|
||||||
|
self.geometry("500x420")
|
||||||
|
self.resizable(False, False)
|
||||||
|
self.transient(parent)
|
||||||
|
self.grab_set()
|
||||||
|
|
||||||
|
self._info = info
|
||||||
|
self._downloaded_path = downloaded_path
|
||||||
|
self._on_install = on_install
|
||||||
|
self._on_skip = on_skip
|
||||||
|
self._downloading = False
|
||||||
|
|
||||||
|
self._build_ui()
|
||||||
|
|
||||||
|
# Center on parent
|
||||||
|
self.update_idletasks()
|
||||||
|
px = parent.winfo_x() + (parent.winfo_width() - 500) // 2
|
||||||
|
py = parent.winfo_y() + (parent.winfo_height() - 420) // 2
|
||||||
|
self.geometry(f"+{px}+{py}")
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
from version import __version__
|
||||||
|
|
||||||
|
# Header
|
||||||
|
header = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
header.pack(fill="x", padx=20, pady=(20, 10))
|
||||||
|
|
||||||
|
ctk.CTkLabel(
|
||||||
|
header, text="ServerManager",
|
||||||
|
font=ctk.CTkFont(size=18, weight="bold"),
|
||||||
|
).pack(anchor="w")
|
||||||
|
|
||||||
|
# Version info
|
||||||
|
ver_frame = ctk.CTkFrame(header, fg_color="transparent")
|
||||||
|
ver_frame.pack(fill="x", pady=(8, 0))
|
||||||
|
|
||||||
|
ctk.CTkLabel(
|
||||||
|
ver_frame, text=f"{t('update_current')}: v{__version__}",
|
||||||
|
font=ctk.CTkFont(size=13), text_color="#9ca3af",
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
ctk.CTkLabel(
|
||||||
|
ver_frame, text=" \u2192 ",
|
||||||
|
font=ctk.CTkFont(size=13), text_color="#6b7280",
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
ctk.CTkLabel(
|
||||||
|
ver_frame, text=f"v{self._info['version']}",
|
||||||
|
font=ctk.CTkFont(size=14, weight="bold"), text_color="#22c55e",
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
# File size
|
||||||
|
size_mb = self._info.get("size", 0) / (1024 * 1024)
|
||||||
|
if size_mb > 0:
|
||||||
|
ctk.CTkLabel(
|
||||||
|
ver_frame, text=f" ({size_mb:.1f} MB)",
|
||||||
|
font=ctk.CTkFont(size=12), text_color="#6b7280",
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
# Changelog
|
||||||
|
ctk.CTkLabel(
|
||||||
|
self, text=t("update_changelog"),
|
||||||
|
font=ctk.CTkFont(size=13, weight="bold"),
|
||||||
|
).pack(anchor="w", padx=20, pady=(10, 4))
|
||||||
|
|
||||||
|
changelog_frame = ctk.CTkFrame(self, fg_color="#1a1a2e", corner_radius=8)
|
||||||
|
changelog_frame.pack(fill="both", expand=True, padx=20, pady=(0, 10))
|
||||||
|
|
||||||
|
self._changelog_text = ctk.CTkTextbox(
|
||||||
|
changelog_frame, font=ctk.CTkFont(size=12),
|
||||||
|
fg_color="transparent", wrap="word",
|
||||||
|
activate_scrollbars=True,
|
||||||
|
)
|
||||||
|
self._changelog_text.pack(fill="both", expand=True, padx=8, pady=8)
|
||||||
|
|
||||||
|
changelog = self._info.get("changelog", "")
|
||||||
|
if changelog:
|
||||||
|
self._changelog_text.insert("1.0", changelog)
|
||||||
|
else:
|
||||||
|
self._changelog_text.insert("1.0", t("update_no_changelog"))
|
||||||
|
self._changelog_text.configure(state="disabled")
|
||||||
|
|
||||||
|
# Progress bar (hidden initially)
|
||||||
|
self._progress_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
self._progress_bar = ctk.CTkProgressBar(
|
||||||
|
self._progress_frame, width=400, height=8,
|
||||||
|
)
|
||||||
|
self._progress_bar.set(0)
|
||||||
|
self._progress_bar.pack(fill="x", padx=20)
|
||||||
|
|
||||||
|
self._progress_label = ctk.CTkLabel(
|
||||||
|
self._progress_frame, text="",
|
||||||
|
font=ctk.CTkFont(size=11), text_color="#9ca3af",
|
||||||
|
)
|
||||||
|
self._progress_label.pack(pady=(2, 0))
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
btn_frame.pack(fill="x", padx=20, pady=(0, 16))
|
||||||
|
|
||||||
|
self._install_btn = ctk.CTkButton(
|
||||||
|
btn_frame,
|
||||||
|
text=t("update_install") if self._downloaded_path else t("update_download_install"),
|
||||||
|
width=140, height=34, corner_radius=8,
|
||||||
|
fg_color="#2563eb", hover_color="#1d4ed8",
|
||||||
|
font=ctk.CTkFont(size=13, weight="bold"),
|
||||||
|
command=self._on_install_click,
|
||||||
|
)
|
||||||
|
self._install_btn.pack(side="right", padx=(8, 0))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
btn_frame, text=t("update_later"),
|
||||||
|
width=80, height=34, corner_radius=8,
|
||||||
|
fg_color="#4b5563", hover_color="#374151",
|
||||||
|
font=ctk.CTkFont(size=13),
|
||||||
|
command=self.destroy,
|
||||||
|
).pack(side="right", padx=(8, 0))
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
btn_frame, text=t("update_skip"),
|
||||||
|
width=100, height=34, corner_radius=8,
|
||||||
|
fg_color="#4b5563", hover_color="#374151",
|
||||||
|
font=ctk.CTkFont(size=13),
|
||||||
|
command=self._on_skip_click,
|
||||||
|
).pack(side="right")
|
||||||
|
|
||||||
|
def _on_install_click(self):
|
||||||
|
if self._downloaded_path:
|
||||||
|
# Already downloaded — apply
|
||||||
|
if self._on_install:
|
||||||
|
self._on_install(self._downloaded_path)
|
||||||
|
else:
|
||||||
|
# Need to download first
|
||||||
|
self._start_download()
|
||||||
|
|
||||||
|
def _start_download(self):
|
||||||
|
if self._downloading:
|
||||||
|
return
|
||||||
|
self._downloading = True
|
||||||
|
self._install_btn.configure(state="disabled", text=t("update_downloading"))
|
||||||
|
self._progress_frame.pack(fill="x", padx=20, pady=(0, 8))
|
||||||
|
|
||||||
|
def _download():
|
||||||
|
from core.updater import UpdateChecker
|
||||||
|
# Access the updater from parent app
|
||||||
|
app = self.master
|
||||||
|
if hasattr(app, "updater"):
|
||||||
|
path = app.updater.download_update(
|
||||||
|
self._info["url"],
|
||||||
|
progress_cb=self._on_progress,
|
||||||
|
)
|
||||||
|
self.after(0, lambda: self._download_complete(path))
|
||||||
|
else:
|
||||||
|
self.after(0, lambda: self._download_complete(None))
|
||||||
|
|
||||||
|
threading.Thread(target=_download, daemon=True).start()
|
||||||
|
|
||||||
|
def _on_progress(self, downloaded: int, total: int):
|
||||||
|
"""Called from download thread."""
|
||||||
|
pct = downloaded / total if total > 0 else 0
|
||||||
|
mb_done = downloaded / (1024 * 1024)
|
||||||
|
mb_total = total / (1024 * 1024)
|
||||||
|
self.after(0, lambda: self._update_progress(pct, mb_done, mb_total))
|
||||||
|
|
||||||
|
def _update_progress(self, pct: float, mb_done: float, mb_total: float):
|
||||||
|
self._progress_bar.set(pct)
|
||||||
|
self._progress_label.configure(
|
||||||
|
text=f"{mb_done:.1f} / {mb_total:.1f} MB ({pct * 100:.0f}%)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _download_complete(self, path: str):
|
||||||
|
self._downloading = False
|
||||||
|
if path:
|
||||||
|
self._downloaded_path = path
|
||||||
|
self._install_btn.configure(
|
||||||
|
state="normal", text=t("update_restart"),
|
||||||
|
)
|
||||||
|
self._progress_bar.set(1.0)
|
||||||
|
self._progress_label.configure(text=t("update_ready"))
|
||||||
|
else:
|
||||||
|
self._install_btn.configure(
|
||||||
|
state="normal", text=t("update_download_install"),
|
||||||
|
)
|
||||||
|
self._progress_frame.pack_forget()
|
||||||
|
self._progress_label.configure(text=t("update_error"))
|
||||||
|
|
||||||
|
def _on_skip_click(self):
|
||||||
|
if self._on_skip:
|
||||||
|
self._on_skip(self._info["version"])
|
||||||
|
self.destroy()
|
||||||
BIN
releases/ServerManager-v1.8.78-win-x64.exe
Normal file
BIN
releases/ServerManager-v1.8.78-win-x64.exe
Normal file
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
"""Version info for ServerManager."""
|
"""Version info for ServerManager."""
|
||||||
|
|
||||||
__version__ = "1.8.75"
|
__version__ = "1.8.78"
|
||||||
__app_name__ = "ServerManager"
|
__app_name__ = "ServerManager"
|
||||||
__author__ = "aibot777"
|
__author__ = "aibot777"
|
||||||
__description__ = "Desktop GUI for managing remote servers"
|
__description__ = "Desktop GUI for managing remote servers"
|
||||||
|
|||||||
Reference in New Issue
Block a user