Files
pristontale-eu-linux/ptlinux/ui.py

2976 lines
85 KiB
Python

"""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 subprocess
import sys
import threading
import time
import webbrowser
from typing import Callable, Mapping
from . import __version__
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 .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,
)
from .overlay import RuntimeCompatibility, inspect_runtime_compatibility
from .paths import AppPaths
from .settings import ensure_settings, update_settings
from .self_update import (
LauncherUpdateCheck,
cache_launcher_update_check,
check_for_launcher_update,
read_cached_launcher_update_check,
)
from .server_status import (
RealmServerStatus,
ServerStatusError,
fetch_server_status,
)
MAX_NEWS_CARDS = 3
WINDOW_SIZE = "1000x750"
NEWS_CARD_HEIGHT = 242
NEWS_IMAGE_WIDTH = 292
NEWS_IMAGE_HEIGHT = 118
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 = 650
DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS = 10_000
DASHBOARD_TRANSITION_STEP_MILLISECONDS = 16
DASHBOARD_TRANSITION_STEPS = 9
DASHBOARD_TRANSITION_MINIMUM_OPACITY = 0.18
PRELOADER_STATUS_CHARACTERS = 64
DASHBOARD_STARTUP_COMPONENTS = frozenset(
{
"broadcast",
"compatibility",
"launcher_update",
"logo",
"news",
"news_artwork",
"server_status",
"version",
}
)
@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
class DashboardError(RuntimeError):
"""Raised when the native dashboard cannot be displayed."""
class GameClientTracker:
"""Track dashboard-launched clients without retaining process objects in Tk."""
def __init__(self) -> None:
self._next_client_id = 1
self._starting: set[int] = set()
self._running: set[int] = set()
def begin(self) -> int:
client_id = self._next_client_id
self._next_client_id += 1
self._starting.add(client_id)
return client_id
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)
return True
def finish(self, client_id: int) -> str | None:
if client_id in self._starting:
self._starting.remove(client_id)
return "starting"
if client_id in self._running:
self._running.remove(client_id)
return "running"
return None
@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 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 server_status_presentation(status: RealmServerStatus) -> ServerStatusPresentation:
if status.online is True:
players = (
f" · {status.players_online} online"
if status.players_online is not None
else " · online"
)
return ServerStatusPresentation(
text=f"● {status.name}{players}",
style="ServerOnline.TLabel",
)
if status.online is False:
return ServerStatusPresentation(
text=f"● {status.name} · offline",
style="ServerOffline.TLabel",
)
return ServerStatusPresentation(
text=f"○ {status.name} · status unknown",
style="ServerUnknown.TLabel",
)
def launcher_update_presentation(
check: LauncherUpdateCheck,
) -> LauncherUpdatePresentation:
"""Make authenticated updates visually distinct from the normal version button."""
if check.available and check.supported:
return LauncherUpdatePresentation(
button_text=f"UPDATE LAUNCHER · {check.latest_version}",
button_style="LauncherUpdate.TButton",
badge_text="SIGNED UPDATE AVAILABLE",
)
if check.available:
return LauncherUpdatePresentation(
button_text=f"MANUAL UPDATE · {check.latest_version}",
button_style="LauncherManualUpdate.TButton",
badge_text="MANUAL UPDATE REQUIRED",
)
return LauncherUpdatePresentation(
button_text=f"LAUNCHER {check.current_version}",
button_style="Secondary.TButton",
badge_text=None,
)
def dashboard_preloader_delay(
started_at: float,
*,
timestamp: float | None = None,
) -> int:
"""Keep the startup preloader visible long enough to avoid a brief flash."""
now = time.monotonic() if timestamp is None else timestamp
elapsed_milliseconds = round(max(0.0, now - started_at) * 1000)
return max(0, DASHBOARD_PRELOADER_MINIMUM_MILLISECONDS - elapsed_milliseconds)
def dashboard_content_ready(ready_components: set[str] | frozenset[str]) -> bool:
"""Return whether every user-visible startup component has settled."""
return DASHBOARD_STARTUP_COMPONENTS.issubset(ready_components)
def dashboard_transition_opacity(step: int, *, fading_in: bool) -> float:
"""Return one clamped opacity step for the loader/dashboard cross-fade."""
progress = min(max(step, 0), DASHBOARD_TRANSITION_STEPS) / DASHBOARD_TRANSITION_STEPS
opacity_range = 1.0 - DASHBOARD_TRANSITION_MINIMUM_OPACITY
if fading_in:
return DASHBOARD_TRANSITION_MINIMUM_OPACITY + opacity_range * progress
return 1.0 - opacity_range * progress
def 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 freeze_preloader_geometry(frame: tk.Misc) -> 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, messagebox, 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 = None
window_icon_path = find_application_icon()
if window_icon_path is not None:
try:
window_icon = tk.PhotoImage(master=root, file=str(window_icon_path))
root.iconphoto(True, window_icon)
except (OSError, tk.TclError) as icon_error:
print(
f"Warning: could not load the launcher icon: {icon_error}",
file=sys.stderr,
)
root.geometry(WINDOW_SIZE)
root.resizable(False, False)
root.configure(background="#0c0908")
root.withdraw()
try:
startup_settings = ensure_settings(paths)
except (OSError, ValueError) as startup_error:
root.deiconify()
messagebox.showerror(
"Launcher settings",
f"The launcher could not load its settings:\n\n{startup_error}",
parent=root,
)
root.destroy()
return
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("Splash.TFrame", background="#191311")
style.configure(
"SplashBrand.TLabel",
background="#191311",
foreground="#f5d18b",
font=("Georgia", 22, "bold"),
)
style.configure(
"SplashTagline.TLabel",
background="#191311",
foreground="#d8aa61",
font=("Sans", 8, "bold"),
)
style.configure(
"SplashStatus.TLabel",
background="#191311",
foreground="#c9baaa",
font=("Sans", 9),
)
style.configure(
"Section.TLabel",
background="#191311",
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("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",
)
style.configure(
"Settings.TCombobox",
fieldbackground="#100d0c",
background="#211916",
foreground="#f8ead2",
arrowcolor="#e5b66c",
bordercolor="#4a3528",
)
style.map(
"Settings.TCombobox",
fieldbackground=[("readonly", "#100d0c")],
foreground=[("readonly", "#f8ead2")],
)
style.configure(
"Realm.TCombobox",
fieldbackground="#211916",
background="#211916",
foreground="#fff0d2",
arrowcolor="#d8aa61",
bordercolor="#4a3528",
lightcolor="#4a3528",
darkcolor="#4a3528",
font=("Sans", 10, "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 10 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),
)
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(
"Play.TButton",
background="#d98927",
foreground="#fff8e9",
font=("Sans", 10, "bold"),
padding=(20, 10),
bordercolor="#f4bd61",
borderwidth=1,
relief="flat",
)
style.map(
"Play.TButton",
background=[("active", "#efaa4c"), ("disabled", "#6c4826")],
foreground=[("disabled", "#bca889")],
)
style.configure(
"Secondary.TButton",
background="#0b0a09",
foreground="#f2b657",
font=("Sans", 8, "bold"),
padding=(12, 8),
bordercolor="#d89332",
borderwidth=1,
relief="flat",
)
style.map(
"Secondary.TButton",
background=[("active", "#271b10"), ("disabled", "#171412")],
foreground=[("active", "#ffd17c"), ("disabled", "#887a70")],
)
style.configure(
"LauncherUpdate.TButton",
background="#f4bd61",
foreground="#24170f",
font=("Sans", 8, "bold"),
padding=(12, 8),
bordercolor="#ffe3a3",
borderwidth=2,
relief="flat",
)
style.map(
"LauncherUpdate.TButton",
background=[("active", "#ffd47f"), ("disabled", "#7b5d37")],
foreground=[("active", "#120b06"), ("disabled", "#d0bea2")],
)
style.configure(
"LauncherManualUpdate.TButton",
background="#a63f2f",
foreground="#fff4e9",
font=("Sans", 8, "bold"),
padding=(12, 8),
bordercolor="#e98769",
borderwidth=2,
relief="flat",
)
style.map(
"LauncherManualUpdate.TButton",
background=[("active", "#c95842"), ("disabled", "#60352e")],
foreground=[("active", "#ffffff"), ("disabled", "#c9ada5")],
)
style.configure(
"UpdateBadge.TLabel",
background="#d98927",
foreground="#1a1008",
font=("Sans", 7, "bold"),
padding=(7, 3),
)
style.configure(
"Activity.TButton",
background="#0c0908",
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_overlay = tk.Frame(
root,
background="#0c0908",
borderwidth=0,
highlightthickness=0,
)
startup_overlay.place(x=0, y=0, relwidth=1, relheight=1)
startup_border = tk.Frame(
startup_overlay,
background="#d98927",
borderwidth=0,
highlightthickness=0,
)
startup_border.place(relx=0.5, rely=0.5, anchor="center")
startup_shell = ttk.Frame(
startup_border,
style="Splash.TFrame",
padding=(26, 22),
)
startup_shell.pack(fill="both", expand=True, padx=1, pady=1)
ttk.Label(
startup_shell,
text="PRISTONTALE EU",
style="SplashBrand.TLabel",
).pack(anchor="w")
ttk.Label(
startup_shell,
text="NATIVE LINUX LAUNCHER",
style="SplashTagline.TLabel",
).pack(anchor="w", pady=(2, 17))
startup_status_label = ttk.Label(
startup_shell,
textvariable=startup_status,
style="SplashStatus.TLabel",
width=PRELOADER_STATUS_CHARACTERS,
anchor="w",
)
startup_status_label.pack(fill="x", pady=(0, 8))
startup_progress = ttk.Progressbar(
startup_shell,
style="Depot.Horizontal.TProgressbar",
mode="indeterminate",
length=420,
)
startup_progress.pack(fill="x")
freeze_preloader_geometry(startup_border)
startup_progress.start(12)
startup_ready_components: set[str] = set()
startup_transition_started = False
startup_ready_after: str | None = None
startup_timeout_after: str | None = None
root.deiconify()
root.lift()
startup_overlay.lift()
root.update()
def update_startup_progress(message: str) -> None:
if startup_transition_started:
return
startup_progress.stop()
startup_progress.configure(
mode="determinate",
maximum=len(DASHBOARD_STARTUP_COMPONENTS),
value=len(startup_ready_components),
)
startup_status.set(
f"{message} · {len(startup_ready_components)}/"
f"{len(DASHBOARD_STARTUP_COMPONENTS)}"
)
def cancel_startup_after(identifier: str | None) -> None:
if identifier is None:
return
try:
root.after_cancel(identifier)
except tk.TclError:
pass
def set_startup_component_pending(component: str) -> None:
nonlocal startup_ready_after
if startup_transition_started or component not in DASHBOARD_STARTUP_COMPONENTS:
return
if component not in startup_ready_components:
return
startup_ready_components.remove(component)
cancel_startup_after(startup_ready_after)
startup_ready_after = None
update_startup_progress("Loading dashboard content…")
def mark_startup_component_ready(component: str, message: str) -> None:
if startup_transition_started or component not in DASHBOARD_STARTUP_COMPONENTS:
return
startup_ready_components.add(component)
update_startup_progress(message)
maybe_start_dashboard_transition()
def set_root_opacity(value: float) -> bool:
try:
root.attributes("-alpha", value)
except tk.TclError:
return False
return True
def finish_dashboard_transition() -> None:
set_root_opacity(1.0)
root.lift()
root.focus_set()
def fade_dashboard_in(step: int = 0) -> None:
if not set_root_opacity(
dashboard_transition_opacity(step, fading_in=True)
):
finish_dashboard_transition()
return
if step >= DASHBOARD_TRANSITION_STEPS:
finish_dashboard_transition()
return
root.after(
DASHBOARD_TRANSITION_STEP_MILLISECONDS,
lambda: fade_dashboard_in(step + 1),
)
def swap_loader_for_dashboard(*, fade_supported: bool) -> None:
if startup_overlay.winfo_exists():
startup_overlay.destroy()
root.update_idletasks()
if fade_supported:
fade_dashboard_in()
else:
finish_dashboard_transition()
def fade_loader_out(step: int = 0) -> None:
fade_supported = set_root_opacity(
dashboard_transition_opacity(step, fading_in=False)
)
if not fade_supported or step >= DASHBOARD_TRANSITION_STEPS:
swap_loader_for_dashboard(fade_supported=fade_supported)
return
root.after(
DASHBOARD_TRANSITION_STEP_MILLISECONDS,
lambda: fade_loader_out(step + 1),
)
def begin_dashboard_transition(*, timed_out: bool = False) -> None:
nonlocal startup_transition_started
nonlocal startup_ready_after, startup_timeout_after
if startup_transition_started:
return
startup_transition_started = True
cancel_startup_after(startup_ready_after)
cancel_startup_after(startup_timeout_after)
startup_ready_after = None
startup_timeout_after = None
if timed_out:
settle_pending_news_artwork()
if "version" not in startup_ready_components:
version.set(
f"Launcher {__version__} · game build check continuing in background"
)
startup_progress.stop()
startup_progress.configure(
mode="determinate",
maximum=len(DASHBOARD_STARTUP_COMPONENTS),
value=len(DASHBOARD_STARTUP_COMPONENTS),
)
startup_status.set(
"Opening dashboard with available content…"
if timed_out
else "Opening dashboard…"
)
startup_overlay.update_idletasks()
root.after(80, fade_loader_out)
def maybe_start_dashboard_transition() -> None:
nonlocal startup_ready_after
if startup_transition_started or not dashboard_content_ready(
startup_ready_components
):
return
remaining = dashboard_preloader_delay(startup_started_at)
if remaining <= 0:
begin_dashboard_transition()
return
if startup_ready_after is None:
startup_ready_after = root.after(
remaining,
start_dashboard_after_minimum,
)
def start_dashboard_after_minimum() -> None:
nonlocal startup_ready_after
startup_ready_after = None
maybe_start_dashboard_transition()
def timeout_startup_preloader() -> None:
nonlocal startup_timeout_after
startup_timeout_after = None
begin_dashboard_transition(timed_out=True)
frame = ttk.Frame(root, style="Root.TFrame", padding=12)
frame.pack(fill="both", expand=True)
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,
)
logo.create_oval(4, 4, 38, 38, outline="#d98927", width=2)
logo.create_text(
21,
21,
text="PT",
fill="#f5d18b",
font=("Georgia", 9, "bold"),
)
logo.pack(side="left", padx=(0, 9))
brand = ttk.Frame(header, style="Root.TFrame")
brand.pack(side="left", fill="x", expand=True)
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",
)
update_button.pack(side="left")
repair_button = ttk.Button(
game_button_row,
text="REPAIR",
style="Secondary.TButton",
)
repair_button.pack(side="left", padx=(8, 0))
import_button = ttk.Button(
game_button_row,
text="IMPORT GAME",
style="Secondary.TButton",
)
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")
doctor_button.pack(side="left")
settings_button = ttk.Button(system_button_row, text="OPEN SETTINGS", style="Secondary.TButton")
settings_button.pack(side="left", padx=(8, 0))
launcher_update_button = ttk.Button(
system_button_row,
text=f"LAUNCHER {__version__}",
style="Secondary.TButton",
)
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")
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", 10, "bold"),
justify="center",
width=20,
)
realm_selector.pack(fill="x", pady=(5, 0))
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="")
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=client_activity_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=15,
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")
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="x", 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")
ttk.Label(
news_heading,
text="OFFICIAL FEED",
style="Muted.TLabel",
).pack(side="right")
news_canvas = tk.Canvas(
news_panel,
background="#191311",
highlightthickness=0,
borderwidth=0,
height=252,
)
news_canvas.pack(fill="both", expand=True, pady=(6, 0))
news_content = ttk.Frame(news_canvas, style="Surface.TFrame")
news_window = news_canvas.create_window((0, 0), window=news_content, 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_window.geometry("800x400")
activity_window.resizable(False, False)
activity_window.configure(background="#0c0908")
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()
log_toggle = tk.Button(
frame,
text=">_",
background="#0c0908",
foreground="#f8fafc",
activebackground="#24170f",
activeforeground="#ffffff",
font=("Monospace", 8, "bold"),
padx=3,
pady=2,
borderwidth=0,
highlightthickness=0,
relief="flat",
)
log_toggle.place(x=0, rely=1.0, anchor="sw")
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] = {}
startup_news_images_pending: set[int] = set()
logo_image_references: list[tk.PhotoImage] = []
preloader: tk.Toplevel | None = None
preloader_status: tk.StringVar | None = None
preloader_progress: ttk.Progressbar | None = None
settings_window: tk.Toplevel | None = None
game_clients = GameClientTracker()
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_refreshes_compatibility = False
operation_file_progress_verb = "Updating"
last_elapsed_second = -1
official_snapshot: DepotSnapshot | None = None
available_start_options: tuple[DepotStartOption, ...] = (DEFAULT_DEPOT_START_OPTION,)
preferred_realm = startup_settings.realm
latest_server_statuses: tuple[RealmServerStatus, ...] | None = None
server_status_placeholder = "Checking official server status…"
launcher_update_check: LauncherUpdateCheck | None = None
runtime_compatibility: RuntimeCompatibility | None = None
compatibility_check_generation = 0
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:
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_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))
progress_text.set(
f"{files_completed}/{files_total} files · "
f"{_format_bytes(bytes_completed)} / {_format_bytes(bytes_total)}"
)
if phase == "downloading":
bytes_transferred = _as_int(event.get("bytes_transferred"))
rate = transfer_rate.update(bytes_transferred)
speed_text.set(format_transfer_rate(rate) if rate is not None else "0 B/s")
else:
transfer_rate.reset()
speed_text.set("")
else:
progress_bar.configure(maximum=3, value=3)
progress_text.set("Game files are already up to date.")
transfer_rate.reset()
speed_text.set("")
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:
if operation_started_at is None or operation_stage_total <= 0:
return operation_message
elapsed = int(time.monotonic() - operation_started_at)
return (
f"Step {operation_stage}/{operation_stage_total} · "
f"{operation_message} · {elapsed}s elapsed"
)
def set_operation_stage(step: int, total: int, message: str) -> None:
nonlocal operation_stage, operation_stage_total, operation_message
operation_stage = min(max(step, 0), total)
operation_stage_total = max(total, 1)
operation_message = message
transfer_rate.reset()
speed_text.set("")
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 operation_active
or operation_started_at is None
or operation_stage_total != LAUNCH_STAGE_TOTAL
):
return
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 show_game_preloader() -> None:
nonlocal preloader, preloader_status, preloader_progress
if preloader is not None and preloader.winfo_exists():
return
window = tk.Toplevel(root)
preloader = window
window.title("Launching PristonTale EU")
window.configure(background="#0c0908")
window.resizable(False, False)
window.protocol("WM_DELETE_WINDOW", lambda: None)
window.transient(root)
window.attributes("-topmost", True)
status_variable = tk.StringVar(value="Starting launcher process…")
preloader_status = status_variable
content = ttk.Frame(window, style="Root.TFrame", padding=26)
content.pack(fill="both", expand=True)
ttk.Label(content, text="PRISTONTALE EU", style="Brand.TLabel").pack(anchor="w")
ttk.Label(content, text="LAUNCHING THE CLIENT", style="Tagline.TLabel").pack(
anchor="w",
pady=(2, 18),
)
ttk.Label(
content,
text="Please wait while the game prepares its window.",
style="PreloaderStatus.TLabel",
).pack(anchor="w")
status_label = ttk.Label(
content,
textvariable=status_variable,
style="PreloaderMuted.TLabel",
width=PRELOADER_STATUS_CHARACTERS,
anchor="w",
)
status_label.pack(
fill="x",
pady=(4, 10),
)
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)
window.update_idletasks()
width = window.winfo_reqwidth()
height = window.winfo_reqheight()
x = (window.winfo_screenwidth() - width) // 2
y = (window.winfo_screenheight() - height) // 2
window.geometry(f"{width}x{height}+{x}+{y}")
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():
preloader.destroy()
preloader = None
preloader_status = None
preloader_progress = None
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_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_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_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}"))
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 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 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:
webbrowser.open(url)
def make_news_card_clickable(card: tk.Frame, url: str) -> None:
def enter(_event: object) -> None:
card.configure(background="#2a1e17", highlightbackground="#e4a34c", highlightthickness=1)
def leave(_event: object) -> None:
card.configure(background="#211916", highlightbackground="#4a3528", highlightthickness=1)
def open_card(_event: object) -> None:
open_article(url)
def bind_widget(widget: object) -> None:
bind = getattr(widget, "bind")
configure = getattr(widget, "configure")
bind("<Enter>", enter)
bind("<Leave>", leave)
bind("<Button-1>", open_card)
configure(cursor="hand2")
for child in getattr(widget, "winfo_children")():
bind_widget(child)
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 show_news(articles: tuple[NewsArticle, ...]) -> None:
news_image_targets.clear()
news_image_references.clear()
for child in news_content.winfo_children():
child.destroy()
if not 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",
)
return
visible_articles = articles[:MAX_NEWS_CARDS]
for column in range(len(visible_articles)):
news_content.grid_columnconfigure(column, weight=1, uniform="news-card")
news_content.grid_rowconfigure(0, weight=1)
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)
card.grid(
row=0,
column=index,
sticky="nsew",
padx=3,
pady=3,
)
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),
)
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 = " · ".join(part for part in (article.published_at, article.author) if part)
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)
load_news_images(visible_articles)
def load_news_images(articles: tuple[NewsArticle, ...]) -> None:
image_articles = tuple(
article for article in articles if article.image_url is not None
)
startup_news_images_pending.clear()
startup_news_images_pending.update(article.id for article in image_articles)
if startup_news_images_pending:
set_startup_component_pending("news_artwork")
else:
mark_startup_component_ready(
"news_artwork",
"News artwork ready",
)
def worker(news_article: NewsArticle) -> None:
image = fetch_news_image(news_article, paths.news_image_cache)
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),
)
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),
)
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)
def complete_news_image(value: object) -> None:
install_news_image(value)
if not isinstance(value, tuple) or not value or not isinstance(value[0], int):
return
startup_news_images_pending.discard(value[0])
if not startup_news_images_pending:
mark_startup_component_ready(
"news_artwork",
"News artwork ready",
)
def settle_pending_news_artwork() -> None:
for article_id in tuple(startup_news_images_pending):
target = news_image_targets.get(article_id)
if target is None or not target.winfo_exists():
continue
target.delete("all")
target.create_text(
NEWS_IMAGE_WIDTH // 2,
NEWS_IMAGE_HEIGHT // 2,
text="Artwork unavailable",
fill="#a8998a",
font=("Sans", 8),
)
startup_news_images_pending.clear()
def install_official_logo(value: object) -> None:
if not isinstance(value, bytes):
return
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 = fetch_official_logo(paths.logo_cache)
events.put(("logo_image", image))
threading.Thread(target=worker, name="ptlinux-logo", daemon=True).start()
def render_server_status() -> None:
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)
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(
statuses: tuple[RealmServerStatus, ...] | None,
*,
placeholder: str = "Server status unavailable.",
) -> None:
nonlocal latest_server_statuses, server_status_placeholder
latest_server_statuses = statuses
server_status_placeholder = placeholder
render_server_status()
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("<<ComboboxSelected>>", on_realm_selected)
def refresh_primary_action(
snapshot: DepotSnapshot | None = None,
*,
update_activity: bool = True,
) -> GameInstallationStatus:
nonlocal official_snapshot
if snapshot is not None:
official_snapshot = snapshot
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
),
)
additional_client_ready = game_clients.running_count > 0 and installation.installed
client_activity_text.set(game_client_activity_text(game_clients.running_count))
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)
refresh_primary_action(update_activity=update_activity)
if not local_status.installed or game_import_requires_verification(paths):
mark_startup_component_ready(
"compatibility",
"Game state ready",
)
return
def worker() -> None:
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)
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 server_status_worker() -> None:
try:
statuses = fetch_server_status()
except ServerStatusError as status_error:
events.put(("server_status_error", str(status_error)))
else:
events.put(("server_status", statuses))
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))
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=server_status_worker,
name="ptlinux-server-status",
daemon=True,
).start()
threading.Thread(target=version_worker, name="ptlinux-version", daemon=True).start()
refresh_launcher_update_check()
def release_game_launch_controls(
client_id: int,
*,
ready: bool,
returncode: int | None = None,
) -> 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("")
progress_bar.stop()
close_game_preloader()
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.")
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("")
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 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:
messagebox.showerror(
"Launcher update",
f"The update was installed, but the launcher could not restart: {restart_error}",
parent=root,
)
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_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
):
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):
progress_bar.stop()
status.set(f"Game client {value} started; preparing its window…")
elif kind == "game_window_ready" and isinstance(value, int):
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
phase = game_clients.finish(client_id)
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,
)
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()
elif kind == "broadcast":
show_broadcast(value if isinstance(value, Broadcast) else None)
mark_startup_component_ready(
"broadcast",
"Broadcast ready",
)
elif (
kind == "server_status"
and isinstance(value, tuple)
and all(isinstance(item, RealmServerStatus) for item in value)
):
show_server_status(value)
mark_startup_component_ready(
"server_status",
"Server status ready",
)
elif kind == "server_status_error" and isinstance(value, str):
show_server_status(None)
append_log(f"Server status check unavailable: {value}")
mark_startup_component_ready(
"server_status",
"Server status check complete",
)
elif kind == "news" and isinstance(value, tuple):
show_news(value)
mark_startup_component_ready(
"news",
"News cards 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}")
mark_startup_component_ready(
"launcher_update",
"Launcher update check complete",
)
except queue.Empty:
pass
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()
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:
check = launcher_update_check
if check is None:
launcher_update_badge.pack_forget()
launcher_update_button.configure(
text="CHECKING…",
style="Secondary.TButton",
state="disabled",
)
if required_reason is not None:
status.set("Checking for a compatible launcher update…")
progress_text.set("Play remains disabled until this release is supported.")
refresh_launcher_update_check()
return
if not check.available:
if required_reason is None:
messagebox.showinfo(
"Launcher update",
check.reason,
parent=root,
)
else:
messagebox.showwarning(
"Compatibility update required",
"The installed game needs a launcher compatibility update:\n\n"
f"{required_reason}\n\n{check.reason}",
parent=root,
)
return
if not check.supported:
messagebox.showwarning(
"Manual launcher update required",
check.reason,
parent=root,
)
return
if not messagebox.askyesno(
"Update launcher",
f"Install signed launcher version {check.latest_version} and restart now?",
parent=root,
):
return
run_operation(
f"Installing launcher {check.latest_version}…",
command_for("launcher-update"),
restart_on_success=True,
)
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:
messagebox.showerror(
"Realm selection",
"The launcher could not save the official realm selection:\n\n"
f"{cache_error}",
parent=root,
)
return
client_id = game_clients.begin()
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:
messagebox.showerror(
"Settings",
f"The launcher could not load its settings:\n\n{error}",
parent=root,
)
return
dialog = tk.Toplevel(root)
settings_window = dialog
dialog.title("PristonTale EU — Settings")
dialog.geometry("840x680")
dialog.resizable(False, False)
dialog.configure(background="#0c0908")
dialog.transient(root)
settings_header = tk.Frame(dialog, background="#24170f", height=118)
settings_header.pack(fill="x")
settings_header.pack_propagate(False)
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 the game window, runtime, and compatibility preferences.",
background="#24170f",
foreground="#c9baaa",
font=("Sans", 9),
).pack(anchor="w", pady=(2, 0))
content = ttk.Frame(dialog, 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)
suppress_unresponsive_value = tk.BooleanVar(
value=current_settings.suppress_unresponsive_warnings
)
runtime_value = tk.StringVar(value=current_settings.runtime)
proton_sync_value = tk.StringVar(value=current_settings.proton_sync)
proton_path_value = tk.StringVar(value=current_settings.proton_path or "")
game_source_value = tk.StringVar(value=current_settings.game_source or "")
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
def save_current_settings() -> None:
try:
updated_settings = update_settings(
paths,
borderless=borderless_value.get(),
suppress_unresponsive_warnings=suppress_unresponsive_value.get(),
runtime=runtime_value.get(),
proton_sync=proton_sync_value.get(),
proton_path=proton_path_value.get().strip() or None,
game_source=game_source_value.get().strip() or None,
)
except (OSError, ValueError) as error:
messagebox.showerror("Settings", str(error), parent=dialog)
return
append_log(
f"Saved launcher settings to {paths.settings_file}: "
f"runtime={updated_settings.runtime}, "
f"proton_sync={updated_settings.proton_sync}"
)
status.set("Settings saved. They will be used on the next game launch.")
progress_text.set("Open Settings again at any time to adjust the launcher.")
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 uses the proven client resolution and can remove the "
"new game window's 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="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))
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,
("auto", "proton7-container", "proton7", "umu"),
)
add_choice(
rendering_card,
3,
"Proton synchronization",
"Auto uses esync on CachyOS",
proton_sync_value,
("auto", "fsync", "esync"),
)
overrides_card = ttk.LabelFrame(
content,
text="OPTIONAL OVERRIDES",
style="SettingsCard.TLabelframe",
padding=14,
)
overrides_card.grid(row=1, 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=2, 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.lift()
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:
root.iconify()
return
root.destroy()
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)
news_content.bind("<Configure>", update_news_viewport)
news_canvas.bind("<Configure>", resize_news_content)
startup_overlay.lift()
startup_status.set("Restoring dashboard content…")
startup_overlay.update_idletasks()
show_broadcast(read_cached_broadcast(paths.broadcast_cache))
show_news(read_cached_news(paths.news_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_primary_action()
refresh_runtime_compatibility()
poll_events()
load_official_logo()
update_startup_progress("Loading online dashboard content…")
startup_overlay.update_idletasks()
startup_timeout_after = root.after(
DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS,
timeout_startup_preloader,
)
refresh_remote_content()
maybe_start_dashboard_transition()
try:
root.mainloop()
except KeyboardInterrupt:
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 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"