Added proper hotbar/menu icons and added a preloader for the launcher.
This commit is contained in:
@@ -8,3 +8,4 @@ Terminal=false
|
||||
Categories=Game;RolePlaying;
|
||||
Keywords=PristonTale;MMORPG;Wine;Proton;
|
||||
StartupNotify=true
|
||||
StartupWMClass=PristonTaleEU
|
||||
|
||||
@@ -10,8 +10,13 @@ import sys
|
||||
import time
|
||||
from collections.abc import Collection, Iterable, Sequence
|
||||
|
||||
from .desktop_identity import (
|
||||
APPLICATION_NAME,
|
||||
APPLICATION_WM_CLASS,
|
||||
APPLICATION_WM_INSTANCE,
|
||||
)
|
||||
|
||||
WINDOW_TITLE = "PristonTale EU"
|
||||
WINDOW_TITLE = APPLICATION_NAME
|
||||
WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0
|
||||
WINDOW_READY_DELAY_SECONDS = 0.5
|
||||
_X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p)
|
||||
@@ -66,6 +71,13 @@ class _MotifWmHints(ctypes.Structure):
|
||||
]
|
||||
|
||||
|
||||
class _XClassHint(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("res_name", ctypes.c_char_p),
|
||||
("res_class", ctypes.c_char_p),
|
||||
]
|
||||
|
||||
|
||||
class _X11:
|
||||
def __init__(self) -> None:
|
||||
library_path = ctypes.util.find_library("X11") or "libX11.so.6"
|
||||
@@ -135,6 +147,33 @@ class _X11:
|
||||
self.library.XMapRaised(self.display, window)
|
||||
self.library.XFlush(self.display)
|
||||
|
||||
def set_window_identity(
|
||||
self,
|
||||
window: int,
|
||||
*,
|
||||
resource_name: str = APPLICATION_WM_INSTANCE,
|
||||
resource_class: str = APPLICATION_WM_CLASS,
|
||||
) -> None:
|
||||
"""Replace Proton's generic steamapp identity before taskbar matching."""
|
||||
|
||||
for label, value in (
|
||||
("resource name", resource_name),
|
||||
("resource class", resource_class),
|
||||
):
|
||||
if not value or "\x00" in value:
|
||||
raise GameboxRuntimeError(f"invalid X11 {label}")
|
||||
hints = _XClassHint(
|
||||
res_name=resource_name.encode("utf-8"),
|
||||
res_class=resource_class.encode("utf-8"),
|
||||
)
|
||||
if self.library.XSetClassHint(
|
||||
self.display,
|
||||
window,
|
||||
ctypes.byref(hints),
|
||||
) == 0:
|
||||
raise GameboxRuntimeError("could not set the game window identity")
|
||||
self.library.XSync(self.display, 0)
|
||||
|
||||
def suppress_wm_ping(self, window: int) -> bool:
|
||||
"""Stop the WM hang prompt for one window, preserving its other protocols."""
|
||||
ping_atom = int(self.library.XInternAtom(self.display, b"_NET_WM_PING", 0))
|
||||
@@ -265,6 +304,12 @@ class _X11:
|
||||
ctypes.c_int,
|
||||
]
|
||||
self.library.XChangeProperty.restype = ctypes.c_int
|
||||
self.library.XSetClassHint.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
ctypes.POINTER(_XClassHint),
|
||||
]
|
||||
self.library.XSetClassHint.restype = ctypes.c_int
|
||||
self.library.XGetWMProtocols.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
@@ -325,6 +370,18 @@ def _prepare_game_window(
|
||||
mapper.map_window(window, borderless=borderless)
|
||||
|
||||
|
||||
def _apply_game_window_identity(mapper: _X11, window: int) -> None:
|
||||
try:
|
||||
mapper.set_window_identity(window)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"Warning: could not assign the PristonTale EU taskbar identity ({exc}); "
|
||||
"continuing normally",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def run_game_window(
|
||||
command: Sequence[str],
|
||||
window_title: str = WINDOW_TITLE,
|
||||
@@ -371,6 +428,7 @@ def run_game_window(
|
||||
game_window = window
|
||||
game_title = title
|
||||
game_window_created_at = now
|
||||
_apply_game_window_identity(mapper, window)
|
||||
print(
|
||||
f"Game window created; preparing it: {title}",
|
||||
file=sys.stderr,
|
||||
|
||||
545
ptlinux/ui.py
545
ptlinux/ui.py
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
@@ -17,6 +18,11 @@ from typing import Callable, Mapping
|
||||
|
||||
from . import __version__
|
||||
from .depot import DepotClient, DepotError, DepotSnapshot
|
||||
from .desktop_identity import (
|
||||
APPLICATION_NAME,
|
||||
APPLICATION_WM_CLASS,
|
||||
find_application_icon,
|
||||
)
|
||||
from .install_status import (
|
||||
GameInstallationStatus,
|
||||
PrimaryAction,
|
||||
@@ -59,12 +65,89 @@ 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
|
||||
DASHBOARD_STARTUP_COMPONENTS = frozenset(
|
||||
{
|
||||
"broadcast",
|
||||
"compatibility",
|
||||
"launcher_update",
|
||||
"logo",
|
||||
"news",
|
||||
"news_artwork",
|
||||
"version",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LauncherUpdatePresentation:
|
||||
"""Visual treatment for one authenticated launcher-update result."""
|
||||
|
||||
button_text: str
|
||||
button_style: str
|
||||
badge_text: str | None
|
||||
|
||||
|
||||
class DashboardError(RuntimeError):
|
||||
"""Raised when the native dashboard cannot be displayed."""
|
||||
|
||||
|
||||
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 launcher_restart_spec(
|
||||
paths: AppPaths,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
@@ -193,17 +276,31 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
) from exc
|
||||
|
||||
paths.create_base_directories()
|
||||
root = tk.Tk()
|
||||
root.title("PristonTale EU — Linux Launcher")
|
||||
root = tk.Tk(className=APPLICATION_WM_CLASS)
|
||||
root.title(f"{APPLICATION_NAME} — Linux Launcher")
|
||||
root.iconname(APPLICATION_NAME)
|
||||
window_icon = None
|
||||
window_icon_path = find_application_icon()
|
||||
if window_icon_path is not None:
|
||||
try:
|
||||
window_icon = tk.PhotoImage(master=root, file=str(window_icon_path))
|
||||
root.iconphoto(True, window_icon)
|
||||
except (OSError, tk.TclError) as icon_error:
|
||||
print(
|
||||
f"Warning: could not load the launcher icon: {icon_error}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
root.geometry(WINDOW_SIZE)
|
||||
root.resizable(False, False)
|
||||
root.configure(background="#0c0908")
|
||||
root.withdraw()
|
||||
try:
|
||||
ensure_settings(paths)
|
||||
except (OSError, ValueError) as error:
|
||||
except (OSError, ValueError) as startup_error:
|
||||
root.deiconify()
|
||||
messagebox.showerror(
|
||||
"Launcher settings",
|
||||
f"The launcher could not load its settings:\n\n{error}",
|
||||
f"The launcher could not load its settings:\n\n{startup_error}",
|
||||
parent=root,
|
||||
)
|
||||
root.destroy()
|
||||
@@ -227,6 +324,25 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
foreground="#a8998a",
|
||||
font=("Sans", 8),
|
||||
)
|
||||
style.configure("Splash.TFrame", background="#191311")
|
||||
style.configure(
|
||||
"SplashBrand.TLabel",
|
||||
background="#191311",
|
||||
foreground="#f5d18b",
|
||||
font=("Georgia", 22, "bold"),
|
||||
)
|
||||
style.configure(
|
||||
"SplashTagline.TLabel",
|
||||
background="#191311",
|
||||
foreground="#d8aa61",
|
||||
font=("Sans", 8, "bold"),
|
||||
)
|
||||
style.configure(
|
||||
"SplashStatus.TLabel",
|
||||
background="#191311",
|
||||
foreground="#c9baaa",
|
||||
font=("Sans", 9),
|
||||
)
|
||||
style.configure(
|
||||
"Section.TLabel",
|
||||
background="#191311",
|
||||
@@ -397,6 +513,43 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
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",
|
||||
@@ -428,6 +581,203 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
darkcolor="#b66c25",
|
||||
)
|
||||
|
||||
startup_started_at = time.monotonic()
|
||||
startup_status = tk.StringVar(value="Loading dashboard contents…")
|
||||
startup_overlay = tk.Frame(
|
||||
root,
|
||||
background="#0c0908",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
)
|
||||
startup_overlay.place(x=0, y=0, relwidth=1, relheight=1)
|
||||
startup_border = tk.Frame(
|
||||
startup_overlay,
|
||||
background="#d98927",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
)
|
||||
startup_border.place(relx=0.5, rely=0.5, anchor="center")
|
||||
startup_shell = ttk.Frame(
|
||||
startup_border,
|
||||
style="Splash.TFrame",
|
||||
padding=(26, 22),
|
||||
)
|
||||
startup_shell.pack(fill="both", expand=True, padx=1, pady=1)
|
||||
ttk.Label(
|
||||
startup_shell,
|
||||
text="PRISTONTALE EU",
|
||||
style="SplashBrand.TLabel",
|
||||
).pack(anchor="w")
|
||||
ttk.Label(
|
||||
startup_shell,
|
||||
text="NATIVE LINUX LAUNCHER",
|
||||
style="SplashTagline.TLabel",
|
||||
).pack(anchor="w", pady=(2, 17))
|
||||
ttk.Label(
|
||||
startup_shell,
|
||||
textvariable=startup_status,
|
||||
style="SplashStatus.TLabel",
|
||||
).pack(anchor="w", pady=(0, 8))
|
||||
startup_progress = ttk.Progressbar(
|
||||
startup_shell,
|
||||
style="Depot.Horizontal.TProgressbar",
|
||||
mode="indeterminate",
|
||||
length=420,
|
||||
)
|
||||
startup_progress.pack(fill="x")
|
||||
startup_progress.start(12)
|
||||
startup_ready_components: set[str] = set()
|
||||
startup_transition_started = False
|
||||
startup_ready_after: str | None = None
|
||||
startup_timeout_after: str | None = None
|
||||
root.deiconify()
|
||||
root.lift()
|
||||
startup_overlay.lift()
|
||||
root.update()
|
||||
|
||||
def update_startup_progress(message: str) -> None:
|
||||
if startup_transition_started:
|
||||
return
|
||||
startup_progress.stop()
|
||||
startup_progress.configure(
|
||||
mode="determinate",
|
||||
maximum=len(DASHBOARD_STARTUP_COMPONENTS),
|
||||
value=len(startup_ready_components),
|
||||
)
|
||||
startup_status.set(
|
||||
f"{message} · {len(startup_ready_components)}/"
|
||||
f"{len(DASHBOARD_STARTUP_COMPONENTS)}"
|
||||
)
|
||||
|
||||
def cancel_startup_after(identifier: str | None) -> None:
|
||||
if identifier is None:
|
||||
return
|
||||
try:
|
||||
root.after_cancel(identifier)
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
def set_startup_component_pending(component: str) -> None:
|
||||
nonlocal startup_ready_after
|
||||
if startup_transition_started or component not in DASHBOARD_STARTUP_COMPONENTS:
|
||||
return
|
||||
if component not in startup_ready_components:
|
||||
return
|
||||
startup_ready_components.remove(component)
|
||||
cancel_startup_after(startup_ready_after)
|
||||
startup_ready_after = None
|
||||
update_startup_progress("Loading dashboard content…")
|
||||
|
||||
def mark_startup_component_ready(component: str, message: str) -> None:
|
||||
if startup_transition_started or component not in DASHBOARD_STARTUP_COMPONENTS:
|
||||
return
|
||||
startup_ready_components.add(component)
|
||||
update_startup_progress(message)
|
||||
maybe_start_dashboard_transition()
|
||||
|
||||
def set_root_opacity(value: float) -> bool:
|
||||
try:
|
||||
root.attributes("-alpha", value)
|
||||
except tk.TclError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def finish_dashboard_transition() -> None:
|
||||
set_root_opacity(1.0)
|
||||
root.lift()
|
||||
root.focus_set()
|
||||
|
||||
def fade_dashboard_in(step: int = 0) -> None:
|
||||
if not set_root_opacity(
|
||||
dashboard_transition_opacity(step, fading_in=True)
|
||||
):
|
||||
finish_dashboard_transition()
|
||||
return
|
||||
if step >= DASHBOARD_TRANSITION_STEPS:
|
||||
finish_dashboard_transition()
|
||||
return
|
||||
root.after(
|
||||
DASHBOARD_TRANSITION_STEP_MILLISECONDS,
|
||||
lambda: fade_dashboard_in(step + 1),
|
||||
)
|
||||
|
||||
def swap_loader_for_dashboard(*, fade_supported: bool) -> None:
|
||||
if startup_overlay.winfo_exists():
|
||||
startup_overlay.destroy()
|
||||
root.update_idletasks()
|
||||
if fade_supported:
|
||||
fade_dashboard_in()
|
||||
else:
|
||||
finish_dashboard_transition()
|
||||
|
||||
def fade_loader_out(step: int = 0) -> None:
|
||||
fade_supported = set_root_opacity(
|
||||
dashboard_transition_opacity(step, fading_in=False)
|
||||
)
|
||||
if not fade_supported or step >= DASHBOARD_TRANSITION_STEPS:
|
||||
swap_loader_for_dashboard(fade_supported=fade_supported)
|
||||
return
|
||||
root.after(
|
||||
DASHBOARD_TRANSITION_STEP_MILLISECONDS,
|
||||
lambda: fade_loader_out(step + 1),
|
||||
)
|
||||
|
||||
def begin_dashboard_transition(*, timed_out: bool = False) -> None:
|
||||
nonlocal startup_transition_started
|
||||
nonlocal startup_ready_after, startup_timeout_after
|
||||
if startup_transition_started:
|
||||
return
|
||||
startup_transition_started = True
|
||||
cancel_startup_after(startup_ready_after)
|
||||
cancel_startup_after(startup_timeout_after)
|
||||
startup_ready_after = None
|
||||
startup_timeout_after = None
|
||||
if timed_out:
|
||||
settle_pending_news_artwork()
|
||||
if "version" not in startup_ready_components:
|
||||
version.set(
|
||||
f"Launcher {__version__} · game build check continuing in background"
|
||||
)
|
||||
startup_progress.stop()
|
||||
startup_progress.configure(
|
||||
mode="determinate",
|
||||
maximum=len(DASHBOARD_STARTUP_COMPONENTS),
|
||||
value=len(DASHBOARD_STARTUP_COMPONENTS),
|
||||
)
|
||||
startup_status.set(
|
||||
"Opening dashboard with available content…"
|
||||
if timed_out
|
||||
else "Opening dashboard…"
|
||||
)
|
||||
startup_overlay.update_idletasks()
|
||||
root.after(80, fade_loader_out)
|
||||
|
||||
def maybe_start_dashboard_transition() -> None:
|
||||
nonlocal startup_ready_after
|
||||
if startup_transition_started or not dashboard_content_ready(
|
||||
startup_ready_components
|
||||
):
|
||||
return
|
||||
remaining = dashboard_preloader_delay(startup_started_at)
|
||||
if remaining <= 0:
|
||||
begin_dashboard_transition()
|
||||
return
|
||||
if startup_ready_after is None:
|
||||
startup_ready_after = root.after(
|
||||
remaining,
|
||||
start_dashboard_after_minimum,
|
||||
)
|
||||
|
||||
def start_dashboard_after_minimum() -> None:
|
||||
nonlocal startup_ready_after
|
||||
startup_ready_after = None
|
||||
maybe_start_dashboard_transition()
|
||||
|
||||
def timeout_startup_preloader() -> None:
|
||||
nonlocal startup_timeout_after
|
||||
startup_timeout_after = None
|
||||
begin_dashboard_transition(timed_out=True)
|
||||
|
||||
frame = ttk.Frame(root, style="Root.TFrame", padding=12)
|
||||
frame.pack(fill="both", expand=True)
|
||||
|
||||
@@ -441,6 +791,14 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
highlightthickness=0,
|
||||
borderwidth=0,
|
||||
)
|
||||
logo.create_oval(4, 4, 38, 38, outline="#d98927", width=2)
|
||||
logo.create_text(
|
||||
21,
|
||||
21,
|
||||
text="PT",
|
||||
fill="#f5d18b",
|
||||
font=("Georgia", 9, "bold"),
|
||||
)
|
||||
logo.pack(side="left", padx=(0, 9))
|
||||
brand = ttk.Frame(header, style="Root.TFrame")
|
||||
brand.pack(side="left", fill="x", expand=True)
|
||||
@@ -488,7 +846,14 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
|
||||
system_actions = ttk.Frame(controls, style="Surface.TFrame", padding=12)
|
||||
system_actions.grid(row=0, column=1, sticky="nsew", padx=(4, 0))
|
||||
ttk.Label(system_actions, text="LAUNCHER", style="Section.TLabel").pack(anchor="w")
|
||||
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")
|
||||
@@ -609,6 +974,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
transfer_rate = TransferRateEstimator()
|
||||
news_image_targets: dict[int, tk.Canvas] = {}
|
||||
news_image_references: dict[int, tk.PhotoImage] = {}
|
||||
startup_news_images_pending: set[int] = set()
|
||||
logo_image_references: list[tk.PhotoImage] = []
|
||||
preloader: tk.Toplevel | None = None
|
||||
preloader_status: tk.StringVar | None = None
|
||||
@@ -980,6 +1346,11 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
text="Official news is temporarily unavailable. Your cached game can still play.",
|
||||
style="Muted.TLabel",
|
||||
).pack(anchor="w", padx=8, pady=8)
|
||||
startup_news_images_pending.clear()
|
||||
mark_startup_component_ready(
|
||||
"news_artwork",
|
||||
"News layout ready",
|
||||
)
|
||||
return
|
||||
visible_articles = articles[:MAX_NEWS_CARDS]
|
||||
for column in range(len(visible_articles)):
|
||||
@@ -1046,32 +1417,53 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
load_news_images(visible_articles)
|
||||
|
||||
def load_news_images(articles: tuple[NewsArticle, ...]) -> None:
|
||||
image_articles = tuple(
|
||||
article for article in articles if article.image_url is not None
|
||||
)
|
||||
startup_news_images_pending.clear()
|
||||
startup_news_images_pending.update(article.id for article in image_articles)
|
||||
if startup_news_images_pending:
|
||||
set_startup_component_pending("news_artwork")
|
||||
else:
|
||||
mark_startup_component_ready(
|
||||
"news_artwork",
|
||||
"News artwork ready",
|
||||
)
|
||||
|
||||
def worker(news_article: NewsArticle) -> None:
|
||||
image = fetch_news_image(news_article, paths.news_image_cache)
|
||||
if image is not None:
|
||||
events.put(("news_image", (news_article.id, image)))
|
||||
events.put(("news_image", (news_article.id, image)))
|
||||
|
||||
for article in articles:
|
||||
if article.image_url is not None:
|
||||
threading.Thread(
|
||||
target=worker,
|
||||
args=(article,),
|
||||
name="ptlinux-news-image",
|
||||
daemon=True,
|
||||
).start()
|
||||
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 not isinstance(value[1], bytes)
|
||||
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:
|
||||
@@ -1104,6 +1496,32 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
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
|
||||
@@ -1122,8 +1540,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
def load_official_logo() -> None:
|
||||
def worker() -> None:
|
||||
image = fetch_official_logo(paths.logo_cache)
|
||||
if image is not None:
|
||||
events.put(("logo_image", image))
|
||||
events.put(("logo_image", image))
|
||||
|
||||
threading.Thread(target=worker, name="ptlinux-logo", daemon=True).start()
|
||||
|
||||
@@ -1191,6 +1608,10 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
local_status = inspect_game_installation(paths)
|
||||
refresh_primary_action(update_activity=update_activity)
|
||||
if not local_status.installed or game_import_requires_verification(paths):
|
||||
mark_startup_component_ready(
|
||||
"compatibility",
|
||||
"Game state ready",
|
||||
)
|
||||
return
|
||||
|
||||
def worker() -> None:
|
||||
@@ -1215,21 +1636,18 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
) -> None:
|
||||
nonlocal launcher_update_check
|
||||
launcher_update_check = check
|
||||
if check.available and check.supported:
|
||||
launcher_update_button.configure(
|
||||
text=f"UPDATE TO {check.latest_version}",
|
||||
state="normal",
|
||||
)
|
||||
elif check.available:
|
||||
launcher_update_button.configure(
|
||||
text="MANUAL UPDATE NEEDED",
|
||||
state="normal",
|
||||
)
|
||||
presentation = launcher_update_presentation(check)
|
||||
launcher_update_button.configure(
|
||||
text=presentation.button_text,
|
||||
style=presentation.button_style,
|
||||
state="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_button.configure(
|
||||
text=f"LAUNCHER {__version__}",
|
||||
state="normal",
|
||||
)
|
||||
launcher_update_badge.pack_forget()
|
||||
if log_reason:
|
||||
append_log(check.reason)
|
||||
|
||||
@@ -1339,12 +1757,24 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
root.iconify()
|
||||
elif kind == "broadcast":
|
||||
show_broadcast(value if isinstance(value, Broadcast) else None)
|
||||
mark_startup_component_ready(
|
||||
"broadcast",
|
||||
"Broadcast ready",
|
||||
)
|
||||
elif kind == "news" and isinstance(value, tuple):
|
||||
show_news(value)
|
||||
mark_startup_component_ready(
|
||||
"news",
|
||||
"News cards ready",
|
||||
)
|
||||
elif kind == "news_image":
|
||||
install_news_image(value)
|
||||
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)
|
||||
@@ -1368,28 +1798,50 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
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}"
|
||||
)
|
||||
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="normal",
|
||||
)
|
||||
launcher_update_badge.pack_forget()
|
||||
append_log(f"Launcher update check unavailable: {value}")
|
||||
mark_startup_component_ready(
|
||||
"launcher_update",
|
||||
"Launcher update check complete",
|
||||
)
|
||||
except queue.Empty:
|
||||
pass
|
||||
if operation_active:
|
||||
@@ -1399,14 +1851,16 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
if speed_text.get() != "0 B/s" and transfer_rate.is_idle():
|
||||
speed_text.set("0 B/s")
|
||||
update_elapsed_indicator()
|
||||
root.after(
|
||||
dashboard_poll_interval(
|
||||
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(),
|
||||
),
|
||||
poll_events,
|
||||
)
|
||||
)
|
||||
root.after(poll_delay, poll_events)
|
||||
|
||||
def command_for(*arguments: str) -> list[str]:
|
||||
return [sys.executable, "-m", "ptlinux", *arguments]
|
||||
@@ -1414,7 +1868,12 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
def run_launcher_update(required_reason: str | None = None) -> None:
|
||||
check = launcher_update_check
|
||||
if check is None:
|
||||
launcher_update_button.configure(text="CHECKING…", state="disabled")
|
||||
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.")
|
||||
@@ -1824,6 +2283,9 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
|
||||
news_content.bind("<Configure>", update_news_viewport)
|
||||
news_canvas.bind("<Configure>", resize_news_content)
|
||||
startup_overlay.lift()
|
||||
startup_status.set("Restoring dashboard content…")
|
||||
startup_overlay.update_idletasks()
|
||||
show_broadcast(read_cached_broadcast(paths.broadcast_cache))
|
||||
show_news(read_cached_news(paths.news_cache))
|
||||
cached_launcher_update = read_cached_launcher_update_check(
|
||||
@@ -1835,7 +2297,14 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user