Added ui changes to modals, pre-loader and some fixes for other ui elements and performance improvements all over.

This commit is contained in:
2026-08-31 23:23:37 +02:00
parent 99982b12d9
commit dadae8f60a
3 changed files with 597 additions and 215 deletions

View File

@@ -20,6 +20,8 @@ OFFICIAL_LOGO_URL = "https://pristontale.eu/public/img/pristontale/logo/logo_sma
BROADCAST_ENDPOINT = "http://5.135.35.188:90/broadcasts" BROADCAST_ENDPOINT = "http://5.135.35.188:90/broadcasts"
MAX_NEWS_BYTES = 2 * 1024 * 1024 MAX_NEWS_BYTES = 2 * 1024 * 1024
MAX_NEWS_IMAGE_BYTES = 12 * 1024 * 1024 MAX_NEWS_IMAGE_BYTES = 12 * 1024 * 1024
MAX_BROADCAST_AUTHOR_CHARACTERS = 100
MAX_BROADCAST_CONTENT_CHARACTERS = 2000
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
@@ -241,26 +243,41 @@ def _parse_latest_broadcast(payload: bytes) -> Broadcast:
value = json.loads(payload.decode("utf-8-sig")) value = json.loads(payload.decode("utf-8-sig"))
if not isinstance(value, list) or not value or not isinstance(value[0], dict): if not isinstance(value, list) or not value or not isinstance(value[0], dict):
raise NewsError("broadcast response does not contain a latest broadcast") raise NewsError("broadcast response does not contain a latest broadcast")
latest = value[0] return _broadcast_from_mapping(value[0])
author = latest.get("author")
content = latest.get("content")
def _broadcast_from_mapping(latest: dict[object, object]) -> Broadcast:
author = _bounded_text(latest.get("author"), MAX_BROADCAST_AUTHOR_CHARACTERS)
content = _bounded_text(latest.get("content"), MAX_BROADCAST_CONTENT_CHARACTERS)
url = _broadcast_url(latest.get("url")) url = _broadcast_url(latest.get("url"))
if ( if (
not isinstance(author, str) author is None
or not author.strip() or content is None
or not isinstance(content, str)
or not content.strip()
or url is None or url is None
): ):
raise NewsError("latest broadcast is incomplete") raise NewsError("latest broadcast is incomplete")
published_at = (
_format_timestamp(latest.get("timestamp"))
if "timestamp" in latest
else _bounded_text(latest.get("published_at"), 64)
)
return Broadcast( return Broadcast(
author=author.strip(), author=author,
content=content.strip(), content=content,
published_at=_format_timestamp(latest.get("timestamp")), published_at=published_at,
url=url, url=url,
) )
def _bounded_text(value: object, maximum_characters: int) -> str | None:
if not isinstance(value, str):
return None
text = value.strip()
if not text or len(text) > maximum_characters:
return None
return text
def _format_timestamp(value: object) -> str | None: def _format_timestamp(value: object) -> str | None:
if isinstance(value, bool) or not isinstance(value, (int, float)): if isinstance(value, bool) or not isinstance(value, (int, float)):
return None return None
@@ -288,10 +305,16 @@ def _image_url(value: object) -> str | None:
def _broadcast_url(value: object) -> str | None: def _broadcast_url(value: object) -> str | None:
if not isinstance(value, str) or not value.strip(): if not isinstance(value, str) or not value.strip():
return None return None
parsed = urlparse(value) url = value.strip()
if parsed.scheme not in {"http", "https"} or not parsed.netloc: parsed = urlparse(url)
if (
parsed.scheme.casefold() != "https"
or not parsed.netloc
or parsed.username is not None
or parsed.password is not None
):
return None return None
return value.strip() return url
def _read_cache(path: Path) -> tuple[NewsArticle, ...]: def _read_cache(path: Path) -> tuple[NewsArticle, ...]:
@@ -320,8 +343,8 @@ def _write_cache(path: Path, articles: tuple[NewsArticle, ...]) -> None:
def _read_broadcast_cache(path: Path) -> Broadcast | None: def _read_broadcast_cache(path: Path) -> Broadcast | None:
try: try:
value = json.loads(path.read_text(encoding="utf-8")) value = json.loads(path.read_text(encoding="utf-8"))
return Broadcast(**value) if isinstance(value, dict) else None return _broadcast_from_mapping(value) if isinstance(value, dict) else None
except (OSError, UnicodeError, TypeError, json.JSONDecodeError): except (OSError, UnicodeError, NewsError, json.JSONDecodeError):
return None return None

View File

