"""Native PristonTale EU dashboard; Wine never hosts the updater UI.""" from __future__ import annotations import base64 from collections import deque from dataclasses import dataclass import json import math import os import queue import signal import subprocess import sys import threading import time from typing import TYPE_CHECKING, Callable, Iterator, Mapping if TYPE_CHECKING: from tkinter import Frame, PhotoImage, Toplevel from . import __version__ from .changelog import ( MAX_PATCHES as MAX_PATCH_NOTES, PatchNote, fetch_patch_notes, patch_change_tag, read_cached_patch_notes, ) from .depot import ( DEFAULT_DEPOT_START_OPTION, DepotClient, DepotError, DepotSnapshot, DepotStartOption, cache_depot_launch_catalog, launch_catalog_from_snapshot, select_depot_start_option, ) from .desktop_identity import ( APPLICATION_NAME, APPLICATION_WM_CLASS, find_application_icon, ) from .dashboard_debug import ( RuntimeDebugInfo, VerificationDebugInfo, inspect_runtime_debug, read_verification_debug, ) from .install_status import ( GameInstallationStatus, PrimaryAction, inspect_game_installation, ) from .import_state import game_import_requires_verification from .news import ( Broadcast, NewsArticle, fetch_latest_broadcast, fetch_news, fetch_news_image, fetch_official_logo, read_cached_broadcast, read_cached_news, read_cached_news_image, read_cached_official_logo, ) from .overlay import RuntimeCompatibility, inspect_runtime_compatibility from .paths import AppPaths from .settings import Settings, ensure_settings, load_settings, update_settings from .self_update import ( LauncherUpdateCheck, LauncherUpdateHistory, cache_launcher_update_check, check_for_launcher_update, inspect_launcher_update_history, read_cached_launcher_update_check, ) from .server_status import ( RealmServerStatus, ServerStatusError, fetch_server_status, ) from .tk_chrome import ( centered_window_geometry, create_dashboard_chrome, create_window_chrome, load_square_window_icon, show_themed_dialog, ) MAX_NEWS_CARDS = 3 WINDOW_WIDTH = 1002 WINDOW_HEIGHT = 784 BASE_DISPLAY_DPI = 96.0 BASE_TK_SCALING = BASE_DISPLAY_DPI / 72.0 UI_SCALE_FACTORS: dict[str, float] = { "100": 1.0, "125": 1.25, "150": 1.5, "175": 1.75, "200": 2.0, } UI_SCALE_LABELS: dict[str, str] = { "Automatic (system DPI)": "auto", "100% — Standard": "100", "125% — Large": "125", "150% — Larger": "150", "175% — Extra large": "175", "200% — Maximum": "200", } RUNTIME_LABELS: dict[str, str] = { "Automatic (recommended)": "auto", "Proton 7 — Steam Runtime container": "proton7-container", "Proton 7 — Direct": "proton7", "UMU Launcher": "umu", } PROTON_SYNC_LABELS: dict[str, str] = { "Automatic (recommended)": "auto", "Fsync": "fsync", "Esync": "esync", } DOWNLOAD_SPEED_PRESETS_MBPS: dict[str, float | None] = { "Unlimited": None, "5 MB/s": 5.0, "10 MB/s": 10.0, "25 MB/s": 25.0, "50 MB/s": 50.0, } DOWNLOAD_SPEED_CUSTOM_LABEL = "Custom…" NEWS_THREE_COLUMN_WIDTH = 900 NEWS_TWO_COLUMN_WIDTH = 620 STACKED_CONTROLS_WIDTH = 860 NEWS_CARD_HEIGHT = 242 NEWS_IMAGE_WIDTH = 292 NEWS_IMAGE_HEIGHT = 118 PATCH_CARD_WRAP_LENGTH = 900 PATCH_CHANGE_WRAP_LENGTH = 790 LAUNCH_STAGE_TOTAL = 4 EVENT_BATCH_SIZE = 48 POLL_INTERVAL_MILLISECONDS = 33 ACTIVE_POLL_INTERVAL_MILLISECONDS = 75 IDLE_POLL_INTERVAL_MILLISECONDS = 500 MAX_ACTIVITY_LOG_LINES = 5000 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 = 300 DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS = 10_000 DASHBOARD_TRANSITION_STEP_MILLISECONDS = 16 DASHBOARD_TRANSITION_STEPS = 9 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 DASHBOARD_DEBUG_KEY = "" SERVER_STATUS_STALE_SECONDS = 5 * 60 DASHBOARD_STARTUP_COMPONENTS = frozenset( { "broadcast", "cached_content", "compatibility", "launcher_update", "logo", "news", "news_artwork", "patches", "server_status", "version", } ) DASHBOARD_STARTUP_REQUIRED_COMPONENTS = frozenset({"cached_content"}) @dataclass(frozen=True) class LauncherUpdatePresentation: """Visual treatment for one authenticated launcher-update result.""" button_text: str button_style: str badge_text: str | None @dataclass(frozen=True) class GameSyncPresentation: """User-facing wording for one managed-game synchronization.""" operation_label: str file_progress_verb: str @dataclass(frozen=True) class ServerStatusPresentation: """Text and Tk style for one official realm-health result.""" text: str style: str @dataclass(frozen=True) class ServerStatusCheck: """One measured attempt to retrieve official realm health.""" statuses: tuple[RealmServerStatus, ...] | None checked_at: float duration_seconds: float error: str | None = None @dataclass(frozen=True) class GameClientDebugState: """Live process state exposed only in the hidden dashboard debug view.""" client_id: int phase: str supervisor_pid: int | None elapsed_seconds: float class DashboardError(RuntimeError): """Raised when the native dashboard cannot be displayed.""" def launch_external_url(url: str) -> None: """Open one web URL without leaking desktop-toolkit diagnostics to stderr.""" if not url.casefold().startswith("https://"): raise DashboardError("only HTTPS links can be opened") try: process = subprocess.Popen( ["xdg-open", url], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) except OSError as error: raise DashboardError(f"could not start the desktop web browser: {error}") from error threading.Thread( target=process.wait, name="ptlinux-desktop-url-opener", daemon=True, ).start() def resolve_ui_scale(preference: str, pixels_per_inch: float) -> float: """Resolve a saved launcher scale while respecting the detected display DPI.""" if preference == "auto": if not math.isfinite(pixels_per_inch) or pixels_per_inch <= 0: return 1.0 return min(2.0, max(1.0, pixels_per_inch / BASE_DISPLAY_DPI)) try: return UI_SCALE_FACTORS[preference] except KeyError as error: raise ValueError(f"unsupported launcher UI scale: {preference}") from error def fitted_window_size( width: int, height: int, *, scale: float, screen_width: int, screen_height: int, margin: int = 64, ) -> tuple[int, int]: """Scale an initial window without placing it beyond the available display.""" if width <= 0 or height <= 0 or scale <= 0: raise ValueError("window dimensions and scale must be positive") if screen_width <= 0 or screen_height <= 0 or margin < 0: raise ValueError("screen dimensions must be positive and margin non-negative") available_width = max(1, screen_width - margin) available_height = max(1, screen_height - margin) return ( min(max(1, round(width * scale)), available_width), min(max(1, round(height * scale)), available_height), ) def news_card_columns(width: int, scale: float) -> int: """Choose a readable card grid for the current physical dashboard width.""" if width <= 0 or scale <= 0: return 1 logical_width = width / scale if logical_width >= NEWS_THREE_COLUMN_WIDTH: return 3 if logical_width >= NEWS_TWO_COLUMN_WIDTH: return 2 return 1 def dashboard_controls_stacked(width: int, scale: float) -> bool: return width <= 0 or scale <= 0 or width / scale < STACKED_CONTROLS_WIDTH def setting_label(value: str, choices: Mapping[str, str]) -> str: for label, choice in choices.items(): if choice == value: return label raise ValueError(f"unsupported saved setting value: {value}") def setting_value(label: str, choices: Mapping[str, str]) -> str: try: return choices[label] except KeyError as error: raise ValueError(f"unsupported setting choice: {label}") from error def download_speed_setting_label(limit_mbps: float | None) -> str: for label, preset in DOWNLOAD_SPEED_PRESETS_MBPS.items(): if preset == limit_mbps: return label return DOWNLOAD_SPEED_CUSTOM_LABEL def download_speed_setting_value(label: str, custom_value: str) -> float | None: if label in DOWNLOAD_SPEED_PRESETS_MBPS: return DOWNLOAD_SPEED_PRESETS_MBPS[label] if label != DOWNLOAD_SPEED_CUSTOM_LABEL: raise ValueError(f"unsupported download speed choice: {label}") try: value = float(custom_value.strip()) except ValueError as error: raise ValueError("custom download speed must be a number in MB/s") from error if not math.isfinite(value) or value <= 0 or value > 10_000: raise ValueError( "custom download speed must be greater than 0 and at most 10000 MB/s" ) return value def download_activity_metadata( *, current_server: str | None, retry_count: int, retry_reason: str | None, active_workers: int, target_workers: int, ) -> str: """Render compact download details for the existing activity heading.""" if retry_count < 0 or active_workers < 0 or target_workers < 0: raise ValueError("download activity counts must not be negative") parts: list[str] = [] if current_server: parts.append(f"CDN: {_ellipsize(current_server, 28)}") if target_workers: parts.append(f"Workers: {active_workers}/{target_workers}") retry_text = f"Retries: {retry_count}" if retry_reason: retry_text += f" ({_ellipsize(retry_reason, 26, keep_tail=True)})" parts.append(retry_text) return " · ".join(parts) def _ellipsize(value: str, limit: int, *, keep_tail: bool = False) -> str: normalized = " ".join(value.split()) if len(normalized) <= limit: return normalized if keep_tail: return f"…{normalized[-(limit - 1):]}" return f"{normalized[:limit - 1]}…" def call_optional(callback: Callable[..., None] | None, *arguments: object) -> None: if callback is not None: callback(*arguments) 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: """Track dashboard-launched clients without retaining process objects in Tk.""" def __init__(self, *, clock: Callable[[], float] = time.monotonic) -> None: self._clock = clock self._next_client_id = 1 self._starting: set[int] = set() self._running: set[int] = set() self._started_at: dict[int, float] = {} self._running_at: dict[int, float] = {} self._supervisor_pids: dict[int, int] = {} def begin(self) -> int: client_id = self._next_client_id self._next_client_id += 1 self._starting.add(client_id) self._started_at[client_id] = self._clock() return client_id def attach_supervisor(self, client_id: int, pid: int) -> bool: if pid <= 0 or ( client_id not in self._starting and client_id not in self._running ): return False self._supervisor_pids[client_id] = pid return True def mark_running(self, client_id: int) -> bool: if client_id in self._running or client_id not in self._starting: return False self._starting.remove(client_id) self._running.add(client_id) self._running_at[client_id] = self._clock() return True def finish(self, client_id: int) -> str | None: if client_id in self._starting: self._starting.remove(client_id) phase = "starting" elif client_id in self._running: self._running.remove(client_id) phase = "running" else: return None self._started_at.pop(client_id, None) self._running_at.pop(client_id, None) self._supervisor_pids.pop(client_id, None) return phase def debug_states( self, *, timestamp: float | None = None, ) -> tuple[GameClientDebugState, ...]: now = self._clock() if timestamp is None else timestamp return tuple( GameClientDebugState( client_id=client_id, phase="running" if client_id in self._running else "starting", supervisor_pid=self._supervisor_pids.get(client_id), elapsed_seconds=max( 0.0, now - ( self._running_at.get(client_id, now) if client_id in self._running else self._started_at.get(client_id, now) ), ), ) for client_id in sorted(self._starting | self._running) ) @property def starting_count(self) -> int: return len(self._starting) @property def running_count(self) -> int: return len(self._running) @property def active_count(self) -> int: return self.starting_count + self.running_count def game_client_activity_text(running_count: int) -> str: if running_count < 0: raise ValueError("running client count must not be negative") if running_count == 0: return "" label = "client" if running_count == 1 else "clients" return f"{running_count} {label} running" def debug_elapsed_text(elapsed_seconds: float) -> str: """Format a live debug duration without sub-second UI churn.""" seconds = max(0, int(elapsed_seconds)) hours, remainder = divmod(seconds, 60 * 60) minutes, seconds = divmod(remainder, 60) if hours: return f"{hours}h {minutes}m {seconds}s" if minutes: return f"{minutes}m {seconds}s" return f"{seconds}s" def operation_activity_text( message: str, *, started_at: float | None, step: int = 0, total: int = 0, detail: str | None = None, timestamp: float | None = None, ) -> str: """Render live operation progress without dropping its elapsed timer.""" if detail is not None: base = detail elif total > 0: base = f"Step {step}/{total} · {message}" else: return message if started_at is None: return base now = time.monotonic() if timestamp is None else timestamp elapsed = int(max(0.0, now - started_at)) return f"{base} · {elapsed}s elapsed" def should_refresh_operation_elapsed( *, active: bool, started_at: float | None, stage_total: int, detail: str | None, ) -> bool: """Return whether the dashboard has operation text with a live timer.""" return ( active and started_at is not None and (stage_total > 0 or detail is not None) ) def game_sync_presentation( *, installed: bool, repair: bool = False, ) -> GameSyncPresentation: """Distinguish a fresh download from an update or repair.""" if not installed: return GameSyncPresentation( operation_label=( "Installing and verifying official game files…" if repair else "Installing official game files…" ), file_progress_verb="Downloading", ) if repair: return GameSyncPresentation( operation_label="Checking every game file against the official depot…", file_progress_verb="Repairing", ) return GameSyncPresentation( operation_label="Synchronizing official game files…", file_progress_verb="Updating", ) def game_file_progress_status(current_file: str, verb: str) -> str: """Render the current depot asset with the operation's exact intent.""" return f"{verb} {current_file}" def perform_server_status_check( *, fetcher: Callable[[], tuple[RealmServerStatus, ...]] = fetch_server_status, monotonic: Callable[[], float] = time.monotonic, wall_clock: Callable[[], float] = time.time, ) -> ServerStatusCheck: """Measure one official status request without leaking its endpoint.""" started_at = monotonic() try: statuses = fetcher() except ServerStatusError as error: duration_seconds = max(0.0, monotonic() - started_at) return ServerStatusCheck( statuses=None, checked_at=wall_clock(), duration_seconds=duration_seconds, error=str(error), ) duration_seconds = max(0.0, monotonic() - started_at) return ServerStatusCheck( statuses=statuses, checked_at=wall_clock(), duration_seconds=duration_seconds, ) def server_status_check_text( check: ServerStatusCheck, *, timestamp: float | None = None, ) -> str: """Render fetch age, latency, and freshness for the hidden debug view.""" now = time.time() if timestamp is None else timestamp age_seconds = max(0.0, now - check.checked_at) whole_seconds = int(age_seconds) if whole_seconds < 60: age = f"{whole_seconds}s" elif whole_seconds < 60 * 60: age = f"{whole_seconds // 60}m" elif whole_seconds < 24 * 60 * 60: age = f"{whole_seconds // (60 * 60)}h" else: age = f"{whole_seconds // (24 * 60 * 60)}d" latency_milliseconds = max(0, round(check.duration_seconds * 1000)) timing = f"checked {age} ago · {latency_milliseconds} ms" if check.error is not None: return f"failed · {timing} · {check.error}" freshness = "stale" if age_seconds >= SERVER_STATUS_STALE_SECONDS else "fresh" return f"{freshness} · {timing}" def _server_component_text(name: str, online: bool | None) -> str: if online is True: state = "up" elif online is False: state = "down" else: state = "unknown" return f"{name} {state}" def server_status_presentation( status: RealmServerStatus, *, show_debug_details: bool = False, ) -> ServerStatusPresentation: if status.online is True: text = f"● {status.name}" style = "ServerOnline.TLabel" elif status.online is False: text = f"● {status.name} · offline" style = "ServerOffline.TLabel" else: text = f"○ {status.name} · status unknown" style = "ServerUnknown.TLabel" if show_debug_details: details: list[str] = [] if status.players_online is not None: label = "player" if status.players_online == 1 else "players" details.append(f"{status.players_online} {label}") details.extend( ( _server_component_text("login", status.login_online), _server_component_text("game", status.game_online), ) ) text = " · ".join((text, *details)) return ServerStatusPresentation(text=text, style=style) def patch_change_colors(type_key: str) -> tuple[str, str]: """Return a restrained card-background palette for one patch change tag.""" return { "fixed": ("#321c1a", "#e88879"), "hotfix": ("#352018", "#e6a06a"), "new-feature": ("#192a20", "#77c795"), "improvement": ("#19252c", "#82b8d2"), "security": ("#302719", "#ddbb69"), "server": ("#282035", "#b8a0d8"), "game": ("#242b1b", "#a8ca75"), }.get(type_key.casefold(), ("#2a201c", "#d8aa61")) 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 cached content is ready for an interactive dashboard.""" return DASHBOARD_STARTUP_REQUIRED_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 preloader_status_text( value: str, step: int | None = None, total: int | None = None, ) -> str: """Render one launch stage without letting its widget change shape.""" if step is None or total is None: return 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]: """Lock a measured preloader frame so later status text cannot reflow it.""" frame.update_idletasks() width = max(1, frame.winfo_reqwidth()) height = max(1, frame.winfo_reqheight()) frame.configure(width=width, height=height) frame.pack_propagate(False) return width, height def launcher_restart_spec( paths: AppPaths, environment: Mapping[str, str] | None = None, ) -> tuple[list[str], dict[str, str]]: """Build an isolated restart that imports the newly activated release.""" current = paths.launcher_current if not current.is_symlink(): raise DashboardError(f"launcher current path is not a symbolic link: {current}") try: release = current.resolve(strict=True) except OSError as exc: raise DashboardError(f"launcher current release is unavailable: {exc}") from exc releases = paths.launcher_releases.resolve(strict=False) if release.parent != releases or not (release / "ptlinux").is_dir(): raise DashboardError(f"launcher current release is outside the release store: {release}") env = dict(os.environ if environment is None else environment) env.update( { "PTLINUX_INSTALL_DIR": str(paths.data), "PTLINUX_APP_HOME": str(paths.app_home), "PYTHONSAFEPATH": "1", "PYTHONPATH": str(release), } ) return [sys.executable, "-B", "-P", "-m", "ptlinux", "gui"], env def launcher_child_command(*arguments: str) -> list[str]: """Build a child command without writing bytecode into the active release.""" return [sys.executable, "-B", "-m", "ptlinux", *arguments] class ProgressCoalescer: """Keep only the latest cross-thread progress event for the Tk loop. The depot has concurrent download workers. Passing every 512 KiB byte update through the GUI queue lets output overwhelm Tk, so the dashboard displays the most recent snapshot once per UI frame instead. """ def __init__(self) -> None: self._lock = threading.Lock() self._latest: dict[str, object] | None = None def publish(self, event: Mapping[str, object]) -> None: with self._lock: self._latest = dict(event) def take(self) -> dict[str, object] | None: with self._lock: latest = self._latest self._latest = None return latest def clear(self) -> None: with self._lock: self._latest = None class TransferRateEstimator: """Estimate a stable byte rate from cumulative network-transfer samples.""" def __init__( self, window_seconds: float = TRANSFER_RATE_WINDOW_SECONDS, minimum_interval_seconds: float = TRANSFER_RATE_MINIMUM_INTERVAL_SECONDS, ) -> None: if window_seconds <= 0: raise ValueError("transfer-rate window must be positive") if minimum_interval_seconds < 0: raise ValueError("transfer-rate minimum interval must not be negative") self._window_seconds = window_seconds self._minimum_interval_seconds = minimum_interval_seconds self._samples: deque[tuple[float, int]] = deque() def reset(self) -> None: self._samples.clear() def update( self, transferred_bytes: int, *, timestamp: float | None = None, ) -> float | None: """Record one cumulative sample and return bytes per second when stable.""" if transferred_bytes < 0: raise ValueError("transferred bytes must not be negative") now = time.monotonic() if timestamp is None else timestamp if self._samples: last_time, last_bytes = self._samples[-1] if now <= last_time or transferred_bytes < last_bytes: self.reset() self._samples.append((now, transferred_bytes)) cutoff = now - self._window_seconds while len(self._samples) > 2 and self._samples[1][0] <= cutoff: self._samples.popleft() if len(self._samples) < 2: return None first_time, first_bytes = self._samples[0] elapsed = now - first_time if elapsed < self._minimum_interval_seconds: return None return max(0.0, (transferred_bytes - first_bytes) / elapsed) def is_idle( self, threshold_seconds: float = TRANSFER_RATE_IDLE_SECONDS, *, timestamp: float | None = None, ) -> bool: """Return whether no cumulative sample arrived within the threshold.""" if threshold_seconds < 0: raise ValueError("idle threshold must not be negative") if not self._samples: return False now = time.monotonic() if timestamp is None else timestamp return now - self._samples[-1][0] >= threshold_seconds def run_dashboard(paths: AppPaths) -> None: try: import tkinter as tk from tkinter import filedialog, ttk except ImportError as exc: raise DashboardError( "Python Tk is missing. Install your distribution's python3-tk package " "or use the pristontale-eu command-line interface." ) from exc paths.create_base_directories() root = tk.Tk(className=APPLICATION_WM_CLASS) root.title(f"{APPLICATION_NAME} — Linux Launcher") root.iconname(APPLICATION_NAME) window_icon: PhotoImage | None = None window_icon_path = find_application_icon() if window_icon_path is not None: try: loaded_window_icon = load_square_window_icon(root, window_icon_path) root.iconphoto(True, loaded_window_icon) window_icon = loaded_window_icon except (OSError, tk.TclError) as icon_error: print( f"Warning: could not load the launcher icon: {icon_error}", file=sys.stderr, ) def apply_window_icon(window: Toplevel) -> None: icon = window_icon if icon is not None: window.iconphoto(False, icon) root.configure(background="#0c0908") root.withdraw() root.geometry(f"{WINDOW_WIDTH}x{WINDOW_HEIGHT}") try: startup_settings = ensure_settings(paths) except (OSError, ValueError) as startup_error: root.deiconify() show_themed_dialog( root, title="Launcher settings", message=f"The launcher could not load its settings:\n\n{startup_error}", kind="error", window_icon=window_icon, ) root.destroy() return try: detected_dpi = float(root.winfo_fpixels("1i")) except (tk.TclError, TypeError, ValueError): detected_dpi = BASE_DISPLAY_DPI launcher_ui_scale = resolve_ui_scale(startup_settings.ui_scale, detected_dpi) news_card_height = round(NEWS_CARD_HEIGHT * launcher_ui_scale) news_image_width = round(NEWS_IMAGE_WIDTH * launcher_ui_scale) news_image_height = round(NEWS_IMAGE_HEIGHT * launcher_ui_scale) patch_card_wrap_length = round(PATCH_CARD_WRAP_LENGTH * launcher_ui_scale) patch_change_wrap_length = round(PATCH_CHANGE_WRAP_LENGTH * launcher_ui_scale) try: root.tk.call("tk", "scaling", BASE_TK_SCALING * launcher_ui_scale) except tk.TclError: pass initial_width, initial_height = fitted_window_size( WINDOW_WIDTH, WINDOW_HEIGHT, scale=launcher_ui_scale, screen_width=root.winfo_screenwidth(), screen_height=root.winfo_screenheight(), ) root.geometry(f"{initial_width}x{initial_height}") root.resizable(False, False) dashboard_chrome = create_dashboard_chrome( root, title="PRISTONTALE EU", subtitle="NATIVE LINUX LAUNCHER", window_icon=window_icon, ) dashboard_content = dashboard_chrome.content style = ttk.Style(root) style.theme_use("clam") style.configure("Root.TFrame", background="#0c0908") style.configure("Surface.TFrame", background="#191311") style.configure("Hero.TFrame", background="#24170f") style.configure("Card.TFrame", background="#211916") style.configure( "Brand.TLabel", background="#0c0908", foreground="#f5d18b", font=("Georgia", 21, "bold"), ) style.configure( "Tagline.TLabel", background="#0c0908", foreground="#a8998a", font=("Sans", 8), ) style.configure( "Section.TLabel", background="#191311", foreground="#f0d7a2", font=("Sans", 9, "bold"), ) style.configure( "HeroTitle.TLabel", background="#24170f", foreground="#fff2d3", font=("Georgia", 17, "bold"), ) style.configure( "HeroText.TLabel", background="#24170f", foreground="#d6c6b3", font=("Sans", 10), ) style.configure( "Status.TLabel", background="#191311", foreground="#dfd1c1", font=("Sans", 9), ) style.configure( "Muted.TLabel", background="#191311", foreground="#aa9a8b", font=("Sans", 8), ) style.configure( "ServerHeading.TLabel", background="#191311", foreground="#d8aa61", font=("Sans", 8, "bold"), padding=(0, 3), ) style.configure( "ServerOnline.TLabel", background="#102719", foreground="#58e27a", font=("Sans", 8, "bold"), padding=(8, 3), anchor="w", ) style.configure( "ServerOffline.TLabel", background="#301514", foreground="#ff6258", font=("Sans", 8, "bold"), padding=(8, 3), anchor="w", ) style.configure( "ServerUnknown.TLabel", background="#211916", foreground="#b8a99a", font=("Sans", 8), padding=(8, 3), anchor="w", ) style.configure( "Speed.TLabel", background="#191311", foreground="#e5b66c", font=("Monospace", 8, "bold"), ) style.configure( "SettingsTitle.TLabel", background="#191311", foreground="#fff0d2", font=("Georgia", 16, "bold"), ) style.configure( "SettingsText.TLabel", background="#191311", foreground="#d8cbbd", font=("Sans", 9), ) style.configure( "SettingsHint.TLabel", background="#191311", foreground="#aa9a8b", font=("Sans", 8), ) style.configure( "SettingsCard.TLabelframe", background="#191311", bordercolor="#4a3528", lightcolor="#4a3528", darkcolor="#120e0c", borderwidth=1, relief="solid", ) style.configure( "SettingsCard.TLabelframe.Label", background="#191311", foreground="#f0d7a2", font=("Sans", 9, "bold"), ) style.configure("SettingsCard.TFrame", background="#191311") style.configure( "Update.Treeview", background="#100d0c", fieldbackground="#100d0c", foreground="#ded1c4", rowheight=max(24, round(24 * launcher_ui_scale)), bordercolor="#4a3528", ) style.configure( "Update.Treeview.Heading", background="#2a201c", foreground="#f0d7a2", font=("Sans", 8, "bold"), bordercolor="#4a3528", lightcolor="#4a3528", darkcolor="#120e0c", relief="flat", ) style.map( "Update.Treeview.Heading", background=[("active", "#2a201c"), ("pressed", "#2a201c")], foreground=[("active", "#f0d7a2"), ("pressed", "#f0d7a2")], relief=[("active", "flat"), ("pressed", "flat")], ) style.map( "Update.Treeview", background=[("selected", "#6a482d")], foreground=[("selected", "#ffffff")], ) style.configure("SettingsFooter.TFrame", background="#100d0c") style.configure( "SettingsFooter.TLabel", background="#100d0c", foreground="#aa9a8b", font=("Sans", 8), ) style.configure( "Settings.TEntry", fieldbackground="#100d0c", foreground="#f8ead2", insertcolor="#f8fafc", bordercolor="#4a3528", lightcolor="#4a3528", darkcolor="#4a3528", focuscolor="#f4bd61", focusthickness=2, ) style.map("Settings.TEntry", bordercolor=[("focus", "#f4bd61")]) style.configure( "Settings.TCombobox", fieldbackground="#100d0c", background="#211916", foreground="#f8ead2", arrowcolor="#e5b66c", bordercolor="#4a3528", focuscolor="#f4bd61", focusthickness=2, ) style.map( "Settings.TCombobox", fieldbackground=[("readonly", "#100d0c")], foreground=[("readonly", "#f8ead2")], bordercolor=[("focus", "#f4bd61")], ) style.configure( "Realm.TCombobox", fieldbackground="#211916", background="#211916", foreground="#fff0d2", arrowcolor="#d8aa61", bordercolor="#4a3528", lightcolor="#4a3528", darkcolor="#4a3528", font=("Sans", 9, "bold"), padding=(10, 7), relief="flat", ) style.map( "Realm.TCombobox", fieldbackground=[ ("disabled", "#171412"), ("readonly", "#211916"), ], background=[ ("disabled", "#171412"), ("active", "#2a201c"), ("readonly", "#211916"), ], foreground=[ ("disabled", "#887a70"), ("readonly", "#fff0d2"), ], arrowcolor=[ ("disabled", "#887a70"), ("active", "#f0d7a2"), ("readonly", "#d8aa61"), ], bordercolor=[ ("disabled", "#4a3528"), ("focus", "#6a4d38"), ("readonly", "#4a3528"), ], ) root.option_add("*realm_selector*Listbox.background", "#211916") root.option_add("*realm_selector*Listbox.foreground", "#fff0d2") root.option_add("*realm_selector*Listbox.selectBackground", "#2a201c") root.option_add("*realm_selector*Listbox.selectForeground", "#f0d7a2") root.option_add("*realm_selector*Listbox.font", "Sans 9 bold") root.option_add("*realm_selector*Listbox.justify", "center") root.option_add("*realm_selector*Listbox.selectBorderWidth", 4) root.option_add("*realm_selector*Listbox.borderWidth", 1) root.option_add("*realm_selector*Listbox.highlightThickness", 0) root.option_add("*realm_selector*Listbox.relief", "flat") root.option_add("*realm_selector*Listbox.activeStyle", "none") style.configure( "Settings.TCheckbutton", background="#191311", foreground="#d8cbbd", font=("Sans", 9), focuscolor="#f4bd61", focusthickness=2, ) style.map( "Settings.TCheckbutton", background=[("active", "#191311")], foreground=[("active", "#fff0d2")], ) style.configure( "PreloaderStatus.TLabel", background="#0c0908", foreground="#dfd1c1", font=("Sans", 10), ) style.configure( "PreloaderMuted.TLabel", background="#0c0908", foreground="#aa9a8b", font=("Sans", 9), ) style.configure( "NewsTitle.TLabel", background="#211916", foreground="#fff0d2", font=("Sans", 9, "bold"), ) style.configure( "NewsMeta.TLabel", background="#211916", foreground="#d8aa61", font=("Sans", 9, "bold"), ) style.configure( "NewsBody.TLabel", background="#211916", foreground="#c9baaa", font=("Sans", 8), ) style.configure( "NewsTab.TButton", background="#191311", foreground="#9f8d7c", font=("Sans", 8, "bold"), padding=(10, 4), bordercolor="#32251f", borderwidth=1, relief="flat", focusthickness=0, ) style.map( "NewsTab.TButton", background=[("active", "#2a201c")], foreground=[("active", "#f0d7a2")], bordercolor=[("focus", "#f4bd61"), ("active", "#4a3528")], ) style.configure( "NewsTabActive.TButton", background="#2a201c", foreground="#f0d7a2", font=("Sans", 8, "bold"), padding=(10, 4), bordercolor="#6a4d38", borderwidth=1, relief="flat", focusthickness=0, ) style.map( "NewsTabActive.TButton", background=[("active", "#32251f")], foreground=[("active", "#fff0d2")], bordercolor=[("focus", "#f4bd61")], ) style.configure( "News.Vertical.TScrollbar", background="#4a3528", troughcolor="#191311", arrowcolor="#d8aa61", bordercolor="#191311", lightcolor="#4a3528", 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( "Play.TButton", background="#d98927", foreground="#fff8e9", font=("Sans", 10, "bold"), padding=(20, 10), bordercolor="#f4bd61", borderwidth=1, relief="flat", focuscolor="#fff0c2", focusthickness=2, ) style.map( "Play.TButton", background=[("active", "#efaa4c"), ("disabled", "#6c4826")], foreground=[("disabled", "#bca889")], bordercolor=[("focus", "#fff0c2")], ) style.configure( "Secondary.TButton", background="#0b0a09", foreground="#f2b657", font=("Sans", 8, "bold"), padding=(12, 8), bordercolor="#d89332", borderwidth=1, relief="flat", focuscolor="#f4bd61", focusthickness=2, ) style.map( "Secondary.TButton", background=[("active", "#271b10"), ("disabled", "#171412")], foreground=[("active", "#ffd17c"), ("disabled", "#887a70")], bordercolor=[("focus", "#f4bd61")], ) style.configure( "LauncherUpdate.TButton", background="#f4bd61", foreground="#24170f", font=("Sans", 8, "bold"), padding=(12, 8), bordercolor="#ffe3a3", borderwidth=2, relief="flat", focuscolor="#24170f", focusthickness=2, ) style.map( "LauncherUpdate.TButton", background=[("active", "#ffd47f"), ("disabled", "#7b5d37")], foreground=[("active", "#120b06"), ("disabled", "#d0bea2")], bordercolor=[("focus", "#ffffff")], ) style.configure( "LauncherManualUpdate.TButton", background="#a63f2f", foreground="#fff4e9", font=("Sans", 8, "bold"), padding=(12, 8), bordercolor="#e98769", borderwidth=2, relief="flat", focuscolor="#ffffff", focusthickness=2, ) style.map( "LauncherManualUpdate.TButton", background=[("active", "#c95842"), ("disabled", "#60352e")], foreground=[("active", "#ffffff"), ("disabled", "#c9ada5")], bordercolor=[("focus", "#ffffff")], ) style.configure( "UpdateBadge.TLabel", background="#d98927", foreground="#1a1008", font=("Sans", 7, "bold"), padding=(7, 3), ) style.configure( "Activity.TButton", background="#0c0908", foreground="#f8fafc", font=("Monospace", 8, "bold"), padding=(3, 2), borderwidth=0, relief="flat", ) style.map( "Activity.TButton", background=[("active", "#24170f")], foreground=[("active", "#ffffff")], ) style.configure( "News.TButton", background="#211916", foreground="#e5b66c", font=("Sans", 9, "bold"), padding=(0, 3), ) style.map("News.TButton", foreground=[("active", "#ffe0a0")]) style.configure( "Depot.Horizontal.TProgressbar", background="#d68c34", troughcolor="#34251f", bordercolor="#34251f", lightcolor="#e4a34c", darkcolor="#b66c25", ) startup_started_at = time.monotonic() startup_status = tk.StringVar(value="Loading dashboard contents…") startup_image_references: list[tk.PhotoImage] = [] startup_overlay = tk.Frame( dashboard_content, background="#0c0908", borderwidth=0, highlightthickness=0, ) startup_overlay.place(x=0, y=0, relwidth=1, relheight=1) startup_border = tk.Frame( startup_overlay, background="#6a482d", borderwidth=0, highlightthickness=0, ) startup_border.place(relx=0.5, rely=0.5, anchor="center") startup_shell = ttk.Frame( startup_border, style="Root.TFrame", padding=(34, 26), ) 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( startup_shell, text="Preparing the launcher…", style="PreloaderStatus.TLabel", anchor="center", ).pack(fill="x") startup_status_label = ttk.Label( startup_shell, textvariable=startup_status, style="PreloaderMuted.TLabel", width=PRELOADER_STATUS_CHARACTERS, anchor="center", ) startup_status_label.pack(fill="x", pady=(5, 11)) startup_progress_value = tk.DoubleVar(value=0.0) startup_progress = ttk.Progressbar( startup_shell, style="Depot.Horizontal.TProgressbar", orient="horizontal", mode="determinate", maximum=len(DASHBOARD_STARTUP_COMPONENTS), variable=startup_progress_value, length=420, takefocus=False, ) startup_progress.pack(fill="x") freeze_preloader_geometry(startup_border) startup_ready_components: set[str] = set() startup_transition_started = False startup_ready_after: str | None = None startup_timeout_after: str | None = None startup_progress_after: str | None = None startup_progress_target = 0.0 remote_refresh_started = False root.deiconify() root.lift() startup_overlay.lift() 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: if startup_transition_started: return set_startup_progress_target(float(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() play_button.focus_set() root.after_idle(start_remote_content_refresh) 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() startup_image_references.clear() 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" ) set_startup_progress_target( float(len(DASHBOARD_STARTUP_COMPONENTS)), immediate=True, ) 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(dashboard_content, style="Root.TFrame", padding=12) frame.pack(fill="both", expand=True) header = ttk.Frame(frame, style="Root.TFrame") header.pack(fill="x", pady=(0, 8)) logo = tk.Canvas( header, width=42, height=42, background="#0c0908", highlightthickness=0, borderwidth=0, ) dashboard_header_icon: PhotoImage | None = None if window_icon is not None: try: header_icon_factor = preloader_logo_subsample_factor( window_icon.width(), window_icon.height(), 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)) brand = ttk.Frame(header, style="Root.TFrame") brand.pack(side="left", fill="x", expand=True) ttk.Label(brand, text="PRISTONTALE EU", style="Brand.TLabel").pack(anchor="w") ttk.Label( brand, text="PRISTONTALE EU · NATIVE LINUX LAUNCHER", style="Tagline.TLabel", ).pack(anchor="w", pady=(1, 0)) version = tk.StringVar( value=f"Launcher {__version__} · checking official game build…" ) ttk.Label(header, textvariable=version, style="Tagline.TLabel").pack( side="right", anchor="e", ) controls = ttk.Frame(frame, style="Root.TFrame") controls.pack(fill="x", pady=(8, 8)) controls.columnconfigure(0, weight=1, uniform="actions") controls.columnconfigure(1, weight=1, uniform="actions") game_actions = ttk.Frame(controls, style="Surface.TFrame", padding=12) game_actions.grid(row=0, column=0, sticky="nsew", padx=(0, 4)) ttk.Label(game_actions, text="GAME FILES", style="Section.TLabel").pack(anchor="w") game_button_row = ttk.Frame(game_actions, style="Surface.TFrame") game_button_row.pack(anchor="w", fill="x", pady=(6, 0)) update_button = ttk.Button( game_button_row, text="UPDATE", style="Secondary.TButton", underline=0, ) update_button.pack(side="left") repair_button = ttk.Button( game_button_row, text="REPAIR", style="Secondary.TButton", underline=0, ) repair_button.pack(side="left", padx=(8, 0)) import_button = ttk.Button( game_button_row, text="IMPORT GAME", style="Secondary.TButton", underline=0, ) import_button.pack(side="left", padx=(8, 0)) system_actions = ttk.Frame(controls, style="Surface.TFrame", padding=12) system_actions.grid(row=0, column=1, sticky="nsew", padx=(4, 0)) 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", underline=7, ) doctor_button.pack(side="left") settings_button = ttk.Button( system_button_row, text="OPEN SETTINGS", style="Secondary.TButton", underline=5, ) settings_button.pack(side="left", padx=(8, 0)) launcher_update_button = ttk.Button( system_button_row, text=f"LAUNCHER {__version__}", style="Secondary.TButton", underline=0, ) launcher_update_button.pack(side="left", padx=(8, 0)) activity_panel = ttk.Frame(frame, style="Surface.TFrame", padding=(10, 7)) activity_panel.pack(fill="x", pady=(0, 8)) realm_value = tk.StringVar(value=DEFAULT_DEPOT_START_OPTION.name) activity_action = ttk.Frame(activity_panel, style="Surface.TFrame") activity_action.pack(side="right", fill="y", padx=(12, 0)) play_button = ttk.Button( activity_action, text="PLAY", style="Play.TButton", underline=0, ) play_button.pack(fill="both", expand=True) realm_selector = ttk.Combobox( activity_action, name="realm_selector", textvariable=realm_value, values=(DEFAULT_DEPOT_START_OPTION.name,), state="readonly", style="Realm.TCombobox", font=("Sans", 9, "bold"), justify="center", width=20, ) realm_selector.pack(fill="x", pady=(6, 5)) activity_copy = ttk.Frame(activity_panel, style="Surface.TFrame") activity_copy.pack(side="left", fill="both", expand=True) status = tk.StringVar(value="Ready") progress_text = tk.StringVar(value="Click Play to start your journey") speed_text = tk.StringVar(value="") client_activity_text = tk.StringVar(value="") download_context_text = tk.StringVar(value="") activity_meta_text = tk.StringVar(value="") def refresh_activity_meta_text() -> None: activity_meta_text.set( download_context_text.get() or client_activity_text.get() ) def set_download_context_text(value: str) -> None: download_context_text.set(value) refresh_activity_meta_text() activity_heading = ttk.Frame(activity_copy, style="Surface.TFrame") activity_heading.pack(fill="x") ttk.Label( activity_heading, textvariable=status, style="Status.TLabel", ).pack(side="left", anchor="w") ttk.Label( activity_heading, textvariable=activity_meta_text, style="Muted.TLabel", ).pack(side="right", anchor="e") progress_bar = ttk.Progressbar( activity_copy, style="Depot.Horizontal.TProgressbar", mode="determinate", maximum=1, value=0, ) progress_bar.pack(fill="x", pady=(5, 2)) progress_details = ttk.Frame(activity_copy, style="Surface.TFrame") progress_details.pack(fill="x") ttk.Label( progress_details, textvariable=speed_text, style="Speed.TLabel", width=28, anchor="e", ).pack(side="right") ttk.Label( progress_details, textvariable=progress_text, style="Muted.TLabel", anchor="w", ).pack(side="left", fill="x", expand=True) server_status_row = ttk.Frame(activity_copy, style="Surface.TFrame") server_status_row.pack(fill="x", pady=(5, 1)) ttk.Label( server_status_row, text="SERVERS", style="ServerHeading.TLabel", ).pack(side="left", fill="y", padx=(0, 8)) server_status_content = ttk.Frame(server_status_row, style="Surface.TFrame") server_status_content.pack(side="left", fill="y", anchor="w") log_toggle = tk.Button( server_status_row, text=">_", background="#191311", foreground="#f8fafc", activebackground="#24170f", activeforeground="#ffffff", font=("Monospace", 8, "bold"), padx=3, pady=1, borderwidth=0, highlightthickness=1, highlightbackground="#191311", highlightcolor="#f4bd61", relief="flat", takefocus=True, ) log_toggle.pack(side="right", anchor="e", padx=(8, 0)) server_status_debug_text = tk.StringVar(value="") ttk.Label( server_status_row, textvariable=server_status_debug_text, style="Muted.TLabel", ).pack(side="right", anchor="e", padx=(8, 0)) ttk.Label( server_status_content, text="Checking official server status…", style="ServerUnknown.TLabel", ).pack(side="left", anchor="w") news_panel = ttk.Frame(frame, style="Surface.TFrame", padding=(10, 8)) news_panel.pack(fill="both", expand=True, pady=(0, 8), before=controls) news_heading = ttk.Frame(news_panel, style="Surface.TFrame") news_heading.pack(fill="x") ttk.Label(news_heading, text="LATEST NEWS", style="Section.TLabel").pack(side="left") news_tabs = ttk.Frame(news_heading, style="Surface.TFrame") news_tabs.pack(side="left", padx=(14, 0)) news_tab_button = ttk.Button( news_tabs, text="NEWS", style="NewsTabActive.TButton", underline=0, ) news_tab_button.pack(side="left") patches_tab_button = ttk.Button( news_tabs, text="PATCHES", style="NewsTab.TButton", underline=0, ) patches_tab_button.pack(side="left", padx=(5, 0)) ttk.Label( news_heading, text="OFFICIAL FEED", style="Muted.TLabel", ).pack(side="right") news_viewport = ttk.Frame(news_panel, style="Surface.TFrame") news_viewport.pack(fill="both", expand=True, pady=(6, 0)) news_viewport.grid_columnconfigure(0, weight=1) news_viewport.grid_rowconfigure(0, weight=1) news_canvas = tk.Canvas( news_viewport, background="#191311", highlightthickness=0, borderwidth=0, height=round(252 * launcher_ui_scale), takefocus=True, ) news_canvas.grid(row=0, column=0, sticky="nsew") news_scrollbar = ttk.Scrollbar( news_viewport, orient="vertical", style="News.Vertical.TScrollbar", command=news_canvas.yview, ) news_scrollbar.grid(row=0, column=1, sticky="ns", padx=(5, 0)) news_scrollbar.grid_remove() news_canvas.configure(yscrollcommand=news_scrollbar.set) news_content_host = ttk.Frame(news_canvas, style="Surface.TFrame") news_content_host.grid_columnconfigure(0, weight=1) news_content = ttk.Frame(news_content_host, style="Surface.TFrame") patches_content = ttk.Frame(news_content_host, style="Surface.TFrame") news_content.grid(row=0, column=0, sticky="nsew") patches_content.grid(row=0, column=0, sticky="nsew") patches_content.grid_remove() news_window = news_canvas.create_window( (0, 0), window=news_content_host, anchor="nw", ) broadcast_panel = ttk.Frame(frame, style="Surface.TFrame", padding=(10, 8)) broadcast_panel.pack(fill="x", pady=(0, 8), before=news_panel) broadcast_heading = ttk.Frame(broadcast_panel, style="Surface.TFrame") broadcast_heading.pack(fill="x") ttk.Label(broadcast_heading, text="LATEST BROADCAST", style="Section.TLabel").pack(side="left") ttk.Label( broadcast_heading, text="OFFICIAL DISCORD", style="Muted.TLabel", ).pack(side="right") broadcast_content = ttk.Frame(broadcast_panel, style="Surface.TFrame") broadcast_content.pack(fill="x", pady=(6, 0)) activity_window = tk.Toplevel(root) activity_window.title("PristonTale EU — Activity") activity_width, activity_height = fitted_window_size( 800, 400, scale=launcher_ui_scale, screen_width=activity_window.winfo_screenwidth(), screen_height=activity_window.winfo_screenheight(), ) activity_window.geometry(f"{activity_width}x{activity_height}") activity_window.resizable(False, False) activity_window.configure(background="#0c0908") apply_window_icon(activity_window) log = tk.Text( activity_window, background="#100d0c", foreground="#d8cbbd", insertbackground="#f8fafc", relief="flat", font=("Monospace", 9), padx=10, pady=8, state="disabled", ) log.pack(fill="both", expand=True, padx=10, pady=10) activity_window.withdraw() events: queue.Queue[tuple[str, object]] = queue.Queue() progress_updates = ProgressCoalescer() transfer_rate = TransferRateEstimator() news_image_targets: dict[int, tk.Canvas] = {} news_image_references: dict[int, tk.PhotoImage] = {} news_card_widgets: list[tk.Frame] = [] news_layout_columns = MAX_NEWS_CARDS startup_news_images_pending: set[int] = set() active_news_tab = "news" rendered_news_articles: tuple[NewsArticle, ...] | 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] = [] preloader: tk.Toplevel | None = None preloader_status: tk.StringVar | None = None preloader_progress: ttk.Progressbar | None = None preloader_image_references: list[tk.PhotoImage] = [] settings_window: tk.Toplevel | None = None launcher_update_window: tk.Toplevel | None = None launcher_update_dialog_refresh: Callable[[], None] | None = None launcher_update_dialog_error: Callable[[str], None] | None = None dashboard_debug_window: tk.Toplevel | None = None dashboard_debug_output: tk.Text | None = None game_clients = GameClientTracker() game_launch_supervisors = GameLaunchSupervisorRegistry() client_runtime_debug: dict[int, RuntimeDebugInfo] = {} client_launch_settings: dict[int, Settings] = {} active_game_launch_id: int | None = None operation_active = False game_window_active = False activity_log_lines = 0 operation_started_at: float | None = None operation_stage = 0 operation_stage_total = 0 operation_message = "" operation_progress_detail: str | None = None operation_refreshes_compatibility = False operation_file_progress_verb = "Updating" last_elapsed_second = -1 official_snapshot: DepotSnapshot | None = None latest_installation_status: GameInstallationStatus | None = None available_start_options: tuple[DepotStartOption, ...] = (DEFAULT_DEPOT_START_OPTION,) preferred_realm = startup_settings.realm latest_server_statuses: tuple[RealmServerStatus, ...] | None = None latest_server_status_check: ServerStatusCheck | None = None server_status_placeholder = "Checking official server status…" show_dashboard_debug = False dashboard_runtime_debug: RuntimeDebugInfo | None = None dashboard_verification_debug: VerificationDebugInfo | None = None dashboard_debug_state_error: str | None = None last_dashboard_debug_render = "" launcher_update_check: LauncherUpdateCheck | None = None runtime_compatibility: RuntimeCompatibility | None = None compatibility_check_generation = 0 controls_are_stacked: bool | None = None responsive_layout_after: str | None = None buttons = ( play_button, update_button, repair_button, doctor_button, settings_button, import_button, launcher_update_button, ) game_mutation_buttons = ( update_button, repair_button, import_button, launcher_update_button, ) def append_log(value: str) -> None: nonlocal activity_log_lines rendered = value.rstrip() + "\n" log.configure(state="normal") log.insert("end", rendered) activity_log_lines += rendered.count("\n") if activity_log_lines > MAX_ACTIVITY_LOG_LINES: retained_lines = MAX_ACTIVITY_LOG_LINES - ACTIVITY_LOG_TRIM_LINES excess = activity_log_lines - retained_lines log.delete("1.0", f"{excess + 1}.0") activity_log_lines -= excess log.see("end") log.configure(state="disabled") def toggle_log() -> None: if activity_window.state() == "withdrawn": activity_window.deiconify() activity_window.lift() activity_window.focus_set() else: activity_window.withdraw() def hide_activity_window() -> None: activity_window.withdraw() activity_window.protocol("WM_DELETE_WINDOW", hide_activity_window) def set_busy(value: bool) -> None: new_state = "disabled" if value else "normal" for button in buttons: button.configure(state=new_state) realm_selector.configure(state="disabled" if value else "readonly") if not value and game_clients.running_count: for button in game_mutation_buttons: button.configure(state="disabled") def update_progress(event: Mapping[str, object]) -> None: nonlocal operation_progress_detail phase = event.get("phase") files_completed = _as_int(event.get("files_completed")) files_total = _as_int(event.get("files_total")) bytes_completed = _as_int(event.get("bytes_completed")) bytes_total = _as_int(event.get("bytes_total")) current_file = event.get("current_file") if phase == "manifest": transfer_rate.reset() speed_text.set("") set_download_context_text("") set_operation_stage(1, 3, "Checking official CDN manifests…") return progress_bar.stop() progress_bar.configure(mode="determinate") if bytes_total: progress_bar.configure(maximum=bytes_total, value=min(bytes_completed, bytes_total)) bytes_remaining = _as_int(event.get("bytes_remaining")) if "bytes_remaining" not in event: bytes_remaining = max(0, bytes_total - bytes_completed) percent = min(100, round((bytes_completed / bytes_total) * 100)) operation_progress_detail = ( f"{percent}% · {files_completed:,}/{files_total:,} files · " f"{_format_bytes(bytes_completed)} / {_format_bytes(bytes_total)} · " f"{_format_bytes(bytes_remaining)} remaining" ) if phase == "downloading": bytes_transferred = _as_int(event.get("bytes_transferred")) estimated_rate = transfer_rate.update(bytes_transferred) reported_rate = _as_float( event.get("transfer_rate_bytes_per_second") ) rate = reported_rate if reported_rate is not None else estimated_rate eta_seconds = _as_float(event.get("eta_seconds")) if eta_seconds is None and rate is not None and rate > 0: eta_seconds = bytes_remaining / rate speed_text.set( f"{format_transfer_rate(rate) if rate is not None else '0 B/s'} · " f"ETA {format_eta(eta_seconds)}" ) current_server = event.get("current_server") active_workers = _as_int(event.get("active_workers")) target_workers = _as_int(event.get("target_workers")) retry_reason = event.get("retry_reason") set_download_context_text( download_activity_metadata( current_server=( current_server if isinstance(current_server, str) and current_server else None ), retry_count=_as_int(event.get("retry_count")), retry_reason=( retry_reason if isinstance(retry_reason, str) and retry_reason else None ), active_workers=active_workers, target_workers=target_workers, ) ) else: transfer_rate.reset() speed_text.set("") set_download_context_text("") else: progress_bar.configure(maximum=3, value=3) operation_progress_detail = "Game files are already up to date." transfer_rate.reset() speed_text.set("") set_download_context_text("") progress_text.set(activity_text()) if isinstance(current_file, str) and current_file: status.set( game_file_progress_status(current_file, operation_file_progress_verb) ) elif phase == "complete": status.set("Game files are up to date and verified.") def activity_text() -> str: return operation_activity_text( operation_message, started_at=operation_started_at, step=operation_stage, total=operation_stage_total, detail=operation_progress_detail, ) def set_operation_stage(step: int, total: int, message: str) -> None: nonlocal operation_stage, operation_stage_total, operation_message nonlocal operation_progress_detail operation_stage = min(max(step, 0), total) operation_stage_total = max(total, 1) operation_message = message operation_progress_detail = None transfer_rate.reset() speed_text.set("") set_download_context_text("") progress_bar.stop() progress_bar.configure( mode="determinate", maximum=operation_stage_total, value=operation_stage, ) status.set(message) progress_text.set(activity_text()) set_preloader_status(message, operation_stage, operation_stage_total) def update_elapsed_indicator() -> None: nonlocal last_elapsed_second if not should_refresh_operation_elapsed( active=operation_active, started_at=operation_started_at, stage_total=operation_stage_total, detail=operation_progress_detail, ): return assert operation_started_at is not None elapsed = int(time.monotonic() - operation_started_at) if elapsed == last_elapsed_second: return last_elapsed_second = elapsed progress_text.set(activity_text()) 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: nonlocal preloader, preloader_status, preloader_progress if preloader is not None and preloader.winfo_exists(): return window = tk.Toplevel(root) window.withdraw() preloader = window window.title("Launching PristonTale EU") window.configure(background="#0c0908") window.resizable(False, False) window.protocol("WM_DELETE_WINDOW", cancel_active_game_launch) window.transient(root) apply_window_icon(window) loader_chrome = create_window_chrome( window, title="", subtitle="", show_title_bar=False, show_close=False, show_minimize=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…") preloader_status = status_variable content = ttk.Frame( loader_chrome.content, style="Root.TFrame", padding=(34, 26), ) content.pack(fill="both", expand=True) cancel_button.lift() logo_source = window_icon if logo_source is not None: modal_logo_factor = preloader_logo_subsample_factor( logo_source.width(), logo_source.height(), ) modal_logo = logo_source.subsample( modal_logo_factor, modal_logo_factor, ) preloader_image_references.clear() preloader_image_references.append(modal_logo) tk.Label( content, image=modal_logo, background="#0c0908", borderwidth=0, highlightthickness=0, ).pack(anchor="center", pady=(0, 16)) ttk.Label( content, text="Preparing the game window…", style="PreloaderStatus.TLabel", anchor="center", ).pack(fill="x") status_label = ttk.Label( content, textvariable=status_variable, style="PreloaderMuted.TLabel", width=PRELOADER_STATUS_CHARACTERS, anchor="center", ) status_label.pack( fill="x", pady=(5, 11), ) progress_widget = ttk.Progressbar( content, style="Depot.Horizontal.TProgressbar", mode="determinate", maximum=LAUNCH_STAGE_TOTAL, value=1, ) preloader_progress = progress_widget progress_widget.pack(fill="x") freeze_preloader_geometry(content) root.update_idletasks() window.update_idletasks() width = window.winfo_reqwidth() height = window.winfo_reqheight() window.geometry( centered_window_geometry( width, height, parent_x=root.winfo_rootx(), parent_y=root.winfo_rooty(), parent_width=root.winfo_width(), parent_height=root.winfo_height(), ) ) window.deiconify() window.lift() window.grab_set() window.focus_force() def set_preloader_status(value: str, step: int | None = None, total: int | None = None) -> None: if preloader_status is not None: preloader_status.set(preloader_status_text(value, step, total)) if preloader_progress is not None and step is not None and total is not None: preloader_progress.configure(maximum=total, value=step) def close_game_preloader() -> None: nonlocal preloader, preloader_status, preloader_progress if preloader is not None and preloader.winfo_exists(): try: preloader.grab_release() except tk.TclError: pass preloader.destroy() preloader = None preloader_status = None preloader_progress = None preloader_image_references.clear() def run_operation( label: str, command: list[str], *, show_preloader: bool = False, restart_on_success: bool = False, game_files_may_change: bool = False, file_progress_verb: str = "Updating", game_client_id: int | None = None, ) -> None: nonlocal operation_active, game_window_active nonlocal operation_started_at nonlocal operation_stage nonlocal operation_stage_total nonlocal operation_message nonlocal operation_progress_detail nonlocal operation_refreshes_compatibility nonlocal operation_file_progress_verb nonlocal last_elapsed_second operation_active = True game_window_active = game_clients.running_count > 0 operation_started_at = time.monotonic() operation_stage = 0 operation_stage_total = 0 operation_message = "" operation_progress_detail = None operation_refreshes_compatibility = game_files_may_change operation_file_progress_verb = file_progress_verb last_elapsed_second = -1 progress_updates.clear() transfer_rate.reset() speed_text.set("") set_download_context_text("") set_busy(True) status.set(label) progress_text.set("Waiting for the operation to report progress…") if show_preloader: show_game_preloader() set_operation_stage(1, LAUNCH_STAGE_TOTAL, "Starting launcher process…") else: progress_bar.stop() progress_bar.configure(mode="determinate", maximum=3, value=0) append_log(f"$ {' '.join(command)}") def worker() -> None: def publish_output(line: str) -> None: if game_client_id is None: events.put(("output", line)) else: 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: process = subprocess.Popen( command, text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=1, ) except OSError as process_error: publish_output(f"Could not start operation: {process_error}\n") if game_client_id is None: events.put(("done", 1)) else: events.put(("game_done", (game_client_id, 1))) return if game_client_id is not None: cancel_on_start = game_launch_supervisors.attach( game_client_id, process, ) events.put( ("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: for line in process.stdout: if line.startswith("STATUS "): try: parsed_status = json.loads(line.removeprefix("STATUS ")) except json.JSONDecodeError: publish_output(line) else: if isinstance(parsed_status, dict): if game_client_id is None: events.put(("operation_status", parsed_status)) else: events.put( ( "game_operation_status", (game_client_id, parsed_status), ) ) else: publish_output(line) elif line.startswith("PROGRESS "): try: parsed = json.loads(line.removeprefix("PROGRESS ")) except json.JSONDecodeError: publish_output(line) else: if isinstance(parsed, dict): progress_updates.publish(parsed) else: publish_output(line) else: publish_output(line) if game_client_id is not None and line.startswith("Game window created;"): events.put( ( "game_operation_status", ( game_client_id, { "step": 4, "total": LAUNCH_STAGE_TOTAL, "message": "Game client started.", }, ), ) ) events.put(("game_started", game_client_id)) if game_client_id is not None and line.startswith( ("Game window ready:", "Rendered game frame:") ): events.put(("game_window_ready", game_client_id)) returncode = process.wait() if game_client_id is not None: game_launch_supervisors.detach(game_client_id, process) if game_client_id is None: events.put( ("launcher_update_done" if restart_on_success else "done", returncode) ) else: events.put(("game_done", (game_client_id, returncode))) threading.Thread( target=worker, name=( f"ptlinux-game-client-{game_client_id}" if game_client_id is not None else "ptlinux-operation" ), daemon=True, ).start() def open_article(url: str) -> None: try: launch_external_url(url) except DashboardError as error: append_log(f"Could not open external link: {error}") show_themed_dialog( root, title="Open web link", message=str(error), kind="error", window_icon=window_icon, ) def scroll_news_view(event: object) -> str | None: if active_news_tab != "patches": return None delta = getattr(event, "delta", 0) button = getattr(event, "num", None) if isinstance(delta, int) and delta: direction = -1 if delta > 0 else 1 elif button == 4: direction = -1 elif button == 5: direction = 1 else: return None news_canvas.yview_scroll(direction * 3, "units") return "break" def make_news_card_clickable( card: tk.Frame, url: str, *, border_only: bool = False, ) -> None: def enter(_event: object) -> None: card.configure(highlightbackground="#e4a34c", highlightthickness=1) if not border_only: card.configure(background="#2a1e17") def leave(_event: object) -> None: card.configure(highlightbackground="#4a3528", highlightthickness=1) if not border_only: card.configure(background="#211916") def open_card(_event: object) -> None: open_article(url) def activate_card(_event: object) -> str: open_article(url) return "break" def bind_widget(widget: object) -> None: bind = getattr(widget, "bind") configure = getattr(widget, "configure") bind("", enter) bind("", leave) bind("", open_card) bind("", scroll_news_view, "+") bind("", scroll_news_view, "+") bind("", scroll_news_view, "+") configure(cursor="hand2") for child in getattr(widget, "winfo_children")(): bind_widget(child) card.configure(takefocus=True) card.bind("", enter) card.bind("", leave) card.bind("", activate_card) card.bind("", activate_card) bind_widget(card) def show_broadcast(broadcast: Broadcast | None) -> None: for child in broadcast_content.winfo_children(): child.destroy() if broadcast is None: ttk.Label( broadcast_content, text="No current broadcast is available.", style="Muted.TLabel", ).pack(anchor="w", padx=8, pady=6) return card = tk.Frame( broadcast_content, background="#211916", highlightbackground="#4a3528", highlightthickness=1, padx=10, pady=8, ) card.pack(fill="x") meta = " · ".join( part for part in (f"BROADCAST · {broadcast.author}", broadcast.published_at) if part ) tk.Label( card, text=meta, background="#211916", foreground="#e5b66c", font=("Sans", 9, "bold"), ).pack(anchor="w") tk.Label( card, text=broadcast.content, background="#211916", foreground="#e8ded2", font=("Sans", 10), wraplength=930, justify="left", anchor="w", ).pack(anchor="w", fill="x", pady=(3, 0)) make_news_card_clickable(card, broadcast.url) def layout_news_cards() -> None: nonlocal news_layout_columns columns = news_card_columns(max(1, root.winfo_width()), launcher_ui_scale) news_layout_columns = columns for column in range(MAX_NEWS_CARDS): news_content.grid_columnconfigure( column, weight=1 if column < columns else 0, uniform="news-card" if column < columns else "", ) for row in range(MAX_NEWS_CARDS): news_content.grid_rowconfigure(row, weight=0) for index, card in enumerate(news_card_widgets): row, column = divmod(index, columns) news_content.grid_rowconfigure(row, weight=1) card.grid( row=row, column=column, sticky="nsew", padx=3, pady=3, ) root.after_idle(update_news_viewport) def render_news(articles: tuple[NewsArticle, ...]) -> None: nonlocal rendered_news_articles visible_articles = articles[:MAX_NEWS_CARDS] if rendered_news_articles == visible_articles: return news_image_targets.clear() news_image_references.clear() news_card_widgets.clear() for child in news_content.winfo_children(): child.destroy() for column in range(MAX_NEWS_CARDS): news_content.grid_columnconfigure(column, weight=0, uniform="") for row in range(MAX_NEWS_CARDS): news_content.grid_rowconfigure(row, weight=0) if not visible_articles: ttk.Label( news_content, 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", ) rendered_news_articles = visible_articles return for index, article in enumerate(visible_articles): card = tk.Frame( news_content, background="#211916", highlightbackground="#4a3528", highlightthickness=1, height=news_card_height, padx=8, pady=8, ) card.grid_propagate(False) news_card_widgets.append(card) image_canvas = tk.Canvas( card, background="#211916", width=news_image_width, height=news_image_height, highlightthickness=0, borderwidth=0, ) image_canvas.pack(fill="x") image_canvas.create_text( news_image_width // 2, news_image_height // 2, text="Loading official artwork…" if article.image_url else "Official news", fill="#a8998a", font=("Sans", 9), tags=("centered-content",), ) image_canvas.bind( "", lambda event: event.widget.coords( "centered-content", max(1, int(event.width)) // 2, max(1, int(event.height)) // 2, ), ) if article.image_url: news_image_targets[article.id] = image_canvas ttk.Label( card, text=article.title, style="NewsTitle.TLabel", wraplength=news_image_width, justify="left", ).pack(anchor="w", fill="x", pady=(6, 0)) meta = article.published_at if meta: ttk.Label(card, text=meta, style="NewsMeta.TLabel").pack(anchor="w", pady=(2, 4)) tk.Label( card, text=article.summary, background="#211916", foreground="#c9baaa", font=("Sans", 9), wraplength=news_image_width, justify="left", height=3, anchor="nw", ).pack(anchor="w", fill="x") make_news_card_clickable(card, article.url) layout_news_cards() load_news_images(visible_articles) rendered_news_articles = visible_articles def show_news(articles: tuple[NewsArticle, ...]) -> None: render_news(articles) def append_patch_note(patch: PatchNote) -> Iterator[None]: card = tk.Frame( patches_content, background="#211916", highlightbackground="#4a3528", highlightthickness=1, padx=16, pady=13, ) card.pack(fill="x", padx=4, pady=(4, 9)) tk.Label( card, text=patch.title, background="#211916", foreground="#fff0d2", font=("Sans", 12, "bold"), wraplength=patch_card_wrap_length, justify="left", anchor="w", ).pack(anchor="w", fill="x") metadata = tk.Frame(card, background="#211916") metadata.pack(anchor="w", fill="x", pady=(7, 0)) tk.Label( metadata, text=f"VERSION {patch.version or '—'}", background="#30231d", foreground="#d8aa61", font=("Sans", 8, "bold"), padx=6, pady=2, ).pack(side="left", padx=(0, 7)) for tag in patch.tags or ("general",): tk.Label( metadata, text=tag.upper(), background="#2a201c", foreground="#d8aa61", font=("Sans", 7, "bold"), padx=6, pady=2, ).pack(side="left", padx=(0, 7)) if patch.published_at: tk.Label( metadata, text=patch.published_at, background="#211916", foreground="#c9baaa", font=("Sans", 8), ).pack(side="right") tk.Label( card, text=patch.summary or "No summary provided.", background="#211916", foreground="#ded1c4", font=("Sans", 10), wraplength=patch_card_wrap_length, justify="left", anchor="w", ).pack(anchor="w", fill="x", pady=(9, 3)) if not patch.sections: tk.Label( card, text="No detailed changes were published for this patch.", background="#211916", foreground="#ded1c4", font=("Sans", 9), ).pack(anchor="w", pady=(8, 0)) yield for section in patch.sections: tk.Label( card, text=section.title.upper(), background="#211916", foreground="#d8aa61", font=("Sans", 9, "bold"), ).pack(anchor="w", pady=(12, 5)) yield for change in section.items: change_row = tk.Frame(card, background="#211916") change_row.pack(anchor="w", fill="x", pady=3) badge_background, badge_foreground = patch_change_colors( change.type_key ) tk.Label( change_row, text=patch_change_tag(change.type_key), background=badge_background, foreground=badge_foreground, font=("Sans", 8, "bold"), width=14, anchor="center", padx=5, pady=2, ).pack(side="left", anchor="n", padx=(0, 8)) tk.Label( change_row, text=change.text, background="#211916", foreground="#ded1c4", font=("Sans", 10), wraplength=patch_change_wrap_length, justify="left", anchor="w", ).pack(side="left", anchor="w", fill="x", expand=True) yield make_news_card_clickable(card, patch.url, border_only=True) def render_patch_notes( 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: nonlocal active_news_tab if tab not in {"news", "patches"} or tab == active_news_tab: return active_news_tab = tab news_tab_button.configure( style="NewsTabActive.TButton" if tab == "news" else "NewsTab.TButton" ) patches_tab_button.configure( style=( "NewsTabActive.TButton" if tab == "patches" else "NewsTab.TButton" ) ) news_canvas.yview_moveto(0.0) if tab == "patches": news_content.grid_remove() patches_content.grid() news_scrollbar.grid() else: patches_content.grid_remove() news_content.grid() news_scrollbar.grid_remove() root.after_idle(update_news_viewport) def switch_news_tab_from_keyboard(tab: str, button: ttk.Button) -> str: switch_news_tab(tab) button.focus_set() return "break" news_tab_button.configure(command=lambda: switch_news_tab("news")) patches_tab_button.configure(command=lambda: switch_news_tab("patches")) 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 = ( read_cached_news_image(news_article, paths.news_image_cache) if startup_settings.low_bandwidth_mode else fetch_news_image(news_article, paths.news_image_cache) ) events.put(("news_image", (news_article.id, image))) 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 (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), tags=("centered-content",), ) return try: image = tk.PhotoImage(data=base64.b64encode(image_data), format="png") except tk.TclError: target.delete("all") target.create_text( news_image_width // 2, news_image_height // 2, text="Artwork unavailable", fill="#a8998a", font=("Sans", 8), tags=("centered-content",), ) return target_width = target.winfo_width() target_height = target.winfo_height() if target_width <= 1: target_width = news_image_width if target_height <= 1: target_height = news_image_height if image.width() < target_width or image.height() < target_height: factor = max( math.ceil(target_width / image.width()), math.ceil(target_height / image.height()), ) image = image.zoom(factor, factor) else: factor = min(image.width() // target_width, image.height() // target_height) if factor > 1: image = image.subsample(factor, factor) news_image_references[article_id] = image target.delete("all") target.create_image( target_width // 2, target_height // 2, image=image, tags=("centered-content",), ) 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), tags=("centered-content",), ) startup_news_images_pending.clear() def install_official_logo(value: object) -> None: if not isinstance(value, bytes): return try: image = tk.PhotoImage(data=base64.b64encode(value), format="png") except tk.TclError: return factor = max(1, math.ceil(image.width() / 42), math.ceil(image.height() / 42)) if factor > 1: image = image.subsample(factor, factor) logo_image_references.clear() logo_image_references.append(image) logo.delete("all") logo.create_image(21, 21, image=image) def load_official_logo() -> None: def worker() -> None: image = ( read_cached_official_logo(paths.logo_cache) if startup_settings.low_bandwidth_mode else fetch_official_logo(paths.logo_cache) ) events.put(("logo_image", image)) threading.Thread(target=worker, name="ptlinux-logo", daemon=True).start() def update_server_status_debug_text() -> None: value = "" if show_dashboard_debug: value = ( server_status_check_text(latest_server_status_check) if latest_server_status_check is not None else "status check pending" ) if server_status_debug_text.get() != value: server_status_debug_text.set(value) def render_server_status() -> None: update_server_status_debug_text() for child in server_status_content.winfo_children(): child.destroy() if latest_server_statuses is None: ttk.Label( server_status_content, text=server_status_placeholder, style="ServerUnknown.TLabel", ).pack(side="left", anchor="w") return statuses_by_name = { item.name.casefold(): item for item in latest_server_statuses } visible = [ statuses_by_name[option.name.casefold()] for option in available_start_options if option.name.casefold() in statuses_by_name ] if not visible: ttk.Label( server_status_content, text="Official status is unavailable for this realm.", style="ServerUnknown.TLabel", ).pack(side="left", anchor="w") return for index, server in enumerate(visible): presentation = server_status_presentation( server, show_debug_details=show_dashboard_debug, ) ttk.Label( server_status_content, text=presentation.text, style=presentation.style, ).pack( side="left", anchor="w", padx=(0 if index == 0 else 6, 0), ) def show_server_status( check: ServerStatusCheck, ) -> None: nonlocal latest_server_statuses, latest_server_status_check nonlocal server_status_placeholder latest_server_status_check = check latest_server_statuses = check.statuses server_status_placeholder = ( "Server status unavailable." if check.error is not None else "Official status is unavailable for this realm." ) render_server_status() def refresh_dashboard_debug_disk_state() -> None: nonlocal dashboard_runtime_debug, dashboard_verification_debug nonlocal dashboard_debug_state_error dashboard_debug_state_error = None try: settings = load_settings(paths) except (OSError, ValueError) as error: dashboard_runtime_debug = None dashboard_debug_state_error = f"settings unavailable: {error}" else: dashboard_runtime_debug = inspect_runtime_debug(paths, settings) try: dashboard_verification_debug = read_verification_debug(paths) except OSError as error: dashboard_verification_debug = None detail = f"verification state unavailable: {error}" dashboard_debug_state_error = ( f"{dashboard_debug_state_error}; {detail}" if dashboard_debug_state_error else detail ) def current_debug_start_option() -> DepotStartOption | None: selected_name = realm_value.get().casefold() return next( ( option for option in available_start_options if option.name.casefold() == selected_name ), None, ) def dashboard_debug_report() -> str: lines = ["PT-LINUX DASHBOARD DEBUG", ""] lines.extend(("SERVERS", "-------")) lines.append( server_status_check_text(latest_server_status_check) if latest_server_status_check is not None else "status check pending" ) if latest_server_statuses: lines.extend( server_status_presentation( server, show_debug_details=True, ).text for server in latest_server_statuses ) else: lines.append(server_status_placeholder) lines.extend(("", "LAUNCH", "------")) start_option = current_debug_start_option() lines.append(f"Realm: {realm_value.get() or 'unknown'}") lines.append( f"Executable: {start_option.path if start_option is not None else 'unknown'}" ) lines.append( f"Arguments: {start_option.arguments if start_option is not None else 'unknown'}" ) if official_snapshot is None: lines.append("Official depot: unavailable") else: lines.append( f"Official depot: build {official_snapshot.build} · " f"version {official_snapshot.version}" ) if latest_installation_status is None: lines.append("Installed depot: unchecked") else: local_build = latest_installation_status.local_build lines.append( f"Installed depot: {local_build if local_build is not None else 'unknown'}" ) client_states = game_clients.debug_states() active_runtime = next( ( client_runtime_debug[state.client_id] for state in client_states if state.client_id in client_runtime_debug ), dashboard_runtime_debug, ) lines.extend(("", "RUNTIME", "-------")) lines.append( "Source: active client launch snapshot" if client_states and active_runtime is not dashboard_runtime_debug else "Source: next-launch selection" ) if active_runtime is None: lines.append(dashboard_debug_state_error or "Runtime state unavailable") else: lines.append( f"Requested / selected: {active_runtime.requested_kind} / " f"{active_runtime.selected_kind or 'unavailable'}" ) lines.append(f"Runtime executable: {active_runtime.executable or 'unavailable'}") lines.append(f"Proton: {active_runtime.proton_path or 'not applicable'}") lines.append(f"Prefix: {active_runtime.prefix or 'unavailable'}") lines.append( f"Synchronization: {active_runtime.sync_configured} / " f"{active_runtime.sync_effective or 'runtime managed'}" ) lines.append( f"Prepared runtime: {active_runtime.prepared_kind or 'not recorded'} · " f"{active_runtime.prepared_at or 'time unavailable'}" ) if active_runtime.prepared_proton_path is not None: lines.append(f"Prepared Proton: {active_runtime.prepared_proton_path}") marker_match = active_runtime.marker_matches_selection lines.append( "Preparation marker matches selection: " + ( "yes" if marker_match is True else "no" if marker_match is False else "unknown" ) ) lines.append( f"DXVK D3D9 override: " f"{active_runtime.dxvk_d3d9_override or 'not recorded'}" ) lines.append(f"DXVK state cache: {paths.dxvk_cache}") if active_runtime.error: lines.append(f"Runtime selection error: {active_runtime.error}") lines.extend(("", "CLIENTS", "-------")) lines.append( f"Starting: {game_clients.starting_count} · " f"running: {game_clients.running_count}" ) if not client_states: lines.append("No active game clients") for state in client_states: pid = str(state.supervisor_pid) if state.supervisor_pid is not None else "pending" lines.append( f"Client {state.client_id}: {state.phase} · " f"{debug_elapsed_text(state.elapsed_seconds)} · supervisor PID {pid}" ) client_runtime = client_runtime_debug.get(state.client_id) if client_runtime is not None: lines.append( f" runtime {client_runtime.selected_kind or 'unavailable'} · " f"prefix {client_runtime.prefix or 'unavailable'}" ) if state.client_id == active_game_launch_id and operation_stage_total > 0: lines.append( f" launch stage {operation_stage}/{operation_stage_total} · " f"{operation_message or 'waiting'}" ) if operation_active and active_game_launch_id is None: lines.append( f"Launcher operation: {operation_stage}/{operation_stage_total} · " f"{operation_message or status.get()}" ) lines.extend(("", "VERIFICATION", "------------")) verification = dashboard_verification_debug if verification is None: lines.append(dashboard_debug_state_error or "Verification state unavailable") else: lines.append(f"Imported-tree trust: {verification.import_state}") if verification.import_verified_at is not None: lines.append( f"Import verified: build " f"{verification.import_build if verification.import_build is not None else 'unknown'} · " f"{verification.import_verified_at}" ) lines.append( f"Last full verification: " f"{verification.last_full_verification_at or 'not recorded'}" ) lines.append( f"Last depot sync: {verification.last_sync_at or 'not recorded'} · " f"{verification.mode or 'mode unknown'}" ) lines.append( f"Cached integrity: build " f"{verification.build if verification.build is not None else 'unknown'} · " f"version {verification.version or 'unknown'} · " f"{verification.files_total if verification.files_total is not None else '?'} files" ) lines.append( "Last sync changes: " f"downloaded {verification.files_downloaded if verification.files_downloaded is not None else '?'} · " f"deleted {verification.files_deleted if verification.files_deleted is not None else '?'} · " f"created {verification.files_created if verification.files_created is not None else '?'} · " f"quarantined {verification.files_quarantined if verification.files_quarantined is not None else '?'}" ) if latest_installation_status is not None: missing = latest_installation_status.missing_files lines.append( "Required files: complete" if not missing else "Required files missing: " + ", ".join(str(path) for path in missing) ) return "\n".join(lines) + "\n" def update_dashboard_debug_view(*, force: bool = False) -> None: nonlocal last_dashboard_debug_render if ( not show_dashboard_debug or dashboard_debug_output is None or not dashboard_debug_output.winfo_exists() ): return rendered = dashboard_debug_report() if not force and rendered == last_dashboard_debug_render: return position = dashboard_debug_output.yview()[0] dashboard_debug_output.configure(state="normal") dashboard_debug_output.delete("1.0", "end") dashboard_debug_output.insert("1.0", rendered) dashboard_debug_output.configure(state="disabled") dashboard_debug_output.yview_moveto(position) last_dashboard_debug_render = rendered def ensure_dashboard_debug_window() -> None: nonlocal dashboard_debug_window, dashboard_debug_output if dashboard_debug_window is not None and dashboard_debug_window.winfo_exists(): return window = tk.Toplevel(root) dashboard_debug_window = window window.title("PristonTale EU — Debug View") window.geometry("900x640") window.minsize(700, 440) window.configure(background="#0c0908") apply_window_icon(window) debug_header = tk.Frame(window, background="#24170f", padx=16, pady=12) debug_header.pack(fill="x") tk.Label( debug_header, text="DASHBOARD DEBUG VIEW", background="#24170f", foreground="#f5d18b", font=("Sans", 10, "bold"), ).pack(anchor="w") tk.Label( debug_header, text="Read-only live launcher, runtime, client, and trust state", background="#24170f", foreground="#c9baaa", font=("Sans", 9), ).pack(anchor="w", pady=(2, 0)) body = tk.Frame(window, background="#0c0908") body.pack(fill="both", expand=True, padx=12, pady=12) body.grid_rowconfigure(0, weight=1) body.grid_columnconfigure(0, weight=1) output = tk.Text( body, background="#100d0c", foreground="#ded1c4", insertbackground="#f8fafc", font=("Monospace", 10), wrap="none", state="disabled", padx=12, pady=10, relief="flat", ) dashboard_debug_output = output vertical = ttk.Scrollbar(body, orient="vertical", command=output.yview) horizontal = ttk.Scrollbar(body, orient="horizontal", command=output.xview) output.configure(yscrollcommand=vertical.set, xscrollcommand=horizontal.set) output.grid(row=0, column=0, sticky="nsew") vertical.grid(row=0, column=1, sticky="ns") horizontal.grid(row=1, column=0, sticky="ew") window.protocol("WM_DELETE_WINDOW", lambda: set_dashboard_debug(False)) def set_dashboard_debug(enabled: bool) -> None: nonlocal show_dashboard_debug, last_dashboard_debug_render show_dashboard_debug = enabled last_dashboard_debug_render = "" if enabled: refresh_dashboard_debug_disk_state() ensure_dashboard_debug_window() assert dashboard_debug_window is not None dashboard_debug_window.deiconify() dashboard_debug_window.lift() dashboard_debug_window.focus_set() else: if dashboard_debug_window is not None and dashboard_debug_window.winfo_exists(): dashboard_debug_window.withdraw() render_server_status() update_dashboard_debug_view(force=True) def toggle_dashboard_debug(_event: object | None = None) -> str: set_dashboard_debug(not show_dashboard_debug) return "break" root.bind_all( DASHBOARD_DEBUG_KEY, toggle_dashboard_debug, add="+", ) def install_realm_options(snapshot: DepotSnapshot) -> None: nonlocal available_start_options, preferred_realm catalog = launch_catalog_from_snapshot(snapshot) available_start_options = catalog.start_options selected = select_depot_start_option(catalog, preferred_realm) realm_selector.configure( values=tuple(option.name for option in available_start_options), state="disabled" if operation_active else "readonly", ) realm_value.set(selected.name) if selected.name != preferred_realm: preferred_realm = selected.name try: update_settings(paths, realm=selected.name) except (OSError, ValueError) as save_error: append_log(f"Could not save the selected realm: {save_error}") render_server_status() def on_realm_selected(_event: object | None = None) -> None: nonlocal preferred_realm selected_name = realm_value.get() selected = next( ( option for option in available_start_options if option.name.casefold() == selected_name.casefold() ), None, ) if selected is None: fallback = select_depot_start_option( launch_catalog_from_snapshot(official_snapshot) if official_snapshot is not None else None, preferred_realm, ) realm_value.set(fallback.name) return realm_value.set(selected.name) if preferred_realm == selected.name: return preferred_realm = selected.name try: update_settings(paths, realm=selected.name) except (OSError, ValueError) as save_error: append_log(f"Could not save the selected realm: {save_error}") realm_selector.bind("<>", on_realm_selected) def refresh_primary_action( snapshot: DepotSnapshot | None = None, *, installation: GameInstallationStatus | None = None, update_activity: bool = True, ) -> GameInstallationStatus: nonlocal official_snapshot, latest_installation_status if snapshot is not None: official_snapshot = snapshot if installation is None: installation = inspect_game_installation( paths, official_snapshot.build if official_snapshot is not None else None, compatibility_ready=( runtime_compatibility.compatible if runtime_compatibility is not None else None ), ) latest_installation_status = installation additional_client_ready = game_clients.running_count > 0 and installation.installed client_activity_text.set(game_client_activity_text(game_clients.running_count)) refresh_activity_meta_text() play_button.configure( text="PLAY" if additional_client_ready else installation.action.value ) if not operation_active: play_button.configure( state=( "disabled" if ( installation.action is PrimaryAction.CHECKING and not additional_client_ready ) else "normal" ), ) if not update_activity or operation_active: return installation if additional_client_ready: status.set("Ready for another account.") progress_text.set("Click Play to open another game window.") elif installation.action is PrimaryAction.INSTALL: status.set("Game is not installed.") progress_text.set("Click Install to download and verify the official game files.") elif installation.action is PrimaryAction.UPDATE: status.set("A game update is available.") official_build = installation.official_build if official_build is None: progress_text.set("Local game files need an official build check.") elif installation.local_build is None: progress_text.set( f"Local files need verification · official depot {official_build}" ) else: progress_text.set( f"Installed depot {installation.local_build} · " f"official depot {official_build}" ) elif installation.action is PrimaryAction.CHECKING: status.set("Checking game compatibility…") progress_text.set("The installed release is being inspected without modifying it.") elif installation.action is PrimaryAction.LAUNCHER_UPDATE: status.set("Launcher compatibility update required.") progress_text.set("This installed game release is not recognized by the launcher.") else: status.set("Ready") progress_text.set("Click Play to start your journey") return installation def refresh_runtime_compatibility(*, update_activity: bool = True) -> None: nonlocal compatibility_check_generation, runtime_compatibility compatibility_check_generation += 1 check_generation = compatibility_check_generation runtime_compatibility = None local_status = inspect_game_installation( 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): mark_startup_component_ready( "compatibility", "Game state ready", ) return def worker() -> None: result = inspect_runtime_compatibility(paths, paths.source_game) events.put( ( "compatibility", (check_generation, result, update_activity), ) ) threading.Thread( target=worker, name="ptlinux-compatibility", daemon=True, ).start() def show_launcher_update_check( check: LauncherUpdateCheck, *, log_reason: bool = True, ) -> None: nonlocal launcher_update_check launcher_update_check = check presentation = launcher_update_presentation(check) launcher_update_button.configure( text=presentation.button_text, style=presentation.button_style, state=( "disabled" if operation_active or game_clients.running_count else "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_badge.pack_forget() if log_reason: append_log(check.reason) call_optional(launcher_update_dialog_refresh) def refresh_launcher_update_check() -> None: def worker() -> None: try: check = check_for_launcher_update() except RuntimeError as update_error: events.put(("launcher_update_error", str(update_error))) else: cache_launcher_update_check(paths.launcher_update_check_cache, check) events.put(("launcher_update_check", check)) threading.Thread( target=worker, name="ptlinux-self-update", daemon=True, ).start() def refresh_remote_content() -> None: def broadcast_worker() -> None: events.put(("broadcast", fetch_latest_broadcast(paths.broadcast_cache))) def news_worker() -> None: events.put(("news", fetch_news(paths.news_cache))) def patches_worker() -> None: events.put(("patches", fetch_patch_notes(paths.patches_cache))) def server_status_worker() -> None: events.put(("server_status_check", perform_server_status_check())) def version_worker() -> None: try: snapshot = DepotClient().fetch_snapshot() except DepotError as remote_error: events.put(("version_error", str(remote_error))) else: try: cache_depot_launch_catalog(paths.depot_launch_catalog_cache, snapshot) except OSError as cache_error: events.put( ( "output", f"Could not cache official realm choices: {cache_error}\n", ) ) events.put(("version", snapshot)) if startup_settings.low_bandwidth_mode: append_log( "Low-bandwidth mode: using cached news, broadcasts, patch notes, " "and artwork." ) for component, message in ( ("broadcast", "Cached broadcast ready"), ("news", "Cached news ready"), ("patches", "Cached patch notes ready"), ("launcher_update", "Cached launcher update status ready"), ): mark_startup_component_ready(component, message) else: threading.Thread( target=broadcast_worker, name="ptlinux-broadcast", daemon=True, ).start() threading.Thread( target=news_worker, name="ptlinux-news", daemon=True, ).start() threading.Thread( target=patches_worker, name="ptlinux-patches", daemon=True, ).start() threading.Thread( target=server_status_worker, name="ptlinux-server-status", daemon=True, ).start() threading.Thread(target=version_worker, name="ptlinux-version", daemon=True).start() if not startup_settings.low_bandwidth_mode: refresh_launcher_update_check() def start_remote_content_refresh() -> None: nonlocal remote_refresh_started if remote_refresh_started: return remote_refresh_started = True refresh_remote_content() def release_game_launch_controls( client_id: int, *, ready: bool, returncode: int | None = None, cancelled: bool = False, ) -> None: nonlocal active_game_launch_id nonlocal operation_active, game_window_active nonlocal operation_refreshes_compatibility, operation_started_at if active_game_launch_id != client_id: return active_game_launch_id = None operation_active = False game_window_active = game_clients.running_count > 0 operation_refreshes_compatibility = False operation_started_at = None progress_updates.clear() transfer_rate.reset() speed_text.set("") set_download_context_text("") progress_bar.stop() close_game_preloader() if not ready: root.deiconify() set_busy(False) refresh_primary_action(update_activity=False) if ready: status.set("Ready for another account.") progress_text.set("Click Play to open another game window.") dashboard_chrome.minimize() elif cancelled: root.lift() status.set("Game launch cancelled.") progress_text.set("The initiated game and compatibility processes were stopped.") else: root.lift() status.set( f"Client {client_id} failed to start" + (f" (exit {returncode})" if returncode is not None else "") ) progress_text.set("See the activity log for details.") def finish_operation(returncode: int) -> None: nonlocal operation_active, game_window_active nonlocal operation_refreshes_compatibility, operation_started_at refresh_compatibility = operation_refreshes_compatibility operation_active = False game_window_active = game_clients.running_count > 0 operation_refreshes_compatibility = False operation_started_at = None progress_updates.clear() transfer_rate.reset() speed_text.set("") set_download_context_text("") progress_bar.stop() close_game_preloader() root.deiconify() if not game_window_active: root.lift() set_busy(False) if refresh_compatibility: refresh_runtime_compatibility(update_activity=returncode == 0) else: refresh_primary_action(update_activity=returncode == 0) if show_dashboard_debug: refresh_dashboard_debug_disk_state() update_dashboard_debug_view(force=True) if returncode != 0: status.set(f"Operation failed (exit {returncode})") progress_text.set("See the activity log for details.") def poll_events() -> None: nonlocal game_window_active, launcher_update_check, runtime_compatibility processed = 0 try: while processed < EVENT_BATCH_SIZE: kind, value = events.get_nowait() processed += 1 if kind == "output" and isinstance(value, str): append_log(value) elif kind == "done" and isinstance(value, int): finish_operation(value) elif kind == "launcher_update_done" and isinstance(value, int): finish_operation(value) if value == 0: append_log("Launcher update activated; restarting the dashboard.") try: restart_command, restart_environment = launcher_restart_spec(paths) subprocess.Popen( restart_command, env=restart_environment, close_fds=True, start_new_session=True, ) except OSError as restart_error: show_themed_dialog( root, title="Launcher update", message=( "The update was installed, but the launcher could not " f"restart: {restart_error}" ), kind="error", window_icon=window_icon, ) else: root.destroy() return elif kind == "operation_status" and isinstance(value, Mapping): step = _as_int(value.get("step")) total = _as_int(value.get("total")) message = value.get("message") if step > 0 and total > 0 and isinstance(message, str) and message: set_operation_stage(step, total, message) elif ( kind == "game_supervisor_started" and isinstance(value, tuple) and len(value) == 2 and isinstance(value[0], int) and isinstance(value[1], int) ): game_clients.attach_supervisor(value[0], value[1]) elif ( kind == "game_operation_status" and isinstance(value, tuple) and len(value) == 2 and isinstance(value[0], int) and isinstance(value[1], Mapping) and value[0] == active_game_launch_id and not game_launch_supervisors.is_cancel_requested(value[0]) ): step = _as_int(value[1].get("step")) total = _as_int(value[1].get("total")) message = value[1].get("message") if step > 0 and total > 0 and isinstance(message, str) and message: set_operation_stage(step, total, message) elif ( kind == "game_started" and isinstance(value, int) and not game_launch_supervisors.is_cancel_requested(value) ): launch_settings = client_launch_settings.get(value) if launch_settings is not None: client_runtime_debug[value] = inspect_runtime_debug( paths, launch_settings, ) progress_bar.stop() status.set(f"Game client {value} started; preparing its window…") elif ( kind == "game_window_ready" and isinstance(value, int) and not game_launch_supervisors.is_cancel_requested(value) ): if game_clients.mark_running(value): game_window_active = True release_game_launch_controls(value, ready=True) elif ( kind == "game_done" and isinstance(value, tuple) and len(value) == 2 and isinstance(value[0], int) and isinstance(value[1], int) ): client_id, returncode = value cancelled = game_launch_supervisors.finish(client_id) phase = game_clients.finish(client_id) client_runtime_debug.pop(client_id, None) client_launch_settings.pop(client_id, None) game_window_active = game_clients.running_count > 0 append_log(f"Client {client_id} exited with status {returncode}.") if phase == "starting": release_game_launch_controls( client_id, ready=False, returncode=returncode, cancelled=cancelled, ) elif phase == "running" and not operation_active: set_busy(False) refresh_primary_action(update_activity=False) count = game_clients.running_count if count: status.set("Ready for another account.") progress_text.set("Click Play to open another game window.") else: status.set("All game clients have closed.") progress_text.set("Click Play to start a new client.") root.deiconify() root.lift() if show_dashboard_debug: refresh_dashboard_debug_disk_state() elif kind == "broadcast": show_broadcast(value if isinstance(value, Broadcast) else None) mark_startup_component_ready( "broadcast", "Broadcast ready", ) elif kind == "server_status_check" and isinstance( value, ServerStatusCheck, ): show_server_status(value) if value.error is not None: append_log(f"Server status check unavailable: {value.error}") mark_startup_component_ready( "server_status", ( "Server status ready" if value.error is None else "Server status check complete" ), ) elif kind == "news" and isinstance(value, tuple): show_news(value) mark_startup_component_ready( "news", "News cards ready", ) elif ( kind == "patches" and isinstance(value, tuple) and all(isinstance(item, PatchNote) for item in value) ): show_patch_notes( value, on_complete=lambda: mark_startup_component_ready( "patches", "Patch notes ready", ), ) elif kind == "news_image": 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) and len(value) == 3 and isinstance(value[0], int) and isinstance(value[1], RuntimeCompatibility) and isinstance(value[2], bool) ): check_generation, checked, update_activity = value if check_generation == compatibility_check_generation: runtime_compatibility = checked if checked.compatible: append_log( "Compatibility check passed: " f"scene={checked.scene_action}, " f"binaries={checked.binary_action}" ) else: append_log( "Launcher compatibility update required: " 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}" ) install_realm_options(value) 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=( "disabled" if operation_active or game_clients.running_count else "normal" ), ) launcher_update_badge.pack_forget() append_log(f"Launcher update check unavailable: {value}") call_optional(launcher_update_dialog_error, value) mark_startup_component_ready( "launcher_update", "Launcher update check complete", ) except queue.Empty: pass except Exception as event_error: append_log( "Dashboard event processing recovered from an error: " f"{event_error}" ) if operation_active: latest_progress = progress_updates.take() if latest_progress is not None: update_progress(latest_progress) if speed_text.get() != "0 B/s" and transfer_rate.is_idle(): speed_text.set("0 B/s") update_elapsed_indicator() update_server_status_debug_text() update_dashboard_debug_view() 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(), ) ) root.after(poll_delay, poll_events) def command_for(*arguments: str) -> list[str]: return launcher_child_command(*arguments) def run_launcher_update(required_reason: str | None = None) -> None: nonlocal launcher_update_window nonlocal launcher_update_dialog_refresh, launcher_update_dialog_error if launcher_update_window is not None and launcher_update_window.winfo_exists(): launcher_update_window.deiconify() launcher_update_window.lift() launcher_update_window.focus_set() if launcher_update_dialog_refresh is not None: launcher_update_dialog_refresh() return dialog = tk.Toplevel(root) dialog.withdraw() launcher_update_window = dialog dialog.title("PristonTale EU — Launcher Updates") dialog.resizable(False, False) dialog.configure(background="#0c0908") dialog.transient(root) apply_window_icon(dialog) updates_chrome = create_window_chrome( dialog, title="PRISTONTALE EU", subtitle="LAUNCHER UPDATES", window_icon=window_icon, show_close=True, show_minimize=False, allow_close=True, ) updates_header = tk.Frame(updates_chrome.content, background="#24170f") updates_header.pack(fill="x") tk_accent = tk.Frame(updates_header, background="#d98927", width=5) tk_accent.pack(side="left", fill="y") header_copy = tk.Frame(updates_header, background="#24170f") header_copy.pack(side="left", fill="both", expand=True, padx=20, pady=14) tk.Label( header_copy, text="SIGNED STABLE CHANNEL", background="#24170f", foreground="#d8aa61", font=("Sans", 8, "bold"), ).pack(anchor="w") tk.Label( header_copy, text="Launcher Updates", background="#24170f", foreground="#fff0d2", font=("Georgia", 18, "bold"), ).pack(anchor="w", pady=(2, 0)) content = ttk.Frame( updates_chrome.content, style="Root.TFrame", padding=(16, 14, 16, 16), ) content.pack(fill="both", expand=True) content.columnconfigure(0, weight=1) content.rowconfigure(1, weight=1) status_value = tk.StringVar(value="Checking the signed update channel…") details_value = tk.StringVar(value="") overview = ttk.LabelFrame( content, text="UPDATE STATUS", style="SettingsCard.TLabelframe", padding=14, ) overview.grid(row=0, column=0, sticky="ew") overview.columnconfigure(0, weight=1) ttk.Label( overview, textvariable=status_value, style="SettingsText.TLabel", wraplength=round(480 * launcher_ui_scale), justify="left", ).grid(row=0, column=0, sticky="w") ttk.Label( overview, textvariable=details_value, style="SettingsHint.TLabel", wraplength=round(480 * launcher_ui_scale), justify="left", ).grid(row=1, column=0, sticky="w", pady=(6, 0)) release_notes_button = ttk.Button( overview, text="VIEW RELEASE NOTES", style="Secondary.TButton", state="disabled", ) release_notes_button.grid(row=0, column=1, rowspan=2, sticky="e", padx=(12, 0)) history_card = ttk.LabelFrame( content, text="INSTALLED HISTORY", style="SettingsCard.TLabelframe", padding=12, ) history_card.grid(row=1, column=0, sticky="nsew", pady=(12, 0)) history_card.columnconfigure(0, weight=1) history_card.rowconfigure(0, weight=1) history = ttk.Treeview( history_card, columns=("version", "state", "installed"), show="headings", height=6, style="Update.Treeview", selectmode="browse", ) history.heading("version", text="VERSION", anchor="w") history.heading("state", text="STATE", anchor="w") history.heading("installed", text="INSTALLED", anchor="w") history.column( "version", width=round(120 * launcher_ui_scale), minwidth=round(80 * launcher_ui_scale), stretch=False, anchor="w", ) history.column( "state", width=round(150 * launcher_ui_scale), minwidth=round(100 * launcher_ui_scale), stretch=False, anchor="w", ) history.column( "installed", width=round(360 * launcher_ui_scale), minwidth=round(180 * launcher_ui_scale), stretch=True, anchor="w", ) history.grid(row=0, column=0, sticky="nsew") history_scrollbar = ttk.Scrollbar( history_card, orient="vertical", style="News.Vertical.TScrollbar", command=history.yview, ) history_scrollbar.grid(row=0, column=1, sticky="ns", padx=(6, 0)) history.configure(yscrollcommand=history_scrollbar.set) footer = ttk.Frame(content, style="SettingsFooter.TFrame", padding=(12, 9)) footer.grid(row=2, column=0, sticky="ew", pady=(12, 0)) footer.columnconfigure(0, weight=1) check_button = ttk.Button( footer, text="CHECK AGAIN", style="Secondary.TButton", ) check_button.grid(row=0, column=0, sticky="w") rollback_button = ttk.Button( footer, text="ROLL BACK & RESTART", style="Secondary.TButton", state="disabled", ) rollback_button.grid(row=0, column=1, sticky="e", padx=(8, 0)) install_button = ttk.Button( footer, text="INSTALL & RESTART", style="Play.TButton", state="disabled", ) install_button.grid(row=0, column=2, sticky="e", padx=(8, 0)) close_button = ttk.Button( footer, text="CLOSE", style="Secondary.TButton", ) close_button.grid(row=0, column=3, sticky="e", padx=(8, 0)) installed_history: LauncherUpdateHistory | None = None def close_updates() -> None: nonlocal launcher_update_window nonlocal launcher_update_dialog_refresh, launcher_update_dialog_error launcher_update_dialog_refresh = None launcher_update_dialog_error = None if launcher_update_window is not None and launcher_update_window.winfo_exists(): launcher_update_window.destroy() launcher_update_window = None def refresh_update_dialog() -> None: nonlocal installed_history check = launcher_update_check try: installed_history = inspect_launcher_update_history(paths) except RuntimeError as error: installed_history = None append_log(f"Could not inspect launcher update history: {error}") for item in history.get_children(): history.delete(item) if installed_history is not None: for release in installed_history.releases: state_label = ( "Current" if release.active else "Rollback target" if release.previous else "Retained" ) history.insert( "", "end", values=(release.version, state_label, release.installed_at), ) if not history.get_children(): history.insert("", "end", values=(__version__, "Running", "Not recorded")) rollback_available = ( installed_history is not None and installed_history.previous_version is not None and not operation_active and not game_clients.running_count ) rollback_button.configure( state="normal" if rollback_available else "disabled" ) check_button.configure( state=( "disabled" if operation_active or game_clients.running_count else "normal" ) ) if check is None: status_value.set("Checking the signed update channel…") details_value.set( "Installed launcher " + __version__ + " · release information pending" ) install_button.configure(state="disabled") release_notes_button.configure(state="disabled") return status_prefix = ( f"Compatibility update required: {required_reason}\n" if required_reason is not None else "" ) status_value.set(status_prefix + check.reason) details_value.set( f"Current {check.current_version} · Latest {check.latest_version} · " f"{check.manifest.channel.title()} channel · " f"{_format_bytes(check.manifest.archive_size)} · " f"published {check.manifest.published_at} · " f"checked {check.checked_at or 'time unavailable'}" ) install_button.configure( state=( "normal" if check.available and check.supported and not operation_active and not game_clients.running_count else "disabled" ) ) release_notes_button.configure( state="normal" if check.manifest.notes_url is not None else "disabled" ) def show_update_error(message: str) -> None: status_value.set("The signed update channel could not be checked.") details_value.set(message) check_button.configure(state="normal") def check_again() -> None: status_value.set("Checking the signed update channel…") details_value.set("Authenticating the stable manifest and signature.") check_button.configure(state="disabled") install_button.configure(state="disabled") refresh_launcher_update_check() def view_release_notes() -> None: check = launcher_update_check if check is not None and check.manifest.notes_url is not None: open_article(check.manifest.notes_url) def install_update() -> None: check = launcher_update_check if check is None or not check.available or not check.supported: return if not show_themed_dialog( dialog, title="Update launcher", message=( f"Install signed launcher {check.latest_version} " f"({_format_bytes(check.manifest.archive_size)}) and restart now?" ), kind="question", confirm=True, accept_text="INSTALL & RESTART", window_icon=window_icon, ): return version_to_install = check.latest_version close_updates() run_operation( f"Installing launcher {version_to_install}…", command_for("launcher-update"), restart_on_success=True, ) def rollback_update() -> None: history_snapshot = installed_history if history_snapshot is None or history_snapshot.previous_version is None: return if not show_themed_dialog( dialog, title="Roll back launcher", message=( f"Switch from launcher {history_snapshot.current_version or __version__} " f"to {history_snapshot.previous_version} and restart?\n\n" "The current release remains available as the next rollback target." ), kind="warning", confirm=True, accept_text="ROLL BACK & RESTART", window_icon=window_icon, ): return target_version = history_snapshot.previous_version close_updates() run_operation( f"Rolling back launcher to {target_version}…", command_for("launcher-update", "--rollback"), restart_on_success=True, ) release_notes_button.configure(command=view_release_notes) check_button.configure(command=check_again) rollback_button.configure(command=rollback_update) install_button.configure(command=install_update) close_button.configure(command=close_updates) updates_chrome.set_close_command(close_updates) dialog.protocol("WM_DELETE_WINDOW", close_updates) dialog.bind("", lambda _event: close_updates()) launcher_update_dialog_refresh = refresh_update_dialog launcher_update_dialog_error = show_update_error refresh_update_dialog() if launcher_update_check is None: check_again() dialog.update_idletasks() updates_width, updates_height = fitted_window_size( 760, 620, scale=launcher_ui_scale, screen_width=dialog.winfo_screenwidth(), screen_height=dialog.winfo_screenheight(), ) dialog.geometry( centered_window_geometry( updates_width, updates_height, parent_x=root.winfo_rootx(), parent_y=root.winfo_rooty(), parent_width=root.winfo_width(), parent_height=root.winfo_height(), ) ) dialog.deiconify() dialog.lift() history.focus_set() launcher_update_button.configure(command=run_launcher_update) def start_game_client() -> None: nonlocal active_game_launch_id if official_snapshot is not None: try: cache_depot_launch_catalog( paths.depot_launch_catalog_cache, official_snapshot, ) except OSError as cache_error: show_themed_dialog( root, title="Realm selection", message=( "The launcher could not save the official realm selection:\n\n" f"{cache_error}" ), kind="error", window_icon=window_icon, ) return launch_settings: Settings | None = None runtime_debug: RuntimeDebugInfo | None = None try: loaded_launch_settings = load_settings(paths) except (OSError, ValueError) as settings_error: append_log(f"Could not inspect launch runtime settings: {settings_error}") else: launch_settings = loaded_launch_settings runtime_debug = inspect_runtime_debug(paths, loaded_launch_settings) client_id = game_clients.begin() if runtime_debug is not None and launch_settings is not None: client_runtime_debug[client_id] = runtime_debug client_launch_settings[client_id] = launch_settings active_game_launch_id = client_id run_operation( f"Starting PristonTale EU {realm_value.get()} client {client_id}…", command_for("play", "--realm", realm_value.get()), show_preloader=True, game_client_id=client_id, ) def synchronize_game_files( installation: GameInstallationStatus | None = None, *, repair: bool = False, ) -> None: current = installation or refresh_primary_action(update_activity=False) presentation = game_sync_presentation( installed=current.installed, repair=repair, ) command = ( command_for("update", "--no-import", "--repair") if repair else command_for("update") ) run_operation( presentation.operation_label, command, game_files_may_change=True, file_progress_verb=presentation.file_progress_verb, ) def run_primary_action() -> None: installation = refresh_primary_action(update_activity=False) if ( installation.action is PrimaryAction.PLAY or (game_clients.running_count > 0 and installation.installed) ): start_game_client() elif installation.action in {PrimaryAction.INSTALL, PrimaryAction.UPDATE}: synchronize_game_files(installation) elif installation.action is PrimaryAction.LAUNCHER_UPDATE: run_launcher_update( runtime_compatibility.reason if runtime_compatibility is not None else "The installed release profile is not recognized.", ) play_button.configure(command=run_primary_action) update_button.configure(command=synchronize_game_files) repair_button.configure(command=lambda: synchronize_game_files(repair=True)) doctor_button.configure( command=lambda: run_operation("Checking the system…", command_for("doctor")) ) def open_settings() -> None: nonlocal settings_window if settings_window is not None and settings_window.winfo_exists(): settings_window.deiconify() settings_window.lift() settings_window.focus_set() return try: current_settings = ensure_settings(paths) except (OSError, ValueError) as error: show_themed_dialog( root, title="Settings", message=f"The launcher could not load its settings:\n\n{error}", kind="error", window_icon=window_icon, ) return dialog = tk.Toplevel(root) dialog.withdraw() settings_window = dialog dialog.title("PristonTale EU — Settings") dialog.resizable(False, False) dialog.configure(background="#0c0908") dialog.transient(root) apply_window_icon(dialog) settings_chrome = create_window_chrome( dialog, title="PRISTONTALE EU", subtitle="LAUNCHER SETTINGS", window_icon=window_icon, show_close=True, show_minimize=False, allow_close=True, ) settings_header = tk.Frame( settings_chrome.content, background="#24170f", ) settings_header.pack(fill="x") tk.Frame(settings_header, background="#d98927", width=5).pack(side="left", fill="y") header_copy = tk.Frame(settings_header, background="#24170f") header_copy.pack(side="left", fill="both", expand=True, padx=20, pady=14) tk.Label( header_copy, text="PRISTONTALE EU", background="#24170f", foreground="#e5b66c", font=("Sans", 9, "bold"), ).pack(anchor="w") tk.Label( header_copy, text="LAUNCHER SETTINGS", background="#24170f", foreground="#fff0d2", font=("Georgia", 18, "bold"), ).pack(anchor="w", pady=(1, 0)) tk.Label( header_copy, text=( "Tune launcher accessibility, downloads, the game window, and runtime " "preferences." ), background="#24170f", foreground="#c9baaa", font=("Sans", 9), ).pack(anchor="w", pady=(2, 0)) content = ttk.Frame( settings_chrome.content, style="Root.TFrame", padding=(16, 14, 16, 16), ) content.pack(fill="both", expand=True) content.columnconfigure(0, weight=1, uniform="settings-card") content.columnconfigure(1, weight=1, uniform="settings-card") borderless_value = tk.BooleanVar(value=current_settings.borderless) ui_scale_value = tk.StringVar( value=setting_label(current_settings.ui_scale, UI_SCALE_LABELS) ) suppress_unresponsive_value = tk.BooleanVar( value=current_settings.suppress_unresponsive_warnings ) runtime_value = tk.StringVar( value=setting_label(current_settings.runtime, RUNTIME_LABELS) ) proton_sync_value = tk.StringVar( value=setting_label(current_settings.proton_sync, PROTON_SYNC_LABELS) ) proton_path_value = tk.StringVar(value=current_settings.proton_path or "") game_source_value = tk.StringVar(value=current_settings.game_source or "") download_speed_value = tk.StringVar( value=download_speed_setting_label( current_settings.download_speed_limit_mbps ) ) custom_download_speed_value = tk.StringVar( value=( f"{current_settings.download_speed_limit_mbps:g}" if current_settings.download_speed_limit_mbps is not None else "5" ) ) low_bandwidth_value = tk.BooleanVar( value=current_settings.low_bandwidth_mode ) def add_choice( parent: ttk.LabelFrame, row: int, label: str, description: str, variable: tk.StringVar, choices: tuple[str, ...], ) -> None: ttk.Label(parent, text=label, style="SettingsText.TLabel").grid( row=row, column=0, sticky="w", ) ttk.Label(parent, text=description, style="SettingsHint.TLabel").grid( row=row, column=1, sticky="e", ) ttk.Combobox( parent, textvariable=variable, values=choices, state="readonly", style="Settings.TCombobox", ).grid(row=row + 1, column=0, columnspan=2, sticky="ew", pady=(3, 9)) def add_path( parent: ttk.LabelFrame, row: int, label: str, description: str, variable: tk.StringVar, browse: Callable[[], None], ) -> None: ttk.Label(parent, text=label, style="SettingsText.TLabel").grid( row=row, column=0, sticky="w", ) ttk.Label(parent, text=description, style="SettingsHint.TLabel").grid( row=row, column=1, sticky="e", ) ttk.Entry(parent, textvariable=variable, style="Settings.TEntry").grid( row=row + 1, column=0, columnspan=1, sticky="ew", pady=(3, 8), ) ttk.Button(parent, text="BROWSE", style="Secondary.TButton", command=browse).grid( row=row + 1, column=1, sticky="e", padx=(8, 0), pady=(3, 8), ) def choose_proton_path() -> None: selected = filedialog.askopenfilename( parent=dialog, title="Choose the Proton script", ) if selected: proton_path_value.set(selected) def choose_game_source() -> None: selected = filedialog.askdirectory( parent=dialog, title="Choose an existing PristonTale EU game directory", mustexist=True, ) if selected: game_source_value.set(selected) def close_settings() -> None: nonlocal settings_window if settings_window is not None and settings_window.winfo_exists(): settings_window.destroy() settings_window = None settings_chrome.set_close_command(close_settings) def save_current_settings() -> None: try: download_speed_limit = download_speed_setting_value( download_speed_value.get(), custom_download_speed_value.get(), ) updated_settings = update_settings( paths, borderless=borderless_value.get(), ui_scale=setting_value(ui_scale_value.get(), UI_SCALE_LABELS), suppress_unresponsive_warnings=suppress_unresponsive_value.get(), runtime=setting_value(runtime_value.get(), RUNTIME_LABELS), proton_sync=setting_value( proton_sync_value.get(), PROTON_SYNC_LABELS, ), proton_path=proton_path_value.get().strip() or None, game_source=game_source_value.get().strip() or None, download_speed_limit_mbps=download_speed_limit, low_bandwidth_mode=low_bandwidth_value.get(), ) except (OSError, ValueError) as error: show_themed_dialog( dialog, title="Settings", message=str(error), kind="error", window_icon=window_icon, ) return saved_limit = ( f"{updated_settings.download_speed_limit_mbps:g} MB/s" if updated_settings.download_speed_limit_mbps is not None else "unlimited" ) append_log( f"Saved launcher settings to {paths.settings_file}: " f"runtime={updated_settings.runtime}, " f"proton_sync={updated_settings.proton_sync}, " f"download_limit={saved_limit}, " f"low_bandwidth={updated_settings.low_bandwidth_mode}" ) status.set("Settings saved.") progress_text.set( "Game and download settings apply to the next operation; launcher scale " "and cached-content mode apply after restart." ) if show_dashboard_debug: refresh_dashboard_debug_disk_state() update_dashboard_debug_view(force=True) close_settings() display_card = ttk.LabelFrame( content, text="GAME WINDOW", style="SettingsCard.TLabelframe", padding=14, ) display_card.grid(row=0, column=0, sticky="nsew", padx=(0, 6)) display_card.columnconfigure(0, weight=1) display_card.columnconfigure(1, weight=1) ttk.Label( display_card, text=( "The launcher keeps the game window's title bar by default. Enable " "Borderless only when you want to remove its host decorations." ), style="SettingsHint.TLabel", wraplength=360, ).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11)) presentation = ttk.Frame(display_card, style="SettingsCard.TFrame") presentation.grid(row=1, column=0, columnspan=2, sticky="w") ttk.Checkbutton( presentation, text="Remove title bar and borders (Borderless)", variable=borderless_value, style="Settings.TCheckbutton", ).pack(anchor="w") ttk.Checkbutton( presentation, text="Suppress false 'not responding' warnings", variable=suppress_unresponsive_value, style="Settings.TCheckbutton", ).pack(anchor="w", pady=(7, 0)) add_choice( display_card, 2, "Launcher size", "Applies after launcher restart", ui_scale_value, tuple(UI_SCALE_LABELS), ) rendering_card = ttk.LabelFrame( content, text="RUNTIME", style="SettingsCard.TLabelframe", padding=14, ) rendering_card.grid(row=0, column=1, sticky="nsew", padx=(6, 0)) rendering_card.columnconfigure(0, weight=1) rendering_card.columnconfigure(1, weight=1) ttk.Label( rendering_card, text="Choose which compatibility runtime launches the proven game setup.", style="SettingsHint.TLabel", wraplength=360, ).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11)) add_choice( rendering_card, 1, "Runtime", "Compatibility layer", runtime_value, tuple(RUNTIME_LABELS), ) add_choice( rendering_card, 3, "Proton synchronization", "Automatic selects the compatible mode", proton_sync_value, tuple(PROTON_SYNC_LABELS), ) downloads_card = ttk.LabelFrame( content, text="DOWNLOADS", style="SettingsCard.TLabelframe", padding=14, ) downloads_card.grid( row=1, column=0, columnspan=2, sticky="ew", pady=(12, 0), ) downloads_card.columnconfigure(0, weight=1) downloads_card.columnconfigure(1, weight=1) downloads_card.columnconfigure(2, weight=1) ttk.Label( downloads_card, text=( "Concurrency adapts to CDN latency, retries, transfer and disk speed, " "CPU load, and the remaining files." ), style="SettingsHint.TLabel", ).grid(row=0, column=0, columnspan=3, sticky="w", pady=(0, 9)) ttk.Checkbutton( downloads_card, text="Low-bandwidth mode (cached news, up to 2 workers and 5 MB/s)", variable=low_bandwidth_value, style="Settings.TCheckbutton", ).grid(row=1, column=0, sticky="w", padx=(0, 16)) ttk.Label( downloads_card, text="Speed limit", style="SettingsText.TLabel", ).grid(row=1, column=1, sticky="w") ttk.Label( downloads_card, text="Custom MB/s", style="SettingsText.TLabel", ).grid(row=1, column=2, sticky="w", padx=(8, 0)) download_speed_selector = ttk.Combobox( downloads_card, textvariable=download_speed_value, values=tuple(DOWNLOAD_SPEED_PRESETS_MBPS) + ( DOWNLOAD_SPEED_CUSTOM_LABEL, ), state="readonly", style="Settings.TCombobox", ) download_speed_selector.grid( row=2, column=1, sticky="ew", pady=(3, 0), ) custom_download_speed = ttk.Entry( downloads_card, textvariable=custom_download_speed_value, style="Settings.TEntry", width=12, ) custom_download_speed.grid( row=2, column=2, sticky="ew", padx=(8, 0), pady=(3, 0), ) def update_custom_download_state(_event: object | None = None) -> None: custom_download_speed.configure( state=( "normal" if download_speed_value.get() == DOWNLOAD_SPEED_CUSTOM_LABEL else "disabled" ) ) download_speed_selector.bind( "<>", update_custom_download_state, ) update_custom_download_state() overrides_card = ttk.LabelFrame( content, text="OPTIONAL OVERRIDES", style="SettingsCard.TLabelframe", padding=14, ) overrides_card.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(12, 0)) overrides_card.columnconfigure(0, weight=1) ttk.Label( overrides_card, text="Leave these blank for automatic runtime and managed-game detection.", style="SettingsHint.TLabel", ).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11)) add_path( overrides_card, 1, "Proton script", "Use a specific Proton executable", proton_path_value, choose_proton_path, ) add_path( overrides_card, 3, "Import source", "Seed a new managed installation only", game_source_value, choose_game_source, ) footer = ttk.Frame(content, style="SettingsFooter.TFrame", padding=(12, 9)) footer.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(12, 0)) footer.columnconfigure(0, weight=1) ttk.Label( footer, text="Only the settings shown here affect the next launcher operation.", style="SettingsFooter.TLabel", ).grid(row=0, column=0, sticky="w") buttons_row = ttk.Frame(footer, style="SettingsFooter.TFrame") buttons_row.grid(row=0, column=1, sticky="e") ttk.Button( buttons_row, text="CANCEL", style="Secondary.TButton", command=close_settings, ).pack(side="left") ttk.Button( buttons_row, text="SAVE SETTINGS", style="Play.TButton", command=save_current_settings, ).pack(side="left", padx=(8, 0)) dialog.protocol("WM_DELETE_WINDOW", close_settings) dialog.bind("", lambda _event: close_settings()) dialog.bind("", lambda _event: save_current_settings()) dialog.update_idletasks() settings_width, settings_height = fitted_window_size( 842, 880, scale=launcher_ui_scale, screen_width=dialog.winfo_screenwidth(), screen_height=dialog.winfo_screenheight(), ) dialog.geometry( centered_window_geometry( settings_width, settings_height, parent_x=root.winfo_rootx(), parent_y=root.winfo_rooty(), parent_width=root.winfo_width(), parent_height=root.winfo_height(), ) ) dialog.deiconify() dialog.lift() dialog.grab_set() dialog.focus_set() settings_button.configure(command=open_settings) def choose_import_source() -> None: source = filedialog.askdirectory( title="Choose an existing PristonTale EU game directory", mustexist=True, ) if not source: return run_operation( "Importing the existing game into the managed installation…", command_for("import-game", source), game_files_may_change=True, ) import_button.configure(command=choose_import_source) log_toggle.configure(command=toggle_log) def close_dashboard() -> None: # Keep draining each client process while it owns a shared runtime lease. # Closing the window therefore minimizes the dashboard until all clients # and any active launcher operation have finished. if operation_active or game_clients.active_count: set_dashboard_debug(False) dashboard_chrome.minimize() return root.destroy() dashboard_chrome.set_close_command(close_dashboard) root.protocol("WM_DELETE_WINDOW", close_dashboard) def update_news_viewport(_event: object | None = None) -> None: news_canvas.configure(scrollregion=news_canvas.bbox("all")) def resize_news_content(event: object) -> None: width = getattr(event, "width", 0) if isinstance(width, int) and width > 0: news_canvas.itemconfigure(news_window, width=width) def apply_responsive_layout() -> None: nonlocal controls_are_stacked, responsive_layout_after responsive_layout_after = None width = max(1, root.winfo_width()) stacked = dashboard_controls_stacked(width, launcher_ui_scale) if stacked != controls_are_stacked: controls_are_stacked = stacked if stacked: game_actions.grid_configure( row=0, column=0, columnspan=2, padx=0, pady=(0, 4), ) system_actions.grid_configure( row=1, column=0, columnspan=2, padx=0, pady=(4, 0), ) else: game_actions.grid_configure( row=0, column=0, columnspan=1, padx=(0, 4), pady=0, ) system_actions.grid_configure( row=0, column=1, columnspan=1, padx=(4, 0), pady=0, ) if news_card_columns(width, launcher_ui_scale) != news_layout_columns: layout_news_cards() def schedule_responsive_layout(event: object | None = None) -> None: nonlocal responsive_layout_after if event is not None and getattr(event, "widget", root) is not root: return if responsive_layout_after is not None: try: root.after_cancel(responsive_layout_after) except tk.TclError: pass responsive_layout_after = root.after_idle(apply_responsive_layout) def invoke_shortcut(button: ttk.Button) -> str: button.invoke() return "break" def scroll_news_keyboard(units: int) -> str: news_canvas.yview_scroll(units, "units") return "break" news_content_host.bind("", update_news_viewport) news_content.bind("", update_news_viewport) patches_content.bind("", update_news_viewport) news_canvas.bind("", resize_news_content) news_canvas.bind("", lambda _event: news_canvas.focus_set(), add="+") news_canvas.bind("", lambda _event: scroll_news_keyboard(-1)) news_canvas.bind("", lambda _event: scroll_news_keyboard(1)) news_canvas.bind("", lambda _event: scroll_news_keyboard(-8)) news_canvas.bind("", lambda _event: scroll_news_keyboard(8)) news_canvas.bind("", lambda _event: news_canvas.yview_moveto(0.0)) news_canvas.bind("", lambda _event: news_canvas.yview_moveto(1.0)) news_tab_button.bind( "", lambda _event: switch_news_tab_from_keyboard("patches", patches_tab_button), ) patches_tab_button.bind( "", lambda _event: switch_news_tab_from_keyboard("news", news_tab_button), ) root.bind("", lambda _event: invoke_shortcut(play_button)) root.bind("", lambda _event: invoke_shortcut(update_button)) root.bind("", lambda _event: invoke_shortcut(repair_button)) root.bind("", lambda _event: invoke_shortcut(import_button)) root.bind("", lambda _event: invoke_shortcut(doctor_button)) root.bind("", lambda _event: invoke_shortcut(settings_button)) root.bind("", lambda _event: invoke_shortcut(launcher_update_button)) root.bind("", lambda _event: invoke_shortcut(log_toggle)) root.bind("", schedule_responsive_layout, add="+") activity_window.bind("", lambda _event: hide_activity_window()) for scroll_widget in ( news_canvas, news_content_host, news_content, patches_content, ): scroll_widget.bind("", scroll_news_view, add="+") scroll_widget.bind("", scroll_news_view, add="+") scroll_widget.bind("", scroll_news_view, add="+") apply_responsive_layout() 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)) show_patch_notes(read_cached_patch_notes(paths.patches_cache)) cached_launcher_update = read_cached_launcher_update_check( paths.launcher_update_check_cache ) if cached_launcher_update is not None: show_launcher_update_check(cached_launcher_update, log_reason=False) refresh_runtime_compatibility() mark_startup_component_ready( "cached_content", "Cached dashboard content ready", ) poll_events() load_official_logo() update_startup_progress("Loading online dashboard content…") startup_overlay.update_idletasks() if not startup_transition_started: startup_timeout_after = root.after( DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS, timeout_startup_preloader, ) maybe_start_dashboard_transition() try: root.mainloop() except KeyboardInterrupt: try: root.destroy() except tk.TclError: pass def _as_int(value: object) -> int: return value if isinstance(value, int) and value >= 0 else 0 def _as_float(value: object) -> float | None: if isinstance(value, bool) or not isinstance(value, (int, float)): return None number = float(value) return number if math.isfinite(number) and number >= 0 else None def dashboard_poll_interval( *, operation_active: bool, game_window_active: bool, pending_events: bool, ) -> int: """Choose a responsive poll rate while avoiding busy idle/game polling.""" if pending_events: return POLL_INTERVAL_MILLISECONDS if operation_active and not game_window_active: return ACTIVE_POLL_INTERVAL_MILLISECONDS return IDLE_POLL_INTERVAL_MILLISECONDS def _format_bytes(value: int) -> str: units = ("B", "KiB", "MiB", "GiB") amount = float(value) for unit in units: if amount < 1024 or unit == units[-1]: return f"{amount:.1f} {unit}" if unit != "B" else f"{int(amount)} {unit}" amount /= 1024 return f"{value} B" def format_transfer_rate(bytes_per_second: float) -> str: return f"{_format_bytes(max(0, round(bytes_per_second)))}/s" def format_eta(seconds: float | None) -> str: if seconds is None or not math.isfinite(seconds) or seconds < 0: return "calculating…" total_seconds = max(0, math.ceil(seconds)) hours, remainder = divmod(total_seconds, 60 * 60) minutes, seconds = divmod(remainder, 60) if hours: return f"{hours}h {minutes:02d}m" if minutes: return f"{minutes}m {seconds:02d}s" return f"{seconds}s"