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

@@ -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()