Security improvements to locking down play button and make sure one process only runs at once.
This commit is contained in:
199
ptlinux/cli.py
199
ptlinux/cli.py
@@ -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,6 +639,13 @@ 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…")
|
||||
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,
|
||||
@@ -586,6 +653,20 @@ def _play(
|
||||
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(
|
||||
{
|
||||
"ok": outcome.returncode == 0,
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user