Added server status and server selection dropdown.

This commit is contained in:
2026-08-30 22:15:32 +02:00
parent 562c924809
commit bc87f10661
3 changed files with 513 additions and 50 deletions

View File

@@ -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"))
)