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:
128
gui/app.py
128
gui/app.py
@@ -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()
|
||||
|
||||
@@ -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
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()
|
||||
Reference in New Issue
Block a user