Files
pristontale-eu-linux/src/ptlinux/application/commands/installation.py

480 lines
13 KiB
Python

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