"""Launcher update history, install, and rollback dialog.""" from __future__ import annotations from typing import Any, Callable from ...paths import AppPaths from ...self_update import ( LauncherRemoteHistory, LauncherRemoteRelease, LauncherUpdateCheck, LauncherUpdateHistory, inspect_launcher_update_history, ) from ...update_resolver import PRERELEASE_CHANNEL, ReleaseModelError, SemanticVersion 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 MAX_RELEASE_NOTES_PREVIEW_LINES = 6 MAX_RELEASE_NOTES_PREVIEW_CHARACTERS = 900 def release_notes_preview(markdown: str | None) -> str: """Return a compact, readable changelog excerpt for the update dialog.""" if not markdown: return "" lines: list[str] = [] truncated = False for raw_line in markdown.splitlines(): line = raw_line.strip() if not line or (not lines and line.startswith("# ")): continue if line == "---": break if len(lines) >= MAX_RELEASE_NOTES_PREVIEW_LINES: truncated = True break lines.append(line) preview = "\n".join(lines) if len(preview) > MAX_RELEASE_NOTES_PREVIEW_CHARACTERS: preview = preview[:MAX_RELEASE_NOTES_PREVIEW_CHARACTERS].rstrip() truncated = True if truncated: preview = preview.rstrip() + "\n…" return preview 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], get_remote_history: Callable[[], LauncherRemoteHistory | None], get_selected_channel: Callable[[], str], 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._get_remote_history = get_remote_history self._get_selected_channel = get_selected_channel 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._history_filter_value: Any = None self._history_items: dict[str, tuple[str, str, str | None, bool]] = {} 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 RELEASE 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="VERSION 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(1, weight=1) filter_row = ttk.Frame(history_card, style="Root.TFrame") filter_row.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 8)) filter_row.columnconfigure(0, weight=1) ttk.Label( filter_row, text="Signed Gitea releases and retained local versions", style="SettingsHint.TLabel", ).grid(row=0, column=0, sticky="w") self._history_filter_value = tk.StringVar( value=( "All" if self._get_selected_channel() == PRERELEASE_CHANNEL else "Stable" ) ) history_filter = ttk.Combobox( filter_row, name="launcher_update_channel_filter", textvariable=self._history_filter_value, values=("All", "Stable", "Prerelease"), state="readonly", style="Settings.TCombobox", cursor="hand2", exportselection=False, takefocus=True, width=12, ) history_filter.grid(row=0, column=1, sticky="e", padx=(12, 0)) history_filter.bind("<>", lambda _event: self.refresh()) self._history = ttk.Treeview( history_card, columns=("version", "channel", "state", "installed"), show="headings", height=6, style="Update.Treeview", selectmode="browse", ) self._history.heading("version", text="VERSION", anchor="w") self._history.heading("channel", text="CHANNEL", anchor="w") self._history.heading("state", text="STATE", anchor="w") self._history.heading("installed", text="DATE", 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( "channel", width=round(105 * 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=1, column=0, sticky="nsew") self._history.bind("<>", self._history_selected) history_scrollbar = ttk.Scrollbar( history_card, orient="vertical", style="News.Vertical.TScrollbar", command=self._history.yview, ) history_scrollbar.grid(row=1, 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("", 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}") busy = self._is_busy() self._populate_history(busy=busy) 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._refresh_history_actions(busy=busy) return status_prefix = ( f"Compatibility update required: {self._required_reason}\n" if self._required_reason is not None else "" ) remote_history = self._get_remote_history() installed_status = next( ( release.status for release in ( remote_history.releases if remote_history is not None else () ) if release.version == check.current_version and release.status != "active" ), None, ) if installed_status is not None: status_prefix += ( f"Warning: installed launcher {check.current_version} is " f"{installed_status}; install the next active eligible release.\n" ) self._status_value.set(status_prefix + check.reason) release_title = check.manifest.title or f"Launcher {check.latest_version}" release_summary = check.manifest.summary or "No release summary was provided." notes_preview = release_notes_preview(check.manifest.notes_markdown) prerelease_label = ( " · PRERELEASE BUILD" if check.manifest.channel == "prerelease" else "" ) details = ( f"{check.current_version} → {check.latest_version}\n" f"{release_title}{prerelease_label}\n" f"{release_summary}\n" f"Channel: {check.manifest.channel.title()} · " f"{format_bytes(check.manifest.archive_size)} · " f"published {check.manifest.published_at} · " f"checked {check.checked_at or 'time unavailable'}" ) if notes_preview: details += f"\n\nWhat's new:\n{notes_preview}" self._details_value.set(details) 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" ) self._refresh_history_actions(busy=busy) def _populate_history(self, *, busy: bool) -> None: assert self._history is not None assert self._history_filter_value is not None selected_version = None selection = self._history.selection() if selection: selected = self._history_items.get(selection[0]) selected_version = selected[0] if selected is not None else None for item in self._history.get_children(): self._history.delete(item) self._history_items.clear() installed = { release.version: release for release in ( self._installed_history.releases if self._installed_history is not None else () ) } current_version = ( self._installed_history.current_version if self._installed_history is not None and self._installed_history.current_version is not None else self._launcher_version ) try: current_semantic = SemanticVersion.parse(current_version) except ReleaseModelError: current_semantic = None filter_name = str(self._history_filter_value.get()).casefold() def channel_visible(channel: str) -> bool: return filter_name == "all" or filter_name == channel remote_history = self._get_remote_history() remote_versions: set[str] = set() if remote_history is not None: for index, release in enumerate(remote_history.releases): if not channel_visible(release.channel): continue remote_versions.add(release.version) local = installed.get(release.version) is_older = ( current_semantic is not None and SemanticVersion.parse(release.version) < current_semantic ) can_rollback = release.status == "active" and is_older state = self._remote_release_state( release, local_active=bool(local is not None and local.active), local_retained=local is not None, is_older=is_older, ) iid = f"remote:{index}:{release.version}" self._history.insert( "", "end", iid=iid, values=( release.version, release.channel.title(), state, release.release_date, ), ) self._history_items[iid] = ( release.version, release.channel, release.release_notes_url, can_rollback, ) if self._installed_history is not None: for index, release in enumerate(self._installed_history.releases): if release.version in remote_versions or not channel_visible(release.channel): continue try: is_older = ( current_semantic is not None and SemanticVersion.parse(release.version) < current_semantic ) except ReleaseModelError: is_older = False state = "Current" if release.active else "Retained locally" iid = f"local:{index}:{release.version}" self._history.insert( "", "end", iid=iid, values=( release.version, release.channel.title(), state, release.installed_at, ), ) self._history_items[iid] = ( release.version, release.channel, None, is_older, ) if not self._history.get_children(): iid = "placeholder" self._history.insert( "", "end", iid=iid, values=(self._launcher_version, "Unknown", "Running", "Not recorded"), ) default_version = selected_version if default_version is None and self._installed_history is not None: default_version = self._installed_history.previous_version selected_iid = next( ( iid for iid, item in self._history_items.items() if item[0] == default_version ), None, ) if selected_iid is not None: self._history.selection_set(selected_iid) self._history.focus(selected_iid) self._refresh_history_actions(busy=busy) @staticmethod def _remote_release_state( release: LauncherRemoteRelease, *, local_active: bool, local_retained: bool, is_older: bool, ) -> str: if local_active: return "Current" if release.status != "active": return release.status.title() if local_retained: return "Retained locally" if is_older: return "Available rollback" return "Published" def _history_selected(self, _event: object | None = None) -> None: self._refresh_history_actions(busy=self._is_busy()) def _selected_history_item(self) -> tuple[str, str, str | None, bool] | None: assert self._history is not None selection = self._history.selection() return self._history_items.get(selection[0]) if selection else None def _refresh_history_actions(self, *, busy: bool) -> None: assert self._rollback_button is not None assert self._release_notes_button is not None selected = self._selected_history_item() self._rollback_button.configure( state=( "normal" if selected is not None and selected[3] and not busy else "disabled" ) ) if selected is None: check = self._get_check() notes_url = check.manifest.notes_url if check is not None else None else: notes_url = selected[2] self._release_notes_button.configure( state="normal" if 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 selected channel manifests and signatures.") self._check_button.configure(state="disabled") self._install_button.configure(state="disabled") self._request_check() def _view_release_notes(self) -> None: selected = self._selected_history_item() if selected is not None: if selected[2] is not None: self._open_article(selected[2]) return 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"from the {check.manifest.channel} channel " 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 selected = self._selected_history_item() if selected is not None and selected[3]: target_version, target_channel, _notes_url, _can_rollback = selected elif history is not None and history.previous_version is not None: target_version = history.previous_version target = next( ( release for release in history.releases if release.version == target_version ), None, ) target_channel = target.channel if target is not None else "stable" else: return current_release = next( ( release for release in (history.releases if history is not None else ()) if release.active ), None, ) current_channel = ( current_release.channel if current_release is not None else SemanticVersion.parse(self._launcher_version).channel ) channel_warning = "" if current_channel != target_channel: channel_warning = ( f"\n\nThis crosses from {current_channel.title()} to " f"{target_channel.title()}. Older releases may use different " "configuration schemas." ) if not show_themed_dialog( self._window, title="Roll back launcher", message=( f"Switch from launcher " f"{(history.current_version if history is not None else None) or self._launcher_version} " f"to {target_version} and restart?\n\n" "The target manifest, signature, archive, and file hashes will be " "authenticated before activation.\n\n" "The current release remains available as the next rollback target." + channel_warning ), kind="warning", confirm=True, accept_text="ROLL BACK & RESTART", window_icon=self._window_icon, ): return self.close() self._rollback_and_restart(target_version)