Init
This commit is contained in:
7
ptlinux/__init__.py
Normal file
7
ptlinux/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Portable PristonTale EU installer and Linux launcher."""
|
||||
|
||||
from .paths import AppPaths
|
||||
|
||||
__all__ = ["AppPaths", "__version__"]
|
||||
|
||||
__version__ = "0.5.1"
|
||||
4
ptlinux/__main__.py
Normal file
4
ptlinux/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .cli import main
|
||||
|
||||
|
||||
raise SystemExit(main())
|
||||
651
ptlinux/cli.py
Normal file
651
ptlinux/cli.py
Normal file
@@ -0,0 +1,651 @@
|
||||
"""Command-line interface for installation, updates, repair, and play."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from contextlib import AbstractContextManager, nullcontext
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Sequence
|
||||
|
||||
from . import __version__
|
||||
from .command import CommandError, CommandRunner
|
||||
from .depot import SyncProgress, 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 .launcher import (
|
||||
LauncherError,
|
||||
configure_launcher_prefix,
|
||||
find_wine_tools,
|
||||
launch_official_updater,
|
||||
prepare_launcher_binary,
|
||||
)
|
||||
from .locking import launcher_operation_lock
|
||||
from .overlay import cache_compatibility_binaries, cache_runtime_patch, prepare_runtime_view
|
||||
from .paths import AppPaths
|
||||
from .runtime import (
|
||||
prepare_game_runtime,
|
||||
run_game,
|
||||
select_runtime,
|
||||
)
|
||||
from .settings import Settings, ensure_settings, load_settings, save_settings
|
||||
from .self_update import (
|
||||
check_for_launcher_update,
|
||||
install_launcher_update,
|
||||
rollback_launcher_update,
|
||||
update_check_to_dict,
|
||||
)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="pristontale-eu",
|
||||
description="PristonTale EU all-in-one Linux installer and clean launcher",
|
||||
)
|
||||
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")
|
||||
|
||||
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)
|
||||
runtime_patch = subparsers.add_parser(
|
||||
"cache-runtime-patch",
|
||||
help="cache the exact verified V21 texture-reclaim game.dll",
|
||||
)
|
||||
runtime_patch.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 clean Gamescope box")
|
||||
play.add_argument("--game-dir", type=Path)
|
||||
play.add_argument("--no-box", action="store_true", help="launch without Gamescope")
|
||||
play.add_argument("--dry-run", action="store_true")
|
||||
play.add_argument("--skip-runtime-setup", action="store_true")
|
||||
|
||||
settings = subparsers.add_parser("settings", help="show or initialize settings")
|
||||
settings.add_argument("--init", action="store_true", help="write conservative defaults")
|
||||
|
||||
launcher_update = subparsers.add_parser(
|
||||
"launcher-update",
|
||||
help="check, 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(
|
||||
"--rollback",
|
||||
action="store_true",
|
||||
help="switch back to the previously installed launcher 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",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
arguments = parser.parse_args(argv)
|
||||
command = arguments.command or "gui"
|
||||
paths = AppPaths.from_environment()
|
||||
try:
|
||||
if command == "gui":
|
||||
from .ui import run_dashboard
|
||||
|
||||
run_dashboard(paths)
|
||||
return 0
|
||||
if command == "doctor":
|
||||
return _doctor(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 == "cache-runtime-patch":
|
||||
result = cache_runtime_patch(paths, arguments.source)
|
||||
_emit({"ok": True, "runtime_patch": 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,
|
||||
)
|
||||
if command == "settings":
|
||||
return _settings(paths, arguments.init, arguments.json)
|
||||
if command == "launcher-update":
|
||||
return _launcher_update(
|
||||
paths,
|
||||
arguments.check,
|
||||
arguments.rollback,
|
||||
arguments.manifest_url,
|
||||
arguments.public_key,
|
||||
arguments.json,
|
||||
)
|
||||
parser.error(f"unknown command: {command}")
|
||||
except (ValueError, OSError, RuntimeError) as exc:
|
||||
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
|
||||
|
||||
|
||||
def _doctor(as_json: bool) -> int:
|
||||
report = inspect_host()
|
||||
if as_json:
|
||||
_print_json(report.to_dict())
|
||||
else:
|
||||
print(f"Distribution: {report.distribution} {report.version} ({report.architecture})")
|
||||
print(f"Display: {report.display_backend}")
|
||||
for tool in report.tools:
|
||||
status = tool.path or "MISSING"
|
||||
required = "required" if tool.required else "optional"
|
||||
print(f"{tool.name:12} {status} [{required}] — {tool.purpose}")
|
||||
for warning in report.warnings:
|
||||
print(f"Warning: {warning}")
|
||||
if report.package_hint:
|
||||
print(report.package_hint)
|
||||
return 0 if report.ok else 1
|
||||
|
||||
|
||||
def _fetch(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": OFFICIAL_LAUNCHER_URL,
|
||||
"download": str(paths.launcher_pristine),
|
||||
"prepared": str(paths.launcher_patched),
|
||||
}
|
||||
else:
|
||||
download = fetch_official_launcher(
|
||||
paths.launcher_pristine,
|
||||
paths.launcher_metadata,
|
||||
)
|
||||
patch = prepare_launcher_binary(
|
||||
paths.launcher_pristine,
|
||||
paths.launcher_patched,
|
||||
)
|
||||
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."
|
||||
),
|
||||
}
|
||||
_emit(value, as_json)
|
||||
return 0
|
||||
|
||||
|
||||
def _setup(
|
||||
paths: AppPaths,
|
||||
dry_run: bool,
|
||||
force_runtime: bool,
|
||||
as_json: bool,
|
||||
) -> int:
|
||||
tools = find_wine_tools()
|
||||
if tools is None:
|
||||
raise LauncherError("Wine, Wineboot, and Winetricks are required; run doctor")
|
||||
runner = _runner(paths, "setup", dry_run)
|
||||
with _operation_guard(paths, "setup", dry_run):
|
||||
results = configure_launcher_prefix(
|
||||
paths,
|
||||
tools,
|
||||
runner,
|
||||
force_runtime=force_runtime,
|
||||
)
|
||||
_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(
|
||||
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 = _load_or_default_settings(paths, persist=not dry_run)
|
||||
runner = _runner(paths, "native-update", dry_run)
|
||||
with _operation_guard(paths, "native-update", dry_run):
|
||||
_print_operation_status(as_json, 1, 3, "Checking local game files…")
|
||||
game_import = None
|
||||
if not no_import:
|
||||
game_import = _maybe_import_game(
|
||||
paths,
|
||||
settings,
|
||||
seed_from,
|
||||
runner,
|
||||
announce=not as_json,
|
||||
)
|
||||
_print_operation_status(as_json, 2, 3, "Synchronizing official game files…")
|
||||
result = sync_managed_game(
|
||||
paths,
|
||||
repair=repair,
|
||||
dry_run=dry_run,
|
||||
progress=None if as_json else _print_sync_progress,
|
||||
)
|
||||
overlay = None
|
||||
if not dry_run and paths.source_game.is_dir():
|
||||
_print_operation_status(as_json, 3, 3, "Preparing the clean game runtime view…")
|
||||
overlay = asdict(prepare_runtime_view(paths, paths.source_game))
|
||||
_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(
|
||||
paths: AppPaths,
|
||||
dry_run: bool,
|
||||
force_runtime: bool,
|
||||
seed_from: Path | None,
|
||||
no_import: bool,
|
||||
as_json: bool,
|
||||
) -> int:
|
||||
_fetch(paths, dry_run, True)
|
||||
tools = find_wine_tools()
|
||||
if tools is None:
|
||||
raise LauncherError("Wine, Wineboot, and Winetricks are required; run doctor")
|
||||
runner = _runner(paths, "update", dry_run)
|
||||
settings = _load_or_default_settings(paths, persist=not dry_run)
|
||||
with _operation_guard(paths, "update", dry_run):
|
||||
configure_launcher_prefix(
|
||||
paths,
|
||||
tools,
|
||||
runner,
|
||||
force_runtime=force_runtime,
|
||||
)
|
||||
game_import = None
|
||||
if not no_import:
|
||||
game_import = _maybe_import_game(
|
||||
paths,
|
||||
settings,
|
||||
seed_from,
|
||||
runner,
|
||||
announce=not as_json,
|
||||
)
|
||||
updater = launch_official_updater(paths, tools, runner)
|
||||
overlay = None
|
||||
if not dry_run and paths.source_game.is_dir():
|
||||
overlay = asdict(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),
|
||||
"runtime_view": overlay,
|
||||
}
|
||||
_emit(value, as_json)
|
||||
return 0
|
||||
|
||||
|
||||
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 _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(
|
||||
paths: AppPaths,
|
||||
source: Path,
|
||||
dry_run: bool,
|
||||
as_json: bool,
|
||||
) -> int:
|
||||
runner = _runner(paths, "import-game", dry_run)
|
||||
if not as_json:
|
||||
print(f"Importing existing game from {source.expanduser()}…", flush=True)
|
||||
with _operation_guard(paths, "import-game", dry_run):
|
||||
result = import_existing_game(paths, source, runner)
|
||||
_emit({"ok": True, "dry_run": dry_run, "game_import": asdict(result)}, as_json)
|
||||
return 0
|
||||
|
||||
|
||||
def _maybe_import_game(
|
||||
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 = discover_existing_game(paths, settings)
|
||||
if candidate is None:
|
||||
return None
|
||||
if announce:
|
||||
print(f"Bootstrapping from existing game: {candidate}", flush=True)
|
||||
return import_existing_game(paths, candidate, runner)
|
||||
|
||||
|
||||
def _prepare(paths: AppPaths, game_dir: Path | None, as_json: bool) -> int:
|
||||
settings = ensure_settings(paths)
|
||||
source = _source_game(paths, settings, game_dir)
|
||||
result = prepare_runtime_view(paths, source)
|
||||
_emit({"ok": True, "runtime_view": asdict(result)}, as_json)
|
||||
return 0
|
||||
|
||||
|
||||
def _play(
|
||||
paths: AppPaths,
|
||||
game_dir: Path | None,
|
||||
no_box: bool,
|
||||
dry_run: bool,
|
||||
skip_runtime_setup: bool,
|
||||
as_json: bool,
|
||||
) -> int:
|
||||
settings = _load_or_default_settings(paths, persist=not dry_run)
|
||||
source = _source_game(paths, settings, game_dir)
|
||||
overlay = None
|
||||
_print_operation_status(as_json, 1, 4, "Preparing the managed game view…")
|
||||
if not dry_run:
|
||||
overlay = prepare_runtime_view(paths, source)
|
||||
_print_operation_status(as_json, 2, 4, "Selecting the configured game runtime…")
|
||||
selection = select_runtime(settings)
|
||||
runner = _runner(paths, "play", dry_run)
|
||||
setup_results = []
|
||||
if not skip_runtime_setup:
|
||||
_print_operation_status(as_json, 3, 4, "Preparing Proton and DirectX components…")
|
||||
setup_results = prepare_game_runtime(paths, selection, runner)
|
||||
_print_operation_status(as_json, 4, 4, "Starting the PristonTale game client…")
|
||||
outcome = run_game(
|
||||
paths,
|
||||
settings,
|
||||
selection,
|
||||
runner,
|
||||
use_gamebox=not no_box,
|
||||
)
|
||||
_emit(
|
||||
{
|
||||
"ok": outcome.returncode == 0,
|
||||
"dry_run": dry_run,
|
||||
"runtime": asdict(selection),
|
||||
"runtime_view": asdict(overlay) if overlay else None,
|
||||
"setup_commands": [asdict(result) for result in setup_results],
|
||||
"game": asdict(outcome),
|
||||
},
|
||||
as_json,
|
||||
)
|
||||
return 0 if outcome.returncode == 0 else 1
|
||||
|
||||
|
||||
def _settings(paths: AppPaths, initialize: bool, as_json: bool) -> int:
|
||||
if initialize:
|
||||
save_settings(paths, Settings())
|
||||
settings = ensure_settings(paths) if initialize else load_settings(paths)
|
||||
_emit(
|
||||
{
|
||||
"ok": True,
|
||||
"path": str(paths.settings_file),
|
||||
"settings": asdict(settings),
|
||||
},
|
||||
as_json,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _launcher_update(
|
||||
paths: AppPaths,
|
||||
check_only: bool,
|
||||
rollback: bool,
|
||||
manifest_url: str | None,
|
||||
public_key: Path | None,
|
||||
as_json: bool,
|
||||
) -> int:
|
||||
if rollback:
|
||||
result = rollback_launcher_update(paths)
|
||||
_emit(
|
||||
{
|
||||
"ok": True,
|
||||
"launcher_rollback": asdict(result),
|
||||
},
|
||||
as_json,
|
||||
)
|
||||
return 0
|
||||
if check_only:
|
||||
check = check_for_launcher_update(
|
||||
manifest_url=manifest_url,
|
||||
public_key_path=public_key,
|
||||
)
|
||||
_emit(
|
||||
{
|
||||
"ok": True,
|
||||
"launcher_update": update_check_to_dict(check),
|
||||
},
|
||||
as_json,
|
||||
)
|
||||
return 0
|
||||
_print_operation_status(as_json, 1, 2, "Verifying the signed launcher release…")
|
||||
result = install_launcher_update(
|
||||
paths,
|
||||
manifest_url=manifest_url,
|
||||
public_key_path=public_key,
|
||||
)
|
||||
_print_operation_status(as_json, 2, 2, "Launcher update installed safely.")
|
||||
_emit(
|
||||
{
|
||||
"ok": True,
|
||||
"launcher_update": asdict(result),
|
||||
},
|
||||
as_json,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _source_game(
|
||||
paths: AppPaths,
|
||||
settings: Settings,
|
||||
argument: Path | None,
|
||||
) -> Path:
|
||||
if argument is not None:
|
||||
return argument.expanduser().resolve()
|
||||
source_override = settings.source_override
|
||||
if source_override is not None:
|
||||
return source_override
|
||||
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")
|
||||
return CommandRunner(paths.logs / f"{timestamp}-{operation}.jsonl", dry_run=dry_run)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _emit(value: object, as_json: bool) -> None:
|
||||
if as_json:
|
||||
_print_json(value)
|
||||
else:
|
||||
if isinstance(value, dict) and value.get("ok") is True:
|
||||
print("Done.")
|
||||
print(json.dumps(value, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def _print_json(value: object) -> None:
|
||||
print(json.dumps(value, indent=2, sort_keys=True))
|
||||
212
ptlinux/command.py
Normal file
212
ptlinux/command.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""Inspectable subprocess execution for installer operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Mapping, Sequence, TextIO
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandOutcome:
|
||||
argv: list[str]
|
||||
returncode: int | None
|
||||
stdout: str
|
||||
stderr: str
|
||||
duration_seconds: float
|
||||
timed_out: bool
|
||||
dry_run: bool
|
||||
|
||||
|
||||
class CommandError(RuntimeError):
|
||||
"""Raised when a required installer command fails."""
|
||||
|
||||
def __init__(self, outcome: CommandOutcome) -> None:
|
||||
self.outcome = outcome
|
||||
returncode = "unknown" if outcome.returncode is None else str(outcome.returncode)
|
||||
message = f"command failed ({returncode}): {outcome.argv!r}"
|
||||
if outcome.timed_out:
|
||||
message = f"command timed out: {outcome.argv!r}"
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class CommandRunner:
|
||||
"""Run argument arrays with timeouts and append-only JSONL logging."""
|
||||
|
||||
def __init__(self, log_path: Path, *, dry_run: bool = False) -> None:
|
||||
self.log_path = log_path
|
||||
self.dry_run = dry_run
|
||||
|
||||
def run(
|
||||
self,
|
||||
argv: Sequence[str],
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
env_overrides: Mapping[str, str] | None = None,
|
||||
timeout_seconds: float = 300.0,
|
||||
check: bool = True,
|
||||
stream_output: bool = False,
|
||||
) -> CommandOutcome:
|
||||
arguments = [str(value) for value in argv]
|
||||
if not arguments:
|
||||
raise ValueError("command argv cannot be empty")
|
||||
overrides = dict(env_overrides or {})
|
||||
started = time.monotonic()
|
||||
if self.dry_run:
|
||||
outcome = CommandOutcome(
|
||||
argv=arguments,
|
||||
returncode=0,
|
||||
stdout="",
|
||||
stderr="",
|
||||
duration_seconds=0.0,
|
||||
timed_out=False,
|
||||
dry_run=True,
|
||||
)
|
||||
self._record(outcome, cwd, overrides)
|
||||
return outcome
|
||||
|
||||
environment = os.environ.copy()
|
||||
environment.update(overrides)
|
||||
if stream_output:
|
||||
outcome = self._run_streamed(
|
||||
arguments,
|
||||
cwd=cwd,
|
||||
environment=environment,
|
||||
timeout_seconds=timeout_seconds,
|
||||
started=started,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
arguments,
|
||||
cwd=str(cwd) if cwd is not None else None,
|
||||
env=environment,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
outcome = CommandOutcome(
|
||||
argv=arguments,
|
||||
returncode=completed.returncode,
|
||||
stdout=completed.stdout,
|
||||
stderr=completed.stderr,
|
||||
duration_seconds=time.monotonic() - started,
|
||||
timed_out=False,
|
||||
dry_run=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
outcome = CommandOutcome(
|
||||
argv=arguments,
|
||||
returncode=None,
|
||||
stdout=_as_text(exc.stdout),
|
||||
stderr=_as_text(exc.stderr),
|
||||
duration_seconds=time.monotonic() - started,
|
||||
timed_out=True,
|
||||
dry_run=False,
|
||||
)
|
||||
self._record(outcome, cwd, overrides)
|
||||
if check and (outcome.timed_out or outcome.returncode != 0):
|
||||
raise CommandError(outcome)
|
||||
return outcome
|
||||
|
||||
@staticmethod
|
||||
def _run_streamed(
|
||||
arguments: list[str],
|
||||
*,
|
||||
cwd: Path | None,
|
||||
environment: Mapping[str, str],
|
||||
timeout_seconds: float,
|
||||
started: float,
|
||||
) -> CommandOutcome:
|
||||
process = subprocess.Popen(
|
||||
arguments,
|
||||
cwd=str(cwd) if cwd is not None else None,
|
||||
env=dict(environment),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=1,
|
||||
)
|
||||
stdout: list[str] = []
|
||||
stderr: list[str] = []
|
||||
|
||||
def relay(
|
||||
stream: TextIO,
|
||||
capture: list[str],
|
||||
destination: TextIO,
|
||||
) -> None:
|
||||
while True:
|
||||
line = stream.readline()
|
||||
if not line:
|
||||
return
|
||||
capture.append(line)
|
||||
destination.write(line)
|
||||
destination.flush()
|
||||
|
||||
assert process.stdout is not None
|
||||
assert process.stderr is not None
|
||||
stdout_thread = threading.Thread(
|
||||
target=relay,
|
||||
args=(process.stdout, stdout, sys.stdout),
|
||||
daemon=True,
|
||||
)
|
||||
stderr_thread = threading.Thread(
|
||||
target=relay,
|
||||
args=(process.stderr, stderr, sys.stderr),
|
||||
daemon=True,
|
||||
)
|
||||
stdout_thread.start()
|
||||
stderr_thread.start()
|
||||
timed_out = False
|
||||
try:
|
||||
returncode = process.wait(timeout=timeout_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
timed_out = True
|
||||
process.kill()
|
||||
returncode = process.wait()
|
||||
stdout_thread.join()
|
||||
stderr_thread.join()
|
||||
process.stdout.close()
|
||||
process.stderr.close()
|
||||
return CommandOutcome(
|
||||
argv=arguments,
|
||||
returncode=None if timed_out else returncode,
|
||||
stdout="".join(stdout),
|
||||
stderr="".join(stderr),
|
||||
duration_seconds=time.monotonic() - started,
|
||||
timed_out=timed_out,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
def _record(
|
||||
self,
|
||||
outcome: CommandOutcome,
|
||||
cwd: Path | None,
|
||||
env_overrides: Mapping[str, str],
|
||||
) -> None:
|
||||
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
entry = {
|
||||
"timestamp": datetime.now().astimezone().isoformat(),
|
||||
"cwd": str(cwd) if cwd is not None else None,
|
||||
"env_overrides": dict(sorted(env_overrides.items())),
|
||||
**asdict(outcome),
|
||||
}
|
||||
with self.log_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(entry, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def _as_text(value: bytes | str | None) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", errors="replace")
|
||||
return value
|
||||
1091
ptlinux/depot.py
Normal file
1091
ptlinux/depot.py
Normal file
File diff suppressed because it is too large
Load Diff
108
ptlinux/desktop_entry.py
Normal file
108
ptlinux/desktop_entry.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Install a desktop entry that does not depend on the GUI session's PATH."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
EXECUTABLE_TOKEN = "@PTLINUX_EXECUTABLE@"
|
||||
ICON_TOKEN = "@PTLINUX_ICON@"
|
||||
|
||||
|
||||
class DesktopEntryError(ValueError):
|
||||
"""Raised when a safe desktop entry cannot be rendered."""
|
||||
|
||||
|
||||
def quote_exec_argument(value: str) -> str:
|
||||
"""Quote one Desktop Entry ``Exec`` argument without field-code expansion."""
|
||||
|
||||
if not value or any(character in value for character in ("\x00", "\n", "\r")):
|
||||
raise DesktopEntryError("desktop executable path contains invalid characters")
|
||||
escaped = value.replace("\\", "\\\\\\\\")
|
||||
escaped = escaped.replace('"', '\\\\"')
|
||||
escaped = escaped.replace("`", "\\\\`")
|
||||
escaped = escaped.replace("$", "\\\\$")
|
||||
escaped = escaped.replace("%", "%%")
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
def escape_icon_path(value: str) -> str:
|
||||
"""Escape one absolute Desktop Entry ``Icon`` path as a string value."""
|
||||
|
||||
if not value or any(character in value for character in ("\x00", "\n", "\r")):
|
||||
raise DesktopEntryError("desktop icon path contains invalid characters")
|
||||
return value.replace("\\", "\\\\")
|
||||
|
||||
|
||||
def render_desktop_entry(template: str, executable: Path, icon: Path) -> str:
|
||||
"""Replace launcher and icon tokens with validated absolute paths."""
|
||||
|
||||
if template.count(EXECUTABLE_TOKEN) != 1:
|
||||
raise DesktopEntryError("desktop template must contain exactly one executable token")
|
||||
if template.count(ICON_TOKEN) != 1:
|
||||
raise DesktopEntryError("desktop template must contain exactly one icon token")
|
||||
launcher = executable.expanduser()
|
||||
if not launcher.is_absolute():
|
||||
raise DesktopEntryError("desktop launcher path must be absolute")
|
||||
if not launcher.is_file() or not os.access(launcher, os.X_OK):
|
||||
raise DesktopEntryError(f"desktop launcher is missing or not executable: {launcher}")
|
||||
icon_path = icon.expanduser()
|
||||
if not icon_path.is_absolute():
|
||||
raise DesktopEntryError("desktop icon path must be absolute")
|
||||
if not icon_path.is_file():
|
||||
raise DesktopEntryError(f"desktop icon is missing: {icon_path}")
|
||||
rendered = template.replace(EXECUTABLE_TOKEN, quote_exec_argument(str(launcher)))
|
||||
rendered = rendered.replace(ICON_TOKEN, escape_icon_path(str(icon_path)))
|
||||
return rendered if rendered.endswith("\n") else rendered + "\n"
|
||||
|
||||
|
||||
def install_desktop_entry(
|
||||
template: Path,
|
||||
executable: Path,
|
||||
icon: Path,
|
||||
destination: Path,
|
||||
) -> None:
|
||||
"""Render and atomically install one user-level desktop entry."""
|
||||
|
||||
rendered = render_desktop_entry(
|
||||
template.read_text(encoding="utf-8"),
|
||||
executable,
|
||||
icon,
|
||||
)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_text(rendered, encoding="utf-8")
|
||||
temporary.chmod(0o644)
|
||||
temporary.replace(destination)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="install the PristonTale EU desktop entry")
|
||||
parser.add_argument("template", type=Path)
|
||||
parser.add_argument("executable", type=Path)
|
||||
parser.add_argument("icon", type=Path)
|
||||
parser.add_argument("destination", type=Path)
|
||||
arguments = parser.parse_args(argv)
|
||||
try:
|
||||
install_desktop_entry(
|
||||
arguments.template,
|
||||
arguments.executable,
|
||||
arguments.icon,
|
||||
arguments.destination,
|
||||
)
|
||||
except (DesktopEntryError, OSError) as exc:
|
||||
print(f"Could not install desktop shortcut: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
184
ptlinux/doctor.py
Normal file
184
ptlinux/doctor.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""Cross-distribution dependency and host diagnostics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from importlib.util import find_spec
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import shutil
|
||||
from typing import Mapping
|
||||
|
||||
from .launcher import find_wine_tools
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolStatus:
|
||||
name: str
|
||||
path: str | None
|
||||
required: bool
|
||||
purpose: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DoctorReport:
|
||||
ok: bool
|
||||
distribution: str
|
||||
version: str
|
||||
architecture: str
|
||||
display_backend: str
|
||||
tools: list[ToolStatus]
|
||||
package_hint: str | None
|
||||
warnings: list[str]
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
|
||||
env = os.environ if environment is None else environment
|
||||
os_release = _read_os_release()
|
||||
distribution = os_release.get("ID", "unknown")
|
||||
version = os_release.get("VERSION_ID", "unknown")
|
||||
wine_tools = find_wine_tools()
|
||||
tools = [
|
||||
ToolStatus(
|
||||
"python3",
|
||||
shutil.which("python3"),
|
||||
True,
|
||||
"native installer and command-line interface",
|
||||
),
|
||||
ToolStatus(
|
||||
"python-tk",
|
||||
_python_tk_path(),
|
||||
True,
|
||||
"native dashboard",
|
||||
),
|
||||
ToolStatus(
|
||||
"wine",
|
||||
str(wine_tools.wine) if wine_tools else None,
|
||||
True,
|
||||
"legacy updater support and game compatibility utilities",
|
||||
),
|
||||
ToolStatus(
|
||||
"winetricks",
|
||||
str(wine_tools.winetricks) if wine_tools else shutil.which("winetricks"),
|
||||
True,
|
||||
"native D3D helper DLLs for the Proton game prefix",
|
||||
),
|
||||
ToolStatus(
|
||||
"gamescope",
|
||||
shutil.which("gamescope"),
|
||||
True,
|
||||
"borderless X11/Wayland game container",
|
||||
),
|
||||
ToolStatus(
|
||||
"cabextract",
|
||||
shutil.which("cabextract"),
|
||||
True,
|
||||
"Microsoft runtime extraction through Winetricks",
|
||||
),
|
||||
ToolStatus(
|
||||
"openssl",
|
||||
shutil.which("openssl"),
|
||||
True,
|
||||
"signed native-launcher update verification",
|
||||
),
|
||||
ToolStatus(
|
||||
"umu-run",
|
||||
shutil.which("umu-run"),
|
||||
False,
|
||||
"portable Proton runtime when Steam Proton 7 is unavailable",
|
||||
),
|
||||
ToolStatus(
|
||||
"vulkaninfo",
|
||||
shutil.which("vulkaninfo"),
|
||||
False,
|
||||
"Vulkan diagnostics",
|
||||
),
|
||||
]
|
||||
warnings: list[str] = []
|
||||
if platform.machine().casefold() not in {"x86_64", "amd64"}:
|
||||
warnings.append("PristonTale EU requires an x86-64 host with 32-bit Vulkan support.")
|
||||
if not env.get("WAYLAND_DISPLAY") and not env.get("DISPLAY"):
|
||||
warnings.append("No graphical X11 or Wayland session is visible.")
|
||||
if shutil.which("umu-run") is None and not _has_steam_proton7(env):
|
||||
warnings.append(
|
||||
"No game runtime was found: install umu-launcher or Steam Proton 7.0."
|
||||
)
|
||||
required_ok = all(item.path is not None for item in tools if item.required)
|
||||
return DoctorReport(
|
||||
ok=required_ok and not any("No game runtime" in warning for warning in warnings),
|
||||
distribution=distribution,
|
||||
version=version,
|
||||
architecture=platform.machine(),
|
||||
display_backend="wayland" if env.get("WAYLAND_DISPLAY") else "x11",
|
||||
tools=tools,
|
||||
package_hint=_package_hint(distribution),
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def _read_os_release() -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
try:
|
||||
lines = Path("/etc/os-release").read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
return result
|
||||
for line in lines:
|
||||
if "=" not in line or line.startswith("#"):
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
result[key] = value.strip().strip('"')
|
||||
return result
|
||||
|
||||
|
||||
def _python_tk_path() -> str | None:
|
||||
try:
|
||||
specification = find_spec("_tkinter")
|
||||
except (ImportError, ValueError):
|
||||
return None
|
||||
if specification is None:
|
||||
return None
|
||||
return specification.origin or "available"
|
||||
|
||||
|
||||
def _package_hint(distribution: str) -> str | None:
|
||||
if distribution in {"ubuntu", "debian", "linuxmint", "pop"}:
|
||||
return (
|
||||
"Install Python/Tk, Wine (64- and 32-bit), Winetricks, Gamescope, "
|
||||
"Cabextract, OpenSSL, and both 64- and 32-bit Vulkan drivers with APT."
|
||||
)
|
||||
if distribution in {"fedora", "nobara"}:
|
||||
return (
|
||||
"Install python3-tkinter, wine, winetricks, gamescope, cabextract, openssl, "
|
||||
"and the matching 32-bit Vulkan driver with DNF."
|
||||
)
|
||||
if distribution in {"arch", "cachyos", "endeavouros", "manjaro"}:
|
||||
return (
|
||||
"Install python, tk, wine, winetricks, gamescope, cabextract, openssl, and "
|
||||
"the matching lib32 Vulkan driver with Pacman."
|
||||
)
|
||||
if distribution in {"opensuse-tumbleweed", "opensuse-leap", "suse"}:
|
||||
return (
|
||||
"Install Python/Tk, Wine, Winetricks, Gamescope, Cabextract, OpenSSL, and "
|
||||
"32-bit Vulkan support with Zypper."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _has_steam_proton7(environment: Mapping[str, str]) -> bool:
|
||||
home_value = environment.get("HOME")
|
||||
if not home_value:
|
||||
return False
|
||||
home = Path(home_value)
|
||||
for steam_root in (
|
||||
home / ".local/share/Steam",
|
||||
home / ".steam/steam",
|
||||
home / "snap/steam/common/.local/share/Steam",
|
||||
home / ".var/app/com.valvesoftware.Steam/.local/share/Steam",
|
||||
):
|
||||
if (steam_root / "steamapps/common/Proton 7.0/proton").is_file():
|
||||
return True
|
||||
return False
|
||||
210
ptlinux/download.py
Normal file
210
ptlinux/download.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""Secure, conditional download of the official PristonTale EU launcher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import struct
|
||||
from typing import Callable
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from . import __version__
|
||||
|
||||
|
||||
OFFICIAL_LAUNCHER_URL = (
|
||||
"https://pristontale.eu/storage/uploads/Pristontale%20EU%20Launcher.exe"
|
||||
)
|
||||
MAX_LAUNCHER_BYTES = 64 * 1024 * 1024
|
||||
READ_SIZE = 256 * 1024
|
||||
USER_AGENT = f"PT-Linux/{__version__} (+https://pristontale.eu/)"
|
||||
|
||||
|
||||
class DownloadError(RuntimeError):
|
||||
"""Raised when the official launcher cannot be safely downloaded."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LauncherDownload:
|
||||
path: str
|
||||
url: str
|
||||
sha256: str
|
||||
size: int
|
||||
etag: str | None
|
||||
last_modified: str | None
|
||||
fetched_at: str
|
||||
changed: bool
|
||||
|
||||
|
||||
def fetch_official_launcher(
|
||||
destination: Path,
|
||||
metadata_path: Path,
|
||||
*,
|
||||
url: str = OFFICIAL_LAUNCHER_URL,
|
||||
timeout_seconds: float = 60.0,
|
||||
progress: Callable[[int, int | None], None] | None = None,
|
||||
) -> LauncherDownload:
|
||||
"""Download the current official launcher and atomically replace the cache."""
|
||||
|
||||
metadata = _read_metadata(metadata_path)
|
||||
headers = {"User-Agent": USER_AGENT}
|
||||
if destination.is_file() and _looks_like_pe(destination):
|
||||
etag = metadata.get("etag")
|
||||
last_modified = metadata.get("last_modified")
|
||||
if isinstance(etag, str) and etag:
|
||||
headers["If-None-Match"] = etag
|
||||
if isinstance(last_modified, str) and last_modified:
|
||||
headers["If-Modified-Since"] = last_modified
|
||||
|
||||
request = Request(url, headers=headers, method="GET")
|
||||
try:
|
||||
response = urlopen(request, timeout=timeout_seconds)
|
||||
except HTTPError as exc:
|
||||
if exc.code == 304 and destination.is_file():
|
||||
return _existing_download(destination, metadata, url)
|
||||
raise DownloadError(f"official launcher request failed: HTTP {exc.code}") from exc
|
||||
except OSError as exc:
|
||||
raise DownloadError(f"official launcher request failed: {exc}") from exc
|
||||
|
||||
with response:
|
||||
length = _content_length(response.headers.get("Content-Length"))
|
||||
if length is not None and length > MAX_LAUNCHER_BYTES:
|
||||
raise DownloadError(
|
||||
f"launcher response is unexpectedly large: {length} bytes"
|
||||
)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.part")
|
||||
digest = hashlib.sha256()
|
||||
written = 0
|
||||
try:
|
||||
with temporary.open("wb") as handle:
|
||||
while True:
|
||||
chunk = response.read(READ_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
written += len(chunk)
|
||||
if written > MAX_LAUNCHER_BYTES:
|
||||
raise DownloadError(
|
||||
"launcher response exceeded the 64 MiB safety limit"
|
||||
)
|
||||
digest.update(chunk)
|
||||
handle.write(chunk)
|
||||
if progress is not None:
|
||||
progress(written, length)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
if length is not None and written != length:
|
||||
raise DownloadError(
|
||||
f"incomplete launcher response: {written}/{length} bytes"
|
||||
)
|
||||
validate_pe_launcher(temporary)
|
||||
temporary.replace(destination)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
result = LauncherDownload(
|
||||
path=str(destination),
|
||||
url=url,
|
||||
sha256=digest.hexdigest(),
|
||||
size=written,
|
||||
etag=response.headers.get("ETag"),
|
||||
last_modified=response.headers.get("Last-Modified"),
|
||||
fetched_at=datetime.now().astimezone().isoformat(),
|
||||
changed=True,
|
||||
)
|
||||
_write_json(metadata_path, asdict(result))
|
||||
return result
|
||||
|
||||
|
||||
def validate_pe_launcher(path: Path) -> None:
|
||||
"""Validate the minimum PE structure and require a 64-bit GUI executable."""
|
||||
|
||||
try:
|
||||
with path.open("rb") as handle:
|
||||
dos = handle.read(64)
|
||||
if len(dos) < 64 or dos[:2] != b"MZ":
|
||||
raise DownloadError("download is not a DOS/PE executable")
|
||||
pe_offset = struct.unpack_from("<I", dos, 0x3C)[0]
|
||||
if pe_offset < 64 or pe_offset > 8 * 1024 * 1024:
|
||||
raise DownloadError("launcher has an invalid PE header offset")
|
||||
handle.seek(pe_offset)
|
||||
pe_header = handle.read(24)
|
||||
if len(pe_header) != 24 or pe_header[:4] != b"PE\x00\x00":
|
||||
raise DownloadError("launcher has no valid PE signature")
|
||||
machine = struct.unpack_from("<H", pe_header, 4)[0]
|
||||
if machine != 0x8664:
|
||||
raise DownloadError(
|
||||
f"launcher has unexpected PE machine type 0x{machine:04x}"
|
||||
)
|
||||
except OSError as exc:
|
||||
raise DownloadError(f"cannot validate launcher: {exc}") from exc
|
||||
|
||||
|
||||
def _looks_like_pe(path: Path) -> bool:
|
||||
try:
|
||||
validate_pe_launcher(path)
|
||||
except DownloadError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _existing_download(
|
||||
path: Path,
|
||||
metadata: dict[str, object],
|
||||
url: str,
|
||||
) -> LauncherDownload:
|
||||
validate_pe_launcher(path)
|
||||
return LauncherDownload(
|
||||
path=str(path),
|
||||
url=url,
|
||||
sha256=_sha256(path),
|
||||
size=path.stat().st_size,
|
||||
etag=_optional_string(metadata.get("etag")),
|
||||
last_modified=_optional_string(metadata.get("last_modified")),
|
||||
fetched_at=datetime.now().astimezone().isoformat(),
|
||||
changed=False,
|
||||
)
|
||||
|
||||
|
||||
def _read_metadata(path: Path) -> dict[str, object]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(READ_SIZE), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _content_length(value: str | None) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
length = int(value)
|
||||
except ValueError as exc:
|
||||
raise DownloadError(f"invalid Content-Length: {value!r}") from exc
|
||||
if length < 0:
|
||||
raise DownloadError(f"invalid Content-Length: {value!r}")
|
||||
return length
|
||||
|
||||
|
||||
def _optional_string(value: object) -> str | None:
|
||||
return value if isinstance(value, str) else None
|
||||
27
ptlinux/gamebox.py
Normal file
27
ptlinux/gamebox.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Native X11 command construction for the clean borderless game window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Sequence
|
||||
|
||||
from .settings import Settings
|
||||
|
||||
|
||||
def build_gamebox_command(
|
||||
game_command: Sequence[str],
|
||||
settings: Settings,
|
||||
) -> list[str]:
|
||||
if not game_command:
|
||||
raise ValueError("game command cannot be empty")
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"ptlinux.gamebox_runtime",
|
||||
"--window-title",
|
||||
"PristonTale EU",
|
||||
]
|
||||
if settings.borderless:
|
||||
command.append("--borderless")
|
||||
command.extend(("--", *[str(value) for value in game_command]))
|
||||
return command
|
||||
315
ptlinux/gamebox_runtime.py
Normal file
315
ptlinux/gamebox_runtime.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""Map and style the real Wine game window on the host X11 display."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Collection, Iterable, Sequence
|
||||
|
||||
|
||||
WINDOW_TITLE = "PristonTale EU"
|
||||
WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0
|
||||
WINDOW_READY_DELAY_SECONDS = 0.5
|
||||
_X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p)
|
||||
|
||||
|
||||
def _ignore_x11_error(_display: object, _event: object) -> int:
|
||||
"""Keep a stale X11 window race from terminating the mapper process."""
|
||||
return 0
|
||||
|
||||
|
||||
def _matches_game_window_title(title: str, expected_title: str) -> bool:
|
||||
"""Match the game title without accepting dialogs that merely mention it."""
|
||||
candidate = title.strip().casefold()
|
||||
expected = expected_title.strip().casefold()
|
||||
if not candidate or not expected:
|
||||
return False
|
||||
return candidate == expected or candidate.startswith(f"{expected} ")
|
||||
|
||||
|
||||
def _select_game_window(
|
||||
windows: Iterable[tuple[int, str]],
|
||||
expected_title: str,
|
||||
excluded_window_ids: Collection[int],
|
||||
) -> tuple[int, str] | None:
|
||||
for window, title in windows:
|
||||
if window in excluded_window_ids:
|
||||
continue
|
||||
if _matches_game_window_title(title, expected_title):
|
||||
return window, title
|
||||
return None
|
||||
|
||||
|
||||
class GameboxRuntimeError(RuntimeError):
|
||||
"""Raised when the game command or X11 mapper cannot be started."""
|
||||
|
||||
|
||||
class _MotifWmHints(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("flags", ctypes.c_ulong),
|
||||
("functions", ctypes.c_ulong),
|
||||
("decorations", ctypes.c_ulong),
|
||||
("input_mode", ctypes.c_long),
|
||||
("status", ctypes.c_ulong),
|
||||
]
|
||||
|
||||
|
||||
class _X11:
|
||||
def __init__(self) -> None:
|
||||
library_path = ctypes.util.find_library("X11") or "libX11.so.6"
|
||||
try:
|
||||
self.library = ctypes.CDLL(library_path)
|
||||
except OSError as exc:
|
||||
raise GameboxRuntimeError("the X11 client library is unavailable") from exc
|
||||
self._configure_signatures()
|
||||
self._error_handler = _X_ERROR_HANDLER(_ignore_x11_error) # type: ignore[arg-type]
|
||||
self._previous_error_handler = self.library.XSetErrorHandler(
|
||||
ctypes.cast(self._error_handler, ctypes.c_void_p),
|
||||
)
|
||||
self.display = self.library.XOpenDisplay(None)
|
||||
if not self.display:
|
||||
self._restore_error_handler()
|
||||
raise GameboxRuntimeError("could not open the host X11 display")
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
if self.display:
|
||||
self.library.XCloseDisplay(self.display)
|
||||
self.display = None
|
||||
finally:
|
||||
self._restore_error_handler()
|
||||
|
||||
def root_window_ids(self) -> set[int]:
|
||||
return set(self._root_windows())
|
||||
|
||||
def find_first_window(
|
||||
self,
|
||||
title_fragment: str,
|
||||
*,
|
||||
excluded_window_ids: Collection[int],
|
||||
) -> tuple[int, str] | None:
|
||||
windows: list[tuple[int, str]] = []
|
||||
for window in self._root_windows():
|
||||
title = self._window_title(window)
|
||||
if title is not None:
|
||||
windows.append((window, title))
|
||||
return _select_game_window(windows, title_fragment, excluded_window_ids)
|
||||
|
||||
def _root_windows(self) -> list[int]:
|
||||
root = self.library.XDefaultRootWindow(self.display)
|
||||
root_return = ctypes.c_ulong()
|
||||
parent_return = ctypes.c_ulong()
|
||||
children = ctypes.POINTER(ctypes.c_ulong)()
|
||||
child_count = ctypes.c_uint()
|
||||
status = self.library.XQueryTree(
|
||||
self.display,
|
||||
root,
|
||||
ctypes.byref(root_return),
|
||||
ctypes.byref(parent_return),
|
||||
ctypes.byref(children),
|
||||
ctypes.byref(child_count),
|
||||
)
|
||||
if status == 0:
|
||||
return []
|
||||
try:
|
||||
return [int(children[index]) for index in range(child_count.value)]
|
||||
finally:
|
||||
if children:
|
||||
self.library.XFree(ctypes.cast(children, ctypes.c_void_p))
|
||||
|
||||
def map_window(self, window: int, *, borderless: bool) -> None:
|
||||
if borderless:
|
||||
self._remove_decorations(window)
|
||||
self.library.XMapRaised(self.display, window)
|
||||
self.library.XFlush(self.display)
|
||||
|
||||
def _remove_decorations(self, window: int) -> None:
|
||||
atom = self.library.XInternAtom(self.display, b"_MOTIF_WM_HINTS", 0)
|
||||
if atom == 0:
|
||||
return
|
||||
hints = _MotifWmHints(
|
||||
flags=1 << 1,
|
||||
functions=0,
|
||||
decorations=0,
|
||||
input_mode=0,
|
||||
status=0,
|
||||
)
|
||||
self.library.XChangeProperty(
|
||||
self.display,
|
||||
window,
|
||||
atom,
|
||||
atom,
|
||||
32,
|
||||
0,
|
||||
ctypes.cast(ctypes.byref(hints), ctypes.POINTER(ctypes.c_ubyte)),
|
||||
5,
|
||||
)
|
||||
|
||||
def _window_title(self, window: int) -> str | None:
|
||||
value = ctypes.c_char_p()
|
||||
if self.library.XFetchName(self.display, window, ctypes.byref(value)) == 0:
|
||||
return None
|
||||
try:
|
||||
if value.value is None:
|
||||
return None
|
||||
return value.value.decode("utf-8", errors="replace")
|
||||
finally:
|
||||
self.library.XFree(ctypes.cast(value, ctypes.c_void_p))
|
||||
|
||||
def _restore_error_handler(self) -> None:
|
||||
if self._error_handler is None:
|
||||
return
|
||||
self.library.XSetErrorHandler(self._previous_error_handler)
|
||||
self._error_handler = None
|
||||
|
||||
def _configure_signatures(self) -> None:
|
||||
self.library.XSetErrorHandler.argtypes = [ctypes.c_void_p]
|
||||
self.library.XSetErrorHandler.restype = ctypes.c_void_p
|
||||
self.library.XOpenDisplay.argtypes = [ctypes.c_char_p]
|
||||
self.library.XOpenDisplay.restype = ctypes.c_void_p
|
||||
self.library.XDefaultRootWindow.argtypes = [ctypes.c_void_p]
|
||||
self.library.XDefaultRootWindow.restype = ctypes.c_ulong
|
||||
self.library.XQueryTree.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
ctypes.POINTER(ctypes.c_ulong),
|
||||
ctypes.POINTER(ctypes.c_ulong),
|
||||
ctypes.POINTER(ctypes.POINTER(ctypes.c_ulong)),
|
||||
ctypes.POINTER(ctypes.c_uint),
|
||||
]
|
||||
self.library.XQueryTree.restype = ctypes.c_int
|
||||
self.library.XFetchName.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
ctypes.POINTER(ctypes.c_char_p),
|
||||
]
|
||||
self.library.XFetchName.restype = ctypes.c_int
|
||||
self.library.XInternAtom.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_int,
|
||||
]
|
||||
self.library.XInternAtom.restype = ctypes.c_ulong
|
||||
self.library.XChangeProperty.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
ctypes.c_ulong,
|
||||
ctypes.c_ulong,
|
||||
ctypes.c_int,
|
||||
ctypes.c_int,
|
||||
ctypes.POINTER(ctypes.c_ubyte),
|
||||
ctypes.c_int,
|
||||
]
|
||||
self.library.XChangeProperty.restype = ctypes.c_int
|
||||
self.library.XMapRaised.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
|
||||
self.library.XMapRaised.restype = ctypes.c_int
|
||||
self.library.XFlush.argtypes = [ctypes.c_void_p]
|
||||
self.library.XFlush.restype = ctypes.c_int
|
||||
self.library.XFree.argtypes = [ctypes.c_void_p]
|
||||
self.library.XFree.restype = ctypes.c_int
|
||||
self.library.XCloseDisplay.argtypes = [ctypes.c_void_p]
|
||||
self.library.XCloseDisplay.restype = ctypes.c_int
|
||||
|
||||
|
||||
def run_game_window(
|
||||
command: Sequence[str],
|
||||
window_title: str = WINDOW_TITLE,
|
||||
*,
|
||||
borderless: bool = False,
|
||||
) -> int:
|
||||
if not command:
|
||||
raise GameboxRuntimeError("game command cannot be empty")
|
||||
mapper: _X11 | None = None
|
||||
existing_window_ids: set[int] = set()
|
||||
try:
|
||||
created_mapper = _X11()
|
||||
existing_window_ids = created_mapper.root_window_ids()
|
||||
mapper = created_mapper
|
||||
except GameboxRuntimeError as error:
|
||||
print(
|
||||
f"Warning: {error}; continuing without X11 window styling",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
process = subprocess.Popen([str(value) for value in command])
|
||||
if mapper is None:
|
||||
print(
|
||||
f"Game window ready: unmanaged {window_title}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return int(process.wait())
|
||||
game_window: int | None = None
|
||||
game_title: str | None = None
|
||||
game_window_created_at: float | None = None
|
||||
deadline = time.monotonic() + WINDOW_DISCOVERY_TIMEOUT_SECONDS
|
||||
try:
|
||||
while process.poll() is None and time.monotonic() < deadline:
|
||||
now = time.monotonic()
|
||||
if game_window is None:
|
||||
found = mapper.find_first_window(
|
||||
window_title,
|
||||
excluded_window_ids=existing_window_ids,
|
||||
)
|
||||
if found is not None:
|
||||
window, title = found
|
||||
game_window = window
|
||||
game_title = title
|
||||
game_window_created_at = now
|
||||
print(
|
||||
f"Game window created; preparing it: {title}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
if (
|
||||
game_window is not None
|
||||
and game_window_created_at is not None
|
||||
and now - game_window_created_at >= WINDOW_READY_DELAY_SECONDS
|
||||
):
|
||||
mapper.map_window(game_window, borderless=borderless)
|
||||
print(
|
||||
f"Game window ready: {game_title or window_title}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return int(process.wait())
|
||||
time.sleep(0.1)
|
||||
if process.poll() is None:
|
||||
print(
|
||||
f"Warning: no new game window matching {window_title!r} appeared",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
if game_window is not None:
|
||||
mapper.map_window(game_window, borderless=borderless)
|
||||
return int(process.wait())
|
||||
finally:
|
||||
mapper.close()
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--window-title", default=WINDOW_TITLE)
|
||||
parser.add_argument("--borderless", action="store_true")
|
||||
parser.add_argument("command", nargs=argparse.REMAINDER)
|
||||
arguments = parser.parse_args(argv)
|
||||
command = arguments.command
|
||||
if command and command[0] == "--":
|
||||
command = command[1:]
|
||||
try:
|
||||
return run_game_window(
|
||||
command,
|
||||
arguments.window_title,
|
||||
borderless=arguments.borderless,
|
||||
)
|
||||
except (GameboxRuntimeError, OSError) as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
332
ptlinux/importer.py
Normal file
332
ptlinux/importer.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""Safe local bootstrap from an existing PristonTale EU installation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typing import Mapping
|
||||
import uuid
|
||||
|
||||
from .command import CommandRunner
|
||||
from .install_status import REQUIRED_GAME_FILES
|
||||
from .overlay import COMPATIBILITY_BINARY_HASHES, cache_compatibility_binaries
|
||||
from .paths import AppPaths
|
||||
from .settings import Settings
|
||||
|
||||
|
||||
class GameImportError(RuntimeError):
|
||||
"""Raised when a source cannot be safely imported."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GameTreeInventory:
|
||||
files: int
|
||||
bytes: int
|
||||
core_sha256: dict[str, str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GameImportResult:
|
||||
source: str
|
||||
destination: str
|
||||
copy_mode: str
|
||||
files: int
|
||||
bytes: int
|
||||
core_sha256: dict[str, str]
|
||||
depot_metadata_source: str | None
|
||||
depot_metadata_destination: str | None
|
||||
installed_build: int | None
|
||||
started_at: str
|
||||
completed_at: str
|
||||
|
||||
|
||||
def import_existing_game(
|
||||
paths: AppPaths,
|
||||
source_game: Path,
|
||||
runner: CommandRunner,
|
||||
*,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> GameImportResult:
|
||||
"""Clone a complete source without ever writing through to it."""
|
||||
|
||||
source = source_game.expanduser().resolve(strict=True)
|
||||
destination = paths.source_game
|
||||
_validate_destination(paths, destination)
|
||||
if source == destination.resolve(strict=False):
|
||||
raise GameImportError("source and managed destination are the same directory")
|
||||
if destination.resolve(strict=False).is_relative_to(source):
|
||||
raise GameImportError("managed destination cannot be inside the source game tree")
|
||||
if destination.exists() or destination.is_symlink():
|
||||
raise GameImportError(
|
||||
f"managed game directory already exists: {destination}; "
|
||||
"finish or remove the existing updater installation first"
|
||||
)
|
||||
|
||||
started_at = datetime.now().astimezone().isoformat()
|
||||
source_before = inventory_game_tree(source)
|
||||
depot_metadata = discover_depot_metadata(source)
|
||||
if runner.dry_run:
|
||||
return GameImportResult(
|
||||
source=str(source),
|
||||
destination=str(destination),
|
||||
copy_mode="reflink-or-copy-dry-run",
|
||||
files=source_before.files,
|
||||
bytes=source_before.bytes,
|
||||
core_sha256=source_before.core_sha256,
|
||||
depot_metadata_source=(
|
||||
str(depot_metadata.path) if depot_metadata is not None else None
|
||||
),
|
||||
depot_metadata_destination=None,
|
||||
installed_build=(
|
||||
depot_metadata.installed_build if depot_metadata is not None else None
|
||||
),
|
||||
started_at=started_at,
|
||||
completed_at=started_at,
|
||||
)
|
||||
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = destination.with_name(f".{destination.name}.import-{uuid.uuid4().hex}")
|
||||
try:
|
||||
temporary.mkdir(parents=False, exist_ok=False)
|
||||
runner.run(
|
||||
["cp", "-a", "--reflink=auto", "--", f"{source}/.", str(temporary)],
|
||||
timeout_seconds=1800.0,
|
||||
)
|
||||
source_after = inventory_game_tree(source)
|
||||
copied = inventory_game_tree(temporary)
|
||||
if source_after != source_before:
|
||||
raise GameImportError("source game changed during import; staged copy discarded")
|
||||
if copied != source_before:
|
||||
raise GameImportError(
|
||||
"staged game inventory does not match the source; staged copy discarded"
|
||||
)
|
||||
if all(
|
||||
source_before.core_sha256.get(name) == expected
|
||||
for name, expected in COMPATIBILITY_BINARY_HASHES.items()
|
||||
):
|
||||
cache_compatibility_binaries(paths, source)
|
||||
temporary.replace(destination)
|
||||
except BaseException:
|
||||
_remove_staging_tree(temporary, destination.parent)
|
||||
raise
|
||||
|
||||
metadata_destination = None
|
||||
if depot_metadata is not None:
|
||||
metadata_destination = _install_depot_metadata(
|
||||
paths,
|
||||
depot_metadata,
|
||||
environment,
|
||||
)
|
||||
result = GameImportResult(
|
||||
source=str(source),
|
||||
destination=str(destination),
|
||||
copy_mode="reflink-or-copy",
|
||||
files=source_before.files,
|
||||
bytes=source_before.bytes,
|
||||
core_sha256=source_before.core_sha256,
|
||||
depot_metadata_source=(
|
||||
str(depot_metadata.path) if depot_metadata is not None else None
|
||||
),
|
||||
depot_metadata_destination=(
|
||||
str(metadata_destination) if metadata_destination is not None else None
|
||||
),
|
||||
installed_build=(
|
||||
depot_metadata.installed_build if depot_metadata is not None else None
|
||||
),
|
||||
started_at=started_at,
|
||||
completed_at=datetime.now().astimezone().isoformat(),
|
||||
)
|
||||
_write_json(paths.game_import_manifest, asdict(result))
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DepotMetadata:
|
||||
path: Path
|
||||
installed_build: int
|
||||
sha256: str
|
||||
|
||||
|
||||
def discover_depot_metadata(source_game: Path) -> DepotMetadata | None:
|
||||
source = source_game.expanduser().resolve(strict=True)
|
||||
prefix = None
|
||||
for parent in source.parents:
|
||||
if parent.name.casefold() == "drive_c":
|
||||
prefix = parent.parent
|
||||
break
|
||||
if prefix is None:
|
||||
return None
|
||||
users = prefix / "drive_c/users"
|
||||
try:
|
||||
user_directories = sorted(path for path in users.iterdir() if path.is_dir())
|
||||
except OSError:
|
||||
return None
|
||||
for user_directory in user_directories:
|
||||
candidate = (
|
||||
user_directory
|
||||
/ "AppData/Roaming/Pristontale EU Launcher/Depots/depot_1.json"
|
||||
)
|
||||
try:
|
||||
value = json.loads(candidate.read_text(encoding="utf-8-sig"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
installed_build = value.get("InstalledBuild")
|
||||
if (
|
||||
isinstance(installed_build, bool)
|
||||
or not isinstance(installed_build, int)
|
||||
or installed_build <= 0
|
||||
or value.get("UpdateComplete") is not True
|
||||
):
|
||||
continue
|
||||
install_folder = value.get("InstallFolder")
|
||||
if not isinstance(install_folder, str) or not install_folder.casefold().endswith(
|
||||
r"\program files (x86)\pristontale eu".casefold()
|
||||
):
|
||||
continue
|
||||
return DepotMetadata(
|
||||
path=candidate,
|
||||
installed_build=installed_build,
|
||||
sha256=_sha256(candidate),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def inventory_game_tree(source_game: Path) -> GameTreeInventory:
|
||||
source = source_game.expanduser().resolve(strict=True)
|
||||
for relative in REQUIRED_GAME_FILES:
|
||||
path = source / relative
|
||||
if not path.is_file():
|
||||
raise GameImportError(f"game installation is incomplete: missing {relative}")
|
||||
|
||||
files = 0
|
||||
total_bytes = 0
|
||||
for root, _directories, filenames in os.walk(source, followlinks=False):
|
||||
root_path = Path(root)
|
||||
for filename in filenames:
|
||||
path = root_path / filename
|
||||
if path.is_symlink():
|
||||
files += 1
|
||||
continue
|
||||
try:
|
||||
total_bytes += path.stat().st_size
|
||||
except OSError as exc:
|
||||
raise GameImportError(f"cannot inventory game file {path}: {exc}") from exc
|
||||
files += 1
|
||||
return GameTreeInventory(
|
||||
files=files,
|
||||
bytes=total_bytes,
|
||||
core_sha256={str(relative): _sha256(source / relative) for relative in REQUIRED_GAME_FILES},
|
||||
)
|
||||
|
||||
|
||||
def discover_existing_game(
|
||||
paths: AppPaths,
|
||||
settings: Settings,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> Path | None:
|
||||
env = os.environ if environment is None else environment
|
||||
candidates: list[Path] = []
|
||||
source_override = settings.source_override
|
||||
if source_override is not None:
|
||||
candidates.append(source_override)
|
||||
home_value = env.get("HOME")
|
||||
if home_value:
|
||||
home = Path(home_value).expanduser()
|
||||
for root_name in ("PristonTale-EU", "pristontale-eu"):
|
||||
root = home / "Games" / root_name
|
||||
candidates.extend(
|
||||
(
|
||||
root / "wine/drive_c/Program Files (x86)/Pristontale EU",
|
||||
root / "drive_c/Program Files (x86)/Pristontale EU",
|
||||
root / "Pristontale EU",
|
||||
)
|
||||
)
|
||||
|
||||
destination = paths.source_game.resolve(strict=False)
|
||||
seen: set[Path] = set()
|
||||
for candidate in candidates:
|
||||
resolved = candidate.expanduser().resolve(strict=False)
|
||||
if resolved in seen or resolved == destination:
|
||||
continue
|
||||
seen.add(resolved)
|
||||
try:
|
||||
inventory_game_tree(resolved)
|
||||
except (FileNotFoundError, GameImportError, OSError):
|
||||
continue
|
||||
return resolved
|
||||
return None
|
||||
|
||||
|
||||
def _validate_destination(paths: AppPaths, destination: Path) -> None:
|
||||
expected = (
|
||||
paths.launcher_prefix
|
||||
/ "drive_c/Program Files (x86)/Pristontale EU"
|
||||
).resolve(strict=False)
|
||||
if destination.resolve(strict=False) != expected:
|
||||
raise GameImportError(f"unexpected managed game destination: {destination}")
|
||||
|
||||
|
||||
def _install_depot_metadata(
|
||||
paths: AppPaths,
|
||||
metadata: DepotMetadata,
|
||||
environment: Mapping[str, str] | None,
|
||||
) -> Path:
|
||||
env = os.environ if environment is None else environment
|
||||
users_root = paths.launcher_prefix / "drive_c/users"
|
||||
preferred_names = [env.get("USER"), Path(env.get("HOME", "")).name]
|
||||
user_home = None
|
||||
for name in preferred_names:
|
||||
if not name:
|
||||
continue
|
||||
candidate = users_root / name
|
||||
if candidate.is_dir():
|
||||
user_home = candidate
|
||||
break
|
||||
if user_home is None:
|
||||
name = next((value for value in preferred_names if value), None)
|
||||
if name is None:
|
||||
raise GameImportError("cannot determine the Wine user for depot metadata")
|
||||
user_home = users_root / name
|
||||
target = user_home / "AppData/Roaming/Pristontale EU Launcher/Depots/depot_1.json"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = target.with_name(f".{target.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
shutil.copy2(metadata.path, temporary)
|
||||
if _sha256(temporary) != metadata.sha256:
|
||||
raise GameImportError("copied depot metadata hash mismatch")
|
||||
temporary.replace(target)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
return target
|
||||
|
||||
|
||||
def _remove_staging_tree(path: Path, expected_parent: Path) -> None:
|
||||
if path.parent.resolve() != expected_parent.resolve():
|
||||
raise GameImportError(f"refusing to remove staging path outside destination: {path}")
|
||||
if path.is_symlink() or path.is_file():
|
||||
path.unlink(missing_ok=True)
|
||||
elif path.is_dir():
|
||||
shutil.rmtree(path)
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
92
ptlinux/install_status.py
Normal file
92
ptlinux/install_status.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Read-only installation state used by the native launcher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import AppPaths
|
||||
|
||||
|
||||
REQUIRED_GAME_FILES = (
|
||||
Path("Game.exe"),
|
||||
Path("game.dll"),
|
||||
Path("game.ini"),
|
||||
Path("game/maps/WhisperingVale.rovug"),
|
||||
)
|
||||
|
||||
|
||||
class PrimaryAction(str, Enum):
|
||||
"""Action presented by the launcher's large primary button."""
|
||||
|
||||
INSTALL = "INSTALL"
|
||||
UPDATE = "UPDATE"
|
||||
PLAY = "PLAY"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GameInstallationStatus:
|
||||
"""Local game completeness and its relationship to the official depot."""
|
||||
|
||||
action: PrimaryAction
|
||||
installed: bool
|
||||
local_build: int | None
|
||||
official_build: int | None
|
||||
missing_files: tuple[Path, ...]
|
||||
|
||||
|
||||
def inspect_game_installation(
|
||||
paths: AppPaths,
|
||||
official_build: int | None = None,
|
||||
) -> GameInstallationStatus:
|
||||
"""Select Install, Update, or Play without modifying the installation.
|
||||
|
||||
A completed native sync is the verified source for the local depot build.
|
||||
When the official build cannot be checked, a complete local game remains
|
||||
playable so temporary network failures do not lock users out.
|
||||
"""
|
||||
|
||||
if official_build is not None and (
|
||||
isinstance(official_build, bool)
|
||||
or not isinstance(official_build, int)
|
||||
or official_build < 0
|
||||
):
|
||||
raise ValueError("official build must be a non-negative integer")
|
||||
|
||||
missing_files = tuple(
|
||||
relative
|
||||
for relative in REQUIRED_GAME_FILES
|
||||
if not (paths.source_game / relative).is_file()
|
||||
)
|
||||
installed = not missing_files
|
||||
local_build = _read_verified_local_build(paths.native_depot_state)
|
||||
if not installed:
|
||||
action = PrimaryAction.INSTALL
|
||||
elif official_build is not None and (
|
||||
local_build is None or local_build < official_build
|
||||
):
|
||||
action = PrimaryAction.UPDATE
|
||||
else:
|
||||
action = PrimaryAction.PLAY
|
||||
return GameInstallationStatus(
|
||||
action=action,
|
||||
installed=installed,
|
||||
local_build=local_build,
|
||||
official_build=official_build,
|
||||
missing_files=missing_files,
|
||||
)
|
||||
|
||||
|
||||
def _read_verified_local_build(path: Path) -> int | None:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(value, dict) or value.get("dry_run") is not False:
|
||||
return None
|
||||
build = value.get("build")
|
||||
if isinstance(build, bool) or not isinstance(build, int) or build < 0:
|
||||
return None
|
||||
return build
|
||||
11
ptlinux/launcher-update-public.pem
Normal file
11
ptlinux/launcher-update-public.pem
Normal file
@@ -0,0 +1,11 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAt7MwE0ZLWR6uy0aEW2b/
|
||||
Hr/txx9mSuWxKUxQAE07Gl7A5ZTgivhZ9w0pbCcVtcirhg9lzi7OKcc3VrERcHTq
|
||||
lK1+h3NENPNSVfhQeTjIEIC9W8hXK1/mHnz1IEk5nziGarnGTiSaIaI23TXZ5QtY
|
||||
vbpj+gTkSZswsLUlBCENFaS/1k6YZLGJdJ9l4luTkV47vqD6lvPYY8Mob3AxCwTj
|
||||
PuEZYMW53NfZx/Lrk7MVzTrTa66L9cFnLUGHwguiA9SbjzppDOw6YJw3sTZalC+a
|
||||
xseEle0dymCjCyEU1fVCizvW88/ZV+a2kpvfMWUVBl4MAzyH8Ja+FFqAkzjd2YKx
|
||||
7fI1X5Cr9rTG8obmYfH/Co+gfiU2jDeQZL6ELB9/zukWYxJp1jztlPxB4/nHoJgG
|
||||
1QCM7i0QGne4kIgWn6Qm2uBu/ucwaDlm/842VHKQEtcKBeYpkxO9B5xcRBJ59qMu
|
||||
OSUuFAEiX4TN56wS7F1XTogpFTUJs5PX42d9Rd6hkW3ZAgMBAAE=
|
||||
-----END PUBLIC KEY-----
|
||||
322
ptlinux/launcher.py
Normal file
322
ptlinux/launcher.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""Wine launcher preparation and exact-profile WPF compatibility patching."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typing import Iterable
|
||||
|
||||
from .command import CommandOutcome, CommandRunner
|
||||
from .paths import AppPaths
|
||||
|
||||
|
||||
KNOWN_PRISTINE_LAUNCHER_SHA256 = (
|
||||
"64e46469d7de675e52741cabf71a8c4b7852769b96bd59ef9a15032b3d61c4d7"
|
||||
)
|
||||
KNOWN_PATCHED_LAUNCHER_SHA256 = (
|
||||
"aa18a8d74b1c5a644251babe15ae6828a221bdb65f970cdef275276ca750f1e8"
|
||||
)
|
||||
KNOWN_COMPLETE_PATCHED_LAUNCHER_SHA256 = (
|
||||
"fb89f2e8a7f68775c4c1a09ea7f88f1799b8f36355985d26b30f56701d11f677"
|
||||
)
|
||||
LAUNCHER_PREFIX_CONFIGURATION_VERSION = 1
|
||||
_NEWS_FEED_FONT_OFFSET = 269118
|
||||
|
||||
_PATCHES = (
|
||||
(b"/Resources/Fonts/#FontAwesome", b"FontAwesome,Lato,Arial,Tahoma", 1),
|
||||
(b"/Resources/Fonts/#Lato Regular", b"Lato Regular,Lato,Arial,Tahoma", 1),
|
||||
)
|
||||
|
||||
|
||||
class LauncherError(RuntimeError):
|
||||
"""Raised when the launcher cannot be safely prepared."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WineTools:
|
||||
wine: Path
|
||||
wineboot: Path
|
||||
winetricks: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LauncherPatchResult:
|
||||
source: str
|
||||
destination: str
|
||||
source_sha256: str
|
||||
destination_sha256: str
|
||||
patched: bool
|
||||
supported_profile: bool
|
||||
replacements: dict[str, int]
|
||||
|
||||
|
||||
def find_wine_tools() -> WineTools | None:
|
||||
wine_candidates = (
|
||||
Path("/opt/wine-stable/bin/wine"),
|
||||
Path("/opt/wine-staging/bin/wine"),
|
||||
Path("/opt/wine/bin/wine"),
|
||||
Path("/usr/local/bin/wine"),
|
||||
Path("/usr/bin/wine"),
|
||||
Path("/usr/bin/wine64"),
|
||||
)
|
||||
wine = next((path for path in wine_candidates if path.is_file()), None)
|
||||
if wine is None:
|
||||
located = shutil.which("wine") or shutil.which("wine64")
|
||||
wine = Path(located) if located else None
|
||||
if wine is None:
|
||||
return None
|
||||
wineboot = wine.with_name("wineboot")
|
||||
if not wineboot.is_file():
|
||||
located_boot = shutil.which("wineboot")
|
||||
if not located_boot:
|
||||
return None
|
||||
wineboot = Path(located_boot)
|
||||
located_winetricks = shutil.which("winetricks")
|
||||
if not located_winetricks:
|
||||
return None
|
||||
return WineTools(
|
||||
wine=wine.resolve(),
|
||||
# Preserve the wineboot basename when it is a dispatcher symlink to Wine.
|
||||
wineboot=wineboot.absolute(),
|
||||
winetricks=Path(located_winetricks).resolve(),
|
||||
)
|
||||
|
||||
|
||||
def prepare_launcher_binary(source: Path, destination: Path) -> LauncherPatchResult:
|
||||
"""Patch only the exact known launcher profile; retain unknown builds intact."""
|
||||
|
||||
source_hash = _sha256(source)
|
||||
data = source.read_bytes()
|
||||
replacements: dict[str, int] = {}
|
||||
supported = source_hash == KNOWN_PRISTINE_LAUNCHER_SHA256
|
||||
if supported:
|
||||
for old, new, expected_count in _PATCHES:
|
||||
if len(old) != len(new):
|
||||
raise AssertionError("launcher replacement lengths must match")
|
||||
actual_count = data.count(old)
|
||||
if actual_count != expected_count:
|
||||
raise LauncherError(
|
||||
f"known launcher profile changed for {old!r}: "
|
||||
f"expected {expected_count}, found {actual_count}"
|
||||
)
|
||||
data = data.replace(old, new)
|
||||
replacements[old.decode("ascii")] = actual_count
|
||||
intermediate_hash = hashlib.sha256(data).hexdigest()
|
||||
if intermediate_hash != KNOWN_PATCHED_LAUNCHER_SHA256:
|
||||
raise LauncherError(
|
||||
"known launcher font fallback patch produced an unexpected SHA-256: "
|
||||
f"{intermediate_hash}"
|
||||
)
|
||||
news_old = b"FontAwesome#"
|
||||
news_new = b"LatoRegular#"
|
||||
news_end = _NEWS_FEED_FONT_OFFSET + len(news_old)
|
||||
if data[_NEWS_FEED_FONT_OFFSET:news_end] != news_old:
|
||||
raise LauncherError("known NewsFeed font offset no longer matches")
|
||||
data = (
|
||||
data[:_NEWS_FEED_FONT_OFFSET]
|
||||
+ news_new
|
||||
+ data[news_end:]
|
||||
)
|
||||
replacements[f"FontAwesome#@{_NEWS_FEED_FONT_OFFSET}"] = 1
|
||||
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_bytes(data)
|
||||
shutil.copymode(source, temporary)
|
||||
destination_hash = _sha256(temporary)
|
||||
if supported and destination_hash != KNOWN_COMPLETE_PATCHED_LAUNCHER_SHA256:
|
||||
raise LauncherError(
|
||||
"known launcher patch produced an unexpected SHA-256: "
|
||||
f"{destination_hash}"
|
||||
)
|
||||
temporary.replace(destination)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
return LauncherPatchResult(
|
||||
source=str(source),
|
||||
destination=str(destination),
|
||||
source_sha256=source_hash,
|
||||
destination_sha256=destination_hash,
|
||||
patched=supported,
|
||||
supported_profile=supported,
|
||||
replacements=replacements,
|
||||
)
|
||||
|
||||
|
||||
def launcher_environment(paths: AppPaths, tools: WineTools) -> dict[str, str]:
|
||||
return {
|
||||
"WINEPREFIX": str(paths.launcher_prefix),
|
||||
"WINE": str(tools.wine),
|
||||
"WINEDEBUG": "-all",
|
||||
"WINEESYNC": "1",
|
||||
"DOTNET_SYSTEM_GLOBALIZATION_USENLS": "1",
|
||||
}
|
||||
|
||||
|
||||
def configure_launcher_prefix(
|
||||
paths: AppPaths,
|
||||
tools: WineTools,
|
||||
runner: CommandRunner,
|
||||
*,
|
||||
force_runtime: bool = False,
|
||||
) -> list[CommandOutcome]:
|
||||
"""Create the launcher prefix, install .NET Desktop 6, and apply WPF fixes."""
|
||||
|
||||
paths.create_base_directories()
|
||||
if not runner.dry_run:
|
||||
paths.launcher_prefix.mkdir(parents=True, exist_ok=True)
|
||||
environment = launcher_environment(paths, tools)
|
||||
marker = _read_json(paths.launcher_prefix_marker)
|
||||
if not force_runtime and _launcher_prefix_ready(paths, tools, marker):
|
||||
if not runner.dry_run and "configuration_version" not in marker:
|
||||
_write_json(
|
||||
paths.launcher_prefix_marker,
|
||||
{
|
||||
**marker,
|
||||
"configuration_version": LAUNCHER_PREFIX_CONFIGURATION_VERSION,
|
||||
},
|
||||
)
|
||||
return []
|
||||
results = [
|
||||
runner.run(
|
||||
[str(tools.wineboot), "-u"],
|
||||
env_overrides={**environment, "WINEARCH": "win64"},
|
||||
timeout_seconds=300.0,
|
||||
)
|
||||
]
|
||||
if force_runtime or not _dotnet_desktop_ready(paths, marker):
|
||||
results.append(
|
||||
runner.run(
|
||||
[str(tools.winetricks), "-q", "dotnetdesktop6"],
|
||||
env_overrides=environment,
|
||||
timeout_seconds=1800.0,
|
||||
)
|
||||
)
|
||||
|
||||
registry_commands: Iterable[list[str]] = (
|
||||
[
|
||||
str(tools.wine), "reg", "add",
|
||||
r"HKCU\Software\Microsoft\Avalon.Graphics",
|
||||
"/v", "DisableHWAcceleration", "/t", "REG_DWORD", "/d", "1", "/f",
|
||||
],
|
||||
[
|
||||
str(tools.wine), "reg", "add",
|
||||
r"HKCU\Software\Microsoft\Avalon.Graphics",
|
||||
"/v", "MaxMultisampleType", "/t", "REG_DWORD", "/d", "0", "/f",
|
||||
],
|
||||
)
|
||||
for command in registry_commands:
|
||||
results.append(
|
||||
runner.run(command, env_overrides=environment, timeout_seconds=60.0)
|
||||
)
|
||||
for requested, replacement in (
|
||||
("Segoe UI", "Carlito"),
|
||||
("Segoe UI Regular", "Carlito"),
|
||||
("Segoe UI Light", "Carlito"),
|
||||
("Segoe UI Semilight", "Carlito"),
|
||||
("Segoe UI Semibold", "Carlito"),
|
||||
("Segoe UI Bold", "Carlito"),
|
||||
("Segoe UI Black", "Carlito"),
|
||||
("Segoe UI Symbol", "Noto Sans Symbols 2"),
|
||||
("Lato Regular", "Lato"),
|
||||
):
|
||||
results.append(
|
||||
runner.run(
|
||||
[
|
||||
str(tools.wine), "reg", "add",
|
||||
r"HKLM\Software\Microsoft\Windows NT\CurrentVersion\FontSubstitutes",
|
||||
"/v", requested, "/t", "REG_SZ", "/d", replacement, "/f",
|
||||
],
|
||||
env_overrides=environment,
|
||||
timeout_seconds=60.0,
|
||||
)
|
||||
)
|
||||
if not runner.dry_run:
|
||||
_write_json(
|
||||
paths.launcher_prefix_marker,
|
||||
{
|
||||
"configuration_version": LAUNCHER_PREFIX_CONFIGURATION_VERSION,
|
||||
"configured_at": datetime.now().astimezone().isoformat(),
|
||||
"dotnetdesktop6": True,
|
||||
"wine": str(tools.wine),
|
||||
},
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def launch_official_updater(
|
||||
paths: AppPaths,
|
||||
tools: WineTools,
|
||||
runner: CommandRunner,
|
||||
) -> CommandOutcome:
|
||||
if not paths.launcher_patched.is_file() and not runner.dry_run:
|
||||
raise LauncherError("prepared launcher is missing; run fetch first")
|
||||
return runner.run(
|
||||
[str(tools.wine), str(paths.launcher_patched)],
|
||||
cwd=paths.launcher_patched.parent,
|
||||
env_overrides=launcher_environment(paths, tools),
|
||||
timeout_seconds=4 * 60 * 60,
|
||||
)
|
||||
|
||||
|
||||
def patch_result_dict(result: LauncherPatchResult) -> dict[str, object]:
|
||||
return asdict(result)
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, object]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _dotnet_desktop_ready(paths: AppPaths, marker: dict[str, object]) -> bool:
|
||||
if not bool(marker.get("dotnetdesktop6")):
|
||||
return False
|
||||
desktop_root = (
|
||||
paths.launcher_prefix
|
||||
/ "drive_c/Program Files/dotnet/shared/Microsoft.WindowsDesktop.App"
|
||||
)
|
||||
try:
|
||||
return any(path.is_dir() and path.name.startswith("6.") for path in desktop_root.iterdir())
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _launcher_prefix_ready(
|
||||
paths: AppPaths,
|
||||
tools: WineTools,
|
||||
marker: dict[str, object],
|
||||
) -> bool:
|
||||
configuration_version = marker.get(
|
||||
"configuration_version",
|
||||
1,
|
||||
)
|
||||
return (
|
||||
configuration_version == LAUNCHER_PREFIX_CONFIGURATION_VERSION
|
||||
and marker.get("wine") == str(tools.wine)
|
||||
and _dotnet_desktop_ready(paths, marker)
|
||||
)
|
||||
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
113
ptlinux/launcher_wrapper.py
Normal file
113
ptlinux/launcher_wrapper.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Render the installed command wrapper with persistent application paths."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
APP_ROOT_TOKEN = "@PTLINUX_APP_ROOT@"
|
||||
INSTALL_DIRECTORY_TOKEN = "@PTLINUX_INSTALL_DIR@"
|
||||
|
||||
|
||||
class LauncherWrapperError(ValueError):
|
||||
"""Raised when a safe launcher wrapper cannot be rendered."""
|
||||
|
||||
|
||||
def quote_shell_word(value: str) -> str:
|
||||
"""Quote one value as a literal POSIX shell word."""
|
||||
|
||||
if not value or any(character in value for character in ("\x00", "\n", "\r")):
|
||||
raise LauncherWrapperError("launcher path contains invalid characters")
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _absolute_directory(path: Path, label: str) -> Path:
|
||||
directory = path.expanduser()
|
||||
if not directory.is_absolute():
|
||||
raise LauncherWrapperError(f"{label} must be absolute")
|
||||
return directory
|
||||
|
||||
|
||||
def render_launcher_wrapper(
|
||||
template: str,
|
||||
app_root: Path,
|
||||
install_directory: Path,
|
||||
) -> str:
|
||||
"""Embed the selected install layout in one command-wrapper template."""
|
||||
|
||||
for token in (APP_ROOT_TOKEN, INSTALL_DIRECTORY_TOKEN):
|
||||
if template.count(token) != 1:
|
||||
raise LauncherWrapperError(
|
||||
f"launcher template must contain exactly one {token} token"
|
||||
)
|
||||
application = _absolute_directory(app_root, "application directory")
|
||||
installation = _absolute_directory(install_directory, "install directory")
|
||||
if not any(
|
||||
(candidate / "ptlinux").is_dir()
|
||||
for candidate in (
|
||||
application / "current",
|
||||
application / "previous",
|
||||
application,
|
||||
)
|
||||
):
|
||||
raise LauncherWrapperError(f"application files are missing: {application}")
|
||||
rendered = template.replace(APP_ROOT_TOKEN, quote_shell_word(str(application)))
|
||||
rendered = rendered.replace(
|
||||
INSTALL_DIRECTORY_TOKEN,
|
||||
quote_shell_word(str(installation)),
|
||||
)
|
||||
return rendered if rendered.endswith("\n") else rendered + "\n"
|
||||
|
||||
|
||||
def install_launcher_wrapper(
|
||||
template: Path,
|
||||
app_root: Path,
|
||||
install_directory: Path,
|
||||
destination: Path,
|
||||
) -> None:
|
||||
"""Render and atomically install one executable command wrapper."""
|
||||
|
||||
rendered = render_launcher_wrapper(
|
||||
template.read_text(encoding="utf-8"),
|
||||
app_root,
|
||||
install_directory,
|
||||
)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_text(rendered, encoding="utf-8")
|
||||
temporary.chmod(0o755)
|
||||
temporary.replace(destination)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="install the PristonTale EU command wrapper"
|
||||
)
|
||||
parser.add_argument("template", type=Path)
|
||||
parser.add_argument("app_root", type=Path)
|
||||
parser.add_argument("install_directory", type=Path)
|
||||
parser.add_argument("destination", type=Path)
|
||||
arguments = parser.parse_args(argv)
|
||||
try:
|
||||
install_launcher_wrapper(
|
||||
arguments.template,
|
||||
arguments.app_root,
|
||||
arguments.install_directory,
|
||||
arguments.destination,
|
||||
)
|
||||
except (LauncherWrapperError, OSError) as exc:
|
||||
print(f"Could not install command wrapper: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
42
ptlinux/locking.py
Normal file
42
ptlinux/locking.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Cross-process locks for launcher-prefix mutations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
import fcntl
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
|
||||
class OperationBusyError(RuntimeError):
|
||||
"""Raised when another launcher-prefix operation is still active."""
|
||||
|
||||
|
||||
@contextmanager
|
||||
def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with lock_path.open("a+", encoding="utf-8") as handle:
|
||||
try:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError as exc:
|
||||
handle.seek(0)
|
||||
owner = handle.read().strip() or "unknown operation"
|
||||
raise OperationBusyError(
|
||||
f"launcher files are busy ({owner}); close the updater and try again"
|
||||
) from exc
|
||||
handle.seek(0)
|
||||
handle.truncate()
|
||||
handle.write(
|
||||
f"{operation}, pid={os.getpid()}, "
|
||||
f"started={datetime.now().astimezone().isoformat()}\n"
|
||||
)
|
||||
handle.flush()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
handle.seek(0)
|
||||
handle.truncate()
|
||||
handle.flush()
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
354
ptlinux/news.py
Normal file
354
ptlinux/news.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""Official news feed support for the native Linux dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Callable, Protocol
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
NEWS_ENDPOINT = "https://pristontale.eu/api/news/articles"
|
||||
NEWS_ARTICLE_URL = "https://pristontale.eu/news/"
|
||||
NEWS_ORIGIN = "https://pristontale.eu"
|
||||
OFFICIAL_LOGO_URL = "https://pristontale.eu/public/img/pristontale/logo/logo_small.png"
|
||||
BROADCAST_ENDPOINT = "http://5.135.35.188:90/broadcasts"
|
||||
MAX_NEWS_BYTES = 2 * 1024 * 1024
|
||||
MAX_NEWS_IMAGE_BYTES = 12 * 1024 * 1024
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
class NewsError(RuntimeError):
|
||||
"""Raised when the official news feed cannot be parsed safely."""
|
||||
|
||||
|
||||
class NewsResponse(Protocol):
|
||||
"""The minimal response interface used by the news client."""
|
||||
|
||||
def read(self, size: int = -1) -> bytes: ...
|
||||
|
||||
def __enter__(self) -> NewsResponse: ...
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exception_type: type[BaseException] | None,
|
||||
exception: BaseException | None,
|
||||
traceback: object | None,
|
||||
) -> bool | None: ...
|
||||
|
||||
|
||||
NewsOpener = Callable[..., NewsResponse]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NewsArticle:
|
||||
id: int
|
||||
slug: str
|
||||
title: str
|
||||
summary: str
|
||||
author: str | None
|
||||
published_at: str | None
|
||||
image_url: str | None = None
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
return f"{NEWS_ARTICLE_URL}{self.slug}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Broadcast:
|
||||
"""The latest announcement from the official broadcast API."""
|
||||
|
||||
author: str
|
||||
content: str
|
||||
published_at: str | None
|
||||
url: str
|
||||
|
||||
|
||||
def fetch_news_image(
|
||||
article: NewsArticle,
|
||||
cache_directory: Path,
|
||||
*,
|
||||
timeout_seconds: float = 15.0,
|
||||
opener: NewsOpener = urlopen,
|
||||
) -> bytes | None:
|
||||
"""Return one verified PNG thumbnail, preferring the local image cache.
|
||||
|
||||
The official feed currently serves PNG article art. Deliberately accepting
|
||||
only PNG keeps the Tk UI dependency-free and avoids treating arbitrary remote
|
||||
data as a displayable image format.
|
||||
"""
|
||||
|
||||
if article.image_url is None:
|
||||
return None
|
||||
cache_path = _image_cache_path(cache_directory, article)
|
||||
cached = _read_png(cache_path)
|
||||
if cached is not None:
|
||||
return cached
|
||||
return _fetch_png_image(article.image_url, cache_path, timeout_seconds, opener)
|
||||
|
||||
|
||||
def fetch_official_logo(
|
||||
cache_path: Path,
|
||||
*,
|
||||
timeout_seconds: float = 15.0,
|
||||
opener: NewsOpener = urlopen,
|
||||
) -> bytes | None:
|
||||
"""Return the official PristonTale EU PNG logo, using its local cache first."""
|
||||
|
||||
return _fetch_png_image(OFFICIAL_LOGO_URL, cache_path, timeout_seconds, opener)
|
||||
|
||||
|
||||
def _fetch_png_image(
|
||||
url: str,
|
||||
cache_path: Path,
|
||||
timeout_seconds: float,
|
||||
opener: NewsOpener,
|
||||
) -> bytes | None:
|
||||
cached = _read_png(cache_path)
|
||||
if cached is not None:
|
||||
return cached
|
||||
request = Request(
|
||||
url,
|
||||
headers={"User-Agent": "PT-Linux/0.2 (+https://pristontale.eu/)"},
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
response = opener(request, timeout=timeout_seconds)
|
||||
with response:
|
||||
payload = response.read(MAX_NEWS_IMAGE_BYTES + 1)
|
||||
except OSError:
|
||||
return None
|
||||
if len(payload) > MAX_NEWS_IMAGE_BYTES or not _is_png(payload):
|
||||
return None
|
||||
_write_image_cache(cache_path, payload)
|
||||
return payload
|
||||
|
||||
|
||||
def fetch_news(
|
||||
cache_path: Path,
|
||||
*,
|
||||
timeout_seconds: float = 15.0,
|
||||
opener: NewsOpener = urlopen,
|
||||
) -> tuple[NewsArticle, ...]:
|
||||
"""Fetch fresh official news, falling back to the last valid local cache."""
|
||||
|
||||
request = Request(
|
||||
NEWS_ENDPOINT,
|
||||
headers={"User-Agent": "PT-Linux/0.2 (+https://pristontale.eu/)"},
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
response = opener(request, timeout=timeout_seconds)
|
||||
with response:
|
||||
payload = response.read(MAX_NEWS_BYTES + 1)
|
||||
if len(payload) > MAX_NEWS_BYTES:
|
||||
raise NewsError("official news response exceeds the 2 MiB limit")
|
||||
articles = _parse_news(payload)
|
||||
_write_cache(cache_path, articles)
|
||||
return articles
|
||||
except (OSError, NewsError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
return _read_cache(cache_path)
|
||||
|
||||
|
||||
def fetch_latest_broadcast(
|
||||
cache_path: Path,
|
||||
*,
|
||||
timeout_seconds: float = 15.0,
|
||||
opener: NewsOpener = urlopen,
|
||||
) -> Broadcast | None:
|
||||
"""Fetch the first broadcast supplied by the official broadcast service."""
|
||||
|
||||
request = Request(
|
||||
BROADCAST_ENDPOINT,
|
||||
headers={"User-Agent": "PT-Linux/0.2 (+https://pristontale.eu/)"},
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
response = opener(request, timeout=timeout_seconds)
|
||||
with response:
|
||||
payload = response.read(MAX_NEWS_BYTES + 1)
|
||||
if len(payload) > MAX_NEWS_BYTES:
|
||||
raise NewsError("broadcast response exceeds the 2 MiB limit")
|
||||
broadcast = _parse_latest_broadcast(payload)
|
||||
_write_broadcast_cache(cache_path, broadcast)
|
||||
return broadcast
|
||||
except (OSError, NewsError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
return _read_broadcast_cache(cache_path)
|
||||
|
||||
|
||||
def _parse_news(payload: bytes) -> tuple[NewsArticle, ...]:
|
||||
value = json.loads(payload.decode("utf-8-sig"))
|
||||
if isinstance(value, dict):
|
||||
value = value.get("data")
|
||||
if not isinstance(value, list):
|
||||
raise NewsError("official news response is not an article list")
|
||||
articles: list[NewsArticle] = []
|
||||
for item in value[:20]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
article_id = item.get("id")
|
||||
slug = item.get("slug")
|
||||
title = item.get("title")
|
||||
summary = item.get("summary")
|
||||
if (
|
||||
isinstance(article_id, bool)
|
||||
or not isinstance(article_id, int)
|
||||
or not isinstance(slug, str)
|
||||
or not slug.strip()
|
||||
or "/" in slug
|
||||
or not isinstance(title, str)
|
||||
or not title.strip()
|
||||
or not isinstance(summary, str)
|
||||
):
|
||||
continue
|
||||
author = item.get("author")
|
||||
date = item.get("date")
|
||||
articles.append(
|
||||
NewsArticle(
|
||||
id=article_id,
|
||||
slug=slug.strip(),
|
||||
title=title.strip(),
|
||||
summary=summary.strip(),
|
||||
author=author.strip() if isinstance(author, str) and author.strip() else None,
|
||||
published_at=_format_timestamp(date),
|
||||
image_url=_image_url(item.get("image")),
|
||||
)
|
||||
)
|
||||
if not articles:
|
||||
raise NewsError("official news response contains no usable articles")
|
||||
return tuple(articles)
|
||||
|
||||
|
||||
def _parse_latest_broadcast(payload: bytes) -> Broadcast:
|
||||
value = json.loads(payload.decode("utf-8-sig"))
|
||||
if not isinstance(value, list) or not value or not isinstance(value[0], dict):
|
||||
raise NewsError("broadcast response does not contain a latest broadcast")
|
||||
latest = value[0]
|
||||
author = latest.get("author")
|
||||
content = latest.get("content")
|
||||
url = _broadcast_url(latest.get("url"))
|
||||
if (
|
||||
not isinstance(author, str)
|
||||
or not author.strip()
|
||||
or not isinstance(content, str)
|
||||
or not content.strip()
|
||||
or url is None
|
||||
):
|
||||
raise NewsError("latest broadcast is incomplete")
|
||||
return Broadcast(
|
||||
author=author.strip(),
|
||||
content=content.strip(),
|
||||
published_at=_format_timestamp(latest.get("timestamp")),
|
||||
url=url,
|
||||
)
|
||||
|
||||
|
||||
def _format_timestamp(value: object) -> str | None:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(value, UTC).astimezone().strftime("%d %b %Y")
|
||||
except (OverflowError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _image_url(value: object) -> str | None:
|
||||
"""Accept only an official, relative article-art path from the feed."""
|
||||
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
parsed = urlparse(value)
|
||||
if parsed.scheme or parsed.netloc or not value.startswith("/"):
|
||||
return None
|
||||
if not parsed.path.startswith("/storage/media/images/"):
|
||||
return None
|
||||
if any(part in ("", ".", "..") for part in parsed.path.split("/")[1:]):
|
||||
return None
|
||||
return urljoin(NEWS_ORIGIN, value)
|
||||
|
||||
|
||||
def _broadcast_url(value: object) -> str | None:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
parsed = urlparse(value)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
return None
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _read_cache(path: Path) -> tuple[NewsArticle, ...]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
return tuple(NewsArticle(**item) for item in value if isinstance(item, dict))
|
||||
except (OSError, TypeError, json.JSONDecodeError):
|
||||
return ()
|
||||
|
||||
|
||||
def _write_cache(path: Path, articles: tuple[NewsArticle, ...]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_text(
|
||||
json.dumps([asdict(article) for article in articles], indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(path)
|
||||
except OSError:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _read_broadcast_cache(path: Path) -> Broadcast | None:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
return Broadcast(**value) if isinstance(value, dict) else None
|
||||
except (OSError, TypeError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _write_broadcast_cache(path: Path, broadcast: Broadcast) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_text(
|
||||
json.dumps(asdict(broadcast), indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(path)
|
||||
except OSError:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _image_cache_path(cache_directory: Path, article: NewsArticle) -> Path:
|
||||
assert article.image_url is not None
|
||||
digest = hashlib.sha256(article.image_url.encode("utf-8")).hexdigest()[:16]
|
||||
return cache_directory / f"{article.id}-{digest}.png"
|
||||
|
||||
|
||||
def _read_png(path: Path) -> bytes | None:
|
||||
try:
|
||||
payload = path.read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
return payload if _is_png(payload) else None
|
||||
|
||||
|
||||
def _is_png(payload: bytes) -> bool:
|
||||
return PNG_SIGNATURE == payload[:len(PNG_SIGNATURE)] and len(payload) <= MAX_NEWS_IMAGE_BYTES
|
||||
|
||||
|
||||
def _write_image_cache(path: Path, payload: bytes) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_bytes(payload)
|
||||
temporary.replace(path)
|
||||
except OSError:
|
||||
temporary.unlink(missing_ok=True)
|
||||
418
ptlinux/overlay.py
Normal file
418
ptlinux/overlay.py
Normal file
@@ -0,0 +1,418 @@
|
||||
"""Updater-safe runtime view with one verified normalized scene cache."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typing import Mapping
|
||||
import uuid
|
||||
|
||||
from ptdiag.assbin import (
|
||||
SceneInspection,
|
||||
inspect_wrapped_assbin,
|
||||
normalize_wrapped_assbin_textures,
|
||||
)
|
||||
|
||||
from .paths import AppPaths
|
||||
from .v21_texture_reclaim import TextureReclaimPatchError, build_texture_reclaim_patch
|
||||
|
||||
|
||||
WHISPERING_VALE_RELATIVE = Path("game/maps/WhisperingVale.rovug")
|
||||
KNOWN_SOURCE_SHA256 = (
|
||||
"1c9655945882b6b2189bbc6961018fa84295f895330987bad34e8428e9b5ce90"
|
||||
)
|
||||
TEXTURE_INDICES = (37, 38)
|
||||
KNOWN_NORMALIZED_SCANLINES_SHA256 = {
|
||||
37: "8fcc54e8d30fbfb725bb4c917a123e4f28a228397b4939d2fcfc7e8f3edc84f5",
|
||||
38: "a8301700ee76b0cbf9d974c1395093623390244f2f92658b352a1c3e19e20f60",
|
||||
}
|
||||
COMPATIBILITY_BINARY_HASHES = {
|
||||
"Game.exe": "3cc0980aac0f65b379ff340ec61750498fb9f620fb6c88aa66281e8ab27023a5",
|
||||
"game.dll": "7ea85b19f452962a39c91f967cad22495f066b2d52e8ac37f4e234463d5c4f09",
|
||||
}
|
||||
CURRENT_V21_BINARY_HASHES = {
|
||||
"Game.exe": "2bc3edd26c5f6d7ae9bbdc23967c5aefd374bdf934fcdfa2ed42c6442e550837",
|
||||
"game.dll": "4b4e5c28e2bfdaa8d36c8341fad27becfd03faf63aa8ea874f5d43c5b1961868",
|
||||
}
|
||||
PATCHED_V21_GAME_DLL_SHA256 = (
|
||||
"0be11b06ccf9be747e32b195c8d5980062f446d7d3a0a662ebfe0e602a01c538"
|
||||
)
|
||||
|
||||
|
||||
class OverlayError(RuntimeError):
|
||||
"""Raised when a safe runtime view cannot be built."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OverlayResult:
|
||||
source_game: str
|
||||
runtime_view: str
|
||||
source_scene_sha256: str
|
||||
runtime_scene_sha256: str
|
||||
scene_action: str
|
||||
binary_action: str
|
||||
game_exe_sha256: str
|
||||
game_dll_sha256: str
|
||||
created_at: str
|
||||
links_created: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompatibilityCacheResult:
|
||||
source: str
|
||||
destination: str
|
||||
game_exe_sha256: str
|
||||
game_dll_sha256: str
|
||||
created_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimePatchCacheResult:
|
||||
source: str
|
||||
destination: str
|
||||
game_dll_sha256: str
|
||||
created_at: str
|
||||
|
||||
|
||||
def prepare_runtime_view(
|
||||
paths: AppPaths,
|
||||
source_game: Path,
|
||||
) -> OverlayResult:
|
||||
"""Build a symlink view and keep the launcher-managed source read-only."""
|
||||
|
||||
source = source_game.expanduser().resolve(strict=True)
|
||||
_validate_game_tree(source)
|
||||
_validate_runtime_target(paths)
|
||||
source_scene = source / WHISPERING_VALE_RELATIVE
|
||||
inspection = inspect_wrapped_assbin(source_scene)
|
||||
action = _scene_action(inspection)
|
||||
binary_action, game_exe_source, game_dll_source = _runtime_game_binary_sources(
|
||||
paths,
|
||||
source,
|
||||
)
|
||||
|
||||
build = paths.data / f".{paths.runtime_view.name}.build-{uuid.uuid4().hex}"
|
||||
backup = paths.data / f".{paths.runtime_view.name}.old-{uuid.uuid4().hex}"
|
||||
links_created = 0
|
||||
try:
|
||||
build.mkdir(parents=True, exist_ok=False)
|
||||
top_level_exceptions = {"game"}
|
||||
for name, binary_source in (
|
||||
("Game.exe", game_exe_source),
|
||||
("game.dll", game_dll_source),
|
||||
):
|
||||
if binary_source != source / name:
|
||||
top_level_exceptions.add(name)
|
||||
links_created += _link_level(
|
||||
source,
|
||||
build,
|
||||
except_names=top_level_exceptions,
|
||||
)
|
||||
if game_exe_source != source / "Game.exe":
|
||||
(build / "Game.exe").symlink_to(game_exe_source)
|
||||
links_created += 1
|
||||
if game_dll_source != source / "game.dll":
|
||||
(build / "game.dll").symlink_to(game_dll_source)
|
||||
links_created += 1
|
||||
game_source = source / "game"
|
||||
game_target = build / "game"
|
||||
game_target.mkdir()
|
||||
links_created += _link_level(game_source, game_target, except_names={"maps"})
|
||||
maps_source = game_source / "maps"
|
||||
maps_target = game_target / "maps"
|
||||
maps_target.mkdir()
|
||||
links_created += _link_level(
|
||||
maps_source,
|
||||
maps_target,
|
||||
except_names={"WhisperingVale.rovug"},
|
||||
)
|
||||
runtime_scene = maps_target / "WhisperingVale.rovug"
|
||||
if action == "normalize-known-profile":
|
||||
normalization = normalize_wrapped_assbin_textures(
|
||||
source_scene,
|
||||
runtime_scene,
|
||||
TEXTURE_INDICES,
|
||||
)
|
||||
validate_scene_normalization(normalization)
|
||||
else:
|
||||
shutil.copy2(source_scene, runtime_scene)
|
||||
|
||||
if paths.runtime_view.exists() or paths.runtime_view.is_symlink():
|
||||
paths.runtime_view.replace(backup)
|
||||
build.replace(paths.runtime_view)
|
||||
if backup.exists() or backup.is_symlink():
|
||||
_remove_runtime_tree(backup)
|
||||
except BaseException:
|
||||
_remove_runtime_tree(build)
|
||||
if backup.exists() and not paths.runtime_view.exists():
|
||||
backup.replace(paths.runtime_view)
|
||||
raise
|
||||
|
||||
result = OverlayResult(
|
||||
source_game=str(source),
|
||||
runtime_view=str(paths.runtime_view),
|
||||
source_scene_sha256=inspection.file_sha256,
|
||||
runtime_scene_sha256=_sha256(paths.runtime_view / WHISPERING_VALE_RELATIVE),
|
||||
scene_action=action,
|
||||
binary_action=binary_action,
|
||||
game_exe_sha256=_sha256(paths.runtime_view / "Game.exe"),
|
||||
game_dll_sha256=_sha256(paths.runtime_view / "game.dll"),
|
||||
created_at=datetime.now().astimezone().isoformat(),
|
||||
links_created=links_created,
|
||||
)
|
||||
_write_json(paths.runtime_manifest, asdict(result))
|
||||
return result
|
||||
|
||||
|
||||
def cache_compatibility_binaries(
|
||||
paths: AppPaths,
|
||||
source_game: Path,
|
||||
) -> CompatibilityCacheResult:
|
||||
source = source_game.expanduser().resolve(strict=True)
|
||||
hashes = _binary_hashes(source)
|
||||
if hashes != COMPATIBILITY_BINARY_HASHES:
|
||||
raise OverlayError(
|
||||
"compatibility source does not match the verified Game.exe/game.dll profile: "
|
||||
f"{source}"
|
||||
)
|
||||
target = paths.compatibility_binaries
|
||||
if target.exists() or target.is_symlink():
|
||||
if target.is_dir() and _binary_hashes(target) == COMPATIBILITY_BINARY_HASHES:
|
||||
return _compatibility_result(paths, source)
|
||||
raise OverlayError(f"compatibility cache has an unknown profile: {target}")
|
||||
paths.data.mkdir(parents=True, exist_ok=True)
|
||||
build = paths.data / f".{target.name}.build-{uuid.uuid4().hex}"
|
||||
try:
|
||||
build.mkdir(parents=False, exist_ok=False)
|
||||
for name in COMPATIBILITY_BINARY_HASHES:
|
||||
shutil.copy2(source / name, build / name)
|
||||
if _binary_hashes(build) != COMPATIBILITY_BINARY_HASHES:
|
||||
raise OverlayError("copied compatibility binaries failed hash verification")
|
||||
build.replace(target)
|
||||
except BaseException:
|
||||
_remove_runtime_tree(build)
|
||||
raise
|
||||
result = _compatibility_result(paths, source)
|
||||
_write_json(paths.compatibility_manifest, asdict(result))
|
||||
return result
|
||||
|
||||
|
||||
def cache_runtime_patch(
|
||||
paths: AppPaths,
|
||||
source_dll: Path,
|
||||
) -> RuntimePatchCacheResult:
|
||||
source = source_dll.expanduser().resolve(strict=True)
|
||||
if _sha256(source) != PATCHED_V21_GAME_DLL_SHA256:
|
||||
raise OverlayError("runtime patch does not match the verified V21 profile")
|
||||
target = paths.runtime_patch_dll
|
||||
if target.exists() or target.is_symlink():
|
||||
if target.is_file() and _sha256(target) == PATCHED_V21_GAME_DLL_SHA256:
|
||||
return _runtime_patch_result(paths, source)
|
||||
raise OverlayError(f"runtime patch cache has an unknown profile: {target}")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp")
|
||||
try:
|
||||
shutil.copy2(source, temporary)
|
||||
if _sha256(temporary) != PATCHED_V21_GAME_DLL_SHA256:
|
||||
raise OverlayError("copied runtime patch failed hash verification")
|
||||
temporary.replace(target)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
result = _runtime_patch_result(paths, source)
|
||||
_write_json(paths.runtime_patch_manifest, asdict(result))
|
||||
return result
|
||||
|
||||
|
||||
def select_binary_action(
|
||||
cache_hashes: dict[str, str] | None,
|
||||
source_hashes: dict[str, str],
|
||||
runtime_patch_hash: str | None = None,
|
||||
) -> str:
|
||||
if runtime_patch_hash is not None:
|
||||
if runtime_patch_hash != PATCHED_V21_GAME_DLL_SHA256:
|
||||
raise OverlayError("runtime patch cache has an unknown binary profile")
|
||||
if cache_hashes is not None:
|
||||
if cache_hashes != COMPATIBILITY_BINARY_HASHES:
|
||||
raise OverlayError("compatibility cache has an unknown binary profile")
|
||||
if source_hashes == COMPATIBILITY_BINARY_HASHES:
|
||||
return "verified-source-pair"
|
||||
if runtime_patch_hash is not None:
|
||||
if source_hashes != CURRENT_V21_BINARY_HASHES:
|
||||
raise OverlayError("V21 runtime patch does not match the installed client profile")
|
||||
return "verified-v21-texture-reclaim"
|
||||
return "upstream-pair"
|
||||
|
||||
|
||||
def _scene_action(inspection: SceneInspection) -> str:
|
||||
selected = []
|
||||
for index in TEXTURE_INDICES:
|
||||
if index >= len(inspection.embedded_textures):
|
||||
raise OverlayError(f"scene no longer has embedded texture {index}")
|
||||
selected.append(inspection.embedded_textures[index])
|
||||
depths = [texture.png.bit_depth if texture.png is not None else None for texture in selected]
|
||||
colors = [texture.png.color_type if texture.png is not None else None for texture in selected]
|
||||
return select_scene_action(inspection.file_sha256, depths, colors)
|
||||
|
||||
|
||||
def select_scene_action(
|
||||
file_sha256: str,
|
||||
depths: list[int | None],
|
||||
colors: list[int | None],
|
||||
) -> str:
|
||||
"""Choose a repair only from the exact source hash and decoded PNG profile."""
|
||||
|
||||
if file_sha256 == KNOWN_SOURCE_SHA256 and depths == [16, 16] and colors == [2, 2]:
|
||||
return "normalize-known-profile"
|
||||
if depths == [8, 8] and colors == [2, 2]:
|
||||
return "upstream-compatible-copy"
|
||||
raise OverlayError(
|
||||
"WhisperingVale.rovug is not a supported release profile; refusing to "
|
||||
f"modify unknown textures (sha256={file_sha256}, "
|
||||
f"depths={depths}, colors={colors})"
|
||||
)
|
||||
|
||||
|
||||
def validate_scene_normalization(report: Mapping[str, object]) -> None:
|
||||
"""Verify the known repair by decoded pixels, not zlib's encoded bytes.
|
||||
|
||||
zlib is allowed to emit different valid DEFLATE streams across versions and
|
||||
compatible implementations. The old whole-file hash therefore rejected a
|
||||
semantically identical scene on some distributions. The source file remains
|
||||
pinned exactly, while these hashes pin the decoded scanlines produced for the
|
||||
two repaired textures.
|
||||
"""
|
||||
|
||||
if report.get("source_sha256") != KNOWN_SOURCE_SHA256:
|
||||
raise OverlayError("normalized scene source did not match the verified release profile")
|
||||
verified = report.get("verified")
|
||||
if not isinstance(verified, bool) or not verified:
|
||||
raise OverlayError("normalized scene did not pass structural verification")
|
||||
if report.get("texture_indices") != list(TEXTURE_INDICES):
|
||||
raise OverlayError("normalized scene changed an unexpected texture set")
|
||||
|
||||
changes = report.get("changes")
|
||||
if not isinstance(changes, list):
|
||||
raise OverlayError("normalized scene report did not contain texture changes")
|
||||
change_hashes: dict[int, str] = {}
|
||||
for change in changes:
|
||||
if not isinstance(change, dict):
|
||||
raise OverlayError("normalized scene report contained an invalid texture change")
|
||||
index = change.get("index")
|
||||
scanlines_sha256 = change.get("new_scanlines_sha256")
|
||||
if isinstance(index, bool) or not isinstance(index, int):
|
||||
raise OverlayError("normalized scene report contained an invalid texture index")
|
||||
if not isinstance(scanlines_sha256, str):
|
||||
raise OverlayError("normalized scene report omitted a decoded texture hash")
|
||||
if index in change_hashes:
|
||||
raise OverlayError("normalized scene report repeated a texture change")
|
||||
change_hashes[index] = scanlines_sha256
|
||||
|
||||
if change_hashes != KNOWN_NORMALIZED_SCANLINES_SHA256:
|
||||
raise OverlayError(
|
||||
"normalized scene pixels did not match the verified release profile"
|
||||
)
|
||||
|
||||
|
||||
def _validate_game_tree(source: Path) -> None:
|
||||
for relative in (
|
||||
Path("Game.exe"),
|
||||
Path("game.dll"),
|
||||
Path("game.ini"),
|
||||
WHISPERING_VALE_RELATIVE,
|
||||
):
|
||||
if not (source / relative).is_file():
|
||||
raise OverlayError(f"game installation is incomplete: missing {relative}")
|
||||
|
||||
|
||||
def _validate_runtime_target(paths: AppPaths) -> None:
|
||||
data = paths.data.resolve()
|
||||
target = paths.runtime_view
|
||||
if target.parent.resolve() != data:
|
||||
raise OverlayError(f"runtime view is outside the application data root: {target}")
|
||||
if target == data:
|
||||
raise OverlayError("runtime view cannot be the application data root")
|
||||
|
||||
|
||||
def _link_level(source: Path, target: Path, *, except_names: set[str]) -> int:
|
||||
count = 0
|
||||
for child in source.iterdir():
|
||||
if child.name in except_names:
|
||||
continue
|
||||
(target / child.name).symlink_to(child, target_is_directory=child.is_dir())
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _runtime_game_binary_sources(paths: AppPaths, source: Path) -> tuple[str, Path, Path]:
|
||||
cache = paths.compatibility_binaries
|
||||
cache_hashes = _binary_hashes(cache) if cache.is_dir() else None
|
||||
source_hashes = _binary_hashes(source)
|
||||
patch = paths.runtime_patch_dll
|
||||
if source_hashes == CURRENT_V21_BINARY_HASHES and not patch.exists():
|
||||
try:
|
||||
result = build_texture_reclaim_patch(source / "game.dll", patch)
|
||||
except TextureReclaimPatchError as exc:
|
||||
raise OverlayError(f"could not build the verified V21 runtime patch: {exc}") from exc
|
||||
_write_json(patch.parent / "patch.json", {"ok": True, **asdict(result)})
|
||||
patch_hash = _sha256(patch) if patch.is_file() else None
|
||||
action = select_binary_action(cache_hashes, source_hashes, patch_hash)
|
||||
return (
|
||||
action,
|
||||
source / "Game.exe",
|
||||
patch if action == "verified-v21-texture-reclaim" else source / "game.dll",
|
||||
)
|
||||
|
||||
|
||||
def _binary_hashes(directory: Path) -> dict[str, str]:
|
||||
return {
|
||||
name: _sha256(directory / name)
|
||||
for name in COMPATIBILITY_BINARY_HASHES
|
||||
if (directory / name).is_file()
|
||||
}
|
||||
|
||||
|
||||
def _compatibility_result(paths: AppPaths, source: Path) -> CompatibilityCacheResult:
|
||||
return CompatibilityCacheResult(
|
||||
source=str(source),
|
||||
destination=str(paths.compatibility_binaries),
|
||||
game_exe_sha256=COMPATIBILITY_BINARY_HASHES["Game.exe"],
|
||||
game_dll_sha256=COMPATIBILITY_BINARY_HASHES["game.dll"],
|
||||
created_at=datetime.now().astimezone().isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def _runtime_patch_result(paths: AppPaths, source: Path) -> RuntimePatchCacheResult:
|
||||
return RuntimePatchCacheResult(
|
||||
source=str(source),
|
||||
destination=str(paths.runtime_patch_dll),
|
||||
game_dll_sha256=PATCHED_V21_GAME_DLL_SHA256,
|
||||
created_at=datetime.now().astimezone().isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def _remove_runtime_tree(path: Path) -> None:
|
||||
if path.is_symlink() or path.is_file():
|
||||
path.unlink(missing_ok=True)
|
||||
elif path.is_dir():
|
||||
shutil.rmtree(path)
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
219
ptlinux/paths.py
Normal file
219
ptlinux/paths.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""Per-user game data and XDG support paths used by the installer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
APP_DIRECTORY_NAME = "pristontale-eu"
|
||||
DEFAULT_INSTALL_DIRECTORY_NAME = "PristontaleEU"
|
||||
INSTALL_DIRECTORY_VARIABLE = "PTLINUX_INSTALL_DIR"
|
||||
GAME_RELATIVE_PATH = Path("drive_c/Program Files (x86)/Pristontale EU")
|
||||
|
||||
|
||||
def _xdg_path(
|
||||
environment: Mapping[str, str],
|
||||
variable: str,
|
||||
fallback: Path,
|
||||
) -> Path:
|
||||
value = environment.get(variable)
|
||||
return Path(value).expanduser() if value else fallback
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AppPaths:
|
||||
"""All persistent paths for one user's installation."""
|
||||
|
||||
data: Path
|
||||
config: Path
|
||||
state: Path
|
||||
cache: Path
|
||||
|
||||
@classmethod
|
||||
def from_environment(
|
||||
cls,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> AppPaths:
|
||||
env = os.environ if environment is None else environment
|
||||
home_value = env.get("HOME")
|
||||
if not home_value:
|
||||
raise RuntimeError("HOME is not set")
|
||||
home = Path(home_value).expanduser()
|
||||
config_home = _xdg_path(env, "XDG_CONFIG_HOME", home / ".config")
|
||||
state_home = _xdg_path(env, "XDG_STATE_HOME", home / ".local/state")
|
||||
cache_home = _xdg_path(env, "XDG_CACHE_HOME", home / ".cache")
|
||||
install_value = env.get(INSTALL_DIRECTORY_VARIABLE)
|
||||
if install_value:
|
||||
data = Path(install_value).expanduser()
|
||||
if not data.is_absolute():
|
||||
raise RuntimeError(
|
||||
f"{INSTALL_DIRECTORY_VARIABLE} must be an absolute path"
|
||||
)
|
||||
else:
|
||||
data = home / "Games" / DEFAULT_INSTALL_DIRECTORY_NAME
|
||||
return cls(
|
||||
data=data.resolve(),
|
||||
config=(config_home / APP_DIRECTORY_NAME).resolve(),
|
||||
state=(state_home / APP_DIRECTORY_NAME).resolve(),
|
||||
cache=(cache_home / APP_DIRECTORY_NAME).resolve(),
|
||||
)
|
||||
|
||||
@property
|
||||
def downloads(self) -> Path:
|
||||
return self.cache / "downloads"
|
||||
|
||||
@property
|
||||
def app_home(self) -> Path:
|
||||
"""Stable home containing versioned native-launcher releases."""
|
||||
|
||||
return self.data / "app"
|
||||
|
||||
@property
|
||||
def launcher_releases(self) -> Path:
|
||||
return self.app_home / "releases"
|
||||
|
||||
@property
|
||||
def launcher_current(self) -> Path:
|
||||
return self.app_home / "current"
|
||||
|
||||
@property
|
||||
def launcher_previous(self) -> Path:
|
||||
return self.app_home / "previous"
|
||||
|
||||
@property
|
||||
def launcher_update_state(self) -> Path:
|
||||
return self.state / "launcher-update.json"
|
||||
|
||||
@property
|
||||
def launcher_update_lock(self) -> Path:
|
||||
return self.state / "launcher-update.lock"
|
||||
|
||||
def launcher_update_archive(self, version: str) -> Path:
|
||||
return self.downloads / f"pristontale-eu-linux-update-v{version}.tar.gz"
|
||||
|
||||
@property
|
||||
def launcher_pristine(self) -> Path:
|
||||
return self.downloads / "Pristontale EU Launcher.exe"
|
||||
|
||||
@property
|
||||
def launcher_patched(self) -> Path:
|
||||
return self.data / "launcher" / "Pristontale EU Launcher.exe"
|
||||
|
||||
@property
|
||||
def launcher_metadata(self) -> Path:
|
||||
return self.state / "launcher-download.json"
|
||||
|
||||
@property
|
||||
def launcher_prefix(self) -> Path:
|
||||
return self.data / "launcher-prefix"
|
||||
|
||||
@property
|
||||
def launcher_prefix_marker(self) -> Path:
|
||||
return self.state / "launcher-prefix.json"
|
||||
|
||||
@property
|
||||
def launcher_operation_lock(self) -> Path:
|
||||
return self.state / "launcher-operation.lock"
|
||||
|
||||
@property
|
||||
def source_game(self) -> Path:
|
||||
return self.launcher_prefix / GAME_RELATIVE_PATH
|
||||
|
||||
@property
|
||||
def game_import_manifest(self) -> Path:
|
||||
return self.state / "game-import.json"
|
||||
|
||||
@property
|
||||
def native_depot_state(self) -> Path:
|
||||
"""Last completed native depot synchronization."""
|
||||
|
||||
return self.state / "native-depot.json"
|
||||
|
||||
@property
|
||||
def news_cache(self) -> Path:
|
||||
return self.cache / "news.json"
|
||||
|
||||
@property
|
||||
def broadcast_cache(self) -> Path:
|
||||
"""Cached latest announcement from the broadcast service."""
|
||||
|
||||
return self.cache / "broadcast.json"
|
||||
|
||||
@property
|
||||
def news_image_cache(self) -> Path:
|
||||
"""Cached official PNG thumbnails used by the native news cards."""
|
||||
|
||||
return self.cache / "news-images"
|
||||
|
||||
@property
|
||||
def logo_cache(self) -> Path:
|
||||
"""Cached PNG asset for the official PristonTale EU logo."""
|
||||
|
||||
return self.cache / "logo-small.png"
|
||||
|
||||
@property
|
||||
def runtime_view(self) -> Path:
|
||||
return self.data / "runtime-view"
|
||||
|
||||
@property
|
||||
def runtime_manifest(self) -> Path:
|
||||
return self.state / "runtime-view.json"
|
||||
|
||||
@property
|
||||
def compatibility_binaries(self) -> Path:
|
||||
return self.data / "compatibility-binaries"
|
||||
|
||||
@property
|
||||
def compatibility_manifest(self) -> Path:
|
||||
return self.state / "compatibility-binaries.json"
|
||||
|
||||
@property
|
||||
def runtime_patch_directory(self) -> Path:
|
||||
return self.data / "runtime-patches" / "v21-texture-reclaim"
|
||||
|
||||
@property
|
||||
def runtime_patch_dll(self) -> Path:
|
||||
return self.runtime_patch_directory / "game.dll"
|
||||
|
||||
@property
|
||||
def runtime_patch_manifest(self) -> Path:
|
||||
return self.state / "runtime-patch.json"
|
||||
|
||||
@property
|
||||
def compat_data(self) -> Path:
|
||||
return self.data / "game-compat"
|
||||
|
||||
@property
|
||||
def proton_prefix(self) -> Path:
|
||||
return self.compat_data / "pfx"
|
||||
|
||||
@property
|
||||
def umu_prefix(self) -> Path:
|
||||
return self.data / "umu-prefix"
|
||||
|
||||
@property
|
||||
def dxvk_cache(self) -> Path:
|
||||
return self.cache / "dxvk-state"
|
||||
|
||||
@property
|
||||
def logs(self) -> Path:
|
||||
return self.state / "logs"
|
||||
|
||||
@property
|
||||
def settings_file(self) -> Path:
|
||||
return self.config / "settings.json"
|
||||
|
||||
def create_base_directories(self) -> None:
|
||||
for path in (
|
||||
self.data,
|
||||
self.config,
|
||||
self.state,
|
||||
self.cache,
|
||||
self.downloads,
|
||||
self.logs,
|
||||
self.dxvk_cache,
|
||||
):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
31
ptlinux/proton_compat.py
Normal file
31
ptlinux/proton_compat.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Compatibility entry point for retained Valve Proton 7 scripts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import sys
|
||||
import tarfile
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> None:
|
||||
arguments = list(sys.argv[1:] if argv is None else argv)
|
||||
if not arguments:
|
||||
raise SystemExit("usage: proton_compat.py /path/to/proton [arguments...]")
|
||||
proton = Path(arguments[0]).expanduser().resolve(strict=True)
|
||||
proton_directory = str(proton.parent)
|
||||
if proton_directory not in sys.path:
|
||||
sys.path.insert(0, proton_directory)
|
||||
if hasattr(tarfile, "fully_trusted_filter"):
|
||||
setattr(
|
||||
tarfile.TarFile,
|
||||
"extraction_filter",
|
||||
staticmethod(tarfile.fully_trusted_filter),
|
||||
)
|
||||
sys.argv = [str(proton), *arguments[1:]]
|
||||
runpy.run_path(str(proton), run_name="__main__")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
390
ptlinux/runtime.py
Normal file
390
ptlinux/runtime.py
Normal file
@@ -0,0 +1,390 @@
|
||||
"""Portable game runtime selection and Proton/UMU preparation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
from typing import Mapping
|
||||
|
||||
from .command import CommandOutcome, CommandRunner
|
||||
from .gamebox import build_gamebox_command
|
||||
from .paths import AppPaths
|
||||
from .settings import Settings
|
||||
|
||||
|
||||
WINDOWS_GAME_EXE = r"C:\Program Files (x86)\Pristontale EU\Game.exe"
|
||||
NATIVE_D3D43_DLLS = ("d3dx9_43", "d3dcompiler_43")
|
||||
DXVK_D3D9_OVERRIDE = "native,builtin"
|
||||
DLL_OVERRIDES_KEY = r"HKCU\Software\Wine\DllOverrides"
|
||||
WINETRICKS_CODEPAGE_PROBE = (
|
||||
'winetricks_early_wine_arch cmd.exe /c "chcp 65001 > nul & echo %$1%"'
|
||||
)
|
||||
WINETRICKS_PROTON7_PROBE = 'winetricks_early_wine_arch cmd.exe /c "echo %$1%"'
|
||||
|
||||
|
||||
class RuntimeErrorPT(RuntimeError):
|
||||
"""Raised when a supported game runtime cannot be prepared."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeSelection:
|
||||
kind: str
|
||||
executable: str
|
||||
proton_path: str | None
|
||||
steam_root: str | None
|
||||
|
||||
|
||||
def select_runtime(
|
||||
settings: Settings,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> RuntimeSelection:
|
||||
env = os.environ if environment is None else environment
|
||||
if settings.proton_path:
|
||||
proton = Path(settings.proton_path).expanduser().resolve()
|
||||
if not proton.is_file():
|
||||
raise RuntimeErrorPT(f"configured Proton script does not exist: {proton}")
|
||||
steam_root = _steam_root_for_proton(proton)
|
||||
return RuntimeSelection("proton7", sys.executable, str(proton), str(steam_root))
|
||||
if settings.runtime in {"auto", "proton7"}:
|
||||
proton_result = find_steam_proton7(env)
|
||||
if proton_result is not None:
|
||||
proton, steam_root = proton_result
|
||||
return RuntimeSelection("proton7", sys.executable, str(proton), str(steam_root))
|
||||
if settings.runtime == "proton7":
|
||||
raise RuntimeErrorPT("Steam Proton 7.0 was requested but not found")
|
||||
if settings.runtime in {"auto", "umu"}:
|
||||
umu = shutil.which("umu-run")
|
||||
if umu:
|
||||
return RuntimeSelection("umu", umu, None, None)
|
||||
raise RuntimeErrorPT(
|
||||
"no supported game runtime found; install umu-launcher or Steam Proton 7.0"
|
||||
)
|
||||
raise RuntimeErrorPT(f"unsupported runtime selection: {settings.runtime}")
|
||||
|
||||
|
||||
def find_steam_proton7(
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> tuple[Path, Path] | None:
|
||||
env = os.environ if environment is None else environment
|
||||
home_value = env.get("HOME")
|
||||
if not home_value:
|
||||
return None
|
||||
home = Path(home_value).expanduser()
|
||||
roots: list[Path] = []
|
||||
explicit = env.get("STEAM_COMPAT_CLIENT_INSTALL_PATH")
|
||||
if explicit:
|
||||
roots.append(Path(explicit).expanduser())
|
||||
roots.extend(
|
||||
(
|
||||
home / ".local/share/Steam",
|
||||
home / ".steam/steam",
|
||||
home / "snap/steam/common/.local/share/Steam",
|
||||
home / ".var/app/com.valvesoftware.Steam/.local/share/Steam",
|
||||
)
|
||||
)
|
||||
seen: set[Path] = set()
|
||||
for root in roots:
|
||||
resolved = root.resolve()
|
||||
if resolved in seen:
|
||||
continue
|
||||
seen.add(resolved)
|
||||
proton = resolved / "steamapps/common/Proton 7.0/proton"
|
||||
if proton.is_file():
|
||||
return proton, resolved
|
||||
return None
|
||||
|
||||
|
||||
def prepare_game_runtime(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
runner: CommandRunner,
|
||||
) -> list[CommandOutcome]:
|
||||
if not runner.dry_run:
|
||||
paths.create_base_directories()
|
||||
results: list[CommandOutcome] = []
|
||||
if selection.kind == "umu":
|
||||
environment = _umu_environment(paths)
|
||||
if not native_d3d_ready(paths.umu_prefix):
|
||||
results.append(
|
||||
runner.run(
|
||||
[selection.executable, "winetricks", "-q", "d3dx9_43", "d3dcompiler_43"],
|
||||
env_overrides=environment,
|
||||
timeout_seconds=1800.0,
|
||||
)
|
||||
)
|
||||
elif selection.kind == "proton7":
|
||||
if not selection.proton_path or not selection.steam_root:
|
||||
raise RuntimeErrorPT("incomplete Proton 7 runtime selection")
|
||||
if not runner.dry_run:
|
||||
paths.compat_data.mkdir(parents=True, exist_ok=True)
|
||||
proton = Path(selection.proton_path)
|
||||
environment = _proton_environment(paths, selection)
|
||||
prefix_verb = "runinprefix" if _proton_prefix_ready(paths.proton_prefix) else "run"
|
||||
results.append(
|
||||
runner.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"ptlinux.proton_compat",
|
||||
str(proton),
|
||||
prefix_verb,
|
||||
"cmd",
|
||||
"/c",
|
||||
"exit",
|
||||
],
|
||||
env_overrides=environment,
|
||||
timeout_seconds=600.0,
|
||||
)
|
||||
)
|
||||
if not runner.dry_run:
|
||||
_ensure_proton_game_link(paths)
|
||||
results.append(
|
||||
runner.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"ptlinux.proton_compat",
|
||||
str(proton),
|
||||
"runinprefix",
|
||||
"reg.exe",
|
||||
"add",
|
||||
DLL_OVERRIDES_KEY,
|
||||
"/v",
|
||||
"d3d9",
|
||||
"/t",
|
||||
"REG_SZ",
|
||||
"/d",
|
||||
DXVK_D3D9_OVERRIDE,
|
||||
"/f",
|
||||
],
|
||||
env_overrides=environment,
|
||||
timeout_seconds=60.0,
|
||||
)
|
||||
)
|
||||
if not native_d3d_ready(paths.proton_prefix):
|
||||
wine_tools = _find_proton_wine_tools(proton)
|
||||
winetricks = shutil.which("winetricks")
|
||||
if wine_tools is None or not winetricks:
|
||||
raise RuntimeErrorPT("Proton Winetricks helpers are unavailable")
|
||||
wine, wineserver = wine_tools
|
||||
compatible_winetricks = prepare_proton7_winetricks(
|
||||
Path(winetricks),
|
||||
paths.cache / "compat/winetricks-proton7",
|
||||
dry_run=runner.dry_run,
|
||||
)
|
||||
results.append(
|
||||
runner.run(
|
||||
[
|
||||
str(compatible_winetricks),
|
||||
"-q",
|
||||
"d3dx9_43",
|
||||
"d3dcompiler_43",
|
||||
],
|
||||
env_overrides={
|
||||
"WINEPREFIX": str(paths.proton_prefix),
|
||||
"WINE": str(wine),
|
||||
"WINESERVER": str(wineserver),
|
||||
"WINEDEBUG": "-all",
|
||||
},
|
||||
timeout_seconds=1800.0,
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise RuntimeErrorPT(f"unsupported runtime kind: {selection.kind}")
|
||||
if not runner.dry_run:
|
||||
_write_json(
|
||||
paths.state / "game-runtime.json",
|
||||
{
|
||||
"configured_at": datetime.now().astimezone().isoformat(),
|
||||
"native_d3d43": True,
|
||||
"dxvk_d3d9_override": DXVK_D3D9_OVERRIDE,
|
||||
"runtime": asdict(selection),
|
||||
},
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def run_game(
|
||||
paths: AppPaths,
|
||||
settings: Settings,
|
||||
selection: RuntimeSelection,
|
||||
runner: CommandRunner,
|
||||
*,
|
||||
use_gamebox: bool = True,
|
||||
) -> CommandOutcome:
|
||||
if not paths.runtime_view.is_dir() and not runner.dry_run:
|
||||
raise RuntimeErrorPT("runtime view is missing; prepare the game first")
|
||||
if selection.kind == "umu":
|
||||
game_command = [
|
||||
selection.executable,
|
||||
str(paths.runtime_view / "Game.exe"),
|
||||
"PTEU_REBORN",
|
||||
]
|
||||
environment = _umu_environment(paths)
|
||||
else:
|
||||
if not selection.proton_path:
|
||||
raise RuntimeErrorPT("Proton script path is missing")
|
||||
game_command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"ptlinux.proton_compat",
|
||||
selection.proton_path,
|
||||
"runinprefix",
|
||||
WINDOWS_GAME_EXE,
|
||||
"PTEU_REBORN",
|
||||
]
|
||||
environment = _proton_environment(paths, selection)
|
||||
if use_gamebox:
|
||||
game_command = build_gamebox_command(game_command, settings)
|
||||
environment["PYTHONPATH"] = _runtime_python_path(
|
||||
os.environ.get("PYTHONPATH"),
|
||||
)
|
||||
return runner.run(
|
||||
game_command,
|
||||
cwd=paths.runtime_view,
|
||||
env_overrides=environment,
|
||||
timeout_seconds=12 * 60 * 60,
|
||||
stream_output=True,
|
||||
)
|
||||
|
||||
|
||||
def _runtime_python_path(existing: str | None) -> str:
|
||||
"""Make the installed package importable after changing into runtime_view."""
|
||||
package_root = str(Path(__file__).resolve().parent.parent)
|
||||
if not existing:
|
||||
return package_root
|
||||
return os.pathsep.join((package_root, existing))
|
||||
|
||||
|
||||
def _umu_environment(paths: AppPaths) -> dict[str, str]:
|
||||
return {
|
||||
"WINEPREFIX": str(paths.umu_prefix),
|
||||
"GAMEID": "umu-pristontale-eu",
|
||||
"STORE": "none",
|
||||
"DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache),
|
||||
"WINEDEBUG": "-all",
|
||||
}
|
||||
|
||||
|
||||
def _proton_environment(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
) -> dict[str, str]:
|
||||
if not selection.steam_root:
|
||||
raise RuntimeErrorPT("Steam root is missing for Proton")
|
||||
return {
|
||||
"STEAM_COMPAT_CLIENT_INSTALL_PATH": selection.steam_root,
|
||||
"STEAM_COMPAT_DATA_PATH": str(paths.compat_data),
|
||||
"STEAM_COMPAT_INSTALL_PATH": str(paths.runtime_view),
|
||||
"SteamAppId": "0",
|
||||
"SteamGameId": "0",
|
||||
"WINEPREFIX": str(paths.proton_prefix),
|
||||
"WINEDEBUG": "-all",
|
||||
"PROTON_LOG": "0",
|
||||
"DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache),
|
||||
}
|
||||
|
||||
|
||||
def _ensure_proton_game_link(paths: AppPaths) -> None:
|
||||
link = paths.proton_prefix / "drive_c/Program Files (x86)/Pristontale EU"
|
||||
link.parent.mkdir(parents=True, exist_ok=True)
|
||||
if link.is_symlink():
|
||||
if link.resolve() == paths.runtime_view.resolve():
|
||||
return
|
||||
raise RuntimeErrorPT(f"Proton game link points somewhere unexpected: {link}")
|
||||
if link.exists():
|
||||
if link.is_dir() and not any(link.iterdir()):
|
||||
link.rmdir()
|
||||
else:
|
||||
raise RuntimeErrorPT(f"refusing to replace existing Proton game path: {link}")
|
||||
link.symlink_to(paths.runtime_view, target_is_directory=True)
|
||||
|
||||
|
||||
def _steam_root_for_proton(proton: Path) -> Path:
|
||||
parts = proton.parts
|
||||
try:
|
||||
index = parts.index("steamapps")
|
||||
except ValueError as exc:
|
||||
raise RuntimeErrorPT(
|
||||
"configured Proton path must be inside a Steam steamapps directory"
|
||||
) from exc
|
||||
return Path(*parts[:index])
|
||||
|
||||
|
||||
def native_d3d_ready(prefix: Path) -> bool:
|
||||
directory = prefix / "drive_c/windows/syswow64"
|
||||
if not all((directory / f"{name}.dll").is_file() for name in NATIVE_D3D43_DLLS):
|
||||
return False
|
||||
try:
|
||||
registry = (prefix / "user.reg").read_text(
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
).casefold()
|
||||
except OSError:
|
||||
return False
|
||||
return all(_native_override_ready(registry, name) for name in NATIVE_D3D43_DLLS)
|
||||
|
||||
|
||||
def _native_override_ready(registry: str, name: str) -> bool:
|
||||
return any(
|
||||
entry in registry
|
||||
for entry in (
|
||||
f'"{name}"="native,builtin"',
|
||||
f'"{name}"="native"',
|
||||
f'"*{name}"="native"',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def prepare_proton7_winetricks(
|
||||
source: Path,
|
||||
destination: Path,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> Path:
|
||||
try:
|
||||
content = source.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise RuntimeErrorPT(f"could not read Winetricks script {source}: {exc}") from exc
|
||||
if WINETRICKS_CODEPAGE_PROBE not in content:
|
||||
return source
|
||||
if dry_run:
|
||||
return destination
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
patched = content.replace(
|
||||
WINETRICKS_CODEPAGE_PROBE,
|
||||
WINETRICKS_PROTON7_PROBE,
|
||||
1,
|
||||
)
|
||||
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
|
||||
temporary.write_text(patched, encoding="utf-8")
|
||||
temporary.chmod(source.stat().st_mode & 0o777)
|
||||
temporary.replace(destination)
|
||||
return destination
|
||||
|
||||
|
||||
def _proton_prefix_ready(prefix: Path) -> bool:
|
||||
return (prefix / "user.reg").is_file() and (
|
||||
prefix / "drive_c/windows/system32/cmd.exe"
|
||||
).exists()
|
||||
|
||||
|
||||
def _find_proton_wine_tools(proton: Path) -> tuple[Path, Path] | None:
|
||||
for directory in (proton.parent / "files/bin", proton.parent / "dist/bin"):
|
||||
wine = directory / "wine"
|
||||
wineserver = directory / "wineserver"
|
||||
if wine.is_file() and wineserver.is_file():
|
||||
return wine, wineserver
|
||||
return None
|
||||
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
857
ptlinux/self_update.py
Normal file
857
ptlinux/self_update.py
Normal file
@@ -0,0 +1,857 @@
|
||||
"""Signed, atomic self-updates for the native Linux launcher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
from typing import Mapping
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from . import __version__
|
||||
from .locking import launcher_operation_lock
|
||||
from .paths import AppPaths
|
||||
|
||||
|
||||
DEFAULT_UPDATE_REPOSITORY_URL = (
|
||||
"https://git.m4ko.net/m4ko/pristontale-eu-linux"
|
||||
)
|
||||
DEFAULT_UPDATE_MANIFEST_URL = (
|
||||
f"{DEFAULT_UPDATE_REPOSITORY_URL}/raw/branch/main/updates/stable.json"
|
||||
)
|
||||
UPDATE_MANIFEST_URL_VARIABLE = "PTLINUX_UPDATE_MANIFEST_URL"
|
||||
UPDATE_PUBLIC_KEY_VARIABLE = "PTLINUX_UPDATE_PUBLIC_KEY"
|
||||
PUBLIC_KEY_FILENAME = "launcher-update-public.pem"
|
||||
USER_AGENT = f"PT-Linux/{__version__} launcher-updater"
|
||||
READ_SIZE = 256 * 1024
|
||||
MAX_MANIFEST_BYTES = 128 * 1024
|
||||
MAX_SIGNATURE_BYTES = 16 * 1024
|
||||
MAX_ARCHIVE_BYTES = 32 * 1024 * 1024
|
||||
MAX_EXTRACTED_BYTES = 64 * 1024 * 1024
|
||||
MAX_ARCHIVE_MEMBERS = 512
|
||||
UPDATE_ARCHIVE_PREFIX = "pristontale-eu-linux-update-v"
|
||||
_VERSION_PATTERN = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\Z")
|
||||
_SHA256_PATTERN = re.compile(r"[0-9a-f]{64}\Z")
|
||||
_ALLOWED_RELEASE_ROOTS = {"ptlinux", "ptdiag", "release.json"}
|
||||
|
||||
|
||||
class LauncherUpdateError(RuntimeError):
|
||||
"""Raised when a launcher update cannot be trusted or installed safely."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpdateManifest:
|
||||
schema: int
|
||||
channel: str
|
||||
version: str
|
||||
minimum_updater_version: str
|
||||
archive_url: str
|
||||
archive_sha256: str
|
||||
archive_size: int
|
||||
published_at: str
|
||||
notes_url: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LauncherUpdateCheck:
|
||||
current_version: str
|
||||
latest_version: str
|
||||
available: bool
|
||||
supported: bool
|
||||
reason: str
|
||||
manifest_url: str
|
||||
manifest: UpdateManifest
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LauncherUpdateResult:
|
||||
changed: bool
|
||||
current_version: str
|
||||
installed_version: str
|
||||
release_directory: str | None
|
||||
previous_release: str | None
|
||||
archive: str | None
|
||||
archive_sha256: str | None
|
||||
restarted_required: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LauncherRollbackResult:
|
||||
from_version: str
|
||||
to_version: str
|
||||
current_release: str
|
||||
previous_release: str
|
||||
restart_required: bool
|
||||
|
||||
|
||||
def default_public_key_path() -> Path:
|
||||
return Path(__file__).with_name(PUBLIC_KEY_FILENAME)
|
||||
|
||||
|
||||
def configured_manifest_url(
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> str:
|
||||
env = os.environ if environment is None else environment
|
||||
return env.get(UPDATE_MANIFEST_URL_VARIABLE, DEFAULT_UPDATE_MANIFEST_URL)
|
||||
|
||||
|
||||
def configured_public_key_path(
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> Path:
|
||||
env = os.environ if environment is None else environment
|
||||
configured = env.get(UPDATE_PUBLIC_KEY_VARIABLE)
|
||||
return Path(configured).expanduser() if configured else default_public_key_path()
|
||||
|
||||
|
||||
def check_for_launcher_update(
|
||||
*,
|
||||
current_version: str = __version__,
|
||||
manifest_url: str | None = None,
|
||||
public_key_path: Path | None = None,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
timeout_seconds: float = 20.0,
|
||||
) -> LauncherUpdateCheck:
|
||||
"""Fetch and authenticate the stable update manifest."""
|
||||
|
||||
_parse_version(current_version, "current launcher version")
|
||||
url = manifest_url or configured_manifest_url(environment)
|
||||
_require_https_url(url, "update manifest URL")
|
||||
key = public_key_path or configured_public_key_path(environment)
|
||||
manifest_bytes = _fetch_small_file(
|
||||
url,
|
||||
maximum_bytes=MAX_MANIFEST_BYTES,
|
||||
timeout_seconds=timeout_seconds,
|
||||
label="update manifest",
|
||||
)
|
||||
signature_bytes = _fetch_small_file(
|
||||
_signature_url(url),
|
||||
maximum_bytes=MAX_SIGNATURE_BYTES,
|
||||
timeout_seconds=timeout_seconds,
|
||||
label="update manifest signature",
|
||||
)
|
||||
_verify_manifest_signature(manifest_bytes, signature_bytes, key)
|
||||
manifest = parse_update_manifest(manifest_bytes)
|
||||
current = _parse_version(current_version, "current launcher version")
|
||||
latest = _parse_version(manifest.version, "manifest version")
|
||||
minimum = _parse_version(
|
||||
manifest.minimum_updater_version,
|
||||
"minimum updater version",
|
||||
)
|
||||
available = latest > current
|
||||
supported = current >= minimum
|
||||
if not available:
|
||||
reason = (
|
||||
"This launcher is newer than the stable update channel."
|
||||
if latest < current
|
||||
else "The launcher is up to date."
|
||||
)
|
||||
elif not supported:
|
||||
reason = (
|
||||
f"Version {manifest.version} requires updater "
|
||||
f"{manifest.minimum_updater_version} or newer; install it manually."
|
||||
)
|
||||
else:
|
||||
reason = f"Launcher {manifest.version} is available."
|
||||
return LauncherUpdateCheck(
|
||||
current_version=current_version,
|
||||
latest_version=manifest.version,
|
||||
available=available,
|
||||
supported=supported,
|
||||
reason=reason,
|
||||
manifest_url=url,
|
||||
manifest=manifest,
|
||||
)
|
||||
|
||||
|
||||
def install_launcher_update(
|
||||
paths: AppPaths,
|
||||
*,
|
||||
manifest_url: str | None = None,
|
||||
public_key_path: Path | None = None,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
timeout_seconds: float = 60.0,
|
||||
) -> LauncherUpdateResult:
|
||||
"""Authenticate, stage, validate, and atomically activate one update."""
|
||||
|
||||
check = check_for_launcher_update(
|
||||
manifest_url=manifest_url,
|
||||
public_key_path=public_key_path,
|
||||
environment=environment,
|
||||
timeout_seconds=min(timeout_seconds, 30.0),
|
||||
)
|
||||
if not check.available:
|
||||
return LauncherUpdateResult(
|
||||
changed=False,
|
||||
current_version=check.current_version,
|
||||
installed_version=check.current_version,
|
||||
release_directory=None,
|
||||
previous_release=None,
|
||||
archive=None,
|
||||
archive_sha256=None,
|
||||
restarted_required=False,
|
||||
)
|
||||
if not check.supported:
|
||||
raise LauncherUpdateError(check.reason)
|
||||
|
||||
paths.create_base_directories()
|
||||
with launcher_operation_lock(paths.launcher_update_lock, "launcher-update"):
|
||||
archive = paths.launcher_update_archive(check.manifest.version)
|
||||
_download_archive(
|
||||
check.manifest,
|
||||
archive,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
release_directory = _stage_and_install_release(paths, archive, check.manifest)
|
||||
previous = _activate_release(paths, release_directory)
|
||||
_write_json(
|
||||
paths.launcher_update_state,
|
||||
{
|
||||
"schema": 1,
|
||||
"updated_at": datetime.now().astimezone().isoformat(),
|
||||
"from_version": check.current_version,
|
||||
"to_version": check.manifest.version,
|
||||
"manifest_url": check.manifest_url,
|
||||
"archive_url": check.manifest.archive_url,
|
||||
"archive_sha256": check.manifest.archive_sha256,
|
||||
"release_directory": str(release_directory),
|
||||
"previous_release": str(previous) if previous else None,
|
||||
},
|
||||
)
|
||||
return LauncherUpdateResult(
|
||||
changed=True,
|
||||
current_version=check.current_version,
|
||||
installed_version=check.manifest.version,
|
||||
release_directory=str(release_directory),
|
||||
previous_release=str(previous) if previous else None,
|
||||
archive=str(archive),
|
||||
archive_sha256=check.manifest.archive_sha256,
|
||||
restarted_required=True,
|
||||
)
|
||||
|
||||
|
||||
def rollback_launcher_update(paths: AppPaths) -> LauncherRollbackResult:
|
||||
"""Swap the current and previous authenticated launcher releases."""
|
||||
|
||||
with launcher_operation_lock(paths.launcher_update_lock, "launcher-rollback"):
|
||||
current = _release_from_link(paths.launcher_current, paths.launcher_releases)
|
||||
previous = _release_from_link(paths.launcher_previous, paths.launcher_releases)
|
||||
if current is None:
|
||||
raise LauncherUpdateError("the current launcher release link is missing")
|
||||
if previous is None:
|
||||
raise LauncherUpdateError("no previous launcher release is available")
|
||||
current_version = _validate_release_directory(current)
|
||||
previous_version = _validate_release_directory(previous)
|
||||
if current == previous:
|
||||
raise LauncherUpdateError("current and previous launcher releases are identical")
|
||||
_replace_release_link(paths.launcher_previous, current, paths.launcher_releases)
|
||||
_replace_release_link(paths.launcher_current, previous, paths.launcher_releases)
|
||||
_fsync_directory(paths.app_home)
|
||||
_write_json(
|
||||
paths.launcher_update_state,
|
||||
{
|
||||
"schema": 1,
|
||||
"rolled_back_at": datetime.now().astimezone().isoformat(),
|
||||
"from_version": current_version,
|
||||
"to_version": previous_version,
|
||||
"release_directory": str(previous),
|
||||
"previous_release": str(current),
|
||||
},
|
||||
)
|
||||
return LauncherRollbackResult(
|
||||
from_version=current_version,
|
||||
to_version=previous_version,
|
||||
current_release=str(previous),
|
||||
previous_release=str(current),
|
||||
restart_required=True,
|
||||
)
|
||||
|
||||
|
||||
def parse_update_manifest(data: bytes) -> UpdateManifest:
|
||||
"""Strictly parse one already-authenticated manifest."""
|
||||
|
||||
if not data or len(data) > MAX_MANIFEST_BYTES:
|
||||
raise LauncherUpdateError("update manifest has an invalid size")
|
||||
try:
|
||||
value = json.loads(
|
||||
data.decode("utf-8"),
|
||||
object_pairs_hook=_unique_json_object,
|
||||
)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
|
||||
raise LauncherUpdateError(f"update manifest is invalid JSON: {exc}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise LauncherUpdateError("update manifest must contain a JSON object")
|
||||
schema = _required_integer(value, "schema")
|
||||
if schema != 1:
|
||||
raise LauncherUpdateError(f"unsupported update manifest schema: {schema}")
|
||||
channel = _required_string(value, "channel")
|
||||
if channel != "stable":
|
||||
raise LauncherUpdateError(f"unsupported update channel: {channel}")
|
||||
version = _required_string(value, "version")
|
||||
minimum = _required_string(value, "minimum_updater_version")
|
||||
_parse_version(version, "manifest version")
|
||||
_parse_version(minimum, "minimum updater version")
|
||||
archive_url = _required_string(value, "archive_url")
|
||||
_require_https_url(archive_url, "update archive URL")
|
||||
archive_sha256 = _required_string(value, "archive_sha256")
|
||||
if _SHA256_PATTERN.fullmatch(archive_sha256) is None:
|
||||
raise LauncherUpdateError("archive_sha256 must be a lowercase SHA-256 digest")
|
||||
archive_size = _required_integer(value, "archive_size")
|
||||
if archive_size <= 0 or archive_size > MAX_ARCHIVE_BYTES:
|
||||
raise LauncherUpdateError(
|
||||
f"update archive size must be between 1 and {MAX_ARCHIVE_BYTES} bytes"
|
||||
)
|
||||
published_at = _required_string(value, "published_at")
|
||||
notes_value = value.get("notes_url")
|
||||
if notes_value is not None and not isinstance(notes_value, str):
|
||||
raise LauncherUpdateError("notes_url must be a string or null")
|
||||
if notes_value:
|
||||
_require_https_url(notes_value, "release notes URL")
|
||||
return UpdateManifest(
|
||||
schema=schema,
|
||||
channel=channel,
|
||||
version=version,
|
||||
minimum_updater_version=minimum,
|
||||
archive_url=archive_url,
|
||||
archive_sha256=archive_sha256,
|
||||
archive_size=archive_size,
|
||||
published_at=published_at,
|
||||
notes_url=notes_value or None,
|
||||
)
|
||||
|
||||
|
||||
def _fetch_small_file(
|
||||
url: str,
|
||||
*,
|
||||
maximum_bytes: int,
|
||||
timeout_seconds: float,
|
||||
label: str,
|
||||
) -> bytes:
|
||||
request = Request(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": USER_AGENT,
|
||||
"Accept": "application/octet-stream, application/json",
|
||||
"Accept-Encoding": "identity",
|
||||
},
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
response = urlopen(request, timeout=timeout_seconds)
|
||||
except HTTPError as exc:
|
||||
raise LauncherUpdateError(f"{label} request failed: HTTP {exc.code}") from exc
|
||||
except (URLError, OSError) as exc:
|
||||
raise LauncherUpdateError(f"{label} request failed: {exc}") from exc
|
||||
with response:
|
||||
_require_https_url(response.geturl(), f"final {label} URL")
|
||||
length = _content_length(response.headers.get("Content-Length"), label)
|
||||
if length is not None and length > maximum_bytes:
|
||||
raise LauncherUpdateError(f"{label} exceeds its size limit")
|
||||
data = response.read(maximum_bytes + 1)
|
||||
if len(data) > maximum_bytes:
|
||||
raise LauncherUpdateError(f"{label} exceeds its size limit")
|
||||
if length is not None and len(data) != length:
|
||||
raise LauncherUpdateError(f"{label} response was incomplete")
|
||||
return data
|
||||
|
||||
|
||||
def _verify_manifest_signature(
|
||||
manifest: bytes,
|
||||
signature: bytes,
|
||||
public_key_path: Path,
|
||||
) -> None:
|
||||
if not signature or len(signature) > MAX_SIGNATURE_BYTES:
|
||||
raise LauncherUpdateError("update manifest signature has an invalid size")
|
||||
key = public_key_path.expanduser().resolve()
|
||||
if not key.is_file():
|
||||
raise LauncherUpdateError(f"launcher update public key is missing: {key}")
|
||||
openssl = shutil.which("openssl")
|
||||
if not openssl:
|
||||
raise LauncherUpdateError("OpenSSL is required to verify launcher updates")
|
||||
with tempfile.TemporaryDirectory(prefix="ptlinux-update-signature-") as temporary:
|
||||
root = Path(temporary)
|
||||
manifest_path = root / "stable.json"
|
||||
signature_path = root / "stable.json.sig"
|
||||
manifest_path.write_bytes(manifest)
|
||||
signature_path.write_bytes(signature)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
openssl,
|
||||
"dgst",
|
||||
"-sha256",
|
||||
"-verify",
|
||||
str(key),
|
||||
"-signature",
|
||||
str(signature_path),
|
||||
str(manifest_path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise LauncherUpdateError(f"could not verify update signature: {exc}") from exc
|
||||
if result.returncode != 0 or "Verified OK" not in result.stdout:
|
||||
raise LauncherUpdateError("launcher update manifest signature is invalid")
|
||||
|
||||
|
||||
def _download_archive(
|
||||
manifest: UpdateManifest,
|
||||
destination: Path,
|
||||
*,
|
||||
timeout_seconds: float,
|
||||
) -> None:
|
||||
if destination.is_file() and _archive_matches(destination, manifest):
|
||||
return
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.part")
|
||||
temporary.unlink(missing_ok=True)
|
||||
request = Request(
|
||||
manifest.archive_url,
|
||||
headers={
|
||||
"User-Agent": USER_AGENT,
|
||||
"Accept": "application/gzip, application/octet-stream",
|
||||
"Accept-Encoding": "identity",
|
||||
},
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
try:
|
||||
response = urlopen(request, timeout=timeout_seconds)
|
||||
except HTTPError as exc:
|
||||
raise LauncherUpdateError(
|
||||
f"launcher archive request failed: HTTP {exc.code}"
|
||||
) from exc
|
||||
except (URLError, OSError) as exc:
|
||||
raise LauncherUpdateError(f"launcher archive request failed: {exc}") from exc
|
||||
with response:
|
||||
_require_https_url(response.geturl(), "final update archive URL")
|
||||
length = _content_length(
|
||||
response.headers.get("Content-Length"),
|
||||
"launcher archive",
|
||||
)
|
||||
if length is not None and length != manifest.archive_size:
|
||||
raise LauncherUpdateError(
|
||||
"launcher archive Content-Length does not match the signed manifest"
|
||||
)
|
||||
digest = hashlib.sha256()
|
||||
written = 0
|
||||
with temporary.open("xb") as handle:
|
||||
while True:
|
||||
chunk = response.read(READ_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
written += len(chunk)
|
||||
if written > manifest.archive_size or written > MAX_ARCHIVE_BYTES:
|
||||
raise LauncherUpdateError("launcher archive exceeded its signed size")
|
||||
digest.update(chunk)
|
||||
handle.write(chunk)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
if written != manifest.archive_size:
|
||||
raise LauncherUpdateError(
|
||||
f"launcher archive was incomplete: {written}/{manifest.archive_size} bytes"
|
||||
)
|
||||
if digest.hexdigest() != manifest.archive_sha256:
|
||||
raise LauncherUpdateError("launcher archive SHA-256 does not match the manifest")
|
||||
temporary.replace(destination)
|
||||
_fsync_directory(destination.parent)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _stage_and_install_release(
|
||||
paths: AppPaths,
|
||||
archive: Path,
|
||||
manifest: UpdateManifest,
|
||||
) -> Path:
|
||||
if paths.app_home.is_symlink():
|
||||
raise LauncherUpdateError(f"launcher application home cannot be a link: {paths.app_home}")
|
||||
paths.app_home.mkdir(parents=True, exist_ok=True, mode=0o755)
|
||||
paths.app_home.chmod(0o755)
|
||||
if paths.launcher_releases.is_symlink():
|
||||
raise LauncherUpdateError(
|
||||
f"launcher release directory cannot be a link: {paths.launcher_releases}"
|
||||
)
|
||||
paths.launcher_releases.mkdir(parents=True, exist_ok=True, mode=0o755)
|
||||
paths.launcher_releases.chmod(0o755)
|
||||
staging = paths.launcher_releases / f".{manifest.version}.{os.getpid()}.staging"
|
||||
if staging.exists() or staging.is_symlink():
|
||||
raise LauncherUpdateError(f"launcher staging path already exists: {staging}")
|
||||
staging.mkdir(mode=0o700)
|
||||
try:
|
||||
_extract_release_archive(archive, staging, manifest.version)
|
||||
staged_version = _validate_release_directory(staging)
|
||||
if staged_version != manifest.version:
|
||||
raise LauncherUpdateError(
|
||||
f"staged launcher version is {staged_version}, expected {manifest.version}"
|
||||
)
|
||||
_self_check_release(staging, manifest.version)
|
||||
staging.chmod(0o755)
|
||||
destination = paths.launcher_releases / manifest.version
|
||||
if destination.exists() or destination.is_symlink():
|
||||
if not destination.is_dir() or destination.is_symlink():
|
||||
raise LauncherUpdateError(
|
||||
f"launcher release destination is not a directory: {destination}"
|
||||
)
|
||||
if _tree_digest(destination) != _tree_digest(staging):
|
||||
raise LauncherUpdateError(
|
||||
f"launcher release {manifest.version} already exists with different contents"
|
||||
)
|
||||
shutil.rmtree(staging)
|
||||
else:
|
||||
staging.replace(destination)
|
||||
_fsync_directory(paths.launcher_releases)
|
||||
return destination
|
||||
except BaseException:
|
||||
if staging.exists() and staging.is_dir() and not staging.is_symlink():
|
||||
shutil.rmtree(staging)
|
||||
raise
|
||||
|
||||
|
||||
def _extract_release_archive(archive: Path, destination: Path, version: str) -> None:
|
||||
expected_root = f"{UPDATE_ARCHIVE_PREFIX}{version}"
|
||||
seen: set[PurePosixPath] = set()
|
||||
total_bytes = 0
|
||||
try:
|
||||
bundle = tarfile.open(archive, "r:gz")
|
||||
except (OSError, tarfile.TarError) as exc:
|
||||
raise LauncherUpdateError(f"launcher archive cannot be opened: {exc}") from exc
|
||||
with bundle:
|
||||
members = bundle.getmembers()
|
||||
if not members or len(members) > MAX_ARCHIVE_MEMBERS:
|
||||
raise LauncherUpdateError("launcher archive has an invalid member count")
|
||||
for member in members:
|
||||
name = member.name[:-1] if member.isdir() and member.name.endswith("/") else member.name
|
||||
if not name or "\\" in name or "\x00" in name:
|
||||
raise LauncherUpdateError(f"unsafe launcher archive path: {member.name!r}")
|
||||
raw_parts = name.split("/")
|
||||
if any(part in {"", ".", ".."} for part in raw_parts):
|
||||
raise LauncherUpdateError(f"unsafe launcher archive path: {member.name!r}")
|
||||
path = PurePosixPath(name)
|
||||
if path.is_absolute() or not path.parts or path.parts[0] != expected_root:
|
||||
raise LauncherUpdateError(
|
||||
f"launcher archive member is outside {expected_root}: {member.name!r}"
|
||||
)
|
||||
relative = PurePosixPath(*path.parts[1:])
|
||||
if not relative.parts:
|
||||
if not member.isdir():
|
||||
raise LauncherUpdateError("launcher archive root must be a directory")
|
||||
continue
|
||||
if relative.parts[0] not in _ALLOWED_RELEASE_ROOTS:
|
||||
raise LauncherUpdateError(
|
||||
f"unexpected launcher payload path: {relative.as_posix()}"
|
||||
)
|
||||
if relative in seen:
|
||||
raise LauncherUpdateError(
|
||||
f"duplicate launcher archive path: {relative.as_posix()}"
|
||||
)
|
||||
seen.add(relative)
|
||||
if not member.isdir() and not member.isfile():
|
||||
raise LauncherUpdateError(
|
||||
f"unsupported launcher archive member: {member.name!r}"
|
||||
)
|
||||
if member.mode & 0o022:
|
||||
raise LauncherUpdateError(
|
||||
f"launcher archive member is group/world writable: {member.name!r}"
|
||||
)
|
||||
target = destination.joinpath(*relative.parts)
|
||||
if member.isdir():
|
||||
target.mkdir(parents=True, exist_ok=True, mode=0o755)
|
||||
continue
|
||||
if member.size < 0:
|
||||
raise LauncherUpdateError("launcher archive contains a negative file size")
|
||||
total_bytes += member.size
|
||||
if total_bytes > MAX_EXTRACTED_BYTES:
|
||||
raise LauncherUpdateError("launcher archive exceeds its extraction limit")
|
||||
target.parent.mkdir(parents=True, exist_ok=True, mode=0o755)
|
||||
source = bundle.extractfile(member)
|
||||
if source is None:
|
||||
raise LauncherUpdateError(f"cannot read launcher payload: {member.name!r}")
|
||||
written = 0
|
||||
with source, target.open("xb") as handle:
|
||||
while True:
|
||||
chunk = source.read(READ_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
written += len(chunk)
|
||||
if written > member.size:
|
||||
raise LauncherUpdateError(
|
||||
f"launcher payload exceeded its declared size: {member.name!r}"
|
||||
)
|
||||
handle.write(chunk)
|
||||
if written != member.size:
|
||||
raise LauncherUpdateError(
|
||||
f"launcher payload was incomplete: {member.name!r}"
|
||||
)
|
||||
target.chmod(0o644)
|
||||
|
||||
|
||||
def _validate_release_directory(release: Path) -> str:
|
||||
if not release.is_dir() or release.is_symlink():
|
||||
raise LauncherUpdateError(f"launcher release directory is invalid: {release}")
|
||||
for item in (release, *release.rglob("*")):
|
||||
if item.is_symlink():
|
||||
raise LauncherUpdateError(f"launcher release contains a symbolic link: {item}")
|
||||
try:
|
||||
mode = item.stat().st_mode
|
||||
except OSError as exc:
|
||||
raise LauncherUpdateError(f"cannot inspect launcher release path {item}: {exc}") from exc
|
||||
if mode & 0o022:
|
||||
raise LauncherUpdateError(
|
||||
f"launcher release path is group/world writable: {item}"
|
||||
)
|
||||
if not stat.S_ISDIR(mode) and not stat.S_ISREG(mode):
|
||||
raise LauncherUpdateError(f"launcher release contains a special file: {item}")
|
||||
metadata_path = release / "release.json"
|
||||
try:
|
||||
metadata = json.loads(
|
||||
metadata_path.read_text(encoding="utf-8"),
|
||||
object_pairs_hook=_unique_json_object,
|
||||
)
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
|
||||
raise LauncherUpdateError(f"launcher release metadata is invalid: {exc}") from exc
|
||||
if not isinstance(metadata, dict) or metadata.get("schema") != 1:
|
||||
raise LauncherUpdateError("launcher release metadata has an unsupported schema")
|
||||
version = metadata.get("version")
|
||||
if not isinstance(version, str):
|
||||
raise LauncherUpdateError("launcher release metadata has no version")
|
||||
_parse_version(version, "launcher release version")
|
||||
for required in (
|
||||
Path("ptlinux/__init__.py"),
|
||||
Path("ptlinux/__main__.py"),
|
||||
Path("ptlinux/cli.py"),
|
||||
Path("ptlinux/self_update.py"),
|
||||
Path("ptlinux") / PUBLIC_KEY_FILENAME,
|
||||
Path("ptdiag/__init__.py"),
|
||||
Path("ptdiag/assbin.py"),
|
||||
):
|
||||
if not (release / required).is_file():
|
||||
raise LauncherUpdateError(f"launcher release is missing {required}")
|
||||
return version
|
||||
|
||||
|
||||
def _self_check_release(release: Path, expected_version: str) -> None:
|
||||
check = (
|
||||
"import sys; "
|
||||
"sys.path.insert(0, sys.argv[1]); "
|
||||
"import ptlinux, ptlinux.cli, ptlinux.self_update; "
|
||||
"raise SystemExit(0 if ptlinux.__version__ == sys.argv[2] else 1)"
|
||||
)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-I", "-c", check, str(release), expected_version],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise LauncherUpdateError(f"could not test the staged launcher: {exc}") from exc
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout).strip()
|
||||
raise LauncherUpdateError(
|
||||
"staged launcher self-check failed" + (f": {detail}" if detail else "")
|
||||
)
|
||||
|
||||
|
||||
def _activate_release(paths: AppPaths, release: Path) -> Path | None:
|
||||
if paths.app_home.is_symlink():
|
||||
raise LauncherUpdateError(f"launcher application home cannot be a link: {paths.app_home}")
|
||||
paths.app_home.mkdir(parents=True, exist_ok=True, mode=0o755)
|
||||
paths.app_home.chmod(0o755)
|
||||
current = _release_from_link(paths.launcher_current, paths.launcher_releases)
|
||||
if paths.launcher_current.exists() and not paths.launcher_current.is_symlink():
|
||||
raise LauncherUpdateError(
|
||||
f"launcher current path is not a symbolic link: {paths.launcher_current}"
|
||||
)
|
||||
if paths.launcher_previous.exists() and not paths.launcher_previous.is_symlink():
|
||||
raise LauncherUpdateError(
|
||||
f"launcher previous path is not a symbolic link: {paths.launcher_previous}"
|
||||
)
|
||||
if current is not None and current != release:
|
||||
_validate_release_directory(current)
|
||||
_replace_release_link(paths.launcher_previous, current, paths.launcher_releases)
|
||||
_replace_release_link(paths.launcher_current, release, paths.launcher_releases)
|
||||
_fsync_directory(paths.app_home)
|
||||
return current
|
||||
|
||||
|
||||
def _replace_release_link(link: Path, release: Path, releases: Path) -> None:
|
||||
resolved_release = release.resolve()
|
||||
resolved_releases = releases.resolve()
|
||||
if resolved_release.parent != resolved_releases:
|
||||
raise LauncherUpdateError(f"release is outside the launcher release directory: {release}")
|
||||
_validate_release_directory(resolved_release)
|
||||
temporary = link.with_name(f".{link.name}.{os.getpid()}.tmp")
|
||||
temporary.unlink(missing_ok=True)
|
||||
try:
|
||||
temporary.symlink_to(Path("releases") / resolved_release.name)
|
||||
os.replace(temporary, link)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _release_from_link(link: Path, releases: Path) -> Path | None:
|
||||
if not link.is_symlink():
|
||||
if link.exists():
|
||||
raise LauncherUpdateError(f"launcher release link is not symbolic: {link}")
|
||||
return None
|
||||
try:
|
||||
target_text = os.readlink(link)
|
||||
except OSError as exc:
|
||||
raise LauncherUpdateError(f"cannot read launcher release link {link}: {exc}") from exc
|
||||
target = (link.parent / target_text).resolve()
|
||||
root = releases.resolve()
|
||||
if target.parent != root:
|
||||
raise LauncherUpdateError(f"launcher release link escapes its release directory: {link}")
|
||||
if not target.is_dir() or target.is_symlink():
|
||||
raise LauncherUpdateError(f"launcher release link is broken: {link}")
|
||||
return target
|
||||
|
||||
|
||||
def _archive_matches(path: Path, manifest: UpdateManifest) -> bool:
|
||||
try:
|
||||
return (
|
||||
path.stat().st_size == manifest.archive_size
|
||||
and _sha256_file(path) == manifest.archive_sha256
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _tree_digest(root: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
for item in sorted(root.rglob("*"), key=lambda value: value.relative_to(root).as_posix()):
|
||||
relative = item.relative_to(root).as_posix().encode("utf-8")
|
||||
if item.is_symlink():
|
||||
raise LauncherUpdateError(f"launcher release contains a symbolic link: {item}")
|
||||
if item.is_dir():
|
||||
digest.update(b"D\x00" + relative + b"\x00")
|
||||
elif item.is_file():
|
||||
digest.update(b"F\x00" + relative + b"\x00")
|
||||
digest.update(str(item.stat().st_size).encode("ascii") + b"\x00")
|
||||
with item.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(READ_SIZE), b""):
|
||||
digest.update(chunk)
|
||||
digest.update(b"\x00")
|
||||
else:
|
||||
raise LauncherUpdateError(f"launcher release contains a special file: {item}")
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(READ_SIZE), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _signature_url(manifest_url: str) -> str:
|
||||
parts = urlsplit(manifest_url)
|
||||
return urlunsplit((parts.scheme, parts.netloc, parts.path + ".sig", parts.query, ""))
|
||||
|
||||
|
||||
def _require_https_url(value: str, label: str) -> None:
|
||||
parts = urlsplit(value)
|
||||
if (
|
||||
parts.scheme.casefold() != "https"
|
||||
or not parts.netloc
|
||||
or parts.username is not None
|
||||
or parts.password is not None
|
||||
or parts.fragment
|
||||
):
|
||||
raise LauncherUpdateError(f"{label} must be an HTTPS URL without credentials")
|
||||
|
||||
|
||||
def _parse_version(value: str, label: str) -> tuple[int, int, int]:
|
||||
if _VERSION_PATTERN.fullmatch(value) is None:
|
||||
raise LauncherUpdateError(f"{label} must use X.Y.Z format: {value!r}")
|
||||
major, minor, patch = value.split(".")
|
||||
return int(major), int(minor), int(patch)
|
||||
|
||||
|
||||
def _required_string(value: dict[str, object], key: str) -> str:
|
||||
item = value.get(key)
|
||||
if not isinstance(item, str) or not item:
|
||||
raise LauncherUpdateError(f"update manifest field {key!r} must be a string")
|
||||
return item
|
||||
|
||||
|
||||
def _required_integer(value: dict[str, object], key: str) -> int:
|
||||
item = value.get(key)
|
||||
if isinstance(item, bool) or not isinstance(item, int):
|
||||
raise LauncherUpdateError(f"update manifest field {key!r} must be an integer")
|
||||
return item
|
||||
|
||||
|
||||
def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
||||
result: dict[str, object] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError(f"duplicate JSON key: {key}")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _content_length(value: str | None, label: str) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
length = int(value)
|
||||
except ValueError as exc:
|
||||
raise LauncherUpdateError(f"{label} returned an invalid Content-Length") from exc
|
||||
if length < 0:
|
||||
raise LauncherUpdateError(f"{label} returned an invalid Content-Length")
|
||||
return length
|
||||
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
with temporary.open("w", encoding="utf-8") as handle:
|
||||
json.dump(value, handle, indent=2, sort_keys=True)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
temporary.replace(path)
|
||||
_fsync_directory(path.parent)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
try:
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def update_check_to_dict(check: LauncherUpdateCheck) -> dict[str, object]:
|
||||
"""Return an explicitly public JSON representation for CLI/UI callers."""
|
||||
|
||||
return asdict(check)
|
||||
127
ptlinux/settings.py
Normal file
127
ptlinux/settings.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""User-editable launcher settings with conservative defaults."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import AppPaths
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
width: int = 1600
|
||||
height: int = 900
|
||||
output_width: int = 1600
|
||||
output_height: int = 900
|
||||
refresh_rate: int = 60
|
||||
fullscreen: bool = False
|
||||
borderless: bool = True
|
||||
scaler: str = "fit"
|
||||
filter: str = "linear"
|
||||
runtime: str = "auto"
|
||||
proton_path: str | None = None
|
||||
game_source: str | None = None
|
||||
|
||||
@property
|
||||
def source_override(self) -> Path | None:
|
||||
return Path(self.game_source).expanduser().resolve() if self.game_source else None
|
||||
|
||||
|
||||
def load_settings(paths: AppPaths) -> Settings:
|
||||
try:
|
||||
value = json.loads(paths.settings_file.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return Settings()
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"invalid settings file {paths.settings_file}: {exc}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"settings file must contain an object: {paths.settings_file}")
|
||||
settings = Settings(
|
||||
width=_integer(value, "width", 1600),
|
||||
height=_integer(value, "height", 900),
|
||||
output_width=_integer(value, "output_width", 1600),
|
||||
output_height=_integer(value, "output_height", 900),
|
||||
refresh_rate=_integer(value, "refresh_rate", 60),
|
||||
fullscreen=_boolean(value, "fullscreen", False),
|
||||
borderless=_boolean(value, "borderless", True),
|
||||
scaler=_string(value, "scaler", "fit"),
|
||||
filter=_string(value, "filter", "linear"),
|
||||
runtime=_string(value, "runtime", "auto"),
|
||||
proton_path=_optional_string(value, "proton_path"),
|
||||
game_source=_optional_string(value, "game_source"),
|
||||
)
|
||||
_validate(settings)
|
||||
return settings
|
||||
|
||||
|
||||
def save_settings(paths: AppPaths, settings: Settings) -> None:
|
||||
_validate(settings)
|
||||
paths.config.mkdir(parents=True, exist_ok=True)
|
||||
temporary = paths.settings_file.with_name(
|
||||
f".{paths.settings_file.name}.{os.getpid()}.tmp"
|
||||
)
|
||||
temporary.write_text(
|
||||
json.dumps(asdict(settings), indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(paths.settings_file)
|
||||
|
||||
|
||||
def ensure_settings(paths: AppPaths) -> Settings:
|
||||
if paths.settings_file.exists():
|
||||
return load_settings(paths)
|
||||
settings = Settings()
|
||||
save_settings(paths, settings)
|
||||
return settings
|
||||
|
||||
|
||||
def _validate(settings: Settings) -> None:
|
||||
for name, value in (
|
||||
("width", settings.width),
|
||||
("height", settings.height),
|
||||
("output_width", settings.output_width),
|
||||
("output_height", settings.output_height),
|
||||
):
|
||||
if value < 640 or value > 16384:
|
||||
raise ValueError(f"{name} must be between 640 and 16384")
|
||||
if settings.refresh_rate < 24 or settings.refresh_rate > 1000:
|
||||
raise ValueError("refresh_rate must be between 24 and 1000")
|
||||
if settings.scaler not in {"auto", "integer", "fit", "fill", "stretch"}:
|
||||
raise ValueError(f"unsupported Gamescope scaler: {settings.scaler}")
|
||||
if settings.filter not in {"linear", "nearest", "fsr", "nis", "pixel"}:
|
||||
raise ValueError(f"unsupported Gamescope filter: {settings.filter}")
|
||||
if settings.runtime not in {"auto", "proton7", "umu"}:
|
||||
raise ValueError(f"unsupported runtime selection: {settings.runtime}")
|
||||
|
||||
|
||||
def _integer(value: dict[str, object], key: str, default: int) -> int:
|
||||
item = value.get(key, default)
|
||||
if isinstance(item, bool) or not isinstance(item, int):
|
||||
raise ValueError(f"{key} must be an integer")
|
||||
return item
|
||||
|
||||
|
||||
def _boolean(value: dict[str, object], key: str, default: bool) -> bool:
|
||||
item = value.get(key, default)
|
||||
if not isinstance(item, bool):
|
||||
raise ValueError(f"{key} must be a boolean")
|
||||
return item
|
||||
|
||||
|
||||
def _string(value: dict[str, object], key: str, default: str) -> str:
|
||||
item = value.get(key, default)
|
||||
if not isinstance(item, str) or not item:
|
||||
raise ValueError(f"{key} must be a non-empty string")
|
||||
return item
|
||||
|
||||
|
||||
def _optional_string(value: dict[str, object], key: str) -> str | None:
|
||||
item = value.get(key)
|
||||
if item is None:
|
||||
return None
|
||||
if not isinstance(item, str) or not item:
|
||||
raise ValueError(f"{key} must be null or a non-empty string")
|
||||
return item
|
||||
1753
ptlinux/ui.py
Normal file
1753
ptlinux/ui.py
Normal file
File diff suppressed because it is too large
Load Diff
156
ptlinux/v21_texture_reclaim.py
Normal file
156
ptlinux/v21_texture_reclaim.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Build the verified V21 texture-reclaim runtime DLL from a local game DLL."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import struct
|
||||
|
||||
|
||||
SOURCE_SHA256 = "4b4e5c28e2bfdaa8d36c8341fad27becfd03faf63aa8ea874f5d43c5b1961868"
|
||||
PATCHED_SHA256 = "0be11b06ccf9be747e32b195c8d5980062f446d7d3a0a662ebfe0e602a01c538"
|
||||
TEXT_VA = 0x10001000
|
||||
TEXT_FILE_OFFSET = 0x400
|
||||
ENTRY_VA = 0x107C0B40
|
||||
ENTRY_FILE_OFFSET = TEXT_FILE_OFFSET + (ENTRY_VA - TEXT_VA)
|
||||
ENTRY_ORIGINAL = bytes.fromhex("55 8b ec 6a ff")
|
||||
CAVE_VA = 0x11195CE0
|
||||
CAVE_FILE_OFFSET = TEXT_FILE_OFFSET + (CAVE_VA - TEXT_VA)
|
||||
TEXT_RAW_SIZE = 0x1194E00
|
||||
MAX_HOOK_SIZE = TEXT_FILE_OFFSET + TEXT_RAW_SIZE - CAVE_FILE_OFFSET
|
||||
HOOK = bytes.fromhex(
|
||||
"9c60e8490136ff85c0744a89c38d70088dbe505d010039fe743b80be58030000"
|
||||
"00742a8b068b4c242885c974083b81d41f000074188b4c242c85c974083b81d4"
|
||||
"1f000074085089d9e8a32036ff81c650040000ebc1619d5589e56affe904ae62"
|
||||
"ff"
|
||||
)
|
||||
|
||||
|
||||
class TextureReclaimPatchError(RuntimeError):
|
||||
"""Raised when the exact V21 binary profile cannot be patched safely."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TextureReclaimPatchResult:
|
||||
source: str
|
||||
destination: str
|
||||
source_sha256: str
|
||||
destination_sha256: str
|
||||
entry_va: str
|
||||
cave_va: str
|
||||
hook_size: int
|
||||
old_text_virtual_size: int
|
||||
new_text_virtual_size: int
|
||||
commands: tuple[tuple[str, ...], ...]
|
||||
created_at: str
|
||||
|
||||
|
||||
def build_texture_reclaim_patch(
|
||||
source: Path,
|
||||
destination: Path,
|
||||
) -> TextureReclaimPatchResult:
|
||||
"""Create the V21 patch in a separate runtime cache, never in the game tree."""
|
||||
source = source.expanduser().resolve(strict=True)
|
||||
if _sha256(source) != SOURCE_SHA256:
|
||||
raise TextureReclaimPatchError(
|
||||
"source game.dll does not match the verified V21 profile"
|
||||
)
|
||||
|
||||
hook = HOOK
|
||||
commands: list[tuple[str, ...]] = []
|
||||
if not hook or len(hook) > MAX_HOOK_SIZE:
|
||||
raise TextureReclaimPatchError(
|
||||
f"compiled hook size {len(hook)} exceeds the {MAX_HOOK_SIZE}-byte code cave"
|
||||
)
|
||||
|
||||
image = bytearray(source.read_bytes())
|
||||
if bytes(image[ENTRY_FILE_OFFSET : ENTRY_FILE_OFFSET + len(ENTRY_ORIGINAL)]) != ENTRY_ORIGINAL:
|
||||
raise TextureReclaimPatchError(
|
||||
"V21 map-loader entry bytes do not match the verified profile"
|
||||
)
|
||||
if any(image[CAVE_FILE_OFFSET : CAVE_FILE_OFFSET + len(hook)]):
|
||||
raise TextureReclaimPatchError("V21 code cave is not empty")
|
||||
|
||||
text_header = _find_pe_section(image, b".text")
|
||||
old_virtual_size, text_rva, raw_size, raw_offset = struct.unpack_from(
|
||||
"<IIII", image, text_header + 8
|
||||
)
|
||||
if (text_rva, raw_size, raw_offset) != (
|
||||
TEXT_VA - 0x10000000,
|
||||
TEXT_RAW_SIZE,
|
||||
TEXT_FILE_OFFSET,
|
||||
):
|
||||
raise TextureReclaimPatchError(
|
||||
"V21 executable section layout does not match the verified profile"
|
||||
)
|
||||
new_virtual_size = CAVE_VA - TEXT_VA + len(hook)
|
||||
if new_virtual_size > TEXT_RAW_SIZE:
|
||||
raise TextureReclaimPatchError("compiled hook extends beyond the executable section")
|
||||
if old_virtual_size > new_virtual_size:
|
||||
raise TextureReclaimPatchError(
|
||||
"verified executable section is already larger than the patch target"
|
||||
)
|
||||
|
||||
entry_displacement = CAVE_VA - (ENTRY_VA + 5)
|
||||
image[ENTRY_FILE_OFFSET : ENTRY_FILE_OFFSET + 5] = b"\xe9" + struct.pack(
|
||||
"<i", entry_displacement
|
||||
)
|
||||
image[CAVE_FILE_OFFSET : CAVE_FILE_OFFSET + len(hook)] = hook
|
||||
struct.pack_into("<I", image, text_header + 8, new_virtual_size)
|
||||
|
||||
destination = destination.expanduser().absolute()
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = destination.with_name(f".{destination.name}.tmp")
|
||||
try:
|
||||
temporary.write_bytes(image)
|
||||
if _sha256(temporary) != PATCHED_SHA256:
|
||||
raise TextureReclaimPatchError(
|
||||
"built runtime patch does not match the verified V21 profile"
|
||||
)
|
||||
temporary.replace(destination)
|
||||
shutil.copystat(source, destination)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
return TextureReclaimPatchResult(
|
||||
source=str(source),
|
||||
destination=str(destination),
|
||||
source_sha256=SOURCE_SHA256,
|
||||
destination_sha256=PATCHED_SHA256,
|
||||
entry_va=f"0x{ENTRY_VA:08x}",
|
||||
cave_va=f"0x{CAVE_VA:08x}",
|
||||
hook_size=len(hook),
|
||||
old_text_virtual_size=old_virtual_size,
|
||||
new_text_virtual_size=new_virtual_size,
|
||||
commands=tuple(commands),
|
||||
created_at=datetime.now().astimezone().isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def _find_pe_section(image: bytes | bytearray, section_name: bytes) -> int:
|
||||
if len(image) < 0x40:
|
||||
raise TextureReclaimPatchError("source is too small to be a PE image")
|
||||
pe_offset = struct.unpack_from("<I", image, 0x3C)[0]
|
||||
if image[pe_offset : pe_offset + 4] != b"PE\0\0":
|
||||
raise TextureReclaimPatchError("source does not have a valid PE signature")
|
||||
number_of_sections = struct.unpack_from("<H", image, pe_offset + 6)[0]
|
||||
optional_header_size = struct.unpack_from("<H", image, pe_offset + 20)[0]
|
||||
section_table = pe_offset + 24 + optional_header_size
|
||||
for index in range(number_of_sections):
|
||||
header = section_table + index * 40
|
||||
name = bytes(image[header : header + 8]).rstrip(b"\0")
|
||||
if name == section_name:
|
||||
return header
|
||||
raise TextureReclaimPatchError(f"PE section is missing: {section_name!r}")
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
Reference in New Issue
Block a user