Security improvements to locking down play button and make sure one process only runs at once.
This commit is contained in:
211
ptlinux/cli.py
211
ptlinux/cli.py
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user