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
from pathlib import Path
import sys
import time
from typing import Sequence
from ptdiag.logs import (
LogFileState,
newest_current_native_log,
parse_current_run_log,
snapshot_native_logs,
)
from . import __version__
from .command import CommandError, CommandRunner
from .depot import SyncProgress, sync_managed_game
@@ -26,7 +34,8 @@ from .launcher import (
)
from .locking import launcher_operation_lock
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 .runtime import (
prepare_game_runtime,
@@ -122,20 +131,32 @@ def build_parser() -> argparse.ArgumentParser:
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 = subparsers.add_parser("play", help="launch the game in the managed Proton window")
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("--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.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)
_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":
@@ -240,10 +257,20 @@ def main(argv: Sequence[str] | None = None) -> int:
paths,
arguments.game_dir,
arguments.no_box,
arguments.publisher_dll,
arguments.dry_run,
arguments.skip_runtime_setup,
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":
return _settings(paths, arguments.init, arguments.json)
if command == "launcher-update":
@@ -256,6 +283,13 @@ def main(argv: Sequence[str] | None = None) -> int:
arguments.json,
)
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:
if arguments.json:
_print_json({"ok": False, "error": str(exc)})
@@ -561,6 +595,28 @@ def _play(
paths: AppPaths,
game_dir: Path | None,
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,
skip_runtime_setup: bool,
as_json: bool,
@@ -570,7 +626,11 @@ def _play(
overlay = None
_print_operation_status(as_json, 1, 4, "Preparing the managed game view…")
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…")
selection = select_runtime(settings)
runner = _runner(paths, "play", dry_run)
@@ -579,12 +639,33 @@ def _play(
_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,
native_log_directory = paths.game_save / "logs"
native_log_baseline = snapshot_native_logs(
native_log_directory,
patterns=("log_*.txt",),
)
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(
{
@@ -594,12 +675,90 @@ def _play(
"runtime_view": asdict(overlay) if overlay else None,
"setup_commands": [asdict(result) for result in setup_results],
"game": asdict(outcome),
"current_game_log": current_game_log,
},
as_json,
)
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:
if initialize:
save_settings(paths, Settings())
@@ -665,14 +824,18 @@ def _launcher_update(
def _source_game(
paths: AppPaths,
settings: Settings,
_settings: Settings,
argument: Path | None,
) -> 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:
return argument.expanduser().resolve()
source_override = settings.source_override
if source_override is not None:
return source_override
return paths.source_game

View File

@@ -24,7 +24,7 @@ def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
handle.seek(0)
owner = handle.read().strip() or "unknown operation"
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
handle.seek(0)
handle.truncate()