Added more folders and restructured classes inside them.
This commit is contained in:
479
src/ptlinux/application/commands/installation.py
Normal file
479
src/ptlinux/application/commands/installation.py
Normal file
@@ -0,0 +1,479 @@
|
||||
"""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
|
||||
|
||||
Reference in New Issue
Block a user