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

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