Files
pristontale-eu-linux/ptlinux/ui/components/update_dialog.py
2026-09-03 19:07:09 +02:00

435 lines
13 KiB
Python

"""Launcher update history, install, and rollback dialog."""
from __future__ import annotations
from typing import Any, Callable
from ...paths import AppPaths
from ...self_update import (
LauncherUpdateCheck,
LauncherUpdateHistory,
inspect_launcher_update_history,
)
from ...tk_chrome import (
centered_window_geometry,
create_window_chrome,
show_themed_dialog,
)
from ..viewmodels.dashboard import fitted_window_size
from ..viewmodels.downloader import format_bytes
class LauncherUpdateDialog:
"""Own the update window and expose small refresh/error entry points."""
def __init__(
self,
root: Any,
*,
paths: AppPaths,
launcher_version: str,
ui_scale: float,
window_icon: Any | None,
apply_window_icon: Callable[[Any], None],
get_check: Callable[[], LauncherUpdateCheck | None],
is_busy: Callable[[], bool],
request_check: Callable[[], None],
install_and_restart: Callable[[str], None],
rollback_and_restart: Callable[[str], None],
open_article: Callable[[str], None],
append_log: Callable[[str], None],
) -> None:
self._root = root
self._paths = paths
self._launcher_version = launcher_version
self._ui_scale = ui_scale
self._window_icon = window_icon
self._apply_window_icon = apply_window_icon
self._get_check = get_check
self._is_busy = is_busy
self._request_check = request_check
self._install_and_restart = install_and_restart
self._rollback_and_restart = rollback_and_restart
self._open_article = open_article
self._append_log = append_log
self._window: Any = None
self._required_reason: str | None = None
self._installed_history: LauncherUpdateHistory | None = None
self._status_value: Any = None
self._details_value: Any = None
self._history: Any = None
self._release_notes_button: Any = None
self._check_button: Any = None
self._rollback_button: Any = None
self._install_button: Any = None
def open(self, required_reason: str | None = None) -> None:
if self._window is not None and self._window.winfo_exists():
if required_reason is not None:
self._required_reason = required_reason
self._window.deiconify()
self._window.lift()
self._window.focus_set()
self.refresh()
return
self._required_reason = required_reason
import tkinter as tk
from tkinter import ttk
dialog = tk.Toplevel(self._root)
dialog.withdraw()
self._window = dialog
dialog.title("PristonTale EU — Launcher Updates")
dialog.resizable(False, False)
dialog.configure(background="#0c0908")
dialog.transient(self._root)
self._apply_window_icon(dialog)
updates_chrome = create_window_chrome(
dialog,
title="PRISTONTALE EU",
subtitle="LAUNCHER UPDATES",
window_icon=self._window_icon,
show_close=True,
show_minimize=False,
allow_close=True,
)
updates_header = tk.Frame(updates_chrome.content, background="#24170f")
updates_header.pack(fill="x")
tk_accent = tk.Frame(updates_header, background="#d98927", width=5)
tk_accent.pack(side="left", fill="y")
header_copy = tk.Frame(updates_header, background="#24170f")
header_copy.pack(side="left", fill="both", expand=True, padx=20, pady=14)
tk.Label(
header_copy,
text="SIGNED STABLE CHANNEL",
background="#24170f",
foreground="#d8aa61",
font=("Sans", 8, "bold"),
).pack(anchor="w")
tk.Label(
header_copy,
text="Launcher Updates",
background="#24170f",
foreground="#fff0d2",
font=("Georgia", 18, "bold"),
).pack(anchor="w", pady=(2, 0))
content = ttk.Frame(
updates_chrome.content,
style="Root.TFrame",
padding=(16, 14, 16, 16),
)
content.pack(fill="both", expand=True)
content.columnconfigure(0, weight=1)
content.rowconfigure(1, weight=1)
self._status_value = tk.StringVar(value="Checking the signed update channel…")
self._details_value = tk.StringVar(value="")
overview = ttk.LabelFrame(
content,
text="UPDATE STATUS",
style="SettingsCard.TLabelframe",
padding=14,
)
overview.grid(row=0, column=0, sticky="ew")
overview.columnconfigure(0, weight=1)
ttk.Label(
overview,
textvariable=self._status_value,
style="SettingsText.TLabel",
wraplength=round(480 * self._ui_scale),
justify="left",
).grid(row=0, column=0, sticky="w")
ttk.Label(
overview,
textvariable=self._details_value,
style="SettingsHint.TLabel",
wraplength=round(480 * self._ui_scale),
justify="left",
).grid(row=1, column=0, sticky="w", pady=(6, 0))
self._release_notes_button = ttk.Button(
overview,
text="VIEW RELEASE NOTES",
style="Secondary.TButton",
state="disabled",
command=self._view_release_notes,
)
self._release_notes_button.grid(
row=0,
column=1,
rowspan=2,
sticky="e",
padx=(12, 0),
)
history_card = ttk.LabelFrame(
content,
text="INSTALLED HISTORY",
style="SettingsCard.TLabelframe",
padding=12,
)
history_card.grid(row=1, column=0, sticky="nsew", pady=(12, 0))
history_card.columnconfigure(0, weight=1)
history_card.rowconfigure(0, weight=1)
self._history = ttk.Treeview(
history_card,
columns=("version", "state", "installed"),
show="headings",
height=6,
style="Update.Treeview",
selectmode="browse",
)
self._history.heading("version", text="VERSION", anchor="w")
self._history.heading("state", text="STATE", anchor="w")
self._history.heading("installed", text="INSTALLED", anchor="w")
self._history.column(
"version",
width=round(120 * self._ui_scale),
minwidth=round(80 * self._ui_scale),
stretch=False,
anchor="w",
)
self._history.column(
"state",
width=round(150 * self._ui_scale),
minwidth=round(100 * self._ui_scale),
stretch=False,
anchor="w",
)
self._history.column(
"installed",
width=round(360 * self._ui_scale),
minwidth=round(180 * self._ui_scale),
stretch=True,
anchor="w",
)
self._history.grid(row=0, column=0, sticky="nsew")
history_scrollbar = ttk.Scrollbar(
history_card,
orient="vertical",
style="News.Vertical.TScrollbar",
command=self._history.yview,
)
history_scrollbar.grid(row=0, column=1, sticky="ns", padx=(6, 0))
self._history.configure(yscrollcommand=history_scrollbar.set)
footer = ttk.Frame(content, style="SettingsFooter.TFrame", padding=(12, 9))
footer.grid(row=2, column=0, sticky="ew", pady=(12, 0))
footer.columnconfigure(0, weight=1)
self._check_button = ttk.Button(
footer,
text="CHECK AGAIN",
style="Secondary.TButton",
command=self._check_again,
)
self._check_button.grid(row=0, column=0, sticky="w")
self._rollback_button = ttk.Button(
footer,
text="ROLL BACK & RESTART",
style="Secondary.TButton",
state="disabled",
command=self._rollback_update,
)
self._rollback_button.grid(row=0, column=1, sticky="e", padx=(8, 0))
self._install_button = ttk.Button(
footer,
text="INSTALL & RESTART",
style="Play.TButton",
state="disabled",
command=self._install_update,
)
self._install_button.grid(row=0, column=2, sticky="e", padx=(8, 0))
close_button = ttk.Button(
footer,
text="CLOSE",
style="Secondary.TButton",
command=self.close,
)
close_button.grid(row=0, column=3, sticky="e", padx=(8, 0))
updates_chrome.set_close_command(self.close)
dialog.protocol("WM_DELETE_WINDOW", self.close)
dialog.bind("<Escape>", lambda _event: self.close())
self.refresh()
if self._get_check() is None:
self._check_again()
dialog.update_idletasks()
updates_width, updates_height = fitted_window_size(
760,
620,
scale=self._ui_scale,
screen_width=dialog.winfo_screenwidth(),
screen_height=dialog.winfo_screenheight(),
)
dialog.geometry(
centered_window_geometry(
updates_width,
updates_height,
parent_x=self._root.winfo_rootx(),
parent_y=self._root.winfo_rooty(),
parent_width=self._root.winfo_width(),
parent_height=self._root.winfo_height(),
)
)
dialog.deiconify()
dialog.lift()
self._history.focus_set()
def close(self) -> None:
window = self._window
self._window = None
if window is not None and window.winfo_exists():
window.destroy()
def refresh(self) -> None:
if self._window is None or not self._window.winfo_exists():
return
assert self._status_value is not None
assert self._details_value is not None
assert self._history is not None
assert self._release_notes_button is not None
assert self._check_button is not None
assert self._rollback_button is not None
assert self._install_button is not None
check = self._get_check()
try:
self._installed_history = inspect_launcher_update_history(self._paths)
except RuntimeError as error:
self._installed_history = None
self._append_log(f"Could not inspect launcher update history: {error}")
for item in self._history.get_children():
self._history.delete(item)
if self._installed_history is not None:
for release in self._installed_history.releases:
state_label = (
"Current"
if release.active
else "Rollback target"
if release.previous
else "Retained"
)
self._history.insert(
"",
"end",
values=(release.version, state_label, release.installed_at),
)
if not self._history.get_children():
self._history.insert(
"",
"end",
values=(self._launcher_version, "Running", "Not recorded"),
)
busy = self._is_busy()
rollback_available = (
self._installed_history is not None
and self._installed_history.previous_version is not None
and not busy
)
self._rollback_button.configure(
state="normal" if rollback_available else "disabled"
)
self._check_button.configure(state="disabled" if busy else "normal")
if check is None:
self._status_value.set("Checking the signed update channel…")
self._details_value.set(
"Installed launcher "
+ self._launcher_version
+ " · release information pending"
)
self._install_button.configure(state="disabled")
self._release_notes_button.configure(state="disabled")
return
status_prefix = (
f"Compatibility update required: {self._required_reason}\n"
if self._required_reason is not None
else ""
)
self._status_value.set(status_prefix + check.reason)
self._details_value.set(
f"Current {check.current_version} · Latest {check.latest_version} · "
f"{check.manifest.channel.title()} channel · "
f"{format_bytes(check.manifest.archive_size)} · "
f"published {check.manifest.published_at} · "
f"checked {check.checked_at or 'time unavailable'}"
)
self._install_button.configure(
state=(
"normal"
if check.available and check.supported and not busy
else "disabled"
)
)
self._release_notes_button.configure(
state="normal" if check.manifest.notes_url is not None else "disabled"
)
def show_error(self, message: str) -> None:
if self._window is None or not self._window.winfo_exists():
return
assert self._status_value is not None
assert self._details_value is not None
assert self._check_button is not None
self._status_value.set("The signed update channel could not be checked.")
self._details_value.set(message)
self._check_button.configure(state="normal")
def _check_again(self) -> None:
assert self._status_value is not None
assert self._details_value is not None
assert self._check_button is not None
assert self._install_button is not None
self._status_value.set("Checking the signed update channel…")
self._details_value.set("Authenticating the stable manifest and signature.")
self._check_button.configure(state="disabled")
self._install_button.configure(state="disabled")
self._request_check()
def _view_release_notes(self) -> None:
check = self._get_check()
if check is not None and check.manifest.notes_url is not None:
self._open_article(check.manifest.notes_url)
def _install_update(self) -> None:
check = self._get_check()
if check is None or not check.available or not check.supported:
return
if not show_themed_dialog(
self._window,
title="Update launcher",
message=(
f"Install signed launcher {check.latest_version} "
f"({format_bytes(check.manifest.archive_size)}) and restart now?"
),
kind="question",
confirm=True,
accept_text="INSTALL & RESTART",
window_icon=self._window_icon,
):
return
version_to_install = check.latest_version
self.close()
self._install_and_restart(version_to_install)
def _rollback_update(self) -> None:
history = self._installed_history
if history is None or history.previous_version is None:
return
if not show_themed_dialog(
self._window,
title="Roll back launcher",
message=(
f"Switch from launcher {history.current_version or self._launcher_version} "
f"to {history.previous_version} and restart?\n\n"
"The current release remains available as the next rollback target."
),
kind="warning",
confirm=True,
accept_text="ROLL BACK & RESTART",
window_icon=self._window_icon,
):
return
target_version = history.previous_version
self.close()
self._rollback_and_restart(target_version)