Added support for multiple clients to be ran at the same time.

This commit is contained in:
2026-08-30 12:32:39 +02:00
parent 994fda5cc7
commit 94f2260f43
4 changed files with 453 additions and 64 deletions

View File

@@ -10,7 +10,7 @@ import json
from pathlib import Path from pathlib import Path
import sys import sys
import time import time
from typing import Sequence from typing import Callable, Sequence
from ptdiag.logs import ( from ptdiag.logs import (
LogFileState, LogFileState,
@@ -33,12 +33,13 @@ from .launcher import (
launch_official_updater, launch_official_updater,
prepare_launcher_binary, prepare_launcher_binary,
) )
from .locking import launcher_operation_lock from .locking import LauncherPlayLease, launcher_operation_lock, launcher_play_lock
from .login_diagnostics import inspect_login_environment from .login_diagnostics import inspect_login_environment
from .login_trace import DEFAULT_TRACE_TIMEOUT_SECONDS, LOGIN_TRACE_DISABLED_REASON from .login_trace import DEFAULT_TRACE_TIMEOUT_SECONDS, LOGIN_TRACE_DISABLED_REASON
from .overlay import cache_compatibility_binaries, prepare_runtime_view from .overlay import cache_compatibility_binaries, prepare_runtime_view
from .paths import AppPaths from .paths import AppPaths
from .runtime import ( from .runtime import (
game_runtime_ready,
prepare_game_runtime, prepare_game_runtime,
run_game, run_game,
select_runtime, select_runtime,
@@ -687,6 +688,7 @@ def _maybe_import_game(
def _prepare(paths: AppPaths, game_dir: Path | None, as_json: bool) -> int: def _prepare(paths: AppPaths, game_dir: Path | None, as_json: bool) -> int:
with _operation_guard(paths, "prepare", False):
if game_dir is None and game_import_requires_verification(paths): if game_dir is None and game_import_requires_verification(paths):
raise LauncherError( raise LauncherError(
"the imported game has not completed its full publisher verification; " "the imported game has not completed its full publisher verification; "
@@ -707,7 +709,7 @@ def _play(
skip_runtime_setup: bool, skip_runtime_setup: bool,
as_json: bool, as_json: bool,
) -> int: ) -> int:
with _operation_guard(paths, "play", dry_run): with _play_guard(paths, dry_run) as play_lease:
return _play_locked( return _play_locked(
paths, paths,
game_dir, game_dir,
@@ -715,6 +717,8 @@ def _play(
dry_run, dry_run,
skip_runtime_setup, skip_runtime_setup,
as_json, as_json,
reuse_prepared=not play_lease.preparation_allowed,
before_game=play_lease.allow_shared_clients,
) )
@@ -725,6 +729,9 @@ def _play_locked(
dry_run: bool, dry_run: bool,
skip_runtime_setup: bool, skip_runtime_setup: bool,
as_json: bool, as_json: bool,
*,
reuse_prepared: bool = False,
before_game: Callable[[], None] | None = None,
) -> int: ) -> int:
if game_dir is None and game_import_requires_verification(paths): if game_dir is None and game_import_requires_verification(paths):
raise LauncherError( raise LauncherError(
@@ -734,16 +741,49 @@ def _play_locked(
settings = _load_or_default_settings(paths, persist=not dry_run) settings = _load_or_default_settings(paths, persist=not dry_run)
source = _source_game(paths, settings, game_dir) source = _source_game(paths, settings, game_dir)
overlay = None overlay = None
_print_operation_status(as_json, 1, 4, "Preparing the managed game view…") _print_operation_status(
if not dry_run: as_json,
1,
4,
(
"Reusing the active managed game view…"
if reuse_prepared
else "Preparing the managed game view…"
),
)
if reuse_prepared:
if game_dir is not None:
raise LauncherError(
"an additional client cannot replace the active managed game view; "
"close every client before using --game-dir"
)
if not paths.runtime_view.is_dir():
raise LauncherError(
"another game client is active, but the prepared runtime view is missing"
)
elif not dry_run:
overlay = prepare_runtime_view(paths, source) overlay = prepare_runtime_view(paths, source)
_print_operation_status(as_json, 2, 4, "Selecting the configured game runtime…") _print_operation_status(as_json, 2, 4, "Selecting the configured game runtime…")
selection = select_runtime(settings) selection = select_runtime(settings)
runner = _runner(paths, "play", dry_run) runner = _runner(paths, "play", dry_run)
setup_results = [] setup_results = []
if not skip_runtime_setup: if reuse_prepared:
_print_operation_status(
as_json,
3,
4,
"Reusing the active Proton and DirectX runtime…",
)
if not game_runtime_ready(paths, selection):
raise LauncherError(
"another game client is active, but its prepared runtime no longer "
"matches the current settings; close every client and launch again"
)
elif not skip_runtime_setup:
_print_operation_status(as_json, 3, 4, "Preparing Proton and DirectX components…") _print_operation_status(as_json, 3, 4, "Preparing Proton and DirectX components…")
setup_results = prepare_game_runtime(paths, selection, runner) setup_results = prepare_game_runtime(paths, selection, runner)
else:
_print_operation_status(as_json, 3, 4, "Using the existing Proton runtime…")
_print_operation_status(as_json, 4, 4, "Starting the PristonTale game client…") _print_operation_status(as_json, 4, 4, "Starting the PristonTale game client…")
native_log_directory = paths.game_save / "logs" native_log_directory = paths.game_save / "logs"
native_log_baseline = snapshot_native_logs( native_log_baseline = snapshot_native_logs(
@@ -751,6 +791,8 @@ def _play_locked(
patterns=("log_*.txt",), patterns=("log_*.txt",),
) )
game_started_ns = time.time_ns() game_started_ns = time.time_ns()
if before_game is not None:
before_game()
try: try:
outcome = run_game( outcome = run_game(
paths, paths,
@@ -1000,10 +1042,19 @@ def _load_or_default_settings(paths: AppPaths, *, persist: bool) -> Settings:
def _runner(paths: AppPaths, operation: str, dry_run: bool) -> CommandRunner: def _runner(paths: AppPaths, operation: str, dry_run: bool) -> CommandRunner:
timestamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S") timestamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S-%f")
return CommandRunner(paths.logs / f"{timestamp}-{operation}.jsonl", dry_run=dry_run) return CommandRunner(paths.logs / f"{timestamp}-{operation}.jsonl", dry_run=dry_run)
def _play_guard(
paths: AppPaths,
dry_run: bool,
) -> AbstractContextManager[LauncherPlayLease]:
if dry_run:
return nullcontext(LauncherPlayLease.dry_run())
return launcher_play_lock(paths.launcher_operation_lock)
def _operation_guard( def _operation_guard(
paths: AppPaths, paths: AppPaths,
operation: str, operation: str,

View File

@@ -7,13 +7,51 @@ from datetime import datetime
import fcntl import fcntl
import os import os
from pathlib import Path from pathlib import Path
from typing import Iterator from typing import Iterator, TextIO
class OperationBusyError(RuntimeError): class OperationBusyError(RuntimeError):
"""Raised when another launcher-prefix operation is still active.""" """Raised when another launcher-prefix operation is still active."""
class LauncherPlayLease:
"""Exclusive preparation lease that can become a shared game lease."""
def __init__(
self,
handle: TextIO | None,
*,
preparation_allowed: bool,
record_owned: bool,
) -> None:
self._handle = handle
self.preparation_allowed = preparation_allowed
self._record_owned = record_owned
self._shared = not preparation_allowed
@classmethod
def dry_run(cls) -> LauncherPlayLease:
return cls(None, preparation_allowed=True, record_owned=False)
@property
def shared(self) -> bool:
return self._shared
def allow_shared_clients(self) -> None:
"""Downgrade after all runtime mutations have completed."""
if self._shared:
return
if self._handle is not None:
_write_owner(self._handle, "play")
fcntl.flock(self._handle.fileno(), fcntl.LOCK_SH)
self._shared = True
@property
def record_owned(self) -> bool:
return self._record_owned
@contextmanager @contextmanager
def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]: def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
lock_path.parent.mkdir(parents=True, exist_ok=True) lock_path.parent.mkdir(parents=True, exist_ok=True)
@@ -26,13 +64,7 @@ def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
raise OperationBusyError( raise OperationBusyError(
f"launcher files are busy ({owner}); wait for it to finish and try again" f"launcher files are busy ({owner}); wait for it to finish and try again"
) from exc ) from exc
handle.seek(0) _write_owner(handle, operation)
handle.truncate()
handle.write(
f"{operation}, pid={os.getpid()}, "
f"started={datetime.now().astimezone().isoformat()}\n"
)
handle.flush()
try: try:
yield yield
finally: finally:
@@ -40,3 +72,67 @@ def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
handle.truncate() handle.truncate()
handle.flush() handle.flush()
fcntl.flock(handle.fileno(), fcntl.LOCK_UN) fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
@contextmanager
def launcher_play_lock(
lock_path: Path,
operation: str = "play",
) -> Iterator[LauncherPlayLease]:
"""Serialize preparation, then allow multiple active game clients.
The first play process acquires an exclusive lease so it may safely rebuild
the runtime view or prefix. It downgrades that lease immediately before
starting Game.exe. Later play processes then receive shared, read-only leases,
while updates and repairs remain excluded until every client exits.
"""
lock_path.parent.mkdir(parents=True, exist_ok=True)
with lock_path.open("a+", encoding="utf-8") as handle:
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_SH | fcntl.LOCK_NB)
except BlockingIOError as exc:
owner = _read_owner(handle)
raise OperationBusyError(
f"launcher files are busy ({owner}); wait for it to finish and try again"
) from exc
lease = LauncherPlayLease(
handle,
preparation_allowed=False,
record_owned=False,
)
else:
_write_owner(handle, f"{operation} preparation")
lease = LauncherPlayLease(
handle,
preparation_allowed=True,
record_owned=True,
)
try:
yield lease
finally:
# A downgraded owner record remains useful while any secondary shared
# lease is active. The next exclusive operation atomically replaces it.
if lease.record_owned and not lease.shared:
handle.seek(0)
handle.truncate()
handle.flush()
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
def _read_owner(handle: TextIO) -> str:
handle.seek(0)
return handle.read().strip() or "unknown operation"
def _write_owner(handle: TextIO, operation: str) -> None:
handle.seek(0)
handle.truncate()
handle.write(
f"{operation}, pid={os.getpid()}, "
f"started={datetime.now().astimezone().isoformat()}\n"
)
handle.flush()

View File

@@ -277,6 +277,16 @@ def prepare_game_runtime(
return results return results
def game_runtime_ready(paths: AppPaths, selection: RuntimeSelection) -> bool:
"""Return whether another client may reuse the prepared runtime read-only."""
if selection.kind == "umu":
return native_d3d_ready(paths.umu_prefix)
if selection.kind in {"proton7", "proton7-container"}:
return _game_runtime_ready(paths, selection)
return False
def run_game( def run_game(
paths: AppPaths, paths: AppPaths,
settings: Settings, settings: Settings,

View File

@@ -96,6 +96,58 @@ class DashboardError(RuntimeError):
"""Raised when the native dashboard cannot be displayed.""" """Raised when the native dashboard cannot be displayed."""
class GameClientTracker:
"""Track dashboard-launched clients without retaining process objects in Tk."""
def __init__(self) -> None:
self._next_client_id = 1
self._starting: set[int] = set()
self._running: set[int] = set()
def begin(self) -> int:
client_id = self._next_client_id
self._next_client_id += 1
self._starting.add(client_id)
return client_id
def mark_running(self, client_id: int) -> bool:
if client_id in self._running or client_id not in self._starting:
return False
self._starting.remove(client_id)
self._running.add(client_id)
return True
def finish(self, client_id: int) -> str | None:
if client_id in self._starting:
self._starting.remove(client_id)
return "starting"
if client_id in self._running:
self._running.remove(client_id)
return "running"
return None
@property
def starting_count(self) -> int:
return len(self._starting)
@property
def running_count(self) -> int:
return len(self._running)
@property
def active_count(self) -> int:
return self.starting_count + self.running_count
def game_client_activity_text(running_count: int) -> str:
if running_count < 0:
raise ValueError("running client count must not be negative")
if running_count == 0:
return ""
label = "client" if running_count == 1 else "clients"
return f"{running_count} {label} running"
def launcher_update_presentation( def launcher_update_presentation(
check: LauncherUpdateCheck, check: LauncherUpdateCheck,
) -> LauncherUpdatePresentation: ) -> LauncherUpdatePresentation:
@@ -874,7 +926,19 @@ def run_dashboard(paths: AppPaths) -> None:
status = tk.StringVar(value="Ready") status = tk.StringVar(value="Ready")
progress_text = tk.StringVar(value="Click Play to start your journey") progress_text = tk.StringVar(value="Click Play to start your journey")
speed_text = tk.StringVar(value="") speed_text = tk.StringVar(value="")
ttk.Label(activity_copy, textvariable=status, style="Status.TLabel").pack(anchor="w") 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( progress_bar = ttk.Progressbar(
activity_copy, activity_copy,
style="Depot.Horizontal.TProgressbar", style="Depot.Horizontal.TProgressbar",
@@ -980,6 +1044,8 @@ def run_dashboard(paths: AppPaths) -> None:
preloader_status: tk.StringVar | None = None preloader_status: tk.StringVar | None = None
preloader_progress: ttk.Progressbar | None = None preloader_progress: ttk.Progressbar | None = None
settings_window: tk.Toplevel | None = None settings_window: tk.Toplevel | None = None
game_clients = GameClientTracker()
active_game_launch_id: int | None = None
operation_active = False operation_active = False
game_window_active = False game_window_active = False
activity_log_lines = 0 activity_log_lines = 0
@@ -1002,6 +1068,12 @@ def run_dashboard(paths: AppPaths) -> None:
import_button, import_button,
launcher_update_button, launcher_update_button,
) )
game_mutation_buttons = (
update_button,
repair_button,
import_button,
launcher_update_button,
)
def append_log(value: str) -> None: def append_log(value: str) -> None:
nonlocal activity_log_lines nonlocal activity_log_lines
@@ -1034,6 +1106,9 @@ def run_dashboard(paths: AppPaths) -> None:
new_state = "disabled" if value else "normal" new_state = "disabled" if value else "normal"
for button in buttons: for button in buttons:
button.configure(state=new_state) button.configure(state=new_state)
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: def update_progress(event: Mapping[str, object]) -> None:
phase = event.get("phase") phase = event.get("phase")
@@ -1184,6 +1259,7 @@ def run_dashboard(paths: AppPaths) -> None:
show_preloader: bool = False, show_preloader: bool = False,
restart_on_success: bool = False, restart_on_success: bool = False,
game_files_may_change: bool = False, game_files_may_change: bool = False,
game_client_id: int | None = None,
) -> None: ) -> None:
nonlocal operation_active, game_window_active nonlocal operation_active, game_window_active
nonlocal operation_started_at nonlocal operation_started_at
@@ -1193,7 +1269,7 @@ def run_dashboard(paths: AppPaths) -> None:
nonlocal operation_refreshes_compatibility nonlocal operation_refreshes_compatibility
nonlocal last_elapsed_second nonlocal last_elapsed_second
operation_active = True operation_active = True
game_window_active = False game_window_active = game_clients.running_count > 0
operation_started_at = time.monotonic() operation_started_at = time.monotonic()
operation_stage = 0 operation_stage = 0
operation_stage_total = 0 operation_stage_total = 0
@@ -1215,6 +1291,12 @@ def run_dashboard(paths: AppPaths) -> None:
append_log(f"$ {' '.join(command)}") append_log(f"$ {' '.join(command)}")
def worker() -> None: def worker() -> None:
def publish_output(line: str) -> None:
if game_client_id is None:
events.put(("output", line))
else:
events.put(("output", f"[Client {game_client_id}] {line}"))
try: try:
process = subprocess.Popen( process = subprocess.Popen(
command, command,
@@ -1226,8 +1308,11 @@ def run_dashboard(paths: AppPaths) -> None:
bufsize=1, bufsize=1,
) )
except OSError as process_error: except OSError as process_error:
events.put(("output", f"Could not start operation: {process_error}\n")) publish_output(f"Could not start operation: {process_error}\n")
if game_client_id is None:
events.put(("done", 1)) events.put(("done", 1))
else:
events.put(("game_done", (game_client_id, 1)))
return return
if process.stdout is not None: if process.stdout is not None:
for line in process.stdout: for line in process.stdout:
@@ -1235,39 +1320,68 @@ def run_dashboard(paths: AppPaths) -> None:
try: try:
parsed_status = json.loads(line.removeprefix("STATUS ")) parsed_status = json.loads(line.removeprefix("STATUS "))
except json.JSONDecodeError: except json.JSONDecodeError:
events.put(("output", line)) publish_output(line)
else: else:
if isinstance(parsed_status, dict): if isinstance(parsed_status, dict):
if game_client_id is None:
events.put(("operation_status", parsed_status)) events.put(("operation_status", parsed_status))
else: else:
events.put(("output", line)) events.put(
(
"game_operation_status",
(game_client_id, parsed_status),
)
)
else:
publish_output(line)
elif line.startswith("PROGRESS "): elif line.startswith("PROGRESS "):
try: try:
parsed = json.loads(line.removeprefix("PROGRESS ")) parsed = json.loads(line.removeprefix("PROGRESS "))
except json.JSONDecodeError: except json.JSONDecodeError:
events.put(("output", line)) publish_output(line)
else: else:
if isinstance(parsed, dict): if isinstance(parsed, dict):
progress_updates.publish(parsed) progress_updates.publish(parsed)
else: else:
events.put(("output", line)) publish_output(line)
else: else:
events.put(("output", line)) publish_output(line)
if show_preloader and line.startswith("Game window created;"): if game_client_id is not None and line.startswith("Game window created;"):
events.put( events.put(
("operation_status", { (
"game_operation_status",
(
game_client_id,
{
"step": 4, "step": 4,
"total": LAUNCH_STAGE_TOTAL, "total": LAUNCH_STAGE_TOTAL,
"message": "Game client started.", "message": "Game client started.",
}) },
),
) )
events.put(("game_started", None)) )
if show_preloader and line.startswith(("Game window ready:", "Rendered game frame:")): events.put(("game_started", game_client_id))
events.put(("game_window_ready", None)) 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() returncode = process.wait()
events.put(("launcher_update_done" if restart_on_success else "done", returncode)) 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="ptlinux-operation", daemon=True).start() 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: def open_article(url: str) -> None:
webbrowser.open(url) webbrowser.open(url)
@@ -1561,18 +1675,28 @@ def run_dashboard(paths: AppPaths) -> None:
else None else None
), ),
) )
play_button.configure(text=installation.action.value) 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: if not operation_active:
play_button.configure( play_button.configure(
state=( state=(
"disabled" "disabled"
if installation.action is PrimaryAction.CHECKING if (
installation.action is PrimaryAction.CHECKING
and not additional_client_ready
)
else "normal" else "normal"
), ),
) )
if not update_activity or operation_active: if not update_activity or operation_active:
return installation return installation
if installation.action is PrimaryAction.INSTALL: 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.") status.set("Game is not installed.")
progress_text.set("Click Install to download and verify the official game files.") progress_text.set("Click Install to download and verify the official game files.")
elif installation.action is PrimaryAction.UPDATE: elif installation.action is PrimaryAction.UPDATE:
@@ -1640,7 +1764,11 @@ def run_dashboard(paths: AppPaths) -> None:
launcher_update_button.configure( launcher_update_button.configure(
text=presentation.button_text, text=presentation.button_text,
style=presentation.button_style, style=presentation.button_style,
state="normal", state=(
"disabled"
if operation_active or game_clients.running_count
else "normal"
),
) )
if presentation.badge_text is not None: if presentation.badge_text is not None:
launcher_update_badge.configure(text=presentation.badge_text) launcher_update_badge.configure(text=presentation.badge_text)
@@ -1687,12 +1815,20 @@ def run_dashboard(paths: AppPaths) -> None:
threading.Thread(target=version_worker, name="ptlinux-version", daemon=True).start() threading.Thread(target=version_worker, name="ptlinux-version", daemon=True).start()
refresh_launcher_update_check() refresh_launcher_update_check()
def finish_operation(returncode: int) -> None: def release_game_launch_controls(
client_id: int,
*,
ready: bool,
returncode: int | None = None,
) -> None:
nonlocal active_game_launch_id
nonlocal operation_active, game_window_active nonlocal operation_active, game_window_active
nonlocal operation_refreshes_compatibility, operation_started_at nonlocal operation_refreshes_compatibility, operation_started_at
refresh_compatibility = operation_refreshes_compatibility if active_game_launch_id != client_id:
return
active_game_launch_id = None
operation_active = False operation_active = False
game_window_active = False game_window_active = game_clients.running_count > 0
operation_refreshes_compatibility = False operation_refreshes_compatibility = False
operation_started_at = None operation_started_at = None
progress_updates.clear() progress_updates.clear()
@@ -1701,6 +1837,34 @@ def run_dashboard(paths: AppPaths) -> None:
progress_bar.stop() progress_bar.stop()
close_game_preloader() close_game_preloader()
root.deiconify() root.deiconify()
set_busy(False)
refresh_primary_action(update_activity=False)
if ready:
status.set("Ready for another account.")
progress_text.set("Click Play to open another game window.")
else:
root.lift()
status.set(
f"Client {client_id} failed to start"
+ (f" (exit {returncode})" if returncode is not None else "")
)
progress_text.set("See the activity log for details.")
def finish_operation(returncode: int) -> None:
nonlocal operation_active, game_window_active
nonlocal operation_refreshes_compatibility, operation_started_at
refresh_compatibility = operation_refreshes_compatibility
operation_active = False
game_window_active = game_clients.running_count > 0
operation_refreshes_compatibility = False
operation_started_at = None
progress_updates.clear()
transfer_rate.reset()
speed_text.set("")
progress_bar.stop()
close_game_preloader()
root.deiconify()
if not game_window_active:
root.lift() root.lift()
set_busy(False) set_busy(False)
if refresh_compatibility: if refresh_compatibility:
@@ -1749,12 +1913,55 @@ def run_dashboard(paths: AppPaths) -> None:
message = value.get("message") message = value.get("message")
if step > 0 and total > 0 and isinstance(message, str) and message: if step > 0 and total > 0 and isinstance(message, str) and message:
set_operation_stage(step, total, message) set_operation_stage(step, total, message)
elif kind in {"game_started", "game_window_ready"}: elif (
game_window_active = True kind == "game_operation_status"
and isinstance(value, tuple)
and len(value) == 2
and isinstance(value[0], int)
and isinstance(value[1], Mapping)
and value[0] == active_game_launch_id
):
step = _as_int(value[1].get("step"))
total = _as_int(value[1].get("total"))
message = value[1].get("message")
if step > 0 and total > 0 and isinstance(message, str) and message:
set_operation_stage(step, total, message)
elif kind == "game_started" and isinstance(value, int):
progress_bar.stop() progress_bar.stop()
status.set("Game client started.") status.set(f"Game client {value} started; preparing its window…")
close_game_preloader() elif kind == "game_window_ready" and isinstance(value, int):
root.iconify() if game_clients.mark_running(value):
game_window_active = True
release_game_launch_controls(value, ready=True)
elif (
kind == "game_done"
and isinstance(value, tuple)
and len(value) == 2
and isinstance(value[0], int)
and isinstance(value[1], int)
):
client_id, returncode = value
phase = game_clients.finish(client_id)
game_window_active = game_clients.running_count > 0
append_log(f"Client {client_id} exited with status {returncode}.")
if phase == "starting":
release_game_launch_controls(
client_id,
ready=False,
returncode=returncode,
)
elif phase == "running" and not operation_active:
set_busy(False)
refresh_primary_action(update_activity=False)
count = game_clients.running_count
if count:
status.set("Ready for another account.")
progress_text.set("Click Play to open another game window.")
else:
status.set("All game clients have closed.")
progress_text.set("Click Play to start a new client.")
root.deiconify()
root.lift()
elif kind == "broadcast": elif kind == "broadcast":
show_broadcast(value if isinstance(value, Broadcast) else None) show_broadcast(value if isinstance(value, Broadcast) else None)
mark_startup_component_ready( mark_startup_component_ready(
@@ -1834,7 +2041,11 @@ def run_dashboard(paths: AppPaths) -> None:
launcher_update_button.configure( launcher_update_button.configure(
text="CHECK LAUNCHER", text="CHECK LAUNCHER",
style="Secondary.TButton", style="Secondary.TButton",
state="normal", state=(
"disabled"
if operation_active or game_clients.running_count
else "normal"
),
) )
launcher_update_badge.pack_forget() launcher_update_badge.pack_forget()
append_log(f"Launcher update check unavailable: {value}") append_log(f"Launcher update check unavailable: {value}")
@@ -1915,14 +2126,24 @@ def run_dashboard(paths: AppPaths) -> None:
launcher_update_button.configure(command=run_launcher_update) launcher_update_button.configure(command=run_launcher_update)
def run_primary_action() -> None: def start_game_client() -> None:
installation = refresh_primary_action(update_activity=False) nonlocal active_game_launch_id
if installation.action is PrimaryAction.PLAY: client_id = game_clients.begin()
active_game_launch_id = client_id
run_operation( run_operation(
"Starting PristonTale EU…", f"Starting PristonTale EU client {client_id}…",
command_for("play"), command_for("play"),
show_preloader=True, show_preloader=True,
game_client_id=client_id,
) )
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 is PrimaryAction.INSTALL: elif installation.action is PrimaryAction.INSTALL:
run_operation( run_operation(
"Installing official game files…", "Installing official game files…",
@@ -2273,6 +2494,17 @@ def run_dashboard(paths: AppPaths) -> None:
import_button.configure(command=choose_import_source) import_button.configure(command=choose_import_source)
log_toggle.configure(command=toggle_log) log_toggle.configure(command=toggle_log)
def close_dashboard() -> None:
# Keep draining each client process while it owns a shared runtime lease.
# Closing the window therefore minimizes the dashboard until all clients
# and any active launcher operation have finished.
if operation_active or game_clients.active_count:
root.iconify()
return
root.destroy()
root.protocol("WM_DELETE_WINDOW", close_dashboard)
def update_news_viewport(_event: object | None = None) -> None: def update_news_viewport(_event: object | None = None) -> None:
news_canvas.configure(scrollregion=news_canvas.bbox("all")) news_canvas.configure(scrollregion=news_canvas.bbox("all"))