4333 lines
125 KiB
Python
4333 lines
125 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 signal
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
import time
|
||
import webbrowser
|
||
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,
|
||
)
|
||
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,
|
||
cache_launcher_update_check,
|
||
check_for_launcher_update,
|
||
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_SIZE = "1002x784"
|
||
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 = 650
|
||
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 = "<Control-Shift-F12>"
|
||
SERVER_STATUS_STALE_SECONDS = 5 * 60
|
||
DASHBOARD_STARTUP_COMPONENTS = frozenset(
|
||
{
|
||
"broadcast",
|
||
"compatibility",
|
||
"launcher_update",
|
||
"logo",
|
||
"news",
|
||
"news_artwork",
|
||
"patches",
|
||
"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
|
||
|
||
|
||
@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."""
|
||
|
||
|
||
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 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 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.geometry(WINDOW_SIZE)
|
||
root.resizable(False, False)
|
||
root.configure(background="#0c0908")
|
||
root.withdraw()
|
||
dashboard_chrome = create_dashboard_chrome(
|
||
root,
|
||
title="PRISTONTALE EU",
|
||
subtitle="NATIVE LINUX LAUNCHER",
|
||
window_icon=window_icon,
|
||
)
|
||
dashboard_content = dashboard_chrome.content
|
||
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
|
||
|
||
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("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", 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),
|
||
)
|
||
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",
|
||
)
|
||
style.map(
|
||
"NewsTab.TButton",
|
||
background=[("active", "#2a201c")],
|
||
foreground=[("active", "#f0d7a2")],
|
||
bordercolor=[("active", "#4a3528")],
|
||
)
|
||
style.configure(
|
||
"NewsTabActive.TButton",
|
||
background="#2a201c",
|
||
foreground="#f0d7a2",
|
||
font=("Sans", 8, "bold"),
|
||
padding=(10, 4),
|
||
bordercolor="#6a4d38",
|
||
borderwidth=1,
|
||
relief="flat",
|
||
)
|
||
style.map(
|
||
"NewsTabActive.TButton",
|
||
background=[("active", "#32251f")],
|
||
foreground=[("active", "#fff0d2")],
|
||
)
|
||
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",
|
||
)
|
||
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_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
|
||
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()
|
||
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()
|
||
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",
|
||
)
|
||
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", 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="")
|
||
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")
|
||
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="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")
|
||
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",
|
||
)
|
||
news_tab_button.pack(side="left")
|
||
patches_tab_button = ttk.Button(
|
||
news_tabs,
|
||
text="PATCHES",
|
||
style="NewsTab.TButton",
|
||
)
|
||
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=252,
|
||
)
|
||
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_window.geometry("800x400")
|
||
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()
|
||
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()
|
||
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
|
||
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
|
||
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_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))
|
||
operation_progress_detail = (
|
||
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)
|
||
operation_progress_detail = "Game files are already up to date."
|
||
transfer_rate.reset()
|
||
speed_text.set("")
|
||
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("")
|
||
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_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:
|
||
webbrowser.open(url)
|
||
|
||
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 bind_widget(widget: object) -> None:
|
||
bind = getattr(widget, "bind")
|
||
configure = getattr(widget, "configure")
|
||
bind("<Enter>", enter)
|
||
bind("<Leave>", leave)
|
||
bind("<Button-1>", open_card)
|
||
bind("<MouseWheel>", scroll_news_view, "+")
|
||
bind("<Button-4>", scroll_news_view, "+")
|
||
bind("<Button-5>", scroll_news_view, "+")
|
||
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 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()
|
||
for child in news_content.winfo_children():
|
||
child.destroy()
|
||
for column in range(MAX_NEWS_CARDS):
|
||
news_content.grid_columnconfigure(column, weight=0, uniform="")
|
||
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 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)
|
||
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)
|
||
|
||
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 = 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 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("<<ComboboxSelected>>", 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))
|
||
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)
|
||
|
||
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))
|
||
|
||
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()
|
||
refresh_launcher_update_check()
|
||
|
||
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("")
|
||
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.")
|
||
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("")
|
||
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}")
|
||
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:
|
||
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:
|
||
show_themed_dialog(
|
||
root,
|
||
title="Launcher is up to date",
|
||
message=check.reason,
|
||
kind="success",
|
||
window_icon=window_icon,
|
||
)
|
||
else:
|
||
show_themed_dialog(
|
||
root,
|
||
title="Compatibility update required",
|
||
message=(
|
||
"The installed game needs a launcher compatibility update:\n\n"
|
||
f"{required_reason}\n\n{check.reason}"
|
||
),
|
||
kind="warning",
|
||
window_icon=window_icon,
|
||
)
|
||
return
|
||
if not check.supported:
|
||
show_themed_dialog(
|
||
root,
|
||
title="Manual launcher update required",
|
||
message=check.reason,
|
||
kind="warning",
|
||
window_icon=window_icon,
|
||
)
|
||
return
|
||
if not show_themed_dialog(
|
||
root,
|
||
title="Update launcher",
|
||
message=(
|
||
f"Install signed launcher version {check.latest_version} and restart now?"
|
||
),
|
||
kind="question",
|
||
confirm=True,
|
||
accept_text="INSTALL & RESTART",
|
||
window_icon=window_icon,
|
||
):
|
||
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:
|
||
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.geometry("842x714")
|
||
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",
|
||
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(
|
||
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)
|
||
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
|
||
|
||
settings_chrome.set_close_command(close_settings)
|
||
|
||
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:
|
||
show_themed_dialog(
|
||
dialog,
|
||
title="Settings",
|
||
message=str(error),
|
||
kind="error",
|
||
window_icon=window_icon,
|
||
)
|
||
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.")
|
||
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 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.update_idletasks()
|
||
dialog.geometry(
|
||
centered_window_geometry(
|
||
842,
|
||
714,
|
||
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)
|
||
|
||
news_content_host.bind("<Configure>", update_news_viewport)
|
||
news_content.bind("<Configure>", update_news_viewport)
|
||
patches_content.bind("<Configure>", update_news_viewport)
|
||
news_canvas.bind("<Configure>", resize_news_content)
|
||
for scroll_widget in (
|
||
news_canvas,
|
||
news_content_host,
|
||
news_content,
|
||
patches_content,
|
||
):
|
||
scroll_widget.bind("<MouseWheel>", scroll_news_view, add="+")
|
||
scroll_widget.bind("<Button-4>", scroll_news_view, add="+")
|
||
scroll_widget.bind("<Button-5>", scroll_news_view, add="+")
|
||
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()
|
||
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"
|