480 lines
13 KiB
Python
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
|
|
|