Fixed chmod issues with launcher updater.
This commit is contained in:
@@ -33,8 +33,9 @@ fi
|
|||||||
|
|
||||||
# Never let an unpacked older installer in the caller's current directory
|
# Never let an unpacked older installer in the caller's current directory
|
||||||
# shadow the selected installed release. Children started by the dashboard
|
# shadow the selected installed release. Children started by the dashboard
|
||||||
# inherit the same safe-path policy and exact package root.
|
# inherit the same safe-path policy, package root, and no-bytecode policy.
|
||||||
export PYTHONSAFEPATH=1
|
export PYTHONSAFEPATH=1
|
||||||
|
export PYTHONDONTWRITEBYTECODE=1
|
||||||
export PYTHONPATH="${release_root}"
|
export PYTHONPATH="${release_root}"
|
||||||
|
|
||||||
exec "$python_bin" -B -P -m ptlinux "$@"
|
exec "$python_bin" -B -P -m ptlinux "$@"
|
||||||
|
|||||||
@@ -21,7 +21,12 @@ from ptdiag.logs import (
|
|||||||
|
|
||||||
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,
|
||||||
|
read_cached_depot_launch_catalog,
|
||||||
|
select_depot_start_option,
|
||||||
|
sync_managed_game,
|
||||||
|
)
|
||||||
from .doctor import inspect_host
|
from .doctor import inspect_host
|
||||||
from .download import OFFICIAL_LAUNCHER_URL, fetch_official_launcher
|
from .download import OFFICIAL_LAUNCHER_URL, fetch_official_launcher
|
||||||
from .importer import GameImportResult, discover_existing_game, import_existing_game
|
from .importer import GameImportResult, discover_existing_game, import_existing_game
|
||||||
@@ -148,6 +153,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
|
|
||||||
play = subparsers.add_parser("play", help="launch the game in the managed Proton window")
|
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(
|
||||||
|
"--realm",
|
||||||
|
help="launch an available realm from the last official depot manifest",
|
||||||
|
)
|
||||||
play.add_argument("--no-box", action="store_true", help="launch without X11 window styling")
|
play.add_argument("--no-box", action="store_true", help="launch without X11 window styling")
|
||||||
play.add_argument(
|
play.add_argument(
|
||||||
"--publisher-dll",
|
"--publisher-dll",
|
||||||
@@ -297,6 +306,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
arguments.dry_run,
|
arguments.dry_run,
|
||||||
arguments.skip_runtime_setup,
|
arguments.skip_runtime_setup,
|
||||||
arguments.json,
|
arguments.json,
|
||||||
|
realm=arguments.realm,
|
||||||
)
|
)
|
||||||
if command == "login-trace":
|
if command == "login-trace":
|
||||||
return _login_trace(
|
return _login_trace(
|
||||||
@@ -708,6 +718,8 @@ def _play(
|
|||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
skip_runtime_setup: bool,
|
skip_runtime_setup: bool,
|
||||||
as_json: bool,
|
as_json: bool,
|
||||||
|
*,
|
||||||
|
realm: str | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
with _play_guard(paths, dry_run) as play_lease:
|
with _play_guard(paths, dry_run) as play_lease:
|
||||||
return _play_locked(
|
return _play_locked(
|
||||||
@@ -717,6 +729,7 @@ def _play(
|
|||||||
dry_run,
|
dry_run,
|
||||||
skip_runtime_setup,
|
skip_runtime_setup,
|
||||||
as_json,
|
as_json,
|
||||||
|
realm=realm,
|
||||||
reuse_prepared=not play_lease.preparation_allowed,
|
reuse_prepared=not play_lease.preparation_allowed,
|
||||||
before_game=play_lease.allow_shared_clients,
|
before_game=play_lease.allow_shared_clients,
|
||||||
)
|
)
|
||||||
@@ -730,6 +743,7 @@ def _play_locked(
|
|||||||
skip_runtime_setup: bool,
|
skip_runtime_setup: bool,
|
||||||
as_json: bool,
|
as_json: bool,
|
||||||
*,
|
*,
|
||||||
|
realm: str | None = None,
|
||||||
reuse_prepared: bool = False,
|
reuse_prepared: bool = False,
|
||||||
before_game: Callable[[], None] | None = None,
|
before_game: Callable[[], None] | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
@@ -739,6 +753,10 @@ def _play_locked(
|
|||||||
"run pristontale-eu update before playing"
|
"run pristontale-eu update before playing"
|
||||||
)
|
)
|
||||||
settings = _load_or_default_settings(paths, persist=not dry_run)
|
settings = _load_or_default_settings(paths, persist=not dry_run)
|
||||||
|
start_option = select_depot_start_option(
|
||||||
|
read_cached_depot_launch_catalog(paths.depot_launch_catalog_cache),
|
||||||
|
realm or settings.realm,
|
||||||
|
)
|
||||||
source = _source_game(paths, settings, game_dir)
|
source = _source_game(paths, settings, game_dir)
|
||||||
overlay = None
|
overlay = None
|
||||||
_print_operation_status(
|
_print_operation_status(
|
||||||
@@ -799,6 +817,7 @@ def _play_locked(
|
|||||||
settings,
|
settings,
|
||||||
selection,
|
selection,
|
||||||
runner,
|
runner,
|
||||||
|
start_option=start_option,
|
||||||
use_gamebox=not no_box,
|
use_gamebox=not no_box,
|
||||||
)
|
)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
@@ -826,6 +845,7 @@ def _play_locked(
|
|||||||
"ok": outcome.returncode == 0,
|
"ok": outcome.returncode == 0,
|
||||||
"dry_run": dry_run,
|
"dry_run": dry_run,
|
||||||
"runtime": asdict(selection),
|
"runtime": asdict(selection),
|
||||||
|
"realm": asdict(start_option),
|
||||||
"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),
|
||||||
|
|||||||
196
ptlinux/depot.py
196
ptlinux/depot.py
@@ -73,6 +73,7 @@ ALWAYS_VERIFY_PATHS = frozenset(
|
|||||||
)
|
)
|
||||||
SHA1_PATTERN = re.compile(r"^[0-9a-fA-F]{40}$")
|
SHA1_PATTERN = re.compile(r"^[0-9a-fA-F]{40}$")
|
||||||
CONTENT_RANGE_PATTERN = re.compile(r"^bytes\s+(\d+)-(\d+)/(\d+)$", re.IGNORECASE)
|
CONTENT_RANGE_PATTERN = re.compile(r"^bytes\s+(\d+)-(\d+)/(\d+)$", re.IGNORECASE)
|
||||||
|
DEPOT_LAUNCH_CATALOG_SCHEMA = 1
|
||||||
|
|
||||||
|
|
||||||
class DepotError(RuntimeError):
|
class DepotError(RuntimeError):
|
||||||
@@ -105,6 +106,22 @@ class CreateIfMissing:
|
|||||||
content_base64: str
|
content_base64: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DepotStartOption:
|
||||||
|
"""One publisher-advertised realm launch target."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
path: str
|
||||||
|
arguments: str
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_DEPOT_START_OPTION = DepotStartOption(
|
||||||
|
name="Babel",
|
||||||
|
path="Game.exe",
|
||||||
|
arguments="PTEU_REBORN",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class DepotSnapshot:
|
class DepotSnapshot:
|
||||||
"""The current, verified shape of the public game depot."""
|
"""The current, verified shape of the public game depot."""
|
||||||
@@ -117,6 +134,101 @@ class DepotSnapshot:
|
|||||||
files: tuple[DepotFile, ...]
|
files: tuple[DepotFile, ...]
|
||||||
deleted_files: tuple[str, ...]
|
deleted_files: tuple[str, ...]
|
||||||
create_if_missing: tuple[CreateIfMissing, ...]
|
create_if_missing: tuple[CreateIfMissing, ...]
|
||||||
|
start_options: tuple[DepotStartOption, ...] = (DEFAULT_DEPOT_START_OPTION,)
|
||||||
|
default_start_option: str = DEFAULT_DEPOT_START_OPTION.name
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DepotLaunchCatalog:
|
||||||
|
"""Small cached subset of a depot manifest needed for offline Play."""
|
||||||
|
|
||||||
|
build: int
|
||||||
|
start_options: tuple[DepotStartOption, ...]
|
||||||
|
default_start_option: str
|
||||||
|
|
||||||
|
|
||||||
|
def launch_catalog_from_snapshot(snapshot: DepotSnapshot) -> DepotLaunchCatalog:
|
||||||
|
return DepotLaunchCatalog(
|
||||||
|
build=snapshot.build,
|
||||||
|
start_options=snapshot.start_options,
|
||||||
|
default_start_option=snapshot.default_start_option,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def select_depot_start_option(
|
||||||
|
catalog: DepotLaunchCatalog | None,
|
||||||
|
preferred_realm: str | None = None,
|
||||||
|
) -> DepotStartOption:
|
||||||
|
"""Choose a published realm, falling back to Babel when possible."""
|
||||||
|
|
||||||
|
current = catalog or DepotLaunchCatalog(
|
||||||
|
build=0,
|
||||||
|
start_options=(DEFAULT_DEPOT_START_OPTION,),
|
||||||
|
default_start_option=DEFAULT_DEPOT_START_OPTION.name,
|
||||||
|
)
|
||||||
|
if preferred_realm:
|
||||||
|
preferred = preferred_realm.casefold()
|
||||||
|
for option in current.start_options:
|
||||||
|
if option.name.casefold() == preferred:
|
||||||
|
return option
|
||||||
|
for option in current.start_options:
|
||||||
|
if option.name.casefold() == DEFAULT_DEPOT_START_OPTION.name.casefold():
|
||||||
|
return option
|
||||||
|
for option in current.start_options:
|
||||||
|
if option.name.casefold() == current.default_start_option.casefold():
|
||||||
|
return option
|
||||||
|
return current.start_options[0]
|
||||||
|
|
||||||
|
|
||||||
|
def cache_depot_launch_catalog(path: Path, snapshot: DepotSnapshot) -> None:
|
||||||
|
"""Cache only publisher-advertised launch metadata for network-free Play."""
|
||||||
|
|
||||||
|
catalog = launch_catalog_from_snapshot(snapshot)
|
||||||
|
_write_json(
|
||||||
|
path,
|
||||||
|
{
|
||||||
|
"schema": DEPOT_LAUNCH_CATALOG_SCHEMA,
|
||||||
|
"build": catalog.build,
|
||||||
|
"default_start_option": catalog.default_start_option,
|
||||||
|
"start_options": [asdict(option) for option in catalog.start_options],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def read_cached_depot_launch_catalog(path: Path) -> DepotLaunchCatalog | None:
|
||||||
|
"""Read the last valid launch catalog without performing network I/O."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return None
|
||||||
|
if value.get("schema") != DEPOT_LAUNCH_CATALOG_SCHEMA:
|
||||||
|
return None
|
||||||
|
build = _positive_int(value.get("build"), "cached depot build")
|
||||||
|
raw_options = value.get("start_options")
|
||||||
|
if not isinstance(raw_options, list):
|
||||||
|
return None
|
||||||
|
manifest_options = [
|
||||||
|
{
|
||||||
|
"Name": item.get("name"),
|
||||||
|
"Path": item.get("path"),
|
||||||
|
"Arguments": item.get("arguments"),
|
||||||
|
}
|
||||||
|
if isinstance(item, dict)
|
||||||
|
else item
|
||||||
|
for item in raw_options
|
||||||
|
]
|
||||||
|
options, default_name = _parse_start_options(
|
||||||
|
manifest_options,
|
||||||
|
value.get("default_start_option"),
|
||||||
|
)
|
||||||
|
except (OSError, UnicodeError, json.JSONDecodeError, DepotError):
|
||||||
|
return None
|
||||||
|
return DepotLaunchCatalog(
|
||||||
|
build=build,
|
||||||
|
start_options=options,
|
||||||
|
default_start_option=default_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -579,6 +691,7 @@ def sync_managed_game(
|
|||||||
quarantine=quarantine,
|
quarantine=quarantine,
|
||||||
)
|
)
|
||||||
_write_json(paths.native_depot_state, asdict(result))
|
_write_json(paths.native_depot_state, asdict(result))
|
||||||
|
cache_depot_launch_catalog(paths.depot_launch_catalog_cache, snapshot)
|
||||||
_emit_progress(
|
_emit_progress(
|
||||||
progress,
|
progress,
|
||||||
"complete",
|
"complete",
|
||||||
@@ -913,6 +1026,19 @@ def _parse_snapshot(
|
|||||||
|
|
||||||
deleted = _parse_deleted_paths(build_manifest.get("df"), seen_paths)
|
deleted = _parse_deleted_paths(build_manifest.get("df"), seen_paths)
|
||||||
create_if_missing = _parse_create_if_missing(depot_manifest.get("CreateIfMissing"))
|
create_if_missing = _parse_create_if_missing(depot_manifest.get("CreateIfMissing"))
|
||||||
|
start_options, default_start_option = _parse_start_options(
|
||||||
|
depot_manifest.get("StartOptions"),
|
||||||
|
depot_manifest.get("DefaultStartOption"),
|
||||||
|
)
|
||||||
|
canonical_file_paths = {file.path.casefold(): file.path for file in files}
|
||||||
|
start_options = tuple(
|
||||||
|
DepotStartOption(
|
||||||
|
name=option.name,
|
||||||
|
path=canonical_file_paths.get(option.path.casefold(), option.path),
|
||||||
|
arguments=option.arguments,
|
||||||
|
)
|
||||||
|
for option in start_options
|
||||||
|
)
|
||||||
return DepotSnapshot(
|
return DepotSnapshot(
|
||||||
region=region,
|
region=region,
|
||||||
server=server,
|
server=server,
|
||||||
@@ -922,6 +1048,8 @@ def _parse_snapshot(
|
|||||||
files=tuple(files),
|
files=tuple(files),
|
||||||
deleted_files=deleted,
|
deleted_files=deleted,
|
||||||
create_if_missing=create_if_missing,
|
create_if_missing=create_if_missing,
|
||||||
|
start_options=start_options,
|
||||||
|
default_start_option=default_start_option,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -966,6 +1094,74 @@ def _parse_create_if_missing(value: object) -> tuple[CreateIfMissing, ...]:
|
|||||||
return tuple(entries)
|
return tuple(entries)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_start_options(
|
||||||
|
value: object,
|
||||||
|
default_value: object,
|
||||||
|
) -> tuple[tuple[DepotStartOption, ...], str]:
|
||||||
|
"""Validate the launch targets published by the official depot manifest."""
|
||||||
|
|
||||||
|
# Older test fixtures and historical manifests did not expose StartOptions.
|
||||||
|
# Babel remains the launcher-safe compatibility default for that case.
|
||||||
|
if value is None:
|
||||||
|
return (DEFAULT_DEPOT_START_OPTION,), DEFAULT_DEPOT_START_OPTION.name
|
||||||
|
if not isinstance(value, list) or not value:
|
||||||
|
raise DepotError("StartOptions must be a non-empty array")
|
||||||
|
options: list[DepotStartOption] = []
|
||||||
|
seen_names: set[str] = set()
|
||||||
|
for item in value:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise DepotError("StartOptions contains a non-object entry")
|
||||||
|
name = _start_option_text(item.get("Name"), "StartOptions Name", 64)
|
||||||
|
folded_name = name.casefold()
|
||||||
|
if folded_name in seen_names:
|
||||||
|
raise DepotError(f"StartOptions has duplicate realm name: {name}")
|
||||||
|
seen_names.add(folded_name)
|
||||||
|
path = _normalise_relative_path(item.get("Path"), f"StartOptions path for {name}")
|
||||||
|
if PurePosixPath(path).suffix.casefold() != ".exe":
|
||||||
|
raise DepotError(f"StartOptions path is not an executable for {name}: {path}")
|
||||||
|
arguments = _start_option_text(
|
||||||
|
item.get("Arguments"),
|
||||||
|
f"StartOptions arguments for {name}",
|
||||||
|
2048,
|
||||||
|
)
|
||||||
|
options.append(DepotStartOption(name=name, path=path, arguments=arguments))
|
||||||
|
|
||||||
|
if default_value is None:
|
||||||
|
default_name = next(
|
||||||
|
(
|
||||||
|
option.name
|
||||||
|
for option in options
|
||||||
|
if option.name.casefold() == DEFAULT_DEPOT_START_OPTION.name.casefold()
|
||||||
|
),
|
||||||
|
options[0].name,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
default_name = _start_option_text(
|
||||||
|
default_value,
|
||||||
|
"DefaultStartOption",
|
||||||
|
64,
|
||||||
|
)
|
||||||
|
matching_default = next(
|
||||||
|
(option.name for option in options if option.name.casefold() == default_name.casefold()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if matching_default is None:
|
||||||
|
raise DepotError(
|
||||||
|
f"DefaultStartOption does not match an advertised realm: {default_name}"
|
||||||
|
)
|
||||||
|
default_name = matching_default
|
||||||
|
return tuple(options), default_name
|
||||||
|
|
||||||
|
|
||||||
|
def _start_option_text(value: object, field: str, maximum_length: int) -> str:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise DepotError(f"{field} must be a string")
|
||||||
|
text = value.strip()
|
||||||
|
if not text or len(text) > maximum_length or any(ord(character) < 32 for character in text):
|
||||||
|
raise DepotError(f"invalid {field}")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def _decode_create_if_missing(entry: CreateIfMissing) -> bytes:
|
def _decode_create_if_missing(entry: CreateIfMissing) -> bytes:
|
||||||
try:
|
try:
|
||||||
content = b64decode(entry.content_base64, validate=True)
|
content = b64decode(entry.content_base64, validate=True)
|
||||||
|
|||||||
@@ -156,6 +156,12 @@ class AppPaths:
|
|||||||
|
|
||||||
return self.state / "native-depot.json"
|
return self.state / "native-depot.json"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def depot_launch_catalog_cache(self) -> Path:
|
||||||
|
"""Last official realm choices used for network-free game launches."""
|
||||||
|
|
||||||
|
return self.cache / "depot-launch-catalog.json"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def news_cache(self) -> Path:
|
def news_cache(self) -> Path:
|
||||||
return self.cache / "news.json"
|
return self.cache / "news.json"
|
||||||
|
|||||||
Reference in New Issue
Block a user