Added support for multiple clients to be ran at the same time.
This commit is contained in:
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