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
|
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,14 +688,15 @@ 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:
|
||||||
if game_dir is None and game_import_requires_verification(paths):
|
with _operation_guard(paths, "prepare", False):
|
||||||
raise LauncherError(
|
if game_dir is None and game_import_requires_verification(paths):
|
||||||
"the imported game has not completed its full publisher verification; "
|
raise LauncherError(
|
||||||
"run pristontale-eu update"
|
"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)
|
settings = ensure_settings(paths)
|
||||||
result = prepare_runtime_view(paths, source)
|
source = _source_game(paths, settings, game_dir)
|
||||||
|
result = prepare_runtime_view(paths, source)
|
||||||
_emit({"ok": True, "runtime_view": asdict(result)}, as_json)
|
_emit({"ok": True, "runtime_view": asdict(result)}, as_json)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
314
ptlinux/ui.py
314
ptlinux/ui.py
@@ -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")
|
||||||
events.put(("done", 1))
|
if game_client_id is None:
|
||||||
|
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):
|
||||||
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:
|
else:
|
||||||
events.put(("output", line))
|
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", {
|
(
|
||||||
"step": 4,
|
"game_operation_status",
|
||||||
"total": LAUNCH_STAGE_TOTAL,
|
(
|
||||||
"message": "Game client started.",
|
game_client_id,
|
||||||
})
|
{
|
||||||
|
"step": 4,
|
||||||
|
"total": LAUNCH_STAGE_TOTAL,
|
||||||
|
"message": "Game client started.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
events.put(("game_started", None))
|
events.put(("game_started", game_client_id))
|
||||||
if show_preloader and line.startswith(("Game window ready:", "Rendered game frame:")):
|
if game_client_id is not None and line.startswith(
|
||||||
events.put(("game_window_ready", None))
|
("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,7 +1837,35 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
progress_bar.stop()
|
progress_bar.stop()
|
||||||
close_game_preloader()
|
close_game_preloader()
|
||||||
root.deiconify()
|
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)
|
set_busy(False)
|
||||||
if refresh_compatibility:
|
if refresh_compatibility:
|
||||||
refresh_runtime_compatibility(update_activity=returncode == 0)
|
refresh_runtime_compatibility(update_activity=returncode == 0)
|
||||||
@@ -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 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:
|
def run_primary_action() -> None:
|
||||||
installation = refresh_primary_action(update_activity=False)
|
installation = refresh_primary_action(update_activity=False)
|
||||||
if installation.action is PrimaryAction.PLAY:
|
if (
|
||||||
run_operation(
|
installation.action is PrimaryAction.PLAY
|
||||||
"Starting PristonTale EU…",
|
or (game_clients.running_count > 0 and installation.installed)
|
||||||
command_for("play"),
|
):
|
||||||
show_preloader=True,
|
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"))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user