mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-17 20:00:46 +02:00
179 lines
6.1 KiB
Python
Executable File
179 lines
6.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# Copyright 2026 Mocktail Project Authors
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
"""Report when the pinned Roblox payload metadata has fallen behind.
|
|
|
|
Two kinds of drift make a first launch fail, and neither one shows up until a
|
|
user hits it:
|
|
|
|
* the compatibility catalog no longer lists the Roblox version the provider
|
|
publishes, so every new install lands on a version Roblox has since retired;
|
|
* the preferred supported profile has no pinned bootstrap source, so the
|
|
pinned bootstrap path cannot produce the version the updater falls back to.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
|
|
MAX_MANIFEST_BYTES = 4 * 1024 * 1024
|
|
PACKAGE = "com.roblox.client"
|
|
ARCHITECTURE = "x86_64"
|
|
|
|
|
|
class DriftError(RuntimeError):
|
|
"""Raised when the pinned metadata cannot be inspected at all."""
|
|
|
|
|
|
def _load(path: Path) -> dict:
|
|
try:
|
|
encoded = path.read_bytes()
|
|
except OSError as error:
|
|
raise DriftError(f"cannot read {path}") from error
|
|
if len(encoded) > MAX_MANIFEST_BYTES:
|
|
raise DriftError(f"{path} exceeds its size limit")
|
|
try:
|
|
document = json.loads(encoded.decode("utf-8"))
|
|
except (UnicodeError, json.JSONDecodeError) as error:
|
|
raise DriftError(f"cannot parse {path}") from error
|
|
if not isinstance(document, dict) or document.get("schema_version") != 1:
|
|
raise DriftError(f"{path} has an unsupported schema")
|
|
return document
|
|
|
|
|
|
def supported_profiles(catalog: dict) -> list[dict]:
|
|
profiles = catalog.get("profiles")
|
|
if not isinstance(profiles, list):
|
|
raise DriftError("compatibility catalog has no profiles")
|
|
return [
|
|
profile
|
|
for profile in profiles
|
|
if isinstance(profile, dict)
|
|
and profile.get("status") == "supported"
|
|
and profile.get("default_allowed") is True
|
|
and profile.get("abi", ARCHITECTURE) == ARCHITECTURE
|
|
and isinstance(profile.get("version_name"), str)
|
|
and isinstance(profile.get("version_code"), int)
|
|
]
|
|
|
|
|
|
def preferred_profile(catalog: dict) -> dict:
|
|
"""The profile the updater falls back to, matching the C++ coordinator."""
|
|
profiles = supported_profiles(catalog)
|
|
if not profiles:
|
|
raise DriftError("compatibility catalog has no supported profile")
|
|
return max(profiles, key=lambda profile: profile["version_code"])
|
|
|
|
|
|
def pinned_versions(sources: dict) -> set[str]:
|
|
entries = sources.get("sources")
|
|
if not isinstance(entries, list):
|
|
raise DriftError("bootstrap source manifest has no sources")
|
|
return {
|
|
entry["version_name"]
|
|
for entry in entries
|
|
if isinstance(entry, dict)
|
|
and entry.get("package") == PACKAGE
|
|
and entry.get("abi") == ARCHITECTURE
|
|
and isinstance(entry.get("version_name"), str)
|
|
}
|
|
|
|
|
|
def latest_published_version(provider: Path) -> tuple[str, int]:
|
|
completed = subprocess.run(
|
|
[sys.executable, str(provider), "--check", "--package", PACKAGE,
|
|
"--arch", ARCHITECTURE],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=180,
|
|
check=False,
|
|
)
|
|
if completed.returncode != 0:
|
|
raise DriftError(
|
|
"provider metadata is unavailable: "
|
|
+ (completed.stderr.strip() or "no diagnostic")
|
|
)
|
|
try:
|
|
reported = json.loads(completed.stdout)
|
|
return str(reported["latest_version_name"]), int(
|
|
reported["latest_version_code"]
|
|
)
|
|
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
|
|
raise DriftError("provider metadata is not understood") from error
|
|
|
|
|
|
def findings(catalog: dict, sources: dict, latest: tuple[str, int] | None
|
|
) -> list[str]:
|
|
reported: list[str] = []
|
|
preferred = preferred_profile(catalog)
|
|
pinned = pinned_versions(sources)
|
|
if preferred["version_name"] not in pinned:
|
|
reported.append(
|
|
f"The preferred supported profile {preferred['version_name']} "
|
|
f"({preferred['version_code']}) has no pinned bootstrap source. "
|
|
"The pinned bootstrap path cannot produce the version the updater "
|
|
"falls back to."
|
|
)
|
|
if latest is not None:
|
|
latest_name, latest_code = latest
|
|
if latest_code > preferred["version_code"]:
|
|
reported.append(
|
|
f"Roblox {latest_name} ({latest_code}) is published, but the "
|
|
f"newest supported profile is {preferred['version_name']} "
|
|
f"({preferred['version_code']}). New installs land on a "
|
|
"version Roblox may already have retired."
|
|
)
|
|
return reported
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
root = Path(__file__).resolve().parents[1]
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--catalog", type=Path,
|
|
default=root / "config/roblox_compatibility.json")
|
|
parser.add_argument(
|
|
"--sources", type=Path,
|
|
default=root / "config/roblox_bootstrap_sources.json")
|
|
parser.add_argument(
|
|
"--provider", type=Path,
|
|
default=root / "scripts/apk_providers/direct_apkpure.py")
|
|
parser.add_argument(
|
|
"--check-latest", action="store_true",
|
|
help="also ask the provider which version is published")
|
|
arguments = parser.parse_args(argv)
|
|
|
|
try:
|
|
catalog = _load(arguments.catalog)
|
|
sources = _load(arguments.sources)
|
|
latest = (
|
|
latest_published_version(arguments.provider)
|
|
if arguments.check_latest
|
|
else None
|
|
)
|
|
reported = findings(catalog, sources, latest)
|
|
except DriftError as error:
|
|
print(f"payload source drift: {error}", file=sys.stderr)
|
|
return 2
|
|
|
|
if not reported:
|
|
print("Pinned Roblox payload metadata is current.")
|
|
return 0
|
|
for finding in reported:
|
|
print(f"- {finding}")
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|