@@ -304,6 +304,7 @@ def create_window_chrome(
*, *,
title: str, title: str,
subtitle: str, subtitle: str,
window_icon: Any | None = None,
show_title_bar: bool = True, show_title_bar: bool = True,
show_close: bool = True, show_close: bool = True,
show_minimize: bool = False, show_minimize: bool = False,
@@ -347,7 +348,31 @@ def create_window_chrome(
title_bar.pack_propagate(False) title_bar.pack_propagate(False)
tk_accent = tk.Frame(title_bar, background="#d98927", width=3) tk_accent = tk.Frame(title_bar, background="#d98927", width=3)
tk_accent.pack(side="left", fill="y") tk_accent.pack(side="left", fill="y")
mark = tk.Canvas( title_icon: Any | None = None
if window_icon is not None:
try:
factor = icon_subsample_factor(
window_icon.width(),
window_icon.height(),
20,
)
title_icon = window_icon.subsample(factor, factor)
except (tk.TclError, ValueError):
title_icon = None
if title_icon is not None:
mark = tk.Label(
title_bar,
image=title_icon,
width=22,
height=22,
background="#17100d",
borderwidth=0,
highlightthickness=0,
cursor="fleur",
)
mark.image = title_icon
else:
mark = tk.Frame(
title_bar, title_bar,
width=22, width=22,
height=22, height=22,
@@ -356,8 +381,6 @@ def create_window_chrome(
highlightthickness=0, highlightthickness=0,
cursor="fleur", cursor="fleur",
) )
mark.create_oval(3, 3, 19, 19, outline="#d98927", width=1)
mark.create_text(11, 11, text="PT", fill="#f5d18b", font=("Georgia", 6, "bold"))
mark.pack(side="left", padx=(9, 7)) mark.pack(side="left", padx=(9, 7))
identity = tk.Frame(title_bar, background="#17100d", cursor="fleur") identity = tk.Frame(title_bar, background="#17100d", cursor="fleur")
identity.pack(side="left", fill="y") identity.pack(side="left", fill="y")
@@ -440,6 +463,7 @@ def create_dashboard_chrome(
*, *,
title: str, title: str,
subtitle: str, subtitle: str,
window_icon: Any | None = None,
) -> DashboardChrome: ) -> DashboardChrome:
"""Install the draggable frame and full controls used by the dashboard.""" """Install the draggable frame and full controls used by the dashboard."""
@@ -447,6 +471,7 @@ def create_dashboard_chrome(
root, root,
title=title, title=title,
subtitle=subtitle, subtitle=subtitle,
window_icon=window_icon,
show_close=True, show_close=True,
show_minimize=True, show_minimize=True,
allow_close=True, allow_close=True,

View File

@@ -9,12 +9,13 @@ import json
import math import math
import os import os
import queue import queue
import signal
import subprocess import subprocess
import sys import sys
import threading import threading
import time import time
import webbrowser import webbrowser
from typing import TYPE_CHECKING, Callable, Mapping from typing import TYPE_CHECKING, Callable, Iterator, Mapping
if TYPE_CHECKING: if TYPE_CHECKING:
from tkinter import Frame, PhotoImage, Toplevel from tkinter import Frame, PhotoImage, Toplevel
@@ -109,6 +110,10 @@ DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS = 10_000
DASHBOARD_TRANSITION_STEP_MILLISECONDS = 16 DASHBOARD_TRANSITION_STEP_MILLISECONDS = 16
DASHBOARD_TRANSITION_STEPS = 9 DASHBOARD_TRANSITION_STEPS = 9
DASHBOARD_TRANSITION_MINIMUM_OPACITY = 0.18 DASHBOARD_TRANSITION_MINIMUM_OPACITY = 0.18
STARTUP_PROGRESS_ANIMATION_INTERVAL_MILLISECONDS = 16
STARTUP_PROGRESS_ANIMATION_EASING = 0.28
STARTUP_PROGRESS_ANIMATION_MINIMUM_STEP = 0.08
PATCH_PRELOAD_INTERVAL_MILLISECONDS = 1
PRELOADER_STATUS_CHARACTERS = 64 PRELOADER_STATUS_CHARACTERS = 64
DASHBOARD_DEBUG_KEY = "<Control-Shift-F12>" DASHBOARD_DEBUG_KEY = "<Control-Shift-F12>"
SERVER_STATUS_STALE_SECONDS = 5 * 60 SERVER_STATUS_STALE_SECONDS = 5 * 60
@@ -176,6 +181,60 @@ class DashboardError(RuntimeError):
"""Raised when the native dashboard cannot be displayed.""" """Raised when the native dashboard cannot be displayed."""
class GameLaunchSupervisorRegistry:
"""Bridge Tk cancellation requests to game launch worker processes safely."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._processes: dict[int, subprocess.Popen[str]] = {}
self._cancel_requested: set[int] = set()
def attach(self, client_id: int, process: subprocess.Popen[str]) -> bool:
"""Register a supervisor and report whether cancellation raced its start."""
with self._lock:
self._processes[client_id] = process
return client_id in self._cancel_requested
def request_cancel(self, client_id: int) -> subprocess.Popen[str] | None:
"""Record cancellation and return an already-started supervisor, if any."""
with self._lock:
self._cancel_requested.add(client_id)
return self._processes.get(client_id)
def detach(self, client_id: int, process: subprocess.Popen[str]) -> None:
with self._lock:
if self._processes.get(client_id) is process:
self._processes.pop(client_id, None)
def is_cancel_requested(self, client_id: int) -> bool:
with self._lock:
return client_id in self._cancel_requested
def finish(self, client_id: int) -> bool:
"""Forget one launch and return whether the user cancelled it."""
with self._lock:
self._processes.pop(client_id, None)
if client_id not in self._cancel_requested:
return False
self._cancel_requested.remove(client_id)
return True
def interrupt_game_supervisor(process: subprocess.Popen[str]) -> bool:
"""Ask the play CLI to stop its complete Proton/game process group."""
if process.poll() is not None:
return False
try:
process.send_signal(signal.SIGINT)
except OSError:
return False
return True
class GameClientTracker: class GameClientTracker:
"""Track dashboard-launched clients without retaining process objects in Tk.""" """Track dashboard-launched clients without retaining process objects in Tk."""
@@ -527,6 +586,39 @@ def preloader_status_text(
return f"Step {step} of {total} · {value}" return f"Step {step} of {total} · {value}"
def preloader_logo_subsample_factor(
width: int,
height: int,
*,
max_dimension: int = 88,
) -> int:
"""Return an integer Tk subsample factor for a compact preloader logo."""
if width <= 0 or height <= 0 or max_dimension <= 0:
raise ValueError("preloader logo dimensions must be positive")
return max(
1,
math.ceil(width / max_dimension),
math.ceil(height / max_dimension),
)
def startup_progress_animation_value(current: float, target: float) -> float:
"""Ease one determinate startup-progress frame without overshooting."""
distance = abs(target - current)
if distance == 0:
return target
step = max(
STARTUP_PROGRESS_ANIMATION_MINIMUM_STEP,
distance * STARTUP_PROGRESS_ANIMATION_EASING,
)
if step >= distance:
return target
direction = 1.0 if target > current else -1.0
return current + (direction * step)
def freeze_preloader_geometry(frame: Frame) -> tuple[int, int]: def freeze_preloader_geometry(frame: Frame) -> tuple[int, int]:
"""Lock a measured preloader frame so later status text cannot reflow it.""" """Lock a measured preloader frame so later status text cannot reflow it."""
@@ -701,6 +793,7 @@ def run_dashboard(paths: AppPaths) -> None:
root, root,
title="PRISTONTALE EU", title="PRISTONTALE EU",
subtitle="NATIVE LINUX LAUNCHER", subtitle="NATIVE LINUX LAUNCHER",
window_icon=window_icon,
) )
dashboard_content = dashboard_chrome.content dashboard_content = dashboard_chrome.content
try: try:
@@ -712,6 +805,7 @@ def run_dashboard(paths: AppPaths) -> None:
title="Launcher settings", title="Launcher settings",
message=f"The launcher could not load its settings:\n\n{startup_error}", message=f"The launcher could not load its settings:\n\n{startup_error}",
kind="error", kind="error",
window_icon=window_icon,
) )
root.destroy() root.destroy()
return return
@@ -734,25 +828,6 @@ 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",
@@ -1014,6 +1089,29 @@ def run_dashboard(paths: AppPaths) -> None:
lightcolor="#4a3528", lightcolor="#4a3528",
darkcolor="#32251f", darkcolor="#32251f",
) )
style.map(
"News.Vertical.TScrollbar",
background=[
("pressed", "#765034"),
("active", "#62452f"),
("!disabled", "#4a3528"),
],
arrowcolor=[
("pressed", "#fff0c2"),
("active", "#f0c77f"),
("!disabled", "#d8aa61"),
],
lightcolor=[
("pressed", "#765034"),
("active", "#62452f"),
("!disabled", "#4a3528"),
],
darkcolor=[
("pressed", "#4b3021"),
("active", "#3d2a21"),
("!disabled", "#32251f"),
],
)
style.configure( style.configure(
"Play.TButton", "Play.TButton",
background="#d98927", background="#d98927",
@@ -1114,6 +1212,7 @@ def run_dashboard(paths: AppPaths) -> None:
startup_started_at = time.monotonic() startup_started_at = time.monotonic()
startup_status = tk.StringVar(value="Loading dashboard contents…") startup_status = tk.StringVar(value="Loading dashboard contents…")
startup_image_references: list[tk.PhotoImage] = []
startup_overlay = tk.Frame( startup_overlay = tk.Frame(
dashboard_content, dashboard_content,
background="#0c0908", background="#0c0908",
@@ -1123,62 +1222,117 @@ def run_dashboard(paths: AppPaths) -> None:
startup_overlay.place(x=0, y=0, relwidth=1, relheight=1) startup_overlay.place(x=0, y=0, relwidth=1, relheight=1)
startup_border = tk.Frame( startup_border = tk.Frame(
startup_overlay, startup_overlay,
background="#d98927", background="#6a482d",
borderwidth=0, borderwidth=0,
highlightthickness=0, highlightthickness=0,
) )
startup_border.place(relx=0.5, rely=0.5, anchor="center") startup_border.place(relx=0.5, rely=0.5, anchor="center")
startup_shell = ttk.Frame( startup_shell = ttk.Frame(
startup_border, startup_border,
style="Splash.TFrame", style="Root.TFrame",
padding=(26, 22), padding=(34, 26),
) )
startup_shell.pack(fill="both", expand=True, padx=1, pady=1) startup_shell.pack(fill="both", expand=True, padx=1, pady=1)
startup_logo_source = window_icon
if startup_logo_source is not None:
startup_logo_factor = preloader_logo_subsample_factor(
startup_logo_source.width(),
startup_logo_source.height(),
)
loaded_startup_logo = startup_logo_source.subsample(
startup_logo_factor,
startup_logo_factor,
)
startup_image_references.append(loaded_startup_logo)
tk.Label(
startup_shell,
image=loaded_startup_logo,
background="#0c0908",
borderwidth=0,
highlightthickness=0,
).pack(anchor="center", pady=(0, 16))
ttk.Label( ttk.Label(
startup_shell, startup_shell,
text="PRISTONTALE EU", text="Preparing the launcher…",
style="SplashBrand.TLabel", style="PreloaderStatus.TLabel",
).pack(anchor="w") anchor="center",
ttk.Label( ).pack(fill="x")
startup_shell,
text="NATIVE LINUX LAUNCHER",
style="SplashTagline.TLabel",
).pack(anchor="w", pady=(2, 17))
startup_status_label = ttk.Label( startup_status_label = ttk.Label(
startup_shell, startup_shell,
textvariable=startup_status, textvariable=startup_status,
style="SplashStatus.TLabel", style="PreloaderMuted.TLabel",
width=PRELOADER_STATUS_CHARACTERS, width=PRELOADER_STATUS_CHARACTERS,
anchor="w", anchor="center",
) )
startup_status_label.pack(fill="x", pady=(0, 8)) startup_status_label.pack(fill="x", pady=(5, 11))
startup_progress_value = tk.DoubleVar(value=0.0)
startup_progress = ttk.Progressbar( startup_progress = ttk.Progressbar(
startup_shell, startup_shell,
style="Depot.Horizontal.TProgressbar", style="Depot.Horizontal.TProgressbar",
mode="indeterminate", orient="horizontal",
mode="determinate",
maximum=len(DASHBOARD_STARTUP_COMPONENTS),
variable=startup_progress_value,
length=420, length=420,
takefocus=False,
) )
startup_progress.pack(fill="x") startup_progress.pack(fill="x")
freeze_preloader_geometry(startup_border) freeze_preloader_geometry(startup_border)
startup_progress.start(12)
startup_ready_components: set[str] = set() startup_ready_components: set[str] = set()
startup_transition_started = False startup_transition_started = False
startup_ready_after: str | None = None startup_ready_after: str | None = None
startup_timeout_after: str | None = None startup_timeout_after: str | None = None
startup_progress_after: str | None = None
startup_progress_target = 0.0
root.deiconify() root.deiconify()
root.lift() root.lift()
startup_overlay.lift() startup_overlay.lift()
root.update() root.update()
def cancel_startup_progress_animation() -> None:
nonlocal startup_progress_after
if startup_progress_after is None:
return
try:
root.after_cancel(startup_progress_after)
except tk.TclError:
pass
startup_progress_after = None
def animate_startup_progress() -> None:
nonlocal startup_progress_after
startup_progress_after = None
if startup_transition_started or not startup_overlay.winfo_exists():
return
current = float(startup_progress_value.get())
value = startup_progress_animation_value(current, startup_progress_target)
startup_progress_value.set(value)
if value != startup_progress_target:
startup_progress_after = root.after(
STARTUP_PROGRESS_ANIMATION_INTERVAL_MILLISECONDS,
animate_startup_progress,
)
def set_startup_progress_target(value: float, *, immediate: bool = False) -> None:
nonlocal startup_progress_after, startup_progress_target
startup_progress_target = min(
max(0.0, value),
float(len(DASHBOARD_STARTUP_COMPONENTS)),
)
if immediate:
cancel_startup_progress_animation()
startup_progress_value.set(startup_progress_target)
return
if startup_progress_after is None:
startup_progress_after = root.after(
STARTUP_PROGRESS_ANIMATION_INTERVAL_MILLISECONDS,
animate_startup_progress,
)
def update_startup_progress(message: str) -> None: def update_startup_progress(message: str) -> None:
if startup_transition_started: if startup_transition_started:
return return
startup_progress.stop() set_startup_progress_target(float(len(startup_ready_components)))
startup_progress.configure(
mode="determinate",
maximum=len(DASHBOARD_STARTUP_COMPONENTS),
value=len(startup_ready_components),
)
startup_status.set( startup_status.set(
f"{message} · {len(startup_ready_components)}/" f"{message} · {len(startup_ready_components)}/"
f"{len(DASHBOARD_STARTUP_COMPONENTS)}" f"{len(DASHBOARD_STARTUP_COMPONENTS)}"
@@ -1239,6 +1393,7 @@ def run_dashboard(paths: AppPaths) -> None:
def swap_loader_for_dashboard(*, fade_supported: bool) -> None: def swap_loader_for_dashboard(*, fade_supported: bool) -> None:
if startup_overlay.winfo_exists(): if startup_overlay.winfo_exists():
startup_overlay.destroy() startup_overlay.destroy()
startup_image_references.clear()
root.update_idletasks() root.update_idletasks()
if fade_supported: if fade_supported:
fade_dashboard_in() fade_dashboard_in()
@@ -1273,11 +1428,9 @@ def run_dashboard(paths: AppPaths) -> None:
version.set( version.set(
f"Launcher {__version__} · game build check continuing in background" f"Launcher {__version__} · game build check continuing in background"
) )
startup_progress.stop() set_startup_progress_target(
startup_progress.configure( float(len(DASHBOARD_STARTUP_COMPONENTS)),
mode="determinate", immediate=True,
maximum=len(DASHBOARD_STARTUP_COMPONENTS),
value=len(DASHBOARD_STARTUP_COMPONENTS),
) )
startup_status.set( startup_status.set(
"Opening dashboard with available content…" "Opening dashboard with available content…"
@@ -1326,14 +1479,23 @@ 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) dashboard_header_icon: PhotoImage | None = None
logo.create_text( if window_icon is not None:
21, try:
21, header_icon_factor = preloader_logo_subsample_factor(
text="PT", window_icon.width(),
fill="#f5d18b", window_icon.height(),
font=("Georgia", 9, "bold"), max_dimension=40,
) )
dashboard_header_icon = window_icon.subsample(
header_icon_factor,
header_icon_factor,
)
except tk.TclError:
dashboard_header_icon = None
if dashboard_header_icon is not None:
logo.create_image(21, 21, image=dashboard_header_icon)
logo.image = dashboard_header_icon
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)
@@ -1598,6 +1760,9 @@ def run_dashboard(paths: AppPaths) -> None:
active_news_tab = "news" active_news_tab = "news"
rendered_news_articles: tuple[NewsArticle, ...] | None = None rendered_news_articles: tuple[NewsArticle, ...] | None = None
rendered_patch_notes: tuple[PatchNote, ...] | None = None rendered_patch_notes: tuple[PatchNote, ...] | None = None
patch_render_generation = 0
patch_render_target: tuple[PatchNote, ...] | None = None
patch_render_callbacks: list[Callable[[], None]] = []
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
@@ -1607,6 +1772,7 @@ def run_dashboard(paths: AppPaths) -> None:
dashboard_debug_window: tk.Toplevel | None = None dashboard_debug_window: tk.Toplevel | None = None
dashboard_debug_output: tk.Text | None = None dashboard_debug_output: tk.Text | None = None
game_clients = GameClientTracker() game_clients = GameClientTracker()
game_launch_supervisors = GameLaunchSupervisorRegistry()
client_runtime_debug: dict[int, RuntimeDebugInfo] = {} client_runtime_debug: dict[int, RuntimeDebugInfo] = {}
client_launch_settings: dict[int, Settings] = {} client_launch_settings: dict[int, Settings] = {}
active_game_launch_id: int | None = None active_game_launch_id: int | None = None
@@ -1774,6 +1940,25 @@ def run_dashboard(paths: AppPaths) -> None:
progress_text.set(activity_text()) progress_text.set(activity_text())
set_preloader_status(operation_message, operation_stage, operation_stage_total) set_preloader_status(operation_message, operation_stage, operation_stage_total)
def cancel_active_game_launch() -> None:
client_id = active_game_launch_id
if client_id is None:
close_game_preloader()
return
process = game_launch_supervisors.request_cancel(client_id)
interrupted = process is not None and interrupt_game_supervisor(process)
if interrupted:
append_log(f"Cancelling game client {client_id}; stopping its process group.")
elif process is None:
append_log(f"Cancelling game client {client_id} before its supervisor starts.")
else:
append_log(f"Game client {client_id} was already stopping.")
close_game_preloader()
root.deiconify()
root.lift()
status.set(f"Cancelling game client {client_id}…")
progress_text.set("Stopping the initiated game and compatibility processes…")
def show_game_preloader() -> None: def show_game_preloader() -> None:
nonlocal preloader, preloader_status, preloader_progress nonlocal preloader, preloader_status, preloader_progress
if preloader is not None and preloader.winfo_exists(): if preloader is not None and preloader.winfo_exists():
@@ -1784,7 +1969,7 @@ def run_dashboard(paths: AppPaths) -> None:
window.title("Launching PristonTale EU") window.title("Launching PristonTale EU")
window.configure(background="#0c0908") window.configure(background="#0c0908")
window.resizable(False, False) window.resizable(False, False)
window.protocol("WM_DELETE_WINDOW", lambda: None) window.protocol("WM_DELETE_WINDOW", cancel_active_game_launch)
window.transient(root) window.transient(root)
apply_window_icon(window) apply_window_icon(window)
loader_chrome = create_window_chrome( loader_chrome = create_window_chrome(
@@ -1794,8 +1979,26 @@ def run_dashboard(paths: AppPaths) -> None:
show_title_bar=False, show_title_bar=False,
show_close=False, show_close=False,
show_minimize=False, show_minimize=False,
allow_close=False, allow_close=True,
) )
loader_chrome.set_close_command(cancel_active_game_launch)
cancel_button = tk.Button(
loader_chrome.content,
text="×",
command=cancel_active_game_launch,
background="#0c0908",
foreground="#bda998",
activebackground="#a63f2f",
activeforeground="#ffffff",
font=("Sans", 12),
width=3,
borderwidth=0,
highlightthickness=0,
relief="flat",
cursor="hand2",
takefocus=True,
)
cancel_button.place(relx=1.0, x=-9, y=9, anchor="ne")
status_variable = tk.StringVar(value="Starting launcher process…") status_variable = tk.StringVar(value="Starting launcher process…")
preloader_status = status_variable preloader_status = status_variable
content = ttk.Frame( content = ttk.Frame(
@@ -1804,14 +2007,17 @@ def run_dashboard(paths: AppPaths) -> None:
padding=(34, 26), padding=(34, 26),
) )
content.pack(fill="both", expand=True) content.pack(fill="both", expand=True)
cancel_button.lift()
logo_source = window_icon logo_source = window_icon
if logo_source is not None: if logo_source is not None:
factor = max( modal_logo_factor = preloader_logo_subsample_factor(
1, logo_source.width(),
math.ceil(logo_source.width() / 88), logo_source.height(),
math.ceil(logo_source.height() / 88), )
modal_logo = logo_source.subsample(
modal_logo_factor,
modal_logo_factor,
) )
modal_logo = logo_source.subsample(factor, factor)
preloader_image_references.clear() preloader_image_references.clear()
preloader_image_references.append(modal_logo) preloader_image_references.append(modal_logo)
tk.Label( tk.Label(
@@ -1936,6 +2142,13 @@ def run_dashboard(paths: AppPaths) -> None:
else: else:
events.put(("output", f"[Client {game_client_id}] {line}")) events.put(("output", f"[Client {game_client_id}] {line}"))
if (
game_client_id is not None
and game_launch_supervisors.is_cancel_requested(game_client_id)
):
publish_output("Launch cancelled before the game supervisor started.\n")
events.put(("game_done", (game_client_id, 130)))
return
try: try:
process = subprocess.Popen( process = subprocess.Popen(
command, command,
@@ -1954,9 +2167,16 @@ def run_dashboard(paths: AppPaths) -> None:
events.put(("game_done", (game_client_id, 1))) events.put(("game_done", (game_client_id, 1)))
return return
if game_client_id is not None: if game_client_id is not None:
cancel_on_start = game_launch_supervisors.attach(
game_client_id,
process,
)
events.put( events.put(
("game_supervisor_started", (game_client_id, process.pid)) ("game_supervisor_started", (game_client_id, process.pid))
) )
if cancel_on_start:
publish_output("Stopping the game supervisor after launch cancellation.\n")
interrupt_game_supervisor(process)
if process.stdout is not None: if process.stdout is not None:
for line in process.stdout: for line in process.stdout:
if line.startswith("STATUS "): if line.startswith("STATUS "):
@@ -2009,6 +2229,8 @@ def run_dashboard(paths: AppPaths) -> None:
): ):
events.put(("game_window_ready", game_client_id)) events.put(("game_window_ready", game_client_id))
returncode = process.wait() returncode = process.wait()
if game_client_id is not None:
game_launch_supervisors.detach(game_client_id, process)
if game_client_id is None: if game_client_id is None:
events.put( events.put(
("launcher_update_done" if restart_on_success else "done", returncode) ("launcher_update_done" if restart_on_success else "done", returncode)
@@ -2211,25 +2433,7 @@ def run_dashboard(paths: AppPaths) -> None:
def show_news(articles: tuple[NewsArticle, ...]) -> None: def show_news(articles: tuple[NewsArticle, ...]) -> None:
render_news(articles) render_news(articles)
def render_patch_notes(patches: tuple[PatchNote, ...]) -> None: def append_patch_note(patch: PatchNote) -> Iterator[None]:
nonlocal rendered_patch_notes
visible_patches = patches[:MAX_PATCH_NOTES]
if rendered_patch_notes == visible_patches:
return
for child in patches_content.winfo_children():
child.destroy()
if not visible_patches:
ttk.Label(
patches_content,
text=(
"Official patch notes are temporarily unavailable. "
"Your cached game can still play."
),
style="Muted.TLabel",
).pack(anchor="w", padx=8, pady=8)
rendered_patch_notes = visible_patches
return
for patch in visible_patches:
card = tk.Frame( card = tk.Frame(
patches_content, patches_content,
background="#211916", background="#211916",
@@ -2296,6 +2500,7 @@ def run_dashboard(paths: AppPaths) -> None:
foreground="#ded1c4", foreground="#ded1c4",
font=("Sans", 9), font=("Sans", 9),
).pack(anchor="w", pady=(8, 0)) ).pack(anchor="w", pady=(8, 0))
yield
for section in patch.sections: for section in patch.sections:
tk.Label( tk.Label(
card, card,
@@ -2304,6 +2509,7 @@ def run_dashboard(paths: AppPaths) -> None:
foreground="#d8aa61", foreground="#d8aa61",
font=("Sans", 9, "bold"), font=("Sans", 9, "bold"),
).pack(anchor="w", pady=(12, 5)) ).pack(anchor="w", pady=(12, 5))
yield
for change in section.items: for change in section.items:
change_row = tk.Frame(card, background="#211916") change_row = tk.Frame(card, background="#211916")
change_row.pack(anchor="w", fill="x", pady=3) change_row.pack(anchor="w", fill="x", pady=3)
@@ -2331,11 +2537,99 @@ def run_dashboard(paths: AppPaths) -> None:
justify="left", justify="left",
anchor="w", anchor="w",
).pack(side="left", anchor="w", fill="x", expand=True) ).pack(side="left", anchor="w", fill="x", expand=True)
yield
make_news_card_clickable(card, patch.url, border_only=True) make_news_card_clickable(card, patch.url, border_only=True)
rendered_patch_notes = visible_patches
def show_patch_notes(patches: tuple[PatchNote, ...]) -> None: def render_patch_notes(
render_patch_notes(patches) patches: tuple[PatchNote, ...],
*,
on_complete: Callable[[], None] | None = None,
) -> None:
"""Preload patch cards incrementally without blocking Tk's event loop."""
nonlocal patch_render_generation, patch_render_target, rendered_patch_notes
visible_patches = patches[:MAX_PATCH_NOTES]
if rendered_patch_notes == visible_patches:
if on_complete is not None:
on_complete()
return
if patch_render_target == visible_patches:
if on_complete is not None:
patch_render_callbacks.append(on_complete)
return
patch_render_generation += 1
generation = patch_render_generation
patch_render_target = visible_patches
if on_complete is not None:
patch_render_callbacks.append(on_complete)
rendered_patch_notes = None
for child in patches_content.winfo_children():
child.destroy()
patches_content.grid()
if active_news_tab == "patches":
patches_content.tkraise()
else:
news_content.tkraise()
def finish_render() -> None:
nonlocal patch_render_target, rendered_patch_notes
if generation != patch_render_generation:
return
rendered_patch_notes = visible_patches
patch_render_target = None
if active_news_tab == "patches":
patches_content.grid()
patches_content.tkraise()
else:
patches_content.grid_remove()
news_content.tkraise()
news_canvas.configure(scrollregion=news_canvas.bbox("all"))
callbacks = tuple(patch_render_callbacks)
patch_render_callbacks.clear()
for callback in callbacks:
callback()
if not visible_patches:
ttk.Label(
patches_content,
text=(
"Official patch notes are temporarily unavailable. "
"Your cached game can still play."
),
style="Muted.TLabel",
).pack(anchor="w", padx=8, pady=8)
root.after(PATCH_PRELOAD_INTERVAL_MILLISECONDS, finish_render)
return
def patch_render_steps() -> Iterator[None]:
for patch in visible_patches:
yield from append_patch_note(patch)
render_steps = patch_render_steps()
def append_next() -> None:
if generation != patch_render_generation:
return
try:
next(render_steps)
except StopIteration:
finish_render()
return
patches_content.update_idletasks()
root.after(
PATCH_PRELOAD_INTERVAL_MILLISECONDS,
append_next,
)
root.after(PATCH_PRELOAD_INTERVAL_MILLISECONDS, append_next)
def show_patch_notes(
patches: tuple[PatchNote, ...],
*,
on_complete: Callable[[], None] | None = None,
) -> None:
render_patch_notes(patches, on_complete=on_complete)
def switch_news_tab(tab: str) -> None: def switch_news_tab(tab: str) -> None:
nonlocal active_news_tab nonlocal active_news_tab
@@ -2910,11 +3204,13 @@ def run_dashboard(paths: AppPaths) -> None:
def refresh_primary_action( def refresh_primary_action(
snapshot: DepotSnapshot | None = None, snapshot: DepotSnapshot | None = None,
*, *,
installation: GameInstallationStatus | None = None,
update_activity: bool = True, update_activity: bool = True,
) -> GameInstallationStatus: ) -> GameInstallationStatus:
nonlocal official_snapshot, latest_installation_status nonlocal official_snapshot, latest_installation_status
if snapshot is not None: if snapshot is not None:
official_snapshot = snapshot official_snapshot = snapshot
if installation is None:
installation = inspect_game_installation( installation = inspect_game_installation(
paths, paths,
official_snapshot.build if official_snapshot is not None else None, official_snapshot.build if official_snapshot is not None else None,
@@ -2979,8 +3275,15 @@ def run_dashboard(paths: AppPaths) -> None:
compatibility_check_generation += 1 compatibility_check_generation += 1
check_generation = compatibility_check_generation check_generation = compatibility_check_generation
runtime_compatibility = None runtime_compatibility = None
local_status = inspect_game_installation(paths) local_status = inspect_game_installation(
refresh_primary_action(update_activity=update_activity) paths,
official_snapshot.build if official_snapshot is not None else None,
compatibility_ready=None,
)
refresh_primary_action(
installation=local_status,
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( mark_startup_component_ready(
"compatibility", "compatibility",
@@ -3091,6 +3394,7 @@ def run_dashboard(paths: AppPaths) -> None:
*, *,
ready: bool, ready: bool,
returncode: int | None = None, returncode: int | None = None,
cancelled: bool = False,
) -> None: ) -> None:
nonlocal active_game_launch_id nonlocal active_game_launch_id
nonlocal operation_active, game_window_active nonlocal operation_active, game_window_active
@@ -3113,6 +3417,10 @@ def run_dashboard(paths: AppPaths) -> None:
if ready: if ready:
status.set("Ready for another account.") status.set("Ready for another account.")
progress_text.set("Click Play to open another game window.") progress_text.set("Click Play to open another game window.")
elif cancelled:
root.lift()
status.set("Game launch cancelled.")
progress_text.set("The initiated game and compatibility processes were stopped.")
else: else:
root.lift() root.lift()
status.set( status.set(
@@ -3181,6 +3489,7 @@ def run_dashboard(paths: AppPaths) -> None:
f"restart: {restart_error}" f"restart: {restart_error}"
), ),
kind="error", kind="error",
window_icon=window_icon,
) )
else: else:
root.destroy() root.destroy()
@@ -3206,13 +3515,18 @@ def run_dashboard(paths: AppPaths) -> None:
and isinstance(value[0], int) and isinstance(value[0], int)
and isinstance(value[1], Mapping) and isinstance(value[1], Mapping)
and value[0] == active_game_launch_id and value[0] == active_game_launch_id
and not game_launch_supervisors.is_cancel_requested(value[0])
): ):
step = _as_int(value[1].get("step")) step = _as_int(value[1].get("step"))
total = _as_int(value[1].get("total")) total = _as_int(value[1].get("total"))
message = value[1].get("message") message = value[1].get("message")
if step > 0 and total > 0 and isinstance(message, str) and message: if step > 0 and total > 0 and isinstance(message, str) and message:
set_operation_stage(step, total, message) set_operation_stage(step, total, message)
elif kind == "game_started" and isinstance(value, int): elif (
kind == "game_started"
and isinstance(value, int)
and not game_launch_supervisors.is_cancel_requested(value)
):
launch_settings = client_launch_settings.get(value) launch_settings = client_launch_settings.get(value)
if launch_settings is not None: if launch_settings is not None:
client_runtime_debug[value] = inspect_runtime_debug( client_runtime_debug[value] = inspect_runtime_debug(
@@ -3221,7 +3535,11 @@ def run_dashboard(paths: AppPaths) -> None:
) )
progress_bar.stop() progress_bar.stop()
status.set(f"Game client {value} started; preparing its window…") status.set(f"Game client {value} started; preparing its window…")
elif kind == "game_window_ready" and isinstance(value, int): elif (
kind == "game_window_ready"
and isinstance(value, int)
and not game_launch_supervisors.is_cancel_requested(value)
):
if game_clients.mark_running(value): if game_clients.mark_running(value):
game_window_active = True game_window_active = True
release_game_launch_controls(value, ready=True) release_game_launch_controls(value, ready=True)
@@ -3233,6 +3551,7 @@ def run_dashboard(paths: AppPaths) -> None:
and isinstance(value[1], int) and isinstance(value[1], int)
): ):
client_id, returncode = value client_id, returncode = value
cancelled = game_launch_supervisors.finish(client_id)
phase = game_clients.finish(client_id) phase = game_clients.finish(client_id)
client_runtime_debug.pop(client_id, None) client_runtime_debug.pop(client_id, None)
client_launch_settings.pop(client_id, None) client_launch_settings.pop(client_id, None)
@@ -3243,6 +3562,7 @@ def run_dashboard(paths: AppPaths) -> None:
client_id, client_id,
ready=False, ready=False,
returncode=returncode, returncode=returncode,
cancelled=cancelled,
) )
elif phase == "running" and not operation_active: elif phase == "running" and not operation_active:
set_busy(False) set_busy(False)
@@ -3290,10 +3610,12 @@ def run_dashboard(paths: AppPaths) -> None:
and isinstance(value, tuple) and isinstance(value, tuple)
and all(isinstance(item, PatchNote) for item in value) and all(isinstance(item, PatchNote) for item in value)
): ):
show_patch_notes(value) show_patch_notes(
mark_startup_component_ready( value,
on_complete=lambda: mark_startup_component_ready(
"patches", "patches",
"Patch notes ready", "Patch notes ready",
),
) )
elif kind == "news_image": elif kind == "news_image":
complete_news_image(value) complete_news_image(value)
@@ -3377,6 +3699,11 @@ def run_dashboard(paths: AppPaths) -> None:
) )
except queue.Empty: except queue.Empty:
pass pass
except Exception as event_error:
append_log(
"Dashboard event processing recovered from an error: "
f"{event_error}"
)
if operation_active: if operation_active:
latest_progress = progress_updates.take() latest_progress = progress_updates.take()
if latest_progress is not None: if latest_progress is not None:
@@ -3421,6 +3748,7 @@ def run_dashboard(paths: AppPaths) -> None:
title="Launcher is up to date", title="Launcher is up to date",
message=check.reason, message=check.reason,
kind="success", kind="success",
window_icon=window_icon,
) )
else: else:
show_themed_dialog( show_themed_dialog(
@@ -3431,6 +3759,7 @@ def run_dashboard(paths: AppPaths) -> None:
f"{required_reason}\n\n{check.reason}" f"{required_reason}\n\n{check.reason}"
), ),
kind="warning", kind="warning",
window_icon=window_icon,
) )
return return
if not check.supported: if not check.supported:
@@ -3439,6 +3768,7 @@ def run_dashboard(paths: AppPaths) -> None:
title="Manual launcher update required", title="Manual launcher update required",
message=check.reason, message=check.reason,
kind="warning", kind="warning",
window_icon=window_icon,
) )
return return
if not show_themed_dialog( if not show_themed_dialog(
@@ -3450,6 +3780,7 @@ def run_dashboard(paths: AppPaths) -> None:
kind="question", kind="question",
confirm=True, confirm=True,
accept_text="INSTALL & RESTART", accept_text="INSTALL & RESTART",
window_icon=window_icon,
): ):
return return
run_operation( run_operation(
@@ -3477,6 +3808,7 @@ def run_dashboard(paths: AppPaths) -> None:
f"{cache_error}" f"{cache_error}"
), ),
kind="error", kind="error",
window_icon=window_icon,
) )
return return
launch_settings: Settings | None = None launch_settings: Settings | None = None
@@ -3561,6 +3893,7 @@ def run_dashboard(paths: AppPaths) -> None:
title="Settings", title="Settings",
message=f"The launcher could not load its settings:\n\n{error}", message=f"The launcher could not load its settings:\n\n{error}",
kind="error", kind="error",
window_icon=window_icon,
) )
return return
dialog = tk.Toplevel(root) dialog = tk.Toplevel(root)
@@ -3576,6 +3909,7 @@ def run_dashboard(paths: AppPaths) -> None:
dialog, dialog,
title="PRISTONTALE EU", title="PRISTONTALE EU",
subtitle="LAUNCHER SETTINGS", subtitle="LAUNCHER SETTINGS",
window_icon=window_icon,
show_close=True, show_close=True,
show_minimize=False, show_minimize=False,
allow_close=True, allow_close=True,
@@ -3732,6 +4066,7 @@ def run_dashboard(paths: AppPaths) -> None:
title="Settings", title="Settings",
message=str(error), message=str(error),
kind="error", kind="error",
window_icon=window_icon,
) )
return return
append_log( append_log(
@@ -3944,7 +4279,6 @@ def run_dashboard(paths: AppPaths) -> None:
) )
if cached_launcher_update is not None: if cached_launcher_update is not None:
show_launcher_update_check(cached_launcher_update, log_reason=False) show_launcher_update_check(cached_launcher_update, log_reason=False)
refresh_primary_action()
refresh_runtime_compatibility() refresh_runtime_compatibility()
poll_events() poll_events()
load_official_logo() load_official_logo()