"""Command-line interface for installation, updates, repair, and play.""" from __future__ import annotations import argparse from contextlib import AbstractContextManager, nullcontext from dataclasses import asdict, replace from datetime import datetime import json from pathlib import Path import re import signal import sys import threading from typing import Callable, Sequence from uuid import uuid4 from .diagnostics.native_logs import ( LogFileState, classify_asset_references, newest_current_native_log, parse_current_run_log, snapshot_native_logs, ) from . import __version__ from .application import common as _application_helpers from .application.commands import health as _health_commands from .application.commands import installation as _installation_commands from .application.commands import launcher_update as _launcher_update_commands from .application.commands import play as _play_commands from .application.commands import settings as _settings_commands from .command import CommandError, CommandOutcome, CommandRunner from .container_host import ( ContainerHostReport, container_host_error, inspect_container_host, repair_container_host, repair_container_host_after_update, ) from .depot import ( DownloadOptions, SyncProgress, read_cached_depot_launch_catalog, read_current_depot_file_catalog, preflight_managed_game, select_depot_start_option, sync_managed_game, ) from .doctor import inspect_host from .download import OFFICIAL_LAUNCHER_URL, fetch_official_launcher from .importer import GameImportResult, discover_existing_game, import_existing_game from .import_state import game_import_requires_verification, mark_game_import_verified from .launcher import ( LauncherError, WineTools, configure_launcher_prefix, find_wine_tools, launch_official_updater, prepare_launcher_binary, ) from .locking import ( LauncherPlayLease, launcher_operation_lock, launcher_play_lock, managed_game_process_is_active, ) 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 ( RuntimeErrorPT, game_runtime_ready, prepare_game_runtime, run_game, select_runtime, ) from .settings import Settings, ensure_settings, load_settings, reset_settings from .self_update import ( check_for_launcher_update, install_launcher_update, rollback_launcher_update, update_check_to_dict, verify_launcher_update_channel, ) from .update_resolver import STABLE_CHANNEL from .telemetry import ( classify_network_failure, current_telemetry, initialize_shared_telemetry, network_http_status, shutdown_shared_telemetry, ) from .system_identity import is_running_inside_steam_runtime from .umu import ( UmuBootstrapResult, find_umu_launcher, provision_umu, provision_umu_for_startup, ) from .window_diagnostics import ( WINDOW_DIAGNOSTICS_ENV, WINDOW_DIAGNOSTICS_SESSION_ENV, parse_window_diagnostic, ) class _GameLaunchInterruptionReported(KeyboardInterrupt): """Mark an in-game interrupt whose cancellation telemetry was already sent.""" def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="pristontale-eu", description="PristonTale EU all-in-one Linux installer and clean launcher", ) # Python 3.14 enables ANSI-colored argparse output by default. Keep the CLI's # help and error text stable for redirected output and downstream tooling. if hasattr(parser, "color"): parser.color = False parser.add_argument( "--version", action="version", version=f"%(prog)s {__version__}", ) parser.add_argument("--json", action="store_true", help="emit machine-readable output") subparsers = parser.add_subparsers(dest="command") subparsers.add_parser("gui", help="open the native Linux dashboard") subparsers.add_parser("doctor", help="check cross-distribution dependencies") container_host = subparsers.add_parser( "container-host", help="check Bubblewrap and Ubuntu AppArmor for the Steam Runtime", ) container_host.add_argument( "--repair", action="store_true", help="repair only recognized Ubuntu AppArmor profile states", ) subparsers.add_parser( "runtime-bootstrap", help="download and verify the Steam Runtime launcher helper", ) login_diagnostics = subparsers.add_parser( "login-diagnostics", help="check the client, runtime, and publisher login route without credentials", ) login_diagnostics.add_argument( "--timeout", type=float, default=5.0, help="TCP connection timeout in seconds (default: 5)", ) login_diagnostics.add_argument( "--runtime", choices=("configured", "auto", "proton7", "proton7-container", "umu"), default="configured", help=( "probe one runtime without changing settings or starting the game " "(default: configured)" ), ) fetch = subparsers.add_parser("fetch", help="fetch and safely prepare the latest official launcher") fetch.add_argument("--dry-run", action="store_true") setup = subparsers.add_parser("setup", help="configure Wine and .NET Desktop for the updater") setup.add_argument("--dry-run", action="store_true") setup.add_argument("--force-runtime", action="store_true") update = subparsers.add_parser( "update", help="safely synchronize the official game depot without Wine", ) update.add_argument("--dry-run", action="store_true") update.add_argument("--repair", action="store_true", help="hash-check every game file") import_options = update.add_mutually_exclusive_group() import_options.add_argument( "--seed-from", type=Path, help="import an existing game before updating", ) import_options.add_argument( "--no-import", action="store_true", help="disable automatic existing-game discovery", ) sync = subparsers.add_parser( "sync", help="native official depot synchronization without bootstrap discovery", ) sync.add_argument("--dry-run", action="store_true") sync.add_argument("--repair", action="store_true", help="hash-check every game file") legacy_update = subparsers.add_parser( "wine-update", help="legacy official WPF updater (normally unnecessary)", ) legacy_update.add_argument("--dry-run", action="store_true") legacy_update.add_argument("--force-runtime", action="store_true") legacy_import_options = legacy_update.add_mutually_exclusive_group() legacy_import_options.add_argument("--seed-from", type=Path) legacy_import_options.add_argument("--no-import", action="store_true") import_game = subparsers.add_parser( "import-game", help="safely seed the managed installation from an existing game", ) import_game.add_argument("source", type=Path) import_game.add_argument("--dry-run", action="store_true") compatibility = subparsers.add_parser( "cache-compatibility", help="cache one exact verified Game.exe/game.dll compatibility pair", ) compatibility.add_argument("source", type=Path) prepare = subparsers.add_parser("prepare", help="build the read-only runtime view and scene fix") prepare.add_argument("--game-dir", type=Path) play = subparsers.add_parser("play", help="launch the game in the managed Proton window") play.add_argument("--game-dir", type=Path) play.add_argument( "--realm", help="launch an available realm from the last official depot manifest", ) play.add_argument("--no-box", action="store_true", help="launch without X11 window styling") play.add_argument( "--publisher-dll", "--no-world-fix", action="store_true", dest="publisher_dll", help=( "compatibility option retained for older scripts; publisher code is " "always used without the retired world fix" ), ) play.add_argument("--dry-run", action="store_true") play.add_argument("--skip-runtime-setup", action="store_true") login_trace = subparsers.add_parser( "login-trace", help="retired unsafe authentication trace (always refuses to launch)", ) login_trace.add_argument("--game-dir", type=Path) login_trace.add_argument("--no-box", action="store_true", help="launch without X11 window styling") login_trace.add_argument("--skip-runtime-setup", action="store_true") login_trace.add_argument( "--timeout", type=float, default=DEFAULT_TRACE_TIMEOUT_SECONDS, help="maximum game runtime in seconds (default: 600)", ) settings = subparsers.add_parser("settings", help="show, initialize, or reset settings") settings_action = settings.add_mutually_exclusive_group() settings_action.add_argument( "--init", action="store_true", help="create missing settings and migrate legacy files without resetting choices", ) settings_action.add_argument( "--reset", action="store_true", help="explicitly replace all launcher settings with defaults", ) launcher_update = subparsers.add_parser( "launcher-update", help="check, verify, install, or roll back the native Linux launcher", ) launcher_action = launcher_update.add_mutually_exclusive_group() launcher_action.add_argument( "--check", action="store_true", help="authenticate the update manifest without installing it", ) launcher_action.add_argument( "--verify", action="store_true", help="download and fully validate the published release without installing it", ) launcher_action.add_argument( "--rollback", nargs="?", const="", metavar="VERSION", help=( "switch to VERSION from signed release history; without VERSION, " "switch to the locally retained previous release" ), ) launcher_update.add_argument( "--manifest-url", help="override the signed stable-manifest URL", ) launcher_update.add_argument( "--public-key", type=Path, help="override the pinned launcher-update public key", ) launcher_update.add_argument( "--history-url", help="override the signed launcher release-history URL", ) return parser def main( argv: Sequence[str] | None = None, *, _manage_telemetry: bool | None = None, ) -> int: parser = build_parser() arguments = parser.parse_args(argv) command = arguments.command or "gui" paths = AppPaths.from_environment() manage_telemetry = argv is None if _manage_telemetry is None else _manage_telemetry telemetry_lifecycle = _start_command_telemetry( sys.modules[__name__], paths, command, arguments, manage_telemetry, ) telemetry = telemetry_lifecycle.telemetry telemetry_events = telemetry_lifecycle.events operation_started = telemetry_lifecycle.started_at try: returncode = _dispatch_command(parser, arguments, command, paths) except KeyboardInterrupt as exc: if command == "play": if ( not isinstance(exc, _GameLaunchInterruptionReported) and not bool(getattr(arguments, "dry_run", False)) ): _report_game_launch_cancelled( telemetry, operation_started, stage="preflight", ) else: _report_command_failure( telemetry, telemetry_events, command, operation_started, "Operation interrupted", stage="interrupted", ) message = "Interrupted; PristonTale and its compatibility runtime were stopped." if arguments.json: _print_json({"ok": False, "error": message, "interrupted": True}) else: print(f"\n{message}", file=sys.stderr) return 130 except (ValueError, OSError, RuntimeError) as exc: _report_command_failure( telemetry, telemetry_events, command, operation_started, str(exc), stage=_failure_stage(command, exc), ) if telemetry is not None and isinstance(exc, ValueError): telemetry.send_event( "configuration_error", severity="error", message=str(exc), metadata={"setting": "unknown", "reason": "invalid_value"}, context={"operation": command, "stage": "configuration"}, ) if arguments.json: _print_json({"ok": False, "error": str(exc)}) else: print(f"Error: {exc}", file=sys.stderr) if isinstance(exc, CommandError): if exc.outcome.stdout: print(exc.outcome.stdout.rstrip(), file=sys.stderr) if exc.outcome.stderr: print(exc.outcome.stderr.rstrip(), file=sys.stderr) return 1 except BaseException as exc: if telemetry is not None: telemetry.report_exception( "launcher_exception", exc, metadata={"command": command}, context={"operation": command, "stage": "dispatch"}, severity="critical", ) _report_command_failure( telemetry, telemetry_events, command, operation_started, str(exc), stage="dispatch", ) raise else: _report_command_result( telemetry, telemetry_events, command, operation_started, returncode, ) return returncode finally: _shutdown_command_telemetry(sys.modules[__name__], telemetry_lifecycle) def _dispatch_command( parser: argparse.ArgumentParser, arguments: argparse.Namespace, command: str, paths: AppPaths, ) -> int: if command == "gui": provision_umu_for_startup(paths) _container_host_startup_migration(paths) from .ui import run_dashboard run_dashboard(paths) return 0 if command == "doctor": return _doctor(arguments.json) if command == "container-host": return _container_host(arguments.repair, arguments.json) if command == "runtime-bootstrap": return _runtime_bootstrap(paths, arguments.json) if command == "login-diagnostics": return _login_diagnostics( paths, arguments.timeout, arguments.runtime, arguments.json, ) if command == "fetch": return _fetch(paths, arguments.dry_run, arguments.json) if command == "setup": return _setup( paths, arguments.dry_run, arguments.force_runtime, arguments.json, ) if command == "update": return _native_update( paths, arguments.dry_run, arguments.repair, arguments.seed_from, arguments.no_import, arguments.json, ) if command == "sync": return _native_update( paths, arguments.dry_run, arguments.repair, None, True, arguments.json, ) if command == "wine-update": return _wine_update( paths, arguments.dry_run, arguments.force_runtime, arguments.seed_from, arguments.no_import, arguments.json, ) if command == "import-game": return _import_game( paths, arguments.source, arguments.dry_run, arguments.json, ) if command == "cache-compatibility": result = cache_compatibility_binaries(paths, arguments.source) _emit({"ok": True, "compatibility": asdict(result)}, arguments.json) return 0 if command == "prepare": return _prepare(paths, arguments.game_dir, arguments.json) if command == "play": return _play( paths, arguments.game_dir, arguments.no_box, arguments.dry_run, arguments.skip_runtime_setup, arguments.json, realm=arguments.realm, ) if command == "login-trace": return _login_trace( paths, arguments.game_dir, arguments.no_box, arguments.skip_runtime_setup, arguments.timeout, arguments.json, ) if command == "settings": return _settings(paths, arguments.init, arguments.reset, arguments.json) if command == "launcher-update": return _launcher_update( paths, arguments.check, arguments.verify, arguments.rollback, arguments.manifest_url, arguments.public_key, arguments.json, history_url=arguments.history_url, ) parser.error(f"unknown command: {command}") _command_telemetry_events = _application_helpers.command_telemetry_events _start_command_telemetry = _application_helpers.start_command_telemetry _shutdown_command_telemetry = _application_helpers.shutdown_command_telemetry _report_command_result = _application_helpers.report_command_result _report_command_failure = _application_helpers.report_command_failure _report_game_launch_cancelled = _application_helpers.report_game_launch_cancelled _failure_stage = _application_helpers.failure_stage def _doctor(as_json: bool) -> int: return _health_commands.doctor(sys.modules[__name__], as_json) def _container_host(repair: bool, as_json: bool) -> int: return _health_commands.container_host(sys.modules[__name__], repair, as_json) _container_host_ok = _health_commands.container_host_ok def _container_host_startup_migration(paths: AppPaths) -> None: _health_commands.container_host_startup_migration(sys.modules[__name__], paths) def _runtime_bootstrap(paths: AppPaths, as_json: bool) -> int: return _health_commands.runtime_bootstrap(sys.modules[__name__], paths, as_json) def _login_diagnostics( paths: AppPaths, timeout_seconds: float, runtime_choice: str, as_json: bool, ) -> int: return _health_commands.login_diagnostics( sys.modules[__name__], paths, timeout_seconds, runtime_choice, as_json, ) def _fetch(paths: AppPaths, dry_run: bool, as_json: bool) -> int: return _installation_commands.fetch( sys.modules[__name__], paths, dry_run, as_json, ) def _setup( paths: AppPaths, dry_run: bool, force_runtime: bool, as_json: bool, ) -> int: return _installation_commands.setup( sys.modules[__name__], paths, dry_run, force_runtime, as_json, ) def _native_update( paths: AppPaths, dry_run: bool, repair: bool, seed_from: Path | None, no_import: bool, as_json: bool, ) -> int: return _installation_commands.native_update( sys.modules[__name__], paths, dry_run, repair, seed_from, no_import, as_json, ) def _wine_update( paths: AppPaths, dry_run: bool, force_runtime: bool, seed_from: Path | None, no_import: bool, as_json: bool, ) -> int: return _installation_commands.wine_update( sys.modules[__name__], paths, dry_run, force_runtime, seed_from, no_import, as_json, ) def _configure_launcher_runtime( paths: AppPaths, tools: WineTools, runner: CommandRunner, *, force_runtime: bool, ) -> list[CommandOutcome]: return _installation_commands.configure_launcher_runtime( sys.modules[__name__], paths, tools, runner, force_runtime=force_runtime, ) _print_sync_progress = _installation_commands.print_sync_progress _configured_download_options = _installation_commands.configured_download_options _print_operation_status = _installation_commands.print_operation_status def _import_game( paths: AppPaths, source: Path, dry_run: bool, as_json: bool, ) -> int: return _installation_commands.import_game( sys.modules[__name__], paths, source, dry_run, as_json, ) def _maybe_import_game( paths: AppPaths, settings: Settings, seed_from: Path | None, runner: CommandRunner, *, announce: bool, ) -> GameImportResult | None: return _installation_commands.maybe_import_game( sys.modules[__name__], paths, settings, seed_from, runner, announce=announce, ) def _import_with_telemetry( paths: AppPaths, source: Path, runner: CommandRunner, ) -> GameImportResult: return _installation_commands.import_with_telemetry( sys.modules[__name__], paths, source, runner, ) def _prepare(paths: AppPaths, game_dir: Path | None, as_json: bool) -> int: return _installation_commands.prepare( sys.modules[__name__], paths, game_dir, as_json, ) def _play( paths: AppPaths, game_dir: Path | None, no_box: bool, dry_run: bool, skip_runtime_setup: bool, as_json: bool, *, realm: str | None = None, ) -> int: return _play_commands.play( sys.modules[__name__], paths, game_dir, no_box, dry_run, skip_runtime_setup, as_json, realm=realm, ) def _play_locked( paths: AppPaths, game_dir: Path | None, no_box: bool, dry_run: bool, skip_runtime_setup: bool, as_json: bool, *, realm: str | None = None, reuse_prepared: bool = False, before_game: Callable[[], None] | None = None, game_lease_fds: Sequence[int] = (), ) -> int: return _play_commands.play_locked( sys.modules[__name__], paths, game_dir, no_box, dry_run, skip_runtime_setup, as_json, realm=realm, reuse_prepared=reuse_prepared, before_game=before_game, game_lease_fds=game_lease_fds, ) _session_duration_class = _play_commands.session_duration_class _game_exit_metadata = _play_commands.game_exit_metadata _signal_name = _play_commands.signal_name def _ensure_portable_umu_for_play( paths: AppPaths, settings: Settings, *, dry_run: bool, ) -> UmuBootstrapResult | None: return _play_commands.ensure_portable_umu_for_play( sys.modules[__name__], paths, settings, dry_run=dry_run, ) def _current_game_log( directory: Path, baseline: dict[str, LogFileState], started_ns: int, *, managed_asset_paths: Sequence[str] | None = None, source_game: Path | None = None, ) -> dict[str, object] | None: return _play_commands.current_game_log( sys.modules[__name__], directory, baseline, started_ns, managed_asset_paths=managed_asset_paths, source_game=source_game, ) def _current_game_crash( directory: Path, baseline: dict[str, LogFileState], started_ns: int, ) -> dict[str, object] | None: return _play_commands.current_game_crash( sys.modules[__name__], directory, baseline, started_ns, ) _runtime_issue_summary = _play_commands.runtime_issue_summary _gamescope_presentation_failure = _play_commands.gamescope_presentation_failure _game_window_was_ready = _play_commands.game_window_was_ready _game_readiness_signal = _play_commands.game_readiness_signal _window_diagnostic_severity = _play_commands.window_diagnostic_severity _print_game_outcome = _play_commands.print_game_outcome _print_interrupted_game_log = _play_commands.print_interrupted_game_log _login_trace = _play_commands.login_trace def _settings( paths: AppPaths, initialize: bool, reset: bool, as_json: bool, ) -> int: return _settings_commands.settings( sys.modules[__name__], paths, initialize, reset, as_json, ) def _launcher_update( paths: AppPaths, check_only: bool, verify: bool, rollback: str | bool | None, manifest_url: str | None, public_key: Path | None, as_json: bool, *, history_url: str | None = None, ) -> int: return _launcher_update_commands.launcher_update( sys.modules[__name__], paths, check_only, verify, rollback, manifest_url, public_key, as_json, history_url=history_url, ) def _source_game( paths: AppPaths, _settings: Settings, argument: Path | None, ) -> Path: """Select the managed game unless an explicit diagnostic path was supplied. The persisted ``game_source`` setting is an import seed only. It must not redirect normal Play, Prepare, or Login Trace operations away from the launcher-managed tree. """ if argument is not None: return argument.expanduser().resolve() return paths.source_game def _load_or_default_settings(paths: AppPaths, *, persist: bool) -> Settings: if paths.settings_file.is_file(): return load_settings(paths) if persist: return ensure_settings(paths) return Settings() def _runner(paths: AppPaths, operation: str, dry_run: bool) -> CommandRunner: timestamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S-%f") return CommandRunner( paths.logs / f"{timestamp}-{operation}.jsonl", dry_run=dry_run, operation=operation, ) 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, active_game_probe=lambda: _managed_game_process_is_active(paths), ) def _operation_guard( paths: AppPaths, operation: str, dry_run: bool, ) -> AbstractContextManager[None]: if dry_run: return nullcontext() return launcher_operation_lock( paths.launcher_operation_lock, operation, active_game_probe=lambda: _managed_game_process_is_active(paths), ) def _managed_game_process_is_active(paths: AppPaths) -> bool: return managed_game_process_is_active( paths.runtime_view, (paths.proton_prefix, paths.umu_prefix), ) def _emit(value: object, as_json: bool) -> None: _application_helpers.emit(value, as_json, json_printer=_print_json) def _print_json(value: object) -> None: _application_helpers.print_json(value)