From 994fda5cc77be12c1338560345a5f3454c672909 Mon Sep 17 00:00:00 2001 From: m4ko Date: Sun, 30 Aug 2026 12:06:15 +0200 Subject: [PATCH] Added proper hotbar/menu icons and added a preloader for the launcher. --- packaging/eu.pristontale.PTLinux.desktop | 1 + ptlinux/gamebox_runtime.py | 60 ++- ptlinux/ui.py | 545 +++++++++++++++++++++-- 3 files changed, 567 insertions(+), 39 deletions(-) diff --git a/packaging/eu.pristontale.PTLinux.desktop b/packaging/eu.pristontale.PTLinux.desktop index 0d0c104..2c13005 100644 --- a/packaging/eu.pristontale.PTLinux.desktop +++ b/packaging/eu.pristontale.PTLinux.desktop @@ -8,3 +8,4 @@ Terminal=false Categories=Game;RolePlaying; Keywords=PristonTale;MMORPG;Wine;Proton; StartupNotify=true +StartupWMClass=PristonTaleEU diff --git a/ptlinux/gamebox_runtime.py b/ptlinux/gamebox_runtime.py index bd38ac6..2da2e16 100644 --- a/ptlinux/gamebox_runtime.py +++ b/ptlinux/gamebox_runtime.py @@ -10,8 +10,13 @@ import sys import time from collections.abc import Collection, Iterable, Sequence +from .desktop_identity import ( + APPLICATION_NAME, + APPLICATION_WM_CLASS, + APPLICATION_WM_INSTANCE, +) -WINDOW_TITLE = "PristonTale EU" +WINDOW_TITLE = APPLICATION_NAME WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0 WINDOW_READY_DELAY_SECONDS = 0.5 _X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p) @@ -66,6 +71,13 @@ class _MotifWmHints(ctypes.Structure): ] +class _XClassHint(ctypes.Structure): + _fields_ = [ + ("res_name", ctypes.c_char_p), + ("res_class", ctypes.c_char_p), + ] + + class _X11: def __init__(self) -> None: library_path = ctypes.util.find_library("X11") or "libX11.so.6" @@ -135,6 +147,33 @@ class _X11: self.library.XMapRaised(self.display, window) self.library.XFlush(self.display) + def set_window_identity( + self, + window: int, + *, + resource_name: str = APPLICATION_WM_INSTANCE, + resource_class: str = APPLICATION_WM_CLASS, + ) -> None: + """Replace Proton's generic steamapp identity before taskbar matching.""" + + for label, value in ( + ("resource name", resource_name), + ("resource class", resource_class), + ): + if not value or "\x00" in value: + raise GameboxRuntimeError(f"invalid X11 {label}") + hints = _XClassHint( + res_name=resource_name.encode("utf-8"), + res_class=resource_class.encode("utf-8"), + ) + if self.library.XSetClassHint( + self.display, + window, + ctypes.byref(hints), + ) == 0: + raise GameboxRuntimeError("could not set the game window identity") + self.library.XSync(self.display, 0) + def suppress_wm_ping(self, window: int) -> bool: """Stop the WM hang prompt for one window, preserving its other protocols.""" ping_atom = int(self.library.XInternAtom(self.display, b"_NET_WM_PING", 0)) @@ -265,6 +304,12 @@ class _X11: ctypes.c_int, ] self.library.XChangeProperty.restype = ctypes.c_int + self.library.XSetClassHint.argtypes = [ + ctypes.c_void_p, + ctypes.c_ulong, + ctypes.POINTER(_XClassHint), + ] + self.library.XSetClassHint.restype = ctypes.c_int self.library.XGetWMProtocols.argtypes = [ ctypes.c_void_p, ctypes.c_ulong, @@ -325,6 +370,18 @@ def _prepare_game_window( mapper.map_window(window, borderless=borderless) +def _apply_game_window_identity(mapper: _X11, window: int) -> None: + try: + mapper.set_window_identity(window) + except Exception as exc: + print( + f"Warning: could not assign the PristonTale EU taskbar identity ({exc}); " + "continuing normally", + file=sys.stderr, + flush=True, + ) + + def run_game_window( command: Sequence[str], window_title: str = WINDOW_TITLE, @@ -371,6 +428,7 @@ def run_game_window( game_window = window game_title = title game_window_created_at = now + _apply_game_window_identity(mapper, window) print( f"Game window created; preparing it: {title}", file=sys.stderr, diff --git a/ptlinux/ui.py b/ptlinux/ui.py index 6e66480..e80b429 100644 --- a/ptlinux/ui.py +++ b/ptlinux/ui.py @@ -4,6 +4,7 @@ from __future__ import annotations import base64 from collections import deque +from dataclasses import dataclass import json import math import os @@ -17,6 +18,11 @@ from typing import Callable, Mapping from . import __version__ from .depot import DepotClient, DepotError, DepotSnapshot +from .desktop_identity import ( + APPLICATION_NAME, + APPLICATION_WM_CLASS, + find_application_icon, +) from .install_status import ( GameInstallationStatus, PrimaryAction, @@ -59,12 +65,89 @@ ACTIVITY_LOG_TRIM_LINES = 500 TRANSFER_RATE_WINDOW_SECONDS = 3.0 TRANSFER_RATE_MINIMUM_INTERVAL_SECONDS = 0.2 TRANSFER_RATE_IDLE_SECONDS = 2.0 +DASHBOARD_PRELOADER_MINIMUM_MILLISECONDS = 650 +DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS = 10_000 +DASHBOARD_TRANSITION_STEP_MILLISECONDS = 16 +DASHBOARD_TRANSITION_STEPS = 9 +DASHBOARD_TRANSITION_MINIMUM_OPACITY = 0.18 +DASHBOARD_STARTUP_COMPONENTS = frozenset( + { + "broadcast", + "compatibility", + "launcher_update", + "logo", + "news", + "news_artwork", + "version", + } +) + + +@dataclass(frozen=True) +class LauncherUpdatePresentation: + """Visual treatment for one authenticated launcher-update result.""" + + button_text: str + button_style: str + badge_text: str | None class DashboardError(RuntimeError): """Raised when the native dashboard cannot be displayed.""" +def launcher_update_presentation( + check: LauncherUpdateCheck, +) -> LauncherUpdatePresentation: + """Make authenticated updates visually distinct from the normal version button.""" + + if check.available and check.supported: + return LauncherUpdatePresentation( + button_text=f"UPDATE LAUNCHER · {check.latest_version}", + button_style="LauncherUpdate.TButton", + badge_text="SIGNED UPDATE AVAILABLE", + ) + if check.available: + return LauncherUpdatePresentation( + button_text=f"MANUAL UPDATE · {check.latest_version}", + button_style="LauncherManualUpdate.TButton", + badge_text="MANUAL UPDATE REQUIRED", + ) + return LauncherUpdatePresentation( + button_text=f"LAUNCHER {check.current_version}", + button_style="Secondary.TButton", + badge_text=None, + ) + + +def dashboard_preloader_delay( + started_at: float, + *, + timestamp: float | None = None, +) -> int: + """Keep the startup preloader visible long enough to avoid a brief flash.""" + + now = time.monotonic() if timestamp is None else timestamp + elapsed_milliseconds = round(max(0.0, now - started_at) * 1000) + return max(0, DASHBOARD_PRELOADER_MINIMUM_MILLISECONDS - elapsed_milliseconds) + + +def dashboard_content_ready(ready_components: set[str] | frozenset[str]) -> bool: + """Return whether every user-visible startup component has settled.""" + + return DASHBOARD_STARTUP_COMPONENTS.issubset(ready_components) + + +def dashboard_transition_opacity(step: int, *, fading_in: bool) -> float: + """Return one clamped opacity step for the loader/dashboard cross-fade.""" + + progress = min(max(step, 0), DASHBOARD_TRANSITION_STEPS) / DASHBOARD_TRANSITION_STEPS + opacity_range = 1.0 - DASHBOARD_TRANSITION_MINIMUM_OPACITY + if fading_in: + return DASHBOARD_TRANSITION_MINIMUM_OPACITY + opacity_range * progress + return 1.0 - opacity_range * progress + + def launcher_restart_spec( paths: AppPaths, environment: Mapping[str, str] | None = None, @@ -193,17 +276,31 @@ def run_dashboard(paths: AppPaths) -> None: ) from exc paths.create_base_directories() - root = tk.Tk() - root.title("PristonTale EU — Linux Launcher") + root = tk.Tk(className=APPLICATION_WM_CLASS) + root.title(f"{APPLICATION_NAME} — Linux Launcher") + root.iconname(APPLICATION_NAME) + window_icon = None + window_icon_path = find_application_icon() + if window_icon_path is not None: + try: + window_icon = tk.PhotoImage(master=root, file=str(window_icon_path)) + root.iconphoto(True, window_icon) + except (OSError, tk.TclError) as icon_error: + print( + f"Warning: could not load the launcher icon: {icon_error}", + file=sys.stderr, + ) root.geometry(WINDOW_SIZE) root.resizable(False, False) root.configure(background="#0c0908") + root.withdraw() try: ensure_settings(paths) - except (OSError, ValueError) as error: + except (OSError, ValueError) as startup_error: + root.deiconify() messagebox.showerror( "Launcher settings", - f"The launcher could not load its settings:\n\n{error}", + f"The launcher could not load its settings:\n\n{startup_error}", parent=root, ) root.destroy() @@ -227,6 +324,25 @@ def run_dashboard(paths: AppPaths) -> None: foreground="#a8998a", font=("Sans", 8), ) + style.configure("Splash.TFrame", background="#191311") + style.configure( + "SplashBrand.TLabel", + background="#191311", + foreground="#f5d18b", + font=("Georgia", 22, "bold"), + ) + style.configure( + "SplashTagline.TLabel", + background="#191311", + foreground="#d8aa61", + font=("Sans", 8, "bold"), + ) + style.configure( + "SplashStatus.TLabel", + background="#191311", + foreground="#c9baaa", + font=("Sans", 9), + ) style.configure( "Section.TLabel", background="#191311", @@ -397,6 +513,43 @@ def run_dashboard(paths: AppPaths) -> None: background=[("active", "#271b10"), ("disabled", "#171412")], foreground=[("active", "#ffd17c"), ("disabled", "#887a70")], ) + style.configure( + "LauncherUpdate.TButton", + background="#f4bd61", + foreground="#24170f", + font=("Sans", 8, "bold"), + padding=(12, 8), + bordercolor="#ffe3a3", + borderwidth=2, + relief="flat", + ) + style.map( + "LauncherUpdate.TButton", + background=[("active", "#ffd47f"), ("disabled", "#7b5d37")], + foreground=[("active", "#120b06"), ("disabled", "#d0bea2")], + ) + style.configure( + "LauncherManualUpdate.TButton", + background="#a63f2f", + foreground="#fff4e9", + font=("Sans", 8, "bold"), + padding=(12, 8), + bordercolor="#e98769", + borderwidth=2, + relief="flat", + ) + style.map( + "LauncherManualUpdate.TButton", + background=[("active", "#c95842"), ("disabled", "#60352e")], + foreground=[("active", "#ffffff"), ("disabled", "#c9ada5")], + ) + style.configure( + "UpdateBadge.TLabel", + background="#d98927", + foreground="#1a1008", + font=("Sans", 7, "bold"), + padding=(7, 3), + ) style.configure( "Activity.TButton", background="#0c0908", @@ -428,6 +581,203 @@ def run_dashboard(paths: AppPaths) -> None: darkcolor="#b66c25", ) + startup_started_at = time.monotonic() + startup_status = tk.StringVar(value="Loading dashboard contents…") + startup_overlay = tk.Frame( + root, + background="#0c0908", + borderwidth=0, + highlightthickness=0, + ) + startup_overlay.place(x=0, y=0, relwidth=1, relheight=1) + startup_border = tk.Frame( + startup_overlay, + background="#d98927", + borderwidth=0, + highlightthickness=0, + ) + startup_border.place(relx=0.5, rely=0.5, anchor="center") + startup_shell = ttk.Frame( + startup_border, + style="Splash.TFrame", + padding=(26, 22), + ) + startup_shell.pack(fill="both", expand=True, padx=1, pady=1) + ttk.Label( + startup_shell, + text="PRISTONTALE EU", + style="SplashBrand.TLabel", + ).pack(anchor="w") + ttk.Label( + startup_shell, + text="NATIVE LINUX LAUNCHER", + style="SplashTagline.TLabel", + ).pack(anchor="w", pady=(2, 17)) + ttk.Label( + startup_shell, + textvariable=startup_status, + style="SplashStatus.TLabel", + ).pack(anchor="w", pady=(0, 8)) + startup_progress = ttk.Progressbar( + startup_shell, + style="Depot.Horizontal.TProgressbar", + mode="indeterminate", + length=420, + ) + startup_progress.pack(fill="x") + startup_progress.start(12) + startup_ready_components: set[str] = set() + startup_transition_started = False + startup_ready_after: str | None = None + startup_timeout_after: str | None = None + root.deiconify() + root.lift() + startup_overlay.lift() + root.update() + + def update_startup_progress(message: str) -> None: + if startup_transition_started: + return + startup_progress.stop() + startup_progress.configure( + mode="determinate", + maximum=len(DASHBOARD_STARTUP_COMPONENTS), + value=len(startup_ready_components), + ) + startup_status.set( + f"{message} · {len(startup_ready_components)}/" + f"{len(DASHBOARD_STARTUP_COMPONENTS)}" + ) + + def cancel_startup_after(identifier: str | None) -> None: + if identifier is None: + return + try: + root.after_cancel(identifier) + except tk.TclError: + pass + + def set_startup_component_pending(component: str) -> None: + nonlocal startup_ready_after + if startup_transition_started or component not in DASHBOARD_STARTUP_COMPONENTS: + return + if component not in startup_ready_components: + return + startup_ready_components.remove(component) + cancel_startup_after(startup_ready_after) + startup_ready_after = None + update_startup_progress("Loading dashboard content…") + + def mark_startup_component_ready(component: str, message: str) -> None: + if startup_transition_started or component not in DASHBOARD_STARTUP_COMPONENTS: + return + startup_ready_components.add(component) + update_startup_progress(message) + maybe_start_dashboard_transition() + + def set_root_opacity(value: float) -> bool: + try: + root.attributes("-alpha", value) + except tk.TclError: + return False + return True + + def finish_dashboard_transition() -> None: + set_root_opacity(1.0) + root.lift() + root.focus_set() + + def fade_dashboard_in(step: int = 0) -> None: + if not set_root_opacity( + dashboard_transition_opacity(step, fading_in=True) + ): + finish_dashboard_transition() + return + if step >= DASHBOARD_TRANSITION_STEPS: + finish_dashboard_transition() + return + root.after( + DASHBOARD_TRANSITION_STEP_MILLISECONDS, + lambda: fade_dashboard_in(step + 1), + ) + + def swap_loader_for_dashboard(*, fade_supported: bool) -> None: + if startup_overlay.winfo_exists(): + startup_overlay.destroy() + root.update_idletasks() + if fade_supported: + fade_dashboard_in() + else: + finish_dashboard_transition() + + def fade_loader_out(step: int = 0) -> None: + fade_supported = set_root_opacity( + dashboard_transition_opacity(step, fading_in=False) + ) + if not fade_supported or step >= DASHBOARD_TRANSITION_STEPS: + swap_loader_for_dashboard(fade_supported=fade_supported) + return + root.after( + DASHBOARD_TRANSITION_STEP_MILLISECONDS, + lambda: fade_loader_out(step + 1), + ) + + def begin_dashboard_transition(*, timed_out: bool = False) -> None: + nonlocal startup_transition_started + nonlocal startup_ready_after, startup_timeout_after + if startup_transition_started: + return + startup_transition_started = True + cancel_startup_after(startup_ready_after) + cancel_startup_after(startup_timeout_after) + startup_ready_after = None + startup_timeout_after = None + if timed_out: + settle_pending_news_artwork() + if "version" not in startup_ready_components: + version.set( + f"Launcher {__version__} · game build check continuing in background" + ) + startup_progress.stop() + startup_progress.configure( + mode="determinate", + maximum=len(DASHBOARD_STARTUP_COMPONENTS), + value=len(DASHBOARD_STARTUP_COMPONENTS), + ) + startup_status.set( + "Opening dashboard with available content…" + if timed_out + else "Opening dashboard…" + ) + startup_overlay.update_idletasks() + root.after(80, fade_loader_out) + + def maybe_start_dashboard_transition() -> None: + nonlocal startup_ready_after + if startup_transition_started or not dashboard_content_ready( + startup_ready_components + ): + return + remaining = dashboard_preloader_delay(startup_started_at) + if remaining <= 0: + begin_dashboard_transition() + return + if startup_ready_after is None: + startup_ready_after = root.after( + remaining, + start_dashboard_after_minimum, + ) + + def start_dashboard_after_minimum() -> None: + nonlocal startup_ready_after + startup_ready_after = None + maybe_start_dashboard_transition() + + def timeout_startup_preloader() -> None: + nonlocal startup_timeout_after + startup_timeout_after = None + begin_dashboard_transition(timed_out=True) + frame = ttk.Frame(root, style="Root.TFrame", padding=12) frame.pack(fill="both", expand=True) @@ -441,6 +791,14 @@ def run_dashboard(paths: AppPaths) -> None: highlightthickness=0, borderwidth=0, ) + logo.create_oval(4, 4, 38, 38, outline="#d98927", width=2) + logo.create_text( + 21, + 21, + text="PT", + fill="#f5d18b", + font=("Georgia", 9, "bold"), + ) logo.pack(side="left", padx=(0, 9)) brand = ttk.Frame(header, style="Root.TFrame") brand.pack(side="left", fill="x", expand=True) @@ -488,7 +846,14 @@ def run_dashboard(paths: AppPaths) -> None: system_actions = ttk.Frame(controls, style="Surface.TFrame", padding=12) system_actions.grid(row=0, column=1, sticky="nsew", padx=(4, 0)) - ttk.Label(system_actions, text="LAUNCHER", style="Section.TLabel").pack(anchor="w") + system_heading = ttk.Frame(system_actions, style="Surface.TFrame") + system_heading.pack(fill="x") + ttk.Label(system_heading, text="LAUNCHER", style="Section.TLabel").pack(side="left") + launcher_update_badge = ttk.Label( + system_heading, + text="", + style="UpdateBadge.TLabel", + ) system_button_row = ttk.Frame(system_actions, style="Surface.TFrame") system_button_row.pack(anchor="w", fill="x", pady=(6, 0)) doctor_button = ttk.Button(system_button_row, text="SYSTEM CHECK", style="Secondary.TButton") @@ -609,6 +974,7 @@ def run_dashboard(paths: AppPaths) -> None: transfer_rate = TransferRateEstimator() news_image_targets: dict[int, tk.Canvas] = {} news_image_references: dict[int, tk.PhotoImage] = {} + startup_news_images_pending: set[int] = set() logo_image_references: list[tk.PhotoImage] = [] preloader: tk.Toplevel | None = None preloader_status: tk.StringVar | None = None @@ -980,6 +1346,11 @@ def run_dashboard(paths: AppPaths) -> None: text="Official news is temporarily unavailable. Your cached game can still play.", style="Muted.TLabel", ).pack(anchor="w", padx=8, pady=8) + startup_news_images_pending.clear() + mark_startup_component_ready( + "news_artwork", + "News layout ready", + ) return visible_articles = articles[:MAX_NEWS_CARDS] for column in range(len(visible_articles)): @@ -1046,32 +1417,53 @@ def run_dashboard(paths: AppPaths) -> None: load_news_images(visible_articles) def load_news_images(articles: tuple[NewsArticle, ...]) -> None: + image_articles = tuple( + article for article in articles if article.image_url is not None + ) + startup_news_images_pending.clear() + startup_news_images_pending.update(article.id for article in image_articles) + if startup_news_images_pending: + set_startup_component_pending("news_artwork") + else: + mark_startup_component_ready( + "news_artwork", + "News artwork ready", + ) + def worker(news_article: NewsArticle) -> None: image = fetch_news_image(news_article, paths.news_image_cache) - if image is not None: - events.put(("news_image", (news_article.id, image))) + events.put(("news_image", (news_article.id, image))) - for article in articles: - if article.image_url is not None: - threading.Thread( - target=worker, - args=(article,), - name="ptlinux-news-image", - daemon=True, - ).start() + for article in image_articles: + threading.Thread( + target=worker, + args=(article,), + name="ptlinux-news-image", + daemon=True, + ).start() def install_news_image(value: object) -> None: if ( not isinstance(value, tuple) or len(value) != 2 or not isinstance(value[0], int) - or not isinstance(value[1], bytes) + or (value[1] is not None and not isinstance(value[1], bytes)) ): return article_id, image_data = value target = news_image_targets.get(article_id) if target is None or not target.winfo_exists(): return + if image_data is None: + target.delete("all") + target.create_text( + NEWS_IMAGE_WIDTH // 2, + NEWS_IMAGE_HEIGHT // 2, + text="Artwork unavailable", + fill="#a8998a", + font=("Sans", 8), + ) + return try: image = tk.PhotoImage(data=base64.b64encode(image_data), format="png") except tk.TclError: @@ -1104,6 +1496,32 @@ def run_dashboard(paths: AppPaths) -> None: target.delete("all") target.create_image(target_width // 2, target_height // 2, image=image) + def complete_news_image(value: object) -> None: + install_news_image(value) + if not isinstance(value, tuple) or not value or not isinstance(value[0], int): + return + startup_news_images_pending.discard(value[0]) + if not startup_news_images_pending: + mark_startup_component_ready( + "news_artwork", + "News artwork ready", + ) + + def settle_pending_news_artwork() -> None: + for article_id in tuple(startup_news_images_pending): + target = news_image_targets.get(article_id) + if target is None or not target.winfo_exists(): + continue + target.delete("all") + target.create_text( + NEWS_IMAGE_WIDTH // 2, + NEWS_IMAGE_HEIGHT // 2, + text="Artwork unavailable", + fill="#a8998a", + font=("Sans", 8), + ) + startup_news_images_pending.clear() + def install_official_logo(value: object) -> None: if not isinstance(value, bytes): return @@ -1122,8 +1540,7 @@ def run_dashboard(paths: AppPaths) -> None: def load_official_logo() -> None: def worker() -> None: image = fetch_official_logo(paths.logo_cache) - if image is not None: - events.put(("logo_image", image)) + events.put(("logo_image", image)) threading.Thread(target=worker, name="ptlinux-logo", daemon=True).start() @@ -1191,6 +1608,10 @@ def run_dashboard(paths: AppPaths) -> None: local_status = inspect_game_installation(paths) refresh_primary_action(update_activity=update_activity) if not local_status.installed or game_import_requires_verification(paths): + mark_startup_component_ready( + "compatibility", + "Game state ready", + ) return def worker() -> None: @@ -1215,21 +1636,18 @@ def run_dashboard(paths: AppPaths) -> None: ) -> None: nonlocal launcher_update_check launcher_update_check = check - if check.available and check.supported: - launcher_update_button.configure( - text=f"UPDATE TO {check.latest_version}", - state="normal", - ) - elif check.available: - launcher_update_button.configure( - text="MANUAL UPDATE NEEDED", - state="normal", - ) + presentation = launcher_update_presentation(check) + launcher_update_button.configure( + text=presentation.button_text, + style=presentation.button_style, + state="normal", + ) + if presentation.badge_text is not None: + launcher_update_badge.configure(text=presentation.badge_text) + if not launcher_update_badge.winfo_manager(): + launcher_update_badge.pack(side="right") else: - launcher_update_button.configure( - text=f"LAUNCHER {__version__}", - state="normal", - ) + launcher_update_badge.pack_forget() if log_reason: append_log(check.reason) @@ -1339,12 +1757,24 @@ def run_dashboard(paths: AppPaths) -> None: root.iconify() elif kind == "broadcast": show_broadcast(value if isinstance(value, Broadcast) else None) + mark_startup_component_ready( + "broadcast", + "Broadcast ready", + ) elif kind == "news" and isinstance(value, tuple): show_news(value) + mark_startup_component_ready( + "news", + "News cards ready", + ) elif kind == "news_image": - install_news_image(value) + complete_news_image(value) elif kind == "logo_image": install_official_logo(value) + mark_startup_component_ready( + "logo", + "Launcher artwork ready", + ) elif ( kind == "compatibility" and isinstance(value, tuple) @@ -1368,28 +1798,50 @@ def run_dashboard(paths: AppPaths) -> None: f"{checked.reason or 'unknown release profile'}" ) refresh_primary_action(update_activity=update_activity) + mark_startup_component_ready( + "compatibility", + "Game compatibility ready", + ) elif kind == "version" and isinstance(value, DepotSnapshot): version.set( f"Launcher {__version__} · {value.version} · depot {value.build}" ) refresh_primary_action(value) + mark_startup_component_ready( + "version", + "Game version ready", + ) elif kind == "version_error": version.set( f"Launcher {__version__} · game build check unavailable" ) refresh_primary_action() + mark_startup_component_ready( + "version", + "Game version check complete", + ) elif kind == "launcher_update_check" and isinstance( value, LauncherUpdateCheck, ): show_launcher_update_check(value) + mark_startup_component_ready( + "launcher_update", + "Launcher update status ready", + ) elif kind == "launcher_update_error" and isinstance(value, str): if launcher_update_check is None: launcher_update_button.configure( text="CHECK LAUNCHER", + style="Secondary.TButton", state="normal", ) + launcher_update_badge.pack_forget() append_log(f"Launcher update check unavailable: {value}") + mark_startup_component_ready( + "launcher_update", + "Launcher update check complete", + ) except queue.Empty: pass if operation_active: @@ -1399,14 +1851,16 @@ def run_dashboard(paths: AppPaths) -> None: if speed_text.get() != "0 B/s" and transfer_rate.is_idle(): speed_text.set("0 B/s") update_elapsed_indicator() - root.after( - dashboard_poll_interval( + poll_delay = ( + ACTIVE_POLL_INTERVAL_MILLISECONDS + if not startup_transition_started + else dashboard_poll_interval( operation_active=operation_active, game_window_active=game_window_active, pending_events=not events.empty(), - ), - poll_events, + ) ) + root.after(poll_delay, poll_events) def command_for(*arguments: str) -> list[str]: return [sys.executable, "-m", "ptlinux", *arguments] @@ -1414,7 +1868,12 @@ def run_dashboard(paths: AppPaths) -> None: def run_launcher_update(required_reason: str | None = None) -> None: check = launcher_update_check if check is None: - launcher_update_button.configure(text="CHECKING…", state="disabled") + launcher_update_badge.pack_forget() + launcher_update_button.configure( + text="CHECKING…", + style="Secondary.TButton", + state="disabled", + ) if required_reason is not None: status.set("Checking for a compatible launcher update…") progress_text.set("Play remains disabled until this release is supported.") @@ -1824,6 +2283,9 @@ def run_dashboard(paths: AppPaths) -> None: news_content.bind("", update_news_viewport) news_canvas.bind("", resize_news_content) + startup_overlay.lift() + startup_status.set("Restoring dashboard content…") + startup_overlay.update_idletasks() show_broadcast(read_cached_broadcast(paths.broadcast_cache)) show_news(read_cached_news(paths.news_cache)) cached_launcher_update = read_cached_launcher_update_check( @@ -1835,7 +2297,14 @@ def run_dashboard(paths: AppPaths) -> None: refresh_runtime_compatibility() poll_events() load_official_logo() + update_startup_progress("Loading online dashboard content…") + startup_overlay.update_idletasks() + startup_timeout_after = root.after( + DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS, + timeout_startup_preloader, + ) refresh_remote_content() + maybe_start_dashboard_transition() try: root.mainloop() except KeyboardInterrupt: