Added proper hotbar/menu icons and added a preloader for the launcher.

This commit is contained in:
2026-08-30 12:06:15 +02:00
parent 2a7da33160
commit 994fda5cc7
3 changed files with 567 additions and 39 deletions

View File

@@ -8,3 +8,4 @@ Terminal=false
Categories=Game;RolePlaying; Categories=Game;RolePlaying;
Keywords=PristonTale;MMORPG;Wine;Proton; Keywords=PristonTale;MMORPG;Wine;Proton;
StartupNotify=true StartupNotify=true
StartupWMClass=PristonTaleEU

View File

@@ -10,8 +10,13 @@ import sys
import time import time
from collections.abc import Collection, Iterable, Sequence 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_DISCOVERY_TIMEOUT_SECONDS = 60.0
WINDOW_READY_DELAY_SECONDS = 0.5 WINDOW_READY_DELAY_SECONDS = 0.5
_X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p) _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: class _X11:
def __init__(self) -> None: def __init__(self) -> None:
library_path = ctypes.util.find_library("X11") or "libX11.so.6" 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.XMapRaised(self.display, window)
self.library.XFlush(self.display) 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: def suppress_wm_ping(self, window: int) -> bool:
"""Stop the WM hang prompt for one window, preserving its other protocols.""" """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)) ping_atom = int(self.library.XInternAtom(self.display, b"_NET_WM_PING", 0))
@@ -265,6 +304,12 @@ class _X11:
ctypes.c_int, ctypes.c_int,
] ]
self.library.XChangeProperty.restype = 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 = [ self.library.XGetWMProtocols.argtypes = [
ctypes.c_void_p, ctypes.c_void_p,
ctypes.c_ulong, ctypes.c_ulong,
@@ -325,6 +370,18 @@ def _prepare_game_window(
mapper.map_window(window, borderless=borderless) 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( def run_game_window(
command: Sequence[str], command: Sequence[str],
window_title: str = WINDOW_TITLE, window_title: str = WINDOW_TITLE,
@@ -371,6 +428,7 @@ def run_game_window(
game_window = window game_window = window
game_title = title game_title = title
game_window_created_at = now game_window_created_at = now
_apply_game_window_identity(mapper, window)
print( print(
f"Game window created; preparing it: {title}", f"Game window created; preparing it: {title}",
file=sys.stderr, file=sys.stderr,

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import base64 import base64
from collections import deque from collections import deque
from dataclasses import dataclass
import json import json
import math import math
import os import os
@@ -17,6 +18,11 @@ from typing import Callable, Mapping
from . import __version__ from . import __version__
from .depot import DepotClient, DepotError, DepotSnapshot from .depot import DepotClient, DepotError, DepotSnapshot
from .desktop_identity import (
APPLICATION_NAME,
APPLICATION_WM_CLASS,
find_application_icon,
)
from .install_status import ( from .install_status import (
GameInstallationStatus, GameInstallationStatus,
PrimaryAction, PrimaryAction,
@@ -59,12 +65,89 @@ ACTIVITY_LOG_TRIM_LINES = 500
TRANSFER_RATE_WINDOW_SECONDS = 3.0 TRANSFER_RATE_WINDOW_SECONDS = 3.0
TRANSFER_RATE_MINIMUM_INTERVAL_SECONDS = 0.2 TRANSFER_RATE_MINIMUM_INTERVAL_SECONDS = 0.2
TRANSFER_RATE_IDLE_SECONDS = 2.0 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): class DashboardError(RuntimeError):
"""Raised when the native dashboard cannot be displayed.""" """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( def launcher_restart_spec(
paths: AppPaths, paths: AppPaths,
environment: Mapping[str, str] | None = None, environment: Mapping[str, str] | None = None,
@@ -193,17 +276,31 @@ def run_dashboard(paths: AppPaths) -> None:
) from exc ) from exc
paths.create_base_directories() paths.create_base_directories()
root = tk.Tk() root = tk.Tk(className=APPLICATION_WM_CLASS)
root.title("PristonTale EU — Linux Launcher") 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.geometry(WINDOW_SIZE)
root.resizable(False, False) root.resizable(False, False)
root.configure(background="#0c0908") root.configure(background="#0c0908")
root.withdraw()
try: try:
ensure_settings(paths) ensure_settings(paths)
except (OSError, ValueError) as error: except (OSError, ValueError) as startup_error:
root.deiconify()
messagebox.showerror( messagebox.showerror(
"Launcher settings", "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, parent=root,
) )
root.destroy() root.destroy()
@@ -227,6 +324,25 @@ def run_dashboard(paths: AppPaths) -> None:
foreground="#a8998a", foreground="#a8998a",
font=("Sans", 8), 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( style.configure(
"Section.TLabel", "Section.TLabel",
background="#191311", background="#191311",
@@ -397,6 +513,43 @@ def run_dashboard(paths: AppPaths) -> None:
background=[("active", "#271b10"), ("disabled", "#171412")], background=[("active", "#271b10"), ("disabled", "#171412")],
foreground=[("active", "#ffd17c"), ("disabled", "#887a70")], 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( style.configure(
"Activity.TButton", "Activity.TButton",
background="#0c0908", background="#0c0908",
@@ -428,6 +581,203 @@ def run_dashboard(paths: AppPaths) -> None:
darkcolor="#b66c25", 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 = ttk.Frame(root, style="Root.TFrame", padding=12)
frame.pack(fill="both", expand=True) frame.pack(fill="both", expand=True)
@@ -441,6 +791,14 @@ def run_dashboard(paths: AppPaths) -> None:
highlightthickness=0, highlightthickness=0,
borderwidth=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)) logo.pack(side="left", padx=(0, 9))
brand = ttk.Frame(header, style="Root.TFrame") brand = ttk.Frame(header, style="Root.TFrame")
brand.pack(side="left", fill="x", expand=True) 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 = ttk.Frame(controls, style="Surface.TFrame", padding=12)
system_actions.grid(row=0, column=1, sticky="nsew", padx=(4, 0)) 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 = ttk.Frame(system_actions, style="Surface.TFrame")
system_button_row.pack(anchor="w", fill="x", pady=(6, 0)) system_button_row.pack(anchor="w", fill="x", pady=(6, 0))
doctor_button = ttk.Button(system_button_row, text="SYSTEM CHECK", style="Secondary.TButton") 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() transfer_rate = TransferRateEstimator()
news_image_targets: dict[int, tk.Canvas] = {} news_image_targets: dict[int, tk.Canvas] = {}
news_image_references: dict[int, tk.PhotoImage] = {} news_image_references: dict[int, tk.PhotoImage] = {}
startup_news_images_pending: set[int] = set()
logo_image_references: list[tk.PhotoImage] = [] logo_image_references: list[tk.PhotoImage] = []
preloader: tk.Toplevel | None = None preloader: tk.Toplevel | None = None
preloader_status: tk.StringVar | 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.", text="Official news is temporarily unavailable. Your cached game can still play.",
style="Muted.TLabel", style="Muted.TLabel",
).pack(anchor="w", padx=8, pady=8) ).pack(anchor="w", padx=8, pady=8)
startup_news_images_pending.clear()
mark_startup_component_ready(
"news_artwork",
"News layout ready",
)
return return
visible_articles = articles[:MAX_NEWS_CARDS] visible_articles = articles[:MAX_NEWS_CARDS]
for column in range(len(visible_articles)): for column in range(len(visible_articles)):
@@ -1046,13 +1417,24 @@ def run_dashboard(paths: AppPaths) -> None:
load_news_images(visible_articles) load_news_images(visible_articles)
def load_news_images(articles: tuple[NewsArticle, ...]) -> None: 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: def worker(news_article: NewsArticle) -> None:
image = fetch_news_image(news_article, paths.news_image_cache) 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: for article in image_articles:
if article.image_url is not None:
threading.Thread( threading.Thread(
target=worker, target=worker,
args=(article,), args=(article,),
@@ -1065,13 +1447,23 @@ def run_dashboard(paths: AppPaths) -> None:
not isinstance(value, tuple) not isinstance(value, tuple)
or len(value) != 2 or len(value) != 2
or not isinstance(value[0], int) 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 return
article_id, image_data = value article_id, image_data = value
target = news_image_targets.get(article_id) target = news_image_targets.get(article_id)
if target is None or not target.winfo_exists(): if target is None or not target.winfo_exists():
return 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: try:
image = tk.PhotoImage(data=base64.b64encode(image_data), format="png") image = tk.PhotoImage(data=base64.b64encode(image_data), format="png")
except tk.TclError: except tk.TclError:
@@ -1104,6 +1496,32 @@ def run_dashboard(paths: AppPaths) -> None:
target.delete("all") target.delete("all")
target.create_image(target_width // 2, target_height // 2, image=image) 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: def install_official_logo(value: object) -> None:
if not isinstance(value, bytes): if not isinstance(value, bytes):
return return
@@ -1122,7 +1540,6 @@ def run_dashboard(paths: AppPaths) -> None:
def load_official_logo() -> None: def load_official_logo() -> None:
def worker() -> None: def worker() -> None:
image = fetch_official_logo(paths.logo_cache) 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() 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) local_status = inspect_game_installation(paths)
refresh_primary_action(update_activity=update_activity) refresh_primary_action(update_activity=update_activity)
if not local_status.installed or game_import_requires_verification(paths): if not local_status.installed or game_import_requires_verification(paths):
mark_startup_component_ready(
"compatibility",
"Game state ready",
)
return return
def worker() -> None: def worker() -> None:
@@ -1215,21 +1636,18 @@ def run_dashboard(paths: AppPaths) -> None:
) -> None: ) -> None:
nonlocal launcher_update_check nonlocal launcher_update_check
launcher_update_check = check launcher_update_check = check
if check.available and check.supported: presentation = launcher_update_presentation(check)
launcher_update_button.configure( launcher_update_button.configure(
text=f"UPDATE TO {check.latest_version}", text=presentation.button_text,
state="normal", style=presentation.button_style,
)
elif check.available:
launcher_update_button.configure(
text="MANUAL UPDATE NEEDED",
state="normal", 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: else:
launcher_update_button.configure( launcher_update_badge.pack_forget()
text=f"LAUNCHER {__version__}",
state="normal",
)
if log_reason: if log_reason:
append_log(check.reason) append_log(check.reason)
@@ -1339,12 +1757,24 @@ def run_dashboard(paths: AppPaths) -> None:
root.iconify() root.iconify()
elif kind == "broadcast": elif kind == "broadcast":
show_broadcast(value if isinstance(value, Broadcast) else None) show_broadcast(value if isinstance(value, Broadcast) else None)
mark_startup_component_ready(
"broadcast",
"Broadcast ready",
)
elif kind == "news" and isinstance(value, tuple): elif kind == "news" and isinstance(value, tuple):
show_news(value) show_news(value)
mark_startup_component_ready(
"news",
"News cards ready",
)
elif kind == "news_image": elif kind == "news_image":
install_news_image(value) complete_news_image(value)
elif kind == "logo_image": elif kind == "logo_image":
install_official_logo(value) install_official_logo(value)
mark_startup_component_ready(
"logo",
"Launcher artwork ready",
)
elif ( elif (
kind == "compatibility" kind == "compatibility"
and isinstance(value, tuple) and isinstance(value, tuple)
@@ -1368,28 +1798,50 @@ def run_dashboard(paths: AppPaths) -> None:
f"{checked.reason or 'unknown release profile'}" f"{checked.reason or 'unknown release profile'}"
) )
refresh_primary_action(update_activity=update_activity) refresh_primary_action(update_activity=update_activity)
mark_startup_component_ready(
"compatibility",
"Game compatibility ready",
)
elif kind == "version" and isinstance(value, DepotSnapshot): elif kind == "version" and isinstance(value, DepotSnapshot):
version.set( version.set(
f"Launcher {__version__} · {value.version} · depot {value.build}" f"Launcher {__version__} · {value.version} · depot {value.build}"
) )
refresh_primary_action(value) refresh_primary_action(value)
mark_startup_component_ready(
"version",
"Game version ready",
)
elif kind == "version_error": elif kind == "version_error":
version.set( version.set(
f"Launcher {__version__} · game build check unavailable" f"Launcher {__version__} · game build check unavailable"
) )
refresh_primary_action() refresh_primary_action()
mark_startup_component_ready(
"version",
"Game version check complete",
)
elif kind == "launcher_update_check" and isinstance( elif kind == "launcher_update_check" and isinstance(
value, value,
LauncherUpdateCheck, LauncherUpdateCheck,
): ):
show_launcher_update_check(value) show_launcher_update_check(value)
mark_startup_component_ready(
"launcher_update",
"Launcher update status ready",
)
elif kind == "launcher_update_error" and isinstance(value, str): elif kind == "launcher_update_error" and isinstance(value, str):
if launcher_update_check is None: if launcher_update_check is None:
launcher_update_button.configure( launcher_update_button.configure(
text="CHECK LAUNCHER", text="CHECK LAUNCHER",
style="Secondary.TButton",
state="normal", state="normal",
) )
launcher_update_badge.pack_forget()
append_log(f"Launcher update check unavailable: {value}") append_log(f"Launcher update check unavailable: {value}")
mark_startup_component_ready(
"launcher_update",
"Launcher update check complete",
)
except queue.Empty: except queue.Empty:
pass pass
if operation_active: 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(): if speed_text.get() != "0 B/s" and transfer_rate.is_idle():
speed_text.set("0 B/s") speed_text.set("0 B/s")
update_elapsed_indicator() update_elapsed_indicator()
root.after( poll_delay = (
dashboard_poll_interval( ACTIVE_POLL_INTERVAL_MILLISECONDS
if not startup_transition_started
else dashboard_poll_interval(
operation_active=operation_active, operation_active=operation_active,
game_window_active=game_window_active, game_window_active=game_window_active,
pending_events=not events.empty(), pending_events=not events.empty(),
),
poll_events,
) )
)
root.after(poll_delay, poll_events)
def command_for(*arguments: str) -> list[str]: def command_for(*arguments: str) -> list[str]:
return [sys.executable, "-m", "ptlinux", *arguments] 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: def run_launcher_update(required_reason: str | None = None) -> None:
check = launcher_update_check check = launcher_update_check
if check is None: 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: if required_reason is not None:
status.set("Checking for a compatible launcher update…") status.set("Checking for a compatible launcher update…")
progress_text.set("Play remains disabled until this release is supported.") progress_text.set("Play remains disabled until this release is supported.")
@@ -1824,6 +2283,9 @@ def run_dashboard(paths: AppPaths) -> None:
news_content.bind("<Configure>", update_news_viewport) news_content.bind("<Configure>", update_news_viewport)
news_canvas.bind("<Configure>", resize_news_content) news_canvas.bind("<Configure>", 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_broadcast(read_cached_broadcast(paths.broadcast_cache))
show_news(read_cached_news(paths.news_cache)) show_news(read_cached_news(paths.news_cache))
cached_launcher_update = read_cached_launcher_update_check( cached_launcher_update = read_cached_launcher_update_check(
@@ -1835,7 +2297,14 @@ def run_dashboard(paths: AppPaths) -> None:
refresh_runtime_compatibility() refresh_runtime_compatibility()
poll_events() poll_events()
load_official_logo() 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() refresh_remote_content()
maybe_start_dashboard_transition()
try: try:
root.mainloop() root.mainloop()
except KeyboardInterrupt: except KeyboardInterrupt: