Security improvements to locking down play button and make sure one process only runs at once.

This commit is contained in:
2026-08-28 20:16:34 +02:00
parent 30a08c787c
commit 614d5d96c9
2 changed files with 188 additions and 25 deletions

View File

@@ -9,8 +9,16 @@ from datetime import datetime
import json import json
from pathlib import Path from pathlib import Path
import sys import sys
import time
from typing import Sequence from typing import Sequence
from ptdiag.logs import (
LogFileState,
newest_current_native_log,
parse_current_run_log,
snapshot_native_logs,
)
from . import __version__ from . import __version__
from .command import CommandError, CommandRunner from .command import CommandError, CommandRunner
from .depot import SyncProgress, sync_managed_game from .depot import SyncProgress, sync_managed_game
@@ -26,7 +34,8 @@ from .launcher import (
) )
from .locking import launcher_operation_lock from .locking import launcher_operation_lock
from .login_diagnostics import inspect_login_environment from .login_diagnostics import inspect_login_environment
from .overlay import cache_compatibility_binaries, cache_runtime_patch, prepare_runtime_view from .login_trace import DEFAULT_TRACE_TIMEOUT_SECONDS, run_login_trace
from .overlay import cache_compatibility_binaries, prepare_runtime_view
from .paths import AppPaths from .paths import AppPaths
from .runtime import ( from .runtime import (
prepare_game_runtime, prepare_game_runtime,
@@ -122,20 +131,32 @@ def build_parser() -> argparse.ArgumentParser:
help="cache one exact verified Game.exe/game.dll compatibility pair", help="cache one exact verified Game.exe/game.dll compatibility pair",
) )
compatibility.add_argument("source", type=Path) 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 = subparsers.add_parser("prepare", help="build the read-only runtime view and scene fix")
prepare.add_argument("--game-dir", type=Path) prepare.add_argument("--game-dir", type=Path)
play = subparsers.add_parser("play", help="launch the game in the clean Gamescope box") play = subparsers.add_parser("play", help="launch the game in the managed Proton window")
play.add_argument("--game-dir", type=Path) play.add_argument("--game-dir", type=Path)
play.add_argument("--no-box", action="store_true", help="launch without Gamescope") play.add_argument("--no-box", action="store_true", help="launch without X11 window styling")
play.add_argument(
"--publisher-dll",
action="store_true",
help="diagnostic fallback: disable the post-authentication world fix",
)
play.add_argument("--dry-run", action="store_true") play.add_argument("--dry-run", action="store_true")
play.add_argument("--skip-runtime-setup", action="store_true") play.add_argument("--skip-runtime-setup", action="store_true")
login_trace = subparsers.add_parser(
"login-trace",
help="trace the post-authentication account check without retaining credentials",
)
login_trace.add_argument("--game-dir", type=Path)
login_trace.add_argument("--no-box", action="store_true", help="launch without X11 window styling")
login_trace.add_argument("--skip-runtime-setup", action="store_true")
login_trace.add_argument(
"--timeout",
type=float,
default=DEFAULT_TRACE_TIMEOUT_SECONDS,
help="maximum game runtime in seconds (default: 600)",
)
settings = subparsers.add_parser("settings", help="show or initialize settings") settings = subparsers.add_parser("settings", help="show or initialize settings")
settings.add_argument("--init", action="store_true", help="write conservative defaults") settings.add_argument("--init", action="store_true", help="write conservative defaults")
@@ -229,10 +250,6 @@ def main(argv: Sequence[str] | None = None) -> int:
result = cache_compatibility_binaries(paths, arguments.source) result = cache_compatibility_binaries(paths, arguments.source)
_emit({"ok": True, "compatibility": asdict(result)}, arguments.json) _emit({"ok": True, "compatibility": asdict(result)}, arguments.json)
return 0 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": if command == "prepare":
return _prepare(paths, arguments.game_dir, arguments.json) return _prepare(paths, arguments.game_dir, arguments.json)
if command == "play": if command == "play":
@@ -240,10 +257,20 @@ def main(argv: Sequence[str] | None = None) -> int:
paths, paths,
arguments.game_dir, arguments.game_dir,
arguments.no_box, arguments.no_box,
arguments.publisher_dll,
arguments.dry_run, arguments.dry_run,
arguments.skip_runtime_setup, arguments.skip_runtime_setup,
arguments.json, arguments.json,
) )
if command == "login-trace":
return _login_trace(
paths,
arguments.game_dir,
arguments.no_box,
arguments.skip_runtime_setup,
arguments.timeout,
arguments.json,
)
if command == "settings": if command == "settings":
return _settings(paths, arguments.init, arguments.json) return _settings(paths, arguments.init, arguments.json)
if command == "launcher-update": if command == "launcher-update":
@@ -256,6 +283,13 @@ def main(argv: Sequence[str] | None = None) -> int:
arguments.json, arguments.json,
) )
parser.error(f"unknown command: {command}") parser.error(f"unknown command: {command}")
except KeyboardInterrupt:
message = "Interrupted; PristonTale and its compatibility runtime were stopped."
if arguments.json:
_print_json({"ok": False, "error": message, "interrupted": True})
else:
print(f"\n{message}", file=sys.stderr)
return 130
except (ValueError, OSError, RuntimeError) as exc: except (ValueError, OSError, RuntimeError) as exc:
if arguments.json: if arguments.json:
_print_json({"ok": False, "error": str(exc)}) _print_json({"ok": False, "error": str(exc)})
@@ -561,6 +595,28 @@ def _play(
paths: AppPaths, paths: AppPaths,
game_dir: Path | None, game_dir: Path | None,
no_box: bool, no_box: bool,
publisher_dll: bool,
dry_run: bool,
skip_runtime_setup: bool,
as_json: bool,
) -> int:
with _operation_guard(paths, "play", dry_run):
return _play_locked(
paths,
game_dir,
no_box,
publisher_dll,
dry_run,
skip_runtime_setup,
as_json,
)
def _play_locked(
paths: AppPaths,
game_dir: Path | None,
no_box: bool,
publisher_dll: bool,
dry_run: bool, dry_run: bool,
skip_runtime_setup: bool, skip_runtime_setup: bool,
as_json: bool, as_json: bool,
@@ -570,7 +626,11 @@ def _play(
overlay = None overlay = None
_print_operation_status(as_json, 1, 4, "Preparing the managed game view…") _print_operation_status(as_json, 1, 4, "Preparing the managed game view…")
if not dry_run: if not dry_run:
overlay = prepare_runtime_view(paths, source) overlay = prepare_runtime_view(
paths,
source,
use_world_fix=not publisher_dll,
)
_print_operation_status(as_json, 2, 4, "Selecting the configured game runtime…") _print_operation_status(as_json, 2, 4, "Selecting the configured game runtime…")
selection = select_runtime(settings) selection = select_runtime(settings)
runner = _runner(paths, "play", dry_run) runner = _runner(paths, "play", dry_run)
@@ -579,12 +639,33 @@ def _play(
_print_operation_status(as_json, 3, 4, "Preparing Proton and DirectX components…") _print_operation_status(as_json, 3, 4, "Preparing Proton and DirectX components…")
setup_results = prepare_game_runtime(paths, selection, runner) setup_results = prepare_game_runtime(paths, selection, runner)
_print_operation_status(as_json, 4, 4, "Starting the PristonTale game client…") _print_operation_status(as_json, 4, 4, "Starting the PristonTale game client…")
outcome = run_game( native_log_directory = paths.game_save / "logs"
paths, native_log_baseline = snapshot_native_logs(
settings, native_log_directory,
selection, patterns=("log_*.txt",),
runner, )
use_gamebox=not no_box, game_started_ns = time.time_ns()
try:
outcome = run_game(
paths,
settings,
selection,
runner,
use_gamebox=not no_box,
)
except KeyboardInterrupt:
current_game_log = _current_game_log(
native_log_directory,
native_log_baseline,
game_started_ns,
)
if current_game_log is not None and not as_json:
_print_interrupted_game_log(current_game_log)
raise
current_game_log = _current_game_log(
native_log_directory,
native_log_baseline,
game_started_ns,
) )
_emit( _emit(
{ {
@@ -594,12 +675,90 @@ def _play(
"runtime_view": asdict(overlay) if overlay else None, "runtime_view": asdict(overlay) if overlay else None,
"setup_commands": [asdict(result) for result in setup_results], "setup_commands": [asdict(result) for result in setup_results],
"game": asdict(outcome), "game": asdict(outcome),
"current_game_log": current_game_log,
}, },
as_json, as_json,
) )
return 0 if outcome.returncode == 0 else 1 return 0 if outcome.returncode == 0 else 1
def _current_game_log(
directory: Path,
baseline: dict[str, LogFileState],
started_ns: int,
) -> dict[str, object] | None:
state = newest_current_native_log(
directory,
baseline,
started_ns,
patterns=("log_*.txt",),
)
if state is None:
return None
analysis = parse_current_run_log(state, baseline)
return {
"path": str(state.path),
"modified_at": datetime.fromtimestamp(
state.mtime_ns / 1_000_000_000
).astimezone().isoformat(),
"size": state.size,
"load_texture_to_material_failed": analysis.load_texture_to_material_failed,
"import_scene_threw": analysis.import_scene_threw,
"failed_import_scenes": analysis.failed_import_scenes,
"stage_without_new_stage": analysis.stage_without_new_stage,
"failed_file_count": len(analysis.failed_files),
}
def _print_interrupted_game_log(report: dict[str, object]) -> None:
print(f"\nCurrent game log: {report['path']}", file=sys.stderr)
if report["load_texture_to_material_failed"]:
print(
"Detected a texture/material failure while loading the game world.",
file=sys.stderr,
)
elif report["import_scene_threw"] or report["failed_import_scenes"]:
print("Detected a game-world scene import failure.", file=sys.stderr)
def _login_trace(
paths: AppPaths,
game_dir: Path | None,
no_box: bool,
skip_runtime_setup: bool,
timeout_seconds: float,
as_json: bool,
) -> int:
settings = _load_or_default_settings(paths, persist=True)
source = _source_game(paths, settings, game_dir)
runner = _runner(paths, "login-trace", False)
with _operation_guard(paths, "login-trace", False):
_print_operation_status(as_json, 1, 4, "Preparing the managed game view…")
prepare_runtime_view(paths, source)
_print_operation_status(as_json, 2, 4, "Selecting the configured Proton runtime…")
selection = select_runtime(settings)
if not skip_runtime_setup:
_print_operation_status(as_json, 3, 4, "Checking Proton and DirectX components…")
prepare_game_runtime(paths, selection, runner)
_print_operation_status(as_json, 4, 4, "Starting the privacy-safe login trace…")
if not as_json:
print(
"Enter the account name with its registered capitalization, press Login, "
"leave the approval screen visible for five seconds, then close the game.",
flush=True,
)
report = run_login_trace(
paths,
settings,
selection,
runner,
use_gamebox=not no_box,
timeout_seconds=timeout_seconds,
)
_emit({"ok": True, "login_trace": report.to_dict()}, as_json)
return 0
def _settings(paths: AppPaths, initialize: bool, as_json: bool) -> int: def _settings(paths: AppPaths, initialize: bool, as_json: bool) -> int:
if initialize: if initialize:
save_settings(paths, Settings()) save_settings(paths, Settings())
@@ -665,14 +824,18 @@ def _launcher_update(
def _source_game( def _source_game(
paths: AppPaths, paths: AppPaths,
settings: Settings, _settings: Settings,
argument: Path | None, argument: Path | None,
) -> Path: ) -> Path:
"""Select the managed game unless an explicit diagnostic path was supplied.
The persisted ``game_source`` setting is an import seed only. It must not
redirect normal Play, Prepare, or Login Trace operations away from the
launcher-managed tree.
"""
if argument is not None: if argument is not None:
return argument.expanduser().resolve() return argument.expanduser().resolve()
source_override = settings.source_override
if source_override is not None:
return source_override
return paths.source_game return paths.source_game

View File

@@ -24,7 +24,7 @@ def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
handle.seek(0) handle.seek(0)
owner = handle.read().strip() or "unknown operation" owner = handle.read().strip() or "unknown operation"
raise OperationBusyError( raise OperationBusyError(
f"launcher files are busy ({owner}); close the updater and try again" f"launcher files are busy ({owner}); wait for it to finish and try again"
) from exc ) from exc
handle.seek(0) handle.seek(0)
handle.truncate() handle.truncate()