"""Download, setup, depot synchronization, import, and prepare commands.""" from __future__ import annotations from dataclasses import asdict import json from pathlib import Path import time from typing import Any from ...command import CommandOutcome, CommandRunner from ...depot import DownloadOptions, SyncProgress from ...importer import GameImportResult from ...launcher import LauncherError, WineTools from ...paths import AppPaths from ...settings import Settings def fetch(api: Any, paths: AppPaths, dry_run: bool, as_json: bool) -> int: if not dry_run: paths.create_base_directories() if dry_run: value = { "ok": True, "dry_run": True, "url": api.OFFICIAL_LAUNCHER_URL, "download": str(paths.launcher_pristine), "prepared": str(paths.launcher_patched), } else: telemetry = api.current_telemetry() download_started_at = time.monotonic() if telemetry is not None: telemetry.send_event( "download_started", metadata={ "resource_type": "launcher", "mirror_id": "pristontale.eu", }, context={"operation": "download", "stage": "launcher_download"}, ) try: download = api.fetch_official_launcher( paths.launcher_pristine, paths.launcher_metadata, ) except BaseException as exc: if telemetry is not None: telemetry.send_event( "download_failed", severity="error", message=str(exc), metadata={ "resource_type": "launcher", "duration_ms": round( (time.monotonic() - download_started_at) * 1000 ), "failure_type": api.classify_network_failure(exc), "http_status": api.network_http_status(exc), "mirror_id": "pristontale.eu", }, context={"operation": "download", "stage": "launcher_download"}, ) raise download_duration = max(0.0, time.monotonic() - download_started_at) download_bytes = download.size if download.changed else 0 if telemetry is not None: telemetry.send_event( "download_completed", metadata={ "resource_type": "launcher", "download_bytes": download_bytes, "content_bytes": download.size, "duration_ms": round(download_duration * 1000), "average_mbps": ( round((download_bytes * 8) / download_duration / 1_000_000, 3) if download_bytes and download_duration > 0 else None ), "retry_count": 0, "resume_used": False, "http_status": 200 if download.changed else 304, "changed": download.changed, "mirror_id": "pristontale.eu", }, context={"operation": "download", "stage": "launcher_download"}, ) validation_started_at = time.monotonic() try: patch = api.prepare_launcher_binary( paths.launcher_pristine, paths.launcher_patched, ) except BaseException as exc: if telemetry is not None: telemetry.send_event( "file_validation_failed", severity="error", message=str(exc), metadata={ "file_category": "launcher", "duration_ms": round( (time.monotonic() - validation_started_at) * 1000 ), }, context={"operation": "download", "stage": "validation"}, ) raise value = { "ok": True, "download": asdict(download), "patch": asdict(patch), "warning": None if patch.supported_profile else ( "The launcher is newer than the verified font-patch profile; " "it was retained byte-for-byte." ), } api._emit(value, as_json) return 0 def setup( api: Any, paths: AppPaths, dry_run: bool, force_runtime: bool, as_json: bool, ) -> int: tools = api.find_wine_tools() if tools is None: raise LauncherError("Wine, Wineboot, and Winetricks are required; run doctor") runner = api._runner(paths, "setup", dry_run) with api._operation_guard(paths, "setup", dry_run): results = api._configure_launcher_runtime( paths, tools, runner, force_runtime=force_runtime, ) api._emit( { "ok": True, "dry_run": dry_run, "prefix": str(paths.launcher_prefix), "commands": [asdict(result) for result in results], }, as_json, ) return 0 def native_update( api: Any, paths: AppPaths, dry_run: bool, repair: bool, seed_from: Path | None, no_import: bool, as_json: bool, ) -> int: """Use the native verified depot client for normal installs and updates.""" settings = api._load_or_default_settings(paths, persist=not dry_run) download_options = api._configured_download_options(settings) runner = api._runner(paths, "native-update", dry_run) with api._operation_guard(paths, "native-update", dry_run): api._print_operation_status(as_json, 1, 3, "Checking local game files…") game_import = None if not no_import: game_import = api._maybe_import_game( paths, settings, seed_from, runner, announce=not as_json, ) verify_import = game_import is not None or api.game_import_requires_verification(paths) api._print_operation_status(as_json, 2, 3, "Synchronizing official game files…") result = api.sync_managed_game( paths, repair=repair or verify_import, quarantine_unmanaged=verify_import, dry_run=dry_run, progress=None if as_json else api._print_sync_progress, **( {"download_options": download_options} if download_options is not None else {} ), ) if verify_import and not dry_run: api.mark_game_import_verified( paths, build=result.build, files_quarantined=result.files_quarantined, quarantine_directory=result.quarantine_directory, ) overlay = None if not dry_run and paths.source_game.is_dir(): api._print_operation_status(as_json, 3, 3, "Preparing the clean game runtime view…") overlay = asdict(api.prepare_runtime_view(paths, paths.source_game)) api._emit( { "ok": True, "dry_run": dry_run, "game_import": asdict(game_import) if game_import else None, "native_update": asdict(result), "runtime_view": overlay, }, as_json, ) return 0 def wine_update( api: Any, paths: AppPaths, dry_run: bool, force_runtime: bool, seed_from: Path | None, no_import: bool, as_json: bool, ) -> int: api._fetch(paths, dry_run, True) tools = api.find_wine_tools() if tools is None: raise LauncherError("Wine, Wineboot, and Winetricks are required; run doctor") runner = api._runner(paths, "update", dry_run) settings = api._load_or_default_settings(paths, persist=not dry_run) download_options = api._configured_download_options(settings) with api._operation_guard(paths, "update", dry_run): api._configure_launcher_runtime( paths, tools, runner, force_runtime=force_runtime, ) game_import = None if not no_import: game_import = api._maybe_import_game( paths, settings, seed_from, runner, announce=not as_json, ) updater = api.launch_official_updater(paths, tools, runner) native_verification = None verify_import = not dry_run and ( game_import is not None or api.game_import_requires_verification(paths) ) if verify_import: native_verification = api.sync_managed_game( paths, repair=True, quarantine_unmanaged=True, progress=None if as_json else api._print_sync_progress, **( {"download_options": download_options} if download_options is not None else {} ), ) api.mark_game_import_verified( paths, build=native_verification.build, files_quarantined=native_verification.files_quarantined, quarantine_directory=native_verification.quarantine_directory, ) overlay = None if not dry_run and paths.source_game.is_dir(): overlay = asdict(api.prepare_runtime_view(paths, paths.source_game)) value = { "ok": True, "dry_run": dry_run, "game_import": asdict(game_import) if game_import else None, "updater": asdict(updater), "native_verification": ( asdict(native_verification) if native_verification is not None else None ), "runtime_view": overlay, } api._emit(value, as_json) return 0 def configure_launcher_runtime( api: Any, paths: AppPaths, tools: WineTools, runner: CommandRunner, *, force_runtime: bool, ) -> list[CommandOutcome]: if not force_runtime or runner.dry_run: return api.configure_launcher_prefix( paths, tools, runner, force_runtime=force_runtime, ) telemetry = api.current_telemetry() started_at = time.monotonic() metadata = { "runtime_type": "dotnet_desktop", "runtime_version": "6", } if telemetry is not None: telemetry.send_event( "runtime_reinstall_requested", metadata={**metadata, "reason": "force_runtime"}, context={"operation": "runtime_repair", "stage": "requested"}, ) telemetry.send_event( "runtime_repair_started", metadata=metadata, context={"operation": "runtime_repair", "stage": "started"}, ) try: results = api.configure_launcher_prefix( paths, tools, runner, force_runtime=True, ) except BaseException as exc: if telemetry is not None: telemetry.send_event( "runtime_repair_failed", severity="error", message=str(exc), metadata={ **metadata, "duration_ms": round((time.monotonic() - started_at) * 1000), }, context={"operation": "runtime_repair", "stage": "runtime_install"}, ) raise if telemetry is not None: telemetry.send_event( "runtime_repair_success", metadata={ **metadata, "duration_ms": round((time.monotonic() - started_at) * 1000), }, context={"operation": "runtime_repair", "stage": "completed"}, ) return results def print_sync_progress(progress: SyncProgress) -> None: """Emit line-delimited progress that the native Tk dashboard can consume.""" print(f"PROGRESS {json.dumps(asdict(progress), sort_keys=True)}", flush=True) def configured_download_options(settings: Settings) -> DownloadOptions | None: if ( settings.download_speed_limit_bytes_per_second is None and not settings.low_bandwidth_mode ): return None return DownloadOptions( bandwidth_limit_bytes_per_second=( settings.download_speed_limit_bytes_per_second ), low_bandwidth=settings.low_bandwidth_mode, ) def print_operation_status( as_json: bool, step: int, total: int, message: str, ) -> None: """Emit a structured operation stage for the native launcher dashboard.""" if not as_json: print( f"STATUS {json.dumps({'step': step, 'total': total, 'message': message})}", flush=True, ) def import_game( api: Any, paths: AppPaths, source: Path, dry_run: bool, as_json: bool, ) -> int: runner = api._runner(paths, "import-game", dry_run) if not as_json: print(f"Importing existing game from {source.expanduser()}…", flush=True) with api._operation_guard(paths, "import-game", dry_run): result = api._import_with_telemetry(paths, source, runner) api._emit({"ok": True, "dry_run": dry_run, "game_import": asdict(result)}, as_json) return 0 def maybe_import_game( api: Any, paths: AppPaths, settings: Settings, seed_from: Path | None, runner: CommandRunner, *, announce: bool, ) -> GameImportResult | None: if paths.source_game.exists() or paths.source_game.is_symlink(): if seed_from is not None: raise RuntimeError( f"cannot seed an existing managed game directory: {paths.source_game}" ) return None candidate = seed_from if candidate is None: candidate = api.discover_existing_game(paths, settings) if candidate is None: return None if announce: print(f"Bootstrapping from existing game: {candidate}", flush=True) return api._import_with_telemetry(paths, candidate, runner) def import_with_telemetry( api: Any, paths: AppPaths, source: Path, runner: CommandRunner, ) -> GameImportResult: telemetry = api.current_telemetry() started_at = time.monotonic() if telemetry is not None and not runner.dry_run: telemetry.send_event( "import_started", context={"operation": "import", "stage": "copy"}, ) try: result = api.import_existing_game(paths, source, runner) except BaseException as exc: if telemetry is not None and not runner.dry_run: telemetry.send_event( "import_failed", severity="error", message=str(exc), metadata={ "duration_ms": round((time.monotonic() - started_at) * 1000), }, context={"operation": "import", "stage": "copy"}, ) raise if telemetry is not None and not runner.dry_run: telemetry.send_event( "import_success", metadata={ "files_imported": result.files, "bytes_imported": result.bytes, "duration_ms": round((time.monotonic() - started_at) * 1000), }, context={"operation": "import", "stage": "completed"}, ) return result def prepare(api: Any, paths: AppPaths, game_dir: Path | None, as_json: bool) -> int: with api._operation_guard(paths, "prepare", False): if game_dir is None and api.game_import_requires_verification(paths): raise LauncherError( "the imported game has not completed its full publisher verification; " "run pristontale-eu update" ) settings = api.ensure_settings(paths) source = api._source_game(paths, settings, game_dir) result = api.prepare_runtime_view(paths, source) api._emit({"ok": True, "runtime_view": asdict(result)}, as_json) return 0