Added server status and server selection dropdown.
This commit is contained in:
@@ -7,19 +7,22 @@ from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath
|
||||
import platform
|
||||
import shlex
|
||||
import shutil
|
||||
import sys
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
from .command import CommandOutcome, CommandRunner, OutputLineFilter
|
||||
from .depot import DEFAULT_DEPOT_START_OPTION, DepotStartOption
|
||||
from .gamebox import build_gamebox_command
|
||||
from .paths import AppPaths
|
||||
from .settings import Settings
|
||||
|
||||
|
||||
WINDOWS_GAME_EXE = r"C:\Program Files (x86)\Pristontale EU\Game.exe"
|
||||
WINDOWS_GAME_DIRECTORY = r"C:\Program Files (x86)\Pristontale EU"
|
||||
WINDOWS_GAME_EXE = rf"{WINDOWS_GAME_DIRECTORY}\Game.exe"
|
||||
NATIVE_D3D43_DLLS = ("d3dx9_43", "d3dcompiler_43")
|
||||
DXVK_D3D9_OVERRIDE = "native,builtin"
|
||||
DLL_OVERRIDES_KEY = r"HKCU\Software\Wine\DllOverrides"
|
||||
@@ -293,6 +296,7 @@ def run_game(
|
||||
selection: RuntimeSelection,
|
||||
runner: CommandRunner,
|
||||
*,
|
||||
start_option: DepotStartOption = DEFAULT_DEPOT_START_OPTION,
|
||||
use_gamebox: bool = True,
|
||||
timeout_seconds: float = 12 * 60 * 60,
|
||||
check: bool = True,
|
||||
@@ -301,11 +305,15 @@ def run_game(
|
||||
) -> CommandOutcome:
|
||||
if not paths.runtime_view.is_dir() and not runner.dry_run:
|
||||
raise RuntimeErrorPT("runtime view is missing; prepare the game first")
|
||||
native_executable, windows_executable, game_arguments = _game_start_command(
|
||||
paths,
|
||||
start_option,
|
||||
)
|
||||
if selection.kind == "umu":
|
||||
game_command = [
|
||||
selection.executable,
|
||||
str(paths.runtime_view / "Game.exe"),
|
||||
"PTEU_REBORN",
|
||||
native_executable,
|
||||
*game_arguments,
|
||||
]
|
||||
environment = _umu_environment(paths)
|
||||
elif selection.kind == "proton7-container":
|
||||
@@ -313,8 +321,8 @@ def run_game(
|
||||
raise RuntimeErrorPT("Proton script path is missing")
|
||||
game_command = [
|
||||
selection.executable,
|
||||
str(paths.runtime_view / "Game.exe"),
|
||||
"PTEU_REBORN",
|
||||
native_executable,
|
||||
*game_arguments,
|
||||
]
|
||||
environment = _umu_proton7_environment(
|
||||
paths,
|
||||
@@ -330,8 +338,8 @@ def run_game(
|
||||
"ptlinux.proton_compat",
|
||||
selection.proton_path,
|
||||
"runinprefix",
|
||||
WINDOWS_GAME_EXE,
|
||||
"PTEU_REBORN",
|
||||
windows_executable,
|
||||
*game_arguments,
|
||||
]
|
||||
environment = _proton_environment(
|
||||
paths,
|
||||
@@ -356,6 +364,57 @@ def run_game(
|
||||
)
|
||||
|
||||
|
||||
def _game_start_command(
|
||||
paths: AppPaths,
|
||||
option: DepotStartOption,
|
||||
) -> tuple[str, str, tuple[str, ...]]:
|
||||
relative = PurePosixPath(option.path)
|
||||
if (
|
||||
relative.is_absolute()
|
||||
or not relative.parts
|
||||
or any(part in {"", ".", ".."} for part in relative.parts)
|
||||
or relative.suffix.casefold() != ".exe"
|
||||
):
|
||||
raise RuntimeErrorPT(f"invalid realm executable path: {option.path!r}")
|
||||
try:
|
||||
arguments = tuple(shlex.split(option.arguments))
|
||||
except ValueError as exc:
|
||||
raise RuntimeErrorPT(
|
||||
f"invalid launch arguments for realm {option.name}: {exc}"
|
||||
) from exc
|
||||
if not arguments:
|
||||
raise RuntimeErrorPT(f"realm {option.name} has no launch arguments")
|
||||
native = str(_resolve_runtime_path_case(paths.runtime_view, relative))
|
||||
windows_relative = "\\".join(relative.parts)
|
||||
return native, rf"{WINDOWS_GAME_DIRECTORY}\{windows_relative}", arguments
|
||||
|
||||
|
||||
def _resolve_runtime_path_case(root: Path, relative: PurePosixPath) -> Path:
|
||||
"""Match Windows manifest casing against the case-sensitive runtime view."""
|
||||
|
||||
direct = root.joinpath(*relative.parts)
|
||||
if direct.exists():
|
||||
return direct
|
||||
cursor = root
|
||||
for part in relative.parts:
|
||||
if not cursor.is_dir():
|
||||
return direct
|
||||
try:
|
||||
matches = tuple(
|
||||
entry for entry in cursor.iterdir() if entry.name.casefold() == part.casefold()
|
||||
)
|
||||
except OSError:
|
||||
return direct
|
||||
if not matches:
|
||||
return direct
|
||||
if len(matches) > 1:
|
||||
raise RuntimeErrorPT(
|
||||
f"realm executable path has ambiguous casing: {relative.as_posix()}"
|
||||
)
|
||||
cursor = matches[0]
|
||||
return cursor
|
||||
|
||||
|
||||
def run_proton_prefix_command(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
|
||||
@@ -19,6 +19,7 @@ SETTINGS_SCHEMA = 1
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
realm: str = "Babel"
|
||||
width: int = 1600
|
||||
height: int = 900
|
||||
output_width: int = 1600
|
||||
@@ -133,6 +134,7 @@ def _read_settings_object(path: Path) -> dict[str, object] | None:
|
||||
|
||||
def _settings_from_object(value: dict[str, object]) -> Settings:
|
||||
settings = Settings(
|
||||
realm=_string(value, "realm", "Babel").strip(),
|
||||
width=_integer(value, "width", 1600),
|
||||
height=_integer(value, "height", 900),
|
||||
output_width=_integer(value, "output_width", 1600),
|
||||
@@ -215,6 +217,12 @@ def _fsync_directory(path: Path) -> None:
|
||||
|
||||
|
||||
def _validate(settings: Settings) -> None:
|
||||
if (
|
||||
len(settings.realm) > 64
|
||||
or not settings.realm.strip()
|
||||
or any(ord(character) < 32 for character in settings.realm)
|
||||
):
|
||||
raise ValueError("realm must be a non-empty display name")
|
||||
for name, value in (
|
||||
("width", settings.width),
|
||||
("height", settings.height),
|
||||
|
||||
480
ptlinux/ui.py
480
ptlinux/ui.py
@@ -17,7 +17,16 @@ import webbrowser
|
||||
from typing import Callable, Mapping
|
||||
|
||||
from . import __version__
|
||||
from .depot import DepotClient, DepotError, DepotSnapshot
|
||||
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,
|
||||
@@ -48,6 +57,11 @@ from .self_update import (
|
||||
check_for_launcher_update,
|
||||
read_cached_launcher_update_check,
|
||||
)
|
||||
from .server_status import (
|
||||
RealmServerStatus,
|
||||
ServerStatusError,
|
||||
fetch_server_status,
|
||||
)
|
||||
|
||||
|
||||
MAX_NEWS_CARDS = 3
|
||||
@@ -70,6 +84,7 @@ DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS = 10_000
|
||||
DASHBOARD_TRANSITION_STEP_MILLISECONDS = 16
|
||||
DASHBOARD_TRANSITION_STEPS = 9
|
||||
DASHBOARD_TRANSITION_MINIMUM_OPACITY = 0.18
|
||||
PRELOADER_STATUS_CHARACTERS = 64
|
||||
DASHBOARD_STARTUP_COMPONENTS = frozenset(
|
||||
{
|
||||
"broadcast",
|
||||
@@ -78,6 +93,7 @@ DASHBOARD_STARTUP_COMPONENTS = frozenset(
|
||||
"logo",
|
||||
"news",
|
||||
"news_artwork",
|
||||
"server_status",
|
||||
"version",
|
||||
}
|
||||
)
|
||||
@@ -92,6 +108,22 @@ class LauncherUpdatePresentation:
|
||||
badge_text: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GameSyncPresentation:
|
||||
"""User-facing wording for one managed-game synchronization."""
|
||||
|
||||
operation_label: str
|
||||
file_progress_verb: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServerStatusPresentation:
|
||||
"""Text and Tk style for one official realm-health result."""
|
||||
|
||||
text: str
|
||||
style: str
|
||||
|
||||
|
||||
class DashboardError(RuntimeError):
|
||||
"""Raised when the native dashboard cannot be displayed."""
|
||||
|
||||
@@ -148,6 +180,61 @@ def game_client_activity_text(running_count: int) -> str:
|
||||
return f"{running_count} {label} running"
|
||||
|
||||
|
||||
def game_sync_presentation(
|
||||
*,
|
||||
installed: bool,
|
||||
repair: bool = False,
|
||||
) -> GameSyncPresentation:
|
||||
"""Distinguish a fresh download from an update or repair."""
|
||||
|
||||
if not installed:
|
||||
return GameSyncPresentation(
|
||||
operation_label=(
|
||||
"Installing and verifying official game files…"
|
||||
if repair
|
||||
else "Installing official game files…"
|
||||
),
|
||||
file_progress_verb="Downloading",
|
||||
)
|
||||
if repair:
|
||||
return GameSyncPresentation(
|
||||
operation_label="Checking every game file against the official depot…",
|
||||
file_progress_verb="Repairing",
|
||||
)
|
||||
return GameSyncPresentation(
|
||||
operation_label="Synchronizing official game files…",
|
||||
file_progress_verb="Updating",
|
||||
)
|
||||
|
||||
|
||||
def game_file_progress_status(current_file: str, verb: str) -> str:
|
||||
"""Render the current depot asset with the operation's exact intent."""
|
||||
|
||||
return f"{verb} {current_file}"
|
||||
|
||||
|
||||
def server_status_presentation(status: RealmServerStatus) -> ServerStatusPresentation:
|
||||
if status.online is True:
|
||||
players = (
|
||||
f" · {status.players_online} online"
|
||||
if status.players_online is not None
|
||||
else " · online"
|
||||
)
|
||||
return ServerStatusPresentation(
|
||||
text=f"● {status.name}{players}",
|
||||
style="ServerOnline.TLabel",
|
||||
)
|
||||
if status.online is False:
|
||||
return ServerStatusPresentation(
|
||||
text=f"● {status.name} · offline",
|
||||
style="ServerOffline.TLabel",
|
||||
)
|
||||
return ServerStatusPresentation(
|
||||
text=f"○ {status.name} · status unknown",
|
||||
style="ServerUnknown.TLabel",
|
||||
)
|
||||
|
||||
|
||||
def launcher_update_presentation(
|
||||
check: LauncherUpdateCheck,
|
||||
) -> LauncherUpdatePresentation:
|
||||
@@ -200,6 +287,29 @@ def dashboard_transition_opacity(step: int, *, fading_in: bool) -> float:
|
||||
return 1.0 - opacity_range * progress
|
||||
|
||||
|
||||
def preloader_status_text(
|
||||
value: str,
|
||||
step: int | None = None,
|
||||
total: int | None = None,
|
||||
) -> str:
|
||||
"""Render one launch stage without letting its widget change shape."""
|
||||
|
||||
if step is None or total is None:
|
||||
return value
|
||||
return f"Step {step} of {total} · {value}"
|
||||
|
||||
|
||||
def freeze_preloader_geometry(frame: tk.Misc) -> tuple[int, int]:
|
||||
"""Lock a measured preloader frame so later status text cannot reflow it."""
|
||||
|
||||
frame.update_idletasks()
|
||||
width = max(1, frame.winfo_reqwidth())
|
||||
height = max(1, frame.winfo_reqheight())
|
||||
frame.configure(width=width, height=height)
|
||||
frame.pack_propagate(False)
|
||||
return width, height
|
||||
|
||||
|
||||
def launcher_restart_spec(
|
||||
paths: AppPaths,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
@@ -228,6 +338,12 @@ def launcher_restart_spec(
|
||||
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.
|
||||
|
||||
@@ -347,7 +463,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
root.configure(background="#0c0908")
|
||||
root.withdraw()
|
||||
try:
|
||||
ensure_settings(paths)
|
||||
startup_settings = ensure_settings(paths)
|
||||
except (OSError, ValueError) as startup_error:
|
||||
root.deiconify()
|
||||
messagebox.showerror(
|
||||
@@ -425,6 +541,37 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
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",
|
||||
@@ -494,6 +641,56 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
fieldbackground=[("readonly", "#100d0c")],
|
||||
foreground=[("readonly", "#f8ead2")],
|
||||
)
|
||||
style.configure(
|
||||
"Realm.TCombobox",
|
||||
fieldbackground="#211916",
|
||||
background="#211916",
|
||||
foreground="#fff0d2",
|
||||
arrowcolor="#d8aa61",
|
||||
bordercolor="#4a3528",
|
||||
lightcolor="#4a3528",
|
||||
darkcolor="#4a3528",
|
||||
font=("Sans", 10, "bold"),
|
||||
padding=(10, 7),
|
||||
relief="flat",
|
||||
)
|
||||
style.map(
|
||||
"Realm.TCombobox",
|
||||
fieldbackground=[
|
||||
("disabled", "#171412"),
|
||||
("readonly", "#211916"),
|
||||
],
|
||||
background=[
|
||||
("disabled", "#171412"),
|
||||
("active", "#2a201c"),
|
||||
("readonly", "#211916"),
|
||||
],
|
||||
foreground=[
|
||||
("disabled", "#887a70"),
|
||||
("readonly", "#fff0d2"),
|
||||
],
|
||||
arrowcolor=[
|
||||
("disabled", "#887a70"),
|
||||
("active", "#f0d7a2"),
|
||||
("readonly", "#d8aa61"),
|
||||
],
|
||||
bordercolor=[
|
||||
("disabled", "#4a3528"),
|
||||
("focus", "#6a4d38"),
|
||||
("readonly", "#4a3528"),
|
||||
],
|
||||
)
|
||||
root.option_add("*realm_selector*Listbox.background", "#211916")
|
||||
root.option_add("*realm_selector*Listbox.foreground", "#fff0d2")
|
||||
root.option_add("*realm_selector*Listbox.selectBackground", "#2a201c")
|
||||
root.option_add("*realm_selector*Listbox.selectForeground", "#f0d7a2")
|
||||
root.option_add("*realm_selector*Listbox.font", "Sans 10 bold")
|
||||
root.option_add("*realm_selector*Listbox.justify", "center")
|
||||
root.option_add("*realm_selector*Listbox.selectBorderWidth", 4)
|
||||
root.option_add("*realm_selector*Listbox.borderWidth", 1)
|
||||
root.option_add("*realm_selector*Listbox.highlightThickness", 0)
|
||||
root.option_add("*realm_selector*Listbox.relief", "flat")
|
||||
root.option_add("*realm_selector*Listbox.activeStyle", "none")
|
||||
style.configure(
|
||||
"Settings.TCheckbutton",
|
||||
background="#191311",
|
||||
@@ -665,11 +862,14 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
text="NATIVE LINUX LAUNCHER",
|
||||
style="SplashTagline.TLabel",
|
||||
).pack(anchor="w", pady=(2, 17))
|
||||
ttk.Label(
|
||||
startup_status_label = ttk.Label(
|
||||
startup_shell,
|
||||
textvariable=startup_status,
|
||||
style="SplashStatus.TLabel",
|
||||
).pack(anchor="w", pady=(0, 8))
|
||||
width=PRELOADER_STATUS_CHARACTERS,
|
||||
anchor="w",
|
||||
)
|
||||
startup_status_label.pack(fill="x", pady=(0, 8))
|
||||
startup_progress = ttk.Progressbar(
|
||||
startup_shell,
|
||||
style="Depot.Horizontal.TProgressbar",
|
||||
@@ -677,6 +877,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
length=420,
|
||||
)
|
||||
startup_progress.pack(fill="x")
|
||||
freeze_preloader_geometry(startup_border)
|
||||
startup_progress.start(12)
|
||||
startup_ready_components: set[str] = set()
|
||||
startup_transition_started = False
|
||||
@@ -921,8 +1122,25 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
|
||||
activity_panel = ttk.Frame(frame, style="Surface.TFrame", padding=(10, 7))
|
||||
activity_panel.pack(fill="x", pady=(0, 8))
|
||||
realm_value = tk.StringVar(value=DEFAULT_DEPOT_START_OPTION.name)
|
||||
activity_action = ttk.Frame(activity_panel, style="Surface.TFrame")
|
||||
activity_action.pack(side="right", fill="y", padx=(12, 0))
|
||||
play_button = ttk.Button(activity_action, text="PLAY", style="Play.TButton")
|
||||
play_button.pack(fill="both", expand=True)
|
||||
realm_selector = ttk.Combobox(
|
||||
activity_action,
|
||||
name="realm_selector",
|
||||
textvariable=realm_value,
|
||||
values=(DEFAULT_DEPOT_START_OPTION.name,),
|
||||
state="readonly",
|
||||
style="Realm.TCombobox",
|
||||
font=("Sans", 10, "bold"),
|
||||
justify="center",
|
||||
width=20,
|
||||
)
|
||||
realm_selector.pack(fill="x", pady=(5, 0))
|
||||
activity_copy = ttk.Frame(activity_panel, style="Surface.TFrame")
|
||||
activity_copy.pack(side="left", fill="x", expand=True, padx=(0, 12))
|
||||
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="")
|
||||
@@ -962,8 +1180,20 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
style="Muted.TLabel",
|
||||
anchor="w",
|
||||
).pack(side="left", fill="x", expand=True)
|
||||
play_button = ttk.Button(activity_panel, text="PLAY", style="Play.TButton")
|
||||
play_button.pack(side="right", fill="y")
|
||||
server_status_row = ttk.Frame(activity_copy, style="Surface.TFrame")
|
||||
server_status_row.pack(fill="x", pady=(5, 1))
|
||||
ttk.Label(
|
||||
server_status_row,
|
||||
text="SERVERS",
|
||||
style="ServerHeading.TLabel",
|
||||
).pack(side="left", fill="y", padx=(0, 8))
|
||||
server_status_content = ttk.Frame(server_status_row, style="Surface.TFrame")
|
||||
server_status_content.pack(side="left", fill="y", anchor="w")
|
||||
ttk.Label(
|
||||
server_status_content,
|
||||
text="Checking official server status…",
|
||||
style="ServerUnknown.TLabel",
|
||||
).pack(side="left", anchor="w")
|
||||
|
||||
news_panel = ttk.Frame(frame, style="Surface.TFrame", padding=(10, 8))
|
||||
news_panel.pack(fill="x", pady=(0, 8), before=controls)
|
||||
@@ -1054,8 +1284,13 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
operation_stage_total = 0
|
||||
operation_message = ""
|
||||
operation_refreshes_compatibility = False
|
||||
operation_file_progress_verb = "Updating"
|
||||
last_elapsed_second = -1
|
||||
official_snapshot: DepotSnapshot | None = None
|
||||
available_start_options: tuple[DepotStartOption, ...] = (DEFAULT_DEPOT_START_OPTION,)
|
||||
preferred_realm = startup_settings.realm
|
||||
latest_server_statuses: tuple[RealmServerStatus, ...] | None = None
|
||||
server_status_placeholder = "Checking official server status…"
|
||||
launcher_update_check: LauncherUpdateCheck | None = None
|
||||
runtime_compatibility: RuntimeCompatibility | None = None
|
||||
compatibility_check_generation = 0
|
||||
@@ -1106,6 +1341,7 @@ def run_dashboard(paths: AppPaths) -> 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")
|
||||
@@ -1143,7 +1379,9 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
transfer_rate.reset()
|
||||
speed_text.set("")
|
||||
if isinstance(current_file, str) and current_file:
|
||||
status.set(f"Updating {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.")
|
||||
|
||||
@@ -1214,8 +1452,15 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
text="Please wait while the game prepares its window.",
|
||||
style="PreloaderStatus.TLabel",
|
||||
).pack(anchor="w")
|
||||
ttk.Label(content, textvariable=status_variable, style="PreloaderMuted.TLabel").pack(
|
||||
status_label = ttk.Label(
|
||||
content,
|
||||
textvariable=status_variable,
|
||||
style="PreloaderMuted.TLabel",
|
||||
width=PRELOADER_STATUS_CHARACTERS,
|
||||
anchor="w",
|
||||
)
|
||||
status_label.pack(
|
||||
fill="x",
|
||||
pady=(4, 10),
|
||||
)
|
||||
progress_widget = ttk.Progressbar(
|
||||
@@ -1227,6 +1472,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
)
|
||||
preloader_progress = progress_widget
|
||||
progress_widget.pack(fill="x")
|
||||
freeze_preloader_geometry(content)
|
||||
window.update_idletasks()
|
||||
width = window.winfo_reqwidth()
|
||||
height = window.winfo_reqheight()
|
||||
@@ -1237,10 +1483,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
|
||||
def set_preloader_status(value: str, step: int | None = None, total: int | None = None) -> None:
|
||||
if preloader_status is not None:
|
||||
if step is not None and total is not None:
|
||||
preloader_status.set(f"Step {step} of {total} · {value}")
|
||||
else:
|
||||
preloader_status.set(value)
|
||||
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)
|
||||
|
||||
@@ -1259,6 +1502,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
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
|
||||
@@ -1267,6 +1511,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
nonlocal operation_stage_total
|
||||
nonlocal operation_message
|
||||
nonlocal operation_refreshes_compatibility
|
||||
nonlocal operation_file_progress_verb
|
||||
nonlocal last_elapsed_second
|
||||
operation_active = True
|
||||
game_window_active = game_clients.running_count > 0
|
||||
@@ -1275,6 +1520,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
operation_stage_total = 0
|
||||
operation_message = ""
|
||||
operation_refreshes_compatibility = game_files_may_change
|
||||
operation_file_progress_verb = file_progress_verb
|
||||
last_elapsed_second = -1
|
||||
progress_updates.clear()
|
||||
transfer_rate.reset()
|
||||
@@ -1658,6 +1904,102 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
|
||||
threading.Thread(target=worker, name="ptlinux-logo", daemon=True).start()
|
||||
|
||||
def render_server_status() -> None:
|
||||
for child in server_status_content.winfo_children():
|
||||
child.destroy()
|
||||
if latest_server_statuses is None:
|
||||
ttk.Label(
|
||||
server_status_content,
|
||||
text=server_status_placeholder,
|
||||
style="ServerUnknown.TLabel",
|
||||
).pack(side="left", anchor="w")
|
||||
return
|
||||
statuses_by_name = {
|
||||
item.name.casefold(): item for item in latest_server_statuses
|
||||
}
|
||||
visible = [
|
||||
statuses_by_name[option.name.casefold()]
|
||||
for option in available_start_options
|
||||
if option.name.casefold() in statuses_by_name
|
||||
]
|
||||
if not visible:
|
||||
ttk.Label(
|
||||
server_status_content,
|
||||
text="Official status is unavailable for this realm.",
|
||||
style="ServerUnknown.TLabel",
|
||||
).pack(side="left", anchor="w")
|
||||
return
|
||||
for index, server in enumerate(visible):
|
||||
presentation = server_status_presentation(server)
|
||||
ttk.Label(
|
||||
server_status_content,
|
||||
text=presentation.text,
|
||||
style=presentation.style,
|
||||
).pack(
|
||||
side="left",
|
||||
anchor="w",
|
||||
padx=(0 if index == 0 else 6, 0),
|
||||
)
|
||||
|
||||
def show_server_status(
|
||||
statuses: tuple[RealmServerStatus, ...] | None,
|
||||
*,
|
||||
placeholder: str = "Server status unavailable.",
|
||||
) -> None:
|
||||
nonlocal latest_server_statuses, server_status_placeholder
|
||||
latest_server_statuses = statuses
|
||||
server_status_placeholder = placeholder
|
||||
render_server_status()
|
||||
|
||||
def install_realm_options(snapshot: DepotSnapshot) -> None:
|
||||
nonlocal available_start_options, preferred_realm
|
||||
catalog = launch_catalog_from_snapshot(snapshot)
|
||||
available_start_options = catalog.start_options
|
||||
selected = select_depot_start_option(catalog, preferred_realm)
|
||||
realm_selector.configure(
|
||||
values=tuple(option.name for option in available_start_options),
|
||||
state="disabled" if operation_active else "readonly",
|
||||
)
|
||||
realm_value.set(selected.name)
|
||||
if selected.name != preferred_realm:
|
||||
preferred_realm = selected.name
|
||||
try:
|
||||
update_settings(paths, realm=selected.name)
|
||||
except (OSError, ValueError) as save_error:
|
||||
append_log(f"Could not save the selected realm: {save_error}")
|
||||
render_server_status()
|
||||
|
||||
def on_realm_selected(_event: object | None = None) -> None:
|
||||
nonlocal preferred_realm
|
||||
selected_name = realm_value.get()
|
||||
selected = next(
|
||||
(
|
||||
option
|
||||
for option in available_start_options
|
||||
if option.name.casefold() == selected_name.casefold()
|
||||
),
|
||||
None,
|
||||
)
|
||||
if selected is None:
|
||||
fallback = select_depot_start_option(
|
||||
launch_catalog_from_snapshot(official_snapshot)
|
||||
if official_snapshot is not None
|
||||
else None,
|
||||
preferred_realm,
|
||||
)
|
||||
realm_value.set(fallback.name)
|
||||
return
|
||||
realm_value.set(selected.name)
|
||||
if preferred_realm == selected.name:
|
||||
return
|
||||
preferred_realm = selected.name
|
||||
try:
|
||||
update_settings(paths, realm=selected.name)
|
||||
except (OSError, ValueError) as save_error:
|
||||
append_log(f"Could not save the selected realm: {save_error}")
|
||||
|
||||
realm_selector.bind("<<ComboboxSelected>>", on_realm_selected)
|
||||
|
||||
def refresh_primary_action(
|
||||
snapshot: DepotSnapshot | None = None,
|
||||
*,
|
||||
@@ -1802,16 +2144,38 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
def news_worker() -> None:
|
||||
events.put(("news", fetch_news(paths.news_cache)))
|
||||
|
||||
def server_status_worker() -> None:
|
||||
try:
|
||||
statuses = fetch_server_status()
|
||||
except ServerStatusError as status_error:
|
||||
events.put(("server_status_error", str(status_error)))
|
||||
else:
|
||||
events.put(("server_status", statuses))
|
||||
|
||||
def version_worker() -> None:
|
||||
try:
|
||||
snapshot = DepotClient().fetch_snapshot()
|
||||
except DepotError as remote_error:
|
||||
events.put(("version_error", str(remote_error)))
|
||||
else:
|
||||
try:
|
||||
cache_depot_launch_catalog(paths.depot_launch_catalog_cache, snapshot)
|
||||
except OSError as cache_error:
|
||||
events.put(
|
||||
(
|
||||
"output",
|
||||
f"Could not cache official realm choices: {cache_error}\n",
|
||||
)
|
||||
)
|
||||
events.put(("version", snapshot))
|
||||
|
||||
threading.Thread(target=broadcast_worker, name="ptlinux-broadcast", daemon=True).start()
|
||||
threading.Thread(target=news_worker, name="ptlinux-news", daemon=True).start()
|
||||
threading.Thread(
|
||||
target=server_status_worker,
|
||||
name="ptlinux-server-status",
|
||||
daemon=True,
|
||||
).start()
|
||||
threading.Thread(target=version_worker, name="ptlinux-version", daemon=True).start()
|
||||
refresh_launcher_update_check()
|
||||
|
||||
@@ -1968,6 +2332,23 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
"broadcast",
|
||||
"Broadcast ready",
|
||||
)
|
||||
elif (
|
||||
kind == "server_status"
|
||||
and isinstance(value, tuple)
|
||||
and all(isinstance(item, RealmServerStatus) for item in value)
|
||||
):
|
||||
show_server_status(value)
|
||||
mark_startup_component_ready(
|
||||
"server_status",
|
||||
"Server status ready",
|
||||
)
|
||||
elif kind == "server_status_error" and isinstance(value, str):
|
||||
show_server_status(None)
|
||||
append_log(f"Server status check unavailable: {value}")
|
||||
mark_startup_component_ready(
|
||||
"server_status",
|
||||
"Server status check complete",
|
||||
)
|
||||
elif kind == "news" and isinstance(value, tuple):
|
||||
show_news(value)
|
||||
mark_startup_component_ready(
|
||||
@@ -2013,6 +2394,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
version.set(
|
||||
f"Launcher {__version__} · {value.version} · depot {value.build}"
|
||||
)
|
||||
install_realm_options(value)
|
||||
refresh_primary_action(value)
|
||||
mark_startup_component_ready(
|
||||
"version",
|
||||
@@ -2074,7 +2456,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
root.after(poll_delay, poll_events)
|
||||
|
||||
def command_for(*arguments: str) -> list[str]:
|
||||
return [sys.executable, "-m", "ptlinux", *arguments]
|
||||
return launcher_child_command(*arguments)
|
||||
|
||||
def run_launcher_update(required_reason: str | None = None) -> None:
|
||||
check = launcher_update_check
|
||||
@@ -2128,15 +2510,51 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
|
||||
def start_game_client() -> None:
|
||||
nonlocal active_game_launch_id
|
||||
if official_snapshot is not None:
|
||||
try:
|
||||
cache_depot_launch_catalog(
|
||||
paths.depot_launch_catalog_cache,
|
||||
official_snapshot,
|
||||
)
|
||||
except OSError as cache_error:
|
||||
messagebox.showerror(
|
||||
"Realm selection",
|
||||
"The launcher could not save the official realm selection:\n\n"
|
||||
f"{cache_error}",
|
||||
parent=root,
|
||||
)
|
||||
return
|
||||
client_id = game_clients.begin()
|
||||
active_game_launch_id = client_id
|
||||
run_operation(
|
||||
f"Starting PristonTale EU client {client_id}…",
|
||||
command_for("play"),
|
||||
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 (
|
||||
@@ -2144,18 +2562,8 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
or (game_clients.running_count > 0 and installation.installed)
|
||||
):
|
||||
start_game_client()
|
||||
elif installation.action is PrimaryAction.INSTALL:
|
||||
run_operation(
|
||||
"Installing official game files…",
|
||||
command_for("update"),
|
||||
game_files_may_change=True,
|
||||
)
|
||||
elif installation.action is PrimaryAction.UPDATE:
|
||||
run_operation(
|
||||
"Synchronizing official game files…",
|
||||
command_for("update"),
|
||||
game_files_may_change=True,
|
||||
)
|
||||
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
|
||||
@@ -2164,20 +2572,8 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
)
|
||||
|
||||
play_button.configure(command=run_primary_action)
|
||||
update_button.configure(
|
||||
command=lambda: run_operation(
|
||||
"Synchronizing official game files…",
|
||||
command_for("update"),
|
||||
game_files_may_change=True,
|
||||
)
|
||||
)
|
||||
repair_button.configure(
|
||||
command=lambda: run_operation(
|
||||
"Checking every game file against the official depot…",
|
||||
command_for("update", "--no-import", "--repair"),
|
||||
game_files_may_change=True,
|
||||
)
|
||||
)
|
||||
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"))
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user