Added support for multiple clients to be ran at the same time.
This commit is contained in:
@@ -10,7 +10,7 @@ import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
from typing import Sequence
|
||||
from typing import Callable, Sequence
|
||||
|
||||
from ptdiag.logs import (
|
||||
LogFileState,
|
||||
@@ -33,12 +33,13 @@ from .launcher import (
|
||||
launch_official_updater,
|
||||
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_trace import DEFAULT_TRACE_TIMEOUT_SECONDS, LOGIN_TRACE_DISABLED_REASON
|
||||
from .overlay import cache_compatibility_binaries, prepare_runtime_view
|
||||
from .paths import AppPaths
|
||||
from .runtime import (
|
||||
game_runtime_ready,
|
||||
prepare_game_runtime,
|
||||
run_game,
|
||||
select_runtime,
|
||||
@@ -687,14 +688,15 @@ def _maybe_import_game(
|
||||
|
||||
|
||||
def _prepare(paths: AppPaths, game_dir: Path | None, as_json: bool) -> int:
|
||||
if game_dir is None and game_import_requires_verification(paths):
|
||||
raise LauncherError(
|
||||
"the imported game has not completed its full publisher verification; "
|
||||
"run pristontale-eu update"
|
||||
)
|
||||
settings = ensure_settings(paths)
|
||||
source = _source_game(paths, settings, game_dir)
|
||||
result = prepare_runtime_view(paths, source)
|
||||
with _operation_guard(paths, "prepare", False):
|
||||
if game_dir is None and game_import_requires_verification(paths):
|
||||
raise LauncherError(
|
||||
"the imported game has not completed its full publisher verification; "
|
||||
"run pristontale-eu update"
|
||||
)
|
||||
settings = ensure_settings(paths)
|
||||
source = _source_game(paths, settings, game_dir)
|
||||
result = prepare_runtime_view(paths, source)
|
||||
_emit({"ok": True, "runtime_view": asdict(result)}, as_json)
|
||||
return 0
|
||||
|
||||
@@ -707,7 +709,7 @@ def _play(
|
||||
skip_runtime_setup: bool,
|
||||
as_json: bool,
|
||||
) -> int:
|
||||
with _operation_guard(paths, "play", dry_run):
|
||||
with _play_guard(paths, dry_run) as play_lease:
|
||||
return _play_locked(
|
||||
paths,
|
||||
game_dir,
|
||||
@@ -715,6 +717,8 @@ def _play(
|
||||
dry_run,
|
||||
skip_runtime_setup,
|
||||
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,
|
||||
skip_runtime_setup: bool,
|
||||
as_json: bool,
|
||||
*,
|
||||
reuse_prepared: bool = False,
|
||||
before_game: Callable[[], None] | None = None,
|
||||
) -> int:
|
||||
if game_dir is None and game_import_requires_verification(paths):
|
||||
raise LauncherError(
|
||||
@@ -734,16 +741,49 @@ def _play_locked(
|
||||
settings = _load_or_default_settings(paths, persist=not dry_run)
|
||||
source = _source_game(paths, settings, game_dir)
|
||||
overlay = None
|
||||
_print_operation_status(as_json, 1, 4, "Preparing the managed game view…")
|
||||
if not dry_run:
|
||||
_print_operation_status(
|
||||
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)
|
||||
_print_operation_status(as_json, 2, 4, "Selecting the configured game runtime…")
|
||||
selection = select_runtime(settings)
|
||||
runner = _runner(paths, "play", dry_run)
|
||||
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…")
|
||||
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…")
|
||||
native_log_directory = paths.game_save / "logs"
|
||||
native_log_baseline = snapshot_native_logs(
|
||||
@@ -751,6 +791,8 @@ def _play_locked(
|
||||
patterns=("log_*.txt",),
|
||||
)
|
||||
game_started_ns = time.time_ns()
|
||||
if before_game is not None:
|
||||
before_game()
|
||||
try:
|
||||
outcome = run_game(
|
||||
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:
|
||||
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)
|
||||
|
||||
|
||||
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(
|
||||
paths: AppPaths,
|
||||
operation: str,
|
||||
|
||||
@@ -7,13 +7,51 @@ from datetime import datetime
|
||||
import fcntl
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
from typing import Iterator, TextIO
|
||||
|
||||
|
||||
class OperationBusyError(RuntimeError):
|
||||
"""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
|
||||
def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
|
||||
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(
|
||||
f"launcher files are busy ({owner}); wait for it to finish and try again"
|
||||
) from exc
|
||||
handle.seek(0)
|
||||
handle.truncate()
|
||||
handle.write(
|
||||
f"{operation}, pid={os.getpid()}, "
|
||||
f"started={datetime.now().astimezone().isoformat()}\n"
|
||||
)
|
||||
handle.flush()
|
||||
_write_owner(handle, operation)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -40,3 +72,67 @@ def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
|
||||
handle.truncate()
|
||||
handle.flush()
|
||||
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()
|
||||
|
||||
@@ -277,6 +277,16 @@ def prepare_game_runtime(
|
||||
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(
|
||||
paths: AppPaths,
|
||||
settings: Settings,
|
||||
|
||||
314
ptlinux/ui.py
314
ptlinux/ui.py
@@ -96,6 +96,58 @@ class DashboardError(RuntimeError):
|
||||
"""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(
|
||||
check: LauncherUpdateCheck,
|
||||
) -> LauncherUpdatePresentation:
|
||||
@@ -874,7 +926,19 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
status = tk.StringVar(value="Ready")
|
||||
progress_text = tk.StringVar(value="Click Play to start your journey")
|
||||
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(
|
||||
activity_copy,
|
||||
style="Depot.Horizontal.TProgressbar",
|
||||
@@ -980,6 +1044,8 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
preloader_status: tk.StringVar | None = None
|
||||
preloader_progress: ttk.Progressbar | None = None
|
||||
settings_window: tk.Toplevel | None = None
|
||||
game_clients = GameClientTracker()
|
||||
active_game_launch_id: int | None = None
|
||||
operation_active = False
|
||||
game_window_active = False
|
||||
activity_log_lines = 0
|
||||
@@ -1002,6 +1068,12 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
import_button,
|
||||
launcher_update_button,
|
||||
)
|
||||
game_mutation_buttons = (
|
||||
update_button,
|
||||
repair_button,
|
||||
import_button,
|
||||
launcher_update_button,
|
||||
)
|
||||
|
||||
def append_log(value: str) -> None:
|
||||
nonlocal activity_log_lines
|
||||
@@ -1034,6 +1106,9 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
new_state = "disabled" if value else "normal"
|
||||
for button in buttons:
|
||||
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:
|
||||
phase = event.get("phase")
|
||||
@@ -1184,6 +1259,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
show_preloader: bool = False,
|
||||
restart_on_success: bool = False,
|
||||
game_files_may_change: bool = False,
|
||||
game_client_id: int | None = None,
|
||||
) -> None:
|
||||
nonlocal operation_active, game_window_active
|
||||
nonlocal operation_started_at
|
||||
@@ -1193,7 +1269,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
nonlocal operation_refreshes_compatibility
|
||||
nonlocal last_elapsed_second
|
||||
operation_active = True
|
||||
game_window_active = False
|
||||
game_window_active = game_clients.running_count > 0
|
||||
operation_started_at = time.monotonic()
|
||||
operation_stage = 0
|
||||
operation_stage_total = 0
|
||||
@@ -1215,6 +1291,12 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
append_log(f"$ {' '.join(command)}")
|
||||
|
||||
def worker() -> None:
|
||||
def publish_output(line: str) -> None:
|
||||
if game_client_id is None:
|
||||
events.put(("output", line))
|
||||
else:
|
||||
events.put(("output", f"[Client {game_client_id}] {line}"))
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
@@ -1226,8 +1308,11 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
bufsize=1,
|
||||
)
|
||||
except OSError as process_error:
|
||||
events.put(("output", f"Could not start operation: {process_error}\n"))
|
||||
events.put(("done", 1))
|
||||
publish_output(f"Could not start operation: {process_error}\n")
|
||||
if game_client_id is None:
|
||||
events.put(("done", 1))
|
||||
else:
|
||||
events.put(("game_done", (game_client_id, 1)))
|
||||
return
|
||||
if process.stdout is not None:
|
||||
for line in process.stdout:
|
||||
@@ -1235,39 +1320,68 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
try:
|
||||
parsed_status = json.loads(line.removeprefix("STATUS "))
|
||||
except json.JSONDecodeError:
|
||||
events.put(("output", line))
|
||||
publish_output(line)
|
||||
else:
|
||||
if isinstance(parsed_status, dict):
|
||||
events.put(("operation_status", parsed_status))
|
||||
if game_client_id is None:
|
||||
events.put(("operation_status", parsed_status))
|
||||
else:
|
||||
events.put(
|
||||
(
|
||||
"game_operation_status",
|
||||
(game_client_id, parsed_status),
|
||||
)
|
||||
)
|
||||
else:
|
||||
events.put(("output", line))
|
||||
publish_output(line)
|
||||
elif line.startswith("PROGRESS "):
|
||||
try:
|
||||
parsed = json.loads(line.removeprefix("PROGRESS "))
|
||||
except json.JSONDecodeError:
|
||||
events.put(("output", line))
|
||||
publish_output(line)
|
||||
else:
|
||||
if isinstance(parsed, dict):
|
||||
progress_updates.publish(parsed)
|
||||
else:
|
||||
events.put(("output", line))
|
||||
publish_output(line)
|
||||
else:
|
||||
events.put(("output", line))
|
||||
if show_preloader and line.startswith("Game window created;"):
|
||||
publish_output(line)
|
||||
if game_client_id is not None and line.startswith("Game window created;"):
|
||||
events.put(
|
||||
("operation_status", {
|
||||
"step": 4,
|
||||
"total": LAUNCH_STAGE_TOTAL,
|
||||
"message": "Game client started.",
|
||||
})
|
||||
(
|
||||
"game_operation_status",
|
||||
(
|
||||
game_client_id,
|
||||
{
|
||||
"step": 4,
|
||||
"total": LAUNCH_STAGE_TOTAL,
|
||||
"message": "Game client started.",
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
events.put(("game_started", None))
|
||||
if show_preloader and line.startswith(("Game window ready:", "Rendered game frame:")):
|
||||
events.put(("game_window_ready", None))
|
||||
events.put(("game_started", game_client_id))
|
||||
if game_client_id is not None and line.startswith(
|
||||
("Game window ready:", "Rendered game frame:")
|
||||
):
|
||||
events.put(("game_window_ready", game_client_id))
|
||||
returncode = process.wait()
|
||||
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:
|
||||
webbrowser.open(url)
|
||||
@@ -1561,18 +1675,28 @@ def run_dashboard(paths: AppPaths) -> 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:
|
||||
play_button.configure(
|
||||
state=(
|
||||
"disabled"
|
||||
if installation.action is PrimaryAction.CHECKING
|
||||
if (
|
||||
installation.action is PrimaryAction.CHECKING
|
||||
and not additional_client_ready
|
||||
)
|
||||
else "normal"
|
||||
),
|
||||
)
|
||||
if not update_activity or operation_active:
|
||||
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.")
|
||||
progress_text.set("Click Install to download and verify the official game files.")
|
||||
elif installation.action is PrimaryAction.UPDATE:
|
||||
@@ -1640,7 +1764,11 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
launcher_update_button.configure(
|
||||
text=presentation.button_text,
|
||||
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:
|
||||
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()
|
||||
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_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
|
||||
game_window_active = False
|
||||
game_window_active = game_clients.running_count > 0
|
||||
operation_refreshes_compatibility = False
|
||||
operation_started_at = None
|
||||
progress_updates.clear()
|
||||
@@ -1701,7 +1837,35 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
progress_bar.stop()
|
||||
close_game_preloader()
|
||||
root.deiconify()
|
||||
root.lift()
|
||||
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()
|
||||
set_busy(False)
|
||||
if refresh_compatibility:
|
||||
refresh_runtime_compatibility(update_activity=returncode == 0)
|
||||
@@ -1749,12 +1913,55 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
message = value.get("message")
|
||||
if step > 0 and total > 0 and isinstance(message, str) and message:
|
||||
set_operation_stage(step, total, message)
|
||||
elif kind in {"game_started", "game_window_ready"}:
|
||||
game_window_active = True
|
||||
elif (
|
||||
kind == "game_operation_status"
|
||||
and isinstance(value, tuple)
|
||||
and len(value) == 2
|
||||
and isinstance(value[0], int)
|
||||
and isinstance(value[1], Mapping)
|
||||
and value[0] == active_game_launch_id
|
||||
):
|
||||
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()
|
||||
status.set("Game client started.")
|
||||
close_game_preloader()
|
||||
root.iconify()
|
||||
status.set(f"Game client {value} started; preparing its window…")
|
||||
elif kind == "game_window_ready" and isinstance(value, int):
|
||||
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":
|
||||
show_broadcast(value if isinstance(value, Broadcast) else None)
|
||||
mark_startup_component_ready(
|
||||
@@ -1834,7 +2041,11 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
launcher_update_button.configure(
|
||||
text="CHECK LAUNCHER",
|
||||
style="Secondary.TButton",
|
||||
state="normal",
|
||||
state=(
|
||||
"disabled"
|
||||
if operation_active or game_clients.running_count
|
||||
else "normal"
|
||||
),
|
||||
)
|
||||
launcher_update_badge.pack_forget()
|
||||
append_log(f"Launcher update check unavailable: {value}")
|
||||
@@ -1915,14 +2126,24 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
|
||||
launcher_update_button.configure(command=run_launcher_update)
|
||||
|
||||
def start_game_client() -> None:
|
||||
nonlocal active_game_launch_id
|
||||
client_id = game_clients.begin()
|
||||
active_game_launch_id = client_id
|
||||
run_operation(
|
||||
f"Starting PristonTale EU client {client_id}…",
|
||||
command_for("play"),
|
||||
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:
|
||||
run_operation(
|
||||
"Starting PristonTale EU…",
|
||||
command_for("play"),
|
||||
show_preloader=True,
|
||||
)
|
||||
if (
|
||||
installation.action is PrimaryAction.PLAY
|
||||
or (game_clients.running_count > 0 and installation.installed)
|
||||
):
|
||||
start_game_client()
|
||||
elif installation.action is PrimaryAction.INSTALL:
|
||||
run_operation(
|
||||
"Installing official game files…",
|
||||
@@ -2273,6 +2494,17 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
import_button.configure(command=choose_import_source)
|
||||
log_toggle.configure(command=toggle_log)
|
||||
|
||||
def close_dashboard() -> None:
|
||||
# Keep draining each client process while it owns a shared runtime lease.
|
||||
# Closing the window therefore minimizes the dashboard until all clients
|
||||
# and any active launcher operation have finished.
|
||||
if operation_active or game_clients.active_count:
|
||||
root.iconify()
|
||||
return
|
||||
root.destroy()
|
||||
|
||||
root.protocol("WM_DELETE_WINDOW", close_dashboard)
|
||||
|
||||
def update_news_viewport(_event: object | None = None) -> None:
|
||||
news_canvas.configure(scrollregion=news_canvas.bbox("all"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user