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:
chrome-storm-c442
2026-03-01 09:00:27 -05:00
parent c23eb36dcc
commit 9393134593
8 changed files with 974 additions and 1 deletions

View File

@@ -453,6 +453,30 @@ _EN = {
"ctx_check_status": "Check Status",
"ctx_copy_alias": "Copy Alias",
"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 = {
@@ -883,6 +907,30 @@ _RU = {
"ctx_check_status": "Проверить статус",
"ctx_copy_alias": "Копировать алиас",
"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 = {
@@ -1313,6 +1361,30 @@ _ZH = {
"ctx_check_status": "检查状态",
"ctx_copy_alias": "复制别名",
"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 = {

View File

@@ -59,6 +59,10 @@ class ServerStore:
self._terminal_font_size: int = 11
self._window_geometry: str = ""
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()
@@ -79,6 +83,9 @@ class ServerStore:
self._check_interval = settings.get("check_interval", 60)
self._terminal_font_size = settings.get("terminal_font_size", 11)
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:
log.warning("Corrupted settings.json, using defaults")
except Exception as e:
@@ -93,6 +100,9 @@ class ServerStore:
"check_interval": self._check_interval,
"terminal_font_size": self._terminal_font_size,
"window_geometry": self._window_geometry,
"update_mode": self._update_mode,
"last_update_check": self._last_update_check,
"skip_version": self._skip_version,
}
try:
tmp = SETTINGS_FILE + ".tmp"
@@ -413,3 +423,28 @@ class ServerStore:
def set_terminal_font_size(self, size: int):
self._terminal_font_size = max(6, min(28, size))
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
View 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)

View File

@@ -8,6 +8,7 @@ from tkinter import messagebox
from core.server_store import ServerStore
from core.status_checker import StatusChecker
from core.updater import UpdateChecker
from core import i18n
from core.i18n import t, LANGUAGES
from core.icons import icon, TAB_ICONS
@@ -83,6 +84,7 @@ class App(ctk.CTk):
self.store = ServerStore()
self.checker = StatusChecker(self.store)
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
saved_geo = self.store._window_geometry
@@ -99,6 +101,9 @@ class App(ctk.CTk):
self.checker.start()
self.checker.check_all_now()
# Auto-updater
self.updater.start()
# 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
# 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))
# 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
self.about_btn = ctk.CTkButton(
header_bar, text="", width=30, height=30,
@@ -150,6 +163,11 @@ class App(ctk.CTk):
)
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
self.tabview = None
self._tab_keys = []
@@ -316,6 +334,115 @@ class App(ctk.CTk):
def _show_about(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:
"""Get the i18n key of the currently active tab."""
try:
@@ -514,4 +641,5 @@ class App(ctk.CTk):
# Disconnect all sessions before closing
self.session_pool.disconnect_all()
self.checker.stop()
self.updater.stop()
self.destroy()

View File

@@ -124,6 +124,45 @@ class SetupTab(ctk.CTkFrame):
btn.pack(side="left", padx=2)
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 ─────────────────────
config_frame = ctk.CTkFrame(self._scroll)
config_frame.pack(fill="x", padx=20, pady=(5, 5))
@@ -212,6 +251,38 @@ class SetupTab(ctk.CTkFrame):
# Initial status check
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):
self.store.set_check_interval(seconds)
for s, btn in self._interval_buttons.items():

271
gui/update_dialog.py Normal file
View 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()

Binary file not shown.

View File

@@ -1,6 +1,6 @@
"""Version info for ServerManager."""
__version__ = "1.8.75"
__version__ = "1.8.78"
__app_name__ = "ServerManager"
__author__ = "aibot777"
__description__ = "Desktop GUI for managing remote servers"